diff --git a/web-common/src/features/dashboards/dashboard-utils.ts b/web-common/src/features/dashboards/dashboard-utils.ts index 9293d984305..3df04daad13 100644 --- a/web-common/src/features/dashboards/dashboard-utils.ts +++ b/web-common/src/features/dashboards/dashboard-utils.ts @@ -6,7 +6,7 @@ import type { } from "@rilldata/web-common/runtime-client"; import type { TimeControlState } from "./time-controls/time-control-store"; import { getQuerySortType } from "./leaderboard/leaderboard-utils"; -import type { DashboardState_LeaderboardSortType } from "@rilldata/web-common/proto/gen/rill/ui/v1/dashboard_pb"; +import { SortType } from "./proto-state/derived-types"; export function isSummableMeasure(measure: MetricsViewSpecMeasureV2): boolean { return ( @@ -31,10 +31,26 @@ export function prepareSortedQueryBody( measureNames: string[], timeControls: TimeControlState, sortMeasureName: string, - sortType: DashboardState_LeaderboardSortType, + sortType: SortType, sortAscending: boolean, filterForDimension: V1MetricsViewFilter ): QueryServiceMetricsViewComparisonToplistBody { + let comparisonTimeRange = { + start: timeControls.comparisonTimeStart, + end: timeControls.comparisonTimeEnd, + }; + + // FIXME: As a temporary way of enabling sorting by dimension values, + // Benjamin and Egor put in a patch that will allow us to use the + // dimension name as the measure name. This will need to be updated + // once they have stabilized the API. + if (sortType === SortType.DIMENSION) { + sortMeasureName = dimensionName; + // note also that we need to remove the comparison time range + // when sorting by dimension values, or the query errors + comparisonTimeRange = undefined; + } + const querySortType = getQuerySortType(sortType); return { @@ -44,10 +60,7 @@ export function prepareSortedQueryBody( start: timeControls.timeStart, end: timeControls.timeEnd, }, - comparisonTimeRange: { - start: timeControls.comparisonTimeStart, - end: timeControls.comparisonTimeEnd, - }, + comparisonTimeRange, sort: [ { ascending: sortAscending, diff --git a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte index 04ad59d75f1..eadf72b135a 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte @@ -21,13 +21,6 @@ } from "@rilldata/web-common/runtime-client"; import { useQueryClient } from "@tanstack/svelte-query"; import { runtime } from "../../../runtime-client/runtime-store"; - - import { SortDirection, SortType } from "../proto-state/derived-types"; - import { - metricsExplorerStore, - useDashboardStore, - } from "web-common/src/features/dashboards/stores/dashboard-stores"; - import { getDimensionFilterWithSearch, prepareDimensionTableRows, @@ -40,7 +33,7 @@ isSummableMeasure, prepareSortedQueryBody, } from "../dashboard-utils"; - import { LeaderboardContextColumn } from "../leaderboard-context-column"; + import { metricsExplorerStore } from "../stores/dashboard-stores"; export let metricViewName: string; export let dimensionName: string; @@ -62,10 +55,15 @@ let dimension: MetricsViewDimension; $: dimension = $dimensionQuery?.data; $: dimensionColumn = getDimensionColumn(dimension); + const stateManagers = getStateManagers(); + const timeControlsStore = useTimeControlStore(stateManagers); - $: dashboardStore = useDashboardStore(metricViewName); - - const timeControlsStore = useTimeControlStore(getStateManagers()); + const { + dashboardStore, + selectors: { + sorting: { sortedAscending }, + }, + } = stateManagers; $: leaderboardMeasureName = $dashboardStore?.leaderboardMeasureName; $: isBeingCompared = @@ -101,8 +99,6 @@ $dashboardStore?.visibleMeasureKeys.has(m.name) ); - $: sortAscending = $dashboardStore.sortDirection === SortDirection.ASCENDING; - $: totalsQuery = createQueryServiceMetricsViewTotals( instanceId, metricViewName, @@ -130,15 +126,12 @@ } $: columns = prepareVirtualizedDimTableColumns( + $dashboardStore, allMeasures, - leaderboardMeasureName, referenceValues, dimension, - [...$dashboardStore.visibleMeasureKeys], $timeControlsStore.showComparison, - validPercentOfTotal, - $dashboardStore.dashboardSortType, - $dashboardStore.sortDirection + validPercentOfTotal ); $: sortedQueryBody = prepareSortedQueryBody( @@ -147,7 +140,7 @@ $timeControlsStore, leaderboardMeasureName, $dashboardStore.dashboardSortType, - sortAscending, + $sortedAscending, filterSet ); @@ -178,39 +171,6 @@ metricsExplorerStore.toggleFilter(metricViewName, dimensionName, label); } - function onSortByColumn(event) { - const columnName = event.detail; - - if (columnName === leaderboardMeasureName + "_delta") { - metricsExplorerStore.toggleSort(metricViewName, SortType.DELTA_ABSOLUTE); - metricsExplorerStore.setContextColumn( - metricViewName, - LeaderboardContextColumn.DELTA_ABSOLUTE - ); - } else if (columnName === leaderboardMeasureName + "_delta_perc") { - metricsExplorerStore.toggleSort(metricViewName, SortType.DELTA_PERCENT); - metricsExplorerStore.setContextColumn( - metricViewName, - LeaderboardContextColumn.DELTA_PERCENT - ); - } else if (columnName === leaderboardMeasureName + "_percent_of_total") { - metricsExplorerStore.toggleSort(metricViewName, SortType.PERCENT); - metricsExplorerStore.setContextColumn( - metricViewName, - LeaderboardContextColumn.PERCENT - ); - } else if (columnName === leaderboardMeasureName) { - metricsExplorerStore.toggleSort(metricViewName, SortType.VALUE); - } else { - metricsExplorerStore.setLeaderboardMeasureName( - metricViewName, - columnName - ); - metricsExplorerStore.toggleSort(metricViewName, SortType.VALUE); - metricsExplorerStore.setSortDescending(metricViewName); - } - } - function toggleComparisonDimension(dimensionName, isBeingCompared) { metricsExplorerStore.setComparisonDimension( metricViewName, @@ -237,15 +197,14 @@
onSelectItem(event)} - on:sort={(event) => onSortByColumn(event)} on:toggle-dimension-comparison={() => toggleComparisonDimension(dimensionName, isBeingCompared)} + isFetching={$sortedQuery?.isFetching} dimensionName={dimensionColumn} {isBeingCompared} {columns} {selectedValues} rows={tableRows} - sortByColumn={leaderboardMeasureName} {excludeMode} />
diff --git a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte index f6e57ed06b1..e588be5ef62 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte @@ -18,12 +18,24 @@ import Spinner from "../../entity-management/Spinner.svelte"; import { metricsExplorerStore } from "web-common/src/features/dashboards/stores/dashboard-stores"; import ExportDimensionTableDataButton from "./ExportDimensionTableDataButton.svelte"; + import { getStateManagers } from "../state-managers/state-managers"; + import { SortType } from "../proto-state/derived-types"; export let metricViewName: string; export let dimensionName: string; export let isFetching: boolean; export let excludeMode = false; + const stateManagers = getStateManagers(); + const { + selectors: { + sorting: { sortedByDimensionValue }, + }, + actions: { + sorting: { toggleSort }, + }, + } = stateManagers; + const queryClient = useQueryClient(); $: filterKey = excludeMode ? "exclude" : "include"; @@ -46,6 +58,9 @@ const goBackToLeaderboard = () => { metricsExplorerStore.setMetricDimensionName(metricViewName, null); + if ($sortedByDimensionValue) { + toggleSort(SortType.VALUE); + } }; function toggleFilterMode() { cancelDashboardQueries(queryClient, metricViewName); diff --git a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte index 2056abf0ee2..abb1eadf627 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte @@ -18,16 +18,25 @@ TableCells – the cell contents. } from "./dimension-table-utils"; import type { DimensionTableRow } from "./dimension-table-types"; + import { getStateManagers } from "../state-managers/state-managers"; + const dispatch = createEventDispatcher(); export let rows: DimensionTableRow[]; export let columns: VirtualizedTableColumns[]; export let selectedValues: Array = []; - export let sortByColumn: string; export let dimensionName: string; export let excludeMode = false; export let isBeingCompared = false; + export let isFetching: boolean; + + const { + actions: { dimTable }, + selectors: { + sorting: { sortMeasure }, + }, + } = getStateManagers(); /** the overscan values tell us how much to render off-screen. These may be set by the consumer * in certain circumstances. The tradeoff: the higher the overscan amount, the more DOM elements we have @@ -169,7 +178,8 @@ TableCells – the cell contents. async function handleColumnHeaderClick(event) { colScrollOffset = $columnVirtualizer.scrollOffset; - dispatch("sort", event.detail); + const columnName = event.detail; + dimTable.handleMeasureColumnHeaderClick(columnName); } async function handleResizeDimensionColumn(event) { @@ -216,7 +226,7 @@ TableCells – the cell contents. @@ -245,6 +255,7 @@ TableCells – the cell contents. {excludeMode} {scrolling} {horizontalScrolling} + on:dimension-sort on:select-item={(event) => onSelectItem(event)} on:inspect={setActiveIndex} /> @@ -264,6 +275,10 @@ TableCells – the cell contents. on:inspect={setActiveIndex} cellLabel="Filter dimension value" /> + {:else if isFetching} +
+ Loading... +
{:else}
No results to show diff --git a/web-common/src/features/dashboards/dimension-table/DimensionValueHeader.svelte b/web-common/src/features/dashboards/dimension-table/DimensionValueHeader.svelte index 2aba90342b6..cec14c971a1 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionValueHeader.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionValueHeader.svelte @@ -4,6 +4,9 @@ import { createEventDispatcher, getContext } from "svelte"; import Cell from "../../../components/virtualized-table/core/Cell.svelte"; import type { VirtualizedTableConfig } from "../../../components/virtualized-table/types"; + import ArrowDown from "@rilldata/web-common/components/icons/ArrowDown.svelte"; + import { fly } from "svelte/transition"; + import { getStateManagers } from "../state-managers/state-managers"; import type { ResizeEvent } from "@rilldata/web-common/components/virtualized-table/drag-table-cell"; const config: VirtualizedTableConfig = getContext("config"); @@ -21,6 +24,14 @@ export let activeIndex; export let excludeMode = false; + const { + actions: { + sorting: { sortByDimensionValue }, + }, + selectors: { + sorting: { sortedByDimensionValue, sortedAscending }, + }, + } = getStateManagers(); const dispatch = createEventDispatcher(); $: atLeastOneSelected = !!selectedIndex?.length; @@ -54,10 +65,29 @@ enableResize={true} position="top-left" borderRight={horizontalScrolling} - bgClass="bg-white" + bgClass={$sortedByDimensionValue ? `bg-gray-50` : "bg-white"} + on:click={sortByDimensionValue} + on:keydown={sortByDimensionValue} on:resize={handleResize} > - {column?.label || column?.name} +
+ {column?.label || column?.name} + {#if $sortedByDimensionValue} +
+ {#if $sortedAscending} +
+ +
+ {:else} +
+ +
+ {/if} +
+ {/if} +
{#each virtualRowItems as row (`row-${row.key}`)} {@const rowActive = activeIndex === row?.index} @@ -66,7 +96,7 @@ position="left" header={{ size: width, start: row.start }} borderRight={horizontalScrolling} - bgClass="bg-white" + bgClass={$sortedByDimensionValue ? `bg-gray-50` : "bg-white"} > m.name); + const leaderboardMeasureName = dash.leaderboardMeasureName; const selectedMeasure = allMeasures.find( (m) => m.name === leaderboardMeasureName ); const dimensionColumn = getDimensionColumn(dimension); // copy column names so we don't mutate the original - const columnNames = [...inputColumnNames]; - - addContextColumnNames( - columnNames, - timeComparison, - validPercentOfTotal, - selectedMeasure - ); + const columnNames = [...dash.visibleMeasureKeys]; + + // don't add context columns if sorting by dimension + if (sortType !== SortType.DIMENSION) { + addContextColumnNames( + columnNames, + timeComparison, + validPercentOfTotal, + selectedMeasure + ); + } // Make dimension the first column columnNames.unshift(dimensionColumn); return columnNames .map((name) => { - const highlight = - name === selectedMeasure.name || - name.endsWith("_delta") || - name.endsWith("_delta_perc") || - name.endsWith("_percent_of_total"); + let highlight = false; + if (sortType === SortType.DIMENSION) { + highlight = name === dimensionColumn; + } else { + highlight = + name === selectedMeasure.name || + name.endsWith("_delta") || + name.endsWith("_delta_perc") || + name.endsWith("_percent_of_total"); + } let sorted = undefined; if (name.endsWith("_delta") && sortType === SortType.DELTA_ABSOLUTE) { diff --git a/web-common/src/features/dashboards/state-managers/actions/context-columns.ts b/web-common/src/features/dashboards/state-managers/actions/context-columns.ts new file mode 100644 index 00000000000..2686f9d63fc --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/context-columns.ts @@ -0,0 +1,42 @@ +import { LeaderboardContextColumn } from "../../leaderboard-context-column"; +import { sortTypeForContextColumnType } from "../../stores/dashboard-stores"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +export const setContextColumn = ( + metricsExplorer: MetricsExplorerEntity, + contextColumn: LeaderboardContextColumn +) => { + const initialSort = sortTypeForContextColumnType( + metricsExplorer.leaderboardContextColumn + ); + switch (contextColumn) { + case LeaderboardContextColumn.DELTA_ABSOLUTE: + case LeaderboardContextColumn.DELTA_PERCENT: { + // if there is no time comparison, then we can't show + // these context columns, so return with no change + if (metricsExplorer.showTimeComparison === false) return; + + metricsExplorer.leaderboardContextColumn = contextColumn; + break; + } + default: + metricsExplorer.leaderboardContextColumn = contextColumn; + } + + // if we have changed the context column, and the leaderboard is + // sorted by the context column from before we made the change, + // then we also need to change + // the sort type to match the new context column + if (metricsExplorer.dashboardSortType === initialSort) { + metricsExplorer.dashboardSortType = + sortTypeForContextColumnType(contextColumn); + } +}; + +export const contextColActions = { + /** + * Updates the dashboard to use the context column of the given type, + * as well as updating to sort by that context column. + */ + setContextColumn, +}; diff --git a/web-common/src/features/dashboards/state-managers/actions/core-actions.ts b/web-common/src/features/dashboards/state-managers/actions/core-actions.ts new file mode 100644 index 00000000000..8597c600a93 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/core-actions.ts @@ -0,0 +1,8 @@ +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +export const setLeaderboardMeasureName = ( + dash: MetricsExplorerEntity, + name: string +) => { + dash.leaderboardMeasureName = name; +}; diff --git a/web-common/src/features/dashboards/state-managers/actions/dimension-table.ts b/web-common/src/features/dashboards/state-managers/actions/dimension-table.ts new file mode 100644 index 00000000000..67be5ab31a0 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/dimension-table.ts @@ -0,0 +1,34 @@ +import { SortType } from "../../proto-state/derived-types"; +import { toggleSort, sortActions } from "./sorting"; +import { LeaderboardContextColumn } from "../../leaderboard-context-column"; +import { setContextColumn } from "./context-columns"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; +import { setLeaderboardMeasureName } from "./core-actions"; + +export const handleMeasureColumnHeaderClick = ( + dash: MetricsExplorerEntity, + measureName: string +) => { + const { leaderboardMeasureName: name } = dash; + + if (measureName === name + "_delta") { + toggleSort(dash, SortType.DELTA_ABSOLUTE); + setContextColumn(dash, LeaderboardContextColumn.DELTA_ABSOLUTE); + } else if (measureName === name + "_delta_perc") { + toggleSort(dash, SortType.DELTA_PERCENT); + setContextColumn(dash, LeaderboardContextColumn.DELTA_PERCENT); + } else if (measureName === name + "_percent_of_total") { + toggleSort(dash, SortType.PERCENT); + setContextColumn(dash, LeaderboardContextColumn.PERCENT); + } else if (measureName === name) { + toggleSort(dash, SortType.VALUE); + } else { + setLeaderboardMeasureName(dash, measureName); + toggleSort(dash, SortType.VALUE); + sortActions.setSortDescending(dash); + } +}; + +export const dimTableActions = { + handleMeasureColumnHeaderClick, +}; diff --git a/web-common/src/features/dashboards/state-managers/actions/index.ts b/web-common/src/features/dashboards/state-managers/actions/index.ts new file mode 100644 index 00000000000..f8047b4f2cb --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/index.ts @@ -0,0 +1,69 @@ +import { sortActions } from "./sorting"; +import { contextColActions } from "./context-columns"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; +import { setLeaderboardMeasureName } from "./core-actions"; +import { dimTableActions } from "./dimension-table"; +import type { + DashboardCallbackExecutor, + DashboardMutatorFn, + DashboardMutatorFns, + DashboardUpdaters, +} from "./types"; + +export type StateManagerActions = ReturnType; + +export const createStateManagerActions = ( + updateDashboard: DashboardCallbackExecutor +) => { + return { + /** + * Actions related to the sorting state of the dashboard. + */ + sorting: createDashboardUpdaters(updateDashboard, sortActions), + /** + * Actions related to the dashboard context columns. + */ + contextCol: createDashboardUpdaters(updateDashboard, contextColActions), + /** + * Actions related to the dimension table. + */ + dimTable: createDashboardUpdaters(updateDashboard, dimTableActions), + // Note: for now, some core actions are kept in the root of the + // actions object. Can revisit that later if we want to move them. + setLeaderboardMeasureName: dashboardMutatorToUpdater( + updateDashboard, + setLeaderboardMeasureName + ), + }; +}; + +/** + * `dashboardMutatorToUpdater` take a DashboardCallbackExecutor + * and returns a DashboardMutatorFn that directly updates the dashboard + * by calling the DashboardCallbackExecutor. + **/ +function dashboardMutatorToUpdater( + updateDashboard: DashboardCallbackExecutor, + mutator: DashboardMutatorFn +): (...params: T) => void { + return (...x) => { + const callback = (dash: MetricsExplorerEntity) => mutator(dash, ...x); + updateDashboard(callback); + }; +} + +/** + * Takes an object containing `DashboardMutatorFn`s, + * and returns an object of functions that directly update the dashboard. + */ +function createDashboardUpdaters( + updateDashboard: DashboardCallbackExecutor, + mutators: T +): DashboardUpdaters { + return Object.fromEntries( + Object.entries(mutators).map(([key, mutator]) => [ + key, + dashboardMutatorToUpdater(updateDashboard, mutator), + ]) + ) as DashboardUpdaters; +} diff --git a/web-common/src/features/dashboards/state-managers/actions/sorting.ts b/web-common/src/features/dashboards/state-managers/actions/sorting.ts new file mode 100644 index 00000000000..78f1f3d3238 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/sorting.ts @@ -0,0 +1,46 @@ +import { SortDirection, SortType } from "../../proto-state/derived-types"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +export const toggleSort = ( + metricsExplorer: MetricsExplorerEntity, + sortType: SortType +) => { + // if sortType is not provided, or if it is provided + // and is the same as the current sort type, + // then just toggle the current sort direction + if ( + sortType === undefined || + metricsExplorer.dashboardSortType === sortType + ) { + metricsExplorer.sortDirection = + metricsExplorer.sortDirection === SortDirection.ASCENDING + ? SortDirection.DESCENDING + : SortDirection.ASCENDING; + } else { + // if the sortType is different from the current sort type, + // then update the sort type and set the sort direction + // to descending + metricsExplorer.dashboardSortType = sortType; + metricsExplorer.sortDirection = SortDirection.DESCENDING; + } +}; + +export const sortActions = { + /** + * Sets the sort type for the dashboard (value, percent, delta, etc.) + */ + toggleSort, + /** + * Sets the dashboard to be sorted by dimension value. + * Note that this should only be used in the dimension table + */ + sortByDimensionValue: (metricsExplorer: MetricsExplorerEntity) => + toggleSort(metricsExplorer, SortType.DIMENSION), + + /** + * Sets the sort direction to descending. + */ + setSortDescending: (metricsExplorer: MetricsExplorerEntity) => { + metricsExplorer.sortDirection = SortDirection.DESCENDING; + }, +}; diff --git a/web-common/src/features/dashboards/state-managers/actions/types.ts b/web-common/src/features/dashboards/state-managers/actions/types.ts new file mode 100644 index 00000000000..7b01e56eecc --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/actions/types.ts @@ -0,0 +1,57 @@ +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +// Note: the types below are helper types to simplify the type inference +// used in the creation of StateManagerActions, so that we can have nice +// autocomplete and type checking in the IDE, while still keeping the +// code that is used to define actions organized and readable. + +/** + * A DashboardMutatorCallback is a function that mutates + * a MetricsExplorerEntity, i.e., the data single dashboard. + * This will often be a closure over other parameters + * that are relevant to the mutation. + */ +export type DashboardMutatorCallback = ( + metricsExplorer: MetricsExplorerEntity +) => void; + +/** + * DashboardCallbackExecutor is a function that takes a + * DashboardMutatorCallback and executes it. The + * DashboardCallbackExecutor is a closure containing a reference + * to the live dashboard, and therefore calling this function + * on a DashboardMutatorCallback will actually update the dashboard. + */ +export type DashboardCallbackExecutor = ( + callback: DashboardMutatorCallback +) => void; + +/** + * A DashboardMutatorFn is a function mutates the data + * model of a single dashboard. + * It takes a reference to a dashboard as its first parameter, + * and may take any number of additional parameters relevant to the mutation. + */ +export type DashboardMutatorFn = ( + dash: MetricsExplorerEntity, + ...params: T +) => void; + +export type DashboardMutatorFns = { + [key: string]: DashboardMutatorFn; +}; + +/** + * A helper type that drops the first element from a tuple. + */ +type DropFirst = T extends [unknown, ...infer U] + ? U + : never; + +/** + * A DashboardUpdaters object is a collection of functions that + * directly update the live dashboard. + */ +export type DashboardUpdaters = Expand<{ + [P in keyof T]: (...params: DropFirst>) => void; +}>; diff --git a/web-common/src/features/dashboards/state-managers/selectors/index.ts b/web-common/src/features/dashboards/state-managers/selectors/index.ts new file mode 100644 index 00000000000..6b832e6f24c --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/selectors/index.ts @@ -0,0 +1,31 @@ +import { sortingSelectors } from "./sorting"; +import { derived, type Readable } from "svelte/store"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; +import type { ReadablesObj, SelectorFnsObj } from "./types"; + +export type StateManagerReadables = ReturnType< + typeof createStateManagerReadables +>; + +export const createStateManagerReadables = ( + dashboardStore: Readable +) => { + return { + /** + * Readables related to the sorting state of the dashboard. + */ + sorting: createReadablesFromSelectors(sortingSelectors, dashboardStore), + }; +}; + +function createReadablesFromSelectors( + selectors: T, + dashboardStore: Readable +): ReadablesObj { + return Object.fromEntries( + Object.entries(selectors).map(([key, selectorFn]) => [ + key, + derived(dashboardStore, selectorFn), + ]) + ) as ReadablesObj; +} diff --git a/web-common/src/features/dashboards/state-managers/selectors/sorting.ts b/web-common/src/features/dashboards/state-managers/selectors/sorting.ts new file mode 100644 index 00000000000..ed4b47ac4f0 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/selectors/sorting.ts @@ -0,0 +1,31 @@ +import { SortDirection, SortType } from "../../proto-state/derived-types"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +export const sortingSelectors = { + /** + * Gets the sort type for the dash (value, percent, delta, etc.) + */ + sortType: (dashboard: MetricsExplorerEntity) => dashboard.dashboardSortType, + + /** + * true if the dashboard is sorted ascending, false otherwise. + */ + sortedAscending: (dashboard: MetricsExplorerEntity) => + dashboard.sortDirection === SortDirection.ASCENDING, + + /** + * Returns the measure name that the dashboard is sorted by, + * or null if the dashboard is sorted by dimension value. + */ + sortMeasure: (dashboard: MetricsExplorerEntity) => + dashboard.dashboardSortType !== SortType.DIMENSION && + dashboard.dashboardSortType !== SortType.UNSPECIFIED + ? dashboard.leaderboardMeasureName + : null, + + /** + * Returns true if the dashboard is sorted by a dimension, false otherwise. + */ + sortedByDimensionValue: (dashboard: MetricsExplorerEntity) => + dashboard.dashboardSortType === SortType.DIMENSION, +}; diff --git a/web-common/src/features/dashboards/state-managers/selectors/types.ts b/web-common/src/features/dashboards/state-managers/selectors/types.ts new file mode 100644 index 00000000000..455fc07d998 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/selectors/types.ts @@ -0,0 +1,24 @@ +import type { Readable } from "svelte/store"; +import type { MetricsExplorerEntity } from "../../stores/metrics-explorer-entity"; + +/** + * A SelectorFn is a pure function that takes dashboard data + * (a MetricsExplorerEntity) and returns some derived value from it. + */ +export type SelectorFn = (dashboard: MetricsExplorerEntity) => T; + +/** + * A SelectorFnsObj object is a collection of pure SelectorFn functions. + */ +export type SelectorFnsObj = { + [key: string]: SelectorFn; +}; + +/** + * A ReadablesObj object is a collection readables that are connected + * to the live dashboard store and can be + * used to select data from the dashboard. + */ +export type ReadablesObj = Expand<{ + [P in keyof T]: Readable>; +}>; diff --git a/web-common/src/features/dashboards/state-managers/state-managers.ts b/web-common/src/features/dashboards/state-managers/state-managers.ts index 5a4353e821b..1bbee34e9cb 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -10,6 +10,12 @@ import { useDashboardStore, } from "web-common/src/features/dashboards/stores/dashboard-stores"; import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; +import { + StateManagerReadables, + createStateManagerReadables, +} from "./selectors"; +import { createStateManagerActions, type StateManagerActions } from "./actions"; +import type { DashboardCallbackExecutor } from "./actions/types"; export type StateManagers = { runtime: Writable; @@ -18,9 +24,15 @@ export type StateManagers = { dashboardStore: Readable; queryClient: QueryClient; setMetricsViewName: (s: string) => void; - updateDashboard: ( - callback: (metricsExplorer: MetricsExplorerEntity) => void - ) => void; + updateDashboard: DashboardCallbackExecutor; + /** + * A collection of Readables that can be used to select data from the dashboard. + */ + selectors: StateManagerReadables; + /** + * A collection of functions that update the dashboard data model. + */ + actions: StateManagerActions; }; export const DEFAULT_STORE_KEY = Symbol("state-managers"); @@ -63,6 +75,8 @@ export function createStateManagers({ metricsViewNameStore.set(name); }, updateDashboard, + selectors: createStateManagerReadables(dashboardStore), + actions: createStateManagerActions(updateDashboard), }; } diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 65a92fe8cbf..905bb16f994 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -550,7 +550,7 @@ function setDisplayComparison( } } -function sortTypeForContextColumnType( +export function sortTypeForContextColumnType( contextCol: LeaderboardContextColumn ): SortType { const sortType = { diff --git a/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts b/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts index 99a51c0d7e0..24cb0dfed00 100644 --- a/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts +++ b/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts @@ -11,72 +11,106 @@ import type { V1MetricsViewFilter } from "@rilldata/web-common/runtime-client"; export interface MetricsExplorerEntity { name: string; - // selected measure names to be shown + /** + * selected measure names to be shown + */ selectedMeasureNames: Array; - // This array controls which measures are visible in - // explorer on the client. Note that this will need to be - // updated to include all measure keys upon initialization - // or else all measure will be hidden + /** + * This array controls which measures are visible in + * explorer on the client. Note that this will need to be + * updated to include all measure keys upon initialization + * or else all measure will be hidden + */ visibleMeasureKeys: Set; - // While the `visibleMeasureKeys` has the list of visible measures, - // this is explicitly needed to fill the state. - // TODO: clean this up when we refactor how url state is synced + + /** + * While the `visibleMeasureKeys` has the list of visible measures, + * this is explicitly needed to fill the state. + * TODO: clean this up when we refactor how url state is synced + */ allMeasuresVisible: boolean; - // This array controls which dimensions are visible in - // explorer on the client.Note that if this is null, all - // dimensions will be visible (this is needed to default to all visible - // when there are not existing keys in the URL or saved on the - // server) + /** + * This array controls which dimensions are visible in + * explorer on the client.Note that if this is null, all + * dimensions will be visible (this is needed to default to all visible + * when there are not existing keys in the URL or saved on the + * server) + */ visibleDimensionKeys: Set; - // While the `visibleDimensionKeys` has the list of all visible dimensions, - // this is explicitly needed to fill the state. - // TODO: clean this up when we refactor how url state is synced + + /** + * While the `visibleDimensionKeys` has the list of all visible dimensions, + * this is explicitly needed to fill the state. + * TODO: clean this up when we refactor how url state is synced + */ allDimensionsVisible: boolean; - // This is the name of the primary active measure in the dashboard. - // This is the measure that will be shown in leaderboards, and - // will be used for sorting the leaderboard and dimension - // detail table. - // This "name" is the internal name of the measure from the YAML, - // not the human readable name. + /** + * This is the name of the primary active measure in the dashboard. + * This is the measure that will be shown in leaderboards, and + * will be used for sorting the leaderboard and dimension detail table. + * This "name" is the internal name of the measure from the YAML, + * not the human readable name. + */ leaderboardMeasureName: string; - // This is the sort type that will be used for the leaderboard - // and dimension detail table. See SortType for more details. + /** + * This is the sort type that will be used for the leaderboard + * and dimension detail table. See SortType for more details. + */ dashboardSortType: SortType; - // This is the sort direction that will be used for the leaderboard - // and dimension detail table. + + /** + * This is the sort direction that will be used for the leaderboard + * and dimension detail table. + */ sortDirection: SortDirection; filters: V1MetricsViewFilter; - // stores whether a dimension is in include/exclude filter mode - // false/absence = include, true = exclude + + /** + * stores whether a dimension is in include/exclude filter mode + * false/absence = include, true = exclude + */ dimensionFilterExcludeMode: Map; - // user selected time range + + /** + * user selected time range + */ selectedTimeRange?: DashboardTimeControls; - // user selected scrub range + /** + * user selected scrub range + */ selectedScrubRange?: ScrubRange; lastDefinedScrubRange?: ScrubRange; selectedComparisonTimeRange?: DashboardTimeControls; selectedComparisonDimension?: string; - // user selected timezone + /** + * user selected timezone + */ selectedTimezone?: string; - // flag to show/hide time comparison based on user preference. - // This controls whether a time comparison is shown in e.g. - // the line charts and bignums. - // It does NOT affect the leaderboard context column. + /** + * flag to show/hide time comparison based on user preference. + * This controls whether a time comparison is shown in e.g. + * the line charts and bignums. + * It does NOT affect the leaderboard context column. + */ showTimeComparison?: boolean; - // state of context column in the leaderboard + /** + * state of context column in the leaderboard + */ leaderboardContextColumn: LeaderboardContextColumn; - // user selected dimension + /** + * user selected dimension + */ selectedDimensionName?: string; proto?: string;