Skip to content
37 changes: 37 additions & 0 deletions .changeset/12082-write-affordance-census.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/core': minor
'@object-ui/plugin-view': patch
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-form': patch
---

Five write affordances that read no grant at all now read the affordance-to-grant
map, so a caller without the grant is no longer offered a write the server then
refuses (objectui#12082, the card's remainder).

- **`object-view`'s New** (the toolbar button of the SDUI `object-view` node)
reads the `listNew` row, as the console's list pages already did: the
object's policy, the effective API operation set and the caller's create
grant, on top of the node's `showCreate` / `operations.create` toggles.
- **The calendar's quick-create** (an empty-day click, or a time-range drag in
the week / day grid) reads the new `calendarQuickCreate` row, and
**drag-to-reschedule** reads the new `calendarReschedule` row. A closed row
withholds the handler the calendar grid draws the affordance from: no dialog
on a day click, no range drag, no draggable event. A host-supplied
`onDateClick` / `onEventDrop` is handed through unchanged.
- **The kanban card move** reads the new `kanbanCardMove` row. A closed row
draws the cards as not movable and hands the board no mover, so no drop
reaches the write.
- **A line-items panel's add and remove** (`record:line_items`) read the
`relatedNew` and `relatedRowDelete` rows on the CHILD object, the same rows a
related list reads for the same two writes. Add covers the Add button, the
entry row and Duplicate; remove covers the per-row Remove.

With no permission provider mounted every one of them reads open, as before.

**Clause-②: yes (widening).** `AFFORDANCE_GRANTS`, exported from
`@object-ui/core`, gains three rows: `calendarQuickCreate` (create),
`calendarReschedule` (update) and `kanbanCardMove` (update), and with them the
`ConsoleAffordance` union that `resolveAffordance` accepts. Nothing is removed or
renamed. The other four packages change behaviour only.
12 changes: 12 additions & 0 deletions content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ default.

When `ObjectCalendar` is bound to an object (i.e. it has `objectName` and a `dataSource`), the new dates are persisted automatically via `dataSource.update()` — local state is updated optimistically and rolled back on failure. To intercept (e.g. for a confirm dialog) pass an `onEventDrop` prop; supplying your own handler disables the default persistence.

The default persistence is offered only to a caller who may make the write: events
are draggable when the `calendarReschedule` row of the affordance-to-grant map
(`@object-ui/core`) allows it — the object's policy, the effective API operation
set and the caller's **update** grant. Without it the events stay where they are.
With no permission provider mounted it reads open.

```jsx
<ObjectCalendar
schema={{ type: 'object-calendar', objectName: 'campaign' }}
Expand Down Expand Up @@ -83,6 +89,12 @@ or the field's declared `defaultValue`). This ensures the create
succeeds against `NOT NULL` columns without forcing the user through
the full form.

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
opens nothing and the time grid starts no range drag. With no permission
provider mounted it reads open.

To override (e.g. open your own multi-field create form), pass
`onDateClick`:

Expand Down
7 changes: 7 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,13 @@ save returned as its next `ifMatch`, so its second save is not refused over its
own first one. A host `submitHandler` receives the same payload in edit mode.
The package README has the full rule, under "What an edit save writes".

A `record:line_items` panel offers adding a line (its Add button, entry row and
Duplicate) only when the caller may create the CHILD object, and removing one
only when they may delete it — the `relatedNew` and `relatedRowDelete` rows of the
affordance-to-grant map in `@object-ui/core`, the same rows a related list reads
for the same two writes. Its cells ask each column's field question, as the form
layouts do. With no permission provider mounted both read open.

## Examples

### Form with Validation
Expand Down
6 changes: 5 additions & 1 deletion content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ const onCardMove = (cardId: string, fromCol: string, toCol: string, index: numbe

## Features

- **Drag and drop cards** between columns
- **Drag and drop cards** between columns — on an object-bound board
(`object-kanban`), only for a caller the `kanbanCardMove` row of the
affordance-to-grant map allows (the object's policy, the effective API
operation set and the caller's **update** grant); without it the cards are
not movable. With no permission provider mounted it reads open.
- **Column limits** (WIP limits)
- **Column totals**: a kanban view's `summarizeField` sums that field over each column's loaded cards in the column header, beside the count (with the count's `+` when the fetch window is full)
- **Card badges** for status/priority
Expand Down
6 changes: 5 additions & 1 deletion content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,11 @@ the `show*` flags control whether the matching toolbar affordance is visible.
### Create

`operations.create` enables record creation; `showCreate` shows the button. Both
default to on. The new-record form opens on the surface a `drawer` or `modal`
default to on, and the button also needs the `listNew` row of the
affordance-to-grant map (`@object-ui/core`) to allow it — the object's policy,
the effective API operation set and the caller's **create** grant — exactly as
the console's list pages read it. With no permission provider mounted that grant
reads open. The new-record form opens on the surface a `drawer` or `modal`
navigation names, and on the `layout` surface otherwise, under `split` and
`popover` too (see Opening a record):

Expand Down
8 changes: 7 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,13 @@ resolveFieldAffordance('editFormFields', perms, 'orders', 'title') // false
```

An affordance added later gets a row here and reads it through these functions;
it does not spell its own `can(object, verb)`.
it does not spell its own `can(object, verb)`. That includes an affordance that
reads NO grant at all — the calendar's quick-create and drag-to-reschedule
(`calendarQuickCreate`, `calendarReschedule`) and the kanban card move
(`kanbanCardMove`) were such affordances until they gained their rows. The map's
enumeration pin in `@object-ui/plugin-form` (`affordanceGrantMap-12082.test.tsx`)
also counts every write call site in the console tree and holds each to the row
it sits behind, or to the reason it has none.

### Undo snapshot for an update (`captureUpdateUndoData`)

Expand Down
30 changes: 27 additions & 3 deletions packages/core/src/utils/affordanceGrants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
* against four grant shapes and holds the row set to its own expectation table,
* and its census refuses a console source file that reads a CRUD grant without
* this map.
*
* An affordance that reads NO grant is invisible to a census of grant reads —
* the calendar's quick-create and drag-to-reschedule and the kanban card move
* were such affordances until they gained their rows. So the same pin also
* counts the WRITES: every call site that invokes a create, update or delete on
* a data source has an entry there naming the row it sits behind and the file
* that reads it, or the reason it has none. A write site added without an entry
* turns that pin red.
*/

import {
Expand Down Expand Up @@ -116,7 +124,10 @@ export const AFFORDANCE_GRANTS = {
recordEdit: { crud: 'edit', grant: 'update' },
/** The record page's Delete. */
recordDelete: { crud: 'delete', grant: 'delete' },
/** An object list's New (toolbar button and the phone "+"). */
/**
* An object list's New: the console list page's toolbar button and phone
* "+", and the `object-view` node's toolbar button (`@object-ui/plugin-view`).
*/
listNew: { crud: 'create', grant: 'create' },
/** An object list's Import, and the import wizard's writable target fields. */
listImport: { crud: 'import', grant: 'create', field: 'create' },
Expand All @@ -132,18 +143,31 @@ export const AFFORDANCE_GRANTS = {
rowDelete: { crud: 'delete', grant: 'delete' },
/** A grid's inline add-record row. */
gridAddRow: { crud: 'create', grant: 'create' },
/** A related list's "+ New", asked of the CHILD object. */
/**
* A related list's "+ New", asked of the CHILD object — and a line-items
* panel's add-a-line (its Add, entry row and Duplicate), which creates a child
* under the same parent.
*/
relatedNew: { crud: 'create', grant: 'create' },
/** A related list row's Edit, asked of the child object. */
relatedRowEdit: { crud: 'edit', grant: 'update' },
/** A related list row's Delete, asked of the child object. */
/**
* A related list row's Delete, asked of the child object — and a line-items
* panel's remove-a-line, which deletes that child on Save.
*/
relatedRowDelete: { crud: 'delete', grant: 'delete' },
/** A lookup picker's "Create new", asked of the TARGET object. */
lookupCreateNew: { crud: 'create', grant: 'create' },
/** The record Attachments panel's Upload, asked of `sys_attachment` (storage route, not the data door). */
attachmentUpload: { crud: null, grant: 'create' },
/** The record Attachments panel's per-row delete, asked of `sys_attachment`. */
attachmentDelete: { crud: null, grant: 'delete' },
/** A calendar's quick-create: an empty-day click, or a time-range drag in the week / day grid. */
calendarQuickCreate: { crud: 'create', grant: 'create' },
/** A calendar's drag-to-reschedule: moving or resizing an event writes its date fields. */
calendarReschedule: { crud: 'edit', grant: 'update' },
/** A kanban's card move: a cross-column drop writes the record's `groupBy` field. */
kanbanCardMove: { crud: 'edit', grant: 'update' },
} as const satisfies Record<string, AffordanceGrantRow>;

/** A console affordance with a row in {@link AFFORDANCE_GRANTS}. */
Expand Down
55 changes: 38 additions & 17 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ import {
isRealCalendarDate,
toDateInputValue,
toDisplayDate,
resolveAffordance,
type SchemaLike,
} from '@object-ui/core';

/**
Expand Down Expand Up @@ -1043,6 +1045,27 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
// own container instead.
const { anchorRef, anchorCaptureProps } = useOverlayAnchor();

// objectui#12082 — the calendar's two default writes are rows of the
// affordance-to-grant map (`resolveAffordance` in `@object-ui/core`):
// quick-create is `calendarQuickCreate`, drag-to-reschedule is
// `calendarReschedule`, each the object's policy, the effective operation set
// and the caller's grant on the object the write goes to (`schema.objectName`,
// the object both handlers below write). `CalendarView` draws an affordance
// only when it is handed its handler, so a closed row is a handler withheld
// in the JSX below: no draggable event, no range drag, no dialog on a day
// click. They used to read no grant at all, so a read-only caller could drag
// an event or submit the dialog and the server refused the write. With no
// permission provider mounted both grants read open, as before. The policy
// half reads this object's schema only when it IS the written object (an
// authored `data` block can point the read elsewhere).
const writeAffordanceSource = {
objectSchema: schemaObjectName === schema.objectName ? (objectSchema as SchemaLike | null) : null,
objectName: schema.objectName,
perms,
};
const quickCreateGranted = resolveAffordance('calendarQuickCreate', writeAffordanceSource).allowed;
const rescheduleGranted = resolveAffordance('calendarReschedule', writeAffordanceSource).allowed;

// Default drag-to-reschedule handler. When the caller hasn't provided an
// `onEventDrop`, persist the new dates back to the data source so dragging
// an event in the month view actually changes the record. Optimistic
Expand Down Expand Up @@ -1418,14 +1441,9 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
}
}}
// Quick-create on empty-day click. Caller-supplied onDateClick
// wins; otherwise open the quick-create dialog.
onDateClick={(day) => {
if (onDateClick) {
onDateClick(day);
} else {
handleDateClickDefault(day);
}
}}
// wins; otherwise open the quick-create dialog — when the
// `calendarQuickCreate` row allows it (objectui#12082).
onDateClick={onDateClick ?? (quickCreateGranted ? handleDateClickDefault : undefined)}
onNavigate={(date) => {
setCurrentDate(date);
onNavigate?.(date);
Expand All @@ -1436,15 +1454,18 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
}}
onAddClick={undefined}
// Wire drag-to-reschedule: caller-supplied handler wins, otherwise
// fall back to persisting via dataSource.update().
onEventDrop={(event, newStart, newEnd) => {
if (onEventDrop) {
onEventDrop(event.data, newStart, newEnd);
} else {
void handleEventDropDefault(event.data, newStart, newEnd);
}
}}
onTimeRangeSelect={handleTimeRangeSelectDefault}
// fall back to persisting via dataSource.update() — when the
// `calendarReschedule` row allows it (objectui#12082).
onEventDrop={
onEventDrop
? (event, newStart, newEnd) => onEventDrop(event.data, newStart, newEnd)
: rescheduleGranted
? (event, newStart, newEnd) => {
void handleEventDropDefault(event.data, newStart, newEnd);
}
: undefined
}
onTimeRangeSelect={quickCreateGranted ? handleTimeRangeSelectDefault : undefined}
/>
</div>
{/* objectui#7210 — a month drawn from the first N rows of a larger set
Expand Down
Loading
Loading