@@ -304,6 +304,13 @@ const AUTHORED_TEXT_EXEMPT: Record<string, string> = {};
304304 * mirror reproduces is the host's SURFACE and its rows; the query semantics
305305 * ($search / $orderby / windowing) are the host's, and the parity case pins the
306306 * method names rather than re-deriving them here.
307+ *
308+ * objectui#6317 widened that guard where it had to be widened. Pinning method
309+ * NAMES left the field declarations unwatched, and they drifted: the host
310+ * declared `options` on `role` / `status` in `4b0b12630` and this schema did
311+ * not follow for 330 commits, with every case in this file green throughout.
312+ * The `USERS_SCHEMA` below is now compared to the host's WHOLE, options
313+ * included — see the #6317 cases at the end of this file.
307314 */
308315const USERS_ROWS = [
309316 { id : '1' , name : 'Alice Johnson' , email : 'alice@example.com' , role : 'admin' , department : 'Engineering' , status : 'active' , created_at : '2024-01-14' } ,
@@ -319,9 +326,25 @@ const USERS_SCHEMA = {
319326 fields : {
320327 name : { label : 'Name' , type : 'text' } ,
321328 email : { label : 'Email' , type : 'email' } ,
322- role : { label : 'Role' , type : 'select' } ,
329+ role : {
330+ label : 'Role' ,
331+ type : 'select' ,
332+ options : [
333+ { label : 'Admin' , value : 'admin' } ,
334+ { label : 'Member' , value : 'member' } ,
335+ { label : 'Viewer' , value : 'viewer' } ,
336+ ] ,
337+ } ,
323338 department : { label : 'Department' , type : 'text' } ,
324- status : { label : 'Status' , type : 'select' } ,
339+ status : {
340+ label : 'Status' ,
341+ type : 'select' ,
342+ options : [
343+ { label : 'Active' , value : 'active' } ,
344+ { label : 'Invited' , value : 'invited' } ,
345+ { label : 'Suspended' , value : 'suspended' } ,
346+ ] ,
347+ } ,
325348 created_at : { label : 'Created' , type : 'date' } ,
326349 } ,
327350} ;
@@ -1232,3 +1255,150 @@ describe('objectui#5113 — the docs-site hosts supply the same fixture', () =>
12321255 } ,
12331256 ) ;
12341257} ) ;
1258+
1259+ /**
1260+ * objectui#6317 — every `select` field in the fixture declares the options its
1261+ * own rows use, and this mirror declares exactly what the host declares.
1262+ *
1263+ * ## The defect
1264+ *
1265+ * `ObjectForm` copies a field's options through verbatim — `formField.options =
1266+ * field.options || []` (`packages/plugin-form/src/ObjectForm.tsx`) — so a
1267+ * `select` field with no `options` renders the "No options available" empty
1268+ * state. Measured through this file's own render path, an `object-form` over
1269+ * `users` with no `fields` restriction:
1270+ *
1271+ * before: "NameEmailRoleNo options availableDepartmentStatusNo options
1272+ * availableCancelUpdate"
1273+ * after: "NameEmailRoleAdminAdminMemberViewerDepartmentStatusActiveActive
1274+ * InvitedSuspendedCancelUpdate"
1275+ *
1276+ * and the record's own `role` / `status` join the form's control values
1277+ * (`["Alice Johnson","alice@example.com","Engineering"]` → `["Alice Johnson",
1278+ * "alice@example.com","admin","Engineering","active"]`), so the two fields stop
1279+ * being dropped on the way in.
1280+ *
1281+ * ## Why the grid and view tiles do NOT move with it
1282+ *
1283+ * Worth recording, because the expectation going in was that they would.
1284+ * `ObjectGrid` SYNTHESISES options for an option-less select from the distinct
1285+ * values in the loaded rows (`packages/plugin-grid/src/ObjectGrid.tsx` —
1286+ * `fieldMeta.options = uniqueValues.map(v => ({ value: v, label:
1287+ * humanizeLabel(String(v)) }))`), so `admin` already printed as "Admin".
1288+ * Measured: the tile text of all seven `users`-bound entries is byte-identical
1289+ * before and after this declaration. The grid has a fallback for the missing
1290+ * declaration; the FORM path has none. That asymmetry is the whole card.
1291+ *
1292+ * ## Two directions, because a one-sided pin cannot see this drift
1293+ *
1294+ * The host declared these options in `4b0b12630` and this mirror did not
1295+ * follow for 330 commits — with every case in this file green throughout. The
1296+ * `select`-coverage case below catches a fixture that declares neither; the
1297+ * parity case catches the two files declaring different things.
1298+ */
1299+
1300+ interface UsersFieldDecl {
1301+ label ?: string ;
1302+ type ?: string ;
1303+ options ?: Array < { label : string ; value : string } > ;
1304+ }
1305+
1306+ /** Distinct values the mirror's rows carry for one field — walked, not grepped. */
1307+ function distinctRowValues ( field : string ) : Set < string > {
1308+ const seen = new Set < string > ( ) ;
1309+ for ( const row of USERS_ROWS ) {
1310+ const value = ( row as Record < string , unknown > ) [ field ] ;
1311+ if ( value !== undefined && value !== null ) seen . add ( String ( value ) ) ;
1312+ }
1313+ return seen ;
1314+ }
1315+
1316+ /**
1317+ * The host's `USERS_SCHEMA`, read off its source. `apps/**` is outside every
1318+ * root Vitest project, which is the same constraint that makes this file a
1319+ * mirror in the first place. Brace-matched rather than pattern-matched and then
1320+ * JSON-ified, so an extraction that stops working fails LOUDLY here rather than
1321+ * quietly comparing less than it claims to.
1322+ */
1323+ function readHostUsersFields ( source : string ) : Record < string , UsersFieldDecl > {
1324+ const start = source . indexOf ( 'const USERS_SCHEMA = {' ) ;
1325+ expect ( start , 'the host fixture no longer declares `const USERS_SCHEMA = {`' ) . toBeGreaterThan ( - 1 ) ;
1326+ const open = source . indexOf ( '{' , start ) ;
1327+ let depth = 0 ;
1328+ let end = - 1 ;
1329+ for ( let i = open ; i < source . length ; i ++ ) {
1330+ if ( source [ i ] === '{' ) depth += 1 ;
1331+ else if ( source [ i ] === '}' ) {
1332+ depth -= 1 ;
1333+ if ( depth === 0 ) {
1334+ end = i ;
1335+ break ;
1336+ }
1337+ }
1338+ }
1339+ expect ( end , 'the host `USERS_SCHEMA` literal has unbalanced braces' ) . toBeGreaterThan ( - 1 ) ;
1340+ const json = source
1341+ . slice ( open , end + 1 )
1342+ . replace ( / ' / g, '"' )
1343+ . replace ( / ( [ { , [ ] \s * ) ( [ A - Z a - z _ $ ] [ A - Z a - z 0 - 9 _ $ ] * ) \s * : / g, '$1"$2":' )
1344+ . replace ( / , ( \s * [ } \] ] ) / g, '$1' ) ;
1345+ let parsed : { fields ?: Record < string , UsersFieldDecl > } ;
1346+ try {
1347+ parsed = JSON . parse ( json ) as { fields ?: Record < string , UsersFieldDecl > } ;
1348+ } catch ( error ) {
1349+ throw new Error (
1350+ 'the host `USERS_SCHEMA` is no longer a plain single-quoted literal, so this ' +
1351+ 'parity case can no longer read it — a comment inside the literal, or an ' +
1352+ 'apostrophe inside a string, would each do it. Fix the reader, not the pin.' ,
1353+ { cause : error } ,
1354+ ) ;
1355+ }
1356+ expect ( parsed . fields , 'the host `USERS_SCHEMA` declares no `fields`' ) . toBeTruthy ( ) ;
1357+ return parsed . fields as Record < string , UsersFieldDecl > ;
1358+ }
1359+
1360+ describe ( 'objectui#6317 — a `select` field declares the options its rows use' , ( ) => {
1361+ const mirrorFields = USERS_SCHEMA . fields as Record < string , UsersFieldDecl > ;
1362+ const selectFields = Object . entries ( mirrorFields )
1363+ . filter ( ( [ , field ] ) => field . type === 'select' )
1364+ . map ( ( [ name ] ) => name ) ;
1365+
1366+ it ( 'the fixture declares select fields at all — otherwise the cases below are vacuous' , ( ) => {
1367+ expect ( selectFields ) . not . toEqual ( [ ] ) ;
1368+ } ) ;
1369+
1370+ it . each ( selectFields ) ( '`%s` declares options, and they cover its rows exactly' , ( name ) => {
1371+ const declared = mirrorFields [ name ] . options ;
1372+ expect (
1373+ declared ,
1374+ `\`${ name } \` is declared \`type: 'select'\` with no \`options\`. ObjectForm copies ` +
1375+ 'a field\'s options through verbatim, so a form bound to it renders the "No ' +
1376+ 'options available" empty state — on a docs page whose whole purpose is to ' +
1377+ 'show the component working.' ,
1378+ ) . toBeTruthy ( ) ;
1379+ const optionValues = new Set ( ( declared ?? [ ] ) . map ( ( option ) => String ( option . value ) ) ) ;
1380+ const rowValues = distinctRowValues ( name ) ;
1381+ expect (
1382+ [ ...rowValues ] . filter ( ( value ) => ! optionValues . has ( value ) ) ,
1383+ `rows carry these \`${ name } \` values that no option declares — they would render as a blank cell` ,
1384+ ) . toEqual ( [ ] ) ;
1385+ expect (
1386+ [ ...optionValues ] . filter ( ( value ) => ! rowValues . has ( value ) ) ,
1387+ `these \`${ name } \` options match no row, so nothing in the gallery demonstrates them` ,
1388+ ) . toEqual ( [ ] ) ;
1389+ } ) ;
1390+
1391+ it ( 'the host fixture declares the SAME field surface, options included' , ( ) => {
1392+ const hostSource = fs . readFileSync (
1393+ path . join ( process . cwd ( ) , 'apps/site/app/components/galleryDataSource.ts' ) ,
1394+ 'utf8' ,
1395+ ) ;
1396+ expect (
1397+ readHostUsersFields ( hostSource ) ,
1398+ 'the host fixture and this mirror declare different `users` field surfaces. They ' +
1399+ 'are ONE fixture in two files and have to move together: the host gained its ' +
1400+ '`role` / `status` options in 4b0b12630 and this mirror did not follow for 330 ' +
1401+ 'commits, with every case in this file green the whole time.' ,
1402+ ) . toEqual ( mirrorFields ) ;
1403+ } ) ;
1404+ } ) ;
0 commit comments