diff --git a/cypress/e2e/dashboard.cy.ts b/cypress/e2e/dashboard.cy.ts
index 3af5333100eef..7919c3dded56a 100644
--- a/cypress/e2e/dashboard.cy.ts
+++ b/cypress/e2e/dashboard.cy.ts
@@ -95,7 +95,7 @@ describe('Dashboard', () => {
// refresh the dashboard by changing date range
cy.get('[data-attr="date-filter"]').click()
cy.contains('span', 'Last 14 days').click()
- cy.contains('span', 'Apply and save dashboard').click()
+ cy.contains('span', 'Save').click()
cy.contains('span[class="text-primary text-sm font-medium"]', 'Refreshing').should('not.exist')
cy.get('span').contains('Refreshing').should('not.exist')
@@ -163,7 +163,7 @@ describe('Dashboard', () => {
cy.get('[data-attr=date-filter]').contains('No date range override').click()
cy.get('div').contains('Yesterday').should('exist').click()
cy.get('[data-attr=date-filter]').contains('Yesterday')
- cy.get('button').contains('Apply and save dashboard').click()
+ cy.get('button').contains('Save').click()
cy.get('.InsightCard h5').should('have.length', 1).contains('Yesterday')
// Cool, now back to A and make sure the insight is still using the original range there, not the one from B
cy.clickNavMenu('dashboards')
diff --git a/cypress/productAnalytics/index.ts b/cypress/productAnalytics/index.ts
index cedfa970c825f..af1a0c3f32e85 100644
--- a/cypress/productAnalytics/index.ts
+++ b/cypress/productAnalytics/index.ts
@@ -176,7 +176,7 @@ export const dashboard = {
cy.get('[data-attr="prop-filter-event_properties-0"]').click({ force: true }).wait(1000)
cy.get('.LemonInput').type(value)
cy.contains('.LemonButton__content', value).click({ force: true })
- cy.get('button').contains('Apply and save dashboard').click()
+ cy.get('button').contains('Save').click()
},
addAnyFilter(): void {
cy.get('.PropertyFilterButton').should('have.length', 0)
@@ -188,7 +188,7 @@ export const dashboard = {
// click .dashboard to blur
cy.get('.dashboard').click({ force: true })
cy.get('.PropertyFilterButton').should('have.length', 1)
- cy.get('button').contains('Apply and save dashboard').click()
+ cy.get('button').contains('Save').click()
},
}
diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png
index 030f1724e0f3e..4086ce4b6a27e 100644
Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png differ
diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png
index bcf05c9abf360..648af90308438 100644
Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png differ
diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png
index f981e52556b18..4037a2ef634ce 100644
Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png differ
diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png
index 1581d4a4edae4..cab4754531e0a 100644
Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-dashboards--edit--dark.png b/frontend/__snapshots__/scenes-app-dashboards--edit--dark.png
index 02241c05b0510..fb7b9107acb31 100644
Binary files a/frontend/__snapshots__/scenes-app-dashboards--edit--dark.png and b/frontend/__snapshots__/scenes-app-dashboards--edit--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-dashboards--show--dark.png b/frontend/__snapshots__/scenes-app-dashboards--show--dark.png
index 17acdf6a3854d..2c51c1dcbc24a 100644
Binary files a/frontend/__snapshots__/scenes-app-dashboards--show--dark.png and b/frontend/__snapshots__/scenes-app-dashboards--show--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-dashboards--show--light.png b/frontend/__snapshots__/scenes-app-dashboards--show--light.png
index 7b2cf09f9f27c..0a4ea86b18c4a 100644
Binary files a/frontend/__snapshots__/scenes-app-dashboards--show--light.png and b/frontend/__snapshots__/scenes-app-dashboards--show--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--dark.png b/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--dark.png
index 6f0a54565311f..68560cb0bc84a 100644
Binary files a/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--dark.png and b/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--light.png b/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--light.png
index 9f5a2404bb23d..e125144a00562 100644
Binary files a/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--light.png and b/frontend/__snapshots__/scenes-app-project-homepage--project-homepage--light.png differ
diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx
index 5cd43a5317597..833795d4f24b5 100644
--- a/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx
+++ b/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx
@@ -33,8 +33,6 @@ export interface InsightCardProps extends Resizeable, React.HTMLAttributes
diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts
index 63b62be00ee67..c72f45b329486 100644
--- a/frontend/src/lib/utils/eventUsageLogic.ts
+++ b/frontend/src/lib/utils/eventUsageLogic.ts
@@ -71,7 +71,9 @@ import type { eventUsageLogicType } from './eventUsageLogicType'
export enum DashboardEventSource {
LongPress = 'long_press',
MoreDropdown = 'more_dropdown',
- DashboardHeader = 'dashboard_header',
+ DashboardHeaderSaveDashboard = 'dashboard_header_save_dashboard',
+ DashboardHeaderDiscardChanges = 'dashboard_header_discard_changes',
+ DashboardHeaderExitFullscreen = 'dashboard_header_exit_fullscreen',
Hotkey = 'hotkey',
InputEnter = 'input_enter',
Toast = 'toast',
diff --git a/frontend/src/queries/Query/Query.tsx b/frontend/src/queries/Query/Query.tsx
index 3828f98f2e431..d5a4bb5755a99 100644
--- a/frontend/src/queries/Query/Query.tsx
+++ b/frontend/src/queries/Query/Query.tsx
@@ -1,5 +1,4 @@
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
-import { SpinnerOverlay } from 'lib/lemon-ui/Spinner'
import { useEffect, useState } from 'react'
import { HogDebug } from 'scenes/debug/HogDebug'
@@ -38,14 +37,12 @@ export interface QueryProps {
cachedResults?: AnyResponseType
/** Disable any changes to the query */
readOnly?: boolean
- /** Show a stale overlay */
- stale?: boolean
/** Reduce UI elements to only show data */
embedded?: boolean
}
export function Query(props: QueryProps): JSX.Element | null {
- const { query: propsQuery, setQuery: propsSetQuery, readOnly, stale, embedded } = props
+ const { query: propsQuery, setQuery: propsSetQuery, readOnly, embedded } = props
const [localQuery, localSetQuery] = useState(propsQuery)
useEffect(() => {
@@ -139,7 +136,6 @@ export function Query(props: QueryProps): JSX.Element | null
>
) : null}
- {stale && }
{component}
>
diff --git a/frontend/src/scenes/dashboard/Dashboard.tsx b/frontend/src/scenes/dashboard/Dashboard.tsx
index 4eee95898ee97..d6576eb4fe20b 100644
--- a/frontend/src/scenes/dashboard/Dashboard.tsx
+++ b/frontend/src/scenes/dashboard/Dashboard.tsx
@@ -117,9 +117,9 @@ function DashboardScene(): JSX.Element {
>
{[DashboardPlacement.Public].includes(placement) ? (
- ) : (
+ ) : !(dashboardMode === DashboardMode.Edit) ? (
- )}
+ ) : null}
)}
diff --git a/frontend/src/scenes/dashboard/DashboardEditBar.tsx b/frontend/src/scenes/dashboard/DashboardEditBar.tsx
index 2dc6c481030d9..2fdf7c1c8767e 100644
--- a/frontend/src/scenes/dashboard/DashboardEditBar.tsx
+++ b/frontend/src/scenes/dashboard/DashboardEditBar.tsx
@@ -1,6 +1,4 @@
import { IconCalendar } from '@posthog/icons'
-import { LemonButton, Popover } from '@posthog/lemon-ui'
-import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
@@ -8,72 +6,55 @@ import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { groupsModel } from '~/models/groupsModel'
+import { DashboardMode } from '~/types'
export function DashboardEditBar(): JSX.Element {
- const { dashboard, canEditDashboard, temporaryFilters, stale } = useValues(dashboardLogic)
- const { setDates, setProperties, cancelTemporary, applyTemporary } = useActions(dashboardLogic)
+ const { dashboard, canEditDashboard, temporaryFilters, dashboardMode } = useValues(dashboardLogic)
+ const { setDates, setProperties, setDashboardMode } = useActions(dashboardLogic)
const { groupsTaxonomicTypes } = useValues(groupsModel)
- const isEditInProgress: boolean = canEditDashboard && stale
const disabledReason = !canEditDashboard ? "You don't have permission to edit this dashboard" : undefined
return (
-
-
- Cancel changes
-
-
- Apply and save dashboard
-
-
- }
- placement="right"
- showArrow
- >
-
+
{
+ if (dashboardMode !== DashboardMode.Edit) {
+ setDashboardMode(DashboardMode.Edit, null)
+ }
+ setDates(from_date, to_date)
+ }}
+ disabledReason={disabledReason}
+ makeLabel={(key) => (
+ <>
+
+ {key}
+ >
)}
- >
- (
- <>
-
- {key}
- >
- )}
- />
-
-
-
+ />
+ {
+ if (dashboardMode !== DashboardMode.Edit) {
+ setDashboardMode(DashboardMode.Edit, null)
+ }
+ setProperties(properties)
+ }}
+ pageKey={'dashboard_' + dashboard?.id}
+ propertyFilters={temporaryFilters.properties}
+ taxonomicGroupTypes={[
+ TaxonomicFilterGroupType.EventProperties,
+ TaxonomicFilterGroupType.PersonProperties,
+ TaxonomicFilterGroupType.EventFeatureFlags,
+ ...groupsTaxonomicTypes,
+ TaxonomicFilterGroupType.Cohorts,
+ TaxonomicFilterGroupType.Elements,
+ TaxonomicFilterGroupType.HogQLExpression,
+ ]}
+ />
+
)
}
diff --git a/frontend/src/scenes/dashboard/DashboardHeader.tsx b/frontend/src/scenes/dashboard/DashboardHeader.tsx
index 65ce4cdaeeb74..95bbb0c480e6a 100644
--- a/frontend/src/scenes/dashboard/DashboardHeader.tsx
+++ b/frontend/src/scenes/dashboard/DashboardHeader.tsx
@@ -120,19 +120,33 @@ export function DashboardHeader(): JSX.Element | null {
setDashboardMode(null, DashboardEventSource.DashboardHeader)}
- tabIndex={10}
- disabled={dashboardLoading}
- >
- Done editing
-
+ <>
+
+ setDashboardMode(null, DashboardEventSource.DashboardHeaderDiscardChanges)
+ }
+ tabIndex={9}
+ >
+ Cancel
+
+
+ setDashboardMode(null, DashboardEventSource.DashboardHeaderSaveDashboard)
+ }
+ tabIndex={10}
+ disabled={dashboardLoading}
+ >
+ Save
+
+ >
) : dashboardMode === DashboardMode.Fullscreen ? (
setDashboardMode(null, DashboardEventSource.DashboardHeader)}
+ onClick={() => setDashboardMode(null, DashboardEventSource.DashboardHeaderExitFullscreen)}
data-attr="dashboard-exit-presentation-mode"
disabled={dashboardLoading}
>
diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx
index 552809a5e3214..fccd51a9885f5 100644
--- a/frontend/src/scenes/dashboard/DashboardItems.tsx
+++ b/frontend/src/scenes/dashboard/DashboardItems.tsx
@@ -26,7 +26,6 @@ export function DashboardItems(): JSX.Element {
highlightedInsightId,
refreshStatus,
canEditDashboard,
- stale,
itemsLoading,
} = useValues(dashboardLogic)
const {
@@ -139,7 +138,6 @@ export function DashboardItems(): JSX.Element {
{
logic.mount()
})
- it('saving layouts with no provided tiles updates all tiles', async () => {
+ it('saving layouts creates api call with all tiles', async () => {
+ await expectLogic(logic).toFinishAllListeners()
+
jest.spyOn(api, 'update')
await expectLogic(logic, () => {
- logic.actions.saveLayouts()
+ logic.actions.updateFiltersAndLayouts()
}).toFinishAllListeners()
expect(api.update).toHaveBeenCalledWith(`api/projects/${MOCK_TEAM_ID}/dashboards/5`, {
@@ -322,23 +324,11 @@ describe('dashboardLogic', () => {
layouts: {},
},
],
- })
- })
-
- it('saving layouts with provided tiles updates only those tiles', async () => {
- jest.spyOn(api, 'update')
-
- await expectLogic(logic, () => {
- logic.actions.saveLayouts([{ id: 1, layouts: { sm: {} as TileLayout, xs: {} as TileLayout } }])
- }).toFinishAllListeners()
-
- expect(api.update).toHaveBeenCalledWith(`api/projects/${MOCK_TEAM_ID}/dashboards/5`, {
- tiles: [
- {
- id: 1,
- layouts: { sm: {} as TileLayout, xs: {} as TileLayout },
- },
- ],
+ filters: {
+ date_from: null,
+ date_to: null,
+ properties: [],
+ },
})
})
})
diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx
index 84f9e3ad5aa63..908b778ebb311 100644
--- a/frontend/src/scenes/dashboard/dashboardLogic.tsx
+++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx
@@ -21,7 +21,7 @@ import { captureTimeToSeeData, currentSessionId, TimeToSeeDataPayload } from 'li
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
import { Link } from 'lib/lemon-ui/Link'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
-import { clearDOMTextSelection, isAbortedRequest, isUserLoggedIn, shouldCancelQuery, toParams, uuid } from 'lib/utils'
+import { clearDOMTextSelection, isAbortedRequest, shouldCancelQuery, toParams, uuid } from 'lib/utils'
import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { Layout, Layouts } from 'react-grid-layout'
import { calculateLayouts } from 'scenes/dashboard/tileLayouts'
@@ -137,13 +137,15 @@ async function getSingleInsight(
dashboardId: number,
queryId: string,
refresh: RefreshType,
- methodOptions?: ApiMethodOptions
+ methodOptions?: ApiMethodOptions,
+ filtersOverride?: DashboardFilter
): Promise {
const apiUrl = `api/projects/${currentTeamId}/insights/${insight.id}/?${toParams({
refresh,
from_dashboard: dashboardId, // needed to load insight in correct context
client_query_id: queryId,
session_id: currentSessionId(),
+ ...(filtersOverride ? { filters_override: filtersOverride } : {}),
})}`
const insightResponse: Response = await api.getResponse(apiUrl, methodOptions)
const legacyInsight: InsightModel | null = await getJSONOrNull(insightResponse)
@@ -169,12 +171,17 @@ export const dashboardLogic = kea([
actions({
loadDashboard: (payload: {
refresh?: RefreshType
- action: 'initial_load' | 'update' | 'refresh' | 'load_missing' | 'refresh_insights_on_filters_updated'
+ action:
+ | 'initial_load'
+ | 'update'
+ | 'refresh'
+ | 'load_missing'
+ | 'refresh_insights_on_filters_updated'
+ | 'preview'
}) => payload,
triggerDashboardUpdate: (payload) => ({ payload }),
/** The current state in which the dashboard is being viewed, see DashboardMode. */
setDashboardMode: (mode: DashboardMode | null, source: DashboardEventSource | null) => ({ mode, source }),
- saveLayouts: (tilesToSave: DashboardTileLayoutUpdatePayload[] = []) => ({ tilesToSave }),
updateLayouts: (layouts: Layouts) => ({ layouts }),
updateContainerWidth: (containerWidth: number, columns: number) => ({ containerWidth, columns }),
updateTileColor: (tileId: number, color: string | null) => ({ tileId, color }),
@@ -194,7 +201,7 @@ export const dashboardLogic = kea([
date_to,
}),
setProperties: (properties: AnyPropertyFilter[] | null) => ({ properties }),
- setFilters: (filters: DashboardFilter) => ({ filters }),
+ setFiltersAndLayouts: (filters: DashboardFilter) => ({ filters }),
setAutoRefresh: (enabled: boolean, interval: number) => ({ enabled, interval }),
setRefreshStatus: (shortId: InsightShortId, loading = false, queued = false) => ({ shortId, loading, queued }),
setRefreshStatuses: (shortIds: InsightShortId[], loading = false, queued = false) => ({
@@ -226,8 +233,7 @@ export const dashboardLogic = kea([
setInitialLoadResponseBytes: (responseBytes: number) => ({ responseBytes }),
abortQuery: (payload: { dashboardQueryId: string; queryId: string; queryStartTime: number }) => payload,
abortAnyRunningQuery: true,
- applyTemporary: true,
- cancelTemporary: true,
+ updateFiltersAndLayouts: true,
}),
loaders(({ actions, props, values }) => ({
@@ -240,12 +246,34 @@ export const dashboardLogic = kea([
await breakpoint(200)
try {
- const apiUrl = values.apiUrl(refresh || 'async')
+ const apiUrl = values.apiUrl(
+ refresh || 'async',
+ action === 'preview' ? values.temporaryFilters : undefined
+ )
const dashboardResponse: Response = await api.getResponse(apiUrl)
const dashboard: DashboardType | null = await getJSONOrNull(dashboardResponse)
actions.setInitialLoadResponseBytes(getResponseBytes(dashboardResponse))
+ // don't update dashboard tile layouts if we're previewing
+ // we want to retain what the user has temporarily set
+ if (action === 'preview' && dashboard) {
+ const editModeTileLayouts: Record = {}
+ values.dashboard?.tiles.forEach((tile: DashboardTile) => {
+ editModeTileLayouts[tile.id] = tile.layouts
+ })
+
+ const tilesWithPreviousLayouts = dashboard.tiles.map((tile) => ({
+ ...tile,
+ layouts: editModeTileLayouts?.[tile.id],
+ }))
+
+ return getQueryBasedDashboard({
+ ...dashboard,
+ tiles: tilesWithPreviousLayouts,
+ })
+ }
+
return getQueryBasedDashboard(dashboard)
} catch (error: any) {
if (error.status === 404) {
@@ -254,19 +282,27 @@ export const dashboardLogic = kea([
throw error
}
},
- updateFilters: async () => {
+ updateFiltersAndLayouts: async (_, breakpoint) => {
actions.abortAnyRunningQuery()
try {
+ const layoutsToUpdate = (values.dashboard?.tiles || []).map((tile) => ({
+ id: tile.id,
+ layouts: tile.layouts,
+ }))
+
+ breakpoint()
+
const dashboard: DashboardType = await api.update(
`api/projects/${values.currentTeamId}/dashboards/${props.id}`,
{
filters: values.filters,
+ tiles: layoutsToUpdate,
}
)
return getQueryBasedDashboard(dashboard)
} catch (e) {
- lemonToast.error('Could not update dashboardFilters: ' + String(e))
+ lemonToast.error('Could not update dashboard: ' + String(e))
return values.dashboard
}
},
@@ -299,6 +335,23 @@ export const dashboardLogic = kea([
return values.dashboard
}
},
+ setDashboardMode: async ({ mode, source }) => {
+ if (
+ mode === null &&
+ source === DashboardEventSource.DashboardHeaderDiscardChanges &&
+ values.dashboard?.tiles
+ ) {
+ // layout changes were discarded so need to reset to original state
+ const restoredTiles = values.dashboard?.tiles?.map((tile) => ({
+ ...tile,
+ layouts: values.dashboardLayouts?.[tile.id],
+ }))
+
+ values.dashboard.tiles = restoredTiles
+ }
+
+ return values.dashboard
+ },
duplicateTile: async ({ tile }) => {
try {
const newTile = { ...tile } as Partial>
@@ -340,6 +393,14 @@ export const dashboardLogic = kea([
],
})),
reducers(({ props }) => ({
+ _dashboardLoading: [
+ false,
+ {
+ loadDashboard: () => true,
+ loadDashboardSuccess: () => false,
+ loadDashboardFailure: () => false,
+ },
+ ],
pageVisibility: [
true,
{
@@ -353,6 +414,24 @@ export const dashboardLogic = kea([
loadDashboardFailure: () => true,
},
],
+ dashboardLayouts: [
+ {} as Record,
+ {
+ loadDashboardSuccess: (state, { dashboard, payload }) => {
+ // don't update dashboardLayouts if we're previewing
+ if (payload?.action === 'preview') {
+ return state
+ }
+
+ const tileIdToLayouts: Record = {}
+ dashboard?.tiles.forEach((tile: DashboardTile) => {
+ tileIdToLayouts[tile.id] = tile.layouts
+ })
+
+ return tileIdToLayouts
+ },
+ },
+ ],
temporaryFilters: [
{
date_from: null,
@@ -375,7 +454,7 @@ export const dashboardLogic = kea([
...state,
date_from: dashboard?.filters.date_from || null,
date_to: dashboard?.filters.date_to || null,
- properties: dashboard?.filters.properties || null,
+ properties: dashboard?.filters.properties || [],
}
: state,
},
@@ -387,17 +466,22 @@ export const dashboardLogic = kea([
properties: null,
} as DashboardFilter,
{
- setFilters: (state, { filters }) => ({
+ setFiltersAndLayouts: (state, { filters }) => ({
...state,
...filters,
}),
- loadDashboardSuccess: (state, { dashboard }) =>
+ loadDashboardSuccess: (state, { dashboard, payload }) =>
dashboard
? {
...state,
- date_from: dashboard?.filters.date_from || null,
- date_to: dashboard?.filters.date_to || null,
- properties: dashboard?.filters.properties || [],
+ // don't update filters if we're previewing
+ ...(payload?.action === 'preview'
+ ? {}
+ : {
+ date_from: dashboard?.filters.date_from || null,
+ date_to: dashboard?.filters.date_to || null,
+ properties: dashboard?.filters.properties || [],
+ }),
}
: state,
},
@@ -647,9 +731,10 @@ export const dashboardLogic = kea([
apiUrl: [
() => [(_, props) => props.id],
(id) => {
- return (refresh?: RefreshType) =>
+ return (refresh?: RefreshType, filtersOverride?: DashboardFilter) =>
`api/projects/${teamLogic.values.currentTeamId}/dashboards/${id}/?${toParams({
refresh,
+ filters_override: filtersOverride,
})}`
},
],
@@ -660,7 +745,7 @@ export const dashboardLogic = kea([
],
textTiles: [(s) => [s.tiles], (tiles) => tiles.filter((t) => !!t.text)],
itemsLoading: [
- (s) => [s.dashboardLoading, s.refreshStatus],
+ (s) => [s._dashboardLoading, s.refreshStatus],
(dashboardLoading, refreshStatus) => {
return dashboardLoading || Object.values(refreshStatus).some((s) => s.loading || s.queued)
},
@@ -777,7 +862,7 @@ export const dashboardLogic = kea([
},
],
breadcrumbs: [
- (s) => [s.dashboard, s.dashboardLoading, s.dashboardFailedToLoad],
+ (s) => [s.dashboard, s._dashboardLoading, s.dashboardFailedToLoad],
(dashboard, dashboardLoading, dashboardFailedToLoad): Breadcrumb[] => [
{
key: Scene.Dashboards,
@@ -822,22 +907,6 @@ export const dashboardLogic = kea([
})
},
],
- stale: [
- (s) => [s.temporaryFilters, s.dashboard],
- (temporaryFilters, dashboard) => {
- const isDateFromStale =
- !!(temporaryFilters.date_from || dashboard?.filters.date_from) &&
- temporaryFilters.date_from !== dashboard?.filters.date_from
- const isDateToStale =
- !!(temporaryFilters.date_to || dashboard?.filters.date_to) &&
- temporaryFilters.date_to !== dashboard?.filters.date_to
- const isPropertiesStale =
- !!(temporaryFilters.properties || dashboard?.filters.properties) &&
- JSON.stringify(temporaryFilters.properties) !== JSON.stringify(dashboard?.filters.properties)
-
- return isDateFromStale || isDateToStale || isPropertiesStale
- },
- ],
})),
events(({ actions, cache, props }) => ({
afterMount: () => {
@@ -878,7 +947,7 @@ export const dashboardLogic = kea([
},
})),
listeners(({ actions, values, cache, props, sharedListeners }) => ({
- updateFiltersSuccess: () => {
+ updateFiltersAndLayoutsSuccess: () => {
actions.loadDashboard({ action: 'update' })
},
setRefreshError: sharedListeners.reportRefreshTiming,
@@ -913,25 +982,6 @@ export const dashboardLogic = kea([
actions.loadDashboard({ action: 'update' })
}
},
- updateLayouts: () => {
- actions.saveLayouts()
- },
- saveLayouts: async ({ tilesToSave }, breakpoint) => {
- await breakpoint(300)
- if (!isUserLoggedIn()) {
- // If user is anonymous (i.e. viewing a shared dashboard logged out), we don't save any layout changes.
- return
- }
- const layoutsToUpdate = tilesToSave.length
- ? tilesToSave
- : (values.dashboard?.tiles || []).map((tile) => ({ id: tile.id, layouts: tile.layouts }))
-
- breakpoint()
-
- return await api.update(`api/projects/${values.currentTeamId}/dashboards/${props.id}`, {
- tiles: layoutsToUpdate,
- })
- },
moveToDashboardSuccess: ({ payload }) => {
if (payload?.toDashboard === undefined || payload?.tile === undefined) {
return
@@ -1030,15 +1080,17 @@ export const dashboardLogic = kea([
let refreshesFinished = 0
const totalResponseBytes = 0
- // array of functions that reload each item
+ // array of functions that reload each insight
const fetchItemFunctions = insightsToRefresh.map((insight) => async () => {
- const queryId = `${dashboardQueryId}::${uuid()}`
+ // dashboard refresh or insight refresh will have been triggered first
+ // so we should have a query_id to poll for
+ const queryId = insight?.query_status?.id
const queryStartTime = performance.now()
try {
breakpoint()
- if (insight.query_status) {
- await pollForResults(insight.query_status.id, false, methodOptions)
+ if (queryId) {
+ await pollForResults(queryId, false, methodOptions)
const currentTeamId = values.currentTeamId
// TODO: Check and remove - We get the insight again here to get everything in the right format (e.g. because of result vs results)
const polledInsight = await getSingleInsight(
@@ -1047,8 +1099,14 @@ export const dashboardLogic = kea([
dashboardId,
queryId,
'force_cache',
- methodOptions
+ methodOptions,
+ action === 'preview' ? values.temporaryFilters : undefined
)
+
+ if (action === 'preview' && polledInsight!.dashboard_tiles) {
+ // if we're previewing, only update the insight on this dashboard
+ polledInsight!.dashboards = [dashboardId]
+ }
dashboardsModel.actions.updateDashboardInsight(polledInsight!)
actions.setRefreshStatus(insight.short_id)
}
@@ -1056,9 +1114,9 @@ export const dashboardLogic = kea([
if (isBreakpoint(e)) {
cancelled = true
} else if (shouldCancelQuery(e)) {
- if (!cancelled) {
+ if (!cancelled && queryId) {
// cancel all insight requests for this query in one go
- actions.abortQuery({ dashboardQueryId: dashboardQueryId, queryId: queryId, queryStartTime })
+ actions.abortQuery({ dashboardQueryId: dashboardQueryId, queryId, queryStartTime })
}
if (isAbortedRequest(e)) {
cancelled = true
@@ -1101,14 +1159,29 @@ export const dashboardLogic = kea([
eventUsageLogic.actions.reportDashboardRefreshed(dashboardId, values.newestRefreshed)
},
- setFilters: ({ filters: { date_from, date_to } }) => {
- actions.updateFilters()
+ setFiltersAndLayouts: ({ filters: { date_from, date_to } }) => {
+ actions.updateFiltersAndLayouts()
eventUsageLogic.actions.reportDashboardDateRangeChanged(date_from, date_to)
eventUsageLogic.actions.reportDashboardPropertiesChanged()
},
setDashboardMode: async ({ mode, source }) => {
if (mode === DashboardMode.Edit) {
clearDOMTextSelection()
+ lemonToast.info('Now editing the dashboard – save to persist changes')
+ } else if (mode === null) {
+ if (source === DashboardEventSource.DashboardHeaderDiscardChanges) {
+ // cancel edit mode changes
+
+ // reset filters to that before previewing
+ actions.setDates(values.filters.date_from ?? null, values.filters.date_to ?? null)
+ actions.setProperties(values.filters.properties ?? null)
+
+ // also reset layout to that we stored in dashboardLayouts
+ // this is done in the reducer for dashboard
+ } else if (source === DashboardEventSource.DashboardHeaderSaveDashboard) {
+ // save edit mode changes
+ actions.setFiltersAndLayouts(values.temporaryFilters)
+ }
}
if (mode) {
@@ -1152,7 +1225,7 @@ export const dashboardLogic = kea([
const initialLoad = action === 'initial_load'
const allLoaded = false // TODO: Check this
- actions.refreshAllDashboardItems({ action: 'refresh', initialLoad, dashboardQueryId })
+ actions.refreshAllDashboardItems({ action, initialLoad, dashboardQueryId })
const payload: TimeToSeeDataPayload = {
type: 'dashboard_load',
@@ -1226,12 +1299,11 @@ export const dashboardLogic = kea([
insights_fetched_cached: 0,
})
},
- applyTemporary: () => {
- actions.setFilters(values.temporaryFilters)
+ setProperties: () => {
+ actions.loadDashboard({ action: 'preview' })
},
- cancelTemporary: () => {
- actions.setDates(values.dashboard?.filters.date_from ?? null, values.dashboard?.filters.date_to ?? null)
- actions.setProperties(values.dashboard?.filters.properties ?? null)
+ setDates: () => {
+ actions.loadDashboard({ action: 'preview' })
},
})),
diff --git a/posthog/api/dashboards/dashboard.py b/posthog/api/dashboards/dashboard.py
index 8462aec010f45..b0b3a8084db99 100644
--- a/posthog/api/dashboards/dashboard.py
+++ b/posthog/api/dashboards/dashboard.py
@@ -18,6 +18,7 @@
)
from posthog.api.forbid_destroy_model import ForbidDestroyModel
from posthog.api.insight import InsightSerializer, InsightViewSet
+from posthog.api.monitoring import monitor, Feature
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.api.tagged_item import TaggedItemSerializerMixin, TaggedItemViewSetMixin
@@ -29,7 +30,7 @@
from posthog.models.tagged_item import TaggedItem
from posthog.models.user import User
from posthog.user_permissions import UserPermissionsSerializerMixin
-from posthog.api.monitoring import monitor, Feature
+from posthog.utils import filters_override_requested_by_client
logger = structlog.get_logger(__name__)
@@ -124,6 +125,7 @@ def get_effective_privilege_level(self, dashboard: Dashboard) -> Dashboard.Privi
class DashboardSerializer(DashboardBasicSerializer):
tiles = serializers.SerializerMethodField()
+ filters = serializers.SerializerMethodField()
created_by = UserBasicSerializer(read_only=True)
use_template = serializers.CharField(write_only=True, allow_blank=True, required=False)
use_dashboard = serializers.IntegerField(write_only=True, allow_null=True, required=False)
@@ -172,7 +174,15 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Dashboard:
validated_data.pop("delete_insights", None) # not used during creation
validated_data = self._update_creation_mode(validated_data, use_template, use_dashboard)
tags = validated_data.pop("tags", None) # tags are created separately below as global tag relationships
- dashboard = Dashboard.objects.create(team_id=team_id, **validated_data)
+
+ request_filters = request.data.get("filters")
+ if request_filters:
+ if not isinstance(request_filters, dict):
+ raise serializers.ValidationError("Filters must be a dictionary")
+ filters = request_filters
+ else:
+ filters = {}
+ dashboard = Dashboard.objects.create(team_id=team_id, filters=filters, **validated_data)
if use_template:
try:
@@ -281,6 +291,12 @@ def update(self, instance: Dashboard, validated_data: dict, *args: Any, **kwargs
if validated_data.get("deleted", False):
self._delete_related_tiles(instance, self.validated_data.get("delete_insights", False))
+ request_filters = initial_data.get("filters")
+ if request_filters:
+ if not isinstance(request_filters, dict):
+ raise serializers.ValidationError("Filters must be a dictionary")
+ instance.filters = request_filters
+
instance = super().update(instance, validated_data)
user = cast(User, self.context["request"].user)
@@ -380,6 +396,16 @@ def get_tiles(self, dashboard: Dashboard) -> Optional[list[ReturnDict]]:
return serialized_tiles
+ def get_filters(self, dashboard: Dashboard) -> dict:
+ request = self.context.get("request")
+ if request:
+ filters_override = filters_override_requested_by_client(request)
+
+ if filters_override is not None:
+ return filters_override
+
+ return dashboard.filters
+
def validate(self, data):
if data.get("use_dashboard", None) and data.get("use_template", None):
raise serializers.ValidationError("`use_dashboard` and `use_template` cannot be used together")
diff --git a/posthog/api/insight.py b/posthog/api/insight.py
index 0406cd6e1abce..f4ecb8e1ab49e 100644
--- a/posthog/api/insight.py
+++ b/posthog/api/insight.py
@@ -21,6 +21,7 @@
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework_csv import renderers as csvrenderers
+from rest_framework.request import Request
from posthog import schema
from posthog.api.documentation import extend_schema
@@ -102,6 +103,7 @@
refresh_requested_by_client,
relative_date_parse,
str_to_bool,
+ filters_override_requested_by_client,
)
from posthog.api.monitoring import monitor, Feature
@@ -550,6 +552,9 @@ def to_representation(self, instance: Insight):
representation["dashboards"] = [tile["dashboard_id"] for tile in representation["dashboard_tiles"]]
dashboard: Optional[Dashboard] = self.context.get("dashboard")
+ request: Optional[Request] = self.context.get("request")
+ dashboard_filters_override = filters_override_requested_by_client(request) if request else None
+
if hogql_insights_replace_filters(instance.team) and (
instance.query is not None or instance.query_from_filters is not None
):
@@ -559,12 +564,20 @@ def to_representation(self, instance: Insight):
query = instance.query or instance.query_from_filters
if dashboard:
- query = apply_dashboard_filters_to_dict(query, dashboard.filters, instance.team)
+ query = apply_dashboard_filters_to_dict(
+ query,
+ dashboard_filters_override if dashboard_filters_override is not None else dashboard.filters,
+ instance.team,
+ )
representation["filters"] = {}
representation["query"] = query
else:
- representation["filters"] = instance.dashboard_filters(dashboard=dashboard)
- representation["query"] = instance.get_effective_query(dashboard=dashboard)
+ representation["filters"] = instance.dashboard_filters(
+ dashboard=dashboard, dashboard_filters_override=dashboard_filters_override
+ )
+ representation["query"] = instance.get_effective_query(
+ dashboard=dashboard, dashboard_filters_override=dashboard_filters_override
+ )
if "insight" not in representation["filters"] and not representation["query"]:
representation["filters"]["insight"] = "TRENDS"
@@ -583,6 +596,7 @@ def insight_result(self, insight: Insight) -> InsightResult:
try:
refresh_requested = refresh_requested_by_client(self.context["request"])
execution_mode = execution_mode_from_refresh(refresh_requested)
+ filters_override = filters_override_requested_by_client(self.context["request"])
if self.context.get("is_shared", False):
execution_mode = shared_insights_execution_mode(execution_mode)
@@ -592,6 +606,7 @@ def insight_result(self, insight: Insight) -> InsightResult:
dashboard=dashboard,
execution_mode=execution_mode,
user=self.context["request"].user,
+ filters_override=filters_override,
)
except ExposedHogQLError as e:
raise ValidationError(str(e))
diff --git a/posthog/api/test/dashboards/test_dashboard.py b/posthog/api/test/dashboards/test_dashboard.py
index ae4840b60550c..a23edc4ccc9c8 100644
--- a/posthog/api/test/dashboards/test_dashboard.py
+++ b/posthog/api/test/dashboards/test_dashboard.py
@@ -553,7 +553,9 @@ def test_dashboard_insights_out_of_synch_with_tiles_are_not_shown(self):
dashboard = Dashboard.objects.get(id=dashboard_id)
mock_view = MagicMock()
mock_view.action = "retrieve"
- dashboard_data = DashboardSerializer(dashboard, context={"view": mock_view, "request": MagicMock()}).data
+ mock_request = MagicMock()
+ mock_request.query_params.get.return_value = None
+ dashboard_data = DashboardSerializer(dashboard, context={"view": mock_view, "request": mock_request}).data
assert len(dashboard_data["tiles"]) == 1
def test_dashboard_insight_tiles_can_be_loaded_correct_context(self):
@@ -568,7 +570,6 @@ def test_dashboard_insight_tiles_can_be_loaded_correct_context(self):
response = self.dashboard_api.get_dashboard(dashboard_id)
self.assertEqual(len(response["tiles"]), 1)
- self.assertEqual(len(response["tiles"]), 1)
tile = response["tiles"][0]
assert tile["insight"]["id"] == insight_id
diff --git a/posthog/api/test/test_insight.py b/posthog/api/test/test_insight.py
index 9962db4023311..0ce66a4bb6182 100644
--- a/posthog/api/test/test_insight.py
+++ b/posthog/api/test/test_insight.py
@@ -311,6 +311,7 @@ def test_get_insight_in_shared_context(self) -> None:
dashboard=mock.ANY,
execution_mode=ExecutionMode.EXTENDED_CACHE_CALCULATE_ASYNC_IF_STALE,
user=mock.ANY,
+ filters_override=None,
)
with patch(
@@ -322,6 +323,7 @@ def test_get_insight_in_shared_context(self) -> None:
dashboard=mock.ANY,
execution_mode=ExecutionMode.RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE,
user=mock.ANY,
+ filters_override=None,
)
def test_get_insight_by_short_id(self) -> None:
diff --git a/posthog/caching/calculate_results.py b/posthog/caching/calculate_results.py
index f1b4d50fc4bc8..7da32bb9e88cd 100644
--- a/posthog/caching/calculate_results.py
+++ b/posthog/caching/calculate_results.py
@@ -123,7 +123,12 @@ def get_cache_type(cacheable: Optional[FilterType] | Optional[dict]) -> CacheTyp
def calculate_for_query_based_insight(
- insight: Insight, *, dashboard: Optional[Dashboard] = None, execution_mode: ExecutionMode, user: Optional[User]
+ insight: Insight,
+ *,
+ dashboard: Optional[Dashboard] = None,
+ execution_mode: ExecutionMode,
+ user: Optional[User],
+ filters_override: Optional[dict] = None,
) -> "InsightResult":
from posthog.caching.fetch_from_cache import InsightResult, NothingInCacheResult
from posthog.caching.insight_cache import update_cached_state
@@ -135,7 +140,9 @@ def calculate_for_query_based_insight(
response = process_response = process_query_dict(
insight.team,
insight.query,
- dashboard_filters_json=dashboard.filters if dashboard is not None else None,
+ dashboard_filters_json=(
+ filters_override if filters_override is not None else dashboard.filters if dashboard is not None else None
+ ),
execution_mode=execution_mode,
user=user,
insight_id=insight.pk,
diff --git a/posthog/models/insight.py b/posthog/models/insight.py
index 1b7818bbb2fb8..48d0179417006 100644
--- a/posthog/models/insight.py
+++ b/posthog/models/insight.py
@@ -128,10 +128,14 @@ def query_from_filters(self):
except Exception as e:
capture_exception(e)
- def dashboard_filters(self, dashboard: Optional[Dashboard] = None):
+ def dashboard_filters(
+ self, dashboard: Optional[Dashboard] = None, dashboard_filters_override: Optional[dict] = None
+ ):
# query date range is set in a different function, see dashboard_query
if dashboard and not self.query:
- dashboard_filters = {**dashboard.filters}
+ dashboard_filters = {
+ **(dashboard_filters_override if dashboard_filters_override is not None else dashboard.filters)
+ }
dashboard_properties = dashboard_filters.pop("properties") if dashboard_filters.get("properties") else None
insight_date_from = self.filters.get("date_from", None)
insight_date_to = self.filters.get("date_to", None)
@@ -181,13 +185,19 @@ def dashboard_filters(self, dashboard: Optional[Dashboard] = None):
else:
return self.filters
- def get_effective_query(self, *, dashboard: Optional[Dashboard]) -> Optional[dict]:
+ def get_effective_query(
+ self, *, dashboard: Optional[Dashboard], dashboard_filters_override: Optional[dict] = None
+ ) -> Optional[dict]:
from posthog.hogql_queries.apply_dashboard_filters import apply_dashboard_filters_to_dict
if not dashboard or not self.query:
return self.query
- return apply_dashboard_filters_to_dict(self.query, dashboard.filters, self.team)
+ return apply_dashboard_filters_to_dict(
+ self.query,
+ dashboard_filters_override if dashboard_filters_override is not None else dashboard.filters,
+ self.team,
+ )
@property
def url(self):
diff --git a/posthog/utils.py b/posthog/utils.py
index 478c149b7ddc2..39bf6d606982f 100644
--- a/posthog/utils.py
+++ b/posthog/utils.py
@@ -20,6 +20,7 @@
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from urllib.parse import unquote, urljoin, urlparse
from zoneinfo import ZoneInfo
+from rest_framework import serializers
import lzstring
import posthoganalytics
@@ -68,7 +69,6 @@
DEFAULT_DATE_FROM_DAYS = 7
-
logger = structlog.get_logger(__name__)
# https://stackoverflow.com/questions/4060221/how-to-reliably-open-a-file-in-the-same-directory-as-a-python-script
@@ -1042,6 +1042,18 @@ def cache_requested_by_client(request: Request) -> bool | str:
return _request_has_key_set("use_cache", request)
+def filters_override_requested_by_client(request: Request) -> Optional[dict]:
+ raw_filters = request.query_params.get("filters_override")
+
+ if raw_filters is not None:
+ try:
+ return json.loads(raw_filters)
+ except Exception:
+ raise serializers.ValidationError({"filters_override": "Invalid JSON passed in filters_override parameter"})
+
+ return None
+
+
def _request_has_key_set(key: str, request: Request, allowed_values: Optional[list[str]] = None) -> bool | str:
query_param = request.query_params.get(key)
data_value = request.data.get(key)