diff --git a/.changeset/12291-calendar-quick-create-handoff.md b/.changeset/12291-calendar-quick-create-handoff.md new file mode 100644 index 0000000000..73547a032b --- /dev/null +++ b/.changeset/12291-calendar-quick-create-handoff.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-calendar': minor +'@object-ui/app-shell': minor +--- + +Calendar quick-create no longer offers a dialog that cannot succeed. On an object with a required field it cannot fill, an empty-day click opens the full create form with the clicked day already set (objectui#12291). + +The quick-create dialog asks for a title only. Its write fills the date fields and, for the other required fields, a declared default, a select's first option, `false` for a boolean or `0` for a number. A required field outside that set, such as the required `project` lookup on the stock `showcase_task`, was left empty, and the create was refused with 400 "Project is required" while the dialog offered no Project input. + +- **`@object-ui/plugin-calendar`.** Before the dialog is offered, the calendar checks the object's required fields against what its write can fill. The check and the write read one statement of that set. A required field the server fills on insert (declared `readonly` or `system`, or an `autonumber`) does not count. Where every required field can be filled, the dialog is unchanged. Where one cannot, the empty-day click, or a range drag in the week and day grids, calls the declared `onEdit` prop with a new record (no `id`) that carries the date values the dialog would have written. The host then opens its full create form with them. `ListView` already passes its `onEdit` down to the calendar, and in the console that is the handler that opens the record form. With no `onEdit`, the gesture offers nothing on such an object. A caller-supplied `onDateClick` still takes precedence, and the `calendarQuickCreate` permission row still gates both paths. The dialog gets no lookup picker, and no lookup value is guessed. +- **`@object-ui/app-shell`.** The console's record-form handler already treated a record with no id as a new one. It now opens the create form pre-filled with the values such a record carries. The record-form overlay gets them as its initial values, carried in the state of the history entry that opens it, so Back and a refresh keep them. A page-mode object (`editMode: 'page'`) gets them as the query-string prefill its create page already reads. `onEdit(null)` (the list's New) and a record with an id behave as before. + +No export, prop or type member is added, and no language-pack key or URL parameter. `ObjectCalendarComponentProps.onEdit` was already declared and forwarded; the calendar did not call it until now. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 9999c7b692..34ef0fecc8 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -89,6 +89,23 @@ or the field's declared `defaultValue`). This ensures the create succeeds against `NOT NULL` columns without forcing the user through the full form. +When the object has a required field outside that set (a required lookup, +or a required text field other than the title), the dialog's create could +not succeed, so it is not offered. The same gesture opens the host's full +create form instead: the calendar calls `onEdit` with a new record (no `id`) +carrying the date values the dialog would have written, and the host +pre-fills its create form with them. The console does this already. Without +an `onEdit`, the gesture offers nothing on such an object. Required fields +the server fills on insert (`readonly`, `system`, `autonumber`) do not count. + +```jsx + openCreateForm({ initialValues: record })} +/> +``` + Quick-create is offered only when the `calendarQuickCreate` row of the affordance-to-grant map allows it (the caller's **create** grant, with the object's policy and the effective API operation set): without it a day click diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index de6111db99..fab5303ad8 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -32,7 +32,7 @@ import { buildExpressionUser } from '../providers/expressionUser.js'; import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js'; import { useHomePath } from '../hooks/useHomePath.js'; import { useSignedInUserLocale } from '../hooks/useUserLocale.js'; -import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget } from '../utils/recordFormNavigation.js'; +import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view'; import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js'; import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js'; @@ -435,6 +435,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const [refreshKey, setRefreshKey] = useState(0); const isDialogOpen = !!recordFormParam; + // objectui#12291 — a seeded create's values, carried by the history entry + // that opened the overlay (`handleEdit` below). The object the router keeps + // for that entry, so its identity holds until the entry changes. + const overlayCreateValues = editingRecord ? undefined : readRecordFormCreateValues(location.state); // Close the record-form overlay by stripping `?form` in place. Reads the // LIVE location (not the hook's render-time snapshot) so it stays a no-op @@ -687,7 +691,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // overrides (see the record-form URL contract above). sp.delete(RECORD_FORM_OBJECT_PARAM); sp.delete(RECORD_FORM_LINK_PARAM); - setSearchParams(sp); + // objectui#12291 — a new record that arrives carrying values (the + // calendar's empty-day hand-off sends the day clicked) opens the create + // form with them, riding the history entry this push makes + // (`RECORD_FORM_CREATE_VALUES_STATE`). Page mode carries them in its URL, + // above (`resolveRecordFormTarget`). + const createValues = recordFormCreateValues(record); + setSearchParams( + sp, + createValues ? { state: { [RECORD_FORM_CREATE_VALUES_STATE]: createValues } } : undefined, + ); }; const handleAppChange = (newAppName: string) => { @@ -1191,7 +1204,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = recordId: editingRecord?.id, // Child create task (objectstack-ai/objectstack#2604 D3): pre-link the parent from the // `formLink` URL param (refresh-safe — the link survives). - ...(formLinkValues && !editingRecord ? { initialValues: formLinkValues } : {}), + // Otherwise a create seeded by its opener (objectui#12291 — the + // calendar's empty-day hand-off) opens with the values its + // history entry carries. Each is handed as the object it is, + // never a merge rebuilt per render: the form re-seeds whenever + // `initialValues` is a new object. + ...(!editingRecord && (formLinkValues ?? overlayCreateValues) + ? { initialValues: formLinkValues ?? overlayCreateValues } + : {}), // objectstack-ai/objectstack#2604 D1: create/edit follow the flow-surface derivation — // field-heavy → full-screen modal (the same big canvas the // detail page gets, with overlay return semantics); light diff --git a/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx new file mode 100644 index 0000000000..9cf1ff63bf --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx @@ -0,0 +1,249 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12291 — the console half of the calendar's quick-create hand-off. + * + * On an object whose required fields quick-create cannot fill (the stock + * `showcase_task`, required `project`), an empty-day click no longer offers + * the dialog that cannot succeed: `ObjectCalendar` calls the host's `onEdit` + * with a NEW record — no id — carrying the clicked day (pinned in + * `plugin-calendar`'s `ObjectCalendar.quickCreateHandoff-12291.test.tsx`). + * `ListView` hands that `onEdit` down, and in the console it is this + * component's `handleEdit`, the handler the list's "New" already opens the + * record form through (`onEdit(null)`). + * + * What is pinned here is what the console does with such a record: + * + * - the record-form OVERLAY opens in create mode with the values as its + * `initialValues`, and a row created from it carries the clicked date — + * driven through the real `ModalForm` down to the adapter's `create`; + * - a page-mode object (`editMode: 'page'`) opens its create page with the + * values as the query-string prefill `RecordFormPage` reads; + * - and the two old callers are unchanged: `onEdit(null)` opens a blank + * create form, and a record WITH an id opens the edit form, its other + * keys ignored. + * + * The values ride the history entry that opens the overlay, not a URL param + * (`RECORD_FORM_CREATE_VALUES_STATE`, whose doc comment says why). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { registerAllFields } from '@object-ui/fields'; + +registerAllFields(); + +const probe = vi.hoisted(() => ({ + /** Every schema the global record-form modal was rendered with. */ + modalSchemas: [] as any[], + /** Render the real `ModalForm` under the probe (the create-payload case). */ + realModal: false, + /** What the stand-in list hands `onEdit` — the calendar's hand-off by default. */ + record: { due_date: '2026-10-20' } as Record | null, + /** The router's location, read after each navigation. */ + location: null as null | { pathname: string; search: string; state: unknown }, +})); + +vi.mock('@object-ui/plugin-form', async (importOriginal) => { + const actual = await importOriginal>(); + const RealModalForm = actual.ModalForm; + return { + ...actual, + ModalForm: (props: any) => { + probe.modalSchemas.push(props.schema); + return probe.realModal ? :
; + }, + }; +}); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); + +/** + * The object list stands in for the calendar: what reaches `AppContent` is the + * `onEdit` call the calendar makes through `ListView`, so the list's only job + * here is to make that call. + */ +vi.mock('../../views/ObjectView', () => ({ + ObjectView: ({ onEdit }: { onEdit: (record: unknown) => void }) => ( + + ), +})); + +vi.mock('../../views/RecordFormPage', () => ({ + RecordFormPage: () =>
, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +/** The stock `showcase_task`, cut to the fields this card is about. */ +const TASK_FIELDS = { + title: { type: 'text', label: 'Title', required: true }, + due_date: { type: 'date', label: 'Due Date' }, +}; + +const OBJECTS = [ + { name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: TASK_FIELDS }, + { name: 'page_task', label: 'Page Task', editMode: 'page', fields: TASK_FIELDS }, +]; + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', + getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)), + create: vi.fn(async (_object: string, data: Record) => ({ id: 't-1', ...data })), + findOne: vi.fn(async () => null), +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +const APPS = [{ name: 'showcase_app', label: 'Showcase', isDefault: true, navigation: [] }]; + +// One value for the whole run: effects key on these members. +const METADATA = { + apps: APPS, + objects: OBJECTS, + loading: false, + ensureType: undefined, + error: null, + refresh: async () => {}, +}; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => METADATA, +})); + +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ACTION_RUNNER, + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +function LocationProbe() { + const location = useLocation(); + probe.location = { pathname: location.pathname, search: location.search, state: location.state }; + return null; +} + +/** Open an object's calendar view and make the hand-off from it. */ +async function handOffFrom(objectName: string) { + render( + + + + } /> + + , + ); + fireEvent.click(await screen.findByTestId('hand-off')); +} + +const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1]; + +beforeEach(() => { + probe.modalSchemas.length = 0; + probe.realModal = false; + probe.record = { due_date: '2026-10-20' }; + probe.location = null; + vi.clearAllMocks(); +}); +afterEach(cleanup); + +describe('objectui#12291 — a new record carrying values opens the console\'s create form with them', () => { + it('the overlay opens in create mode with the clicked day as its initial value', async () => { + await handOffFrom('showcase_task'); + + await waitFor(() => expect(lastModal()?.open).toBe(true)); + expect(new URLSearchParams(probe.location!.search).get('form')).toBe('new'); + expect(lastModal().mode).toBe('create'); + expect(lastModal().objectName).toBe('showcase_task'); + expect(lastModal().initialValues).toEqual({ due_date: '2026-10-20' }); + }); + + it('a row created from that form carries the clicked date (the real ModalForm, down to the adapter)', async () => { + probe.realModal = true; + await handOffFrom('showcase_task'); + + const title = await screen.findByRole('textbox', { name: /^Title/ }, { timeout: 5000 }); + fireEvent.change(title, { target: { value: 'Plan the sprint' } }); + // Not inside an awaited `act`: under React 19 that did not return before + // the test timeout (as `AppContent.createLandingOriginPlural-11733` found); + // the `waitFor` below polls inside `act` on its own. + fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement); + + await waitFor(() => expect(dataSourceStub.create).toHaveBeenCalledTimes(1)); + expect(dataSourceStub.create.mock.calls[0][0]).toBe('showcase_task'); + expect(dataSourceStub.create.mock.calls[0][1]).toMatchObject({ + title: 'Plan the sprint', + due_date: '2026-10-20', + }); + }); + + it('a page-mode object opens its create page with the values as the query-string prefill', async () => { + await handOffFrom('page_task'); + + await waitFor(() => expect(probe.location?.pathname).toBe('/apps/showcase_app/page_task/new')); + expect(new URLSearchParams(probe.location!.search).get('due_date')).toBe('2026-10-20'); + expect(await screen.findByTestId('record-form-page')).toBeInTheDocument(); + }); +}); + +describe('objectui#12291 — the two callers before it are unchanged', () => { + it('onEdit(null) — the list\'s "New" — opens a blank create form', async () => { + probe.record = null; + await handOffFrom('showcase_task'); + + await waitFor(() => expect(lastModal()?.open).toBe(true)); + expect(lastModal().mode).toBe('create'); + expect(lastModal().initialValues).toBeUndefined(); + }); + + it('a record WITH an id opens the edit form, and its other keys seed nothing', async () => { + probe.record = { id: 't-9', due_date: '2026-10-20' }; + await handOffFrom('showcase_task'); + + await waitFor(() => expect(lastModal()?.open).toBe(true)); + expect(new URLSearchParams(probe.location!.search).get('form')).toBe('t-9'); + expect(lastModal().mode).toBe('edit'); + expect(lastModal().recordId).toBe('t-9'); + expect(lastModal().initialValues).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/utils/__tests__/recordFormNavigation.test.ts b/packages/app-shell/src/utils/__tests__/recordFormNavigation.test.ts index 9d665d84b4..1b25fb2716 100644 --- a/packages/app-shell/src/utils/__tests__/recordFormNavigation.test.ts +++ b/packages/app-shell/src/utils/__tests__/recordFormNavigation.test.ts @@ -18,6 +18,9 @@ import { resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, + recordFormCreateValues, + readRecordFormCreateValues, + RECORD_FORM_CREATE_VALUES_STATE, } from '../recordFormNavigation'; describe('resolveRecordFormTarget', () => { @@ -127,6 +130,68 @@ describe('resolveRecordFormTarget', () => { }); }); +// objectui#12291 — a NEW record (no id) that carries values is a create +// seeded with them: the calendar's empty-day hand-off sends the clicked day. +describe('recordFormCreateValues (objectui#12291)', () => { + it('returns the values a record without an id carries', () => { + expect(recordFormCreateValues({ due_date: '2026-10-20' })).toEqual({ due_date: '2026-10-20' }); + }); + + it('treats an empty-string id as a new record, and never carries the id keys', () => { + expect(recordFormCreateValues({ id: '', _id: '', due_date: '2026-10-20' })).toEqual({ due_date: '2026-10-20' }); + }); + + it('carries nothing for an existing record (it has an id or an _id)', () => { + expect(recordFormCreateValues({ id: 'r1', due_date: '2026-10-20' })).toBeUndefined(); + expect(recordFormCreateValues({ _id: 'mongo-7', due_date: '2026-10-20' })).toBeUndefined(); + }); + + it('carries nothing for no record, or one with only empty entries', () => { + expect(recordFormCreateValues(null)).toBeUndefined(); + expect(recordFormCreateValues(undefined)).toBeUndefined(); + expect(recordFormCreateValues({ due_date: null, end_date: undefined })).toBeUndefined(); + }); +}); + +describe('readRecordFormCreateValues (objectui#12291)', () => { + it('reads the values a history entry carries under its key', () => { + const values = { due_date: '2026-10-20' }; + expect(readRecordFormCreateValues({ [RECORD_FORM_CREATE_VALUES_STATE]: values })).toBe(values); + }); + + it('reads nothing from an entry without them, or from a value that is not a values object', () => { + expect(readRecordFormCreateValues(null)).toBeUndefined(); + expect(readRecordFormCreateValues({ from: { pathname: '/x' } })).toBeUndefined(); + expect(readRecordFormCreateValues({ [RECORD_FORM_CREATE_VALUES_STATE]: ['due_date'] })).toBeUndefined(); + expect(readRecordFormCreateValues({ [RECORD_FORM_CREATE_VALUES_STATE]: 'due_date' })).toBeUndefined(); + }); +}); + +describe('resolveRecordFormTarget — a seeded create in page mode (objectui#12291)', () => { + it('carries the values as the query-string prefill RecordFormPage reads', () => { + expect( + resolveRecordFormTarget({ + objectDef: { name: 'task', editMode: 'page' }, + baseUrl: '/apps/sales', + record: { due_date: '2026-10-20', start_at: '2026-10-20T09:00:00.000Z' }, + }), + ).toEqual({ + kind: 'page', + url: '/apps/sales/task/new?due_date=2026-10-20&start_at=2026-10-20T09%3A00%3A00.000Z', + }); + }); + + it('an existing record still opens its edit page, its other keys ignored', () => { + expect( + resolveRecordFormTarget({ + objectDef: { name: 'task', editMode: 'page' }, + baseUrl: '/apps/sales', + record: { id: 'r1', due_date: '2026-10-20' }, + }), + ).toEqual({ kind: 'page', url: '/apps/sales/task/record/r1/edit' }); + }); +}); + describe('resolveNavigateCreateUrl', () => { const defaultBaseUrl = '/apps/sales'; diff --git a/packages/app-shell/src/utils/recordFormNavigation.ts b/packages/app-shell/src/utils/recordFormNavigation.ts index 1fd5d4f1fb..72a6b2b206 100644 --- a/packages/app-shell/src/utils/recordFormNavigation.ts +++ b/packages/app-shell/src/utils/recordFormNavigation.ts @@ -28,6 +28,62 @@ export type RecordFormTarget = | { kind: 'modal' } | { kind: 'page'; url: string }; +/** + * The record the console's `onEdit` is handed: an existing record (it carries + * an id), or a NEW one — `null`, or a record with no id, which may carry the + * values the create form opens with (objectui#12291). + */ +export type RecordFormRecord = { id?: unknown; _id?: unknown; [field: string]: unknown }; + +/** + * The values a NEW record carries into the create form (objectui#12291), or + * `undefined` when there are none to carry. + * + * The console's `onEdit` has always read a record without an id as a new one + * (the list's "New" hands it no record at all). A record without an id that + * carries field values is a create SEEDED with them: the calendar's empty-day + * hand-off sends the day that was clicked (its start date field, and its end + * date field when bound), so the full create form opens with the date set. + * An existing record carries none — the edit form reads the stored row. + * `null` / `undefined` entries carry nothing and are dropped. + */ +export function recordFormCreateValues( + record: RecordFormRecord | null | undefined, +): Record | undefined { + if (!record) return undefined; + const rawId = record.id ?? record._id; + if (rawId != null && rawId !== '') return undefined; + const values: Record = {}; + for (const [field, value] of Object.entries(record)) { + if (field === 'id' || field === '_id' || value === undefined || value === null) continue; + values[field] = value; + } + return Object.keys(values).length > 0 ? values : undefined; +} + +/** + * The history-state key a seeded create rides on to the record-form OVERLAY + * (objectui#12291) — the overlay's own create values, beside the `?form=new` + * that opens it. + * + * History state, not a URL param, for the reason the post-create landing in + * `AppContent` already gives for its `from` origin: the reserved console URL + * params are a contract (`urlParams.ts`), and a create form's opening values + * are not something anyone shares or bookmarks. History state still gives what + * a URL would here: it belongs to the entry the overlay was opened on, so Back + * closes the overlay with it, and a refresh of that entry restores it. + */ +export const RECORD_FORM_CREATE_VALUES_STATE = 'recordFormCreateValues'; + +/** The create values a history entry carries for the overlay, if any. */ +export function readRecordFormCreateValues(state: unknown): Record | undefined { + if (!state || typeof state !== 'object') return undefined; + const values = (state as Record)[RECORD_FORM_CREATE_VALUES_STATE]; + return values && typeof values === 'object' && !Array.isArray(values) + ? (values as Record) + : undefined; +} + /** * Build a record-form navigation target. * @@ -38,7 +94,10 @@ export type RecordFormTarget = * URL points at `/{baseUrl}/{objectName}/new` for create, or * `/{baseUrl}/{objectName}/record/{recordId}/edit` for edit. The * `recordId` is derived from `record.id` or `record._id`, falling - * back to create-mode if neither is present. + * back to create-mode if neither is present. A create seeded with + * values ({@link recordFormCreateValues}, objectui#12291) carries them + * as the query-string pairs `RecordFormPage` already reads as its + * create prefill (`/new?field=value`). * * Notes: * - Returns `{ kind: 'modal' }` when `objectDef` is missing or has no @@ -50,7 +109,7 @@ export type RecordFormTarget = export function resolveRecordFormTarget(opts: { objectDef: ObjectDefinitionForNavigation | null | undefined; baseUrl: string; - record: { id?: string | number; _id?: string | number } | null | undefined; + record: RecordFormRecord | null | undefined; }): RecordFormTarget { const { objectDef, baseUrl, record } = opts; @@ -67,7 +126,12 @@ export function resolveRecordFormTarget(opts: { }; } - return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new` }; + const createValues = recordFormCreateValues(record); + if (!createValues) return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new` }; + const prefill = new URLSearchParams( + Object.entries(createValues).map(([field, value]) => [field, String(value)]), + ); + return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new?${prefill.toString()}` }; } /** diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index a53ea82b14..4bef3e92b3 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -77,6 +77,16 @@ The new record is optimistically inserted into local state so it appears immediately. To override (e.g. open your own create form), pass `onDateClick={(day) => …}` — the default behaviour is skipped. +The dialog is offered only where that payload can succeed. If the object +has a required field outside the set above (a required lookup, a required +text field other than the title), the click — or a range drag in the +week/day grid — opens the host's full create form instead: the calendar +calls `onEdit` with a new record (no `id`) carrying the date values, e.g. +`onEdit({ due_date: '2026-10-20' })`, and the host pre-fills its create form +with them. The console does this already. Without an `onEdit`, the gesture +offers nothing on such an object. Required fields the server fills on insert +(`readonly`, `system`, `autonumber`) do not count. + ## Reporting the visible days A host that fetches the calendar's records for it, and hands them over as diff --git a/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx new file mode 100644 index 0000000000..224c7e74fa --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx @@ -0,0 +1,320 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12291 — quick-create from an empty day is offered only where its + * write can succeed, and otherwise hands the day to the host's full create form. + * + * Measured on the stock `showcase_task` (required `project`, a master-detail + * lookup): the dialog asked for a title only, its write auto-filled the + * required select and left the lookup empty, and the server answered 400 + * "Project is required" — with no Project input anywhere in the dialog. The + * triage direction on the card: check the object's required fields against what + * quick-create can fill BEFORE offering the dialog; where one is outside that + * set, open the full create form pre-filled with the clicked day's date field; + * ⛔ no lookup picker in the dialog, ⛔ no guessed lookup value. + * + * The full create form is the HOST's. `ObjectCalendar` reaches it through the + * declared `onEdit` prop, the channel `ListView` already hands down (and in the + * console that is the handler that opens the record form): it is called with a + * NEW record — no id — carrying the date values. The console half of the + * hand-off (the form opening with those values) is pinned in `app-shell`'s + * `AppContent.createSeed-12291.test.tsx`. + * + * `CalendarView` is replaced by a probe that records the handlers it is handed, + * as in `ObjectCalendar.writeGrant-12082.test.tsx`: an affordance is the + * PRESENCE of its handler there, so what is measured here is which handler + * `ObjectCalendar` hands the grid, and driving it is the real path into the + * component's own decision. + */ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; + +const probe = vi.hoisted(() => ({ + props: null as null | Record, +})); + +vi.mock('./CalendarView', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + CalendarView: (props: Record) => { + probe.props = props; + return
; + }, + }; +}); + +import { ObjectCalendar } from './ObjectCalendar'; +// Module scope: the registration the relay case renders through. +import './index'; + +/** The stock `showcase_task`'s required set: a required master-detail lookup. */ +const TASK_FIELDS = { + title: { type: 'text', required: true }, + project: { type: 'master_detail', reference: 'showcase_project', required: true }, + status: { + type: 'select', + required: true, + options: [ + { label: 'Backlog', value: 'backlog' }, + { label: 'To Do', value: 'todo' }, + ], + }, + due_date: { type: 'date' }, +}; + +/** The stock `crm_activity`'s required set: the title plus two required selects. */ +const ACTIVITY_FIELDS = { + subject: { type: 'text', required: true }, + type: { + type: 'select', + required: true, + options: [ + { label: 'Call', value: 'call' }, + { label: 'Email', value: 'email' }, + ], + }, + status: { + type: 'select', + required: true, + options: [ + { label: 'Planned', value: 'planned' }, + { label: 'Completed', value: 'completed' }, + ], + }, + due_date: { type: 'datetime' }, + contact: { type: 'lookup', reference: 'crm_contact' }, +}; + +const TASK_SCHEMA = { + type: 'object-calendar', + objectName: 'showcase_task', + calendar: { startDateField: 'due_date', titleField: 'title' }, +} as any; + +const ACTIVITY_SCHEMA = { + type: 'object-calendar', + objectName: 'crm_activity', + calendar: { startDateField: 'due_date', titleField: 'subject' }, +} as any; + +/** The day the card's reproduction clicked. */ +const OCT_20 = new Date(2026, 9, 20); + +const makeDataSource = (objectName: string, fields: Record) => + ({ + find: vi.fn().mockResolvedValue({ data: [] }), + getObjectSchema: vi.fn().mockResolvedValue({ name: objectName, fields }), + create: vi.fn().mockResolvedValue({ id: 'new-1' }), + update: vi.fn().mockResolvedValue({}), + }) as any; + +beforeEach(() => { + probe.props = null; +}); +afterEach(cleanup); + +type Handler = ((...args: unknown[]) => unknown) | undefined; +const handler = (name: string): Handler => probe.props?.[name] as Handler; + +/** + * Let the object schema SETTLE: the decision reads its field definitions, so + * the handler the grid holds before that is the no-schema default. The adapter + * double answers on a microtask; one macrotask inside `act` lets that answer + * land and the re-render commit. + */ +async function schemaSettled(ds: { getObjectSchema: ReturnType }) { + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function mount( + schema: any, + fields: Record, + props: Record = {}, + perms: MePermissionsResponse | null = null, +) { + const ds = makeDataSource(schema.objectName, fields); + const cal = ; + render(perms ? {cal} : cal); + await screen.findByTestId('calendar-probe'); + await schemaSettled(ds); + return ds; +} + +describe('objectui#12291 — an object quick-create cannot satisfy hands the day to the host\'s full create form', () => { + it('on showcase_task (required project) the empty-day click opens the full form with due_date set, and no dialog', async () => { + const onEdit = vi.fn(); + const ds = await mount(TASK_SCHEMA, TASK_FIELDS, { onEdit }); + + act(() => { + handler('onDateClick')!(OCT_20); + }); + + expect(onEdit).toHaveBeenCalledTimes(1); + // A NEW record (no id) carrying exactly the clicked day, in the stored + // `YYYY-MM-DD` form of a `date` field — and nothing guessed for `project`. + expect(onEdit.mock.calls[0][0]).toEqual({ due_date: '2026-10-20' }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(ds.create).not.toHaveBeenCalled(); + }); + + it('a week / day range drag hands off too, with both ends when the calendar binds an end field', async () => { + const onEdit = vi.fn(); + const schema = { + type: 'object-calendar', + objectName: 'showcase_task', + calendar: { startDateField: 'start_at', endDateField: 'end_at', titleField: 'title' }, + } as any; + const fields = { + title: TASK_FIELDS.title, + project: TASK_FIELDS.project, + start_at: { type: 'datetime' }, + end_at: { type: 'datetime' }, + }; + await mount(schema, fields, { onEdit }); + const start = new Date(2026, 9, 20, 9, 0); + const end = new Date(2026, 9, 20, 10, 30); + + act(() => { + handler('onTimeRangeSelect')!(start, end); + }); + + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit.mock.calls[0][0]).toEqual({ start_at: start.toISOString(), end_at: end.toISOString() }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('reaches the host through the registered renderer, as `ListView` hands its `onEdit` down', async () => { + const onEdit = vi.fn(); + const ds = makeDataSource('showcase_task', TASK_FIELDS); + render( + + + , + ); + await screen.findByTestId('calendar-probe'); + await schemaSettled(ds); + + act(() => { + handler('onDateClick')!(OCT_20); + }); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit.mock.calls[0][0]).toEqual({ due_date: '2026-10-20' }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('with no host form to hand off to, the gesture offers nothing on such an object', async () => { + await mount(TASK_SCHEMA, TASK_FIELDS); + expect(handler('onDateClick')).toBeUndefined(); + expect(handler('onTimeRangeSelect')).toBeUndefined(); + }); + + it('a closed `calendarQuickCreate` row withholds the hand-off exactly as it withholds the dialog', async () => { + const onEdit = vi.fn(); + const readOnly: MePermissionsResponse = { + authenticated: true, + userId: 'u-pin', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { showcase_task: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false } as never }, + fields: {}, + }; + await mount(TASK_SCHEMA, TASK_FIELDS, { onEdit }, readOnly); + expect(handler('onDateClick')).toBeUndefined(); + expect(handler('onTimeRangeSelect')).toBeUndefined(); + }); + + it('a caller-supplied onDateClick still wins over the hand-off', async () => { + const onEdit = vi.fn(); + const onDateClick = vi.fn(); + await mount(TASK_SCHEMA, TASK_FIELDS, { onEdit, onDateClick }); + act(() => { + handler('onDateClick')!(OCT_20); + }); + expect(onDateClick).toHaveBeenCalledTimes(1); + expect(onEdit).not.toHaveBeenCalled(); + }); +}); + +describe('objectui#12291 — an object with no unfillable required field keeps the dialog', () => { + it('crm_activity: the click opens the dialog, and its write carries the day and the auto-filled selects', async () => { + const onEdit = vi.fn(); + const ds = await mount(ACTIVITY_SCHEMA, ACTIVITY_FIELDS, { onEdit }); + + act(() => { + handler('onDateClick')!(OCT_20); + }); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + expect(onEdit).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Call Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1)); + expect(ds.create.mock.calls[0][0]).toBe('crm_activity'); + expect(ds.create.mock.calls[0][1]).toEqual({ + subject: 'Call Ada', + due_date: OCT_20.toISOString(), + type: 'call', + status: 'planned', + }); + }); +}); + +describe('objectui#12291 — the check and the write read ONE statement of what quick-create can fill', () => { + // Each row adds one required field to an object that otherwise keeps the + // dialog, and names whether quick-create can carry it. The write's own + // auto-fill and the check before the dialog must answer alike: a field the + // write fills keeps the dialog, a field it leaves empty hands off. + const ROWS: Array<{ name: string; def: Record; handsOff: boolean }> = [ + { name: 'a required select with options', def: { type: 'select', required: true, options: [{ label: 'A', value: 'a' }] }, handsOff: false }, + { name: 'a required select with NO options', def: { type: 'select', required: true, options: [] }, handsOff: true }, + { name: 'a required boolean', def: { type: 'boolean', required: true }, handsOff: false }, + { name: 'a required currency', def: { type: 'currency', required: true }, handsOff: false }, + { name: 'a required text with a declared default', def: { type: 'text', required: true, defaultValue: 'n/a' }, handsOff: false }, + { name: 'a required text', def: { type: 'text', required: true }, handsOff: true }, + { name: 'a required lookup', def: { type: 'lookup', reference: 'crm_account', required: true }, handsOff: true }, + // The server fills these on insert, so the write is not refused for them. + { name: 'a required readonly field', def: { type: 'text', required: true, readonly: true }, handsOff: false }, + { name: 'a required system field', def: { type: 'lookup', reference: 'sys_user', required: true, system: true }, handsOff: false }, + { name: 'a required autonumber', def: { type: 'autonumber', required: true }, handsOff: false }, + ]; + + it.each(ROWS)('$name — hands off: $handsOff', async ({ def, handsOff }) => { + const onEdit = vi.fn(); + const ds = await mount(ACTIVITY_SCHEMA, { ...ACTIVITY_FIELDS, extra: def }, { onEdit }); + + act(() => { + handler('onDateClick')!(OCT_20); + }); + + if (handsOff) { + expect(onEdit).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog')).toBeNull(); + return; + } + expect(await screen.findByRole('dialog')).toBeTruthy(); + expect(onEdit).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Call Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 0dcedca457..60e33b5b35 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -173,6 +173,18 @@ export interface ObjectCalendarComponentProps { */ onRowClick?: (record: any, event?: any) => void; onDateClick?: (date: Date) => void; + /** + * The host's record form, opened on `record`. This component calls it for + * one gesture (objectui#12291): an empty-day click, or a range drag in the + * week / day grid, on an object with a required field quick-create cannot + * fill (`requiredFieldsQuickCreateCannotFill`). Quick-create could not + * succeed there, so the gesture hands the day to the host's full create + * form instead of offering the dialog: `record` is then a NEW record — it + * carries no `id` — whose keys are the date fields the click sets, written + * in their stored form, and the host pre-fills its create form with them. + * The console's handler already reads a record without an id as a new one. + * With no `onEdit`, that gesture offers nothing on such an object. + */ onEdit?: (record: any) => void; onDelete?: (record: any) => void; onNavigate?: (date: Date) => void; @@ -363,6 +375,110 @@ function isDateOnlyField(declaredType: unknown, stored: unknown): boolean { : typeof stored === 'string' && isRealCalendarDate(stored); } +/** + * The date values a quick-create writes, and the values its hand-off to the + * host's full create form pre-fills (objectui#12291): the start field as the + * day clicked (or the start of the range dragged), and the end field, when + * the calendar binds one, as the range's end — the same day for a click. + * Each in its stored form ({@link toStoredDateValue}). + */ +function quickCreateDateValues( + config: ObjectCalendarConfig, + fieldDefs: Record | undefined, + start: Date, + end: Date | undefined, +): Record { + const values: Record = { + [config.startDateField]: toStoredDateValue(start, fieldDefs?.[config.startDateField]?.type, undefined), + }; + if (config.endDateField) { + values[config.endDateField] = toStoredDateValue(end ?? start, fieldDefs?.[config.endDateField]?.type, undefined); + } + return values; +} + +/** A field definition, as much of it as quick-create reads. */ +interface QuickCreateFieldDef { + type?: unknown; + required?: unknown; + readonly?: unknown; + system?: unknown; + defaultValue?: unknown; + options?: unknown; + choices?: unknown; + name?: string; + apiName?: string; +} + +/** The object schema's fields as `[name, definition]` pairs, keyed map or array. */ +function quickCreateFieldEntries(fieldsMeta: unknown): [string, QuickCreateFieldDef | undefined][] { + if (!fieldsMeta || typeof fieldsMeta !== 'object') return []; + return Array.isArray(fieldsMeta) + ? (fieldsMeta as QuickCreateFieldDef[]).map((f) => [f.name ?? f.apiName ?? '', f]) + : Object.entries(fieldsMeta as Record); +} + +/** + * What quick-create writes for a required field its dialog does not ask for, + * or `undefined` when it has nothing to write. + * + * ⭐ This is THE statement of the set quick-create can fill (objectui#12291), + * and both halves of quick-create read it: `submitQuickCreate` writes these + * values, and {@link requiredFieldsQuickCreateCannotFill} asks the same + * question before the dialog is offered. So the dialog appears exactly when + * the write it sends carries every required field. ⛔ Never a second copy of + * the set beside the check: two copies drift, and the drift is a dialog that + * cannot succeed. + * + * ⛔ Not widened to guess. A required lookup has no value this component + * could know; an object that has one gets the host's full create form, which + * asks for it. + */ +function quickCreateFill(def: QuickCreateFieldDef): { value: unknown } | undefined { + if (def.defaultValue !== undefined && def.defaultValue !== null) { + return { value: def.defaultValue }; + } + const t = def.type; + if (t === 'select' || t === 'picklist' || t === 'status') { + const opts = (def.options || def.choices || []) as unknown[]; + const first = opts[0]; + if (first === undefined) return undefined; + if (typeof first !== 'object' || first === null) return { value: first }; + const option = first as { value?: unknown; id?: unknown }; + return { value: option.value ?? option.id }; + } + if (t === 'boolean' || t === 'checkbox') return { value: false }; + if (t === 'number' || t === 'integer' || t === 'decimal' || t === 'currency' || t === 'percent') { + return { value: 0 }; + } + // A required text, date, lookup or any other type has nothing to write + // here: the write leaves it empty and the server refuses it. + return undefined; +} + +/** + * The required fields a quick-create write would leave empty (objectui#12291): + * required, not among the keys the dialog writes itself (`provided` — the + * title field and the date fields), and outside {@link quickCreateFill}'s set. + * Read from the field definitions this component already holds, never from a + * server round trip. + * + * A required field the server fills on insert needs no input and is not + * counted: one declared `readonly` or `system`, or of type `autonumber`. Those + * are the three exemptions objectql's insert walk (`validateRecordInScope`, + * with `isCallerWritableField`) makes before its required check, so a write + * that leaves them empty is not refused for them. + */ +function requiredFieldsQuickCreateCannotFill(fieldsMeta: unknown, provided: ReadonlySet): string[] { + const unfillable: string[] = []; + for (const [name, def] of quickCreateFieldEntries(fieldsMeta)) { + if (!name || provided.has(name) || !def?.required) continue; + if (def.readonly === true || def.system === true || def.type === 'autonumber') continue; + if (!quickCreateFill(def)) unfillable.push(name); + } + return unfillable; +} + /** * A record the calendar cannot place: the field declared as `startDateField` * carries no value on it, so there is no date to draw and none is invented @@ -383,6 +499,7 @@ export const ObjectCalendar: React.FC = ({ onEventClick, onRowClick, onDateClick, + onEdit, onNavigate, onViewChange, onVisibleRangeChange, @@ -1186,6 +1303,45 @@ export const ObjectCalendar: React.FC = ({ setQuickCreate({ start, end, title: '', submitting: false }); }, [calendarConfig, schema.objectName, dataSource]); + // objectui#12291 — can the dialog's write succeed on this object? It writes + // the title field and the date fields, plus what `quickCreateFill` fills, so + // any other required field is one it would leave empty, and the server would + // refuse the create for it (a required lookup on the stock `showcase_task`: + // 400 "Project is required", with no Project input to offer). Recomputed + // each render from the field definitions; read as a count, never as an + // identity (AGENTS.md #10). + const quickCreateUnfillable = calendarConfig + ? requiredFieldsQuickCreateCannotFill( + objectSchema?.fields, + new Set( + [calendarConfig.titleField || 'name', calendarConfig.startDateField, calendarConfig.endDateField] + .filter((key): key is string => !!key), + ), + ) + : []; + const quickCreateCanSucceed = quickCreateUnfillable.length === 0; + + // …and where it cannot, the same gesture opens the host's FULL create form + // with the day it was made on (the triage direction on objectui#12291): a + // new record, no id, carrying the date values the dialog would have written. + // ⛔ No lookup picker in the dialog and no guessed value in its write — the + // full form is the one that asks for the lookup. + const handOffToCreateForm = useCallback((start: Date, end?: Date) => { + if (!calendarConfig || !schema.objectName || !onEdit) return; + const fieldDefs = objectSchema?.fields as Record | undefined; + onEdit(quickCreateDateValues(calendarConfig, fieldDefs, start, end)); + }, [calendarConfig, schema.objectName, onEdit, objectSchema]); + + // The two quick-create gestures (an empty-day click, a week / day range + // drag), as handed to `CalendarView`, which draws an affordance only when it + // holds its handler: closed by the `calendarQuickCreate` row + // (objectui#12082); the dialog when its write can succeed; otherwise the + // hand-off; and NOTHING when there is no host form to hand off to — a dialog + // that cannot succeed is never offered. + const handOff = onEdit ? handOffToCreateForm : undefined; + const dayClickCreate = !quickCreateGranted ? undefined : quickCreateCanSucceed ? handleDateClickDefault : handOff; + const rangeSelectCreate = !quickCreateGranted ? undefined : quickCreateCanSucceed ? handleTimeRangeSelectDefault : handOff; + const submitQuickCreate = useCallback(async () => { if (!quickCreate || !calendarConfig) return; const title = quickCreate.title.trim(); @@ -1196,53 +1352,24 @@ export const ObjectCalendar: React.FC = ({ if (!schema.objectName || !dataSource?.create) return; setQuickCreate(qc => qc ? { ...qc, submitting: true, error: undefined } : qc); - const { startDateField, endDateField, titleField } = calendarConfig; + const { titleField } = calendarConfig; // A `date` field is written as the calendar day clicked, a `datetime` as - // the instant (objectui#10866, `toStoredDateValue`). + // the instant (objectui#10866, `toStoredDateValue`); the end field, when + // bound, as the range's end or the same day. const dateFieldDefs = objectSchema?.fields as Record | undefined; const payload: Record = { [titleField || 'name']: title, - [startDateField]: toStoredDateValue(quickCreate.start, dateFieldDefs?.[startDateField]?.type, undefined), + ...quickCreateDateValues(calendarConfig, dateFieldDefs, quickCreate.start, quickCreate.end), }; - // Default end_date to range end (or same as start if not provided). - if (endDateField) { - payload[endDateField] = toStoredDateValue( - quickCreate.end ?? quickCreate.start, - dateFieldDefs?.[endDateField]?.type, - undefined, - ); - } - // Auto-fill required fields the user hasn't provided (e.g. select - // status, autonumber). Without this the server would 400 on - // NOT NULL constraint. Uses first option for picklists; falls back - // to defaultValue or sensible empty string for text. - const fieldsMeta = objectSchema?.fields; - if (fieldsMeta && typeof fieldsMeta === 'object') { - const entries: [string, any][] = Array.isArray(fieldsMeta) - ? fieldsMeta.map((f: any) => [f.name ?? f.apiName, f] as [string, any]) - : Object.entries(fieldsMeta); - for (const [name, def] of entries) { - if (!name || name in payload) continue; - if (!def?.required) continue; - if (def.defaultValue !== undefined && def.defaultValue !== null) { - payload[name] = def.defaultValue; - continue; - } - const t = def.type; - if (t === 'select' || t === 'picklist' || t === 'status') { - const opts = (def.options || def.choices || []) as any[]; - const first = opts[0]; - if (first !== undefined) { - payload[name] = typeof first === 'object' ? (first.value ?? first.id) : first; - } - } else if (t === 'boolean' || t === 'checkbox') { - payload[name] = false; - } else if (t === 'number' || t === 'integer' || t === 'decimal' || t === 'currency' || t === 'percent') { - payload[name] = 0; - } - // autonumber/text/date that are required but not provided will fall - // through; the server will surface a clear error which we display. - } + // Auto-fill the required fields the dialog does not ask for, from the one + // statement of what quick-create can fill (`quickCreateFill`) — the set + // the check before the dialog read. The only required fields left empty + // here are the ones the server fills on insert (that check's exemptions); + // an object with any other one hands the gesture to the host's full form. + for (const [name, def] of quickCreateFieldEntries(objectSchema?.fields)) { + if (!name || name in payload || !def?.required) continue; + const fill = quickCreateFill(def); + if (fill) payload[name] = fill.value; } try { const created = await dataSource.create(schema.objectName, payload); @@ -1476,9 +1603,11 @@ export const ObjectCalendar: React.FC = ({ } }} // Quick-create on empty-day click. Caller-supplied onDateClick - // wins; otherwise open the quick-create dialog — when the - // `calendarQuickCreate` row allows it (objectui#12082). - onDateClick={onDateClick ?? (quickCreateGranted ? handleDateClickDefault : undefined)} + // wins; otherwise `dayClickCreate` — the dialog, or the hand-off to + // the host's full create form where the dialog's write cannot + // succeed (objectui#12291), when the `calendarQuickCreate` row + // allows it (objectui#12082). + onDateClick={onDateClick ?? dayClickCreate} onNavigate={(date) => { setCurrentDate(date); onNavigate?.(date); @@ -1500,7 +1629,7 @@ export const ObjectCalendar: React.FC = ({ } : undefined } - onTimeRangeSelect={quickCreateGranted ? handleTimeRangeSelectDefault : undefined} + onTimeRangeSelect={rangeSelectCreate} />
{/* objectui#7210 — a month drawn from the first N rows of a larger set @@ -1553,8 +1682,9 @@ export const ObjectCalendar: React.FC = ({ {/* Quick-create dialog: opens when the user clicks an empty day cell. Pre-fills start_date (and end_date) with the clicked day; only the - title is required. The full record can be edited afterward via the - standard detail page. */} + title is asked for. Offered only on an object whose required fields + its write can carry (objectui#12291); the full record can be edited + afterward via the standard detail page. */} { if (!open) setQuickCreate(null); }}> diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index 6f50ce0bad..ca83ce22e5 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -131,14 +131,20 @@ const OBJECT_CALENDAR_DATA_SOURCE: ElementDataSourceMapping = { * declares, so listing them narrows what may reach the component and widens * nothing. * - * `onEdit` and `onDelete` are declared by `ObjectCalendarComponentProps` but are NOT + * `onDelete` is declared by `ObjectCalendarComponentProps` but is NOT * destructured by the component (`ObjectCalendar.tsx` — the record drawer builds - * its own edit/delete handlers from `dataSource`), so forwarding them is inert - * today. They are listed anyway: the hatch is bounded by the DECLARATION, and a - * key that is declared, inert and silently dropped would be a second, quieter - * contract than the one this boundary states. Their inertness is a census - * observation, not a behaviour change — before and after this card, a value - * under either name does nothing. + * its own delete handler from `dataSource`), so forwarding it is inert today. It + * is listed anyway: the hatch is bounded by the DECLARATION, and a key that is + * declared, inert and silently dropped would be a second, quieter contract than + * the one this boundary states. + * + * `onEdit` was inert the same way until objectui#12291, and it is the one this + * forward carries that a host really wires: `ListView` hands its own `onEdit` + * down through the trailing props, and in the console that is the handler that + * opens the record form. The calendar now calls it for one gesture — an + * empty-day click on an object whose required fields quick-create cannot fill + * — with a new record carrying the clicked day, so the host's full create form + * opens pre-filled (see the prop's own doc comment). */ const HOST_CALLBACKS = [ 'onEventClick',