mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user