diff --git a/web-admin/src/features/dashboards/listing/DashboardsTable.svelte b/web-admin/src/features/dashboards/listing/DashboardsTable.svelte index 1a57c9d860d8..8a108487d7c6 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTable.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTable.svelte @@ -17,13 +17,17 @@ import DashboardsTagSidebar from "@rilldata/web-admin/features/dashboards/listing/DashboardsTagSidebar.svelte"; import { filterResources } from "@rilldata/web-common/features/resources/resource-filter-utils.ts"; import { Throttler } from "@rilldata/web-common/lib/throttler.ts"; + import { + getDashboardFavouritesStore, + RecentlyUsedDashboards, + } from "@rilldata/web-admin/features/dashboards/listing/dashboard-favourites.ts"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import { escapeHtml } from "@rilldata/web-common/lib/i18n"; let { isEmbedded = false, isPreview = false, - previewLimit = 5, + previewLimit = undefined, }: { isEmbedded?: boolean; isPreview?: boolean; @@ -32,7 +36,7 @@ const selectedTagsState = UrlParamsState.createStringArrayParam("tags"); - const searchTextState = UrlParamsState.createStringParam("search"); + const searchTextState = UrlParamsState.createStringParam("q"); const throttler = new Throttler(500, 500); const throttledSearchSetter = (newValue: string) => { throttler.throttle(() => searchTextState.setter(newValue)); @@ -67,14 +71,33 @@ ), ); - 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( + dashboardFavourites.value.filter((f) => + filteredDashboards.find((r) => r.meta?.name?.name?.toLowerCase() === f), + ), ); let hasMoreDashboards = $derived( isPreview && filteredDashboards.length > previewLimit, ); + let displayData = $derived( + filteredDashboards.map((r) => ({ + ...r, + lastUsed: + recentlyUsedDashboards.recentlyUsed.value[ + r.meta?.name?.name?.toLowerCase() + ] ?? 0, + })) as V1Resource[], + ); + const columns = [ { id: "composite", @@ -104,6 +127,8 @@ organization, project, tags, + dashboardFavourites, + recentlyUsedDashboards, }); }, }, @@ -136,6 +161,12 @@ return isMetricsExplorer ? row.explore.spec.description : ""; }, }, + { + id: "lastUsed", + accessorFn: (row: V1Resource) => { + return (row as any).lastUsed; + }, + }, ]; const columnVisibility = { @@ -143,9 +174,13 @@ name: false, lastRefreshed: false, description: false, + lastUsed: false, }; - const initialSorting = [{ id: "name", desc: false }]; + const initialSorting = [ + { id: "lastUsed", desc: true }, + { id: "name", desc: false }, + ]; {#if isLoading || isBuilding} @@ -164,6 +199,7 @@ bind:value={searchTextState.getter, throttledSearchSetter} rounded="lg" retainValueOnMount + large /> {/if} @@ -193,6 +229,8 @@ {columnVisibility} {initialSorting} toolbar={false} + pinnedRows={validDashboardFavourites} + maxRows={previewLimit} > | undefined = + undefined; + export let recentlyUsedDashboards: RecentlyUsedDashboards | undefined = + undefined; $: lastRefreshedDate = lastRefreshed ? new Date(lastRefreshed) : null; @@ -28,9 +35,29 @@ $: 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 onDashboardFavouriteToggle(e: MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + dashboardFavourites?.toggle(name?.toLowerCase()); + } - + (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..6fe275df66ad 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagFilter.svelte @@ -8,8 +8,9 @@ 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 } from "@rilldata/web-admin/features/dashboards/listing/dashboard-favourites.ts"; + import { page } from "$app/state"; let { align = "start", @@ -28,6 +29,21 @@ 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( + [...availableTags].sort((a, b) => { + let aIndex = tagsFavourites.value.indexOf(a.name); + if (aIndex === -1) aIndex = tagsFavourites.value.length; + let bIndex = tagsFavourites.value.indexOf(b.name); + if (bIndex === -1) bIndex = tagsFavourites.value.length; + return aIndex - bIndex; + }), + ); {#if availableTags.length > 0} @@ -45,7 +61,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..81b8cf363aa2 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte @@ -1,25 +1,44 @@ - diff --git a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte index 8ff83139bff4..8cec4aea3cfd 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte @@ -3,6 +3,9 @@ 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 } from "@rilldata/web-admin/features/dashboards/listing/dashboard-favourites.ts"; + import { page } from "$app/state"; + import { flip } from "svelte/animate"; let { resources, @@ -25,21 +28,40 @@ ) : tags, ); + + let { organization, project } = $derived(page.params); + let tagsFavourites = $derived( + getDashboardTagFavouritesStore(organization, project), + ); + + let sortedTags = $derived( + [...filteredTags].sort((a, b) => { + let aIndex = tagsFavourites.value.indexOf(a.name); + if (aIndex === -1) aIndex = tagsFavourites.value.length; + let bIndex = tagsFavourites.value.indexOf(b.name); + if (bIndex === -1) bIndex = tagsFavourites.value.length; + return aIndex - bIndex; + }), + );

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..8172af484207 --- /dev/null +++ b/web-admin/src/features/dashboards/listing/dashboard-favourites.ts @@ -0,0 +1,37 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; + +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); +} + +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..2ff48afd9c18 100644 --- a/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte +++ b/web-admin/src/features/projects/header/VisualizationsBreadcrumbDropdown.svelte @@ -12,6 +12,8 @@ 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 } from "@rilldata/web-admin/features/dashboards/listing/dashboard-favourites.ts"; + import { page } from "$app/state"; let { options, @@ -46,6 +48,21 @@ let filteredOptions = $derived( [...options].filter(([id]) => filteredDashboardNames.has(id)), ); + + let { organization, project } = $derived(page.params); + let dashboardFavourites = $derived( + getDashboardFavouritesStore(organization, project), + ); + + let sortedOptions = $derived( + [...filteredOptions].sort(([a], [b]) => { + let aIndex = dashboardFavourites.value.indexOf(a); + if (aIndex === -1) aIndex = dashboardFavourites.value.length; + let bIndex = dashboardFavourites.value.indexOf(b); + if (bIndex === -1) bIndex = dashboardFavourites.value.length; + return aIndex - bIndex; + }), + ); @@ -62,7 +79,7 @@
{/if} - {#each filteredOptions as [id, option] (id)} + {#each sortedOptions as [id, option] (id)} [] = []; @@ -22,6 +24,8 @@ export let toolbar: boolean = true; export let fixedRowHeight: boolean = true; export let initialSorting: SortingState = []; + export let pinnedRows: string[] = []; + export let maxRows: number | undefined = undefined; let sorting: SortingState = initialSorting; function setSorting(updater) { @@ -39,6 +43,19 @@ })); } + function setPinned(newPinnedRows: string[]) { + options.update((old) => ({ + ...old, + state: { + ...old.state, + rowPinning: { + top: [...newPinnedRows], + }, + }, + })); + } + $: setPinned(pinnedRows); + const options = writable>({ data: data, columns: columns, @@ -46,9 +63,17 @@ 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(), @@ -73,6 +98,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 +113,12 @@
    - {#each $table.getRowModel().rows as row (row.id)} -
  • + {#each limitedRows as row (row.id)} +
  • {#each row.getVisibleCells() as cell (cell.id)} {/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..398f7766d458 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,16 @@
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..35b8ab014987 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,15 @@
diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 1c1b2c7e56be..6ab0612bc5a9 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -706,6 +706,7 @@ "dashboard_include_exclude_toggle": "Include exclude toggle", "dashboard_invalid_time_range": "Invalid time range", "dashboard_last_refreshed_ago": "Last refreshed {time}", + "dashboard_last_used_ago": "Last used {time}", "dashboard_latest_data": "latest data", "dashboard_latest_data_description": "Timestamp of latest data point", "dashboard_leaderboards_aria": "Leaderboards", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 6abbcd049465..c719d7b63384 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -706,6 +706,7 @@ "dashboard_include_exclude_toggle": "Alternar incluir/excluir", "dashboard_invalid_time_range": "Rango de tiempo inválido", "dashboard_last_refreshed_ago": "Última actualización {time}", + "dashboard_last_used_ago": "Última actualización {time}", "dashboard_latest_data": "datos más recientes", "dashboard_latest_data_description": "Marca temporal del punto de datos más reciente", "dashboard_leaderboards_aria": "Rankings", diff --git a/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts b/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts new file mode 100644 index 000000000000..b9628b4cf5c3 --- /dev/null +++ b/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts @@ -0,0 +1,77 @@ +import { + ArrayRuneStore, + type RuneStore, +} from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; + +export class SvelteLocalStorage + 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; + }; +}