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
6 changes: 6 additions & 0 deletions .changeset/current-user-default-prefill-5683.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@object-ui/permissions': minor
'@object-ui/plugin-form': minor
---

Create forms pre-fill the `current_user` defaultValue token with the acting user (#5683). `PermissionContextValue` gains `userId` (from `/me/permissions`; `null` = unknown), and the create-form seeding resolves `defaultValue: 'current_user'` on `user` / `lookup→sys_user` fields to that id — the same value the engine stamps at insert, so the pre-fill is a preview of the server's own resolution, not a second default contract. Unknown user (no provider / anonymous / role-based provider) seeds nothing and keeps the omit-and-let-the-engine-resolve behavior. `NOW()` and CEL defaults stay server-owned.
3 changes: 3 additions & 0 deletions packages/permissions/src/MePermissionsProvider.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,9 @@ export function MePermissionsProvider({
getRowFilter,
getObjectApiOperations,
roles: data?.roles ?? [],
// [objectui#5683] `null` while unloaded/anonymous — never ''. Consumers
// treat null as "unknown" and defer to the server.
userId: data?.userId ?? null,
// [objectui#4656] Forward the raw signal — do NOT `?? []` this. A
// backend predating ADR-0066 omits `systemPermissions` from the
// response entirely, and defaulting that to `[]` here made it
Expand Down
10 changes: 10 additions & 0 deletions packages/permissions/src/PermissionContext.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,16 @@ export interface PermissionContextValue {
getObjectApiOperations: (object: string) => string[] | undefined;
/** Current user roles */
roles: string[];
/**
* [objectui#5683] The acting user's id, from `/me/permissions` (`userId`) —
* or `null` when the mounted provider has no backend answer to give (the
* role-based `PermissionProvider`, no provider at all, or an anonymous
* session). `null` means "unknown", and consumers must fall back to
* server-side behavior rather than substituting any other identity — the
* create-form `current_user` default seeding leaves the field empty and the
* key omitted, which is exactly the case the engine resolves at insert.
*/
userId: string | null;
/**
* [ADR-0066] System capabilities held by the user (union of permission-set
* `systemPermissions`), when the backend actually reports them.
Expand Down
4 changes: 4 additions & 0 deletions packages/permissions/src/PermissionProvider.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,10 @@ export function PermissionProvider({
// operation set — return undefined so consumers keep current behavior.
getObjectApiOperations: () => undefined,
roles: userRoles,
// [objectui#5683] Role-based provider never learns who the user IS —
// unreported (`null`), so create-form current_user seeding stays
// server-side under this provider.
userId: null,
// This role-based provider has no backend answer to give — it never
// fetches /me/permissions — so ADR-0066 system capabilities are simply
// unreported here: `undefined`, not `[]` (objectui#4656; a literal `[]`
Expand Down
2 changes: 2 additions & 0 deletions packages/permissions/src/usePermissions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ export function usePermissions(): PermissionContextValue & {
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
// [objectui#5683] No provider → identity unknown, defer to the server.
userId: null,
// [objectui#4656] No provider mounted at all → no answer, not "holds
// nothing". `undefined` matches MePermissionsProvider's own signal
// for an unreported backend and keeps `hasCapabilities` fail-open.
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-form/src/DrawerForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ import {
import { deriveFieldGroupSections } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { usePermissions } from '@object-ui/permissions';
import { useOccSave } from './occSave';

/**
Expand DownExpand Up@@ -180,6 +181,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
className,
}) => {
const { fieldLabel, sectionLabel } = useSafeFieldLabel();
const { userId: currentUserId } = usePermissions();
const { t } = useDiscardTranslation();
const previewMode = usePreviewMode();
const [objectSchema, setObjectSchema] = useState<any>(null);
Expand DownExpand Up@@ -256,7 +258,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
// Declared static defaults are this form's opening values (#4047) —
// see `schemaDefaults` for the create-only boundary and for why
// runtime defaults are left to the server.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
setLoading(false);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-form/src/ModalForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
// supplied initial values still win. See `schemaDefaults` for why
// runtime defaults (`NOW()`, `current_user`, CEL envelopes) are left
// to the server and why option-level `default` is not read here.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId: perms.userId }));
setLoading(false);
return;
}
Expand Down
8 changes: 6 additions & 2 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1012,9 +1012,13 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
// the create-mode `required` suppression (#4069), so seeding and validation
// cannot disagree about which mode this form is in.
const isCreateForm = isCreateFormMode(schema);
// [#5683] `currentUserId` lets the one client-resolvable token
// (`current_user`) pre-fill with the id the server would stamp anyway;
// null/unloaded seeds nothing and keeps the omit-and-let-the-engine-resolve
// contract above.
const schemaDefaults = React.useMemo(
() => (isCreateForm ? schemaDefaultValues(objectSchema) : {}),
[objectSchema, isCreateForm],
() => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId }) : {}),
[objectSchema, isCreateForm, perms.userId],
);

const finalDefaultValues = {
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-form/src/SplitForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { usePermissions } from '@object-ui/permissions';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { useOccSave } from './occSave';

Expand DownExpand Up@@ -116,6 +117,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
className,
}) => {
const { fieldLabel } = useSafeFieldLabel();
const { userId: currentUserId } = usePermissions();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// OCC-guarded edit save + its conflict dialog (see occSave.tsx).
Expand DownExpand Up@@ -164,7 +166,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
// Declared static defaults are this form's opening values (#4047) —
// see `schemaDefaults` for the create-only boundary and for why
// runtime defaults are left to the server.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
setLoading(false);
return;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-form/src/TabbedForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { usePermissions } from '@object-ui/permissions';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { useOccSave } from './occSave';

Expand DownExpand Up@@ -191,6 +192,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
className,
}) => {
const { fieldLabel } = useSafeFieldLabel();
const { userId: currentUserId } = usePermissions();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// OCC-guarded edit save + its conflict dialog (see occSave.tsx).
Expand DownExpand Up@@ -245,7 +247,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// Declared static defaults are this form's opening values (#4047) —
// see `schemaDefaults` for the create-only boundary and for why
// runtime defaults are left to the server.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
setLoading(false);
return;
}
Expand Down
8 changes: 5 additions & 3 deletions packages/plugin-form/src/WizardForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import { FormSectionContainer } from './FormSection';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues, omitServerResolvedDefaults, isCreateFormMode } from './schemaDefaults';
import { usePermissions } from '@object-ui/permissions';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { resolveSuccessNavigate, type SubmitBehavior } from './successBehavior';
import { resolveSubmitRedirect, submitRedirectScope } from './submitRedirect';
Expand DownExpand Up@@ -255,6 +256,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
className,
}) => {
const { fieldLabel } = useSafeFieldLabel();
const { userId: currentUserId } = usePermissions();
const { t } = useWizardTranslation();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
Expand DownExpand Up@@ -357,7 +359,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
// Declared static defaults are this wizard's opening values (#4047)
// — see `schemaDefaults` for the create-only boundary and for why
// runtime defaults are left to the server.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
seededRef.current = true;
}
setLoading(false);
Expand DownExpand Up@@ -594,7 +596,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
// Back to a fresh step 1 for the next entry — "fresh" means the
// same opening values the wizard had, defaults included (#4047),
// not a blank object the first entry never started from.
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
setCompletedSteps(new Set());
setCurrentStep(0);
setResetNonce((n) => n + 1);
Expand DownExpand Up@@ -649,7 +651,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
if (schema.resetOnSuccess && schema.mode === 'create') {
// Back to a fresh step 1 for the next entry — same opening values
// as the first entry, defaults included (#4047).
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues));
setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId }));
setCompletedSteps(new Set());
setCurrentStep(0);
setResetNonce((n) => n + 1);
Expand Down
165 changes: 165 additions & 0 deletions packages/plugin-form/src/currentUserDefault.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
/**
* 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.
*/

/**
* A CREATE form pre-fills the ONE runtime default the client can resolve
* exactly: `current_user` (#5683).
*
* The #4047/#4068 rule stands — runtime defaults are the server's to resolve,
* and seeding the literal token text would suppress that resolution. But the
* engine's `current_user` resolution IS "the acting user's id"
* (`ObjectQL.applyFieldDefaults` → `execCtx.userId`), and this session is that
* actor, so seeding `usePermissions().userId` previews the very value the
* server would stamp. This is #4069's promised "surface what the server WILL
* supply" follow-up. Live shape that motivated it: 报销流程's
* `applicant: { type: 'lookup', reference: 'sys_user', defaultValue:
* 'current_user' }` — spec-legal, engine-honoured, and yet the create form
* opened with 申请人 empty, reading as "the change did not work".
*
* Boundaries pinned here:
*
* 1. WITH a known user → seeded and SUBMITTED (the explicit id equals the
* engine's own resolution, by construction)
* 2. WITHOUT one (no provider / anonymous / role-based provider) → untouched:
* empty control, key omitted, the engine resolves at insert — the exact
* pre-#5683 contract, and why every older test in
* `createDefaults.test.tsx` passes unchanged
* 3. type gate — the spec allows the token on `user` and `lookup→sys_user`
* ONLY (`field.zod` #7127); a token smuggled onto any other field seeds
* nothing here, mirroring the validator's refusal
* 4. `NOW()` / CEL envelopes stay server-owned even with a known user —
* form-open time is not insert time, and the client cannot evaluate CEL
* 5. caller-supplied initial values outrank the seed, same as every other
* default
*/

import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { MePermissionsProvider } from '@object-ui/permissions';
import { registerAllFields } from '@object-ui/fields';
import { schemaDefaultValues, seedCreateValues } from './schemaDefaults';
import { ObjectForm } from './ObjectForm';

registerAllFields();

const USER_ID = 'user-42';

/** The reported shape: a sys_user lookup defaulted to the acting user. */
const OBJECT_SCHEMA = {
name: 'reimbursement_request',
fields: {
title: { type: 'text', label: 'Title' },
applicant: { type: 'lookup', label: '申请人', reference: 'sys_user', defaultValue: 'current_user' },
// objectui-types spelling of the reference key — must be honoured too.
reviewer: { type: 'lookup', label: 'Reviewer', reference_to: 'sys_user', defaultValue: 'current_user' },
// The dedicated user field type is the token's other legal home.
owner_person: { type: 'user', label: 'Owner', defaultValue: 'current_user' },
// Token on an ILLEGAL type: the engine's validator refuses this authoring;
// the seeding must not resolve it either.
supplier: { type: 'lookup', label: 'Supplier', reference: 'account', defaultValue: 'current_user' },
// The other runtime token stays server-owned even when the user is known.
filed_at: { type: 'datetime', label: 'Filed at', defaultValue: 'NOW()' },
},
};

/**
* Authenticated `/me/permissions` payload for the acting user. The `*` object
* grant matters: an authenticated payload with NO entry for an object
* fail-closes `checkField` (#2926 ④) and the form would render zero fields.
*/
const ME_PERMISSIONS = {
authenticated: true,
userId: USER_ID,
tenantId: 't1',
roles: ['user'],
permissionSets: [],
objects: { '*': { allowCreate: true, allowRead: true, allowEdit: true } },
fields: {},
};

const makeDS = () =>
({
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
create: vi.fn().mockResolvedValue({ id: 'r1' }),
update: vi.fn().mockResolvedValue({ id: 'r1' }),
findOne: vi.fn().mockResolvedValue({ id: USER_ID, name: 'Current User' }),
query: vi.fn().mockResolvedValue({ data: [] }),
}) as any;

beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());

describe('schemaDefaultValues — current_user resolution (#5683)', () => {
it('seeds the acting user on the legal field shapes, and ONLY those', () => {
const seeded = schemaDefaultValues(OBJECT_SCHEMA, { currentUserId: USER_ID });
expect(seeded.applicant).toBe(USER_ID); // lookup + reference
expect(seeded.reviewer).toBe(USER_ID); // lookup + reference_to
expect(seeded.owner_person).toBe(USER_ID); // type: user
expect('supplier' in seeded).toBe(false); // illegal type — validator territory
expect('filed_at' in seeded).toBe(false); // NOW() stays server-owned
expect('title' in seeded).toBe(false);
});

it('seeds nothing without a known user — the pre-#5683 contract', () => {
expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA)).toBe(false);
expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA, {})).toBe(false);
expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA, { currentUserId: null })).toBe(false);
});

it('caller-supplied initial values outrank the seed', () => {
const seeded = seedCreateValues(OBJECT_SCHEMA, { applicant: 'someone-else' }, { currentUserId: USER_ID });
expect(seeded.applicant).toBe('someone-else');
expect(seeded.reviewer).toBe(USER_ID);
});
});

describe('ObjectForm — current_user pre-fill on create (#5683)', () => {
const renderCreate = (ds: any) =>
render(
<MePermissionsProvider initialPermissions={ME_PERMISSIONS as any}>
<ObjectForm
schema={{ type: 'object-form', objectName: 'reimbursement_request', mode: 'create' } as any}
dataSource={ds}
/>
</MePermissionsProvider>,
);

it('submits the seeded acting-user id — the same value the engine would stamp', async () => {
const ds = makeDS();
renderCreate(ds);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
const title = document.body.querySelector('input[name="title"]') as HTMLInputElement;
fireEvent.change(title, { target: { value: 'Taxi' } });
fireEvent.submit(document.body.querySelector('form') as HTMLFormElement);
await waitFor(() => expect(ds.create).toHaveBeenCalled());
const payload = ds.create.mock.calls[0].at(-1);
expect(payload.applicant).toBe(USER_ID);
// The illegal-type token and the server-owned NOW() must not be invented.
expect(payload.supplier ?? undefined).toBeUndefined();
expect(payload.filed_at ?? undefined).toBeUndefined();
});

it('leaves the field empty and omitted without a permission provider', async () => {
const ds = makeDS();
render(
<ObjectForm
schema={{ type: 'object-form', objectName: 'reimbursement_request', mode: 'create' } as any}
dataSource={ds}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
const title = document.body.querySelector('input[name="title"]') as HTMLInputElement;
fireEvent.change(title, { target: { value: 'Taxi' } });
fireEvent.submit(document.body.querySelector('form') as HTMLFormElement);
await waitFor(() => expect(ds.create).toHaveBeenCalled());
const payload = ds.create.mock.calls[0].at(-1);
// Key OMITTED — absence is what makes the engine resolve the token.
expect('applicant' in payload).toBe(false);
});
});
Loading
Loading