diff --git a/.changeset/13083-form-evaluator-capabilities.md b/.changeset/13083-form-evaluator-capabilities.md new file mode 100644 index 0000000000..682f43a8b3 --- /dev/null +++ b/.changeset/13083-form-evaluator-capabilities.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +An object field whose `visibleWhen` names `current_user.hasCapability(NAME)` is now answered on the record form page and in the console's record-form modal (objectui#13083). + +objectui#13006 bound the viewer's held system capabilities into the shell's predicate scope, so page nodes, navigation and actions already answered `hasCapability`. The two form surfaces build their field-list evaluator themselves, and they passed the viewer's object permissions but not the capability list. Every `hasCapability` field gate there faulted: the field was shown to holders and non-holders alike, with a console diagnostic. Both now hand on the same list the shell's provider binds, so the field shows for a viewer holding the capability and hides for one who does not. + +While the permissions answer has not loaded, the predicate still faults loudly, as it does on page predicates; it is never read as `false`. Nothing is added to the package entry. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index ef4fb30e60..787a250edc 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -27,6 +27,7 @@ import { createExpressionEvaluator, isObjectFieldVisible, useExpressionPermissions, + useExpressionSystemPermissions, } from '../providers/ExpressionProvider.js'; import { buildExpressionUser } from '../providers/expressionUser.js'; import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js'; @@ -937,7 +938,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // objectui#4421 — the same `permissions` input the provider below reads, so // `current_user.can(...)` in a field's `visibleWhen` answers here exactly as // it does under the provider (one bag, objectui#6493). + // objectui#13083 — and the `systemPermissions` list beside it, so + // `current_user.hasCapability(name)` answers here as it does under the + // provider (objectui#13006) instead of faulting on an unbound list. const expressionPermissions = useExpressionPermissions(); + const expressionSystemPermissions = useExpressionSystemPermissions(); const expressionEvaluator = useMemo( // ⛔ No `app`: objectui#8155 removed it from the predicate scope, because // neither ADR-0068 nor the engine's `SCOPE_ROOTS` declares such a root. @@ -956,8 +961,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = user: buildExpressionUser(user), features, permissions: expressionPermissions, + systemPermissions: expressionSystemPermissions, }), - [user, features, expressionPermissions], + [user, features, expressionPermissions, expressionSystemPermissions], ); // objectui#5619 — `isWorkspaceAdminResolved` belongs in this readiness gate diff --git a/packages/app-shell/src/console/__tests__/AppContent.hasCapability-13083.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.hasCapability-13083.test.tsx new file mode 100644 index 0000000000..cf38e70ed3 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.hasCapability-13083.test.tsx @@ -0,0 +1,200 @@ +/** + * 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#13083 — an object field whose `visibleWhen` names + * `current_user.hasCapability(NAME)` is answered in the console's global + * record-form modal. + * + * The modal-mode twin of `views/RecordFormPage.hasCapability-13083.test.tsx`, + * whose header carries the rationale: `AppContent` builds the modal's + * field-list evaluator by hand (it sits ABOVE the `ExpressionProvider` it + * mounts), and it passed `permissions` but not `systemPermissions`, so every + * `hasCapability` field gate faulted and failed OPEN here while page nodes + * answered (objectui#13006). + * + * The real `AppContent` is mounted under a real `MePermissionsProvider`; the + * probe is the `ModalForm` schema, where the filtered field list lands — the + * same probe `AppContent.declaredVisibilityKeys.test.tsx` reads. + * + * Reverse verification (direction predicted before running): drop the + * `systemPermissions` hand-off in `AppContent`, and the NON-HOLDER arm goes red + * (shown by the fail-open fault), and the HOLDER arm goes red through its + * silence assertion. The NOT-LOADED control stays green either way. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +// The reporter's dedupe `Set` is module state: reset it so every arm's +// "nothing reported" is a reading, not a leftover from an earlier arm. +import { __resetVisibilityPredicateWarnings } from '../../../../react/src/utils/visibilityDiagnostic'; + +const { modalSchemas } = vi.hoisted(() => ({ modalSchemas: [] as Array<{ fields: string[] }> })); + +/** The probe: the global modal's schema is where the filtered field list lands. */ +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ModalForm: ({ schema }: { schema: { fields: string[] } }) => { + modalSchemas.push(schema); + return
; + }, +})); + +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 })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label }), +})); + +const USER = { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user', positions: ['everyone'] }; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: USER, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_1', name: 'Acme' }, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +const cel = (source: string) => ({ dialect: 'cel', source }); +const PREDICATE = "current_user.hasCapability('manage_org_users')"; + +const ORG = { + name: 'org_member', + label: 'Members', + fields: { + name: { type: 'text' }, + roster_note: { type: 'text', visibleWhen: cel(PREDICATE) }, + }, +}; + +const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }]; + +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: APPS, + objects: [ORG], + loading: false, + ensureType: undefined, + error: null, + refresh: vi.fn(async () => {}), + }), +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +function payload(systemPermissions: string[]): MePermissionsResponse { + return { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: [], + permissionSets: ['member'], + systemPermissions, + objects: { org_member: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false } }, + fields: {}, + }; +} + +/** `?form=new` is the URL contract that opens the global record-form overlay. */ +function createModal() { + return ( + + + } /> + + + ); +} + +const withPermissions = (caps: string[]) => ( + {createModal()} +); + +const lastFields = (): string[] => modalSchemas[modalSchemas.length - 1].fields; + +let warnings: string[]; +beforeEach(() => { + warnings = []; + __resetVisibilityPredicateWarnings(); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => void warnings.push(a.join(' '))); + modalSchemas.length = 0; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('console record-form modal: a field gated on current_user.hasCapability (objectui#13083)', () => { + it('HOLDER: shown, with nothing reported', async () => { + render(withPermissions(['setup.access', 'manage_org_users'])); + await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0)); + expect(lastFields()).toContain('name'); + expect(lastFields()).toContain('roster_note'); + expect(warnings.filter((w) => w.includes('hasCapability'))).toEqual([]); + }); + + it('NON-HOLDER: hidden, with nothing reported — a verdict, not a fault', async () => { + render(withPermissions(['setup.access'])); + await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0)); + expect(lastFields()).toContain('name'); + expect(lastFields()).not.toContain('roster_note'); + expect(warnings.filter((w) => w.includes('hasCapability'))).toEqual([]); + }); + + it('CONTROL — NOT LOADED: the predicate still FAULTS loudly, never a silent answer', async () => { + render(createModal()); + await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0)); + // Fail-open field gate on a fault (the shipped field policy): shown, and + // the fault is reported naming the predicate. + expect(lastFields()).toContain('roster_note'); + expect(warnings.some((w) => w.includes(PREDICATE))).toBe(true); + }); +}); diff --git a/packages/app-shell/src/views/RecordFormPage.hasCapability-13083.test.tsx b/packages/app-shell/src/views/RecordFormPage.hasCapability-13083.test.tsx new file mode 100644 index 0000000000..2cbbf0beb9 --- /dev/null +++ b/packages/app-shell/src/views/RecordFormPage.hasCapability-13083.test.tsx @@ -0,0 +1,168 @@ +/** + * 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#13083 — an object field whose `visibleWhen` names + * `current_user.hasCapability(NAME)` is answered on the record form page. + * + * objectui#13006 bound the viewer's `systemPermissions` into the + * `ExpressionProvider` scope, so page nodes answer `hasCapability`. This page + * builds its field-list evaluator by hand (it sits ABOVE the provider it + * mounts), and that hand-built evaluator passed `permissions` but not + * `systemPermissions`. The engine then refused every `hasCapability` call on a + * field, and the field gate fails OPEN on a fault: the field rendered for a + * holder AND a non-holder, with a diagnostic. + * + * Everything on the path is real except the leaves the sibling suite + * (`RecordFormPage.predicateScope.test.tsx`) also stubs: `MePermissionsProvider` + * holds a real payload, `useExpressionSystemPermissions()` reads it, and the + * page's own evaluator filters `objectDef.fields` into the `schema.fields` + * handed to `ObjectForm`, which is what these tests read. + * + * Reverse verification (direction predicted before running): drop the + * `systemPermissions` hand-off in `RecordFormPage`, and the NON-HOLDER arm goes + * red (the field is shown by the fail-open fault), and the HOLDER arm goes red + * through its silence assertion (shown, but reported). The NOT-LOADED control + * stays green either way: it is the fault this binding must not mask. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +// The reporter's dedupe `Set` is module state: reset it so every arm's +// "nothing reported" is a reading, not a leftover from an earlier arm. +import { __resetVisibilityPredicateWarnings } from '../../../react/src/utils/visibilityDiagnostic'; +import { RecordFormPage } from './RecordFormPage'; + +const h = React.createElement; + +const { formSchemas, authState } = vi.hoisted(() => ({ + formSchemas: [] as Array<{ fields: string[] }>, + authState: { user: null as Record | null }, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + get user() { return authState.user; }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), +})); + +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: ({ schema }: { schema: { fields: string[] } }) => { + formSchemas.push(schema); + return h('div', { 'data-testid': 'object-form' }); + }, +})); + +const metadataState = { objects: [] as unknown[], loading: false }; +vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => metadataState })); +vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null })); + +const cel = (source: string) => ({ dialect: 'cel', source }); +const PREDICATE = "current_user.hasCapability('manage_org_users')"; + +const ORG = { + name: 'org_member', + label: 'Members', + fields: { + name: { type: 'text' }, + roster_note: { type: 'text', visibleWhen: cel(PREDICATE) }, + }, +}; + +const USER = { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user', positions: ['everyone'] }; + +function payload(systemPermissions: string[]): MePermissionsResponse { + return { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: [], + permissionSets: ['member'], + systemPermissions, + objects: { org_member: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false } }, + fields: {}, + }; +} + +function page() { + return h(I18nProvider, { + config: { defaultLanguage: 'en', detectBrowserLanguage: false }, + children: h( + MemoryRouter, + { initialEntries: ['/apps/crm/org_member/new'] }, + h(Routes, null, h(Route, { + path: '/apps/:appName/:objectName/new', + element: h(RecordFormPage, { mode: 'create' }), + })), + ), + }); +} + +const withPermissions = (caps: string[]) => + h(MePermissionsProvider, { initialPermissions: payload(caps), children: page() }); + +const lastFields = (): string[] => formSchemas[formSchemas.length - 1].fields; + +let warnings: string[]; +beforeEach(() => { + warnings = []; + __resetVisibilityPredicateWarnings(); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => void warnings.push(a.join(' '))); + metadataState.objects = [ORG]; + authState.user = USER; + formSchemas.length = 0; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('record form page: a field gated on current_user.hasCapability (objectui#13083)', () => { + it('HOLDER: shown, with nothing reported', async () => { + render(withPermissions(['setup.access', 'manage_org_users'])); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + expect(lastFields()).toContain('name'); + expect(lastFields()).toContain('roster_note'); + expect(warnings.filter((w) => w.includes('hasCapability'))).toEqual([]); + }); + + it('NON-HOLDER: hidden, with nothing reported — a verdict, not a fault', async () => { + render(withPermissions(['setup.access'])); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + // Control: the filter still lets the ungated field by. + expect(lastFields()).toContain('name'); + expect(lastFields()).not.toContain('roster_note'); + expect(warnings.filter((w) => w.includes('hasCapability'))).toEqual([]); + }); + + it('CONTROL — NOT LOADED: the predicate still FAULTS loudly, never a silent answer', async () => { + render(page()); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + // The field gate is fail-open on a fault (the shipped field policy), so the + // field is shown — and the fault is reported naming the predicate. A + // silent `false` would have hidden it with nothing said. + expect(lastFields()).toContain('roster_note'); + expect(warnings.some((w) => w.includes(PREDICATE))).toBe(true); + }); +}); diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index 654c16d751..7358a6fba6 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -42,6 +42,7 @@ import { createExpressionEvaluator, isObjectFieldVisible, useExpressionPermissions, + useExpressionSystemPermissions, } from '../providers/ExpressionProvider.js'; import { buildExpressionUser } from '../providers/expressionUser.js'; import { SkeletonDetail } from '../skeletons/index.js'; @@ -208,7 +209,10 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { // faulted here and failed OPEN while resolving normally on a nav item. // objectui#4421 — the `permissions` input the `ExpressionProvider` below // reads for itself, so `current_user.can(...)` answers the same on both. + // objectui#13083 — and its `systemPermissions` beside it, so + // `current_user.hasCapability(name)` answers here too (objectui#13006). const expressionPermissions = useExpressionPermissions(); + const expressionSystemPermissions = useExpressionSystemPermissions(); const expressionEvaluator = useMemo( () => // ⛔ No `app`: objectui#8155 removed it from the predicate scope, because @@ -223,8 +227,9 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { user: expressionUser, features, permissions: expressionPermissions, + systemPermissions: expressionSystemPermissions, }), - [expressionUser, features, expressionPermissions], + [expressionUser, features, expressionPermissions, expressionSystemPermissions], ); // Resolve the field list using the same visibility-aware logic as the