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:
2026-06-07 17:18:10 +03:00
parent 8c0a6b9239
commit f365258fcc
6 changed files with 597 additions and 16 deletions
+148
View File
@@ -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);
}
});
});