Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
never executes queries.

### Fixed
- **The Workbench favorite star now drives Dashboard tile membership** (part of
#299, post-#287 aggregate). Since `dashboard.tiles[]` became the canonical
Dashboard membership (#280/#287), starring a query only flipped `spec.favorite`
and never added a tile, so favoriting/unfavoriting in the Workbench had no
effect on the Dashboard. `toggleFavorite` now reflects the flip onto tile
membership in the **same atomic commit**: a favorited **panel-role** query gains
a tile (mirroring the legacy migration's role gate — a filter/setup-role
favorite stays favorite-only), and unfavoriting removes every tile referencing
the query and scrubs those tile ids from filter targets. `spec.favorite` stays
the star's visual state (the documented dual-write retirement is deferred).
- **A corrupt-but-present workspace aggregate is surfaced on boot instead of
silently swallowed** (#300). `WorkspaceRepository` gains `loadCurrentResult()`,
which distinguishes "no aggregate yet" (`empty`) from "a record is stored but
won't decode/validate" (`corrupt`) — previously both collapsed to `null`, so a
corrupt record let boot continue on the legacy projection and the next CRUD
commit orphaned it with no user-visible error. Boot now shows a toast ("Saved
workspace could not be read…") with a **Reset workspace** action that clears the
unreadable record and rebuilds a fresh aggregate from the current local state.
`flashToast` gained an optional non-auto-dismissing action button.
- **Dashboard filter values persist across a reload again** (#303, regression from
the #280 dashboard rewrite). The isolated viewer session initialized every
filter purely from `def.defaultValue`/`defaultActive` and persisted nothing, so
a committed filter value lived only in memory and reset on reload (the Workbench
var-strip was unaffected). Filter runtime value/active now persist to an
**isolated** per-dashboard key (`asb:dashFilters`, `dashboardId → filterId →
{value,active}`) — deliberately separate from the Workbench's
`asb:varValues`/`asb:filterActive` — seeded back on load. Persistence stays a
pure `dashboard/model` store behind the existing `loadJSON`/`saveJSON` seam;
opening a dashboard does not persist its defaults (only genuine committed
changes write).
- **Dashboard filter strips no longer wrap, the visible "Clear all" control and
"N active" count are both removed, and the layout switcher moved to the
header as a compact select** (#294 + a same-PR 2026-07-18 owner follow-up —
Expand Down
19 changes: 17 additions & 2 deletions src/dashboard/application/dashboard-viewer-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ export interface DashboardViewerDeps {
onAuthFailed?(): void;
/** #171 bound-param recording on a successful tile. */
recordBoundParams?(boundParams: BoundParamSnapshot[]): void;
/** #303: persisted per-filter seed, keyed by filter `def.id` — the shell
* reads this from the isolated `asb:dashFilters` store (never this layer;
* this session stays storage-free) and passes it in so a filter's initial
* runtime value/active reflects the last COMMITTED state instead of always
* resetting to `def.defaultValue`/`defaultActive`. A filter with no entry
* here (absent/empty map, or no key for its `def.id`) is unaffected — its
* initial state is exactly the pre-#303 default-derived behavior. */
initialFilters?: Record<string, { value: unknown; active: boolean }>;
}

export interface DashboardViewerSession {
Expand Down Expand Up @@ -314,10 +322,17 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
// Filter runtime records, in filter order.
const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => {
const source = typeof def.sourceQueryId === 'string' ? queryById.get(def.sourceQueryId) : undefined;
const active = def.defaultActive ?? (def.defaultValue != null && def.defaultValue !== '');
const defaultValue = def.defaultValue ?? '';
const defaultActive = def.defaultActive ?? (def.defaultValue != null && def.defaultValue !== '');
// #303: a persisted seed for this filter's id overrides the pure-default
// init above (untouched when `initialFilters` is absent/empty, or has no
// entry for `def.id`).
const seed = deps.initialFilters ? deps.initialFilters[def.id] : undefined;
const value = seed !== undefined ? (seed.value ?? defaultValue) : defaultValue;
const active = seed !== undefined ? !!seed.active : defaultActive;
const state: ViewerFilterState = {
id: def.id, parameter: def.parameter, label: def.label || def.parameter,
active, value: def.defaultValue ?? '', status: 'idle', options: null,
active, value, status: 'idle', options: null,
};
return { def, source, gen: 0, abortController: null, provider: null, state };
});
Expand Down
72 changes: 72 additions & 0 deletions src/dashboard/application/tile-membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Wire the Workbench favorite star to Dashboard tile membership (#299).
// `StoredWorkspaceV1.dashboard.tiles[]` is canonical Dashboard membership
// (#287); toggling `spec.favorite` on a saved query must add/remove the
// matching tile IN THE SAME atomic commit as the favorite flip, or the star
// and the Dashboard silently disagree about what's on it.
//
// The role gate mirrors the migration precedent in
// `src/workspace/legacy-migration.ts`'s `buildLegacyMigrationCandidate`: only
// PANEL-role queries ever become tiles — a favorited filter/setup-role query
// stays favorited (the star's own visual state) but never gets a tile. Tile
// removal mirrors `src/dashboard/application/saved-query-mutation.ts`'s
// `removeAffectedTiles`: every tile referencing the query is dropped, and
// those tile ids are scrubbed out of every filter's `targets[]` too.
//
// Pure — no DOM, no persistence; the caller (state.ts's `toggleFavorite`)
// folds the result into the same commit candidate as the favorite patch.

import { queryDashboardRole } from '../model/workspace-semantics.js';
import { flowLayoutPlugin } from '../layouts/flow-layout.js';
import type { DashboardDocumentV1, SavedQueryV2 } from '../../generated/json-schema.types.js';

/** Remove every tile referencing `queryId`, and scrub those tile ids out of
* every filter's `targets` — the typed counterpart of saved-query-mutation.ts's
* `removeAffectedTiles` (that one operates on unknown/untyped documents). */
function removeTilesForQuery(dashboard: DashboardDocumentV1, queryId: string): DashboardDocumentV1 {
const removed = new Set(
dashboard.tiles.filter((tile) => tile.queryId === queryId).map((tile) => tile.id),
);
const tiles = dashboard.tiles.filter((tile) => tile.queryId !== queryId);
const filters = dashboard.filters.map((filter) => (
filter.targets
? { ...filter, targets: filter.targets.filter((target) => !removed.has(target)) }
: filter
));
return { ...dashboard, tiles, filters };
}

/**
* Reflect a Workbench favorite flip onto Dashboard tile membership (#299).
*
* - Star ON + panel-role query + no existing tile referencing it → append one
* tile `{ id: genTileId(), queryId: query.id }`.
* - Star ON + a tile already references it → unchanged (idempotent).
* - Star ON + filter/setup-role query → unchanged (favorite flip only; never
* becomes a tile, matching `buildLegacyMigrationCandidate`).
* - Star OFF → remove EVERY tile referencing the query and scrub those tile
* ids from every filter's `targets`.
* - `dashboard` null in → `null` out (no Dashboard yet; favorite flip only).
*
* The result is always run through `flowLayoutPlugin.normalize` (a new tile
* gets the flow default placement lazily — nothing to add up front — and a
* removed tile's placement item, if any, is dropped) and is always a fresh
* copy; `dashboard` is never mutated.
*/
export function toggleTileMembership(
dashboard: DashboardDocumentV1 | null,
query: SavedQueryV2,
favorite: boolean,
genTileId: () => string,
): DashboardDocumentV1 | null {
if (!dashboard) return null;
const hasTile = dashboard.tiles.some((tile) => tile.queryId === query.id);
let next = dashboard;
if (favorite) {
if (!hasTile && queryDashboardRole(query) === 'panel') {
next = { ...dashboard, tiles: [...dashboard.tiles, { id: genTileId(), queryId: query.id }] };
}
} else if (hasTile) {
next = removeTilesForQuery(dashboard, query.id);
}
return flowLayoutPlugin.normalize(next);
}
86 changes: 86 additions & 0 deletions src/dashboard/model/dashboard-filter-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Isolated per-dashboard Dashboard-filter persistence (#303 Option B). The
// #280 viewer session initializes every filter's runtime state purely from
// `def.defaultValue`/`defaultActive` and never reads persisted values, so a
// committed filter value only lived in memory and reset to defaults on
// reload. The fix is ONE localStorage key (`state.ts`'s `KEYS.dashFilters`)
// holding a map of `dashboardId -> filterId -> { value, active }` — deliberately
// isolated from the Workbench's `asb:varValues`/`asb:filterActive` keys (a
// Dashboard filter is a distinct persisted concern, not a var-strip mirror).
//
// Pure by construction (no DOM, no globals, no storage access of its own):
// the shell (`src/ui/dashboard.ts`) is the only caller that touches
// `core/storage.js`'s `loadJSON`/`saveJSON`; this module only ever receives
// and returns already-parsed JSON values, so it stays testable without a
// storage seam of its own and satisfies the `src/dashboard/model` boundary
// rule (no `state.ts`/`core/storage.js` import here).

/** One filter's persisted runtime state. */
export interface DashboardFilterEntry {
value: string;
active: boolean;
}

/** One dashboard's persisted filter bag, keyed by filter `def.id`. */
export type DashboardFilterBag = Record<string, DashboardFilterEntry>;

/** The whole persisted blob, keyed by `dashboard.id`. */
export type AllDashboardFilters = Record<string, DashboardFilterBag>;

const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);

const coerceValue = (value: unknown): string =>
(typeof value === 'string' ? value : value == null ? '' : String(value));

/**
* Defensively parse an untrusted blob (whatever `loadJSON(KEYS.dashFilters, {})`
* returned) into one dashboard's filter bag. Tolerates a non-object blob, a
* missing dashboard entry, and junk per-filter entries (dropped rather than
* thrown); a present entry has its `value` coerced to a string and its
* `active` coerced to a boolean. Returns `{}` when nothing valid is found.
*/
export function readDashboardFilterBag(all: unknown, dashboardId: string): DashboardFilterBag {
if (!isObject(all)) return {};
const dashboard = all[dashboardId];
if (!isObject(dashboard)) return {};
const out: DashboardFilterBag = {};
for (const [filterId, entry] of Object.entries(dashboard)) {
if (!isObject(entry)) continue; // junk entry (string/number/array/null) — drop
out[filterId] = { value: coerceValue(entry.value), active: !!entry.active };
}
return out;
}

/** Shallow clone of a bag (defends both `writeDashboardFilterBag`'s input and
* its output against later in-place mutation by either side). */
function cloneBag(bag: DashboardFilterBag): DashboardFilterBag {
const out: DashboardFilterBag = {};
for (const [filterId, entry] of Object.entries(bag)) out[filterId] = { value: entry.value, active: entry.active };
return out;
}

/**
* Return a NEW all-dashboards map with `dashboardId`'s bag replaced by `bag`,
* preserving every other dashboard's entry untouched. Never mutates `all` or
* `bag`. Starts from `{}` when `all` isn't a valid object (first write, or a
* corrupt blob).
*/
export function writeDashboardFilterBag(
all: unknown, dashboardId: string, bag: DashboardFilterBag,
): AllDashboardFilters {
const out: AllDashboardFilters = {};
if (isObject(all)) {
for (const [id, value] of Object.entries(all)) {
if (id !== dashboardId) out[id] = value as DashboardFilterBag;
}
}
out[dashboardId] = cloneBag(bag);
return out;
}

/** A stable signature for a bag (sorted-key JSON) so a caller can skip a
* redundant write when nothing has actually changed since the last publish. */
export function filterBagSignature(bag: DashboardFilterBag): string {
const ids = Object.keys(bag).sort();
return JSON.stringify(ids.map((id) => [id, bag[id].value, bag[id].active]));
}
45 changes: 40 additions & 5 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
loadStr as loadStrUntyped,
} from './core/storage.js';
import { emptyRecentMap as emptyRecentMapUntyped } from './core/recent-values.js';
import { toggleTileMembership } from './dashboard/application/tile-membership.js';
import type { ResultSort } from './core/sort.js';
import {
defaultSpecValidationService as defaultSpecValidationServiceUntyped,
Expand Down Expand Up @@ -97,13 +98,16 @@ const asSpecDiagnostics = (diagnostics: readonly WorkspaceDiagnostic[]): SpecDia
* carried through unchanged alongside the op's own next `queries` array. */
type WorkspaceCandidateState = Pick<AppState, 'libraryName' | 'workspaceId' | 'dashboard'>;

function buildWorkspaceCandidate(state: WorkspaceCandidateState, queries: SavedQueryV2[]): StoredWorkspaceV1 {
function buildWorkspaceCandidate(
state: WorkspaceCandidateState, queries: SavedQueryV2[],
dashboard: DashboardDocumentV1 | null = state.dashboard,
): StoredWorkspaceV1 {
return {
storageVersion: 1,
id: state.workspaceId,
name: state.libraryName.value,
queries,
dashboard: state.dashboard,
dashboard,
};
}

Expand Down Expand Up @@ -342,6 +346,11 @@ export const KEYS = {
dashCols: 'asb:dashCols',
varRecent: 'asb:varRecent',
varRecentDisabled: 'asb:varRecentDisabled',
/** Isolated per-dashboard Dashboard-filter persistence (#303 Option B) — a
* single blob keyed `dashboardId -> filterId -> {value,active}`, read/written
* through `dashboard/model/dashboard-filter-store.js`. Deliberately separate
* from the Workbench's `varValues`/`filterActive` keys above. */
dashFilters: 'asb:dashFilters',
};

/** Row-limit options for the result cap selector (shared between state + UI).
Expand Down Expand Up @@ -750,6 +759,16 @@ export async function commitSavedQuery(
return { ok: true, entry: saved };
}

/** A pure transform folded into the SAME commit candidate as a `patchSavedSpec`
* write (#299) — e.g. `toggleFavorite` reflects its favorite flip onto
* Dashboard tile membership atomically alongside the Spec patch. Defaults to
* identity, so `renameSaved`/other callers that don't touch the Dashboard are
* unaffected. Receives the COMMITTED entry (the one about to be sent to
* `commit`, post-patch) so a role/id-dependent transform sees the final Spec. */
export type DashboardTransform = (dashboard: DashboardDocumentV1 | null, entry: SavedQueryV2) => DashboardDocumentV1 | null;

const identityDashboardTransform: DashboardTransform = (dashboard) => dashboard;

/**
* Generic committed-Spec writer for pencil/star/future controls. The patch is
* applied independently to the persisted entry and every linked valid draft,
Expand All @@ -765,6 +784,7 @@ export async function patchSavedSpec(
id: string, patch: SpecPatch,
commit: CommitWorkspace,
validationService: SpecValidationService = defaultSpecValidationService,
transformDashboard: DashboardTransform = identityDashboardTransform,
): Promise<PatchSavedResult> {
const invalidTab = invalidSpecTabForSaved(state, id);
if (invalidTab) return { ok: false, invalidTab, entry: null };
Expand All @@ -788,11 +808,16 @@ export async function patchSavedSpec(
// COMPUTE only above — no `state`/`tabs` mutation yet.
const nextQueries = state.savedQueries.slice();
nextQueries[index] = entry;
const result = await commit(buildWorkspaceCandidate(state, nextQueries));
const nextDashboard = transformDashboard(state.dashboard, entry);
const result = await commit(buildWorkspaceCandidate(state, nextQueries, nextDashboard));
if (!result.ok) {
return { ok: false, invalidTab: null, entry: null, diagnostics: asSpecDiagnostics(result.diagnostics) };
}
state.savedQueries = result.workspace.queries;
// #299: project the committed Dashboard back, same convention as
// `savedQueries` above — the aggregate commit is the single source of truth
// for whether/how tile membership actually landed.
state.dashboard = result.workspace.dashboard;
const saved = committedEntry(state.savedQueries, entry.id, entry);
for (const update of draftUpdates) {
setTabSpecDraft(update.tab, update.spec, { dirty: update.dirty, validationService });
Expand Down Expand Up @@ -824,17 +849,27 @@ export async function renameSaved(
return patchSavedSpec(state, id, patch, commit, validationService);
}

/** Toggle a saved query's favorite flag. */
/**
* Toggle a saved query's favorite flag, atomically committing any Dashboard
* tile-membership change the flip implies in the SAME candidate (#299): a
* favorited panel-role query gets a tile (unless one already references it),
* an unfavorited query loses every tile that references it. `spec.favorite`
* stays the star's own visual state either way — this only ADDS the tile
* side effect, it does not retire the favorite dual-write. `genTileId` mints
* a fresh tile id (only called when a tile is actually appended).
*/
export async function toggleFavorite(
state: AppState, id: string,
commit: CommitWorkspace,
genTileId: () => string,
validationService: SpecValidationService = defaultSpecValidationService,
): Promise<PatchSavedResult | undefined> {
const index = state.savedQueries.findIndex((q) => q.id === id);
const entry = index >= 0 ? state.savedQueries[index] : null;
if (!entry) return;
const favorite = !queryFavorite(entry);
return patchSavedSpec(state, id, { favorite }, commit, validationService);
return patchSavedSpec(state, id, { favorite }, commit, validationService,
(dashboard, patchedEntry) => toggleTileMembership(dashboard, patchedEntry, favorite, genTileId));
}

/** Saved queries with favorites first (stable within each group). */
Expand Down
Loading
Loading