|
| 1 | +/** |
| 2 | + * objectui#11428 — `deriveFormFields`, the field names of an inline |
| 3 | + * master-detail collection's per-row expand form, takes them from |
| 4 | + * `@objectstack/spec`'s `deriveInlineRowFormFields`. It is the row-form twin of |
| 5 | + * `deriveMasterDetail.inlineGridColumns-11345.test.ts` (the grid columns). |
| 6 | + * |
| 7 | + * Two pins over one corpus, and they guard different things: |
| 8 | + * |
| 9 | + * 1. The names and their order ARE the spec's answer. This is what lets |
| 10 | + * objectstack's `field-no-consumers` lint credit exactly the fields this |
| 11 | + * form draws: a local filter creeping back in would let the two disagree, |
| 12 | + * and this pin goes red the moment one changes an answer. |
| 13 | + * 2. The output is what `deriveFormFields` returned BEFORE the swap. The |
| 14 | + * expected arrays below are the answers of the local filter this card |
| 15 | + * replaced (its own system-name, sort-name and non-input-type sets), and |
| 16 | + * `deriveFormFields` is a public export of `@object-ui/plugin-form`: the |
| 17 | + * swap must not move a single name. The corpus covers the relationship |
| 18 | + * field and `exclude` alone, together and absent; every system and |
| 19 | + * sort-position name; the `system` and `hidden` flags; `readonly`, which the |
| 20 | + * form KEEPS where the grid drops it; every non-input type; the rich types |
| 21 | + * the grid omits; falsy and primitive field definitions; prototype-named |
| 22 | + * keys; and the shapes with no field map. |
| 23 | + * |
| 24 | + * That `deriveFormFields` reads the spec's export at all, rather than agreeing |
| 25 | + * with it by coincidence, is pinned where a module mock is file-scoped: |
| 26 | + * `inlineRowForm.specSource-11428.test.tsx`. |
| 27 | + * |
| 28 | + * `toStrictEqual` on purpose, as in the twin. |
| 29 | + */ |
| 30 | +import { describe, it, expect } from 'vitest'; |
| 31 | +import { deriveInlineRowFormFields } from '@objectstack/spec/data'; |
| 32 | +import { deriveFormFields, type ChildObjectSchemaLike } from './deriveMasterDetail'; |
| 33 | + |
| 34 | +type Opts = { relationshipField?: string; exclude?: string[] }; |
| 35 | + |
| 36 | +const line = { |
| 37 | + name: { type: 'text', label: 'Name' }, |
| 38 | + qty: { type: 'number', label: 'Qty' }, |
| 39 | + unit_price: { type: 'currency', label: 'Unit price' }, |
| 40 | + status: { type: 'select', options: ['open', 'closed'] }, |
| 41 | + memo: { type: 'textarea' }, |
| 42 | + order: { type: 'master_detail', reference: 'order', required: true }, |
| 43 | +}; |
| 44 | + |
| 45 | +const CORPUS: Array<{ title: string; schema: ChildObjectSchemaLike | undefined; opts?: Opts; before: string[] }> = [ |
| 46 | + { |
| 47 | + title: 'the relationship field is dropped', |
| 48 | + schema: { name: 'line', fields: line }, |
| 49 | + opts: { relationshipField: 'order' }, |
| 50 | + before: ['name', 'qty', 'unit_price', 'status', 'memo'], |
| 51 | + }, |
| 52 | + { |
| 53 | + title: '`exclude` alone: the unexcluded relationship field stays', |
| 54 | + schema: { name: 'line', fields: line }, |
| 55 | + opts: { exclude: ['memo'] }, |
| 56 | + before: ['name', 'qty', 'unit_price', 'status', 'order'], |
| 57 | + }, |
| 58 | + { |
| 59 | + title: '`exclude` and the relationship field together, with a name the map does not hold', |
| 60 | + schema: { name: 'line', fields: line }, |
| 61 | + opts: { relationshipField: 'order', exclude: ['qty', 'qty', 'nope'] }, |
| 62 | + before: ['name', 'unit_price', 'status', 'memo'], |
| 63 | + }, |
| 64 | + { |
| 65 | + title: 'no options: every business field, the relationship field included', |
| 66 | + schema: { name: 'line', fields: line }, |
| 67 | + before: ['name', 'qty', 'unit_price', 'status', 'memo', 'order'], |
| 68 | + }, |
| 69 | + { |
| 70 | + title: 'an empty relationship field excludes nothing', |
| 71 | + schema: { name: 'line', fields: line }, |
| 72 | + opts: { relationshipField: '' }, |
| 73 | + before: ['name', 'qty', 'unit_price', 'status', 'memo', 'order'], |
| 74 | + }, |
| 75 | + { |
| 76 | + title: 'every system / audit name and every sort-position name is dropped', |
| 77 | + schema: { |
| 78 | + name: 'line', |
| 79 | + fields: { |
| 80 | + id: { type: 'text' }, |
| 81 | + _id: {}, |
| 82 | + recordId: {}, |
| 83 | + created_at: { type: 'datetime' }, |
| 84 | + updated_at: { type: 'datetime' }, |
| 85 | + created_by: { type: 'lookup', reference: 'user' }, |
| 86 | + updated_by: { type: 'lookup', reference: 'user' }, |
| 87 | + createdAt: {}, |
| 88 | + updatedAt: {}, |
| 89 | + createdBy: {}, |
| 90 | + updatedBy: {}, |
| 91 | + organization_id: {}, |
| 92 | + tenant_id: {}, |
| 93 | + space: {}, |
| 94 | + owner: { type: 'lookup', reference: 'user' }, |
| 95 | + position: { type: 'number' }, |
| 96 | + sort_order: { type: 'number' }, |
| 97 | + sequence: { type: 'number' }, |
| 98 | + line_no: { type: 'number' }, |
| 99 | + line_number: { type: 'number' }, |
| 100 | + sort: { type: 'number' }, |
| 101 | + title: { type: 'text', required: true }, |
| 102 | + }, |
| 103 | + }, |
| 104 | + before: ['title'], |
| 105 | + }, |
| 106 | + { |
| 107 | + title: 'flags: `system` and `hidden` drop a field, `readonly` and `required` do not', |
| 108 | + schema: { |
| 109 | + name: 'line', |
| 110 | + fields: { |
| 111 | + secret: { type: 'text', hidden: true }, |
| 112 | + internal: { type: 'text', system: true }, |
| 113 | + locked: { type: 'text', readonly: true }, |
| 114 | + must: { type: 'text', required: true }, |
| 115 | + shown: { type: 'text', hidden: false, system: false }, |
| 116 | + truthy_hidden: { type: 'text', hidden: 1 }, |
| 117 | + }, |
| 118 | + }, |
| 119 | + before: ['locked', 'must', 'shown'], |
| 120 | + }, |
| 121 | + { |
| 122 | + title: 'types: the non-input types are dropped, the rich types the grid omits are kept', |
| 123 | + schema: { |
| 124 | + name: 'invoice_line', |
| 125 | + fields: { |
| 126 | + total: { type: 'formula', expression: 'qty * unit_price' }, |
| 127 | + sum: { type: 'summary' }, |
| 128 | + roll: { type: 'rollup' }, |
| 129 | + seq: { type: 'autonumber' }, |
| 130 | + seq2: { type: 'auto_number' }, |
| 131 | + notes: { type: 'textarea' }, |
| 132 | + body: { type: 'richtext' }, |
| 133 | + blob: { type: 'json' }, |
| 134 | + place: { type: 'location' }, |
| 135 | + html: { type: 'html' }, |
| 136 | + md: { type: 'markdown' }, |
| 137 | + cover: { type: 'image' }, |
| 138 | + attachment: { type: 'file', multiple: true }, |
| 139 | + face: { type: 'avatar' }, |
| 140 | + product: { type: 'lookup', reference: 'product' }, |
| 141 | + amount: { type: 'currency', expression: 'qty * unit_price' }, |
| 142 | + typed_oddly: { type: 7 }, |
| 143 | + untyped: {}, |
| 144 | + }, |
| 145 | + }, |
| 146 | + before: ['notes', 'body', 'blob', 'place', 'html', 'md', 'cover', 'attachment', 'face', 'product', 'amount', 'typed_oddly', 'untyped'], |
| 147 | + }, |
| 148 | + { |
| 149 | + title: 'falsy and primitive field definitions are kept, in field order', |
| 150 | + schema: { |
| 151 | + name: 'line', |
| 152 | + fields: { |
| 153 | + a_null: null, |
| 154 | + b_undefined: undefined, |
| 155 | + c_zero: 0, |
| 156 | + d_empty: '', |
| 157 | + e_false: false, |
| 158 | + f_string: 'text', |
| 159 | + g_number: 1, |
| 160 | + h_true: true, |
| 161 | + name: { type: 'text', label: 'Name' }, |
| 162 | + }, |
| 163 | + }, |
| 164 | + before: ['a_null', 'b_undefined', 'c_zero', 'd_empty', 'e_false', 'f_string', 'g_number', 'h_true', 'name'], |
| 165 | + }, |
| 166 | + { |
| 167 | + title: 'prototype-named keys are ordinary field names', |
| 168 | + schema: { |
| 169 | + name: 'line', |
| 170 | + fields: { |
| 171 | + constructor: { type: 'text' }, |
| 172 | + toString: { type: 'text' }, |
| 173 | + hasOwnProperty: { type: 'text', hidden: true }, |
| 174 | + valueOf: { type: 'formula' }, |
| 175 | + }, |
| 176 | + }, |
| 177 | + opts: { exclude: ['toString'] }, |
| 178 | + before: ['constructor'], |
| 179 | + }, |
| 180 | + { title: 'no schema', schema: undefined, before: [] }, |
| 181 | + { title: 'no field map', schema: { name: 'line' }, before: [] }, |
| 182 | + { title: 'an empty field map', schema: { name: 'line', fields: {} }, before: [] }, |
| 183 | + { title: 'a null field map', schema: { name: 'line', fields: null as unknown as Record<string, unknown> }, before: [] }, |
| 184 | + { title: 'a field map that is not an object', schema: { name: 'line', fields: 'nope' as unknown as Record<string, unknown> }, before: [] }, |
| 185 | +]; |
| 186 | + |
| 187 | +describe('deriveFormFields derives its names through the spec rule (objectui#11428)', () => { |
| 188 | + it.each(CORPUS)("$title: the names and their order are the spec's deriveInlineRowFormFields", ({ schema, opts }) => { |
| 189 | + expect(deriveFormFields(schema, opts)).toStrictEqual(deriveInlineRowFormFields(schema, opts)); |
| 190 | + }); |
| 191 | + |
| 192 | + it.each(CORPUS)('$title: the output is unchanged from the local filter it replaced', ({ schema, opts, before }) => { |
| 193 | + expect(deriveFormFields(schema, opts)).toStrictEqual(before); |
| 194 | + }); |
| 195 | +}); |
0 commit comments