diff --git a/.circleci/config.yml b/.circleci/config.yml index 83822bea28c8..1e173a9272fe 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -187,6 +187,7 @@ jobs: # Playground and motion unit tests; rest of website omitted - project/website/src/components/Playground - project/website/src/components/motion + - project/website/src/utils - project/.yarnrc.yml - project/babel.config.js - project/eslint.config.mjs diff --git a/jest.config.js b/jest.config.js index 04402ea41909..9c7ff98720d6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -35,6 +35,8 @@ const baseConfig = { 'packages/react/src/next', 'packages/react/src/server', 'packages/react/src/components/DevToolsButton.tsx', + // website code is tested but not published, so it isn't tracked + '/website/', ], /** TODO: Remove once we move to 'publishConfig' */ moduleNameMapper: { diff --git a/website/package.json b/website/package.json index e1d033d2455e..a22d615d4297 100644 --- a/website/package.json +++ b/website/package.json @@ -82,7 +82,6 @@ "parse-numeric-range": "^1.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-json-tree": "0.20.0", "react-live": "^4.0.0", "temporal-polyfill": "^0.3.0", "typescript": "npm:@typescript/typescript6@6.0.2", diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 1e5f24e415d3..554b8689cfd8 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -6,6 +6,7 @@ import { ResetButton } from './PreviewError'; import PreviewWrapper from './PreviewWrapper'; import { useRenderCount } from './RenderCount'; import { previewScope } from './scope'; +import SchemaRegistry from './store/schemaRegistry'; import transformCode from './transformCode'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; import { usePreviewReset } from './usePreviewReset'; @@ -28,6 +29,8 @@ export default function LivePreview({ usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); const { key, restored, reset, ...handlers } = usePreviewReset(code); + // outlives remounts, so a restored store keeps its schemas + const registry = useMemo(() => new SchemaRegistry(), []); const getInterceptorData = useMemo( () => restored ? @@ -61,6 +64,7 @@ export default function LivePreview({ getInitialInterceptorData={getInterceptorData} onCommit={onCommit} initialState={restored?.state} + registry={registry} onReset={reset} {...handlers} /> diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index 4651a66a63db..0af7c3e1bc13 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -3,6 +3,7 @@ import { PollingSubscription, SubscriptionManager, NetworkManager, + type Manager, type State, } from '@data-client/react'; import { MockResolver } from '@data-client/test/browser'; @@ -19,6 +20,7 @@ import React, { import { MotionGroup } from '../../motion'; import Boundary from '../Boundary'; import type { PreviewErrorProps } from './PreviewError'; +import SchemaRegistry from './store/schemaRegistry'; import StoreInspector from './StoreInspector'; import { useTabStorage } from '../../../utils/tabStorage'; import styles from '../styles.module.css'; @@ -32,6 +34,7 @@ function Preview({ getInitialInterceptorData, onCommit, initialState, + registry, onInteract, ...errorProps }: PreviewProps & @@ -39,6 +42,8 @@ function Preview({ /** Called on every React commit of the live result (enables a ``) */ onCommit?: ProfilerOnRenderCallback; initialState?: State; + /** Schemas the Store inspector has seen */ + registry: SchemaRegistry; /** User pointer/keyboard input inside the result */ onInteract: () => void; }) { @@ -60,10 +65,17 @@ function Preview({ ], ); - const managers = useMemo( - () => [new NetworkManager(), new SubscriptionManager(PollingSubscription)], - [], - ); + const managers = useMemo(() => { + // this mount is a new store: drop the last one's pending optimistic + // updates before the Store panel first renders (DataStore's init() + // only runs after it) + registry.init(); + return [ + registry, + new NetworkManager(), + new SubscriptionManager(PollingSubscription), + ]; + }, [registry]); const coveredResult = row && selectedValue === 'y'; return ( @@ -86,7 +98,11 @@ function Preview({ - + diff --git a/website/src/components/Playground/preview/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx index 0b05d9ab697e..e0531cddec90 100644 --- a/website/src/components/Playground/preview/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -1,27 +1,32 @@ -import { StateContext } from '@data-client/react'; import clsx from 'clsx'; -import React, { useContext, useDeferredValue, memo, useMemo } from 'react'; +import React, { useDeferredValue, useState, memo } from 'react'; import { Reveal, useLayoutMotion } from '../../motion'; import styles from '../styles.module.css'; -import Tree from './Tree'; +import type SchemaRegistry from './store/schemaRegistry'; +import StorePanel from './store/StorePanel'; function StoreInspector({ toggle, selectedValue, + registry, }: { selectedValue: 'y' | 'n'; toggle: React.MouseEventHandler; + registry: SchemaRegistry; }) { const isSelected = selectedValue === 'y'; - // the empty drawer starts moving at once; the tree renders a frame later - const showTree = useDeferredValue(isSelected); + // the empty drawer starts moving at once; the tree renders a frame later, + // then stays, so reopening finds the Store as it was left + const ready = useDeferredValue(isSelected); + const [showTree, setShowTree] = useState(false); + if (ready && !showTree) setShowTree(true); return ( <> {showTree ? - + : null} @@ -53,13 +58,3 @@ export function StoreToggle({ ); } - -function StoreTree() { - const state = useContext(StateContext); - const simplifiedState = useMemo(() => { - const { optimistic, ...ret } = state; - return ret; - }, [state]); - return ; -} -const StoreTreeM = memo(StoreTree); diff --git a/website/src/components/Playground/preview/Tree.tsx b/website/src/components/Playground/preview/Tree.tsx deleted file mode 100644 index 2b1f00f86634..000000000000 --- a/website/src/components/Playground/preview/Tree.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useColorMode } from '@docusaurus/theme-common'; -import React, { useMemo, type CSSProperties } from 'react'; -import { JSONTree, type KeyPath, type ValueRenderer } from 'react-json-tree'; - -const valueColorMap = { - String: 'rgb(195, 232, 141)', - Date: 'rgb(247, 140, 108)', - Number: 'rgb(247, 140, 108)', - Boolean: 'rgb(247, 140, 108)', - Null: 'rgb(255, 88, 116)', - Undefined: 'rgb(255, 88, 116)', - Function: 'rgb(247, 140, 108)', - Symbol: 'rgb(247, 140, 108)', -}; - -export default function Output({ value }: { value: unknown }) { - const isDarkTheme = useColorMode().colorMode === 'dark'; - const theme = useMemo( - () => ({ - tree: { - overflow: 'auto', - flex: '4 1 40%', - margin: 0, - padding: '0 0.5rem 0 0.8rem', - backgroundColor: - isDarkTheme ? 'var(--ifm-pre-background)' : 'rgb(41, 45, 62)', - font: 'var(--ifm-code-font-size) / var(--ifm-pre-line-height) var(--ifm-font-family-monospace) !important', - color: 'rgb(227, 227, 227)', - }, - arrowContainer: ({ style }: { style: CSSProperties }) => ({ - style: { - ...style, - fontFamily: 'arial', - padding: '7px 7px 7px 0', - margin: '-7px calc(0.5em - 7px) -7px 0', - }, - }), - arrowSign: { - color: 'rgb(130, 170, 255)', - }, - label: { - color: 'rgb(130, 170, 255)', - }, - itemRange: { - color: 'rgb(105, 112, 152)', - }, - valueText: ( - { style }: { style: CSSProperties }, - nodeType: keyof typeof valueColorMap, - ) => ({ - style: { - ...style, - color: valueColorMap[nodeType], - }, - }), - base0B: 'rgb(191, 199, 213)', - }), - [isDarkTheme], - ); - const shouldExpandNodeInitially = useMemo(shouldExpandNode, []); - return ( - - ); -} - -const shouldExpandNode = () => { - let entityCount = 0; - let schemaCount = 0; - - return (keyName: KeyPath, _data: unknown, level: number) => { - if (level === 0) return true; - if (level === 1 && ['entities', 'results'].includes(String(keyName[0]))) - return true; - if (level === 2 && keyName[1] === 'entities') return schemaCount++ < 2; - if (level === 2 && keyName[1] === 'results') return true; - if (level === 3 && keyName[2] === 'entities') { - return entityCount++ < 4; - } - if (level === 3 && keyName[2] === 'results') return true; - return false; - }; -}; - -const timeFormatter = - typeof Intl !== 'undefined' ? - Intl.DateTimeFormat('en-US', { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - fractionalSecondDigits: 3, - }) - : undefined; - -const valueRenderer: ValueRenderer = ( - valueAsString: unknown, - value: unknown, - ...keyPath: KeyPath -) => { - const key = keyPath[0]; - if ( - timeFormatter && - typeof value === 'number' && - typeof key === 'string' && - isFinite(value) && - (key === 'date' || key.endsWith('At')) - ) { - return timeFormatter.format(value); - } - return valueAsString as string; -}; diff --git a/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx b/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx new file mode 100644 index 000000000000..649176f2a9a3 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx @@ -0,0 +1,251 @@ +/// +import { Collection, Entity } from '@data-client/endpoint'; +import { StateContext, type State } from '@data-client/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { byData } from '../store/dom'; +import { endpointId, parseRowId } from '../store/model'; +import SchemaRegistry from '../store/schemaRegistry'; +import StorePanel from '../store/StorePanel'; + +jest.mock('../../../../utils/tabStorage', () => ({ + useTabStorage: () => require('react').useState(null), +})); + +class User extends Entity { + id = ''; + name = ''; +} +class Post extends Entity { + id = ''; + title = ''; + body = ''; + summary = ''; + author = User.fromJS(); + tags: string[] = []; + static schema = { author: User }; +} +class Comment extends Entity { + id = ''; + text = ''; + static schema = {}; +} +const comments = new Collection([Comment]); + +const range = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}`); +const posts = Object.fromEntries( + range(12).map(id => [ + id, + { + id, + title: `Post title number ${id} that is long`, + body: 'A body that wants plenty of room in its column', + summary: 'Another long string field to push columns onto pages', + author: '1', + tags: ['a', 'b'], + }, + ]), +); +const commentIds = range(40); + +const state = { + meta: { + 'GET https://example.com/posts': { date: 0, expiresAt: 0 }, + 'GET https://example.com/broken': { + date: 0, + expiresAt: 0, + error: new Error('404 Not Found'), + }, + }, + entitiesMeta: { + Post: { 1: { date: 1, fetchedAt: 1, expiresAt: 2 } }, + }, + indexes: {}, + optimistic: [], + lastReset: 0, + endpoints: { + 'GET https://example.com/posts': range(12), + 'GET https://example.com/comments': '{}', + }, + entities: { + User: { 1: { id: '1', name: 'Paul' } }, + Post: posts, + Comment: Object.fromEntries( + commentIds.map(id => [id, { id, text: `comment ${id}` }]), + ), + '[Comment]': { '{}': commentIds }, + }, +} as unknown as State; + +function registry() { + const r = new SchemaRegistry(); + r.endpoints.set('GET https://example.com/posts', { + endpoint: { schema: [Post] }, + args: [], + }); + r.endpoints.set('GET https://example.com/comments', { + endpoint: { schema: comments }, + args: [], + }); + r.learn([Post]); + r.learn(comments); + return r; +} + +const top = () => + document.querySelector('[data-level]:not([data-covered])')!; +const crumbs = () => + [...top().querySelectorAll('nav > *')].map(el => el.textContent); +const press = (key: string) => + fireEvent.keyDown(document.activeElement ?? top(), { key }); + +function mount(s = state, r = registry()) { + return render( + + + , + ); +} + +describe('StorePanel table view', () => { + it('shows a few rows of every table, Collections after their Entity', () => { + mount(); + const groups = [...top().querySelectorAll('[data-table]')].map(g => + g.getAttribute('data-table'), + ); + expect(groups).toEqual(['User', 'Post', 'Comment', '[Comment]']); + const post = within(top().querySelector('[data-table=Post]')!); + expect(post.getAllByRole('row').filter(r => r.dataset.id)).toHaveLength(5); + expect(post.getByText('7 more')).toBeTruthy(); + // the error endpoint shows its message + expect(screen.getByText('404 Not Found')).toBeTruthy(); + }); + + it('marks invalidated rows and shows a lone ref however long its pk', () => { + const long = '38200029883820002988382000298838200029883820002988'; + const r = registry(); + r.endpoints.set('POST https://example.com/users', { + endpoint: { schema: [User] }, + args: [], + }); + mount( + { + ...state, + endpoints: { + ...state.endpoints, + 'POST https://example.com/users': [long], + }, + entities: { + ...state.entities, + User: { + ...state.entities.User, + 2: Symbol('INVALID'), + [long]: { id: long, name: 'Al' }, + }, + // every row invalidated: no field columns + Comment: { 1: Symbol('INVALID') }, + }, + } as unknown as State, + r, + ); + const users = within(top().querySelector('[data-table=User]')!); + expect(users.getByText('invalid')).toBeTruthy(); + // a table with no field columns marks it by the key + const comments = within(top().querySelector('[data-table=Comment]')!); + expect(comments.getByText('invalid')).toBeTruthy(); + const endpoint = byData( + top(), + 'id', + endpointId('POST https://example.com/users'), + ); + expect(endpoint?.textContent).toContain(long); + }); + + it('dives into a record and back', () => { + mount(); + fireEvent.click(within(top()).getByText('"Paul"')); + expect(crumbs()).toEqual(['State', '›', 'User 1']); + expect(within(top()).getByText('used by')).toBeTruthy(); + // a ref chip dives further + fireEvent.click(within(top()).getByRole('button', { name: 'Post 1' })); + expect(crumbs()).toEqual(['State', '›', 'User 1', '›', 'Post 1']); + act(() => press('Escape')); + expect(crumbs()).toEqual(['State', '›', 'User 1']); + fireEvent.click(within(top()).getByLabelText('Back')); + expect(top().querySelector('nav')).toBeNull(); + }); + + it('lists a whole table with a filter', () => { + mount(); + fireEvent.click(screen.getByText('7 more')); + expect(crumbs()).toEqual(['State', '›', 'Post12']); + const rows = () => + [...top().querySelectorAll('tr[data-id]')].map(r => { + const ref = parseRowId(r.getAttribute('data-id')!); + return ref?.kind === 'entity' ? ref.pk : undefined; + }); + expect(rows()).toHaveLength(12); + fireEvent.change(within(top()).getByLabelText('Filter rows'), { + target: { value: 'number 1' }, + }); + expect(rows()).toEqual(['1', '10', '11', '12']); + expect(within(top()).getByText('4 of 12')).toBeTruthy(); + fireEvent.change(within(top()).getByLabelText('Filter rows'), { + target: { value: 'nope' }, + }); + expect(within(top()).getByText('no matches')).toBeTruthy(); + }); + + it('pages fields and shows the rest below a row', () => { + mount(); + const post = () => within(top().querySelector('[data-table=Post]')!); + const headers = () => + post() + .getAllByRole('columnheader') + .map(h => h.textContent); + expect(headers()).toEqual(['id', 'title', 'body', '']); + fireEvent.click(post().getByLabelText('Next fields')); + expect(headers()).toEqual(['id', 'summary', 'author', 'tags', '']); + fireEvent.click(post().getByLabelText('Previous fields')); + + const more = post().getAllByLabelText(/Show \d+ more fields below/)[0]; + fireEvent.click(more); + expect(post().getByText('summary')).toBeTruthy(); + fireEvent.click(post().getByLabelText('Hide fields')); + expect(post().queryByLabelText('Hide fields')).toBeNull(); + fireEvent.keyDown(post().getAllByLabelText(/more fields below/)[0], { + key: 'Enter', + }); + expect(post().getByLabelText('Hide fields')).toBeTruthy(); + }); + + it('dives into long lists of refs and endpoint lists', () => { + mount(); + const collection = within(top().querySelector('[data-table="[Comment]"]')!); + // a Collection's +N opens its own record: the member table and its meta + fireEvent.click(collection.getByText(/^\+\d+$/)); + expect(crumbs()[2]).toMatch(/^\[Comment\] all$/); + expect(top().querySelectorAll('tr[data-id]').length).toBeGreaterThan(0); + expect(within(top()).getByText('used by')).toBeTruthy(); + }); + + it('opens a Collection as a table of its members', () => { + mount(); + const collection = top().querySelector('[data-table="[Comment]"]')!; + fireEvent.click(collection.querySelector('tr[data-id]')!); + // the member table, with the Collection's own meta above it + expect(top().querySelectorAll('tr[data-id]').length).toBeGreaterThan(0); + expect(within(top()).getByText('used by')).toBeTruthy(); + expect(within(top()).getByLabelText('Filter rows')).toBeTruthy(); + }); + + it('switches to the tree view', () => { + mount(); + fireEvent.click(screen.getByLabelText('Tree view')); + expect( + screen.getByLabelText('Tree view').getAttribute('aria-pressed'), + ).toBe('true'); + expect(document.querySelector('[data-table]')).toBeNull(); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/StorePanelNav.test.tsx b/website/src/components/Playground/preview/__tests__/StorePanelNav.test.tsx new file mode 100644 index 000000000000..f120f27c6d73 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StorePanelNav.test.tsx @@ -0,0 +1,357 @@ +/// +import { Collection, Endpoint, Entity, schema } from '@data-client/endpoint'; +import { StateContext, type State } from '@data-client/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { endpointId, entityId } from '../store/model'; +import SchemaRegistry from '../store/schemaRegistry'; +import StorePanel from '../store/StorePanel'; + +jest.mock('../../../../utils/tabStorage', () => ({ + useTabStorage: () => require('react').useState(null), +})); + +class User extends Entity { + id = ''; + name = ''; +} +class Post extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + static schema = { author: User }; +} +class Comment extends Entity { + id = ''; + text = ''; +} +const comments = new Collection([Comment]); +const feed = new schema.Union({ post: Post, user: User }, (v: any) => v.type); + +const range = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}`); +const commentIds = range(40); +const NOW = 1_700_000_000_000; +const url = (path: string) => `GET https://example.com/${path}`; +const POSTS = url('posts'); +const COMMENTS = url('comments'); +const FEED = url('feed'); +const NUMBERS = url('numbers'); +const OBJECTS = url('objects'); +const BROKEN = url('broken'); + +/** Enough of everything to page, dive and collapse */ +const state = { + meta: { + [POSTS]: { date: NOW, fetchedAt: NOW, expiresAt: 0 }, + [BROKEN]: { date: 0, expiresAt: 0, error: new Error('404 Not Found') }, + }, + entitiesMeta: {}, + indexes: {}, + optimistic: [], + lastReset: 0, + endpoints: { + [POSTS]: range(12), + [COMMENTS]: '{}', + [FEED]: [ + ...range(11).map(id => ({ id, schema: 'post' })), + { id: '1', schema: 'user' }, + ], + [NUMBERS]: Array.from({ length: 25 }, (_, i) => i), + [OBJECTS]: [{ a: 1 }, { b: 2 }], + ...Object.fromEntries(range(5).map(n => [url(`extra/${n}`), 'x'])), + }, + entities: { + User: { + 1: { + id: '1', + name: 'Paul', + joinedAt: NOW, + prefs: { theme: 'dark' }, + tags: ['a', 'b', 'c', 'd', 'e'], + }, + }, + Post: Object.fromEntries( + range(12).map(id => [ + id, + { id, title: `Post number ${id}`, author: '1' }, + ]), + ), + Comment: Object.fromEntries( + commentIds.map(id => [id, { id, text: `comment ${id}` }]), + ), + '[Comment]': { '{}': commentIds }, + Thing: { + a: { + n: 1, + when: new Date(0), + bad: new Date('nope'), + ok: true, + nothing: null, + }, + }, + // a Collection whose member table never arrived, with enough rows to page + '[Orphan]': Object.fromEntries( + range(9).map(n => [JSON.stringify({ page: n }), ['x']]), + ), + }, +} as unknown as State; + +function registry() { + const r = new SchemaRegistry(); + r.endpoints.set(POSTS, { + endpoint: new Endpoint(() => Promise.resolve([]), { + key: () => POSTS, + schema: [Post], + }), + args: [{ page: 1 }], + }); + // no `key()`: what useSuspense() would get cannot be computed + r.endpoints.set(COMMENTS, { endpoint: { schema: comments }, args: [] }); + r.endpoints.set(FEED, { endpoint: { schema: [feed] }, args: [] }); + r.learn([Post]); + r.learn(comments); + return r; +} + +const top = () => + document.querySelector('[data-level]:not([data-covered])')!; +const crumbs = () => + [...top().querySelectorAll('nav > *')].map(el => el.textContent); +const press = (key: string) => + fireEvent.keyDown(document.activeElement ?? top(), { key }); +/** The last button called `name` on the top level: chips come after crumbs */ +const chip = (name: string) => + within(top()).getAllByRole('button', { name }).at(-1)!; +const row = (id: string) => + top().querySelector(`tr[data-id="${id}"]`)!; +const group = (key: string) => + within(top().querySelector(`[data-table="${key}"]`)!); +const headers = () => + within(top()) + .getAllByRole('columnheader') + .map(h => h.textContent); + +function mount(s = state, r = registry()) { + const ui = (s: State) => ( + + + + ); + const result = render(ui(s)); + return { + ...result, + update: (next: State) => result.rerender(ui(next)), + }; +} + +describe('StorePanel navigation', () => { + it('collapses breadcrumbs past four levels and jumps back by crumb', () => { + mount(); + fireEvent.click(within(top()).getByText('"Paul"')); + fireEvent.click(chip('Post 1')); + fireEvent.click(chip('User 1')); + fireEvent.click(chip('Post 1')); + expect(crumbs()).toEqual(['State', '›', '…', '›', 'User 1', '›', 'Post 1']); + fireEvent.click( + within(top().querySelector('nav')!).getByRole('button', { + name: 'User 1', + }), + ); + expect(crumbs()).toEqual([ + 'State', + '›', + 'User 1', + '›', + 'Post 1', + '›', + 'User 1', + ]); + fireEvent.click( + within(top().querySelector('nav')!).getByRole('button', { + name: 'State', + }), + ); + expect(top().querySelector('nav')).toBeNull(); + }); + + it('ignores Escape at the root', () => { + mount(); + act(() => press('Escape')); + expect(top().querySelector('nav')).toBeNull(); + expect(group('Post').getByText('7 more')).toBeTruthy(); + }); + + it('dives from "used by" into a mixed list and opens a row from it', () => { + mount(); + fireEvent.click(within(top()).getByText('"Paul"')); + // the feed endpoint and 12 posts reference the user: ten chips, then a + // count that dives into all of them + expect(chip('GET /feed')).toBeTruthy(); + expect( + within(top()).getAllByRole('button', { name: /^Post \d+$/ }), + ).toHaveLength(9); + fireEvent.click(chip('+3')); + expect(crumbs()).toEqual(['State', '›', 'User 1', '›', 'used by13']); + expect(headers()).toEqual(['row', 'value']); + expect(top().querySelectorAll('tr[data-id]')).toHaveLength(13); + fireEvent.click(row(entityId('Post', '3'))); + expect(crumbs().at(-1)).toBe('Post 3'); + }); + + it('lists every endpoint past the preview and opens one with Enter', () => { + mount(); + expect(within(top()).getByText('6 more')).toBeTruthy(); + fireEvent.click(within(top()).getByText('6 more')); + expect(crumbs()).toEqual(['State', '›', 'Endpoints11']); + expect(headers()).toEqual(['key', 'status', 'value']); + expect(top().querySelectorAll('tr[data-id]')).toHaveLength(11); + const target = row(endpointId(NUMBERS)); + // keys from a chip inside the row are its own + fireEvent.keyDown(within(target).getByText('0'), { key: 'Enter' }); + expect(crumbs()).toEqual(['State', '›', 'Endpoints11']); + fireEvent.keyDown(target, { key: 'Enter' }); + expect(crumbs().at(-1)).toBe('GET /numbers'); + }); + + it('shows an endpoint record with what was stored and what it returns', () => { + mount(); + fireEvent.click(row(endpointId(POSTS))); + const show = within(top()).getByRole('group', { name: 'Show' }); + expect(within(show).getByText('Stored').getAttribute('aria-pressed')).toBe( + 'true', + ); + // 12 refs: ten chips and a count that dives into the rest + expect( + within(top()).getAllByRole('button', { name: /^Post \d+$/ }), + ).toHaveLength(10); + expect(within(top()).getByText('args')).toBeTruthy(); + expect(within(top()).getByText('page')).toBeTruthy(); + fireEvent.click(within(show).getByText('Returns')); + expect(within(top()).getByText('"Post number 1"')).toBeTruthy(); + expect(within(top()).getAllByText('"Paul"')).toHaveLength(12); + // a date in meta shows as a time of day + expect(within(top()).getAllByTitle(String(NOW)).length).toBeGreaterThan(0); + fireEvent.click(within(show).getByText('Stored')); + fireEvent.click(chip('+2')); + expect(crumbs().at(-1)).toBe('items12'); + }); + + it('explains when what an endpoint returns cannot be computed', () => { + mount(); + fireEvent.click(row(endpointId(COMMENTS))); + fireEvent.click(within(top()).getByText('Returns')); + expect(within(top()).getByText(/is not a function/)).toBeTruthy(); + expect(within(top()).queryByText('args')).toBeNull(); + }); + + it('grows long lists on request and indexes lists of objects', () => { + mount(); + fireEvent.click(row(endpointId(NUMBERS))); + expect(within(top()).queryByText('24')).toBeNull(); + fireEvent.click(within(top()).getByText('5 more')); + expect(within(top()).getByText('24')).toBeTruthy(); + // an endpoint without a known schema shows no Stored/Returns choice + expect(within(top()).queryByRole('group', { name: 'Show' })).toBeNull(); + fireEvent.click(within(top()).getByLabelText('Back')); + fireEvent.click(row(endpointId(OBJECTS))); + expect(within(top()).getByText('0')).toBeTruthy(); + expect(within(top()).getByText('b')).toBeTruthy(); + }); + + it('dives from a mixed list of refs into rows of several tables', () => { + mount(); + fireEvent.click(row(endpointId(FEED))); + fireEvent.click(chip('+2')); + expect(crumbs().at(-1)).toBe('items12'); + expect(headers()).toEqual(['row', 'value']); + expect(row(entityId('User', '1'))).toBeTruthy(); + expect(row(entityId('Post', '11'))).toBeTruthy(); + }); + + it('says so when a listed table or an open record leaves the store', () => { + const { update } = mount(); + fireEvent.click(group('Post').getByText('7 more')); + const { Post: _, ...entities } = state.entities as any; + update({ ...state, entities } as State); + expect(within(top()).getByText('No longer in the store')).toBeTruthy(); + expect(crumbs()).toEqual(['State', '›', 'Post0']); + fireEvent.click(within(top()).getByLabelText('Back')); + fireEvent.click(within(top()).getByText('"Paul"')); + const { User: __, ...rest } = entities; + update({ ...state, entities: rest } as State); + expect(within(top()).getByText('No longer in the store')).toBeTruthy(); + expect(crumbs()).toEqual(['State', '›', '…']); + }); + + it('toggles sections and lists pending optimistic updates', () => { + const r = registry(); + r.optimistic = [ + { + key: 'POST https://example.com/posts', + args: [{ title: 'optimistic' }], + fetchedAt: NOW, + }, + ]; + mount(state, r); + const optimistic = within(top()).getByRole('button', { + name: /^Optimistic/, + }); + expect(optimistic.getAttribute('aria-expanded')).toBe('true'); + expect(within(top()).getByText('POST')).toBeTruthy(); + expect(within(top()).getByText('"optimistic"')).toBeTruthy(); + fireEvent.click(optimistic); + expect(within(top()).queryByText('POST')).toBeNull(); + fireEvent.click(optimistic); + expect(within(top()).getByText('POST')).toBeTruthy(); + fireEvent.click(within(top()).getByRole('button', { name: /^Internals/ })); + expect(within(top()).getByText('lastReset')).toBeTruthy(); + }); + + it('jumps to a table from the index when there are many', () => { + const scrollTo = jest.fn(); + Element.prototype.scrollTo = scrollTo; + try { + mount(); + fireEvent.click( + within(top()).getByRole('button', { name: /^Comment\s?40$/ }), + ); + expect(scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + } finally { + delete (Element.prototype as any).scrollTo; + } + }); +}); + +describe('endpoint status', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(NOW); + }); + afterEach(() => jest.useRealTimers()); + + it('counts down until fresh data goes stale', () => { + mount({ + ...state, + meta: { + [POSTS]: { date: NOW, expiresAt: NOW + 5000 }, + [COMMENTS]: { date: NOW, expiresAt: NOW + 120_000 }, + [FEED]: { date: NOW, expiresAt: Infinity }, + [NUMBERS]: { date: NOW, expiresAt: NOW + 5000, invalidated: true }, + }, + } as unknown as State); + expect(screen.getByText('fresh 5s')).toBeTruthy(); + expect(screen.getByText('fresh 2m')).toBeTruthy(); + expect(screen.getByText('invalid')).toBeTruthy(); + // never expires: fresh without a countdown + expect(screen.getByText('fresh')).toBeTruthy(); + act(() => jest.advanceTimersByTime(1000)); + expect(screen.getByText('fresh 4s')).toBeTruthy(); + act(() => jest.advanceTimersByTime(5000)); + expect(screen.queryByText(/fresh \ds/)).toBeNull(); + expect(screen.getAllByText('stale').length).toBeGreaterThan(0); + expect(screen.getByText('fresh')).toBeTruthy(); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx new file mode 100644 index 000000000000..1ec36b6ebeb4 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx @@ -0,0 +1,77 @@ +/// +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import type { EntityTable, StoreModel } from '../store/model'; +import { endpointId, entityId } from '../store/model'; +import { + groupId, + ROW_LIMIT, + sectionId, + showAllId, + StoreUIProvider, + useStoreUI, +} from '../store/StoreUI'; + +const table = (key: string): EntityTable => ({ + key, + kind: 'entity', + rows: [], + fields: [], + get: () => undefined, +}); +const model = (...keys: string[]) => + ({ endpoints: [], tables: keys.map(table) }) as unknown as StoreModel; + +let ui: ReturnType; +function Probe() { + ui = useStoreUI(); + return null; +} +const onReveal = jest.fn(); +const mount = (m: StoreModel) => ( + + + +); + +describe('StoreUI', () => { + it('keeps groups open or closed when tables arrive later', () => { + const { rerender } = render(mount(model('User', 'Post', '[Comment]'))); + expect(ui.isOpen(groupId('[Comment]'))).toBe(true); + act(() => ui.toggle(groupId('Post'))); + expect(ui.isOpen(groupId('Post'))).toBe(false); + + // a new Entity type sorts ahead of the Collection + rerender(mount(model('User', 'Post', 'Comment', '[Comment]'))); + expect(ui.isOpen(groupId('Post'))).toBe(false); + expect(ui.isOpen(groupId('[Comment]'))).toBe(true); + expect(ui.isOpen(groupId('Comment'))).toBe(false); + }); + + it('reveal opens everything leading to a row', () => { + const rows = Array.from({ length: ROW_LIMIT + 1 }, (_, i) => ({ + id: entityId('Late', `${i}`), + })) as unknown as EntityTable['rows']; + const m = { + endpoints: [{ id: endpointId('GET /x') }], + tables: ['A', 'B', 'C'].map(table).concat({ ...table('Late'), rows }), + } as unknown as StoreModel; + render(mount(m)); + act(() => ui.toggle(sectionId('entities'))); + const target = entityId('Late', `${ROW_LIMIT}`); + act(() => ui.reveal(target)); + expect(ui.selected).toBe(target); + expect(onReveal).toHaveBeenCalledWith(target); + for (const id of [ + target, + sectionId('entities'), + groupId('Late'), + showAllId(groupId('Late')), + ]) + expect(ui.isOpen(id)).toBe(true); + + act(() => ui.reveal(endpointId('GET /x'))); + expect(ui.isOpen(sectionId('endpoints'))).toBe(true); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/storeColumns.test.ts b/website/src/components/Playground/preview/__tests__/storeColumns.test.ts new file mode 100644 index 000000000000..2541b4ee5e29 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/storeColumns.test.ts @@ -0,0 +1,86 @@ +/// +import { + columnWidth, + idWidth, + MORE_WIDTH, + NARROW_WIDTH, + pageColumns, +} from '../store/columns'; +import { entityId, type EntityRow } from '../store/model'; +import { plain, type VNode } from '../store/refs'; + +const row = (pk: string, raw: unknown): EntityRow => ({ + id: entityId('T', pk), + pk, + table: 'T', + raw, + value: plain(raw), + meta: undefined, +}); +const field = (name: string) => (r: EntityRow) => + r.value.t === 'obj' ? + r.value.entries.find(([k]) => k === name)?.[1] + : undefined; + +describe('store columns', () => { + it('sizes a column by the widest sampled value, within bounds', () => { + const rows = [ + row('1', { name: 'Al', createdAt: 1_700_000_000_000, n: 5 }), + row('2', { name: 'A much longer name than the other', n: 123 }), + ]; + // never narrower than 48, never wider than the cap + expect(columnWidth(rows, field('n'), 'n', 560)).toBe(48); + expect(columnWidth(rows, field('name'), 'name', 560)).toBe(232); + expect(columnWidth(rows, field('name'), 'name', NARROW_WIDTH - 1)).toBe( + 150, + ); + // a timestamp shows as a time of day, whatever its digits + expect(columnWidth(rows, field('createdAt'), 'createdAt', 560)).toBe( + Math.round(14 * 7.2 + 20), + ); + // missing values and nested objects have a fixed size + expect(columnWidth([], field('x'), 'x', 560)).toBe(48); + expect(columnWidth([row('1', { o: { a: 1 } })], field('o'), 'o', 560)).toBe( + 232, + ); + }); + + it('sizes lists by their first items plus room for the rest', () => { + const short: VNode = { t: 'arr', items: [{ t: 'val', v: 'a' }] }; + const long: VNode = { + t: 'arr', + items: Array.from({ length: 5 }, () => ({ t: 'val', v: 'a' })), + }; + const refs: VNode = { + t: 'arr', + items: [{ t: 'ref', key: 'User', pk: '1' }], + }; + const width = (node: VNode) => + columnWidth([row('1', {})], () => node, 'x', 560); + expect(width(long)).toBeGreaterThan(width(short) + 44); + expect(width(refs)).toBeGreaterThan(width(short)); + }); + + it('sizes the id column by the prettiest key', () => { + expect(idWidth([row('1', {})])).toBe(Math.round(7.2 + 32)); + expect(idWidth([row('{"userId":"1"}', {})])).toBe( + Math.round('userId: 1'.length * 7.2 + 32), + ); + expect(idWidth([row('x'.repeat(50), {})])).toBe(140); + }); + + it('pages columns to fit, leaving room for the +N column', () => { + const cols = [{ want: 100 }, { want: 200 }, { want: 150 }, { want: 400 }]; + expect(pageColumns(cols, 50, 1000)).toEqual([cols]); + const pages = pageColumns(cols, 50, 400); + expect(pages.map(p => p.map(c => c.want))).toEqual([ + [100, 200], + [150], + [400], + ]); + // a column wider than a page still gets one + expect(pageColumns([{ want: 900 }], 50, 400)).toEqual([[{ want: 900 }]]); + expect(pageColumns([], 50, 400)).toEqual([[]]); + expect(MORE_WIDTH).toBeGreaterThan(0); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts new file mode 100644 index 000000000000..934ce72ab8ff --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -0,0 +1,521 @@ +/// +import { Collection, Entity, schema } from '@data-client/endpoint'; +import { actionTypes, type State } from '@data-client/react'; + +import { + buildModel, + endpointId, + entityId, + findRow, + isChanged, + isInvalidated, + parseRowId, + prettyPk, + referrersOf, + rowLabel, + splitKey, +} from '../store/model'; +import { membersOf, refsList } from '../store/nav'; +import { CIRCULAR, plain, resolve, resolveRow } from '../store/refs'; +import SchemaRegistry from '../store/schemaRegistry'; + +class User extends Entity { + id = ''; + name = ''; +} +class Comment extends Entity { + id = ''; + commenter = User.fromJS(); + static schema = { commenter: User }; +} +class Post extends Entity { + id = ''; + author = User.fromJS(); + comments: Comment[] = []; + static schema = { + author: User, + comments: new Collection([Comment], { + nestKey: (parent: any) => ({ postId: parent.id }), + }), + }; +} + +const state = { + meta: {}, + entitiesMeta: {}, + indexes: {}, + optimistic: [], + lastReset: 0, + endpoints: { 'GET https://example.com/posts': ['1'] }, + entities: { + User: { 123: { id: '123', name: 'Paul' } }, + Comment: { 249: { id: '249', commenter: '123' } }, + Post: { 1: { id: '1', author: '123', comments: '{"postId":"1"}' } }, + '[Comment]': { '{"postId":"1"}': ['249'] }, + }, +} as unknown as State; + +function registryFor(key: string, s: any) { + const registry = new SchemaRegistry(); + registry.endpoints.set(key, { endpoint: { schema: s }, args: [] }); + registry.learn(s); + return registry; +} + +describe('store model', () => { + it('learns every entity table reachable from an endpoint schema', () => { + const registry = registryFor('k', [Post]); + expect([...registry.entities.keys()].sort()).toEqual( + ['Comment', 'Post', 'User', '[Comment]'].sort(), + ); + }); + + it('resolves references through Collections and Unions', () => { + const union = new schema.Union( + { users: User, posts: Post }, + (input: any) => input.type, + ); + expect(resolve({ id: '1', schema: 'posts' }, union)).toEqual({ + t: 'ref', + key: 'Post', + pk: '1', + }); + expect(resolve([{ id: '123', schema: 'users' }], [union])).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: 'User', pk: '123' }], + }); + }); + + it('resolves a Query through the schema it wraps', () => { + const comments = Post.schema.comments; + const query = new schema.Query(comments, (entries: any) => entries); + expect(resolve('{"postId":"1"}', query)).toEqual({ + t: 'ref', + key: '[Comment]', + pk: '{"postId":"1"}', + }); + }); + + it('resolves what Collection adders store to the members they change', () => { + const comments = Post.schema.comments; + const refs = { t: 'arr', items: [{ t: 'ref', key: 'Comment', pk: '249' }] }; + for (const adder of [ + comments.push, + comments.unshift, + comments.remove, + comments.move, + comments.addWith(() => true), + ]) + expect(resolve(['249'], adder)).toEqual(refs); + const byId = new Collection(new schema.Values(Comment)); + for (const adder of [byId.assign, byId.remove]) + expect(resolve({ a: '249' }, adder)).toEqual({ + t: 'obj', + entries: [['a', { t: 'ref', key: 'Comment', pk: '249' }]], + }); + // subclassed Collections are still stored by their own pk + class MyCollection extends Collection {} + for (const collection of [comments, new MyCollection([Comment])]) + expect(resolve('{"postId":"1"}', collection)).toEqual({ + t: 'ref', + key: '[Comment]', + pk: '{"postId":"1"}', + }); + }); + + it('marks invalidated rows, which keep the id field dropped', () => { + const registry = registryFor('k', [User]); + const model = buildModel( + { + ...state, + entities: { + User: { ...state.entities.User, 9: Symbol('INVALID') }, + }, + } as unknown as State, + registry, + ); + const users = model.table('User')!; + expect(users.pkField).toBe('id'); + expect(users.fields).toEqual(['name']); + expect(isInvalidated(users.get('9')!)).toBe(true); + expect(isInvalidated(users.get('123')!)).toBe(false); + }); + + it('keeps id as data when an Entity is keyed by another field', () => { + class Article extends Entity { + id = 0; + slug = ''; + title = ''; + pk() { + return this.slug; + } + } + const registry = registryFor('k', [Article]); + registry.learn([User]); + const model = buildModel( + { + ...state, + entities: { + Article: { intro: { id: 7, slug: 'intro', title: 'Hi' } }, + User: state.entities.User, + }, + } as unknown as State, + registry, + ); + expect(model.table('Article')?.fields).toEqual(['id', 'slug', 'title']); + expect(model.table('User')?.fields).toEqual(['name']); + }); + + it('lists the members of array and Values Collections', () => { + const byId = new Collection(new schema.Values(Comment)); + const registry = registryFor('GET https://example.com/posts', [Post]); + registry.learn(byId); + const model = buildModel( + { + ...state, + entities: { + ...state.entities, + [byId.key]: { '{}': { a: '249' } }, + }, + } as unknown as State, + registry, + ); + const members = (key: string, pk: string) => + membersOf(model, model.table(key)!.get(pk)!); + expect(members('[Comment]', '{"postId":"1"}')).toEqual({ + kind: 'list', + label: '[Comment]', + table: 'Comment', + pks: ['249'], + }); + expect(members(byId.key, '{}')).toMatchObject({ pks: ['249'] }); + expect(members('User', '123')).toBeUndefined(); + }); + + it('resolves Values, Object, Lazy and Invalidate members', () => { + const ref = (key: string, pk: string) => ({ t: 'ref', key, pk }); + expect(resolve({ a: '123' }, new schema.Values(User))).toEqual({ + t: 'obj', + entries: [['a', ref('User', '123')]], + }); + expect( + resolve({ user: '123', n: 1 }, new schema.Object({ user: User })), + ).toEqual({ + t: 'obj', + entries: [ + ['user', ref('User', '123')], + ['n', { t: 'val', v: 1 }], + ], + }); + expect(resolve(['123'], new schema.Lazy([User]))).toEqual({ + t: 'arr', + items: [ref('User', '123')], + }); + expect(resolve('1', new schema.Invalidate(Post))).toEqual(ref('Post', '1')); + }); + + it('links standalone Scalar cells and shows entity-field tuples plainly', () => { + const scalar = new schema.Scalar({ + lens: (args: any) => args[0]?.portfolio, + key: 'portfolio', + entity: User, + }); + expect(resolve(['User|1|a'], [scalar])).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: scalar.key, pk: 'User|1|a' }], + }); + expect(resolve(['1', 'pct', 'User'], scalar)).toEqual({ + t: 'arr', + items: ['1', 'pct', 'User'].map(v => ({ t: 'val', v })), + }); + }); + + it('keeps error details', () => { + const error = Object.assign(new TypeError('boom'), { status: 500 }); + const entries = (plain(error) as any).entries.map(([k]: any) => k); + expect(entries).toEqual(['name', 'message', 'status', 'stack']); + expect((plain(error) as any).entries[1][1]).toEqual({ + t: 'val', + v: 'boom', + }); + }); + + it('keeps own error fields once and stops at cycles', () => { + const error = Object.assign(new Error('boom'), { name: 'HttpError' }); + const keys = (plain(error) as any).entries.map(([k]: any) => k); + expect(keys).toEqual(['name', 'message', 'stack']); + const post: any = { id: '1', tags: [] }; + post.author = { id: '2', posts: [post] }; + post.tags.push(post.tags); + const shared = { n: 1 }; + expect(plain({ post, a: shared, b: shared })).toEqual({ + t: 'obj', + entries: [ + [ + 'post', + { + t: 'obj', + entries: [ + ['id', { t: 'val', v: '1' }], + ['tags', { t: 'arr', items: [{ t: 'val', v: CIRCULAR }] }], + [ + 'author', + { + t: 'obj', + entries: [ + ['id', { t: 'val', v: '2' }], + ['posts', { t: 'arr', items: [{ t: 'val', v: CIRCULAR }] }], + ], + }, + ], + ], + }, + ], + ['a', { t: 'obj', entries: [['n', { t: 'val', v: 1 }]] }], + ['b', { t: 'obj', entries: [['n', { t: 'val', v: 1 }]] }], + ], + }); + }); + + it('lists endpoints that only have meta', () => { + const failed = { + ...state, + meta: { 'GET /broken': { error: new Error('x'), date: 1 } }, + } as unknown as State; + const model = buildModel(failed, new SchemaRegistry()); + expect(model.endpoints.map(e => e.key)).toEqual([ + 'GET https://example.com/posts', + 'GET /broken', + ]); + expect(isChanged(state, failed, endpointId('GET /broken'))).toBe(true); + expect( + isChanged(state, failed, endpointId('GET https://example.com/posts')), + ).toBe(false); + }); + + it('reads row ids back', () => { + expect(parseRowId(endpointId('GET /posts'))).toEqual({ + kind: 'endpoint', + key: 'GET /posts', + }); + expect(parseRowId(entityId('[Comment]', '{"postId":"1"}'))).toEqual({ + kind: 'entity', + table: '[Comment]', + pk: '{"postId":"1"}', + }); + expect(parseRowId('something else')).toBeUndefined(); + const model = buildModel(state, new SchemaRegistry()); + expect(findRow(model, entityId('User', '123'))?.id).toBe( + entityId('User', '123'), + ); + expect(findRow(model, endpointId('GET /nope'))).toBeUndefined(); + }); + + it('falls back to plain values when the stored shape does not match', () => { + const val = (v: unknown) => ({ t: 'val', v }); + expect(resolve('x', new schema.Values(User))).toEqual(val('x')); + expect(resolve('x', new schema.Array(User))).toEqual(val('x')); + expect(resolve('x', [User])).toEqual(val('x')); + expect(resolve({ id: '1' }, User)).toEqual({ + t: 'obj', + entries: [['id', val('1')]], + }); + const union = new schema.Union({ users: User }, (i: any) => i.type); + expect(resolve({ id: '1', schema: 'nope' }, union)).toEqual({ + t: 'obj', + entries: [ + ['id', val('1')], + ['schema', val('nope')], + ], + }); + expect(resolveRow('x', undefined)).toEqual(val('x')); + expect(resolveRow('x', User)).toEqual(val('x')); + expect(resolveRow(['249'], Post.schema.comments)).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: 'Comment', pk: '249' }], + }); + }); + + it('indexes who references each entity', () => { + const model = buildModel( + state, + registryFor('GET https://example.com/posts', [Post]), + ); + const labels = (key: string, pk: string) => + referrersOf(model, entityId(key, pk)).map(rowLabel); + expect(labels('Post', '1')).toEqual(['GET /posts']); + expect(labels('User', '123')).toEqual(['Comment 249', 'Post 1']); + expect(labels('Comment', '249')).toEqual(['[Comment] postId: 1']); + expect(model.tables.map(t => [t.key, t.kind])).toEqual([ + ['User', 'entity'], + ['Comment', 'entity'], + ['[Comment]', 'collection'], + ['Post', 'entity'], + ]); + }); + + it('shows unknown schemas as plain values', () => { + const model = buildModel(state, new SchemaRegistry()); + expect(model.endpoints[0].value).toEqual({ + t: 'arr', + items: [{ t: 'val', v: '1' }], + }); + expect(referrersOf(model, entityId('User', '123'))).toEqual([]); + }); + + it('orders Collections after their Entity, orphans next, then Scalars', () => { + const cell = new schema.Scalar({ + lens: (args: any) => args[0]?.portfolio, + key: 'portfolio', + entity: User, + }); + const registry = registryFor('GET https://example.com/posts', [Post]); + registry.learn(cell); + const model = buildModel( + { + ...state, + entities: { + 'Scalar(portfolio)': { 'User|123|a': 1 }, + '[Orphan]': { '{}': ['x'] }, + ...state.entities, + }, + } as unknown as State, + registry, + ); + expect(model.tables.map(t => `${t.key}:${t.kind}`)).toEqual([ + 'User:entity', + 'Comment:entity', + '[Comment]:collection', + 'Post:entity', + '[Orphan]:collection', + 'Scalar(portfolio):scalar', + ]); + }); + + it('prints Collection args as labels', () => { + expect(prettyPk('{}')).toBe('all'); + expect(prettyPk('{"userId":"1","page":2}')).toBe('userId: 1, page: 2'); + expect(prettyPk('{not json')).toBe('{not json'); + expect(prettyPk('plain')).toBe('plain'); + }); + + it('guesses table kinds without a schema', () => { + const model = buildModel( + { + ...state, + entities: { Thing: { a: { n: 1 } }, '[Thing]': { '{}': ['a'] } }, + } as unknown as State, + new SchemaRegistry(), + ); + expect(model.tables.map(t => t.kind)).toEqual(['unknown', 'collection']); + }); + + it('records endpoints and schemas from actions', async () => { + const registry = new SchemaRegistry(); + const next = jest.fn(() => Promise.resolve()); + const dispatch = registry.middleware({} as any)(next); + await dispatch({ + type: actionTypes.SET_RESPONSE, + key: 'k', + endpoint: { schema: [Post] }, + args: [1], + } as any); + await dispatch({ type: actionTypes.SET, schema: Comment } as any); + await dispatch({ type: actionTypes.RESET } as any); + expect(next).toHaveBeenCalledTimes(3); + expect(registry.endpoints.get('k')?.args).toEqual([1]); + expect(registry.entities.get('Comment')).toBe(Comment); + registry.learn(null); + registry.cleanup(); + }); + + it('tracks optimistic updates until their response arrives', () => { + const registry = new SchemaRegistry(); + const dispatch = registry.middleware({} as any)(() => Promise.resolve()); + const endpoint = { getOptimisticResponse: () => 1, sideEffect: true }; + const fetch = (fetchedAt: number) => + dispatch({ + type: actionTypes.FETCH, + key: 'k', + endpoint, + args: [fetchedAt], + meta: { fetchedAt }, + } as any); + fetch(1); + fetch(2); + dispatch({ + type: actionTypes.FETCH, + key: 'plain', + endpoint: {}, + args: [], + meta: { fetchedAt: 3 }, + } as any); + expect(registry.optimistic.map(o => o.fetchedAt)).toEqual([1, 2]); + dispatch({ + type: actionTypes.SET_RESPONSE, + key: 'k', + endpoint, + args: [1], + meta: { fetchedAt: 1 }, + } as any); + expect(registry.optimistic.map(o => o.fetchedAt)).toEqual([2]); + expect(buildModel(state, registry).optimistic).toBe(registry.optimistic); + dispatch({ type: actionTypes.RESET } as any); + expect(registry.optimistic).toEqual([]); + // a remounted preview starts over without pending updates + fetch(4); + registry.init(); + expect(registry.optimistic).toEqual([]); + }); + + it('finds changed rows', () => { + const next = { + ...state, + entities: { + ...state.entities, + User: { 123: { id: '123', name: 'Paul Jones' } }, + }, + }; + expect(isChanged(state, next, entityId('User', '123'))).toBe(true); + expect(isChanged(state, next, entityId('Post', '1'))).toBe(false); + // shouldUpdate() can keep the row but still refresh its meta + const refetched = { + ...state, + entitiesMeta: { + ...state.entitiesMeta, + Post: { 1: { date: 1, fetchedAt: 1, expiresAt: 2 } }, + }, + }; + expect(isChanged(state, refetched, entityId('Post', '1'))).toBe(true); + }); + + it('learns recursive schemas', () => { + const tree = new schema.Object({}); + tree.define({ children: [tree], author: User }); + const registry = new SchemaRegistry(); + registry.learn(tree); + expect(registry.entities.get('User')).toBe(User); + }); + + it('lists a referenced row once', () => { + const ref = (pk: string) => ({ t: 'ref', key: 'User', pk }) as const; + expect(refsList([ref('1'), ref('2'), ref('1')], 'x')).toMatchObject({ + pks: ['1', '2'], + }); + }); + + it('splits endpoint keys', () => { + expect(splitKey('GET /relative')).toEqual({ + method: 'GET', + path: '/relative', + }); + expect(splitKey('GET https://example.com/posts?page=2')).toEqual({ + method: 'GET', + path: '/posts?page=2', + }); + expect(splitKey('custom key')).toEqual({ + method: '', + path: 'custom key', + }); + }); +}); diff --git a/website/src/components/Playground/preview/store/Details.tsx b/website/src/components/Playground/preview/store/Details.tsx new file mode 100644 index 000000000000..00d6afad872e --- /dev/null +++ b/website/src/components/Playground/preview/store/Details.tsx @@ -0,0 +1,261 @@ +import { StateContext, useController } from '@data-client/react'; +import clsx from 'clsx'; +import React, { useContext, useEffect, useState } from 'react'; + +import { + errorText, + referrersOf, + type EndpointRow, + type EntityRow, + type StoreModel, +} from './model'; +import { plain } from './refs'; +import type { EndpointRecord } from './schemaRegistry'; +import styles from './store.module.css'; +import { Block, Field, formatTime, RefList, RowChip } from './Value'; + +type Meta = EndpointRow['meta']; + +/** fresh (with countdown), stale, error or invalidated */ +export function Status({ meta }: { meta: Meta }) { + const now = useNow(meta?.expiresAt); + if (!meta) return null; + if (meta.error) + return error; + if (meta.invalidated) + return invalid; + if (!isFinite(meta.expiresAt)) + return fresh; + const left = meta.expiresAt - now; + return left > 0 ? + + fresh {seconds(left)} + + : stale; +} + +/** Re-renders each second until `until` passes */ +function useNow(until: number | undefined) { + const [now, setNow] = useState(Date.now); + useEffect(() => { + // a new deadline may already have passed + setNow(Date.now()); + if (until === undefined || !isFinite(until) || until <= Date.now()) return; + const timer = setInterval(() => { + const t = Date.now(); + setNow(t); + if (t >= until) clearInterval(timer); + }, 1000); + return () => clearInterval(timer); + }, [until]); + return now; +} + +const seconds = (ms: number) => + ms < 60_000 ? `${Math.ceil(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`; + +export function EndpointDetail({ + row, + collapsedMeta, +}: { + row: EndpointRow; + /** Meta behind a disclosure (the tree view) instead of always shown */ + collapsedMeta?: boolean; +}) { + return ( +
+ + +
+ ); +} + +/** An endpoint's stored value, or what useSuspense() returns for it */ +export function EndpointBody({ row }: { row: EndpointRow }) { + const [view, setView] = useState<'stored' | 'returns'>('stored'); + const { record } = row; + return ( + <> + {record && ( + + )} + {view === 'returns' && record ? + + : } + + ); +} + +/** An endpoint's key, args and fetch meta */ +export function EndpointMeta({ + row, + collapsed, +}: { + row: EndpointRow; + collapsed?: boolean; +}) { + const { record } = row; + return ( + + + {record?.args.length ? + + : null} + + + ); +} + +function Segmented({ + label, + options, + value, + onChange, +}: { + label: string; + options: Record; + value: V; + onChange: (value: V) => void; +}) { + return ( +
+ {(Object.keys(options) as V[]).map(v => ( + + ))} +
+ ); +} + +/** What useSuspense() hands the component for this endpoint */ +function Returns({ record }: { record: EndpointRecord }) { + const controller = useController(); + const state = useContext(StateContext); + let data: unknown; + try { + data = controller.getResponse(record.endpoint, ...record.args, state).data; + } catch (e) { + data = errorText(e); + } + return ; +} + +export function EntityDetail({ + row, + model, + collapsedMeta, +}: { + row: EntityRow; + model: StoreModel; + /** Meta behind a disclosure (the tree view) instead of always shown */ + collapsedMeta?: boolean; +}) { + return ( +
+ + +
+ ); +} + +/** When a row was fetched and what references it */ +export function RowMeta({ + row, + model, + collapsed, +}: { + row: EntityRow; + model: StoreModel; + collapsed?: boolean; +}) { + const referrers = referrersOf(model, row.id); + const summary = metaSummary(row.meta); + if (referrers.length) summary.push(`used by ${referrers.length}`); + return ( + + +
+ + used by: + + {referrers.length ? + ( + + ))} + list={() => ({ + kind: 'list', + label: 'used by', + ids: referrers.map(r => r.id), + })} + /> + : nothing} +
+
+ ); +} + +/** The quieter rows below a record's data: always shown, or (`collapsed`) + * behind a `meta` disclosure that sums them up on one line */ +function MetaBlock({ + collapsed, + summary, + children, +}: { + collapsed?: boolean; + summary: readonly string[]; + children: React.ReactNode; +}) { + const list = ( +
{children}
+ ); + if (!collapsed) return list; + return ( +
+ + ▶ + meta + {summary.join(' · ')} + + {list} +
+ ); +} + +/** One row per meta field: fetchedAt, date, expiresAt, error... */ +function MetaFields({ meta }: { meta: Meta | EntityRow['meta'] }) { + const node = plain(meta); + if (node.t !== 'obj') return null; + return node.entries.map(([k, v]) => ); +} + +/** `fetched 12:11:13.045 PM · expires 12:12:13.045 PM · error` */ +function metaSummary(meta: Meta | EntityRow['meta']) { + if (!meta) return []; + const parts = [`fetched ${formatTime(meta.fetchedAt || meta.date)}`]; + parts.push( + isFinite(meta.expiresAt) ? + `expires ${formatTime(meta.expiresAt)}` + : 'never expires', + ); + if ('error' in meta && meta.error) parts.push('error'); + if ('invalidated' in meta && meta.invalidated) parts.push('invalidated'); + return parts; +} diff --git a/website/src/components/Playground/preview/store/DiveViews.tsx b/website/src/components/Playground/preview/store/DiveViews.tsx new file mode 100644 index 000000000000..fc1553efb78e --- /dev/null +++ b/website/src/components/Playground/preview/store/DiveViews.tsx @@ -0,0 +1,354 @@ +import React, { + useDeferredValue, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { EndpointBody, EndpointMeta, EntityDetail, RowMeta } from './Details'; +import { offsetIn } from './dom'; +import { + findRow, + isEndpointRow, + prettyPk, + type AnyRow, + type EntityTable, +} from './model'; +import { membersOf, useNav, type ListView } from './nav'; +import styles from './store.module.css'; +import { + endpointColumns, + mixedColumns, + Pager, + RowsTable, + tableColumns, + type Column, +} from './Table'; +import { Block } from './Value'; + +type Scroller = React.RefObject; +type Header = (tools: React.ReactNode) => React.ReactNode; + +/** Rows rendered beyond the visible ones, on each side */ +const OVERSCAN = 20; +/** Row height until one is measured */ +const ROW_GUESS = 28; + +/** Every row of a table, or of a list of refs, with a filter. Only rows on + * screen render, so it stays fast at any size */ +export function ListView({ + view, + scroller, + header, +}: { + view: ListView; + scroller: Scroller; + header: Header; +}) { + const { model } = useNav()!; + if ('table' in view) { + const table = model.table(view.table); + return table ? + + : <> + {header(null)} + + ; + } + return ; +} + +/** Rows of one table: its columns, paged, with `+N` and meta columns */ +function TableList({ + table, + pks, + scroller, + header, +}: { + table: EntityTable; + pks: readonly string[] | undefined; + scroller: Scroller; + header: Header; +}) { + const { model, width } = useNav()!; + const [page, setPage] = useState(0); + const rows = useMemo( + () => (pks ? pks.flatMap(pk => table.get(pk) ?? []) : table.rows), + [table, pks], + ); + const { columns, pages, more } = useMemo( + () => tableColumns(table, rows, width, page, true), + [table, rows, width, page], + ); + return ( + } + scroller={scroller} + header={header} + tools={} + /> + ); +} + +/** Rows by id: endpoints, or a mix of tables */ +function IdList({ + ids, + scroller, + header, +}: { + ids: readonly string[]; + scroller: Scroller; + header: Header; +}) { + const { model, width } = useNav()!; + const rows = useMemo( + () => ids.flatMap(id => findRow(model, id) ?? []), + [model, ids], + ); + const columns = useMemo( + () => + rows.every(isEndpointRow) ? + (endpointColumns(width) as Column[]) + : mixedColumns(width), + [rows, width], + ); + return ( + + ); +} + +/** A filter box over a windowed table of `rows` */ +function FilteredRows({ + rows, + columns, + more, + record, + scroller, + header, + tools, +}: { + rows: readonly R[]; + columns: readonly Column[]; + more?: (row: R) => number; + record?: (row: R) => React.ReactNode; + scroller: Scroller; + header: Header; + tools?: React.ReactNode; +}) { + const { push } = useNav()!; + const [filter, setFilter] = useState(''); + const query = useDeferredValue(filter.trim().toLowerCase()); + const [inline, setInline] = useState(null); + const matches = useMemo( + () => (query ? rows.filter(row => searchText(row).includes(query)) : rows), + [rows, query], + ); + const openIndex = useMemo( + () => (inline ? matches.findIndex(row => row.id === inline) : -1), + [matches, inline], + ); + const { start, end, before, after, spacer } = useWindow( + scroller, + matches.length, + openIndex, + ); + // a new filter starts from the top + useLayoutEffect(() => { + if (scroller.current) scroller.current.scrollTop = 0; + }, [scroller, query]); + + return ( + <> + {header( + <> + setFilter(e.target.value)} + placeholder="filter" + aria-label="Filter rows" + /> + {tools} + , + )} + push({ kind: 'record', id: row.id })} + more={more} + inline={inline} + onInline={setInline} + record={record} + foot={ + query ? + + {matches.length ? + `${matches.length.toLocaleString()} of ${rows.length.toLocaleString()}` + : 'no matches'} + + : null + } + /> + + ); +} + +const Gone = () => ( +
+ No longer in the store +
+); + +/** Lowercase text a filter matches against, cached with the stored object */ +const searchCache = new WeakMap(); +/** JSON for searching; values JSON can't encode (bigint, cycles) fall back + * to their pk alone */ +function stringify(value: unknown) { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function searchText(row: AnyRow) { + if (isEndpointRow(row)) return row.key.toLowerCase(); + const key = row.raw && typeof row.raw === 'object' ? row.raw : null; + let text = key && searchCache.get(key); + if (text) return text; + text = `${prettyPk(row.pk)} ${stringify(row.raw)}`.toLowerCase(); + if (key) searchCache.set(key, text); + return text; +} + +/** Which of `count` rows are on screen in `scroller`; an open record + * (below row `openIndex`) adds its measured height */ +function useWindow( + scroller: React.RefObject, + count: number, + openIndex: number, +) { + const spacer = useRef(null); + const rowHeight = useRef(ROW_GUESS); + const extra = useRef(0); + const [[start, end], setRange] = useState([0, 2 * OVERSCAN]); + + // measure after each render: a row's height, and the open record's + useLayoutEffect(() => { + const row = spacer.current?.nextElementSibling as HTMLElement | null; + if (row?.dataset.id && row.offsetHeight) + rowHeight.current = row.offsetHeight; + const record = spacer.current?.parentElement?.querySelector( + `.${styles.recordRow}`, + ); + if (record) extra.current = record.offsetHeight; + }); + + useLayoutEffect(() => { + const el = scroller.current; + if (!el) return; + const update = () => { + const top = spacer.current; + if (!top) return; + const y = el.scrollTop - offsetIn(el, top); + const h = rowHeight.current; + let first = y / h; + if (openIndex >= 0 && first > openIndex + 1) + first = (y - extra.current) / h; + const from = Math.min( + Math.max(0, Math.floor(first) - OVERSCAN), + Math.max(0, count - 1), + ); + const to = Math.min( + count, + from + Math.ceil(el.clientHeight / h) + 2 * OVERSCAN, + ); + setRange(prev => + prev[0] === from && prev[1] === to ? prev : [from, to], + ); + }; + update(); + el.addEventListener('scroll', update, { passive: true }); + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update); + observer?.observe(el); + return () => { + el.removeEventListener('scroll', update); + observer?.disconnect(); + }; + }, [scroller, count, openIndex]); + + const h = rowHeight.current; + return { + start, + end, + before: + start * h + (openIndex >= 0 && openIndex < start ? extra.current : 0), + after: + Math.max(0, count - end) * h + (openIndex >= end ? extra.current : 0), + spacer, + }; +} + +/** One row with everything about it */ +/** A row's own level: its record, or a Collection's members as a table, + * with the row's meta at the bottom */ +export function RecordLevel({ + id, + scroller, + header, +}: { + id: string; + scroller: Scroller; + header: Header; +}) { + const { model } = useNav()!; + const row = findRow(model, id); + const members = useMemo(() => row && membersOf(model, row), [model, row]); + if (!row) + return ( + <> + {header(null)} + + + ); + return ( + <> + {members ? + + : <> + {header(null)} +
+
+ {isEndpointRow(row) ? + + : } +
+
+ + } +
+ {isEndpointRow(row) ? + + : } +
+ + ); +} diff --git a/website/src/components/Playground/preview/store/RootView.tsx b/website/src/components/Playground/preview/store/RootView.tsx new file mode 100644 index 000000000000..d5b5e0ce303f --- /dev/null +++ b/website/src/components/Playground/preview/store/RootView.tsx @@ -0,0 +1,212 @@ +import React, { useMemo, useState } from 'react'; + +import { TIME_WIDTH } from './columns'; +import { EntityDetail } from './Details'; +import { byData, offsetIn } from './dom'; +import { + optimisticId, + splitKey, + type EntityRow, + type EntityTable, + type StoreModel, +} from './model'; +import { useNav } from './nav'; +import { plain } from './refs'; +import { GroupLabel, Internals, SectionBlock } from './Sections'; +import styles from './store.module.css'; +import { + endpointColumns, + Pager, + RowsTable, + tableColumns, + type Column, +} from './Table'; +import { EndpointKey, Inline, Primitive } from './Value'; + +/** Rows a table shows before "N more"; tables this short show them all */ +const PREVIEW_ROWS = 5; +const SHOW_ALL_UNDER = 8; +/** Tables needed before a jump list of them is worth its line */ +const INDEX_OVER = 5; + +const preview = (rows: readonly T[]) => + rows.length > SHOW_ALL_UNDER ? rows.slice(0, PREVIEW_ROWS) : rows; + +/** The whole store at a glance: a few rows of everything */ +export default function RootView({ + scroller, +}: { + scroller: React.RefObject; +}) { + const { model, width, push } = useNav()!; + const [closed, setClosed] = useState>( + () => new Set(['Internals']), + ); + const section = (title: string, count?: number) => ({ + title, + count, + open: !closed.has(title), + onToggle: () => + setClosed(prev => { + const next = new Set(prev); + if (!next.delete(title)) next.add(title); + return next; + }), + }); + const entityCount = model.tables.reduce((n, t) => n + t.rows.length, 0); + const endpoints = preview(model.endpoints); + const hiddenEndpoints = model.endpoints.length - endpoints.length; + return ( + <> + {model.optimistic.length > 0 && ( + + ({ ...o, id: optimisticId(o) }))} + /> + + )} + + {endpoints.length > 0 && ( + push({ kind: 'record', id: row.id })} + foot={ + hiddenEndpoints > 0 && ( + + ) + } + /> + )} + + + {model.tables.length > INDEX_OVER && ( + + )} + {model.tables.map(table => ( + + ))} + + + + + + ); +} + +/** Jumps to a table further down */ +function TableIndex({ + model, + scroller, +}: { + model: StoreModel; + scroller: React.RefObject; +}) { + return ( +
+ {model.tables.map(table => ( + + ))} +
+ ); +} + +/** One table: a few rows, its fields paged to fit (the pager shows on hover, + * or after tapping the header on touch) */ +function Group({ table }: { table: EntityTable }) { + const { model, width, push } = useNav()!; + const [page, setPage] = useState(0); + const [inline, setInline] = useState(null); + const [active, setActive] = useState(false); + const rows = preview(table.rows); + const { columns, pages, more } = useMemo( + () => tableColumns(table, table.rows, width, page), + [table, width, page], + ); + const hidden = table.rows.length - rows.length; + return ( +
+
setActive(a => !a)}> + + + + +
+ + columns={columns} + rows={rows} + onOpen={row => push({ kind: 'record', id: row.id })} + more={more} + inline={inline} + onInline={setInline} + record={row => } + foot={ + hidden > 0 && ( + + ) + } + /> +
+ ); +} + +type Optimistic = StoreModel['optimistic'][number] & { id: string }; + +/** Updates applied ahead of their response */ +const optimisticColumns: Column[] = [ + { + id: 'key', + header: 'key', + width: '40%', + cell: ({ key }) => ( + + + + ), + }, + { + id: 'args', + header: 'args', + cell: ({ args }) => , + }, + { + id: 'fetchedAt', + header: 'sent', + width: TIME_WIDTH, + cell: ({ fetchedAt }) => , + }, +]; diff --git a/website/src/components/Playground/preview/store/Sections.tsx b/website/src/components/Playground/preview/store/Sections.tsx new file mode 100644 index 000000000000..436f4ea3e7cc --- /dev/null +++ b/website/src/components/Playground/preview/store/Sections.tsx @@ -0,0 +1,78 @@ +import clsx from 'clsx'; +import React from 'react'; + +import type { EntityTable, StoreModel } from './model'; +import { plain } from './refs'; +import styles from './store.module.css'; +import { Field } from './Value'; + +/** `Post 312`, with a disclosure arrow when `open` is given */ +export function GroupLabel({ + table, + open, +}: { + table: EntityTable; + open?: boolean; +}) { + return ( + <> + {open !== undefined && } + {table.key} + {table.rows.length.toLocaleString()} + {table.kind === 'collection' && ( + Collection + )} + + ); +} + +export function Chevron({ open }: { open: boolean }) { + return ( + ▶ + ); +} + +/** Collapsible block with a sticky header */ +export function SectionBlock({ + title, + count, + open, + onToggle, + chevron = false, + children, +}: { + title: string; + count?: number; + open: boolean; + onToggle: () => void; + chevron?: boolean; + children: React.ReactNode; +}) { + return ( +
+ + {open && children} +
+ ); +} + +/** Bookkeeping most people never need */ +export function Internals({ model }: { model: StoreModel }) { + return ( +
+ + +
+ ); +} diff --git a/website/src/components/Playground/preview/store/StorePanel.tsx b/website/src/components/Playground/preview/store/StorePanel.tsx new file mode 100644 index 000000000000..f36d6344ae9e --- /dev/null +++ b/website/src/components/Playground/preview/store/StorePanel.tsx @@ -0,0 +1,349 @@ +import { StateContext, type State } from '@data-client/react'; +import clsx from 'clsx'; +import React, { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { ListView, RecordLevel } from './DiveViews'; +import { flash, scrollToRow, slide } from './dom'; +import { + buildModel, + findRow, + isChanged, + isEndpointRow, + type StoreModel, +} from './model'; +import { NavContext, type Nav, type View } from './nav'; +import RootView from './RootView'; +import type SchemaRegistry from './schemaRegistry'; +import styles from './store.module.css'; +import { StoreUIProvider } from './StoreUI'; +import TreeView from './TreeView'; +import { RowKey } from './Value'; +import { useTabStorage } from '../../../../utils/tabStorage'; + +interface Entry { + readonly key: number; + readonly view: View; +} +/** Breadcrumbs shown before the middle ones collapse to `…` */ +const CRUMBS = 4; + +export default function StorePanel({ registry }: { registry: SchemaRegistry }) { + const state = useContext(StateContext); + const model = useMemo(() => buildModel(state, registry), [state, registry]); + const [stored, setView] = useTabStorage('playgroundStoreView'); + const tree = stored === 'tree'; + + const panel = useRef(null); + const width = useWidth(panel); + useFlashChanges(panel, state); + + return ( +
+
+ + State + + + Actions later + + + + + +
+ {tree ? + + : } +
+ ); +} + +/** The table view: a stack of full-panel levels. Covered levels stay + * mounted (hidden), so going back keeps their scroll, pages and filters */ +function Levels({ model, width }: { model: StoreModel; width: number }) { + // the record a level was opened from flashes once that level is back on top + const [{ stack, returnTo }, setLevels] = useState<{ + readonly stack: readonly Entry[]; + readonly returnTo: string | null; + }>({ stack: [{ key: 0, view: { kind: 'root' } }], returnTo: null }); + const nextKey = useRef(1); + const push = useCallback((view: View) => { + const key = nextKey.current++; + setLevels(prev => ({ ...prev, stack: [...prev.stack, { key, view }] })); + }, []); + const nav = useMemo