Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/__tests__/admin/data/dataCanvasRowCreation.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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<React.ComponentProps<typeof DataCanvas>> = {}) {
return render(
<DataCanvas
table={table}
tables={[table]}
rows={rows}
loading={false}
loadingTables={false}
error={null}
selectedRowId={null}
onSelectRow={() => {}}
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()
})
})
36 changes: 36 additions & 0 deletions src/__tests__/site-explorer/siteExplorerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SiteExplorerPanel sectionGroup="site" />)

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(<SiteExplorerPanel sectionGroup="site" />)

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(<SiteExplorerPanel sectionGroup="site" />)
Expand Down
4 changes: 4 additions & 0 deletions src/admin/pages/data/DataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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') })
}
}

Expand All @@ -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') })
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/admin/pages/data/components/DataCanvas/DataCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<section className={`${canvasStyles.canvas} ${styles.canvas}`} aria-label={`${table.pluralLabel} data grid`}>
<DataGrid
Expand All @@ -123,9 +131,9 @@ export function DataCanvas({
error={error}
readOnly={!canEdit}
onSelectRow={onSelectRow}
onAddRow={onAddRow}
onAddRow={rowCreationSupported ? onAddRow : undefined}
onEditInContent={onEditInContent}
onDuplicateRow={canCreate ? onDuplicateRow : undefined}
onDuplicateRow={canCreate && rowCreationSupported ? onDuplicateRow : undefined}
onOpenInSiteEditor={onOpenInSiteEditor}
onOpenRow={onOpenRow}
onDeleteRow={canDelete ? onDeleteRow : undefined}
Expand Down
3 changes: 2 additions & 1 deletion src/admin/pages/data/components/DataGrid/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ interface DataGridProps {
readOnly?: boolean
/** Click on a row — typically opens the inspector. */
onSelectRow: (rowId: string | null) => void
onAddRow: () => Promise<void> | void
/** Create a new row. Omit to hide the "Add row" toolbar button + empty-state CTA. */
onAddRow?: () => Promise<void> | 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void
onAddRow?: () => Promise<void> | void
}

export function DataGridEmptyState({
Expand All @@ -28,20 +28,21 @@ export function DataGridEmptyState({
onAddRow,
}: DataGridEmptyStateProps): ReactElement {
const noun = table.pluralLabel.toLowerCase()
const canAdd = !readOnly && onAddRow != null
return (
<div className={styles.emptyStateSpan}>
<EmptyState
plain
title={filtered ? `No ${noun} match this view` : `No ${noun} yet`}
description={
readOnly
!canAdd
? undefined
: filtered
? 'Try clearing the search or switching views.'
: 'Add the first row to get started.'
}
action={
!readOnly && !filtered ? (
canAdd && !filtered ? (
<Button variant="secondary" size="sm" onClick={() => { void onAddRow() }}>
<PlusIcon size={12} aria-hidden="true" />
Add row
Expand Down
4 changes: 2 additions & 2 deletions src/admin/pages/data/components/DataGrid/DataGridToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface DataGridToolbarProps {
readOnly: boolean
query: string
onQueryChange: (q: string) => void
onAddRow: () => Promise<void> | void
onAddRow?: () => Promise<void> | void
hasPublishWorkflow: boolean
statusViewOrder: StatusViewChip[]
statusFilter: StatusFilter
Expand Down Expand Up @@ -88,7 +88,7 @@ export function DataGridToolbar({
/>
</div>

{!readOnly && (
{!readOnly && onAddRow != null && (
<Button variant="primary" size="sm" onClick={() => { void onAddRow() }}>
<PlusIcon size={12} aria-hidden="true" />
Add row
Expand Down
43 changes: 22 additions & 21 deletions src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,7 +122,6 @@ export function SiteExplorerPanel({
const [createKind, setCreateKind] = useState<SiteCreateKind | null>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [inlineRenameTarget, setInlineRenameTarget] = useState<SiteExplorerContextTarget | null>(null)
const [templateSettingsTarget, setTemplateSettingsTarget] = useState<Page | null>(null)
const [pathConfirmPlan, setPathConfirmPlan] = useState<ExplorerPathChangePlan | null>(null)
const explorerSelection = useSiteExplorerSelection<SiteExplorerContextTarget>()

Expand Down Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -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) {
Expand All @@ -371,7 +369,7 @@ export function SiteExplorerPanel({
label: 'Template settings',
icon: <FileTextSolidIcon size={13} />,
action: () => {
setTemplateSettingsTarget(page)
openTemplateSettings(page)
setContextMenu(null)
},
},
Expand All @@ -390,7 +388,7 @@ export function SiteExplorerPanel({
label: 'Use as template',
icon: <FileTextSolidIcon size={13} />,
action: () => {
setTemplateSettingsTarget(page)
openTemplateSettings(page)
setContextMenu(null)
},
}]
Expand All @@ -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: <Settings2SolidIcon size={13} />,
action: () => {
openPageSettings(page)
setContextMenu(null)
},
}] : []),
...templateMenuItems(target),
]
}
Expand Down Expand Up @@ -652,14 +660,7 @@ export function SiteExplorerPanel({
onDelete={() => handleDeleteContext(contextMenu)}
/>
)}
{templateSettingsTarget && (
<TemplateSettingsDialog
page={templateSettingsTarget}
pages={pages}
onCancel={() => setTemplateSettingsTarget(null)}
onSave={handleSaveTemplateSettings}
/>
)}
{pageSettingsDialogs}
{pathConfirmPlan && (
<SiteExplorerPathConfirmDialog
plan={pathConfirmPlan}
Expand Down
Loading