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}