From 00feb5de4883cc5a4693dcf17aae76b1014ab670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 27 Aug 2026 01:26:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20Database=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=EC=9D=84=20=EC=A0=95=EB=B3=B8=20=EB=AA=A8=EB=93=88=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EB=A0=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference/database.md | 88 ++----- docs/api-reference/editing.md | 48 ++-- docs/public/database.md | 12 +- fixtures/external-kit/src/main.tsx | 7 +- packages/json-document-database/README.md | 5 +- .../json-document-database/src/contracts.ts | 65 +---- .../src/database-context.tsx | 24 +- .../src/database-hand.tsx | 245 ++++++++---------- .../src/database-hands.tsx | 127 +-------- .../src/database-property-control.tsx | 14 + .../src/database-view-controls.tsx | 16 ++ packages/json-document-database/src/index.ts | 9 +- .../tests/database-context.test.tsx | 11 +- .../tests/database-hand.test.tsx | 2 +- .../benchmarks/editors.mjs | 2 +- .../src/database-validation.ts | 28 +- .../json-document-editing/src/database.ts | 68 ++--- packages/json-document-editing/src/index.ts | 5 + .../tests/clipboard-surface.test.ts | 9 +- .../tests/database-editor.test.ts | 43 +-- .../tests/document-source.test.ts | 9 +- .../tests/intent.test.ts | 9 +- .../tests/web-adapters.test.ts | 9 +- .../src/database-document.ts | 15 +- .../tests/database-document.test.ts | 2 +- .../check-canonical-module-closure.mjs | 9 + .../routes/database-demo/initial-database.ts | 15 +- .../src/shared/demo-workbench/demo-sources.ts | 8 + site/tests/browser/database-demo.spec.ts | 6 +- site/tests/unit/demo-workbench.test.tsx | 4 +- 30 files changed, 387 insertions(+), 527 deletions(-) create mode 100644 packages/json-document-database/src/database-property-control.tsx create mode 100644 packages/json-document-database/src/database-view-controls.tsx diff --git a/docs/api-reference/database.md b/docs/api-reference/database.md index b4e8fa552..041bd33a0 100644 --- a/docs/api-reference/database.md +++ b/docs/api-reference/database.md @@ -14,7 +14,7 @@ createDatabaseResource>(resource: ## `createDatabaseView` ```ts -createDatabaseView(id: string, name: string, propertyIds: ReadonlyArray, ownership?: DatabaseViewDocument["ownership"]): DatabaseViewDocument +createDatabaseView(id: string, name: string, propertyIds: ReadonlyArray, ownership?: DatabaseTableView["ownership"]): DatabaseTableView ``` ## `Database` @@ -37,12 +37,7 @@ interface DatabaseCapabilities { ## `DatabaseColumnProjection` ```ts -interface DatabaseColumnProjection { - readonly propertyId: string; - readonly visible: boolean; - readonly width?: number; - readonly pinned?: "start" | "end"; -} +interface DatabaseColumnProjection extends Record { readonly propertyId: string; readonly visible: boolean; readonly width: number | null; readonly pinned: "start" | "end" | null; } ``` ## `DatabaseContextValue` @@ -52,15 +47,15 @@ interface DatabaseContextValue { readonly rows: ReadonlyArray; readonly total: number; readonly nextCursor?: string; - readonly view: DatabaseViewDocument; - readonly views: ReadonlyArray; + readonly view: DatabaseTableView; + readonly views: ReadonlyArray; readonly status: DatabaseStatus; readonly capabilities: Required; readonly selectedRowIds: ReadonlyArray; readonly activeRow: Row | null; readonly isCreating: boolean; - setView(view: DatabaseViewDocument): void; - saveView(view: DatabaseViewDocument): Promise; + setView(view: DatabaseTableView): void; + saveView(view: DatabaseTableView): Promise; selectRows(ids: ReadonlyArray): void; openRow(row: Row | null): void; startCreate(): void; @@ -86,45 +81,25 @@ interface DatabaseDeleteResult { ```ts type DatabaseFailureKind = "network" | "validation" | "conflict" | "partial" | "unknown"; ``` -## `DatabaseFilterGroup` +## `DatabaseFilter` ```ts -interface DatabaseFilterGroup { - readonly id: string; - readonly conjunction: "and" | "or"; - readonly items: ReadonlyArray; -} +interface DatabaseFilter extends Record { readonly id: string; readonly propertyId: string; readonly operator: DatabaseFilterOperator; readonly value: JSONValue; } ``` -## `DatabaseFilterOperator` +## `DatabaseFilterGroup` ```ts -type DatabaseFilterOperator = - | "equals" - | "not-equals" - | "contains" - | "greater-than" - | "greater-than-or-equal" - | "less-than" - | "less-than-or-equal" - | "is-empty"; +interface DatabaseFilterGroup extends Record { readonly id: string; readonly conjunction: "and" | "or"; readonly items: ReadonlyArray; } ``` -## `DatabaseFilterRule` +## `DatabaseFilterOperator` ```ts -interface DatabaseFilterRule { - readonly id: string; - readonly propertyId: string; - readonly operator: DatabaseFilterOperator; - readonly value?: unknown; -} +type DatabaseFilterOperator = "equals" | "not-equals" | "contains" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-empty"; ``` -## `DatabaseGroupRule` +## `DatabaseGroup` ```ts -interface DatabaseGroupRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} +interface DatabaseGroup extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } ``` ## `DatabaseHand` @@ -149,6 +124,7 @@ interface DatabaseHandChange { readonly records: ReadonlyArray; readonly origin: "cell.commit" | "record.add" | "record.delete" | "undo" | "redo"; readonly revision: number; + readonly updates?: ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }>; } ``` ## `DatabaseHandContext` @@ -303,13 +279,7 @@ interface DatabaseOperations, Upd ## `DatabaseProjection` ```ts -interface DatabaseProjection { - readonly search: string; - readonly filter: DatabaseFilterGroup; - readonly sorts: ReadonlyArray; - readonly groups: ReadonlyArray; - readonly columns: ReadonlyArray; -} +interface DatabaseProjection extends Record { readonly search: string; readonly filter: DatabaseFilterGroup; readonly sorts: ReadonlyArray; readonly groups: ReadonlyArray; readonly columns: ReadonlyArray; } ``` ## `DatabaseProvider` @@ -322,11 +292,11 @@ DatabaseProvider, Update = Partia interface DatabaseProviderProps, Update = Partial> { readonly resource: DatabaseResource; readonly operations: DatabaseOperations; - readonly defaultView: DatabaseViewDocument; - readonly view?: DatabaseViewDocument; - readonly onViewChange?: (view: DatabaseViewDocument) => void; - readonly views?: ReadonlyArray; - readonly onSaveView?: (view: DatabaseViewDocument) => Promise | void; + readonly defaultView: DatabaseTableView; + readonly view?: DatabaseTableView; + readonly onViewChange?: (view: DatabaseTableView) => void; + readonly views?: ReadonlyArray; + readonly onSaveView?: (view: DatabaseTableView) => Promise | void; readonly capabilities?: DatabaseCapabilities; readonly pageSize?: number; readonly children: ReactNode; @@ -336,7 +306,7 @@ interface DatabaseProviderProps, ```ts interface DatabaseQueryRequest { - readonly view: DatabaseViewDocument; + readonly view: DatabaseTableView; readonly cursor?: string; readonly pageSize: number; readonly signal: AbortSignal; @@ -371,10 +341,10 @@ type DatabaseRow = Record; ```ts type DatabaseRowId = string; ``` -## `DatabaseSortRule` +## `DatabaseSort` ```ts -interface DatabaseSortRule { +interface DatabaseSort extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } @@ -399,16 +369,10 @@ interface DatabaseTableProps { readonly density?: "comfortable" | "compact"; } ``` -## `DatabaseViewDocument` +## `DatabaseTableView` ```ts -interface DatabaseViewDocument { - readonly id: string; - readonly name: string; - readonly ownership: "personal" | "shared" | "locked"; - readonly layout: "table"; - readonly projection: DatabaseProjection; -} +interface DatabaseTableView extends Record { readonly id: string; readonly name: string; readonly ownership: "personal" | "shared" | "locked"; readonly layout: "table"; readonly projection: DatabaseProjection; } ``` ## `useDatabase` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 7efcf225a..26b16147d 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -149,6 +149,11 @@ interface DatabaseClipboard extends Record { readonly text: string; } ``` +## `DatabaseColumnProjection` + +```ts +interface DatabaseColumnProjection extends Record { readonly propertyId: string; readonly visible: boolean; readonly width: number | null; readonly pinned: "start" | "end" | null; } +``` ## `DatabaseDocument` ```ts @@ -177,11 +182,22 @@ interface DatabaseEditor { ## `DatabaseFilter` ```ts -interface DatabaseFilter extends Record { - readonly propertyId: string; - readonly operator: "equals"; - readonly value: JSONValue; -} +interface DatabaseFilter extends Record { readonly id: string; readonly propertyId: string; readonly operator: DatabaseFilterOperator; readonly value: JSONValue; } +``` +## `DatabaseFilterGroup` + +```ts +interface DatabaseFilterGroup extends Record { readonly id: string; readonly conjunction: "and" | "or"; readonly items: ReadonlyArray; } +``` +## `DatabaseFilterOperator` + +```ts +type DatabaseFilterOperator = "equals" | "not-equals" | "contains" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-empty"; +``` +## `DatabaseGroup` + +```ts +interface DatabaseGroup extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } ``` ## `DatabaseIntent` @@ -211,11 +227,7 @@ type DatabaseIntent = | { readonly type: "view.configure"; readonly viewId: string; - readonly propertyOrder?: ReadonlyArray; - readonly propertyVisibility?: Readonly>; - readonly propertyWidths?: Readonly>; - readonly sort?: DatabaseSort | null; - readonly filter?: DatabaseFilter | null; + readonly projection: DatabaseProjection; } | { readonly type: "clipboard.paste"; @@ -231,6 +243,11 @@ interface DatabasePoint extends Record { readonly propertyId: string; } ``` +## `DatabaseProjection` + +```ts +interface DatabaseProjection extends Record { readonly search: string; readonly filter: DatabaseFilterGroup; readonly sorts: ReadonlyArray; readonly groups: ReadonlyArray; readonly columns: ReadonlyArray; } +``` ## `DatabaseProperty` ```ts @@ -292,16 +309,7 @@ interface DatabaseSort extends Record { ## `DatabaseTableView` ```ts -interface DatabaseTableView extends Record { - readonly id: string; - readonly name: string; - readonly type: "table"; - readonly propertyOrder: ReadonlyArray; - readonly propertyVisibility: Readonly>; - readonly propertyWidths: Readonly>; - readonly sort: DatabaseSort | null; - readonly filter: DatabaseFilter | null; -} +interface DatabaseTableView extends Record { readonly id: string; readonly name: string; readonly ownership: "personal" | "shared" | "locked"; readonly layout: "table"; readonly projection: DatabaseProjection; } ``` ## `DatabaseTopology` diff --git a/docs/public/database.md b/docs/public/database.md index 43950ab5f..37b782651 100644 --- a/docs/public/database.md +++ b/docs/public/database.md @@ -1,8 +1,8 @@ # Database Database Hands는 이미 존재하는 resource schema와 CRUD API 위에 완성된 database -UX를 놓습니다. 데이터·권한 정책·업무 규칙은 host가 소유하고, Hands는 query, -projection, editing, async failure와 접근성 품질을 소유합니다. +UX를 놓습니다. 데이터·권한 정책·업무 규칙은 host가 소유하고, Editing은 +saved-view projection을, Hands는 query wiring, React editing, async failure와 접근성 품질을 소유합니다. ```text Host @@ -101,8 +101,12 @@ import { } from "@interactive-os/json-document-editing"; const editor = createDatabaseEditor(document); -const sort = nextDatabasePropertySort(view.sort, propertyId); -editor.dispatch({ type: "view.configure", viewId: view.id, sort }); +const sort = nextDatabasePropertySort(view.projection.sorts[0] ?? null, propertyId); +editor.dispatch({ + type: "view.configure", + viewId: view.id, + projection: { ...view.projection, sorts: sort === null ? [] : [sort] }, +}); const initialValue = defaultDatabaseValue(property); const value = databaseValueFromText(property, input.value); if (acceptsDatabaseValue(property, value)) { diff --git a/fixtures/external-kit/src/main.tsx b/fixtures/external-kit/src/main.tsx index 106cfcc0f..402adb39f 100644 --- a/fixtures/external-kit/src/main.tsx +++ b/fixtures/external-kit/src/main.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { createRoot } from "react-dom/client"; -import { Database, DatabaseOperationError, createDatabaseResource, createDatabaseView, type DatabaseFilterGroup, type DatabaseOperations, type DatabaseViewDocument } from "@interactive-os/json-document-database"; +import { Database, DatabaseOperationError, createDatabaseResource, createDatabaseView, type DatabaseFilter, type DatabaseFilterGroup, type DatabaseOperations, type DatabaseTableView } from "@interactive-os/json-document-database"; import "@interactive-os/json-document-database/styles.css"; import * as z from "zod/v4"; import "./styles.css"; @@ -10,7 +10,7 @@ type Task = z.infer; const resource = createDatabaseResource({ id: "delivery-tasks", schema: taskSchema, getRowId: (row) => row.id, createDraft: () => ({ title: "", owner: "", points: 0, status: "backlog", shipped: false }) }); const allTasks = createDatabaseView("all", "All delivery", ["title", "owner", "points", "status", "shipped"], "shared"); const triageBase = createDatabaseView("triage", "My triage", ["status", "title", "owner", "points", "shipped"]); -const triageView: DatabaseViewDocument = { ...triageBase, projection: { ...triageBase.projection, filter: { id: "triage:root", conjunction: "and", items: [{ id: "open", propertyId: "status", operator: "not-equals", value: "done" }] }, columns: [{ propertyId: "status", visible: true, width: 150, pinned: "start" }, { propertyId: "title", visible: true, width: 300 }, { propertyId: "owner", visible: true, width: 150 }, { propertyId: "points", visible: true, width: 110 }, { propertyId: "shipped", visible: true, width: 120 }] } }; +const triageView: DatabaseTableView = { ...triageBase, projection: { ...triageBase.projection, filter: { id: "triage:root", conjunction: "and", items: [{ id: "open", propertyId: "status", operator: "not-equals", value: "done" }] }, columns: [{ propertyId: "status", visible: true, width: 150, pinned: "start" }, { propertyId: "title", visible: true, width: 300, pinned: null }, { propertyId: "owner", visible: true, width: 150, pinned: null }, { propertyId: "points", visible: true, width: 110, pinned: null }, { propertyId: "shipped", visible: true, width: 120, pinned: null }] } }; let serverRows = Array.from({ length: 240 }, (_, index): Task => ({ id: `task-${index + 1}`, title: ["Triage customer feedback", "Polish billing settings", "Publish changelog", "Archive legacy exports"][index % 4]! + (index < 4 ? "" : ` ${index + 1}`), owner: ["Ada", "Lin", "Mina", "Theo"][index % 4]!, points: (index % 8) + 1, status: ["backlog", "progress", "done"][index % 3] as Task["status"], shipped: index % 3 === 2 })); let failureMode: "none" | "network" | "conflict" = "none"; @@ -69,9 +69,10 @@ function DatabaseApp() { function matchesGroup(row: Task, group: DatabaseFilterGroup): boolean { if (group.items.length === 0) return true; - const results = group.items.map((item) => "propertyId" in item ? matchesRule(row, item.propertyId, item.operator, item.value) : matchesGroup(row, item)); + const results = group.items.map((item) => isFilter(item) ? matchesRule(row, item.propertyId, item.operator, item.value) : matchesGroup(row, item)); return group.conjunction === "and" ? results.every(Boolean) : results.some(Boolean); } +function isFilter(item: DatabaseFilter | DatabaseFilterGroup): item is DatabaseFilter { return typeof item.propertyId === "string"; } function matchesRule(row: Task, propertyId: string, operator: string, expected: unknown): boolean { const actual = row[propertyId as keyof Task]; if (operator === "equals") return String(actual) === String(expected); diff --git a/packages/json-document-database/README.md b/packages/json-document-database/README.md index 01e767e28..9acc78cd0 100644 --- a/packages/json-document-database/README.md +++ b/packages/json-document-database/README.md @@ -1,8 +1,9 @@ # @interactive-os/json-document-database Enterprise React Database Hands for existing schemas and CRUD APIs. The host -owns data, authorization, and business rules; the package owns the interaction -quality of querying, projecting, editing, and recovering from failures. +owns data, authorization, and business rules; `@interactive-os/json-document-editing` +owns the saved-view projection contract, and this package owns the React interaction +quality of querying, editing, and recovering from failures. ```tsx import { Database, createDatabaseResource, createDatabaseView } from "@interactive-os/json-document-database"; diff --git a/packages/json-document-database/src/contracts.ts b/packages/json-document-database/src/contracts.ts index 63e6bb3f2..804a8cfb6 100644 --- a/packages/json-document-database/src/contracts.ts +++ b/packages/json-document-database/src/contracts.ts @@ -1,4 +1,5 @@ import type { ZodType } from "zod/v4"; +import type { DatabaseTableView } from "@interactive-os/json-document-editing"; export type DatabaseRow = Record; export type DatabaseRowId = string; @@ -10,64 +11,8 @@ export interface DatabaseResource readonly createDraft: () => Create; } -export type DatabaseFilterOperator = - | "equals" - | "not-equals" - | "contains" - | "greater-than" - | "greater-than-or-equal" - | "less-than" - | "less-than-or-equal" - | "is-empty"; - -export interface DatabaseFilterRule { - readonly id: string; - readonly propertyId: string; - readonly operator: DatabaseFilterOperator; - readonly value?: unknown; -} - -export interface DatabaseFilterGroup { - readonly id: string; - readonly conjunction: "and" | "or"; - readonly items: ReadonlyArray; -} - -export interface DatabaseSortRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} - -export interface DatabaseGroupRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} - -export interface DatabaseColumnProjection { - readonly propertyId: string; - readonly visible: boolean; - readonly width?: number; - readonly pinned?: "start" | "end"; -} - -export interface DatabaseProjection { - readonly search: string; - readonly filter: DatabaseFilterGroup; - readonly sorts: ReadonlyArray; - readonly groups: ReadonlyArray; - readonly columns: ReadonlyArray; -} - -export interface DatabaseViewDocument { - readonly id: string; - readonly name: string; - readonly ownership: "personal" | "shared" | "locked"; - readonly layout: "table"; - readonly projection: DatabaseProjection; -} - export interface DatabaseQueryRequest { - readonly view: DatabaseViewDocument; + readonly view: DatabaseTableView; readonly cursor?: string; readonly pageSize: number; readonly signal: AbortSignal; @@ -140,8 +85,8 @@ export function createDatabaseView( id: string, name: string, propertyIds: ReadonlyArray, - ownership: DatabaseViewDocument["ownership"] = "personal", -): DatabaseViewDocument { + ownership: DatabaseTableView["ownership"] = "personal", +): DatabaseTableView { return { id, name, @@ -152,7 +97,7 @@ export function createDatabaseView( filter: { id: `${id}:root`, conjunction: "and", items: [] }, sorts: [], groups: [], - columns: propertyIds.map((propertyId) => ({ propertyId, visible: propertyId !== "id" })), + columns: propertyIds.map((propertyId) => ({ propertyId, visible: propertyId !== "id", width: null, pinned: null })), }, }; } diff --git a/packages/json-document-database/src/database-context.tsx b/packages/json-document-database/src/database-context.tsx index b816af2cc..664eca681 100644 --- a/packages/json-document-database/src/database-context.tsx +++ b/packages/json-document-database/src/database-context.tsx @@ -15,8 +15,8 @@ import type { DatabaseResource, DatabaseRow, DatabaseRowId, - DatabaseViewDocument, } from "./contracts.js"; +import type { DatabaseTableView } from "@interactive-os/json-document-editing"; import { DatabaseOperationError } from "./contracts.js"; export interface DatabaseStatus { @@ -31,15 +31,15 @@ export interface DatabaseContextValue { readonly rows: ReadonlyArray; readonly total: number; readonly nextCursor?: string; - readonly view: DatabaseViewDocument; - readonly views: ReadonlyArray; + readonly view: DatabaseTableView; + readonly views: ReadonlyArray; readonly status: DatabaseStatus; readonly capabilities: Required; readonly selectedRowIds: ReadonlyArray; readonly activeRow: Row | null; readonly isCreating: boolean; - setView(view: DatabaseViewDocument): void; - saveView(view: DatabaseViewDocument): Promise; + setView(view: DatabaseTableView): void; + saveView(view: DatabaseTableView): Promise; selectRows(ids: ReadonlyArray): void; openRow(row: Row | null): void; startCreate(): void; @@ -66,11 +66,11 @@ const defaultCapabilities: Required = { export interface DatabaseProviderProps, Update = Partial> { readonly resource: DatabaseResource; readonly operations: DatabaseOperations; - readonly defaultView: DatabaseViewDocument; - readonly view?: DatabaseViewDocument; - readonly onViewChange?: (view: DatabaseViewDocument) => void; - readonly views?: ReadonlyArray; - readonly onSaveView?: (view: DatabaseViewDocument) => Promise | void; + readonly defaultView: DatabaseTableView; + readonly view?: DatabaseTableView; + readonly onViewChange?: (view: DatabaseTableView) => void; + readonly views?: ReadonlyArray; + readonly onSaveView?: (view: DatabaseTableView) => Promise | void; readonly capabilities?: DatabaseCapabilities; readonly pageSize?: number; readonly children: ReactNode; @@ -124,13 +124,13 @@ export function DatabaseProvider, return () => queryController.current?.abort(); }, [activeView]); // eslint-disable-line react-hooks/exhaustive-deps - function setView(next: DatabaseViewDocument) { + function setView(next: DatabaseTableView) { if (!capabilities.configureView || activeView.ownership === "locked") return; if (props.view === undefined) setInternalView(next); props.onViewChange?.(next); } - async function saveView(next: DatabaseViewDocument) { + async function saveView(next: DatabaseTableView) { if (!capabilities.saveView || next.ownership === "locked") return; await props.onSaveView?.(next); setStatus({ phase: "ready", message: `View ${next.name} saved` }); diff --git a/packages/json-document-database/src/database-hand.tsx b/packages/json-document-database/src/database-hand.tsx index 5e9c6099d..d0912cf54 100644 --- a/packages/json-document-database/src/database-hand.tsx +++ b/packages/json-document-database/src/database-hand.tsx @@ -16,11 +16,13 @@ import { type DatabaseDocument, type DatabaseEditor, type DatabaseFilter, + type DatabaseFilterGroup, type DatabaseProperty, type DatabaseRecord, type DatabaseSelection, type DatabaseSort, type DatabaseTableView, + type DatabaseTopology, type EditingResult, type EditingSnapshot, } from "@interactive-os/json-document-editing"; @@ -31,14 +33,17 @@ import { webGridCellAddressProps, } from "@interactive-os/json-document-web"; import { databaseDocumentFromZod } from "@interactive-os/json-document-zod"; +import { DatabasePropertyControl } from "./database-property-control.js"; import type { ZodType } from "zod/v4"; import type { JSONValue } from "@interactive-os/json-document"; -import { ArrowDown, ArrowUp, Columns3, Minus, Plus, Redo2, Undo2, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Minus, Plus, Redo2, Undo2 } from "lucide-react"; +import { DatabaseViewControls } from "./database-view-controls.js"; export interface DatabaseHandChange { readonly records: ReadonlyArray; readonly origin: "cell.commit" | "record.add" | "record.delete" | "undo" | "redo"; readonly revision: number; + readonly updates?: ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }>; } export interface DatabaseHandFeatures { @@ -142,6 +147,7 @@ export interface DatabaseHandDocumentChange { } type DatabaseHandOrigin = DatabaseHandChange>["origin"] | "view.configure"; +type DatabaseHandEmission = Pick, "origin" | "updates">; export type DatabaseHandProps> = DatabaseHandCommonProps & ( | DatabaseHandEditorSource @@ -194,10 +200,10 @@ function DatabaseHandDocumentProfile>(props: if (lastEmitted.current === fingerprint) { lastEmitted.current = null; return; } if (open.fingerprint !== fingerprint) setOpen({ editor: createDatabaseEditor(props.document), fingerprint }); }, [fingerprint, open.fingerprint, props.document]); - return { + return { const document = open.editor.snapshot.value as DatabaseDocument; lastEmitted.current = recordsFingerprint([document]); - props.onDocumentChange(document, { origin, revision: open.editor.snapshot.revision }); + props.onDocumentChange(document, { origin: change.origin, revision: open.editor.snapshot.revision }); }} />; } @@ -231,14 +237,14 @@ function DatabaseHandLegacyProfile>(props: D viewId={(open.editor.snapshot.value as DatabaseDocument).views[0]!.id} features={features} labels={labels} - onEmit={(origin) => { - if (origin === "view.configure") return; + onEmit={(change) => { + if (change.origin === "view.configure") return; const records = hostRecords(open.editor.snapshot.value as DatabaseDocument); const fingerprint = recordsFingerprint([records, props.presentation]); lastEmitted.current = fingerprint; props.onRecordsChange?.(records, { records, - origin, + ...change, revision: open.editor.snapshot.revision, }); }} @@ -252,13 +258,12 @@ function DatabaseTableSurface>(props: Databa readonly directEditing?: boolean; readonly features: Required; readonly labels: Required; - readonly onEmit: (origin: DatabaseHandOrigin) => void; + readonly onEmit: (change: DatabaseHandEmission | { readonly origin: "view.configure" }) => void; }) { const { editor } = props; const [announcement, setAnnouncement] = useState(""); const [lastResult, setLastResult] = useState | null>(null); const [nativeTextLease, setNativeTextLease] = useState(null); - const [filterPropertyId, setFilterPropertyId] = useState(""); const [editingKey, setEditingKey] = useState(null); const [editingInitialValue, setEditingInitialValue] = useState(); const [headerMenu, setHeaderMenu] = useState<{ readonly propertyId: string; readonly x: number; readonly y: number } | null>(null); @@ -269,12 +274,19 @@ function DatabaseTableSurface>(props: Databa const snapshot = useEditingSnapshot(editor); const document = snapshot.value as DatabaseDocument; const view = document.views.find((candidate) => candidate.id === props.viewId) ?? document.views[0]!; + const columns = view.projection.columns; + const propertyOrder = columns.map((column) => column.propertyId); + const propertyVisibility = Object.fromEntries(columns.map((column) => [column.propertyId, column.visible])); + const propertyWidths = Object.fromEntries(columns.flatMap((column) => column.width === null ? [] : [[column.propertyId, column.width]])); + const propertyPinned = Object.fromEntries(columns.flatMap((column) => column.pinned === null ? [] : [[column.propertyId, column.pinned]])); + const sort = view.projection.sorts[0] ?? null; + const filter = firstFilter(view.projection.filter); const topology = editor.tableTopology(view.id); - const properties = view.propertyOrder - .filter((id) => view.propertyVisibility[id] !== false) + const properties = propertyOrder + .filter((id) => propertyVisibility[id] !== false) .map((id) => document.schema.properties.find((property) => property.id === id)!) .filter(Boolean); - const hiddenProperties = document.schema.properties.filter((property) => view.propertyVisibility[property.id] === false); + const hiddenProperties = document.schema.properties.filter((property) => propertyVisibility[property.id] === false); const records = topology.recordIds.map((id) => document.records.find((record) => record.id === id)!).filter(Boolean); const focus = snapshot.selection.focus; const editing = useEditing({ @@ -325,14 +337,14 @@ function DatabaseTableSurface>(props: Databa return result; } - function emit(origin: DatabaseHandChange["origin"], message: string) { + function emit(change: DatabaseHandEmission, message: string) { announce(message); - props.onEmit(origin); + props.onEmit(change); } function commit(recordId: string, propertyId: string, value: string | number | boolean) { const result = observe(editor.dispatch({ type: "cell.commit", recordId, propertyId, value })); - if (result.ok) emit("cell.commit", `${propertyId} saved`); + if (result.ok) emit({ origin: "cell.commit", updates: [{ recordId, patch: { [propertyId]: value } as Partial }] }, `${propertyId} saved`); else announce(result.code); } @@ -353,19 +365,19 @@ function DatabaseTableSurface>(props: Databa recordId: id, ...(values === undefined ? {} : { values }), })); - if (result.ok) emit("record.add", "Record added"); + if (result.ok) emit({ origin: "record.add" }, "Record added"); } function deleteSelected() { const recordId = snapshot.selection.focus?.recordId; if (!recordId) return announce("Select a record first"); const result = observe(editor.dispatch({ type: "record.delete", recordId })); - if (result.ok) emit("record.delete", "Record deleted"); + if (result.ok) emit({ origin: "record.delete" }, "Record deleted"); } function history(kind: "undo" | "redo") { const result = observe(editor[kind]()); - if (result.ok) emit(kind, kind === "undo" ? "Undone" : "Redone"); + if (result.ok) emit({ origin: kind }, kind === "undo" ? "Undone" : "Redone"); } function configure(input: { @@ -375,8 +387,20 @@ function DatabaseTableSurface>(props: Databa readonly propertyOrder?: ReadonlyArray; readonly propertyWidths?: Readonly>; }) { - const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, ...input })); - if (result.ok) props.onEmit("view.configure"); + const nextSort = input.sort === undefined ? view.projection.sorts : input.sort === null ? [] : [input.sort]; + const nextFilter = input.filter === undefined ? view.projection.filter : filterGroup(view.id, input.filter); + const nextColumns = (input.propertyOrder ?? propertyOrder).map((propertyId) => { + const current = columns.find((column) => column.propertyId === propertyId); + const width = input.propertyWidths?.[propertyId] ?? current?.width; + return { + propertyId, + visible: input.propertyVisibility?.[propertyId] ?? current?.visible ?? true, + width: width ?? null, + pinned: current?.pinned ?? null, + }; + }); + const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, projection: { ...view.projection, sorts: nextSort, filter: nextFilter, columns: nextColumns } })); + if (result.ok) props.onEmit({ origin: "view.configure" }); announce(result.ok ? "View updated" : result.code); } @@ -446,35 +470,7 @@ function DatabaseTableSurface>(props: Databa ) : null} - {props.features.filter ? ( - configure({ filter })} - /> - ) : null} - {props.features.columns ? ( -
- -
- {document.schema.properties.map((property) => ( - - ))} -
-
- ) : null} + {props.features.filter || props.features.columns ? { const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, projection })); if (result.ok) props.onEmit({ origin: "view.configure" }); announce(result.ok ? "View updated" : result.code); }} /> : null} {props.toolbar} {props.renderToolbar?.(context)} {announcement ? {announcement} : null} @@ -499,7 +495,7 @@ function DatabaseTableSurface>(props: Databa if (clipboard === null) return; event.preventDefault(); const result = editor.dispatch({ type: "clipboard.paste", clipboard, topology }); - if (result.ok) emit("cell.commit", "Selection pasted"); + if (result.ok) emit({ origin: "cell.commit", updates: clipboardUpdates(clipboard, topology, snapshot.selection.focus) }, "Selection pasted"); else announce(result.code); }} > @@ -511,13 +507,13 @@ function DatabaseTableSurface>(props: Databa key={property.id} scope="col" aria-label={`${property.name} ${property.type}`} - aria-sort={ariaSort(view.sort, property.id)} + aria-sort={ariaSort(sort, property.id)} draggable onDragStart={() => setDraggedPropertyId(property.id)} onDragOver={(event) => event.preventDefault()} onDrop={() => { if (!draggedPropertyId || draggedPropertyId === property.id) return; - const order = [...view.propertyOrder]; + const order = [...propertyOrder]; const from = order.indexOf(draggedPropertyId); const to = order.indexOf(property.id); if (from < 0 || to < 0) return; @@ -530,12 +526,12 @@ function DatabaseTableSurface>(props: Databa event.preventDefault(); setHeaderMenu({ propertyId: property.id, x: event.clientX, y: event.clientY }); }} - style={{ ...columnStyle(property.id, properties, view.propertyWidths, props.presentation?.propertyPinned), position: "relative" }} - data-pinned={props.presentation?.propertyPinned?.[property.id]} + style={{ ...columnStyle(property.id, properties, propertyWidths, propertyPinned), position: "relative" }} + data-pinned={propertyPinned[property.id]} > - >(props: Databa onMouseDown={(event) => { event.preventDefault(); const startX = event.clientX; - const startWidth = view.propertyWidths[property.id] ?? 160; + const startWidth = propertyWidths[property.id] ?? 160; const finish = (up: MouseEvent) => { window.removeEventListener("mouseup", finish); - configure({ propertyWidths: { ...view.propertyWidths, [property.id]: Math.max(88, startWidth + up.clientX - startX) } }); + configure({ propertyWidths: { ...propertyWidths, [property.id]: Math.max(88, startWidth + up.clientX - startX) } }); }; window.addEventListener("mouseup", finish); }} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); - resize.current = { propertyId: property.id, startX: event.clientX, startWidth: view.propertyWidths[property.id] ?? 160 }; + resize.current = { propertyId: property.id, startX: event.clientX, startWidth: propertyWidths[property.id] ?? 160 }; }} onPointerMove={(event) => { if (resize.current?.propertyId !== property.id) return; @@ -566,13 +562,13 @@ function DatabaseTableSurface>(props: Databa resize.current = null; event.currentTarget.style.left = ""; if (!active) return; - configure({ propertyWidths: { ...view.propertyWidths, [property.id]: Math.max(88, active.startWidth + event.clientX - active.startX) } }); + configure({ propertyWidths: { ...propertyWidths, [property.id]: Math.max(88, active.startWidth + event.clientX - active.startX) } }); }} /> ))} {hiddenProperties.map((property) => ( - configure({ propertyVisibility: { ...view.propertyVisibility, [property.id]: true } })}>· + configure({ propertyVisibility: { ...propertyVisibility, [property.id]: true } })}>· ))} Row @@ -605,8 +601,8 @@ function DatabaseTableSurface>(props: Databa setEditingKey(key); requestAnimationFrame(() => findWebGridCell(tableRef.current, point)?.querySelector("input, select")?.focus()); }} - style={columnStyle(property.id, properties, view.propertyWidths, props.presentation?.propertyPinned)} - data-pinned={props.presentation?.propertyPinned?.[property.id]} + style={columnStyle(property.id, properties, propertyWidths, propertyPinned)} + data-pinned={propertyPinned[property.id]} > {custom ? custom({ property, @@ -647,9 +643,9 @@ function DatabaseTableSurface>(props: Databa const property = document.schema.properties.find((candidate) => candidate.id === headerMenu.propertyId); if (!property) return null; return
- - {filterItems(property).map((item) => )} - {view.filter?.propertyId === property.id ? : null} + + {filterItems(property).map((item) => )} + {filter?.propertyId === property.id ? : null}
; })() : null} @@ -687,28 +683,21 @@ function DefaultCell(props: { requestAnimationFrame(() => cell?.focus()); } if (props.readOnly || (!props.editing && !props.directEditing)) return {String(value)}; - if (props.property.type === "checkbox") { - return finish(event.currentTarget.checked, event.currentTarget)} onBlur={props.finish} />; - } - if (props.property.type === "select") { - return ( - - ); - } return ( - (props.property.type === "title" || props.property.type === "text") && props.onLease(false)} onCompositionStart={() => props.onLease(true)} onCompositionEnd={() => props.onLease(false)} - onBlur={(event) => { - if (props.directEditing) props.commit(databaseValueFromText(props.property, event.currentTarget.value)); + onChange={(next) => finish(next, document.activeElement as HTMLInputElement | HTMLSelectElement)} + onBlur={(next) => { + if (props.directEditing) props.commit(next); props.onLease(null); props.finish(); }} @@ -716,7 +705,10 @@ function DefaultCell(props: { cancel(event); if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); - finish(databaseValueFromText(props.property, event.currentTarget.value), event.currentTarget); + const next = event.currentTarget instanceof HTMLInputElement && event.currentTarget.type === "checkbox" + ? event.currentTarget.checked + : databaseValueFromText(props.property, event.currentTarget.value); + finish(next, event.currentTarget); props.moveAfterCommit(event.key === "Tab" ? (event.shiftKey ? "left" : "right") : (event.shiftKey ? "up" : "down")); } }} @@ -724,58 +716,6 @@ function DefaultCell(props: { ); } -function FilterControl(props: { - readonly properties: ReadonlyArray; - readonly propertyId: string; - readonly filter: DatabaseFilter | null; - readonly labels: Required; - readonly onProperty: (id: string) => void; - readonly onFilter: (filter: DatabaseFilter | null) => void; -}) { - const propertyId = props.propertyId || props.filter?.propertyId || props.properties[0]?.id || ""; - const property = props.properties.find((candidate) => candidate.id === propertyId); - const value = props.filter?.propertyId === propertyId ? props.filter.value : ""; - return ( -
- - {property ? props.onFilter({ propertyId, operator: "equals", value: next })} /> : null} - {props.filter ? : null} -
- ); -} - -function FilterValue(props: { readonly property: DatabaseProperty; readonly value: unknown; readonly onChange: (value: string | number | boolean) => void }) { - if (props.property.type === "checkbox") { - return ( - - ); - } - if (props.property.type === "select") { - return ( - - ); - } - return ( - props.onChange(databaseValueFromText(props.property, event.currentTarget.value))} - /> - ); -} - function filterItems(property: DatabaseProperty): ReadonlyArray<{ readonly label: string; readonly value: string | boolean }> { if (property.type === "select") return property.options.map((option) => ({ label: option.name, value: option.id })); if (property.type === "checkbox") return [{ label: "checked", value: true }, { label: "unchecked", value: false }]; @@ -803,19 +743,35 @@ function openDatabase>( const available = translated.value.schema.properties.map((property) => property.id); const order = presentation?.propertyOrder ? [...presentation.propertyOrder.filter((id) => available.includes(id)), ...available.filter((id) => !presentation.propertyOrder!.includes(id))] - : firstView.propertyOrder; + : firstView.projection.columns.map((column) => column.propertyId); const value: DatabaseDocument = { ...translated.value, views: [{ ...firstView, - propertyOrder: order, - propertyVisibility: presentation?.propertyVisibility ?? firstView.propertyVisibility, - propertyWidths: presentation?.propertyWidths ?? firstView.propertyWidths, + projection: { + ...firstView.projection, + columns: order.map((propertyId) => ({ + propertyId, + visible: presentation?.propertyVisibility?.[propertyId] ?? true, + width: presentation?.propertyWidths?.[propertyId] ?? null, + pinned: presentation?.propertyPinned?.[propertyId] ?? null, + })), + }, }], }; return { ok: true, editor: createDatabaseEditor(value), fingerprint }; } +function firstFilter(group: DatabaseFilterGroup): DatabaseFilter | null { + for (const item of group.items) { + const found = isFilter(item) ? item : firstFilter(item); + if (found) return found; + } + return null; +} +function isFilter(item: DatabaseFilter | DatabaseFilterGroup): item is DatabaseFilter { return typeof item.propertyId === "string"; } +function filterGroup(viewId: string, filter: DatabaseFilter | null): DatabaseFilterGroup { return { id: `${viewId}:root`, conjunction: "and", items: filter === null ? [] : [filter] }; } + function hostRecords(document: DatabaseDocument): ReadonlyArray { return document.records.map((record) => hostRecordFor(record)); } @@ -863,6 +819,19 @@ function clipboardFromData( return { type: "application/vnd.interactive-os.database+json" as const, cells, text }; } +function clipboardUpdates(clipboard: { readonly cells: ReadonlyArray> }, topology: DatabaseTopology, focus: DatabaseSelection["focus"]): ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }> { + if (focus === null) return []; + const rowStart = topology.recordIds.indexOf(focus.recordId); + const columnStart = topology.propertyIds.indexOf(focus.propertyId); + if (rowStart < 0 || columnStart < 0) return []; + return clipboard.cells.flatMap((cells, rowOffset) => { + const recordId = topology.recordIds[rowStart + rowOffset]; + if (recordId === undefined) return []; + const patch = Object.fromEntries(cells.flatMap((value, columnOffset) => topology.propertyIds[columnStart + columnOffset] === undefined ? [] : [[topology.propertyIds[columnStart + columnOffset], value]])) as Partial; + return [{ recordId, patch }]; + }); +} + function arrowDirection(key: string): "up" | "down" | "left" | "right" | null { if (key === "ArrowUp") return "up"; if (key === "ArrowDown") return "down"; diff --git a/packages/json-document-database/src/database-hands.tsx b/packages/json-document-database/src/database-hands.tsx index 935931a27..5b500137e 100644 --- a/packages/json-document-database/src/database-hands.tsx +++ b/packages/json-document-database/src/database-hands.tsx @@ -1,17 +1,11 @@ import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react"; import { databaseDocumentFromZod } from "@interactive-os/json-document-zod"; -import { ArrowLeft, ArrowRight, ArrowUpDown, Check, Columns3, ListFilter, Minus, Plus, RefreshCw, X } from "lucide-react"; -import { databaseValueFromText } from "@interactive-os/json-document-editing"; +import { Check, Minus, Plus, RefreshCw, X } from "lucide-react"; import { DatabaseHand, type DatabaseHandCellRenderProps } from "./database-hand.js"; import { DatabaseProvider, useDatabase, type DatabaseProviderProps } from "./database-context.js"; -import type { - DatabaseColumnProjection, - DatabaseFilterGroup, - DatabaseFilterOperator, - DatabaseFilterRule, - DatabaseRow, - DatabaseViewDocument, -} from "./contracts.js"; +import { DatabasePropertyControl } from "./database-property-control.js"; +import { DatabaseViewControls } from "./database-view-controls.js"; +import type { DatabaseRow } from "./contracts.js"; export interface DatabaseTableProps { readonly renderCell?: Readonly) => ReactNode>>; @@ -27,8 +21,8 @@ export function DatabaseTable(props: DatabaseTableProps const presentation = { propertyOrder: columns.map((column) => column.propertyId), propertyVisibility: Object.fromEntries(columns.map((column) => [column.propertyId, column.visible])), - propertyWidths: Object.fromEntries(columns.flatMap((column) => column.width === undefined ? [] : [[column.propertyId, column.width]])), - propertyPinned: Object.fromEntries(columns.flatMap((column) => column.pinned === undefined ? [] : [[column.propertyId, column.pinned]])), + propertyWidths: Object.fromEntries(columns.flatMap((column) => column.width === null ? [] : [[column.propertyId, column.width]])), + propertyPinned: Object.fromEntries(columns.flatMap((column) => column.pinned === null ? [] : [[column.propertyId, column.pinned]])), }; return
(props: DatabaseTableProps readOnly={!database.capabilities.update} onSelectionChange={database.selectRows} onRecordOpen={database.openRow} - onRecordsChange={(next) => { - const previous = new Map(database.rows.map((row) => [database.resource.getRowId(row), row])); - for (const row of next) { - const id = database.resource.getRowId(row); - const before = previous.get(id); - if (before && JSON.stringify(before) !== JSON.stringify(row)) { - const patch = Object.fromEntries(Object.entries(row).filter(([key, value]) => !Object.is(before[key], value))); - void database.update(id, patch as Partial); - return; - } - } - }} + onRecordsChange={(_next, change) => change.updates?.forEach(({ recordId, patch }) => { void database.update(recordId, patch); })} /> {database.capabilities.create ? : null}
; @@ -75,10 +58,6 @@ export function DatabaseViewToolbar() { const properties = resourceProperties(database.resource.schema); const canConfigure = database.capabilities.configureView && view.ownership !== "locked"; - function projection(next: Partial) { - database.setView({ ...view, projection: { ...view.projection, ...next } }); - } - return (