diff --git a/proto/rill/ui/v1/dashboard.proto b/proto/rill/ui/v1/dashboard.proto index b33634f954e..b86ad5f780f 100644 --- a/proto/rill/ui/v1/dashboard.proto +++ b/proto/rill/ui/v1/dashboard.proto @@ -89,6 +89,8 @@ message DashboardState { // Expanded measure for TDD view optional string expanded_measure = 18; + + optional int32 pin_index = 19; } message DashboardTimeRange { diff --git a/web-common/src/features/dashboards/actions/index.ts b/web-common/src/features/dashboards/actions/index.ts index 88f2e71c72a..8957e315bff 100644 --- a/web-common/src/features/dashboards/actions/index.ts +++ b/web-common/src/features/dashboards/actions/index.ts @@ -22,6 +22,7 @@ export function clearFilterForDimension( (dimensionValues) => dimensionValues.name === dimensionId ); } + dashboard.pinIndex = -1; }); } @@ -36,6 +37,7 @@ export function clearAllFilters(ctx: StateManagers) { dashboard.filters.include = []; dashboard.filters.exclude = []; dashboard.dimensionFilterExcludeMode.clear(); + dashboard.pinIndex = -1; }); } } @@ -60,18 +62,27 @@ export function toggleDimensionValue( ); if (dimensionEntryIndex >= 0) { - if ( - removeIfExists( - dashboard.filters[relevantFilterKey][dimensionEntryIndex].in, - (value) => value === dimensionValue - ) - ) { + const index = dashboard.filters[relevantFilterKey][ + dimensionEntryIndex + ].in?.findIndex((value) => value === dimensionValue) as number; + + if (index >= 0) { + dashboard.filters[relevantFilterKey][dimensionEntryIndex].in?.splice( + index, + 1 + ); if ( dashboard.filters[relevantFilterKey][dimensionEntryIndex].in .length === 0 ) { dashboard.filters[relevantFilterKey].splice(dimensionEntryIndex, 1); } + + // Only decrement pinIndex if the removed value was before the pinned value + if (dashboard.pinIndex >= index) { + dashboard.pinIndex--; + } + return; } diff --git a/web-common/src/features/dashboards/proto-state/fromProto.ts b/web-common/src/features/dashboards/proto-state/fromProto.ts index 5eef2878591..f1a82f270b7 100644 --- a/web-common/src/features/dashboards/proto-state/fromProto.ts +++ b/web-common/src/features/dashboards/proto-state/fromProto.ts @@ -101,7 +101,9 @@ export function getDashboardStateFromProto( } else { entity.selectedComparisonDimension = undefined; } - + if (dashboard.pinIndex !== undefined) { + entity.pinIndex = dashboard.pinIndex; + } if (dashboard.selectedTimezone) { entity.selectedTimezone = dashboard.selectedTimezone; } diff --git a/web-common/src/features/dashboards/proto-state/toProto.ts b/web-common/src/features/dashboards/proto-state/toProto.ts index 909b27abcb3..229cfd9f385 100644 --- a/web-common/src/features/dashboards/proto-state/toProto.ts +++ b/web-common/src/features/dashboards/proto-state/toProto.ts @@ -82,6 +82,9 @@ export function getProtoFromDashboardState( if (metrics.expandedMeasureName) { state.expandedMeasure = metrics.expandedMeasureName; } + if (metrics.pinIndex !== undefined) { + state.pinIndex = metrics.pinIndex; + } if (metrics.selectedDimensionName) { state.selectedDimension = metrics.selectedDimensionName; } diff --git a/web-common/src/features/dashboards/stores/dashboard-store-defaults.ts b/web-common/src/features/dashboards/stores/dashboard-store-defaults.ts index 5fc941f60f8..8dbdcbb251c 100644 --- a/web-common/src/features/dashboards/stores/dashboard-store-defaults.ts +++ b/web-common/src/features/dashboards/stores/dashboard-store-defaults.ts @@ -131,6 +131,7 @@ export function getDefaultMetricsExplorerEntity( showTimeComparison: false, dimensionSearchText: "", + pinIndex: -1, }; // set time range related stuff setDefaultTimeRange(metricsView, metricsExplorer, fullTimeRange); diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 6471b8cd4ca..a55c369bb56 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -222,6 +222,21 @@ const metricViewReducers = { setExpandedMeasureName(name: string, measureName: string) { updateMetricsExplorerByName(name, (metricsExplorer) => { metricsExplorer.expandedMeasureName = measureName; + + // If going into TDD view and already having a comparison dimension, + // then set the pinIndex + if (metricsExplorer.selectedComparisonDimension) { + metricsExplorer.pinIndex = getPinIndexForDimension( + metricsExplorer, + metricsExplorer.selectedComparisonDimension + ); + } + }); + }, + + setPinIndex(name: string, index: number) { + updateMetricsExplorerByName(name, (metricsExplorer) => { + metricsExplorer.pinIndex = index; }); }, @@ -293,6 +308,10 @@ const metricViewReducers = { setDisplayComparison(metricsExplorer, false); } metricsExplorer.selectedComparisonDimension = dimensionName; + metricsExplorer.pinIndex = getPinIndexForDimension( + metricsExplorer, + dimensionName + ); }); }, @@ -492,12 +511,21 @@ const metricViewReducers = { if (dimensionEntryIndex >= 0) { const filtersIn = filters[dimensionEntryIndex].in; - // if (filtersIn === undefined) return; - if (removeIfExists(filtersIn, (value) => value === dimensionValue)) { + + const index = filtersIn?.findIndex( + (value) => value === dimensionValue + ) as number; + if (index >= 0) { + filtersIn?.splice(index, 1); if (filtersIn.length === 0) { filters.splice(dimensionEntryIndex, 1); } + + // Only decrement pinIndex if the removed value was before the pinned value + if (metricsExplorer.pinIndex >= index) { + metricsExplorer.pinIndex--; + } return; } filtersIn.push(dimensionValue); @@ -515,6 +543,7 @@ const metricViewReducers = { metricsExplorer.filters.include = []; metricsExplorer.filters.exclude = []; metricsExplorer.dimensionFilterExcludeMode.clear(); + metricsExplorer.pinIndex = -1; }); }, @@ -653,3 +682,27 @@ function setSelectedScrubRange( metricsExplorer.selectedScrubRange = scrubRange; } + +function getPinIndexForDimension( + metricsExplorer: MetricsExplorerEntity, + dimensionName: string +) { + const relevantFilterKey = metricsExplorer.dimensionFilterExcludeMode.get( + dimensionName + ) + ? "exclude" + : "include"; + + const dimensionEntryIndex = metricsExplorer.filters[ + relevantFilterKey + ].findIndex((filter) => filter.name === dimensionName); + + if (dimensionEntryIndex >= 0) { + return ( + metricsExplorer.filters[relevantFilterKey][dimensionEntryIndex]?.in + ?.length - 1 + ); + } + + return -1; +} 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 5e36f46c8c1..db82cca3d63 100644 --- a/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts +++ b/web-common/src/features/dashboards/stores/metrics-explorer-entity.ts @@ -62,6 +62,13 @@ export interface MetricsExplorerEntity { */ expandedMeasureName?: string; + /** + * The index at which selected dimension values are pinned in the + * time detailed dimension view. Values above this index preserve + * their original order + */ + pinIndex: number; + /** * This is the sort type that will be used for the leaderboard * and dimension detail table. See SortType for more details. diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDIcons.ts b/web-common/src/features/dashboards/time-dimension-details/TDDIcons.ts index 21ce8d3e59e..7ce30294ddf 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDIcons.ts +++ b/web-common/src/features/dashboards/time-dimension-details/TDDIcons.ts @@ -58,3 +58,30 @@ export const ExcludeIcon = ` `; + +const PinIcon = ( + fill +) => ` + + + + + +`; + +export const PinSetIcon = PinIcon("#374151"); +export const PinSetHoverIcon = PinIcon("#9CA3AF"); + +export const PinUnsetIcon = ` + + + + + +`; + +export const PinHoverUnsetIcon = ` + + + +`; diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte index 695599c6406..9ac60d937eb 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte @@ -11,6 +11,10 @@ ExcludeIcon, MeasureArrow, PieChart, + PinSetIcon, + PinSetHoverIcon, + PinHoverUnsetIcon, + PinUnsetIcon, } from "@rilldata/web-common/features/dashboards/time-dimension-details/TDDIcons"; import type { TableData, TablePosition, TDDComparison } from "./types"; import { SortType } from "@rilldata/web-common/features/dashboards/proto-state/derived-types"; @@ -24,6 +28,7 @@ export let sortType: SortType; export let highlightedCol: number; export let scrubPos: { start: number; end: number }; + export let pinIndex: number; export let comparing: TDDComparison; export let tableData: TableData; @@ -36,6 +41,7 @@ let rowIdxHover: number | undefined; let colIdxHover: number | undefined; + let hoveringPin = false; function getBodyData(pos: PivotPos) { return tableData?.body @@ -54,6 +60,8 @@ const renderCell: PivotRenderCallback = (data) => { const classesToAdd = ["text-right"]; const classesToRemove = [ + "border-b", + "border-gray-200", "bg-white", "bg-gray-100", "bg-gray-200", @@ -65,12 +73,12 @@ "bg-slate-200", ]; - if (data.y === 2) { - if (comparing === "time") { - classesToAdd.push("border-b", "border-gray-200"); - } else { - classesToRemove.push("border-b", "border-gray-200"); - } + if (pinIndex > -1 && comparing === "dimension" && data.y === pinIndex + 1) { + classesToAdd.push("border-b", "border-gray-200"); + } + + if (comparing === "time" && data.y === 2) { + classesToAdd.push("border-b", "border-gray-200"); } const isScrubbed = @@ -104,14 +112,6 @@ return timeFormatter(data.value.value); }; - // Visible line list - const toggleVisible = (n) => { - n = parseInt(n); - if (comparing != "dimension" || n == 0) return; - const label = tableData?.rowHeaderData[n][0].value; - dispatch("toggle-filter", label); - }; - // Any time visible line list changes, redraw the table $: { scrubPos; @@ -120,11 +120,22 @@ pivot?.draw(); } + const getPinIcon = () => { + if (comparing === "dimension") { + if (tableData?.selectedValues.length === 0) return ""; + else if (pinIndex === tableData?.selectedValues.length - 1) + return hoveringPin ? PinHoverUnsetIcon : PinSetIcon; + else return hoveringPin ? PinSetHoverIcon : PinUnsetIcon; + } else { + return ""; + } + }; + let noSelectionMarkerCount = 0; const getMarker = (value, y) => { if (y === 0) { noSelectionMarkerCount = 0; - return { icon: ``, muted: false }; + return { icon: "", muted: false }; } const visibleIdx = tableData?.selectedValues.indexOf(value.value); @@ -165,12 +176,13 @@ }; const renderRowHeader: PivotRenderCallback = ({ value, x, y, element }) => { - if (y === 2) { - if (comparing === "time") { - element.classList.add("border-b", "border-gray-200"); - } else { - element.classList.remove("border-b", "border-gray-200"); - } + const showBorder = + (pinIndex > -1 && comparing === "dimension" && y === pinIndex + 1) || + (comparing === "time" && y === 2); + if (showBorder) { + element.classList.add("border-b", "border-gray-200"); + } else { + element.classList.remove("border-b", "border-gray-200"); } const cellBgColor = getClassForCell( @@ -195,15 +207,14 @@ element?.parentElement?.classList.remove("ui-copy-disabled-faint"); } - const justifyTotal = y === 0 ? "justify-end" : ""; const fontWeight = y === 0 ? "font-semibold" : "font-normal"; - return `
-
${marker.icon}
+ return `
+
${marker.icon}
${value.value}
`; } else if (x === 1) - return `
${value.value} + return `
+ ${value.value} ${value.spark} -
`; else return `
${value.value}
`; @@ -211,16 +222,22 @@ const renderRowCorner: PivotRenderCallback = (data) => { data.element.classList.add("bg-white", "z-10"); - if (data.x === 0) + if (data.x === 0) { + const pinIcon = getPinIcon(); return ` -
- ${dimensionLabel} +
+ ${pinIcon} +
+ ${dimensionLabel} ${ comparing === "dimension" && sortType === SortType.DIMENSION ? `${MeasureArrow(sortDirection)}` : `` } +
`; + } + if (data.x === 1) return `
${measureLabel} @@ -273,6 +290,18 @@ return [250, 130, 50][x]; }; + // Visible line list + const toggleVisible = (n) => { + n = parseInt(n); + if (comparing != "dimension" || n == 0) return; + const label = tableData?.rowHeaderData[n][0].value; + dispatch("toggle-filter", label); + }; + + const togglePin = () => { + dispatch("toggle-pin"); + }; + const handleEvent = (evt, table, attribute, callback) => { let currentNode = evt.target; @@ -290,17 +319,26 @@ const handleMouseDown = (evt, table) => { handleEvent(evt, table, "__row", toggleVisible); handleEvent(evt, table, "sort", (type) => dispatch("toggle-sort", type)); + handleEvent(evt, table, "pin", togglePin); }; const handleMouseHover = (evt, table) => { let newRowIdxHover; let newColIdxHover; + let newHoveringPin; if (evt.type === "mouseout") { newRowIdxHover = undefined; newColIdxHover = undefined; + newHoveringPin = false; } else { handleEvent(evt, table, "__row", (n) => (newRowIdxHover = parseInt(n))); handleEvent(evt, table, "__col", (n) => (newColIdxHover = parseInt(n))); + handleEvent(evt, table, "pin", () => (newHoveringPin = true)); + } + + if (hoveringPin !== newHoveringPin) { + hoveringPin = newHoveringPin; + pivot?.draw(); } if (newRowIdxHover !== rowIdxHover && newColIdxHover !== colIdxHover) { @@ -387,4 +425,8 @@ :global(regular-table table tbody tr:first-child, regular-table thead) { cursor: default; } + :global(.pin) { + cursor: pointer; + margin-top: 2px; + } diff --git a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte index 429803c7627..d46a72faa9e 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte @@ -100,6 +100,7 @@ cancelDashboardQueries(queryClient, metricViewName); metricsExplorerStore.toggleFilter(metricViewName, dimensionName, e.detail); } + function toggleAllSearchItems() { cancelDashboardQueries(queryClient, metricViewName); if (areAllTableRowsSelected) { @@ -117,6 +118,23 @@ ); } } + + function togglePin() { + cancelDashboardQueries(queryClient, metricViewName); + + const pinIndex = $dashboardStore?.pinIndex; + let newPinIndex = -1; + + // Pin if some selected items are not pinned yet + if (pinIndex > -1 && pinIndex < formattedData?.selectedValues?.length - 1) { + newPinIndex = formattedData?.selectedValues?.length - 1; + } + // Pin if no items are pinned yet + else if (pinIndex === -1) { + newPinIndex = formattedData?.selectedValues?.length - 1; + } + metricsExplorerStore.setPinIndex(metricViewName, newPinIndex); + } { cancelDashboardQueries(queryClient, metricViewName); diff --git a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts index f40cfc5de5f..6edbb80a1b4 100644 --- a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts +++ b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts @@ -36,6 +36,33 @@ export type TimeDimensionDataState = { export type TimeSeriesDataStore = Readable; +function getHeaderDataForRow( + row: DimensionDataItem, + isAllTime: boolean, + measureName: string, + formatter: (v: number | undefined | null) => string, + validPercentOfTotal: boolean, + unfilteredTotal: number +) { + const rowData = isAllTime ? row?.data?.slice(1) : row?.data?.slice(1, -1); + const dataRow = [ + { value: row?.value }, + { + value: formatter(row?.total), + spark: createSparkline(rowData, (v) => v[measureName]), + }, + ]; + if (validPercentOfTotal) { + const percOfTotal = (row?.total ?? 0) / unfilteredTotal; + dataRow.push({ + value: isNaN(percOfTotal) + ? "...%" + : numberPartsToString(formatProperFractionAsPercent(percOfTotal)), + }); + } + return dataRow; +} + /*** * Add totals row from time series data * Add rest of dimension values from dimension table data @@ -46,16 +73,19 @@ function prepareDimensionData( data: DimensionDataItem[], total: number, unfilteredTotal: number, - measure: MetricsViewSpecMeasureV2, + measure: MetricsViewSpecMeasureV2 | undefined, selectedValues: string[], - isAllTime: boolean + isAllTime: boolean, + pinIndex: number ): TableData { - if (!data || !totalsData) return; + if (!data || !totalsData || !measure || data?.length < selectedValues.length) + return; const formatter = safeFormatter(createMeasureValueFormatter(measure)); - const measureName = measure?.name; - const validPercentOfTotal = measure?.validPercentOfTotal; + const measureName = measure?.name as string; + const validPercentOfTotal = measure?.validPercentOfTotal as boolean; + // Prepare Columns const totalsTableData = isAllTime ? totalsData?.slice(1) : totalsData?.slice(1, -1); @@ -63,6 +93,31 @@ function prepareDimensionData( const columnCount = columnHeaderData?.length; + // Prepare Row order + let orderedData: DimensionDataItem[] = []; + + if (pinIndex > -1 && selectedValues.length) { + const selectedValuesIndex = selectedValues + .slice(0, pinIndex + 1) + .map((v) => data.findIndex((d) => d.value === v)) + .sort((a, b) => a - b); + + // return if computing on old data + if (selectedValuesIndex.some((v) => v === -1)) return; + + orderedData = orderedData.concat( + selectedValuesIndex?.map((i) => { + return data[i]; + }) + ); + + orderedData = orderedData.concat( + data?.filter((_, i) => !selectedValuesIndex.includes(i)) + ); + } else { + orderedData = data; + } + // Add totals row to count const rowCount = data?.length + 1; @@ -84,36 +139,26 @@ function prepareDimensionData( : numberPartsToString(formatProperFractionAsPercent(percOfTotal)), }); } - let rowHeaderData = [totalsRow]; rowHeaderData = rowHeaderData.concat( - data?.map((row) => { - const rowData = isAllTime ? row?.data?.slice(1) : row?.data?.slice(1, -1); - const dataRow = [ - { value: row?.value }, - { - value: formatter(row?.total), - spark: createSparkline(rowData, (v) => v[measureName]), - }, - ]; - if (validPercentOfTotal) { - const percOfTotal = row?.total / unfilteredTotal; - dataRow.push({ - value: isNaN(percOfTotal) - ? "...%" - : numberPartsToString(formatProperFractionAsPercent(percOfTotal)), - }); - } - return dataRow; + orderedData?.map((row) => { + return getHeaderDataForRow( + row, + isAllTime, + measureName, + formatter, + validPercentOfTotal, + unfilteredTotal + ); }) ); let body = [totalsTableData?.map((v) => formatter(v[measureName])) || []]; body = body?.concat( - data?.map((v) => { - if (v.isFetching) return new Array(columnCount).fill(undefined); + orderedData?.map((v) => { + if (v?.isFetching) return new Array(columnCount).fill(undefined); const dimData = isAllTime ? v?.data?.slice(1) : v?.data?.slice(1, -1); return dimData?.map((v) => formatter(v[measureName])); }) @@ -147,11 +192,11 @@ function prepareTimeData( comparisonTotal: number, currentLabel: string, comparisonLabel: string, - measure: MetricsViewSpecMeasureV2, + measure: MetricsViewSpecMeasureV2 | undefined, hasTimeComparison, isAllTime: boolean ): TableData { - if (!data) return; + if (!data || !measure) return; const formatter = safeFormatter(createMeasureValueFormatter(measure)); const measureName = measure?.name ?? ""; @@ -301,6 +346,7 @@ export function createTimeDimensionDataStore(ctx: StateManagers) { return; const measureName = dashboardStore?.expandedMeasureName; + const pinIndex = dashboardStore?.pinIndex; const dimensionName = dashboardStore?.selectedComparisonDimension; const total = timeSeries?.total && timeSeries?.total[measureName]; const unfilteredTotal = @@ -339,7 +385,8 @@ export function createTimeDimensionDataStore(ctx: StateManagers) { unfilteredTotal, measure, selectedValues, - isAllTime + isAllTime, + pinIndex ); } else { comparing = timeControls.showComparison ? "time" : "none"; diff --git a/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts b/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts index ed5b297f5df..25a65fad714 100644 --- a/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts +++ b/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts @@ -122,6 +122,11 @@ export class DashboardState extends Message { */ expandedMeasure?: string; + /** + * @generated from field: optional int32 pin_index = 19; + */ + pinIndex?: number; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -148,6 +153,7 @@ export class DashboardState extends Message { { no: 16, name: "leaderboard_sort_type", kind: "enum", T: proto3.getEnumType(DashboardState_LeaderboardSortType), opt: true }, { no: 17, name: "comparison_dimension", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 18, name: "expanded_measure", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 19, name: "pin_index", kind: "scalar", T: 5 /* ScalarType.INT32 */, opt: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): DashboardState { @@ -232,7 +238,7 @@ proto3.util.setEnumType(DashboardState_LeaderboardSortDirection, "rill.ui.v1.Das ]); /** - * + * * * SortType is used to determine how to sort the leaderboard * and dimension detail table, as well as where to place the * sort arrow.