diff --git a/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx b/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx new file mode 100644 index 000000000..f78362e15 --- /dev/null +++ b/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import React from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DataCanvas } from '@admin/pages/data/components/DataCanvas/DataCanvas' +import type { DataRow, DataTable } from '@core/data/schemas' + +afterEach(cleanup) + +const now = '2026-07-15T10:00:00.000Z' + +function makePagesTable(overrides: Partial = {}): DataTable { + return { + id: 'table-pages', + name: 'pages', + slug: 'pages', + kind: 'page', + singularLabel: 'Page', + pluralLabel: 'Pages', + routeBase: '', + primaryFieldId: 'title', + fields: [ + { type: 'text', id: 'title', label: 'Title', required: true, builtIn: true }, + { type: 'text', id: 'slug', label: 'Slug', required: true, builtIn: true }, + ], + system: true, + createdByUserId: null, + updatedByUserId: null, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +function makeRow(overrides: Partial = {}): DataRow { + return { + id: 'row-home', + tableId: 'table-pages', + cells: { title: 'Home', slug: 'index' }, + slug: 'index', + status: 'draft', + authorUserId: null, + createdByUserId: null, + updatedByUserId: null, + publishedByUserId: null, + author: null, + createdBy: null, + updatedBy: null, + publishedBy: null, + createdAt: now, + updatedAt: now, + publishedAt: null, + scheduledPublishAt: null, + deletedAt: null, + ...overrides, + } +} + +function renderCanvas(table: DataTable, rows: DataRow[], overrides: Partial> = {}) { + return render( + {}} + onAddRow={async () => {}} + onDeleteRow={() => {}} + onDuplicateRow={mock()} + onEditInContent={() => {}} + onOpenInSiteEditor={() => {}} + onOpenRow={() => {}} + onSetRowStatus={async (id, status) => makeRow({ id, status })} + canCreate + canEdit + canDelete + canExport={false} + {...overrides} + />, + ) +} + +describe('DataCanvas — row creation on locked system tables', () => { + it('hides "Add row" and "Duplicate row" when every field on the table is a locked built-in', () => { + renderCanvas(makePagesTable(), [makeRow()]) + + expect(screen.queryByRole('button', { name: /add row/i })).toBeNull() + + fireEvent.contextMenu(screen.getByText('Home').closest('[role="row"]')!, { clientX: 100, clientY: 100 }) + expect(screen.queryByRole('menuitem', { name: /duplicate row/i })).toBeNull() + }) + + it('shows "Add row" and "Duplicate row" once the table has a custom (non-built-in) field', () => { + const table = makePagesTable({ + fields: [ + { type: 'text', id: 'title', label: 'Title', required: true, builtIn: true }, + { type: 'text', id: 'slug', label: 'Slug', required: true, builtIn: true }, + { type: 'text', id: 'custom-note', label: 'Note', required: false }, + ], + }) + renderCanvas(table, [makeRow()]) + + expect(screen.getByRole('button', { name: /add row/i })).toBeDefined() + + fireEvent.contextMenu(screen.getByText('Home').closest('[role="row"]')!, { clientX: 100, clientY: 100 }) + expect(screen.getByRole('menuitem', { name: /duplicate row/i })).toBeDefined() + }) + + it('still hides "Add row" for a locked table even when the empty state renders', () => { + renderCanvas(makePagesTable(), []) + + expect(screen.queryByRole('button', { name: /add row/i })).toBeNull() + expect(screen.getByText(/no pages yet/i)).toBeDefined() + }) +}) diff --git a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx index d43837590..d6a5fd884 100644 --- a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx +++ b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx @@ -1004,6 +1004,42 @@ describe('SiteExplorerPanel', () => { expect(screen.getByRole('textbox', { name: 'Rename Pricing' })).toBeDefined() }) + it('edits a page slug through the Page settings dialog', () => { + loadSite() + render() + + fireEvent.contextMenu(screen.getByRole('button', { name: /open page pricing/i }), { + clientX: 120, + clientY: 140, + }) + fireEvent.click(screen.getByRole('menuitem', { name: /page settings/i })) + + const slugInput = screen.getByLabelText('Slug') as HTMLInputElement + expect(slugInput.value).toBe('pricing') + + fireEvent.change(slugInput, { target: { value: 'plans-and-pricing' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + const updated = useEditorStore.getState().site?.pages.find((page) => page.id === 'page-pricing') + expect(updated?.slug).toBe('plans-and-pricing') + expect(updated?.title).toBe('Pricing') + }) + + it('locks slug editing for the homepage in the Page settings dialog', () => { + loadSite() + render() + + fireEvent.contextMenu(screen.getByRole('button', { name: /open page home/i }), { + clientX: 120, + clientY: 140, + }) + fireEvent.click(screen.getByRole('menuitem', { name: /page settings/i })) + + const slugInput = screen.getByLabelText('Slug') as HTMLInputElement + expect(slugInput.disabled).toBe(true) + expect(slugInput.value).toBe('index') + }) + it('renames and deletes components from the site row context menu', () => { loadSite() render() diff --git a/src/admin/pages/data/DataPage.tsx b/src/admin/pages/data/DataPage.tsx index fb3b1e431..7cda726f0 100644 --- a/src/admin/pages/data/DataPage.tsx +++ b/src/admin/pages/data/DataPage.tsx @@ -23,6 +23,8 @@ import { canManageTable, } from '@admin/access' import type { DataRow, DataRowCells, DataRowStatus } from '@core/data/schemas' +import { pushToast } from '@ui/components/Toast' +import { getErrorMessage } from '@core/utils/errorMessage' import { useDataWorkspace } from './hooks/useDataWorkspace' import { DataSidebar } from './components/DataSidebar/DataSidebar' import { DataCanvas } from './components/DataCanvas/DataCanvas' @@ -97,6 +99,7 @@ export function DataPage() { workspace.selectRow(newRow.id) } catch (err) { console.error('[DataPage] Add row failed:', err) + pushToast({ kind: 'error', title: 'Could not add row', body: getErrorMessage(err, 'Unknown error') }) } } @@ -105,6 +108,7 @@ export function DataPage() { await workspace.duplicateRow(row) } catch (err) { console.error('[DataPage] Duplicate row failed:', err) + pushToast({ kind: 'error', title: 'Could not duplicate row', body: getErrorMessage(err, 'Unknown error') }) } } diff --git a/src/admin/pages/data/components/DataCanvas/DataCanvas.tsx b/src/admin/pages/data/components/DataCanvas/DataCanvas.tsx index 7cf0b8f6f..8ee205da7 100644 --- a/src/admin/pages/data/components/DataCanvas/DataCanvas.tsx +++ b/src/admin/pages/data/components/DataCanvas/DataCanvas.tsx @@ -15,6 +15,7 @@ import { EmptyState } from '@ui/components/EmptyState' import { DataGrid } from '../DataGrid/DataGrid' import { DataGridSkeleton } from '../DataGrid/DataGridSkeleton' import type { DataRow, DataRowStatus, DataTable } from '@core/data/schemas' +import { tableHasEditableFields } from '@core/data/systemTableGuard' // Reuse the site canvas surface token so the Data page matches // Site / Content / Media visual language. import canvasStyles from '@site/canvas/CanvasRoot.module.css' @@ -112,6 +113,13 @@ export function DataCanvas({ ) } + // `pages` / `components` / `layouts` start out with every field built-in + // and value-locked — a generic "Add row" / "Duplicate row" through the Data + // grid has nothing it could actually fill in, and would only ever bounce + // off the server's `lockedBuiltInCellKey` rejection. Hide both affordances + // for those tables rather than offer an action guaranteed to fail. + const rowCreationSupported = tableHasEditableFields(table) + return (
void - onAddRow: () => Promise | void + /** Create a new row. Omit to hide the "Add row" toolbar button + empty-state CTA. */ + onAddRow?: () => Promise | void /** Delete a row by id. Omit to hide per-row + bulk delete. */ onDeleteRow?: (rowId: string) => void /** Duplicate a row. Omit to hide the duplicate action. */ diff --git a/src/admin/pages/data/components/DataGrid/DataGridEmptyState.tsx b/src/admin/pages/data/components/DataGrid/DataGridEmptyState.tsx index 71c9be890..e35aeb790 100644 --- a/src/admin/pages/data/components/DataGrid/DataGridEmptyState.tsx +++ b/src/admin/pages/data/components/DataGrid/DataGridEmptyState.tsx @@ -18,7 +18,7 @@ interface DataGridEmptyStateProps { /** True when a search query or non-'all' status filter is narrowing the view. */ filtered: boolean readOnly: boolean - onAddRow: () => Promise | void + onAddRow?: () => Promise | void } export function DataGridEmptyState({ @@ -28,20 +28,21 @@ export function DataGridEmptyState({ onAddRow, }: DataGridEmptyStateProps): ReactElement { const noun = table.pluralLabel.toLowerCase() + const canAdd = !readOnly && onAddRow != null return (
{ void onAddRow() }}>
- {!readOnly && ( + {!readOnly && onAddRow != null && ( + + + } + > +
+
+ + setTitle(event.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ +
+ + setSlug(normalizePageSlug(event.target.value))} + autoComplete="off" + spellCheck={false} + disabled={isHome} + invalid={Boolean(slugValidation)} + /> + {isHome ? ( +

The homepage is always served at “/”.

+ ) : slugValidation ? ( +

{slugValidation}

+ ) : null} +
+
+ + ) +} diff --git a/src/admin/shared/dialogs/PageSettingsDialog/index.ts b/src/admin/shared/dialogs/PageSettingsDialog/index.ts new file mode 100644 index 000000000..c86e60118 --- /dev/null +++ b/src/admin/shared/dialogs/PageSettingsDialog/index.ts @@ -0,0 +1,2 @@ +export { PageSettingsDialog } from './PageSettingsDialog' +export type { PageSettingsPayload } from './PageSettingsDialog' diff --git a/src/core/data/__tests__/systemTableGuard.test.ts b/src/core/data/__tests__/systemTableGuard.test.ts index b0f39205b..805d878a6 100644 --- a/src/core/data/__tests__/systemTableGuard.test.ts +++ b/src/core/data/__tests__/systemTableGuard.test.ts @@ -4,6 +4,7 @@ import { assertSystemTableUpdateAllowed, isBuiltInValueLocked, lockedBuiltInCellKey, + tableHasEditableFields, } from '@core/data/systemTableGuard' function field(id: string, overrides: Partial = {}): DataField { @@ -60,6 +61,27 @@ describe('lockedBuiltInCellKey', () => { }) }) +describe('tableHasEditableFields', () => { + it('is false for a structural system table with only built-in fields', () => { + expect(tableHasEditableFields(table())).toBe(false) + }) + + it('is true once a structural system table has a custom field', () => { + const t = table({ fields: [...table().fields, field('note', { builtIn: false })] }) + expect(tableHasEditableFields(t)).toBe(true) + }) + + it('is true for posts (editorial post type) even with only built-in fields', () => { + const posts = table({ id: 'posts', kind: 'postType', system: true }) + expect(tableHasEditableFields(posts)).toBe(true) + }) + + it('is true for a custom (non-system) table', () => { + const custom = table({ kind: 'data', system: false }) + expect(tableHasEditableFields(custom)).toBe(true) + }) +}) + describe('assertSystemTableUpdateAllowed', () => { it('allows any update on a non-system table', () => { const custom = table({ system: false }) diff --git a/src/core/data/systemTableGuard.ts b/src/core/data/systemTableGuard.ts index ea73f20d1..7040a0292 100644 --- a/src/core/data/systemTableGuard.ts +++ b/src/core/data/systemTableGuard.ts @@ -42,6 +42,21 @@ export function isBuiltInValueLocked( return field.builtIn === true && table.system === true && table.kind !== 'postType' } +/** + * Whether this table has at least one field a row write can actually set — + * i.e. at least one field that isn't value-locked. `pages` / `components` / + * `layouts` start out with EVERY field built-in and value-locked, so a brand + * new one of these tables has nothing a generic row create/duplicate could + * fill in until a custom field is added. Gates the Data grid's "Add row" / + * "Duplicate row" affordances — offering them here would only ever produce a + * `lockedBuiltInCellKey` rejection from the server. + */ +export function tableHasEditableFields( + table: Pick, +): boolean { + return table.fields.some((field) => !isBuiltInValueLocked(table, field)) +} + /** * First cell key in `cells` that targets a value-locked built-in field on the * given table, or `null` when none do. Lets a row-write handler reject attempts