mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add URL hash view-state routing with Back/Forward and restore (M6, §01E)
The hash becomes the single serialized view (snippet / Datasets list / a dataset / new-dataset form / Chart Builder). url-hash is the sole reader/writer of location+history (total, never-throwing parse; hash-only canonical URLs). UrlStateSync grows from the modal↔URL seam into the full router: state→hash is derived from the stores (so in-modal dataset sub-views reach the hash), hash→state restores on load and on Back/Forward, validating ids and cleaning stale links. startRouting() runs after store hydrate.
This commit is contained in:
@@ -247,10 +247,32 @@ export const navigate = {
|
|||||||
|
|
||||||
**Don't**
|
**Don't**
|
||||||
|
|
||||||
- Don't read or write `location.hash` from components — call `navigate.*`.
|
- Don't read or write `location.hash` from components.
|
||||||
- Don't `pushState` on load-restore (pollutes Back history).
|
- Don't `pushState` on load-restore (pollutes Back history).
|
||||||
- Don't throw on an unrecognized hash; degrade to the default view.
|
- Don't throw on an unrecognized hash; degrade to the default view.
|
||||||
|
|
||||||
|
### Shipped (M6) — how the implementation refines this sketch
|
||||||
|
|
||||||
|
The routing mediator lives in **`modals/UrlStateSync.ts`**, not a new
|
||||||
|
`orchestration/UrlStateSync.ts`: that file was already the coordinator's modal↔URL
|
||||||
|
seam (`syncModalToUrl` / `clearModalFromUrl`), so M6 grew it into the whole router
|
||||||
|
rather than splitting routing across two modules. It must **not** import the
|
||||||
|
ModalCoordinator (cycle); restore drives `useAppStore.setActiveModal` directly, as
|
||||||
|
this sketch's `applyView` already does.
|
||||||
|
|
||||||
|
**state → hash is derived from the stores, not pushed by `navigate.*` calls.** A
|
||||||
|
`deriveViewState()` reads `activeModal` + `DatasetStore.view`/`selectedId` +
|
||||||
|
`ChartBuilderStore.datasetId` + `activeSnippetId`; a subscription on each of those
|
||||||
|
stores calls `pushView` when the derived view differs from the URL. Components never
|
||||||
|
call a navigate helper — they just mutate stores (select a snippet, open a dataset),
|
||||||
|
and the subscriber reflects it. This is **required**, not stylistic: the in-modal
|
||||||
|
dataset sub-views (`#datasets/new`, `#datasets/dataset-<id>`) are `DatasetStore` view
|
||||||
|
changes, not modal-open events, so only a derive-from-state writer captures them. Only
|
||||||
|
modals flagged `isUrlNavigable` in the registry own the hash; a non-navigable modal
|
||||||
|
(extract / about / donate) leaves the underlying snippet view in the URL. On load,
|
||||||
|
after restore, the active view is reflected with **`replaceView`** (not `pushView`) so
|
||||||
|
there's no dead Back step. `startRouting()` runs in `startup.ts` after store hydrate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Global Event / Keyboard Routing
|
## 2. Global Event / Keyboard Routing
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseHash,
|
||||||
|
serializeHash,
|
||||||
|
readView,
|
||||||
|
replaceView,
|
||||||
|
pushView,
|
||||||
|
type ViewState,
|
||||||
|
} from './url-hash';
|
||||||
|
|
||||||
|
/** One value per `ViewState` variant — the round-trip and serialize coverage set. */
|
||||||
|
const ALL_VIEWS: ViewState[] = [
|
||||||
|
{ kind: 'snippets' },
|
||||||
|
{ kind: 'snippet', snippetId: 'abc123' },
|
||||||
|
{ kind: 'datasets' },
|
||||||
|
{ kind: 'dataset', datasetId: 7 },
|
||||||
|
{ kind: 'dataset-new' },
|
||||||
|
{ kind: 'dataset-build', datasetId: 42 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('serializeHash', () => {
|
||||||
|
it('produces the spec grammar form for each variant', () => {
|
||||||
|
expect(serializeHash({ kind: 'snippets' })).toBe('');
|
||||||
|
expect(serializeHash({ kind: 'snippet', snippetId: 'abc123' })).toBe('#snippet-abc123');
|
||||||
|
expect(serializeHash({ kind: 'datasets' })).toBe('#datasets');
|
||||||
|
expect(serializeHash({ kind: 'dataset', datasetId: 7 })).toBe('#datasets/dataset-7');
|
||||||
|
expect(serializeHash({ kind: 'dataset-new' })).toBe('#datasets/new');
|
||||||
|
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
|
||||||
|
'#datasets/dataset-42/build',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseHash', () => {
|
||||||
|
it('parses each grammar row to the right ViewState', () => {
|
||||||
|
expect(parseHash('')).toEqual({ kind: 'snippets' });
|
||||||
|
expect(parseHash('#snippet-abc123')).toEqual({ kind: 'snippet', snippetId: 'abc123' });
|
||||||
|
expect(parseHash('#datasets')).toEqual({ kind: 'datasets' });
|
||||||
|
expect(parseHash('#datasets/dataset-7')).toEqual({ kind: 'dataset', datasetId: 7 });
|
||||||
|
expect(parseHash('#datasets/new')).toEqual({ kind: 'dataset-new' });
|
||||||
|
expect(parseHash('#datasets/dataset-42/build')).toEqual({
|
||||||
|
kind: 'dataset-build',
|
||||||
|
datasetId: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a missing leading "#"', () => {
|
||||||
|
expect(parseHash('datasets')).toEqual({ kind: 'datasets' });
|
||||||
|
expect(parseHash('snippet-x')).toEqual({ kind: 'snippet', snippetId: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a snippet id as an opaque string (slashes, dashes, etc.)', () => {
|
||||||
|
expect(parseHash('#snippet-a-b-c')).toEqual({ kind: 'snippet', snippetId: 'a-b-c' });
|
||||||
|
expect(parseHash('#snippet-2026-06-07')).toEqual({ kind: 'snippet', snippetId: '2026-06-07' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default snippets view on malformed / unknown hashes', () => {
|
||||||
|
const fallback: ViewState = { kind: 'snippets' };
|
||||||
|
expect(parseHash('#snippet-')).toEqual(fallback); // empty snippet id
|
||||||
|
expect(parseHash('#datasets/dataset-abc')).toEqual(fallback); // non-numeric dataset id
|
||||||
|
expect(parseHash('#datasets/dataset-')).toEqual(fallback); // empty dataset id
|
||||||
|
expect(parseHash('#datasets/dataset-7/extra')).toEqual(fallback); // unknown trailing segment
|
||||||
|
expect(parseHash('#datasets/bogus')).toEqual(fallback); // unknown datasets sub-route
|
||||||
|
expect(parseHash('#garbage')).toEqual(fallback); // unknown top-level
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws for arbitrary input', () => {
|
||||||
|
for (const raw of ['#', '#/', '#//', '#datasets//', '#snippet', '###']) {
|
||||||
|
expect(() => parseHash(raw)).not.toThrow();
|
||||||
|
expect(parseHash(raw).kind).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip identity', () => {
|
||||||
|
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
|
||||||
|
for (const v of ALL_VIEWS) {
|
||||||
|
expect(parseHash(serializeHash(v))).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readView / replaceView / pushView (window + history)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset to a clean, hash-less location before each case.
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readView reflects the current location.hash', () => {
|
||||||
|
window.location.hash = '#datasets/dataset-7';
|
||||||
|
expect(readView()).toEqual({ kind: 'dataset', datasetId: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readView returns the default for an empty hash', () => {
|
||||||
|
expect(readView()).toEqual({ kind: 'snippets' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceView writes the hash without growing history', () => {
|
||||||
|
const before = window.history.length;
|
||||||
|
replaceView({ kind: 'snippet', snippetId: 'xyz' });
|
||||||
|
expect(window.location.hash).toBe('#snippet-xyz');
|
||||||
|
expect(readView()).toEqual({ kind: 'snippet', snippetId: 'xyz' });
|
||||||
|
expect(window.history.length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceView clears the hash for the default view', () => {
|
||||||
|
window.location.hash = '#datasets';
|
||||||
|
replaceView({ kind: 'snippets' });
|
||||||
|
expect(window.location.hash).toBe('');
|
||||||
|
expect(readView()).toEqual({ kind: 'snippets' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushView writes the hash and adds a history entry', () => {
|
||||||
|
const before = window.history.length;
|
||||||
|
pushView({ kind: 'dataset-build', datasetId: 42 });
|
||||||
|
expect(window.location.hash).toBe('#datasets/dataset-42/build');
|
||||||
|
expect(readView()).toEqual({ kind: 'dataset-build', datasetId: 42 });
|
||||||
|
expect(window.history.length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through the real history API for every variant', () => {
|
||||||
|
for (const v of ALL_VIEWS) {
|
||||||
|
replaceView(v);
|
||||||
|
expect(readView()).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* URL hash as view-state (spec §01 → E "Navigation & Shareable URL State";
|
||||||
|
* architecture §04 → "1. URL Hash as View-State"). The current view — selected
|
||||||
|
* snippet, open Datasets modal, a specific dataset, the new-dataset form, or the
|
||||||
|
* Chart Builder — is serialized into `location.hash` so every meaningful view is
|
||||||
|
* shareable, bookmarkable, and reload-safe, and Back/Forward steps between states.
|
||||||
|
*
|
||||||
|
* This is the ONLY module that reads or writes `window.location` / `history`.
|
||||||
|
* Parsing is a total pure function (no side effects, never throws — an unknown or
|
||||||
|
* malformed hash degrades to the default snippets view). Writing is the only place
|
||||||
|
* `history.replaceState` / `pushState` is called.
|
||||||
|
*
|
||||||
|
* Hash grammar (matches the spec table exactly):
|
||||||
|
*
|
||||||
|
* snippets (default) → '' (empty / absent)
|
||||||
|
* snippet → #snippet-<id> (id is an opaque string)
|
||||||
|
* datasets → #datasets
|
||||||
|
* dataset → #datasets/dataset-<id> (id is a decimal number)
|
||||||
|
* dataset-new → #datasets/new
|
||||||
|
* dataset-build → #datasets/dataset-<id>/build
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The serialized view. Snippet id is opaque; dataset id is the numeric id. */
|
||||||
|
export type ViewState =
|
||||||
|
| { kind: 'snippets' } // empty hash
|
||||||
|
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
|
||||||
|
| { kind: 'datasets' } // #datasets
|
||||||
|
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
||||||
|
| { kind: 'dataset-new' } // #datasets/new
|
||||||
|
| { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-<id>/build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw hash (with or without the leading `#`) into a typed `ViewState`.
|
||||||
|
* Total and pure: anything unrecognized or malformed degrades to the default
|
||||||
|
* snippets view rather than throwing.
|
||||||
|
*/
|
||||||
|
export function parseHash(rawHash: string): ViewState {
|
||||||
|
const hash = rawHash.replace(/^#/, '');
|
||||||
|
if (hash === '') return { kind: 'snippets' };
|
||||||
|
|
||||||
|
const snippet = /^snippet-(.+)$/.exec(hash);
|
||||||
|
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
|
||||||
|
|
||||||
|
const parts = hash.split('/').filter(Boolean);
|
||||||
|
if (parts[0] === 'datasets') {
|
||||||
|
if (parts.length === 1) return { kind: 'datasets' };
|
||||||
|
if (parts[1] === 'new') return { kind: 'dataset-new' };
|
||||||
|
const m = /^dataset-(\d+)$/.exec(parts[1]);
|
||||||
|
if (m) {
|
||||||
|
const id = Number(m[1]);
|
||||||
|
if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id };
|
||||||
|
if (parts.length === 2) return { kind: 'dataset', datasetId: id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unknown / malformed hash → fall back to default rather than throwing.
|
||||||
|
return { kind: 'snippets' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a `ViewState` into a hash string (round-trips with `parseHash`).
|
||||||
|
* The exhaustive switch uses a `never`-typed default so a newly added `ViewState`
|
||||||
|
* variant without a case becomes a compile-time error.
|
||||||
|
*/
|
||||||
|
export function serializeHash(view: ViewState): string {
|
||||||
|
switch (view.kind) {
|
||||||
|
case 'snippets':
|
||||||
|
return '';
|
||||||
|
case 'snippet':
|
||||||
|
return `#snippet-${view.snippetId}`;
|
||||||
|
case 'datasets':
|
||||||
|
return '#datasets';
|
||||||
|
case 'dataset':
|
||||||
|
return `#datasets/dataset-${view.datasetId}`;
|
||||||
|
case 'dataset-new':
|
||||||
|
return '#datasets/new';
|
||||||
|
case 'dataset-build':
|
||||||
|
return `#datasets/dataset-${view.datasetId}/build`;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = view;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current view from `window.location.hash`. */
|
||||||
|
export function readView(): ViewState {
|
||||||
|
return parseHash(window.location.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the next URL for `view`. The hash is the app's only view-state channel,
|
||||||
|
* so we normalize to a hash-only canonical URL: any stray query string (a shared
|
||||||
|
* link's `?utm=…`, a dev-server param) is dropped rather than carried forward.
|
||||||
|
*/
|
||||||
|
function nextUrl(view: ViewState): string {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = serializeHash(view);
|
||||||
|
url.search = '';
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write without adding a history entry (in-place correction, load-restore). */
|
||||||
|
export function replaceView(view: ViewState): void {
|
||||||
|
window.history.replaceState({}, '', nextUrl(view));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write and add a history entry (deliberate user navigation → Back works). */
|
||||||
|
export function pushView(view: ViewState): void {
|
||||||
|
window.history.pushState({}, '', nextUrl(view));
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
import { createDataset } from '@core/dataset';
|
||||||
|
import { createSnippet } from '@core/snippet';
|
||||||
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { applyView, deriveViewState } from './UrlStateSync';
|
||||||
|
|
||||||
|
const T = new Date('2026-06-01T00:00:00Z');
|
||||||
|
|
||||||
|
const seedDataset = (id: number, name: string) =>
|
||||||
|
createDataset({ id, name, data: [{ a: 1 }], format: 'json', source: 'inline', now: T });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
useDatasetStore.getState().reset();
|
||||||
|
useChartBuilderStore.setState({ datasetId: null });
|
||||||
|
useAppStore.getState().setActiveModal(null);
|
||||||
|
window.location.hash = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveViewState — the URL is derived from store state', () => {
|
||||||
|
test('no snippet, no modal → default snippets view', () => {
|
||||||
|
useSnippetStore.getState().hydrate([], null);
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'snippets' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an active snippet → #snippet-<id>', () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], 's1');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'snippet', snippetId: 's1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datasets manager (list) → #datasets', () => {
|
||||||
|
useAppStore.getState().setActiveModal('datasets');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'datasets' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a selected dataset → #datasets/dataset-<id>', () => {
|
||||||
|
useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]);
|
||||||
|
useDatasetStore.getState().select(7);
|
||||||
|
useAppStore.getState().setActiveModal('datasets');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'dataset', datasetId: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the new-dataset form → #datasets/new', () => {
|
||||||
|
useDatasetStore.getState().startCreate();
|
||||||
|
useAppStore.getState().setActiveModal('datasets');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'dataset-new' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Chart Builder → #datasets/dataset-<id>/build', () => {
|
||||||
|
useChartBuilderStore.setState({ datasetId: 42 });
|
||||||
|
useAppStore.getState().setActiveModal('chartBuilder');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'dataset-build', datasetId: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-navigable modal (extract) leaves the underlying snippet view in the URL', () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], 's1');
|
||||||
|
useAppStore.getState().setActiveModal('extract');
|
||||||
|
expect(deriveViewState()).toEqual({ kind: 'snippet', snippetId: 's1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyView — the hash drives the stores (restore / Back-Forward)', () => {
|
||||||
|
test('restoring a snippet selects it and closes any modal', () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], null);
|
||||||
|
useAppStore.getState().setActiveModal('datasets');
|
||||||
|
|
||||||
|
applyView({ kind: 'snippet', snippetId: 's1' });
|
||||||
|
|
||||||
|
expect(useAppStore.getState().activeModal).toBeNull();
|
||||||
|
expect(useSnippetStore.getState().activeSnippetId).toBe('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dead snippet id falls back to the default view and cleans the URL', () => {
|
||||||
|
useSnippetStore.getState().hydrate([], null);
|
||||||
|
window.location.hash = '#snippet-gone';
|
||||||
|
|
||||||
|
applyView({ kind: 'snippet', snippetId: 'gone' });
|
||||||
|
|
||||||
|
expect(useSnippetStore.getState().activeSnippetId).toBeNull();
|
||||||
|
expect(window.location.hash).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring the Datasets list opens the manager on its list view', () => {
|
||||||
|
applyView({ kind: 'datasets' });
|
||||||
|
expect(useAppStore.getState().activeModal).toBe('datasets');
|
||||||
|
expect(useDatasetStore.getState().view).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring a specific dataset selects it in the open manager', () => {
|
||||||
|
useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]);
|
||||||
|
|
||||||
|
applyView({ kind: 'dataset', datasetId: 7 });
|
||||||
|
|
||||||
|
expect(useAppStore.getState().activeModal).toBe('datasets');
|
||||||
|
expect(useDatasetStore.getState().selectedId).toBe(7);
|
||||||
|
expect(useDatasetStore.getState().view).toBe('detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dead dataset id degrades to the Datasets list and cleans the URL', () => {
|
||||||
|
useDatasetStore.getState().hydrate([]);
|
||||||
|
window.location.hash = '#datasets/dataset-99';
|
||||||
|
|
||||||
|
applyView({ kind: 'dataset', datasetId: 99 });
|
||||||
|
|
||||||
|
expect(useAppStore.getState().activeModal).toBe('datasets');
|
||||||
|
expect(useDatasetStore.getState().selectedId).toBeNull();
|
||||||
|
expect(window.location.hash).toBe('#datasets');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring the new-dataset form opens the manager on its create view', () => {
|
||||||
|
applyView({ kind: 'dataset-new' });
|
||||||
|
expect(useAppStore.getState().activeModal).toBe('datasets');
|
||||||
|
expect(useDatasetStore.getState().view).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring the Chart Builder loads its dataset and opens the builder', () => {
|
||||||
|
useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]);
|
||||||
|
|
||||||
|
applyView({ kind: 'dataset-build', datasetId: 7 });
|
||||||
|
|
||||||
|
expect(useAppStore.getState().activeModal).toBe('chartBuilder');
|
||||||
|
expect(useChartBuilderStore.getState().datasetId).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip: applyView then deriveViewState is identity', () => {
|
||||||
|
test('every store-representable view survives the round trip', () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], null);
|
||||||
|
useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]);
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ kind: 'snippets' as const },
|
||||||
|
{ kind: 'snippet' as const, snippetId: 's1' },
|
||||||
|
{ kind: 'datasets' as const },
|
||||||
|
{ kind: 'dataset' as const, datasetId: 7 },
|
||||||
|
{ kind: 'dataset-new' as const },
|
||||||
|
{ kind: 'dataset-build' as const, datasetId: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const view of views) {
|
||||||
|
applyView(view);
|
||||||
|
expect(deriveViewState()).toEqual(view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+184
-15
@@ -1,26 +1,195 @@
|
|||||||
/**
|
/**
|
||||||
* Modal ↔ URL hash sync (docs/architecture/03 → "URL & Keyboard Integration").
|
* URL hash ↔ view-state routing (spec §01E, docs/architecture/04 → "Routing").
|
||||||
*
|
*
|
||||||
* The coordinator calls these so a navigable modal (Datasets, Settings, Chart
|
* The hash is the single serialized source of the current view: selected
|
||||||
* Builder) becomes a shareable / back-navigable location, e.g.
|
* snippet, open Datasets manager (list / a dataset / the new-dataset form), or
|
||||||
* `#datasets/dataset-<id>`. Full hash routing — view-state restore on load,
|
* the Chart Builder. It is read on load to restore state, written as the user
|
||||||
* Back/Forward — is milestone M6 (spec §01E, docs/architecture/04). Until then
|
* navigates, and Back/Forward step between prior states.
|
||||||
* these are intentional no-ops so the coordinator's shape is final and M6 fills
|
*
|
||||||
* the bodies in without the call sites changing.
|
* Two directions, one guard:
|
||||||
|
*
|
||||||
|
* - **state → hash** (`syncUrlFromState`): a derive-from-stores writer. Subscribed
|
||||||
|
* to every store that can change the view, it computes the current `ViewState`
|
||||||
|
* and `pushView`s it when it differs from the URL. Deriving from state (rather
|
||||||
|
* than reacting to individual navigation calls) is what lets the *in-modal*
|
||||||
|
* dataset sub-views (`#datasets/new`, `#datasets/dataset-<id>`) reach the hash:
|
||||||
|
* those are `DatasetStore` view changes, not modal-open events.
|
||||||
|
* - **hash → state** (`applyView`): restore-on-load and Back/Forward. Drives the
|
||||||
|
* stores to match a parsed view, validating ids and cleaning a stale hash.
|
||||||
|
*
|
||||||
|
* The `applying` flag suppresses the state→hash writer while `applyView` is
|
||||||
|
* driving the stores, so a restore/Back never echoes back into a navigation.
|
||||||
|
*
|
||||||
|
* `applyView` writes `activeModal` with the bare `setActiveModal` primitive
|
||||||
|
* rather than the coordinator's `openModal`/`closeModal`: it is reflecting the
|
||||||
|
* URL *into* the stores, so it must not re-sync the URL or run the unsaved-change
|
||||||
|
* discard prompt (docs/architecture/04 §1.3).
|
||||||
|
*
|
||||||
|
* The coordinator's original `syncModalToUrl` / `clearModalFromUrl` seam is kept
|
||||||
|
* (now thin wrappers over `syncUrlFromState`) so its call sites are unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModalName } from './types';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import {
|
||||||
|
pushView,
|
||||||
|
readView,
|
||||||
|
replaceView,
|
||||||
|
serializeHash,
|
||||||
|
type ViewState,
|
||||||
|
} from '../infrastructure/url-hash';
|
||||||
import { getModalConfig } from './modal-registry';
|
import { getModalConfig } from './modal-registry';
|
||||||
|
import type { ModalName } from './types';
|
||||||
|
|
||||||
|
let applying = false;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the current view from store state — the URL's single source of truth.
|
||||||
|
* Only URL-navigable modals own the hash; `extract` / `about` / `donate` are not
|
||||||
|
* navigable (spec §01E lists no hash for them), so while one is open the URL
|
||||||
|
* keeps showing the underlying snippet view.
|
||||||
|
*/
|
||||||
|
export function deriveViewState(): ViewState {
|
||||||
|
const modal = useAppStore.getState().activeModal;
|
||||||
|
if (modal && getModalConfig(modal)?.isUrlNavigable) {
|
||||||
|
if (modal === 'chartBuilder') {
|
||||||
|
const datasetId = useChartBuilderStore.getState().datasetId;
|
||||||
|
return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'datasets' };
|
||||||
|
}
|
||||||
|
if (modal === 'datasets') {
|
||||||
|
const ds = useDatasetStore.getState();
|
||||||
|
if (ds.view === 'new') return { kind: 'dataset-new' };
|
||||||
|
if (ds.selectedId !== null) return { kind: 'dataset', datasetId: ds.selectedId };
|
||||||
|
return { kind: 'datasets' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snippetId = useSnippetStore.getState().activeSnippetId;
|
||||||
|
return snippetId ? { kind: 'snippet', snippetId } : { kind: 'snippets' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `view` already matches the current URL hash. */
|
||||||
|
function isCurrent(view: ViewState): boolean {
|
||||||
|
return serializeHash(view) === window.location.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* state → hash. Computes the derived view and pushes it when it differs from the
|
||||||
|
* URL. Suppressed while `applyView` is running (the `applying` guard) so a
|
||||||
|
* Back/Forward or load-restore never echoes a redundant navigation.
|
||||||
|
*/
|
||||||
|
function syncUrlFromState(): void {
|
||||||
|
if (applying) return;
|
||||||
|
const view = deriveViewState();
|
||||||
|
if (!isCurrent(view)) pushView(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hash → state. Drives the stores to reflect `view`; falls back and cleans the
|
||||||
|
* URL (`replaceView`, no history entry) when a referenced id no longer exists
|
||||||
|
* (deleted record, stale shared link).
|
||||||
|
*/
|
||||||
|
export function applyView(view: ViewState): void {
|
||||||
|
applying = true;
|
||||||
|
try {
|
||||||
|
const app = useAppStore.getState();
|
||||||
|
switch (view.kind) {
|
||||||
|
case 'snippets':
|
||||||
|
app.setActiveModal(null);
|
||||||
|
return;
|
||||||
|
case 'snippet': {
|
||||||
|
const exists = useSnippetStore.getState().snippets.some((s) => s.id === view.snippetId);
|
||||||
|
if (!exists) {
|
||||||
|
replaceView({ kind: 'snippets' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.setActiveModal(null);
|
||||||
|
useSnippetStore.getState().selectSnippet(view.snippetId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'datasets':
|
||||||
|
useDatasetStore.getState().select(null);
|
||||||
|
app.setActiveModal('datasets');
|
||||||
|
return;
|
||||||
|
case 'dataset':
|
||||||
|
case 'dataset-build': {
|
||||||
|
const ds = useDatasetStore.getState();
|
||||||
|
if (!ds.datasets.some((d) => d.id === view.datasetId)) {
|
||||||
|
ds.select(null);
|
||||||
|
app.setActiveModal('datasets');
|
||||||
|
replaceView({ kind: 'datasets' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ds.select(view.datasetId);
|
||||||
|
if (view.kind === 'dataset-build') {
|
||||||
|
useChartBuilderStore.getState().init(view.datasetId);
|
||||||
|
app.setActiveModal('chartBuilder');
|
||||||
|
} else {
|
||||||
|
app.setActiveModal('datasets');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'dataset-new':
|
||||||
|
app.setActiveModal('datasets');
|
||||||
|
useDatasetStore.getState().startCreate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start routing. Restores the view from the hash (must run *after* the stores
|
||||||
|
* are hydrated so ids resolve), then keeps hash ↔ state in sync via a
|
||||||
|
* `hashchange` listener (Back/Forward) and store subscriptions (navigation).
|
||||||
|
* Idempotent.
|
||||||
|
*/
|
||||||
|
export function startRouting(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
applyView(readView());
|
||||||
|
|
||||||
|
// Reflect the view the stores actually settled on (e.g. the snippet hydrate
|
||||||
|
// auto-selected) back into the URL with `replaceView` — no history entry. This
|
||||||
|
// replaces the blank load entry rather than letting the first store tick
|
||||||
|
// `pushView` it, which would leave a dead Back step at startup.
|
||||||
|
const initial = deriveViewState();
|
||||||
|
if (!isCurrent(initial)) replaceView(initial);
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
if (applying) return;
|
||||||
|
applyView(readView());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any store that can change the view writes the hash. A change that leaves the
|
||||||
|
// derived view unchanged (e.g. a draft keystroke) is a cheap no-op in
|
||||||
|
// `syncUrlFromState`'s equality check.
|
||||||
|
useAppStore.subscribe(syncUrlFromState);
|
||||||
|
useSnippetStore.subscribe(syncUrlFromState);
|
||||||
|
useDatasetStore.subscribe(syncUrlFromState);
|
||||||
|
useChartBuilderStore.subscribe(syncUrlFromState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: reset the module's start/applying flags between cases. */
|
||||||
|
export function resetRoutingForTest(): void {
|
||||||
|
started = false;
|
||||||
|
applying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coordinator seam (unchanged call sites) -------------------------------
|
||||||
|
// The coordinator calls these on modal open/close; both now just re-derive the
|
||||||
|
// hash from state. Kept as named exports so ModalCoordinator needs no edit.
|
||||||
|
|
||||||
/** Reflect an open navigable modal (and optional sub-target) in the URL hash. */
|
/** Reflect an open navigable modal (and optional sub-target) in the URL hash. */
|
||||||
export function syncModalToUrl(name: ModalName, _arg?: string): void {
|
export function syncModalToUrl(_name: ModalName, _arg?: string): void {
|
||||||
if (!getModalConfig(name)?.isUrlNavigable) return;
|
syncUrlFromState();
|
||||||
// TODO (M6, spec §01E): write `#${name}` / `#${name}/dataset-${arg}` to the
|
|
||||||
// hash via the routing layer (docs/architecture/04).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the hash to the underlying workspace when a navigable modal closes. */
|
/** Return the hash to the underlying workspace when a navigable modal closes. */
|
||||||
export function clearModalFromUrl(name: ModalName): void {
|
export function clearModalFromUrl(_name: ModalName): void {
|
||||||
if (!getModalConfig(name)?.isUrlNavigable) return;
|
syncUrlFromState();
|
||||||
// TODO (M6, spec §01E): restore the pre-modal workspace hash.
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useSnippetStore } from '../stores/SnippetStore';
|
|||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { wirePersistence } from './persistence';
|
import { wirePersistence } from './persistence';
|
||||||
import { wireDatasetPersistence } from './dataset-persistence';
|
import { wireDatasetPersistence } from './dataset-persistence';
|
||||||
|
import { startRouting } from '../modals/UrlStateSync';
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
|
|
||||||
@@ -67,4 +68,8 @@ export async function initApp(): Promise<void> {
|
|||||||
// — otherwise it would redundantly re-save every record on each startup.
|
// — otherwise it would redundantly re-save every record on each startup.
|
||||||
wirePersistence();
|
wirePersistence();
|
||||||
wireDatasetPersistence();
|
wireDatasetPersistence();
|
||||||
|
|
||||||
|
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
|
||||||
|
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
|
||||||
|
startRouting();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user