diff --git a/.claude/skills/i18n-migrate/SKILL.md b/.claude/skills/i18n-migrate/SKILL.md new file mode 100644 index 000000000000..ba504d8cb7bf --- /dev/null +++ b/.claude/skills/i18n-migrate/SKILL.md @@ -0,0 +1,58 @@ +--- +description: Find hardcoded user-facing strings in the branch's changes and migrate them to i18n (paraglide messages), following the repo's i18n conventions +allowed-tools: Bash(git:*), Bash(npm:*), Glob, Grep, Read, Edit, AskUserQuestion +argument-hint: "[optional: files/dirs to narrow focus]" +--- + +Audit the changes on the current branch for hardcoded user-facing strings that should be moved to i18n (Paraglide/inlang messages), following the conventions in `web-common/src/lib/i18n/README.md`. + +Optional focus: $ARGUMENTS + +## Instructions + +### 1. Determine the diff to audit + +- If `$ARGUMENTS` lists files or directories, scope the audit to those paths only. +- Otherwise, audit the PR diff: `git diff --merge-base main` to get changed lines across the branch. +- Only consider **added or modified** lines. Do not flag pre-existing strings unless the file was explicitly passed in `$ARGUMENTS`. +- Only frontend files are in scope: `web-common/`, `web-admin/`, `web-local/`, with extensions `.svelte` and `.ts`/`.svelte.ts`. + +### 2. Find hardcoded user-facing strings + +Mirror the heuristics in `scripts/i18n-guard.js`: + +- **Svelte visible text**: content between `>` and `<` that contains real words. +- **Human-facing attributes**: `placeholder`, `title`, `aria-label`, `alt`, `label`. +- **TypeScript**: string literals used as user-facing copy — e.g. `label:`, `header:`, `body:`, toast/notification text, thrown error messages shown to users, option arrays. + +**Skip** (not user-facing): identifiers, URLs, paths, `CONSTANT_CASE`, class/style names, object keys, `console.*`/log messages, code comments, test files (`*.spec.ts`, `*.test.ts`, e2e), and lines with an `i18n-ignore` comment on or above them. + +### 3. Propose keys, reusing what exists + +For each finding, before proposing a new key, grep `web-common/src/lib/i18n/messages/en.json` for the exact copy — **reuse an existing key** if one matches (especially `common_*`). + +When a new key is needed, follow the README conventions: + +- Naming: `feature_component_purpose`, lower snake_case, grouped by prefix. +- `common_*` for copy reused across features; otherwise prefix with the feature directory name. +- **Generic shared components** (in `web-common/src/components/`) should use `common_*` keys, not feature-specific ones. +- **Interpolation**: named placeholders, never string concatenation — `"Sort by {label}"`, not `"Sort by " + label`. +- **Pluralization**: use Paraglide variants, not `count === 1 ? ... : ...`. + +### 4. Watch for these patterns + +- **Module-level `const` in `.ts`** (e.g. an options array with `label`): calling `m.key()` at module load freezes the locale. Use a getter — `get label() { return m.key(); }` — so it resolves lazily and stays locale-reactive. +- **Composed labels**: parameterize the whole string as one interpolated message rather than concatenating a translated prefix with a value. + +### 5. Report + +Present a table ordered by significance: `file:line`, the current string, the suggested key (or existing key to reuse), and whether interpolation/variants are needed. Note any strings that are borderline or intentionally left as-is. + +### 6. Offer to migrate + +Ask whether to apply the migration. If yes, for each string: + +1. Add the key to **both** `messages/en.json` and `messages/es.json`, placed alphabetically within its prefix group. Provide a real Spanish translation for `es.json`. +2. Replace the literal in code with `m.key()` / `m.key({ var })`. +3. Run `npm run build:i18n` to compile and confirm the message functions generate cleanly. +4. If the change fully migrates a new area, append its directory to `MIGRATED_GLOBS` in `scripts/i18n-guard.js`. 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..d6215908b4c3 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagRow.svelte @@ -1,25 +1,52 @@ - diff --git a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte index 8ff83139bff4..b98f37d9ce9c 100644 --- a/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte +++ b/web-admin/src/features/dashboards/listing/DashboardsTagSidebar.svelte @@ -3,6 +3,13 @@ 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"; + import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; let { resources, @@ -25,21 +32,36 @@ ) : tags, ); + + let { organization, project } = $derived(page.params); + let tagsFavourites = $derived( + getDashboardTagFavouritesStore(organization, project), + ); + + let sortedTags = $derived( + sortByFavourites(filteredTags, tagsFavourites.value, (t) => t.name), + );
-

Tags

+

{m.dashboard_tags()}

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

No matching tags

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

+ {m.dashboard_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..d0057f36a031 --- /dev/null +++ b/web-admin/src/features/dashboards/listing/dashboard-favourites.ts @@ -0,0 +1,81 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; +import type { SortOption } from "@rilldata/web-common/components/table-toolbar"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + +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", + get label() { + return m.dashboard_sort_last_used(); + }, + sort: { + id: "lastUsed", + desc: true, + }, + }, + { + value: "name_asc", + get label() { + return m.dashboard_sort_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..3845628612c1 100644 --- a/web-common/src/components/table-toolbar/TableToolbarSort.svelte +++ b/web-common/src/components/table-toolbar/TableToolbarSort.svelte @@ -1,29 +1,54 @@ - + - - Sort by {sortLabel} + {sortLabel} +
    + +
    {#each sortOptions as option (option.value)} @@ -37,3 +62,13 @@ {/each}
    + + diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 145eac7f6df5..ea685ebc9b87 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -595,6 +595,7 @@ "common_no_changes_detected": "No changes detected", "common_no_results": "No results found", "common_no_results_found": "No results found.", + "common_none": "None", "common_other_values_selected": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -611,6 +612,7 @@ "common_search_ellipsis": "Search...", "common_search_list": "Search list", "common_select_all": "Select all", + "common_sort_by": "Sort by {label}", "common_status": "Status", "common_type": "Type", "common_type_to_confirm": "Type {text} in the box below to confirm:", @@ -622,6 +624,7 @@ "dashboard_add_column": "Add Column", "dashboard_add_filter": "Add filter", "dashboard_add_filter_button": "Add filter button", + "dashboard_add_favourite": "Add to favourites", "dashboard_add_filter_button_aria": "Add filter button", "dashboard_add_mock_user": "Add mock user", "dashboard_add_row": "Add Row", @@ -706,6 +709,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", @@ -757,6 +761,7 @@ "dashboard_readonly_filter_chips_aria": "Readonly Filter Chips", "dashboard_recent": "Recent", "dashboard_reference": "Reference", + "dashboard_remove_favourite": "Remove from favourites", "dashboard_remove_label": "Remove {label}", "dashboard_removed_items_filter": "Removed {count} items from filter", "dashboard_replace": "Replace", @@ -794,11 +799,15 @@ "dashboard_sort_by_percent_change_aria": "Toggle sort leaderboards by percent change", "dashboard_sort_by_percent_total_aria": "Toggle sort leaderboards by percent of total", "dashboard_sort_by_value_aria": "Toggle sort leaderboards by value", + "dashboard_sort_last_used": "Last Used", + "dashboard_sort_name": "Name", "dashboard_start": "start", "dashboard_start_pivot": "Start Pivot", "dashboard_switch_flat": "Switch to flat view", "dashboard_switch_pivot": "Switch to pivot view", "dashboard_table_mode": "Table mode", + "dashboard_tag_add_favourite": "Add {name} to favourites", + "dashboard_tag_remove_favourite": "Remove {name} from favourites", "dashboard_tags": "Tags", "dashboard_tdd_contact_discord": "If the issue persists, please contact us on", "dashboard_tdd_error": "We encountered an error while loading the data. Please try refreshing the page.", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 8f3b41dcc198..98390c68a1a6 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -595,6 +595,7 @@ "common_no_changes_detected": "No se detectaron cambios", "common_no_results": "No se encontraron resultados", "common_no_results_found": "No se encontraron resultados.", + "common_none": "Ninguno", "common_other_values_selected": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -611,6 +612,7 @@ "common_search_ellipsis": "Buscar...", "common_search_list": "Buscar en la lista", "common_select_all": "Seleccionar todo", + "common_sort_by": "Ordenar por {label}", "common_status": "Estado", "common_type": "Tipo", "common_type_to_confirm": "Escriba {text} en el campo de abajo para confirmar:", @@ -622,6 +624,7 @@ "dashboard_add_column": "Agregar columna", "dashboard_add_filter": "Agregar filtro", "dashboard_add_filter_button": "Botón de agregar filtro", + "dashboard_add_favourite": "Agregar a favoritos", "dashboard_add_filter_button_aria": "Botón para agregar filtro", "dashboard_add_mock_user": "Agregar usuario de prueba", "dashboard_add_row": "Agregar fila", @@ -706,6 +709,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": "Último uso {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", @@ -757,6 +761,7 @@ "dashboard_readonly_filter_chips_aria": "Chips de filtro de solo lectura", "dashboard_recent": "Recientes", "dashboard_reference": "Referencia", + "dashboard_remove_favourite": "Quitar de favoritos", "dashboard_remove_label": "Eliminar {label}", "dashboard_removed_items_filter": "Se eliminaron {count} elementos del filtro", "dashboard_replace": "Reemplazar", @@ -803,11 +808,15 @@ "dashboard_sort_by_percent_change_aria": "Alternar orden por cambio porcentual", "dashboard_sort_by_percent_total_aria": "Alternar orden por porcentaje del total", "dashboard_sort_by_value_aria": "Alternar orden por valor", + "dashboard_sort_last_used": "Último uso", + "dashboard_sort_name": "Nombre", "dashboard_start": "inicio", "dashboard_start_pivot": "Iniciar pivote", "dashboard_switch_flat": "Cambiar a vista plana", "dashboard_switch_pivot": "Cambiar a vista de pivote", "dashboard_table_mode": "Modo de tabla", + "dashboard_tag_add_favourite": "Agregar {name} a favoritos", + "dashboard_tag_remove_favourite": "Quitar {name} de favoritos", "dashboard_tags": "Etiquetas", "dashboard_tdd_contact_discord": "Si el problema persiste, contáctanos en", "dashboard_tdd_error": "Encontramos un error al cargar los datos. Por favor, intenta actualizar la página.", 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; + }; +} 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); }; }