Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/12291-calendar-quick-create-handoff.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<ObjectCalendar
schema={{ type: 'object-calendar', objectName: 'task', calendar: { startDateField: 'due_date' } }}
// Called as onEdit({ due_date: '2026-10-20' }) on an object with a required lookup.
onEdit={(record) => 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
Expand Down
26 changes: 23 additions & 3 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<Record<string, any>>();
const RealModalForm = actual.ModalForm;
return {
...actual,
ModalForm: (props: any) => {
probe.modalSchemas.push(props.schema);
return probe.realModal ? <RealModalForm {...props} /> : <div data-testid="modal-form" />;
},
};
});

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
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 }) => (
<button type="button" data-testid="hand-off" onClick={() => onEdit(probe.record)}>
hand off
</button>
),
}));

vi.mock('../../views/RecordFormPage', () => ({
RecordFormPage: () => <div data-testid="record-form-page" />,
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<string, unknown>) => ({ id: 't-1', ...data })),
findOne: vi.fn(async () => null),
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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(
<MemoryRouter initialEntries={[`/apps/showcase_app/${objectName}/view/calendar`]}>
<LocationProbe />
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
);
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();
});
});
Loading
Loading