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:
@@ -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
|
||||
* Builder) becomes a shareable / back-navigable location, e.g.
|
||||
* `#datasets/dataset-<id>`. Full hash routing — view-state restore on load,
|
||||
* Back/Forward — is milestone M6 (spec §01E, docs/architecture/04). Until then
|
||||
* these are intentional no-ops so the coordinator's shape is final and M6 fills
|
||||
* the bodies in without the call sites changing.
|
||||
* The hash is the single serialized source of the current view: selected
|
||||
* snippet, open Datasets manager (list / a dataset / the new-dataset form), or
|
||||
* the Chart Builder. It is read on load to restore state, written as the user
|
||||
* navigates, and Back/Forward step between prior states.
|
||||
*
|
||||
* 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 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. */
|
||||
export function syncModalToUrl(name: ModalName, _arg?: string): void {
|
||||
if (!getModalConfig(name)?.isUrlNavigable) return;
|
||||
// TODO (M6, spec §01E): write `#${name}` / `#${name}/dataset-${arg}` to the
|
||||
// hash via the routing layer (docs/architecture/04).
|
||||
export function syncModalToUrl(_name: ModalName, _arg?: string): void {
|
||||
syncUrlFromState();
|
||||
}
|
||||
|
||||
/** Return the hash to the underlying workspace when a navigable modal closes. */
|
||||
export function clearModalFromUrl(name: ModalName): void {
|
||||
if (!getModalConfig(name)?.isUrlNavigable) return;
|
||||
// TODO (M6, spec §01E): restore the pre-modal workspace hash.
|
||||
export function clearModalFromUrl(_name: ModalName): void {
|
||||
syncUrlFromState();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user