From adfa57e8b8f1978f01b8d2b7f49440040ab3d500 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:12:56 +0000 Subject: [PATCH] fix(plugin-detail): register field:permission-facet-link through withFieldCarrier (#3307) The repo's only raw `field:` registration bypassed the single-metadata- carrier seam (objectui#3233). Under the SDUI path SchemaRenderer passes the authored node as `schema` and never `field`, so the widget read `field === undefined` and silently rendered an anonymous facet summary (`field?.name` empty, no facet branch selected). The form and inline-edit hosts pass `field` directly, which the carrier forwards unchanged, so they are unaffected. Regression test renders through the real registration, the real SchemaRenderer, and the real widget, asserting on facet data (capability chip names) rather than translation copy; break-verified red with the carrier removed, green with it restored. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- .../permission-facet-link-field-carrier.md | 11 +++ .../PermissionFacetLink.sduiCarrier.test.tsx | 85 +++++++++++++++++++ packages/plugin-detail/src/index.tsx | 9 +- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .changeset/permission-facet-link-field-carrier.md create mode 100644 packages/plugin-detail/src/__tests__/PermissionFacetLink.sduiCarrier.test.tsx diff --git a/.changeset/permission-facet-link-field-carrier.md b/.changeset/permission-facet-link-field-carrier.md new file mode 100644 index 0000000000..7190968ff7 --- /dev/null +++ b/.changeset/permission-facet-link-field-carrier.md @@ -0,0 +1,11 @@ +--- +"@object-ui/plugin-detail": patch +--- + +`field:permission-facet-link` now registers through `withFieldCarrier` — the +repo's only raw `field:` registration bypassed the single-metadata-carrier seam +(objectui#3233), so under the SDUI path (`SchemaRenderer` passes `schema`, +never `field`) the widget read `field === undefined` and silently rendered an +anonymous facet summary (`field?.name` empty, no facet branch selected). The +form and inline-edit hosts were unaffected — they pass `field` directly, which +the carrier forwards unchanged. Fixes objectui#3307. diff --git a/packages/plugin-detail/src/__tests__/PermissionFacetLink.sduiCarrier.test.tsx b/packages/plugin-detail/src/__tests__/PermissionFacetLink.sduiCarrier.test.tsx new file mode 100644 index 0000000000..578b604492 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/PermissionFacetLink.sduiCarrier.test.tsx @@ -0,0 +1,85 @@ +/** + * 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. + */ + +/** + * Regression for objectui#3307 — `field:permission-facet-link` was the only + * `field:` registration in the repo that bypassed `withFieldCarrier`. + * + * `SchemaRenderer` (the SDUI path) hands every registered component the + * authored node as `schema` and never passes `field`; the form and inline-edit + * hosts pass `field`. Since objectui#3233 the field-widget contract carries + * metadata on `field` ONLY, and the translation happens once at the + * registration seam via `withFieldCarrier`. A raw registration therefore works + * under the form hosts but reads `field === undefined` under SDUI — the widget + * renders, silently, an anonymous summary (`field?.name` empty, no facet + * branch selected). Exactly the failure class the carrier exists to prevent. + * + * The behavioral test below goes through the REAL registration (imported from + * `../index`), the real `SchemaRenderer`, and the real widget. The asserted + * text is data (capability names), not translation copy, so the test does not + * depend on i18n configuration. Removing `withFieldCarrier` from the + * registration makes it fail: `field?.name` falls to `''`, the widget takes + * the anonymous default branch, and renders the bare count "2" instead of the + * capability chips. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; + +// Module scope, not a hook (AGENTS.md 测试纪律 / objectui#3010): importing the +// package index executes its registration side-effects, so the entry under +// test is the very one production resolves. +import '../index'; + +afterEach(() => { + cleanup(); +}); + +describe('field:permission-facet-link — SDUI path delivers `field` (objectui#3307)', () => { + it('converges the authored node onto `field` (field?.name non-empty end to end)', () => { + // The SDUI shape: `schema` only — no `field` anywhere in these props. + render( + + + , + ); + + // The capability-chip branch renders IFF `field?.name === 'system_permissions'`, + // i.e. the authored node reached the widget as `field`. The chip labels are + // the facet's own data. + expect(screen.getByText('manage_users')).toBeInTheDocument(); + expect(screen.getByText('view_reports')).toBeInTheDocument(); + // …and NOT the anonymous default-branch summary (bare count "2") that a + // raw registration produced when `field?.name` came up empty. + expect(screen.queryByText('2')).toBeNull(); + }); + + it('is registered through the carrier adapter, not raw', () => { + // Cheap seam pin: the registry entry itself is the `withFieldCarrier` + // wrapper. The behavioral test above is the authority; this one makes a + // future raw re-registration fail with an unmistakable message. + const entry = ComponentRegistry.get('field:permission-facet-link') as + | { displayName?: string } + | undefined; + expect(entry).toBeTruthy(); + expect(entry?.displayName).toMatch(/^FieldCarrier\(/); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index dd22a4271b..2846a506a2 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -7,6 +7,7 @@ */ import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; +import { withFieldCarrier } from '@object-ui/fields'; import { DetailView } from './DetailView'; import { DetailSection } from './DetailSection'; import { DetailTabs } from './DetailTabs'; @@ -456,7 +457,13 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // the record form and inline edit resolve `field:permission-facet-link`; the // detail read path special-cases it in DetailSection. Setup never edits these // facets — they are designed in Studio's structured editors. -ComponentRegistry.register('permission-facet-link', PermissionFacetLink, { +// +// `withFieldCarrier` is MANDATORY on every `field:` registration (objectui#3233 +// / #3307): `SchemaRenderer` hands the authored node over as `schema`, and the +// adapter converges it onto `field` — the field-widget contract's only +// metadata carrier. Registered raw, the widget reads `field === undefined` +// under the SDUI path and silently renders an anonymous summary. +ComponentRegistry.register('permission-facet-link', withFieldCarrier(PermissionFacetLink), { namespace: 'field', skipFallback: true, });