diff --git a/web-admin/src/features/dashboards/listing/DashboardsTable.svelte b/web-admin/src/features/dashboards/listing/DashboardsTable.svelte index 2b486833a596..3ce86e6945c1 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTable.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTable.svelte @@ -10,20 +10,28 @@ import { renderComponent } from "tanstack-table-8-svelte-5"; import DashboardsTableCompositeCell from "./DashboardsTableCompositeCell.svelte"; import { useDashboards, useIsInitialBuild } from "./selectors"; - import { Search } from "@rilldata/web-common/components/search"; import { UrlParamsState } from "web-common/src/lib/store-utils/url-params-state.svelte.ts"; import { getAllTagsForResources } from "@rilldata/web-common/features/resources/resource-tag-utils.ts"; import ResizableSidebar from "@rilldata/web-common/layout/ResizableSidebar.svelte"; import DashboardsTagSidebar from "@rilldata/web-admin/features/dashboards/listing/DashboardsTagSidebar.svelte"; import { filterResources } from "@rilldata/web-common/features/resources/resource-filter-utils.ts"; + import { + DashboardTableSortOptions, + getDashboardFavouritesStore, + RecentlyUsedDashboards, + } from "./dashboard-favourites.ts"; import { DebouncedRuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import { escapeHtml } from "@rilldata/web-common/lib/i18n"; + import { TableToolbar } from "@rilldata/web-common/components/table-toolbar"; + import { dedupe } from "@rilldata/web-common/lib/arrayUtils.ts"; + + type DashboardRow = V1Resource & { lastUsed: number }; let { isEmbedded = false, isPreview = false, - previewLimit = 5, + previewLimit = undefined, }: { isEmbedded?: boolean; isPreview?: boolean; @@ -37,6 +45,16 @@ 500, ); + const sortStore = UrlParamsState.createStringParam( + "sort", + DashboardTableSortOptions[0].value, + ); + let sortingOption = $derived( + DashboardTableSortOptions.find( + (option) => option.value === sortStore.value, + ), + ); + const runtimeClient = useRuntimeClient(); let { organization, project } = $derived(page.params); @@ -66,14 +84,38 @@ ), ); - let displayData = $derived( - isPreview ? filteredDashboards.slice(0, previewLimit) : filteredDashboards, + let dashboardFavourites = $derived( + getDashboardFavouritesStore(organization, project), + ); + let recentlyUsedDashboards = $derived( + new RecentlyUsedDashboards(organization, project), + ); + + let validDashboardFavourites = $derived( + dedupe( + dashboardFavourites.value.filter((f) => + filteredDashboards.find((r) => r.meta?.name?.name?.toLowerCase() === f), + ), + (e) => e, + ), ); let hasMoreDashboards = $derived( isPreview && filteredDashboards.length > previewLimit, ); + let displayData = $derived( + filteredDashboards.map( + (r): DashboardRow => ({ + ...r, + lastUsed: + recentlyUsedDashboards.recentlyUsed.value[ + r.meta?.name?.name?.toLowerCase() ?? "" + ] ?? 0, + }), + ), + ); + const columns = [ { id: "composite", @@ -103,6 +145,8 @@ organization, project, tags, + dashboardFavourites, + recentlyUsedDashboards, }); }, }, @@ -135,6 +179,10 @@ return isMetricsExplorer ? row.explore.spec.description : ""; }, }, + { + id: "lastUsed", + accessorFn: (row: DashboardRow) => row.lastUsed, + }, ]; const columnVisibility = { @@ -142,9 +190,8 @@ name: false, lastRefreshed: false, description: false, + lastUsed: false, }; - - const initialSorting = [{ id: "name", desc: false }]; {#if isLoading || isBuilding} @@ -156,16 +203,11 @@ {:else if isSuccess}
{#if !isPreview} -
- -
+ {/if}
@@ -191,8 +233,10 @@ data={displayData} {columns} {columnVisibility} - {initialSorting} + sorting={[sortingOption.sort]} toolbar={false} + pinnedRows={validDashboardFavourites} + maxRows={previewLimit} > | undefined = + undefined; + export let recentlyUsedDashboards: RecentlyUsedDashboards | undefined = + undefined; $: lastRefreshedDate = lastRefreshed ? new Date(lastRefreshed) : null; @@ -28,9 +35,40 @@ $: resourceKind = isMetricsExplorer ? ResourceKind.Explore : ResourceKind.Canvas; + + $: favourites = dashboardFavourites?.value ?? []; + $: isFavourite = favourites.includes(name?.toLowerCase()); + + $: lastUsed = + recentlyUsedDashboards?.recentlyUsed?.value?.[name.toLowerCase()]; + $: lastUsedDate = lastUsed ? new Date(lastUsed) : null; + + let hovered = false; + + function toggleFavourite() { + dashboardFavourites?.toggle(name?.toLowerCase()); + } + + function onDashboardFavouriteToggle(e: MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + toggleFavourite(); + } + + function onDashboardFavouriteKeydown(e: KeyboardEvent) { + if (e.key !== "Enter" && e.key !== " ") return; + e.stopPropagation(); + e.preventDefault(); + toggleFavourite(); + } - + (hovered = true)} + onmouseleave={() => (hovered = false)} +>
{tag} {/each} +
+ {#if dashboardFavourites && (hovered || isFavourite)} + + + + {/if}
{/if} + {#if lastUsedDate} + + + {m.dashboard_last_used_ago({ + time: timeAgo(lastUsedDate), + })} + + {lastUsedDate.toLocaleString()} + + + {/if} {#if description} {description} diff --git a/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte b/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte index 542f4af87bd2..8ab79e796e99 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte @@ -8,8 +8,12 @@ getAllTagsForResources, getTagFilterLabel, } from "@rilldata/web-common/features/resources/resource-tag-utils.ts"; - import type { ArrayRuneStore } from "web-common/src/lib/store-utils/types.svelte.ts"; + import { + getDashboardTagFavouritesStore, + sortByFavourites, + } from "./dashboard-favourites.ts"; + import { page } from "$app/state"; let { align = "start", @@ -28,6 +32,15 @@ let availableTags = $derived(getAllTagsForResources($dashboards?.data ?? [])); let tagsLabel = $derived(getTagFilterLabel(selectedTagsStore.value)); + + let { organization, project } = $derived(page.params); + let tagsFavourites = $derived( + getDashboardTagFavouritesStore(organization, project), + ); + + let sortedTags = $derived( + sortByFavourites(availableTags, tagsFavourites.value, (t) => t.name), + ); {#if availableTags.length > 0} @@ -45,7 +58,7 @@ {/if} - {#each availableTags as tag (tag.name)} + {#each sortedTags as tag (tag.name)} selectedTagsStore.toggle(tag.name)} diff --git a/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte b/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte index 28aa66bb1e70..44f37cbc2d62 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte @@ -1,25 +1,51 @@ - diff --git a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte index 8ff83139bff4..a824ffb75e78 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte @@ -3,6 +3,12 @@ import DashboardsTagRow from "./DashboardsTagRow.svelte"; import { UrlParamsState } from "web-common/src/lib/store-utils/url-params-state.svelte.ts"; import { getAllTagsForResources } from "@rilldata/web-common/features/resources/resource-tag-utils.ts"; + import { + getDashboardTagFavouritesStore, + sortByFavourites, + } from "./dashboard-favourites.ts"; + import { page } from "$app/state"; + import { flip } from "svelte/animate"; let { resources, @@ -25,21 +31,34 @@ ) : tags, ); + + let { organization, project } = $derived(page.params); + let tagsFavourites = $derived( + getDashboardTagFavouritesStore(organization, project), + ); + + let sortedTags = $derived( + sortByFavourites(filteredTags, tagsFavourites.value, (t) => t.name), + );

Tags

- {#if filteredTags.length === 0} + {#if sortedTags.length === 0}

No matching tags

{:else} - {#each filteredTags as tag (tag.name)} - selectedTagsState.toggle(tag.name)} - /> + {#each sortedTags as tag (tag.name)} +
+ selectedTagsState.toggle(tag.name)} + onFavouriteToggle={() => tagsFavourites.toggle(tag.name)} + /> +
{/each} {/if}
diff --git a/web-admin/src/features/dashboards/listing/dashboard-favourites.ts b/web-admin/src/features/dashboards/listing/dashboard-favourites.ts new file mode 100644 index 000000000000..1cfd7ec68aa2 --- /dev/null +++ b/web-admin/src/features/dashboards/listing/dashboard-favourites.ts @@ -0,0 +1,76 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; +import type { SortOption } from "@rilldata/web-common/components/table-toolbar"; + +export function getDashboardFavouritesStore(org: string, project: string) { + const key = `rill:app:${org}:${project}:dashboard:favourites`; + return SvelteLocalStorage.createStringArrayStore(key); +} + +export function getDashboardTagFavouritesStore(org: string, project: string) { + const key = `rill:app:${org}:${project}:tag:favourites`; + return SvelteLocalStorage.createStringArrayStore(key); +} + +/** + * Sorts items so that favourites come first, in the order they were favourited, + * with everything else following in its original (stable) order. + */ +export function sortByFavourites( + items: T[], + favourites: string[], + key: (item: T) => string, +): T[] { + return [...items].sort((a, b) => { + const aIndex = favourites.indexOf(key(a)); + const bIndex = favourites.indexOf(key(b)); + return ( + (aIndex === -1 ? favourites.length : aIndex) - + (bIndex === -1 ? favourites.length : bIndex) + ); + }); +} + +export const DashboardTableSortOptions: SortOption[] = [ + { + value: "last_used_desc", + label: "Last Used", + sort: { + id: "lastUsed", + desc: true, + }, + }, + { + value: "name_asc", + label: "Name", + sort: { + id: "name", + desc: false, + }, + }, +]; + +export class RecentlyUsedDashboards { + public readonly recentlyUsed: SvelteLocalStorage< + Record, + Record + >; + + public constructor( + public org: string, + public project: string, + ) { + this.recentlyUsed = SvelteLocalStorage.getInstance( + `rill:app:${org}:${project}:dashboard:recentlyUsed`, + (value: Record) => JSON.stringify(value), + (value) => (value ? JSON.parse(value) : {}), + {} as Record, + ); + } + + public update(dashboardName: string) { + this.recentlyUsed.setter({ + ...this.recentlyUsed.value, + [dashboardName]: Date.now(), + }); + } +} diff --git a/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte b/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte index 9d09dcf5ee19..791dd58300eb 100644 --- a/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte +++ b/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte @@ -12,6 +12,11 @@ InMemoryRuneStore, } from "web-common/src/lib/store-utils/types.svelte.ts"; import { filterResources } from "@rilldata/web-common/features/resources/resource-filter-utils.ts"; + import { + getDashboardFavouritesStore, + sortByFavourites, + } from "../../dashboards/listing/dashboard-favourites.ts"; + import { page } from "$app/state"; let { options, @@ -46,6 +51,15 @@ let filteredOptions = $derived( [...options].filter(([id]) => filteredDashboardNames.has(id)), ); + + let { organization, project } = $derived(page.params); + let dashboardFavourites = $derived( + getDashboardFavouritesStore(organization, project), + ); + + let sortedOptions = $derived( + sortByFavourites(filteredOptions, dashboardFavourites.value, ([id]) => id), + ); @@ -62,7 +76,7 @@
{/if} - {#each filteredOptions as [id, option] (id)} + {#each sortedOptions as [id, option] (id)} [] = []; @@ -21,23 +23,33 @@ export let kind: string; export let toolbar: boolean = true; export let fixedRowHeight: boolean = true; - export let initialSorting: SortingState = []; - - let sorting: SortingState = initialSorting; - function setSorting(updater) { - if (updater instanceof Function) { - sorting = updater(sorting); - } else { - sorting = updater; - } + export let sorting: SortingState = []; + export let pinnedRows: string[] = []; + export let maxRows: number | undefined = undefined; + + function setSorting(newSorting: SortingState) { options.update((old) => ({ ...old, state: { ...old.state, - sorting, + sorting: newSorting, }, })); } + $: setSorting(sorting); + + function setPinned(newPinnedRows: string[]) { + options.update((old) => ({ + ...old, + state: { + ...old.state, + rowPinning: { + top: [...newPinnedRows], + }, + }, + })); + } + $: setPinned(pinnedRows); const options = writable>({ data: data, @@ -46,11 +58,18 @@ enableSorting: true, enableFilters: true, enableGlobalFilter: true, + enableRowPinning: true, state: { sorting, columnVisibility, + rowPinning: {}, + }, + getRowId(originalRow, index) { + return ( + (originalRow as V1Resource).meta?.name?.name?.toLowerCase() ?? + index.toString() + ); }, - onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), @@ -73,6 +92,9 @@ // Check if we're in a filtered state (search is active) $: isFiltered = $table.getState().globalFilter?.length > 0; + + $: allRows = [...$table.getTopRows(), ...$table.getCenterRows()]; + $: limitedRows = allRows.slice(0, maxRows ?? allRows.length);
@@ -85,8 +107,12 @@
    - {#each $table.getRowModel().rows as row (row.id)} -
  • + {#each limitedRows as row (row.id)} +
  • {#each row.getVisibleCells() as cell (cell.id)} @@ -99,16 +105,25 @@ {/if}
    -

    -
    +

    +
    {m.home_dashboards_heading()} + +
    {#if $personalCanvases && hasNoPersonalCanvases} {/if}

    - +

    diff --git a/web-admin/src/routes/[organization]/[project]/canvas/[dashboard]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/canvas/[dashboard]/+layout.svelte index 14788fbb8eeb..7d955cc55a5b 100644 --- a/web-admin/src/routes/[organization]/[project]/canvas/[dashboard]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/canvas/[dashboard]/+layout.svelte @@ -1,6 +1,15 @@
    diff --git a/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+layout.svelte index e677ef8d2e9c..c51afa61a1fb 100644 --- a/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+layout.svelte @@ -1,5 +1,14 @@
    diff --git a/web-common/src/components/table-toolbar/TableToolbar.svelte b/web-common/src/components/table-toolbar/TableToolbar.svelte index 09105761f48b..010cd22917fa 100644 --- a/web-common/src/components/table-toolbar/TableToolbar.svelte +++ b/web-common/src/components/table-toolbar/TableToolbar.svelte @@ -6,7 +6,7 @@ import TableToolbarViewToggle from "./TableToolbarViewToggle.svelte"; import type { FilterGroup, SortOption, ViewMode } from "./types"; import type { Snippet } from "svelte"; - import type { RuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; + import { type RuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; let { searchTextStore, diff --git a/web-common/src/components/table-toolbar/TableToolbarSort.svelte b/web-common/src/components/table-toolbar/TableToolbarSort.svelte index 69867598f84d..3425e3f02583 100644 --- a/web-common/src/components/table-toolbar/TableToolbarSort.svelte +++ b/web-common/src/components/table-toolbar/TableToolbarSort.svelte @@ -1,31 +1,48 @@ - + - - Sort by {sortLabel} + {sortLabel} - + {#each sortOptions as option (option.value)} + implements RuneStore +{ + public value: Val | DefaultVal; + + // Cache of stores so that different components can share instance without prop drilling. + // Since there is no event when localStorage is updated we need to ensure instances are shared. + private static stores = new Map>(); + + private constructor( + private readonly key: string, + private readonly serializer: (value: Val) => string | null, + private readonly deserializer: (value: string | null) => Val | DefaultVal, + defaultVal: Val | DefaultVal, + ) { + let initValue = defaultVal; + try { + const existingValue = localStorage.getItem(key); + if (existingValue) { + initValue = deserializer(existingValue); + } + } catch { + // no-op + } + + this.value = $state(initValue); + } + + public static getInstance( + key: string, + serializer: (value: Val) => string | null, + deserializer: (value: string | null) => Val | DefaultVal, + defaultVal: Val | DefaultVal, + ) { + if (this.stores.has(key)) + return this.stores.get(key) as SvelteLocalStorage; + const store = new SvelteLocalStorage( + key, + serializer, + deserializer, + defaultVal, + ); + this.stores.set(key, store); + return store; + } + + public static createStringArrayStore(key: string) { + return new ArrayRuneStore( + SvelteLocalStorage.getInstance( + key, + (value: string[]) => (value.length ? JSON.stringify(value) : null), + (value) => (value ? JSON.parse(value) : []), + [], + ), + ); + } + + public getter = () => { + return this.value; + }; + + public setter = (newValue: Val) => { + const newStoreValue = this.serializer(newValue); + try { + if (newStoreValue) localStorage.setItem(this.key, newStoreValue); + else localStorage.removeItem(this.key); + } catch { + // no-op + } + this.value = newValue; + }; +} diff --git a/web-common/src/lib/store-utils/types.svelte.ts b/web-common/src/lib/store-utils/types.svelte.ts index 774bced98208..fb2170532dd4 100644 --- a/web-common/src/lib/store-utils/types.svelte.ts +++ b/web-common/src/lib/store-utils/types.svelte.ts @@ -39,15 +39,15 @@ export class ArrayRuneStore implements RuneStore { } public toggle = (value: Val) => { - const newTags = this.value.includes(value) + const newValues = this.value.includes(value) ? this.value.filter((v) => v !== value) : [...this.value, value]; - this.setter(newTags); + this.setter(newValues); }; public delete = (value: Val) => { - const newTags = this.value.filter((v) => v !== value); - this.setter(newTags); + const newValues = this.value.filter((v) => v !== value); + this.setter(newValues); }; }