From fc4ab48a2c7cefabcfd863c4cea51105b9165221 Mon Sep 17 00:00:00 2001 From: gxvr Date: Tue, 14 Jul 2026 14:54:41 +0300 Subject: [PATCH 1/2] fix(editor): make page slug editable via a new Page settings dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page's slug could only be set at creation time — the site explorer's inline rename only ever updates the title (renamePage(id, title) with no third arg leaves the slug untouched), and there was no other slug editor for a regular (non-template) page. The Data tab correctly locks slug as an editor-managed built-in field, but the editor had nowhere to actually manage it. Add a "Page settings" context-menu item (regular pages only — template pages already get title+slug through the existing Template settings dialog) that opens a small Title + Slug form, reusing the same slug validation helpers as TemplateSettingsDialog. The homepage's slug field is disabled since "index" is a reserved sentinel, not a freely editable value. Extracted the Template/Page settings dialog state and handlers into a usePageSettingsDialogs hook — adding the new dialog inline pushed SiteExplorerPanel.tsx over the 700-line module-size-budgets ceiling. Co-Authored-By: Claude Sonnet 5 --- .../site-explorer/siteExplorerPanel.test.tsx | 36 +++++ .../SiteExplorerPanel/SiteExplorerPanel.tsx | 43 +++--- .../usePageSettingsDialogs.tsx | 68 ++++++++++ .../PageSettingsDialog/PageSettingsDialog.tsx | 124 ++++++++++++++++++ .../dialogs/PageSettingsDialog/index.ts | 2 + 5 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 src/admin/pages/site/panels/SiteExplorerPanel/usePageSettingsDialogs.tsx create mode 100644 src/admin/shared/dialogs/PageSettingsDialog/PageSettingsDialog.tsx create mode 100644 src/admin/shared/dialogs/PageSettingsDialog/index.ts 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/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx index a63d5f68a..1b224c734 100644 --- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx +++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx @@ -12,9 +12,10 @@ import { PaintBucketSolidIcon } from 'pixel-art-icons/icons/paint-bucket-solid' import { CodeIcon } from 'pixel-art-icons/icons/code' import { ExternalLinkSolidIcon } from 'pixel-art-icons/icons/external-link-solid' import { GlobeSolidIcon } from 'pixel-art-icons/icons/globe-solid' +import { Settings2SolidIcon } from 'pixel-art-icons/icons/settings-2-solid' import { SiteCreateDialog, buildScriptPath, buildStylePath, slugifySiteItemName, type SiteCreatePayload, type SiteCreateKind } from '@admin/shared/dialogs/SiteCreateDialog' import type { ExplorerContextMenuItem } from '@site/explorer-actions' -import { TemplateSettingsDialog, type TemplateSettingsPayload } from '@admin/shared/dialogs/TemplateSettingsDialog' +import { usePageSettingsDialogs } from './usePageSettingsDialogs' import { useVCDeletionConfirm } from '@admin/shared/dialogs/VCDeletionConfirmDialog' import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { @@ -121,7 +122,6 @@ export function SiteExplorerPanel({ const [createKind, setCreateKind] = useState(null) const [contextMenu, setContextMenu] = useState(null) const [inlineRenameTarget, setInlineRenameTarget] = useState(null) - const [templateSettingsTarget, setTemplateSettingsTarget] = useState(null) const [pathConfirmPlan, setPathConfirmPlan] = useState(null) const explorerSelection = useSiteExplorerSelection() @@ -152,6 +152,12 @@ export function SiteExplorerPanel({ } const pages = site?.pages ?? [] + const { openTemplateSettings, openPageSettings, dialogs: pageSettingsDialogs } = usePageSettingsDialogs({ + pages, + renamePage, + convertPageToTemplate, + openPageInCanvas, + }) const normalPages = pages.filter((page) => !page.template) const templatePages = pages.filter((page) => page.template) const components = site?.visualComponents ?? [] @@ -350,15 +356,7 @@ export function SiteExplorerPanel({ const slug = createUniquePageSlug('Post Template', pages) const page = addPage('Post Template', slug) openPageInCanvas(page.id) - setTemplateSettingsTarget(page) - } - - function handleSaveTemplateSettings(payload: TemplateSettingsPayload) { - if (!templateSettingsTarget) return - renamePage(templateSettingsTarget.id, payload.title, payload.slug) - convertPageToTemplate(templateSettingsTarget.id, payload.template) - setTemplateSettingsTarget(null) - openPageInCanvas(templateSettingsTarget.id) + openTemplateSettings(page) } function templateMenuItems(target: SiteExplorerContextTarget) { @@ -371,7 +369,7 @@ export function SiteExplorerPanel({ label: 'Template settings', icon: , action: () => { - setTemplateSettingsTarget(page) + openTemplateSettings(page) setContextMenu(null) }, }, @@ -390,7 +388,7 @@ export function SiteExplorerPanel({ label: 'Use as template', icon: , action: () => { - setTemplateSettingsTarget(page) + openTemplateSettings(page) setContextMenu(null) }, }] @@ -417,6 +415,16 @@ export function SiteExplorerPanel({ setContextMenu(null) }, }, + // Templates get title+slug through "Template settings" already — avoid + // a second, redundant slug editor for the same page. + ...(!page.template ? [{ + label: 'Page settings', + icon: , + action: () => { + openPageSettings(page) + setContextMenu(null) + }, + }] : []), ...templateMenuItems(target), ] } @@ -652,14 +660,7 @@ export function SiteExplorerPanel({ onDelete={() => handleDeleteContext(contextMenu)} /> )} - {templateSettingsTarget && ( - setTemplateSettingsTarget(null)} - onSave={handleSaveTemplateSettings} - /> - )} + {pageSettingsDialogs} {pathConfirmPlan && ( void + convertPageToTemplate: (pageId: string, config: PageTemplateConfig) => void + openPageInCanvas: (pageId: string) => void +} + +/** + * Owns the "Template settings" and "Page settings" dialogs for the site + * explorer. Both edit a page's title/slug (template settings additionally + * configures the template target) — grouped here as one unit so + * SiteExplorerPanel only deals with `open*` triggers, not dialog state. + */ +export function usePageSettingsDialogs({ + pages, + renamePage, + convertPageToTemplate, + openPageInCanvas, +}: UsePageSettingsDialogsOptions) { + const [templateSettingsTarget, setTemplateSettingsTarget] = useState(null) + const [pageSettingsTarget, setPageSettingsTarget] = useState(null) + + function handleSaveTemplateSettings(payload: TemplateSettingsPayload) { + if (!templateSettingsTarget) return + renamePage(templateSettingsTarget.id, payload.title, payload.slug) + convertPageToTemplate(templateSettingsTarget.id, payload.template) + setTemplateSettingsTarget(null) + openPageInCanvas(templateSettingsTarget.id) + } + + function handleSavePageSettings(payload: PageSettingsPayload) { + if (!pageSettingsTarget) return + renamePage(pageSettingsTarget.id, payload.title, payload.slug) + setPageSettingsTarget(null) + } + + const dialogs = ( + <> + {templateSettingsTarget && ( + setTemplateSettingsTarget(null)} + onSave={handleSaveTemplateSettings} + /> + )} + {pageSettingsTarget && ( + setPageSettingsTarget(null)} + onSave={handleSavePageSettings} + /> + )} + + ) + + return { + openTemplateSettings: setTemplateSettingsTarget, + openPageSettings: setPageSettingsTarget, + dialogs, + } +} diff --git a/src/admin/shared/dialogs/PageSettingsDialog/PageSettingsDialog.tsx b/src/admin/shared/dialogs/PageSettingsDialog/PageSettingsDialog.tsx new file mode 100644 index 000000000..33c2757a0 --- /dev/null +++ b/src/admin/shared/dialogs/PageSettingsDialog/PageSettingsDialog.tsx @@ -0,0 +1,124 @@ +import { useEffect, useId, useRef, useState, type FormEvent } from 'react' +import type { Page } from '@core/page-tree' +import { + isHomePage, + normalizePageSlug, + pageSlugDuplicateError, + pageSlugError, +} from '@core/page-tree' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { Input } from '@ui/components/Input' +import dialogStyles from '../SiteCreateDialog/SiteCreateDialog.module.css' + +export interface PageSettingsPayload { + title: string + slug: string +} + +interface PageSettingsDialogProps { + page: Page + pages: Page[] + onCancel: () => void + onSave: (payload: PageSettingsPayload) => void +} + +const FORM_ID = 'page-settings-form' + +/** + * Title + slug editor for a regular page. The site explorer's inline rename + * only ever changes the title (`renamePage(id, title)` with no third arg + * leaves the slug untouched) — this dialog is the one place a page's slug + * can actually be changed after creation. + */ +export function PageSettingsDialog({ + page, + pages, + onCancel, + onSave, +}: PageSettingsDialogProps) { + const [title, setTitle] = useState(page.title) + const [slug, setSlug] = useState(page.slug) + const isHome = isHomePage(page) + const inputRef = useRef(null) + const titleInputId = useId() + const slugInputId = useId() + + const trimmedTitle = title.trim() + const normalizedSlug = normalizePageSlug(slug) + const slugValidation = isHome + ? null + : pageSlugError(normalizedSlug) || pageSlugDuplicateError(normalizedSlug, pages, page.id) + + const saveDisabled = !trimmedTitle || Boolean(slugValidation) + + useEffect(() => { + requestAnimationFrame(() => inputRef.current?.select()) + }, []) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + if (saveDisabled) return + onSave({ title: trimmedTitle, slug: isHome ? page.slug : normalizedSlug }) + } + + return ( + + + + + } + > +
+
+ + 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' From fdb2fc6d30d683c3f7ba2a70c86d318d50ceb069 Mon Sep 17 00:00:00 2001 From: gxvr Date: Wed, 15 Jul 2026 15:21:16 +0300 Subject: [PATCH 2/2] fix(data): hide Add row / Duplicate row on tables with no editable fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages, Components, and Layouts start out with every field marked built-in and value-locked (isBuiltInValueLocked), so a generic "Add row" or "Duplicate row" through the Data tab has nothing it can actually fill in — buildEmptyCells and buildDuplicateRowCells always include the locked field keys, and the server's lockedBuiltInCellKey guard rejects the request every time. The UI offered both actions unconditionally, so clicking them silently did nothing beyond a console.error, making the whole table look broken/read-only. Add tableHasEditableFields(table) to systemTableGuard.ts (true when at least one field isn't value-locked) and use it in DataCanvas to hide both affordances for tables where every field is locked. As defense in depth, also surface handleAddRow / handleDuplicateRow failures via the toast bus instead of console.error-only, matching this project's error-handling convention for user-triggered operations. Co-Authored-By: Claude Sonnet 5 --- .../admin/data/dataCanvasRowCreation.test.tsx | 117 ++++++++++++++++++ src/admin/pages/data/DataPage.tsx | 4 + .../data/components/DataCanvas/DataCanvas.tsx | 12 +- .../data/components/DataGrid/DataGrid.tsx | 3 +- .../DataGrid/DataGridEmptyState.tsx | 7 +- .../components/DataGrid/DataGridToolbar.tsx | 4 +- .../data/__tests__/systemTableGuard.test.ts | 22 ++++ src/core/data/systemTableGuard.ts | 15 +++ 8 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/admin/data/dataCanvasRowCreation.test.tsx 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/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 && (