From 253820114ed89649ab65ebc7a8b519d96b05a5ac Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Tue, 4 Jul 2023 14:19:53 -0700 Subject: [PATCH] [Dashboard De-Angular] Fix dashboard save and back button functional test (#4491) * fix copy on save and functional test 5 Signed-off-by: abbyhu2000 * Fix back button navigation -- Fix version migration for panels -- Fix conversions between saved panel and container panel type -- Fix redundant browser update by re-structure app state and global state sync logic in order for back button to work, also fix the corresponding functional test Signed-off-by: abbyhu2000 * migration version Signed-off-by: abbyhu2000 * Add initialization dirty flag and fix full mode filter bar Signed-off-by: abbyhu2000 * Add initialization dirty flag and fix full mode filter bar Signed-off-by: abbyhu2000 --------- Signed-off-by: abbyhu2000 --- .../dashboard/common/migrate_to_730_panels.ts | 9 +++- .../dashboard/public/application/app.tsx | 25 +-------- .../components/dashboard_editor.tsx | 18 ++++--- .../components/dashboard_listing.tsx | 13 +++++ .../components/dashboard_top_nav.tsx | 4 +- .../lib/embeddable_saved_object_converters.ts | 11 ++-- .../application/lib/get_app_state_defaults.ts | 1 - .../application/lib/migrate_app_state.ts | 14 ++++- .../utils/create_dashboard_app_state.tsx | 54 ++++++++++++++----- .../application/utils/get_nav_actions.tsx | 12 ++--- .../utils/use/use_dashboard_app_state.tsx | 15 ++++-- .../utils/use/use_dashboard_container.tsx | 12 ++--- .../utils/use/use_editor_updates.ts | 2 +- .../utils/use/use_saved_dashboard_instance.ts | 8 ++- src/plugins/dashboard/public/types.ts | 1 - 15 files changed, 118 insertions(+), 81 deletions(-) diff --git a/src/plugins/dashboard/common/migrate_to_730_panels.ts b/src/plugins/dashboard/common/migrate_to_730_panels.ts index 875e39c0fcb6..5cd5db5f870c 100644 --- a/src/plugins/dashboard/common/migrate_to_730_panels.ts +++ b/src/plugins/dashboard/common/migrate_to_730_panels.ts @@ -87,8 +87,11 @@ function is640To720Panel( panel: unknown | RawSavedDashboardPanel640To720 ): panel is RawSavedDashboardPanel640To720 { return ( - semver.satisfies((panel as RawSavedDashboardPanel630).version, '>6.3') && - semver.satisfies((panel as RawSavedDashboardPanel630).version, '<7.3') + semver.satisfies( + semver.coerce((panel as RawSavedDashboardPanel630).version)!.version, + '>6.3' + ) && + semver.satisfies(semver.coerce((panel as RawSavedDashboardPanel630).version)!.version, '<7.3') ); } @@ -273,10 +276,12 @@ function migrate640To720PanelsToLatest( version: string ): RawSavedDashboardPanel730ToLatest { const panelIndex = panel.panelIndex ? panel.panelIndex.toString() : uuid.v4(); + const embeddableConfig = panel.embeddableConfig ?? {}; return { ...panel, version, panelIndex, + embeddableConfig, }; } diff --git a/src/plugins/dashboard/public/application/app.tsx b/src/plugins/dashboard/public/application/app.tsx index 93ccdac0a7fa..83634d23729e 100644 --- a/src/plugins/dashboard/public/application/app.tsx +++ b/src/plugins/dashboard/public/application/app.tsx @@ -11,37 +11,16 @@ import './app.scss'; import { AppMountParameters } from 'opensearch-dashboards/public'; -import React, { useEffect } from 'react'; -import { Route, Switch, useLocation } from 'react-router-dom'; +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; import { DashboardConstants, createDashboardEditUrl } from '../dashboard_constants'; import { DashboardEditor, DashboardListing, DashboardNoMatch } from './components'; -import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { DashboardServices } from '../types'; -import { syncQueryStateWithUrl } from '../../../data/public'; export interface DashboardAppProps { onAppLeave: AppMountParameters['onAppLeave']; } export const DashboardApp = ({ onAppLeave }: DashboardAppProps) => { - const { - services: { - data: { query }, - osdUrlStateStorage, - }, - } = useOpenSearchDashboards(); - const { pathname } = useLocation(); - - useEffect(() => { - // syncs `_g` portion of url with query services - const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); - - return () => stop(); - - // this effect should re-run when pathname is changed to preserve querystring part, - // so the global state is always preserved - }, [query, osdUrlStateStorage, pathname]); - return ( diff --git a/src/plugins/dashboard/public/application/components/dashboard_editor.tsx b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx index fb2b7c74aed6..c5681aa3f197 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_editor.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx @@ -11,7 +11,7 @@ import { useChromeVisibility } from '../utils/use/use_chrome_visibility'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { useSavedDashboardInstance } from '../utils/use/use_saved_dashboard_instance'; import { DashboardServices } from '../../types'; -import { useDashboardAppState } from '../utils/use/use_dashboard_app_state'; +import { useDashboardAppAndGlobalState } from '../utils/use/use_dashboard_app_state'; import { useDashboardContainer } from '../utils/use/use_dashboard_container'; import { useEditorUpdates } from '../utils/use/use_editor_updates'; import { @@ -33,7 +33,11 @@ export const DashboardEditor = () => { dashboardIdFromUrl ); - const { appState } = useDashboardAppState(services, eventEmitter, savedDashboardInstance); + const { appState } = useDashboardAppAndGlobalState( + services, + eventEmitter, + savedDashboardInstance + ); const { dashboardContainer } = useDashboardContainer( services, @@ -54,19 +58,19 @@ export const DashboardEditor = () => { ); useEffect(() => { - if (appState) { + if (appState && dashboard) { if (savedDashboardInstance?.id) { chrome.setBreadcrumbs( setBreadcrumbsForExistingDashboard( savedDashboardInstance.title, appState?.getState().viewMode, - appState?.getState().isDirty + dashboard.isDirty ) ); chrome.docTitle.change(savedDashboardInstance.title); } else { chrome.setBreadcrumbs( - setBreadcrumbsForNewDashboard(appState?.getState().viewMode, appState?.getState().isDirty) + setBreadcrumbsForNewDashboard(appState?.getState().viewMode, dashboard.isDirty) ); } } @@ -85,7 +89,9 @@ export const DashboardEditor = () => { console.log('appStateData', appState?.getState()); console.log('currentAppState', currentAppState); console.log('isEmbeddableRendered', isEmbeddableRendered); - console.log('app state isDirty', appState?.getState().isDirty); + if (dashboard) { + console.log('isDirty', dashboard.isDirty); + } console.log('dashboardContainer', dashboardContainer); return ( diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing.tsx b/src/plugins/dashboard/public/application/components/dashboard_listing.tsx index 53593c61a52c..3ffaf8108dae 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_listing.tsx @@ -16,6 +16,7 @@ import { DashboardConstants, createDashboardEditUrl } from '../../dashboard_cons import { DashboardServices } from '../../types'; import { getTableColumns } from '../utils/get_table_columns'; import { getNoItemsMessage } from '../utils/get_no_items_message'; +import { syncQueryStateWithUrl } from '../../../../data/public'; export const EMPTY_FILTER = ''; @@ -32,6 +33,8 @@ export const DashboardListing = () => { notifications, savedDashboards, dashboardProviders, + data: { query }, + osdUrlStateStorage, }, } = useOpenSearchDashboards(); @@ -40,6 +43,16 @@ export const DashboardListing = () => { const initialFiltersFromURL = queryParameters.get('filter'); const [initialFilter, setInitialFilter] = useState(initialFiltersFromURL); + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, location]); + useEffect(() => { const getDashboardsBasedOnUrl = async () => { const title = queryParameters.get('title'); diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx index 329c3182ec02..7675b733fef0 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx @@ -105,7 +105,7 @@ const TopNav = ({ savedDashboardInstance, stateContainer, isEmbeddableRendered, - dashboard + dashboard, ]); useEffect(() => { @@ -140,7 +140,7 @@ const TopNav = ({ }, [dashboardContainer, stateContainer, currentAppState, services.data.indexPatterns]); const shouldShowFilterBar = (forceHide: boolean): boolean => - !forceHide && (filters!.length > 0 || !currentAppState?.fullScreenMode); + !forceHide && (currentAppState.filters!.length > 0 || !currentAppState?.fullScreenMode); const forceShowTopNavMenu = shouldForceDisplay(UrlParams.SHOW_TOP_MENU); const forceShowQueryInput = shouldForceDisplay(UrlParams.SHOW_QUERY_INPUT); diff --git a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts index 70cd2addece2..24630e40500d 100644 --- a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts +++ b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts @@ -52,17 +52,18 @@ export function convertPanelStateToSavedDashboardPanel( panelState: DashboardPanelState, version: string ): SavedDashboardPanel { - const customTitle: string | undefined = panelState.explicitInput.title - ? (panelState.explicitInput.title as string) - : undefined; + const customTitle: string | undefined = + panelState.explicitInput.title !== undefined + ? (panelState.explicitInput.title as string) + : undefined; const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; return { version, type: panelState.type, gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, - embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId']), - ...(customTitle && { title: customTitle }), + embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), + ...(customTitle !== undefined && { title: customTitle }), ...(savedObjectId !== undefined && { id: savedObjectId }), }; } diff --git a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts index aae4287870ea..95e5d70be438 100644 --- a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts +++ b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts @@ -46,6 +46,5 @@ export function getAppStateDefaults( query: savedDashboard.getQuery(), filters: savedDashboard.getFilters(), viewMode: savedDashboard.id || hideWriteControls ? ViewMode.VIEW : ViewMode.EDIT, - isDirty: false, }; } diff --git a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts index 65a132bba27d..a64c253fafed 100644 --- a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts +++ b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts @@ -79,7 +79,15 @@ export function migrateAppState( usageCollection.reportUiStats('DashboardPanelVersionInUrl', METRIC_TYPE.LOADED, `${version}`); } - return semver.satisfies(version, '<7.3'); + // Adding this line to parse versions such as "7.0.0-alpha1" + const cleanVersion = semver.coerce(version); + if (cleanVersion?.version) { + // Only support migration for version 6.0 - 7.2 + // We do not need to migrate OpenSearch version 1.x, 2.x, or 3.x since the panel structure + // is the same as previous version 7.3 + return semver.satisfies(cleanVersion, '<7.3') && semver.satisfies(cleanVersion, '>6.0'); + } + return true; }); if (panelNeedsMigration) { @@ -98,5 +106,9 @@ export function migrateAppState( delete appState.uiState; } + appState.panels.forEach((panel) => { + panel.version = opensearchDashboardsVersion; + }); + return appState; } diff --git a/src/plugins/dashboard/public/application/utils/create_dashboard_app_state.tsx b/src/plugins/dashboard/public/application/utils/create_dashboard_app_state.tsx index 8b7e248cc94f..8724533eaee6 100644 --- a/src/plugins/dashboard/public/application/utils/create_dashboard_app_state.tsx +++ b/src/plugins/dashboard/public/application/utils/create_dashboard_app_state.tsx @@ -17,8 +17,9 @@ import { } from '../../types'; import { ViewMode } from '../../embeddable_plugin'; import { getDashboardIdFromUrl } from '../lib'; +import { syncQueryStateWithUrl } from '../../../../data/public'; -const STATE_STORAGE_KEY = '_a'; +const APP_STATE_STORAGE_KEY = '_a'; interface Arguments { osdUrlStateStorage: IOsdUrlStateStorage; @@ -27,14 +28,27 @@ interface Arguments { instance: any; } -export const createDashboardAppState = ({ +export const createDashboardGlobalAndAppState = ({ stateDefaults, osdUrlStateStorage, services, instance, }: Arguments) => { - const urlState = osdUrlStateStorage.get(STATE_STORAGE_KEY); - const { opensearchDashboardsVersion, usageCollection, history } = services; + const urlState = osdUrlStateStorage.get(APP_STATE_STORAGE_KEY); + const { + opensearchDashboardsVersion, + usageCollection, + history, + data: { query }, + } = services; + + /* + Function migrateAppState() does two things + 1. Migrate panel before version 7.3.0 to the 7.3.0 panel structure. + There are no changes to the panel structure after version 7.3.0 to the current + OpenSearch version so no need to migrate panels that are version 7.3.0 or higher + 2. Update the version number on each panel to the current version. + */ const initialState = migrateAppState( { ...stateDefaults, @@ -55,14 +69,6 @@ export const createDashboardAppState = ({ }), // setDashboard: (state) } as DashboardAppStateTransitions; - /* - make sure url ('_a') matches initial state - Initializing appState does two things - first it translates the defaults into AppState, - second it updates appState based on the url (the url trumps the defaults). This means if - we update the state format at all and want to handle BWC, we must not only migrate the - data stored with saved vis, but also any old state in the url. - */ - osdUrlStateStorage.set(STATE_STORAGE_KEY, initialState, { replace: true }); const stateContainer = createStateContainer( initialState, @@ -78,7 +84,7 @@ export const createDashboardAppState = ({ }; const { start: startStateSync, stop: stopStateSync } = syncState({ - storageKey: STATE_STORAGE_KEY, + storageKey: APP_STATE_STORAGE_KEY, stateContainer: { ...stateContainer, get: () => toUrlState(stateContainer.get()), @@ -112,7 +118,27 @@ export const createDashboardAppState = ({ stateStorage: osdUrlStateStorage, }); + // starts syncing `_g` portion of url with query services + // it is important to start this syncing after we set the time filter if timeRestore = true + // otherwise it will case redundant browser history records and browser navigation like going back will not work correctly + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + query, + osdUrlStateStorage + ); + + /* + make sure url ('_a') matches initial state + Initializing appState does two things - first it translates the defaults into AppState, + second it updates appState based on the url (the url trumps the defaults). This means if + we update the state format at all and want to handle BWC, we must not only migrate the + data stored with saved vis, but also any old state in the url. + */ + osdUrlStateStorage.set(APP_STATE_STORAGE_KEY, toUrlState(initialState), { replace: true }); + + // immediately forces scheduled updates and changes location + osdUrlStateStorage.flush({ replace: true }); + // start syncing the appState with the ('_a') url startStateSync(); - return { stateContainer, stopStateSync }; + return { stateContainer, stopStateSync, stopSyncingQueryServiceStateWithUrl }; }; diff --git a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx index 3e0573e5622e..9c7abab68682 100644 --- a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx +++ b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx @@ -99,7 +99,7 @@ export const getNavActions = ( stateContainer.transitions.set('title', newTitle); stateContainer.transitions.set('description', newDescription); stateContainer.transitions.set('timeRestore', newTimeRestore); - // dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; + savedDashboard.copyOnSave = newCopyOnSave; const saveOptions = { confirmOverwrite: false, @@ -114,8 +114,8 @@ export const getNavActions = ( stateContainer.transitions.set('timeRestore', currentTimeRestore); } - // If the save was successfull, then set the app state isDirty back to false - stateContainer.transitions.set('isDirty', false); + // If the save was successfull, then set the dashboard isDirty back to false + dashboard.isDirty = false; return response; }); }; @@ -283,7 +283,7 @@ export const getNavActions = ( sharingData: { title: savedDashboard.title, }, - isDirty: false, // TODO + isDirty: dashboard.isDirty, embedUrlParamExtensions: [ { paramName: 'embed', @@ -297,7 +297,7 @@ export const getNavActions = ( function onChangeViewMode(newMode: ViewMode) { const isPageRefresh = newMode === appState.viewMode; const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; - const willLoseChanges = isLeavingEditMode && stateContainer.getState().isDirty === true; + const willLoseChanges = isLeavingEditMode && dashboard.isDirty === true; // If there are no changes, do not show the discard window if (!willLoseChanges) { @@ -340,7 +340,7 @@ export const getNavActions = ( } // Set the isDirty flag back to false since we discard all the changes - stateContainer.transitions.set('isDirty', false); + dashboard.isDirty = false; } overlays diff --git a/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx b/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx index ecec53ef2152..1ea5357a95f6 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx +++ b/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx @@ -13,14 +13,14 @@ import { DashboardServices } from '../../../types'; import { DashboardAppStateContainer } from '../../../types'; import { migrateAppState, getAppStateDefaults } from '../../lib'; -import { createDashboardAppState } from '../create_dashboard_app_state'; +import { createDashboardGlobalAndAppState } from '../create_dashboard_app_state'; import { SavedObjectDashboard } from '../../../saved_dashboards'; /** - * This effect is responsible for instantiating the dashboard app state container, - * which is in sync with "_a" url param + * This effect is responsible for instantiating the dashboard app and global state container, + * which is in sync with "_a" and "_g" url param */ -export const useDashboardAppState = ( +export const useDashboardAppAndGlobalState = ( services: DashboardServices, eventEmitter: EventEmitter, instance?: SavedObjectDashboard @@ -37,7 +37,11 @@ export const useDashboardAppState = ( usageCollection ); - const { stateContainer, stopStateSync } = createDashboardAppState({ + const { + stateContainer, + stopStateSync, + stopSyncingQueryServiceStateWithUrl, + } = createDashboardGlobalAndAppState({ stateDefaults, osdUrlStateStorage: services.osdUrlStateStorage, services, @@ -80,6 +84,7 @@ export const useDashboardAppState = ( return () => { stopStateSync(); stopSyncingAppFilters(); + stopSyncingQueryServiceStateWithUrl(); }; } }, [eventEmitter, instance, services]); diff --git a/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx b/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx index b456a064c75a..df777460018a 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx @@ -364,7 +364,6 @@ const handleDashboardContainerChanges = ( dashboard: Dashboard ) => { let dirty = false; - let dirtyBecauseOfInitialStateMigration = false; const appStateData = appState.getState(); const savedDashboardPanelMap: { [key: string]: SavedDashboardPanel } = {}; const { opensearchDashboardsVersion } = dashboardServices; @@ -394,19 +393,14 @@ const handleDashboardContainerChanges = ( ) ) { // A panel was changed + // Do not need to care about initial migration here because the version update + // is already handled in migrateAppState() when we create state container dirty = true; - const oldVersion = savedDashboardPanelMap[panelState.explicitInput.id]?.version; - const newVersion = convertedPanelStateMap[panelState.explicitInput.id]?.version; - if (oldVersion && newVersion && oldVersion !== newVersion) { - dirtyBecauseOfInitialStateMigration = true; - } } }); if (dirty) { appState.transitions.set('panels', Object.values(convertedPanelStateMap)); - if (!dirtyBecauseOfInitialStateMigration) { - appState.transitions.set('isDirty', true); - } + dashboard.isDirty = true; } if (input.isFullScreenMode !== appStateData.fullScreenMode) { appState.transitions.set('fullScreenMode', input.isFullScreenMode); diff --git a/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts b/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts index b9a1d1ef75ce..bb3b4fe09b1a 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts +++ b/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts @@ -44,7 +44,7 @@ export const useEditorUpdates = ( dashboardContainer.updateInput(changes); if (changes.filters || changes.query || changes.timeRange || changes.refreshConfig) { - appState.transitions.set('isDirty', true); + dashboard.isDirty = true; } } } diff --git a/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts b/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts index 24c85687b806..d65b022c623f 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts +++ b/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts @@ -61,11 +61,9 @@ export const useSavedDashboardInstance = ( } } else if (dashboardIdFromUrl) { try { - savedDashboardInstance = await getDashboardInstance( - services, - dashboardIdFromUrl - ); + savedDashboardInstance = await getDashboardInstance(services, dashboardIdFromUrl); const { savedDashboard } = savedDashboardInstance; + // Update time filter to match the saved dashboard if time restore has been set to true when saving the dashboard // We should only set the time filter according to time restore once when we are loading the dashboard if (savedDashboard.timeRestore) { @@ -107,7 +105,7 @@ export const useSavedDashboardInstance = ( } else { // E.g. a corrupt or deleted dashboard notifications.toasts.addDanger(error.message); - history.push(DashboardConstants.LANDING_PAGE_PATH); + history.replace(DashboardConstants.LANDING_PAGE_PATH); return new Promise(() => {}); } } diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 0e09216ea206..efd571ca74fa 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -128,7 +128,6 @@ export interface DashboardAppState { viewMode: ViewMode; expandedPanelId?: string; savedQuery?: string; - isDirty: boolean; } export type DashboardAppStateDefaults = DashboardAppState & {