@happyvertical/smrt-fields 0.40.59 → 0.40.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/AGENTS.md +58 -12
  2. package/README.md +90 -2
  3. package/dist/__smrt-register__.d.ts +2 -0
  4. package/dist/__smrt-register__.d.ts.map +1 -0
  5. package/dist/cache.d.ts +18 -0
  6. package/dist/cache.d.ts.map +1 -0
  7. package/dist/collections/FieldPolicyCollection.d.ts +95 -0
  8. package/dist/collections/FieldPolicyCollection.d.ts.map +1 -0
  9. package/dist/field-definitions.d.ts +77 -0
  10. package/dist/field-definitions.d.ts.map +1 -0
  11. package/dist/field-policy-resolver.d.ts +46 -0
  12. package/dist/field-policy-resolver.d.ts.map +1 -0
  13. package/dist/index.d.ts +9 -532
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +752 -55
  16. package/dist/index.js.map +1 -1
  17. package/dist/manifest.json +45 -3
  18. package/dist/models/FieldPolicy.d.ts +163 -0
  19. package/dist/models/FieldPolicy.d.ts.map +1 -0
  20. package/dist/permissions.d.ts +9 -0
  21. package/dist/permissions.d.ts.map +1 -0
  22. package/dist/playground.d.ts +2 -0
  23. package/dist/playground.d.ts.map +1 -0
  24. package/dist/playground.js +39 -0
  25. package/dist/playground.js.map +1 -0
  26. package/dist/settings-catalog.d.ts +73 -0
  27. package/dist/settings-catalog.d.ts.map +1 -0
  28. package/dist/smrt-knowledge.json +50 -7
  29. package/dist/svelte/__tests__/FieldPolicyControlPanel.test.js +223 -0
  30. package/dist/svelte/__tests__/FieldPolicyGear.test.js +503 -0
  31. package/dist/svelte/__tests__/FormHelp.test.js +138 -0
  32. package/dist/svelte/__tests__/ModeUX.test.js +109 -0
  33. package/dist/svelte/__tests__/ObjectForm.test.js +556 -0
  34. package/dist/svelte/__tests__/ObjectFormGeneratedApi.integration.test.js +436 -0
  35. package/dist/svelte/__tests__/PolicyField.test.js +373 -0
  36. package/dist/svelte/__tests__/fixtures/AdvancedFieldsFixture.svelte +30 -0
  37. package/dist/svelte/__tests__/fixtures/AdvancedFieldsFixture.svelte.d.ts +9 -0
  38. package/dist/svelte/__tests__/fixtures/AdvancedFieldsFixture.svelte.d.ts.map +1 -0
  39. package/dist/svelte/__tests__/fixtures/DomPrefillFixture.svelte +33 -0
  40. package/dist/svelte/__tests__/fixtures/DomPrefillFixture.svelte.d.ts +10 -0
  41. package/dist/svelte/__tests__/fixtures/DomPrefillFixture.svelte.d.ts.map +1 -0
  42. package/dist/svelte/__tests__/fixtures/FormHelpFixture.svelte +19 -0
  43. package/dist/svelte/__tests__/fixtures/FormHelpFixture.svelte.d.ts +10 -0
  44. package/dist/svelte/__tests__/fixtures/FormHelpFixture.svelte.d.ts.map +1 -0
  45. package/dist/svelte/__tests__/fixtures/GeneratedApiGearFixture.svelte +18 -0
  46. package/dist/svelte/__tests__/fixtures/GeneratedApiGearFixture.svelte.d.ts +11 -0
  47. package/dist/svelte/__tests__/fixtures/GeneratedApiGearFixture.svelte.d.ts.map +1 -0
  48. package/dist/svelte/__tests__/fixtures/GracefulDegradeFixture.svelte +11 -0
  49. package/dist/svelte/__tests__/fixtures/GracefulDegradeFixture.svelte.d.ts +19 -0
  50. package/dist/svelte/__tests__/fixtures/GracefulDegradeFixture.svelte.d.ts.map +1 -0
  51. package/dist/svelte/__tests__/fixtures/ModeSwitchFixture.svelte +18 -0
  52. package/dist/svelte/__tests__/fixtures/ModeSwitchFixture.svelte.d.ts +8 -0
  53. package/dist/svelte/__tests__/fixtures/ModeSwitchFixture.svelte.d.ts.map +1 -0
  54. package/dist/svelte/__tests__/fixtures/NoIdFixture.svelte +24 -0
  55. package/dist/svelte/__tests__/fixtures/NoIdFixture.svelte.d.ts +13 -0
  56. package/dist/svelte/__tests__/fixtures/NoIdFixture.svelte.d.ts.map +1 -0
  57. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte +20 -0
  58. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.d.ts +11 -0
  59. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.d.ts.map +1 -0
  60. package/dist/svelte/__tests__/fixtures/ObjectFormFixture.svelte +17 -0
  61. package/dist/svelte/__tests__/fixtures/ObjectFormFixture.svelte.d.ts +10 -0
  62. package/dist/svelte/__tests__/fixtures/ObjectFormFixture.svelte.d.ts.map +1 -0
  63. package/dist/svelte/__tests__/fixtures/ObjectFormRegistryFixture.svelte +25 -0
  64. package/dist/svelte/__tests__/fixtures/ObjectFormRegistryFixture.svelte.d.ts +10 -0
  65. package/dist/svelte/__tests__/fixtures/ObjectFormRegistryFixture.svelte.d.ts.map +1 -0
  66. package/dist/svelte/__tests__/fixtures/ObjectFormSnippetFixture.svelte +34 -0
  67. package/dist/svelte/__tests__/fixtures/ObjectFormSnippetFixture.svelte.d.ts +10 -0
  68. package/dist/svelte/__tests__/fixtures/ObjectFormSnippetFixture.svelte.d.ts.map +1 -0
  69. package/dist/svelte/__tests__/fixtures/ObjectFormSourceFixture.svelte +29 -0
  70. package/dist/svelte/__tests__/fixtures/ObjectFormSourceFixture.svelte.d.ts +11 -0
  71. package/dist/svelte/__tests__/fixtures/ObjectFormSourceFixture.svelte.d.ts.map +1 -0
  72. package/dist/svelte/__tests__/fixtures/ObjectFormSourceSnippetFixture.svelte +33 -0
  73. package/dist/svelte/__tests__/fixtures/ObjectFormSourceSnippetFixture.svelte.d.ts +9 -0
  74. package/dist/svelte/__tests__/fixtures/ObjectFormSourceSnippetFixture.svelte.d.ts.map +1 -0
  75. package/dist/svelte/__tests__/fixtures/PolicyDataTableFixture.svelte +40 -0
  76. package/dist/svelte/__tests__/fixtures/PolicyDataTableFixture.svelte.d.ts +8 -0
  77. package/dist/svelte/__tests__/fixtures/PolicyDataTableFixture.svelte.d.ts.map +1 -0
  78. package/dist/svelte/__tests__/fixtures/PrefillFixture.svelte +26 -0
  79. package/dist/svelte/__tests__/fixtures/PrefillFixture.svelte.d.ts +13 -0
  80. package/dist/svelte/__tests__/fixtures/PrefillFixture.svelte.d.ts.map +1 -0
  81. package/dist/svelte/__tests__/fixtures/ProviderFieldFixture.svelte +39 -0
  82. package/dist/svelte/__tests__/fixtures/ProviderFieldFixture.svelte.d.ts +17 -0
  83. package/dist/svelte/__tests__/fixtures/ProviderFieldFixture.svelte.d.ts.map +1 -0
  84. package/dist/svelte/__tests__/fixtures/RegistryInput.svelte +15 -0
  85. package/dist/svelte/__tests__/fixtures/RegistryInput.svelte.d.ts +5 -0
  86. package/dist/svelte/__tests__/fixtures/RegistryInput.svelte.d.ts.map +1 -0
  87. package/dist/svelte/__tests__/fixtures/RevealPrefillFixture.svelte +29 -0
  88. package/dist/svelte/__tests__/fixtures/RevealPrefillFixture.svelte.d.ts +14 -0
  89. package/dist/svelte/__tests__/fixtures/RevealPrefillFixture.svelte.d.ts.map +1 -0
  90. package/dist/svelte/__tests__/fixtures/SettingsCatalogStub.svelte +24 -0
  91. package/dist/svelte/__tests__/fixtures/SettingsCatalogStub.svelte.d.ts +14 -0
  92. package/dist/svelte/__tests__/fixtures/SettingsCatalogStub.svelte.d.ts.map +1 -0
  93. package/dist/svelte/__tests__/fixtures/SnippetFixture.svelte +28 -0
  94. package/dist/svelte/__tests__/fixtures/SnippetFixture.svelte.d.ts +13 -0
  95. package/dist/svelte/__tests__/fixtures/SnippetFixture.svelte.d.ts.map +1 -0
  96. package/dist/svelte/__tests__/fixtures/ToggleModeButton.svelte +14 -0
  97. package/dist/svelte/__tests__/fixtures/ToggleModeButton.svelte.d.ts +19 -0
  98. package/dist/svelte/__tests__/fixtures/ToggleModeButton.svelte.d.ts.map +1 -0
  99. package/dist/svelte/__tests__/fixtures/TooltipFixture.svelte +25 -0
  100. package/dist/svelte/__tests__/fixtures/TooltipFixture.svelte.d.ts +14 -0
  101. package/dist/svelte/__tests__/fixtures/TooltipFixture.svelte.d.ts.map +1 -0
  102. package/dist/svelte/__tests__/settings-catalog.test.js +161 -0
  103. package/dist/svelte/components/AdvancedFields.svelte +49 -0
  104. package/dist/svelte/components/AdvancedFields.svelte.d.ts +17 -0
  105. package/dist/svelte/components/AdvancedFields.svelte.d.ts.map +1 -0
  106. package/dist/svelte/components/FieldInput.svelte +159 -0
  107. package/dist/svelte/components/FieldInput.svelte.d.ts +5 -0
  108. package/dist/svelte/components/FieldInput.svelte.d.ts.map +1 -0
  109. package/dist/svelte/components/FieldPolicyControlPanel.svelte +261 -0
  110. package/dist/svelte/components/FieldPolicyControlPanel.svelte.d.ts +26 -0
  111. package/dist/svelte/components/FieldPolicyControlPanel.svelte.d.ts.map +1 -0
  112. package/dist/svelte/components/FieldPolicyEditor.svelte +417 -0
  113. package/dist/svelte/components/FieldPolicyEditor.svelte.d.ts +18 -0
  114. package/dist/svelte/components/FieldPolicyEditor.svelte.d.ts.map +1 -0
  115. package/dist/svelte/components/FieldPolicyGearButton.svelte +17 -0
  116. package/dist/svelte/components/FieldPolicyGearButton.svelte.d.ts +8 -0
  117. package/dist/svelte/components/FieldPolicyGearButton.svelte.d.ts.map +1 -0
  118. package/dist/svelte/components/FieldPolicyGearProvider.svelte +135 -0
  119. package/dist/svelte/components/FieldPolicyGearProvider.svelte.d.ts +17 -0
  120. package/dist/svelte/components/FieldPolicyGearProvider.svelte.d.ts.map +1 -0
  121. package/dist/svelte/components/FieldPolicyProvider.svelte +89 -0
  122. package/dist/svelte/components/FieldPolicyProvider.svelte.d.ts +35 -0
  123. package/dist/svelte/components/FieldPolicyProvider.svelte.d.ts.map +1 -0
  124. package/dist/svelte/components/FormHelp.svelte +157 -0
  125. package/dist/svelte/components/FormHelp.svelte.d.ts +15 -0
  126. package/dist/svelte/components/FormHelp.svelte.d.ts.map +1 -0
  127. package/dist/svelte/components/ModeSwitch.svelte +47 -0
  128. package/dist/svelte/components/ModeSwitch.svelte.d.ts +10 -0
  129. package/dist/svelte/components/ModeSwitch.svelte.d.ts.map +1 -0
  130. package/dist/svelte/components/ObjectForm.svelte +351 -0
  131. package/dist/svelte/components/ObjectForm.svelte.d.ts +40 -0
  132. package/dist/svelte/components/ObjectForm.svelte.d.ts.map +1 -0
  133. package/dist/svelte/components/ObjectFormSourceProvider.svelte +18 -0
  134. package/dist/svelte/components/ObjectFormSourceProvider.svelte.d.ts +11 -0
  135. package/dist/svelte/components/ObjectFormSourceProvider.svelte.d.ts.map +1 -0
  136. package/dist/svelte/components/PolicyField.svelte +338 -0
  137. package/dist/svelte/components/PolicyField.svelte.d.ts +80 -0
  138. package/dist/svelte/components/PolicyField.svelte.d.ts.map +1 -0
  139. package/dist/svelte/context.svelte.d.ts +54 -0
  140. package/dist/svelte/context.svelte.d.ts.map +1 -0
  141. package/dist/svelte/context.svelte.js +55 -0
  142. package/dist/svelte/data-table.d.ts +17 -0
  143. package/dist/svelte/data-table.d.ts.map +1 -0
  144. package/dist/svelte/data-table.js +19 -0
  145. package/dist/svelte/field-policy-editor.d.ts +81 -0
  146. package/dist/svelte/field-policy-editor.d.ts.map +1 -0
  147. package/dist/svelte/field-policy-editor.js +120 -0
  148. package/dist/svelte/gear-context.svelte.d.ts +14 -0
  149. package/dist/svelte/gear-context.svelte.d.ts.map +1 -0
  150. package/dist/svelte/gear-context.svelte.js +15 -0
  151. package/dist/svelte/index.d.ts +58 -0
  152. package/dist/svelte/index.d.ts.map +1 -0
  153. package/dist/svelte/index.js +38 -0
  154. package/dist/svelte/input-registry.d.ts +21 -0
  155. package/dist/svelte/input-registry.d.ts.map +1 -0
  156. package/dist/svelte/input-registry.js +26 -0
  157. package/dist/svelte/object-form-source-context.svelte.d.ts +4 -0
  158. package/dist/svelte/object-form-source-context.svelte.d.ts.map +1 -0
  159. package/dist/svelte/object-form-source-context.svelte.js +8 -0
  160. package/dist/svelte/object-form-source.svelte.d.ts +18 -0
  161. package/dist/svelte/object-form-source.svelte.d.ts.map +1 -0
  162. package/dist/svelte/object-form-source.svelte.js +99 -0
  163. package/dist/svelte/object-form.d.ts +6 -0
  164. package/dist/svelte/object-form.d.ts.map +1 -0
  165. package/dist/svelte/object-form.js +38 -0
  166. package/dist/svelte/playground/FieldPolicyFormPreview.svelte +212 -0
  167. package/dist/svelte/playground/FieldPolicyFormPreview.svelte.d.ts +4 -0
  168. package/dist/svelte/playground/FieldPolicyFormPreview.svelte.d.ts.map +1 -0
  169. package/dist/svelte/playground/ObjectFormPreview.svelte +129 -0
  170. package/dist/svelte/playground/ObjectFormPreview.svelte.d.ts +4 -0
  171. package/dist/svelte/playground/ObjectFormPreview.svelte.d.ts.map +1 -0
  172. package/dist/svelte/playground.d.ts +20 -0
  173. package/dist/svelte/playground.d.ts.map +1 -0
  174. package/dist/svelte/playground.js +38 -0
  175. package/dist/svelte/settings-catalog.d.ts +54 -0
  176. package/dist/svelte/settings-catalog.d.ts.map +1 -0
  177. package/dist/svelte/settings-catalog.js +116 -0
  178. package/dist/svelte/types.d.ts +101 -0
  179. package/dist/svelte/types.d.ts.map +1 -0
  180. package/dist/svelte/types.js +4 -0
  181. package/dist/types.d.ts +276 -203
  182. package/dist/types.d.ts.map +1 -0
  183. package/dist/types.js.map +1 -1
  184. package/package.json +33 -8
@@ -0,0 +1,503 @@
1
+ // @vitest-environment jsdom
2
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
3
+ import { describe, expect, it } from 'vitest';
4
+ import FieldPolicyEditor from '../components/FieldPolicyEditor.svelte';
5
+ import { editorStateErrorMessage, isFieldPolicyEditorState, mutationFromDraft, registerFieldPolicyFocusTool, requiredVisibilityIsInvalid, serializeDefaultValue, } from '../field-policy-editor.js';
6
+ const fields = {
7
+ title: { type: 'text', required: true },
8
+ enabled: { type: 'boolean' },
9
+ };
10
+ function stateFor(capabilities = {
11
+ manage: true,
12
+ personalize: true,
13
+ }) {
14
+ return {
15
+ capabilities,
16
+ personalLowerDefaultUsable: { title: false, enabled: false },
17
+ policy: {
18
+ objectRef: '@test:Widget',
19
+ fields: {
20
+ title: {
21
+ fieldName: 'title',
22
+ hasDefault: false,
23
+ defaultValue: undefined,
24
+ visibility: 'basic',
25
+ help: null,
26
+ label: 'Title',
27
+ order: 1,
28
+ group: null,
29
+ locked: false,
30
+ required: true,
31
+ },
32
+ enabled: {
33
+ fieldName: 'enabled',
34
+ hasDefault: true,
35
+ defaultValue: false,
36
+ visibility: 'basic',
37
+ help: null,
38
+ label: 'Enabled',
39
+ order: 2,
40
+ group: null,
41
+ locked: true,
42
+ required: false,
43
+ },
44
+ },
45
+ layers: { title: [], enabled: [] },
46
+ },
47
+ rows: { app: [], tenant: [], user: [] },
48
+ };
49
+ }
50
+ function adapter() {
51
+ return {
52
+ load: async () => stateFor(),
53
+ create: async () => undefined,
54
+ update: async () => undefined,
55
+ delete: async () => undefined,
56
+ };
57
+ }
58
+ /** Small round-trip transport double: tenant rows are shared, user rows aren't. */
59
+ class InMemoryPolicyTransport {
60
+ organization = null;
61
+ personal = new Map();
62
+ forUser(userId, capabilities = { manage: true, personalize: true }) {
63
+ return {
64
+ load: async () => {
65
+ if (!capabilities.manage && !capabilities.personalize) {
66
+ return {
67
+ ok: false,
68
+ code: 'permission_denied',
69
+ status: 403,
70
+ message: 'Permission denied',
71
+ };
72
+ }
73
+ const base = stateFor(capabilities);
74
+ const own = this.personal.get(userId);
75
+ const resolved = own ?? this.organization;
76
+ if (resolved) {
77
+ base.policy.fields.title.label =
78
+ resolved.label ?? base.policy.fields.title.label;
79
+ base.policy.fields.title.visibility =
80
+ resolved.visibility ?? base.policy.fields.title.visibility;
81
+ }
82
+ return base;
83
+ },
84
+ create: async (mutation) => {
85
+ if (mutation.scopeType === 'user') {
86
+ if (this.organization?.locked)
87
+ throw new Error('locked by organization');
88
+ this.personal.set(userId, mutation);
89
+ }
90
+ else
91
+ this.organization = mutation;
92
+ },
93
+ update: async (mutation) => {
94
+ if (mutation.scopeType === 'user') {
95
+ if (this.organization?.locked)
96
+ throw new Error('locked by organization');
97
+ this.personal.set(userId, mutation);
98
+ }
99
+ else
100
+ this.organization = mutation;
101
+ },
102
+ delete: async ({ id }) => {
103
+ if (id === `user:${userId}`)
104
+ this.personal.delete(userId);
105
+ if (id === 'organization')
106
+ this.organization = null;
107
+ },
108
+ };
109
+ }
110
+ }
111
+ describe('FieldPolicy gear', () => {
112
+ it('keeps mutation identities context-derived and JSON encodes defaults', () => {
113
+ expect(mutationFromDraft('@test:Widget', 'title', 'user', {
114
+ defaultEnabled: true,
115
+ defaultValue: 'Hello',
116
+ displayOrder: null,
117
+ help: null,
118
+ label: null,
119
+ locked: true,
120
+ visibility: 'basic',
121
+ })).toEqual({
122
+ objectRef: '@test:Widget',
123
+ fieldName: 'title',
124
+ scopeType: 'user',
125
+ defaultValue: '"Hello"',
126
+ displayOrder: null,
127
+ help: null,
128
+ label: null,
129
+ locked: null,
130
+ visibility: 'basic',
131
+ });
132
+ });
133
+ it('fails closed for missing or non-serializable explicit defaults, but keeps JSON null', () => {
134
+ expect(() => serializeDefaultValue(undefined)).toThrow(/Choose a default/);
135
+ expect(() => serializeDefaultValue(BigInt(1))).toThrow(/JSON-serializable/);
136
+ expect(serializeDefaultValue(null)).toBe('null');
137
+ });
138
+ it('rejects a well-shaped editor response for a different object', () => {
139
+ expect(isFieldPolicyEditorState(stateFor(), '@test:Other')).toBe(false);
140
+ expect(isFieldPolicyEditorState(stateFor(), '@test:Widget')).toBe(true);
141
+ });
142
+ it('fails closed when the personal lower-default signal is absent or malformed', () => {
143
+ const missing = stateFor();
144
+ delete missing.personalLowerDefaultUsable;
145
+ expect(isFieldPolicyEditorState(missing)).toBe(false);
146
+ const malformed = stateFor();
147
+ malformed.personalLowerDefaultUsable.title =
148
+ 'yes';
149
+ expect(isFieldPolicyEditorState(malformed)).toBe(false);
150
+ });
151
+ it('guards required no-default demotion before transport', () => {
152
+ expect(requiredVisibilityIsInvalid(true, 'hidden', undefined)).toBe(true);
153
+ expect(requiredVisibilityIsInvalid(true, 'advanced', 'seed')).toBe(false);
154
+ });
155
+ it('round-trips shared organization, isolated personal, lock, and reset semantics', async () => {
156
+ const transport = new InMemoryPolicyTransport();
157
+ const alice = transport.forUser('alice');
158
+ const bob = transport.forUser('bob');
159
+ const organization = mutationFromDraft('@test:Widget', 'title', 'tenant', {
160
+ defaultEnabled: false,
161
+ defaultValue: undefined,
162
+ displayOrder: null,
163
+ help: null,
164
+ label: 'Organization title',
165
+ locked: false,
166
+ visibility: 'advanced',
167
+ });
168
+ await alice.create(organization);
169
+ expect((await alice.load()).policy.fields.title
170
+ .label).toBe('Organization title');
171
+ expect((await bob.load()).policy.fields.title
172
+ .visibility).toBe('advanced');
173
+ const personal = {
174
+ ...organization,
175
+ scopeType: 'user',
176
+ label: 'Alice title',
177
+ };
178
+ await alice.create(personal);
179
+ expect((await alice.load()).policy.fields.title
180
+ .label).toBe('Alice title');
181
+ expect((await bob.load()).policy.fields.title.label).toBe('Organization title');
182
+ await alice.delete({ id: 'user:alice' });
183
+ expect((await alice.load()).policy.fields.title
184
+ .label).toBe('Organization title');
185
+ await alice.update({ ...organization, id: 'organization', locked: true });
186
+ await expect(bob.create(personal)).rejects.toThrow(/locked by organization/);
187
+ await expect(transport.forUser('nobody', { manage: false, personalize: false }).load()).resolves.toMatchObject({ ok: false, status: 403 });
188
+ });
189
+ it('registers a structural AdminShell focus tool and returns its disposer', () => {
190
+ let captured;
191
+ let disposed = false;
192
+ const component = (() => null);
193
+ const render = (() => undefined);
194
+ const dispose = registerFieldPolicyFocusTool({
195
+ registerFocusTool(tool) {
196
+ captured = tool;
197
+ return () => {
198
+ disposed = true;
199
+ };
200
+ },
201
+ }, '@test:Widget', { id: 'widget-settings', label: 'Field settings', component, render });
202
+ expect(captured).toMatchObject({
203
+ subject: { type: 'object-form', id: '@test:Widget' },
204
+ component,
205
+ render,
206
+ });
207
+ dispose();
208
+ expect(disposed).toBe(true);
209
+ });
210
+ it('fails closed when a generated custom-action response is malformed', () => {
211
+ expect(editorStateErrorMessage({})).toBe('Unable to load field settings.');
212
+ });
213
+ it('links scope tabs to their panel and activates them with roving keyboard navigation', async () => {
214
+ render(FieldPolicyEditor, {
215
+ props: {
216
+ state: stateFor(),
217
+ adapter: adapter(),
218
+ fields,
219
+ onclose: () => undefined,
220
+ onmutated: async () => undefined,
221
+ },
222
+ });
223
+ const organization = screen.getByRole('tab', { name: 'Organization' });
224
+ const personal = screen.getByRole('tab', { name: 'Just me' });
225
+ const panel = screen.getByRole('tabpanel');
226
+ expect(organization).toHaveAttribute('aria-selected', 'true');
227
+ expect(organization).toHaveAttribute('tabindex', '0');
228
+ expect(personal).toHaveAttribute('aria-selected', 'false');
229
+ expect(personal).toHaveAttribute('tabindex', '-1');
230
+ expect(organization).toHaveAttribute('aria-controls', panel.id);
231
+ expect(panel).toHaveAttribute('aria-labelledby', organization.id);
232
+ organization.focus();
233
+ await userEvent.keyboard('{ArrowRight}');
234
+ await new Promise((resolve) => requestAnimationFrame(resolve));
235
+ expect(personal).toHaveFocus();
236
+ expect(personal).toHaveAttribute('aria-selected', 'true');
237
+ expect(personal).toHaveAttribute('tabindex', '0');
238
+ expect(organization).toHaveAttribute('tabindex', '-1');
239
+ expect(personal).toHaveAttribute('aria-controls', panel.id);
240
+ expect(panel).toHaveAttribute('aria-labelledby', personal.id);
241
+ await userEvent.keyboard('{Home}');
242
+ await new Promise((resolve) => requestAnimationFrame(resolve));
243
+ expect(organization).toHaveFocus();
244
+ await userEvent.keyboard('{End}');
245
+ await new Promise((resolve) => requestAnimationFrame(resolve));
246
+ expect(personal).toHaveFocus();
247
+ await userEvent.keyboard('{ArrowLeft}');
248
+ await new Promise((resolve) => requestAnimationFrame(resolve));
249
+ expect(organization).toHaveFocus();
250
+ });
251
+ it('permission-gates tabs, disables locked personal fields, and is axe-clean', async () => {
252
+ const { container } = render(FieldPolicyEditor, {
253
+ props: {
254
+ state: stateFor({ manage: false, personalize: true }),
255
+ adapter: adapter(),
256
+ fields,
257
+ onclose: () => undefined,
258
+ onmutated: async () => undefined,
259
+ },
260
+ });
261
+ expect(screen.queryByRole('tab', { name: 'Organization' })).toBeNull();
262
+ const personal = screen.getByRole('tab', { name: 'Just me' });
263
+ const panel = screen.getByRole('tabpanel');
264
+ expect(personal).toBeInTheDocument();
265
+ expect(personal).toHaveAttribute('aria-selected', 'true');
266
+ expect(personal).toHaveAttribute('tabindex', '0');
267
+ expect(personal).toHaveAttribute('aria-controls', panel.id);
268
+ expect(panel).toHaveAttribute('aria-labelledby', personal.id);
269
+ personal.focus();
270
+ await userEvent.keyboard('{ArrowLeft}{ArrowRight}{Home}{End}');
271
+ expect(personal).toHaveFocus();
272
+ expect(screen.getByText(/locked by your organization/i)).toBeInTheDocument();
273
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[1]).toBeDisabled();
274
+ await expectNoA11yViolations(container);
275
+ });
276
+ it('keeps a manage-only scope tab linked and selected', async () => {
277
+ render(FieldPolicyEditor, {
278
+ props: {
279
+ state: stateFor({ manage: true, personalize: false }),
280
+ adapter: adapter(),
281
+ fields,
282
+ onclose: () => undefined,
283
+ onmutated: async () => undefined,
284
+ },
285
+ });
286
+ const organization = screen.getByRole('tab', { name: 'Organization' });
287
+ const panel = screen.getByRole('tabpanel');
288
+ expect(screen.queryByRole('tab', { name: 'Just me' })).toBeNull();
289
+ expect(organization).toHaveAttribute('aria-selected', 'true');
290
+ expect(organization).toHaveAttribute('tabindex', '0');
291
+ expect(organization).toHaveAttribute('aria-controls', panel.id);
292
+ expect(panel).toHaveAttribute('aria-labelledby', organization.id);
293
+ organization.focus();
294
+ await userEvent.keyboard('{ArrowLeft}{ArrowRight}{Home}{End}');
295
+ expect(organization).toHaveFocus();
296
+ });
297
+ it('prevents a required defaultless field from being hidden', async () => {
298
+ render(FieldPolicyEditor, {
299
+ props: {
300
+ state: stateFor(),
301
+ adapter: adapter(),
302
+ fields,
303
+ onclose: () => undefined,
304
+ onmutated: async () => undefined,
305
+ },
306
+ });
307
+ const visibility = screen.getAllByLabelText('Visibility')[0];
308
+ await userEvent.selectOptions(visibility, 'hidden');
309
+ expect(screen.getByText(/Add a usable default/i)).toBeInTheDocument();
310
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeDisabled();
311
+ });
312
+ it('blocks an app-row default removal when no lower code default exists', async () => {
313
+ const state = stateFor();
314
+ state.policy.fields.title.hasDefault = true;
315
+ state.policy.fields.title.defaultValue = 'app default';
316
+ state.policy.layers.title = [
317
+ {
318
+ layer: 'app',
319
+ delta: { default: { value: 'app default' } },
320
+ },
321
+ ];
322
+ state.rows.app = [
323
+ {
324
+ id: 'app-title',
325
+ fieldName: 'title',
326
+ scopeType: 'app',
327
+ tenantId: null,
328
+ userId: null,
329
+ updatedBy: null,
330
+ defaultValue: '"app default"',
331
+ displayOrder: null,
332
+ help: null,
333
+ label: null,
334
+ locked: null,
335
+ visibility: null,
336
+ },
337
+ ];
338
+ render(FieldPolicyEditor, {
339
+ props: {
340
+ state,
341
+ adapter: adapter(),
342
+ fields,
343
+ organizationScope: 'app',
344
+ onclose: () => undefined,
345
+ onmutated: async () => undefined,
346
+ },
347
+ });
348
+ await userEvent.click(screen.getAllByRole('checkbox', { name: 'Override default value' })[0]);
349
+ await userEvent.selectOptions(screen.getAllByLabelText('Visibility')[0], 'hidden');
350
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeDisabled();
351
+ });
352
+ it('permits a personal required-default removal when the server reports a usable lower default', async () => {
353
+ const state = stateFor({ manage: false, personalize: true });
354
+ state.personalLowerDefaultUsable.title = true;
355
+ state.policy.fields.title.hasDefault = true;
356
+ state.policy.fields.title.defaultValue = 'personal default';
357
+ state.policy.fields.title.visibility = 'hidden';
358
+ // Personal-only state deliberately has no app/tenant explanation layer or
359
+ // raw row, so the boolean signal is the only fallback information.
360
+ state.policy.layers.title = [
361
+ {
362
+ layer: 'user',
363
+ delta: { default: { value: 'personal default' }, visibility: 'hidden' },
364
+ },
365
+ ];
366
+ state.rows.user = [
367
+ {
368
+ id: 'user-title',
369
+ fieldName: 'title',
370
+ scopeType: 'user',
371
+ tenantId: null,
372
+ userId: 'alice',
373
+ updatedBy: null,
374
+ defaultValue: '"personal default"',
375
+ displayOrder: null,
376
+ help: null,
377
+ label: null,
378
+ locked: null,
379
+ visibility: 'hidden',
380
+ },
381
+ ];
382
+ render(FieldPolicyEditor, {
383
+ props: {
384
+ state,
385
+ adapter: adapter(),
386
+ fields,
387
+ onclose: () => undefined,
388
+ onmutated: async () => undefined,
389
+ },
390
+ });
391
+ await userEvent.click(screen.getAllByRole('checkbox', { name: 'Override default value' })[0]);
392
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeEnabled();
393
+ });
394
+ it('permits clearing a current app default when the generated field has a code default', async () => {
395
+ const state = stateFor();
396
+ state.policy.fields.title.hasDefault = true;
397
+ state.policy.fields.title.defaultValue = 'app default';
398
+ state.rows.app = [
399
+ {
400
+ id: 'app-title',
401
+ fieldName: 'title',
402
+ scopeType: 'app',
403
+ tenantId: null,
404
+ userId: null,
405
+ updatedBy: null,
406
+ defaultValue: '"app default"',
407
+ displayOrder: null,
408
+ help: null,
409
+ label: null,
410
+ locked: null,
411
+ visibility: null,
412
+ },
413
+ ];
414
+ render(FieldPolicyEditor, {
415
+ props: {
416
+ state,
417
+ adapter: adapter(),
418
+ fields: {
419
+ ...fields,
420
+ title: { type: 'text', required: true, default: 'code default' },
421
+ },
422
+ organizationScope: 'app',
423
+ onclose: () => undefined,
424
+ onmutated: async () => undefined,
425
+ },
426
+ });
427
+ await userEvent.click(screen.getAllByRole('checkbox', { name: 'Override default value' })[0]);
428
+ await userEvent.selectOptions(screen.getAllByLabelText('Visibility')[0], 'hidden');
429
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeEnabled();
430
+ });
431
+ it('clears invalid JSON default state when its override is disabled', async () => {
432
+ const state = stateFor();
433
+ state.rows.tenant = [
434
+ {
435
+ id: 'tenant-title',
436
+ fieldName: 'title',
437
+ scopeType: 'tenant',
438
+ tenantId: 'tenant-a',
439
+ userId: null,
440
+ updatedBy: null,
441
+ defaultValue: '"seed"',
442
+ displayOrder: null,
443
+ help: null,
444
+ label: null,
445
+ locked: null,
446
+ visibility: null,
447
+ },
448
+ ];
449
+ render(FieldPolicyEditor, {
450
+ props: {
451
+ state,
452
+ adapter: adapter(),
453
+ fields: { ...fields, title: { type: 'json', required: true } },
454
+ onclose: () => undefined,
455
+ onmutated: async () => undefined,
456
+ },
457
+ });
458
+ const json = screen.getByLabelText('Default value');
459
+ json.value = '{bad';
460
+ json.dispatchEvent(new Event('input', { bubbles: true }));
461
+ await Promise.resolve();
462
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeDisabled();
463
+ await userEvent.click(screen.getAllByRole('checkbox', { name: 'Override default value' })[0]);
464
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeEnabled();
465
+ });
466
+ it('remounts default JSON inputs across scope tabs without leaking invalid drafts', async () => {
467
+ const state = stateFor();
468
+ state.rows.tenant = [
469
+ {
470
+ id: 'tenant-title',
471
+ fieldName: 'title',
472
+ scopeType: 'tenant',
473
+ tenantId: 'tenant-a',
474
+ userId: null,
475
+ updatedBy: null,
476
+ defaultValue: '"seed"',
477
+ displayOrder: null,
478
+ help: null,
479
+ label: null,
480
+ locked: null,
481
+ visibility: null,
482
+ },
483
+ ];
484
+ render(FieldPolicyEditor, {
485
+ props: {
486
+ state,
487
+ adapter: adapter(),
488
+ fields: { ...fields, title: { type: 'json', required: true } },
489
+ onclose: () => undefined,
490
+ onmutated: async () => undefined,
491
+ },
492
+ });
493
+ const json = screen.getByLabelText('Default value');
494
+ json.value = '{bad';
495
+ json.dispatchEvent(new Event('input', { bubbles: true }));
496
+ await Promise.resolve();
497
+ expect(screen.getAllByText(/Enter valid JSON/i).length).toBeGreaterThan(0);
498
+ await userEvent.click(screen.getByRole('tab', { name: 'Just me' }));
499
+ await userEvent.click(screen.getByRole('tab', { name: 'Organization' }));
500
+ expect(screen.queryByText(/Enter valid JSON/i)).toBeNull();
501
+ expect(screen.getAllByRole('button', { name: 'Save', exact: true })[0]).toBeEnabled();
502
+ });
503
+ });
@@ -0,0 +1,138 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component tests for FormHelp (#2048).
4
+ */
5
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
6
+ import { describe, expect, it } from 'vitest';
7
+ import FieldPolicyProvider from '../components/FieldPolicyProvider.svelte';
8
+ import FormHelp from '../components/FormHelp.svelte';
9
+ import FormHelpFixture from './fixtures/FormHelpFixture.svelte';
10
+ function makePolicy() {
11
+ return {
12
+ objectRef: '@test:Widget',
13
+ fields: {
14
+ name: {
15
+ fieldName: 'name',
16
+ hasDefault: false,
17
+ defaultValue: undefined,
18
+ visibility: 'basic',
19
+ help: 'The display name for this widget',
20
+ label: 'Name',
21
+ order: 2,
22
+ group: null,
23
+ locked: false,
24
+ required: true,
25
+ },
26
+ sku: {
27
+ fieldName: 'sku',
28
+ hasDefault: true,
29
+ defaultValue: 'W-001',
30
+ visibility: 'basic',
31
+ help: 'Stock keeping unit code',
32
+ label: 'SKU',
33
+ order: 1,
34
+ group: null,
35
+ locked: false,
36
+ required: false,
37
+ },
38
+ wholesalePrice: {
39
+ fieldName: 'wholesalePrice',
40
+ hasDefault: false,
41
+ defaultValue: undefined,
42
+ visibility: 'advanced',
43
+ help: 'Price for wholesale customers',
44
+ label: 'Wholesale Price',
45
+ order: 4,
46
+ group: null,
47
+ locked: false,
48
+ required: false,
49
+ },
50
+ hidden: {
51
+ fieldName: 'hidden',
52
+ hasDefault: false,
53
+ defaultValue: undefined,
54
+ visibility: 'hidden',
55
+ help: 'Should not appear in glossary',
56
+ label: 'Hidden',
57
+ order: 3,
58
+ group: null,
59
+ locked: false,
60
+ required: false,
61
+ },
62
+ },
63
+ };
64
+ }
65
+ describe('FormHelp', () => {
66
+ it('renders a help toggle button', () => {
67
+ render(FormHelpFixture, {
68
+ props: { policy: makePolicy() },
69
+ });
70
+ expect(screen.getByText('Help')).toBeInTheDocument();
71
+ });
72
+ it('shows glossary entries when expanded', async () => {
73
+ render(FormHelpFixture, {
74
+ props: { policy: makePolicy() },
75
+ });
76
+ // Open the help panel
77
+ await userEvent.click(screen.getByText('Help'));
78
+ expect(screen.getByText('The display name for this widget')).toBeInTheDocument();
79
+ expect(screen.getByText('Stock keeping unit code')).toBeInTheDocument();
80
+ });
81
+ it('excludes hidden-tier fields from the glossary', async () => {
82
+ render(FormHelpFixture, {
83
+ props: { policy: makePolicy() },
84
+ });
85
+ await userEvent.click(screen.getByText('Help'));
86
+ expect(screen.queryByText('Should not appear in glossary')).not.toBeInTheDocument();
87
+ });
88
+ it('excludes advanced-tier fields from the glossary in basic mode', async () => {
89
+ // The glossary mirrors PolicyField visibility: advanced-tier fields are
90
+ // not visible in basic mode, so their help must not be listed either.
91
+ render(FormHelpFixture, {
92
+ props: { policy: makePolicy(), mode: 'basic' },
93
+ });
94
+ await userEvent.click(screen.getByText('Help'));
95
+ expect(screen.queryByText('Price for wholesale customers')).not.toBeInTheDocument();
96
+ // Basic-tier entries still present.
97
+ expect(screen.getByText('The display name for this widget')).toBeInTheDocument();
98
+ });
99
+ it('includes advanced-tier fields in the glossary in advanced mode', async () => {
100
+ render(FormHelpFixture, {
101
+ props: { policy: makePolicy(), mode: 'advanced' },
102
+ });
103
+ await userEvent.click(screen.getByText('Help'));
104
+ expect(screen.getByText('Price for wholesale customers')).toBeInTheDocument();
105
+ });
106
+ it('shows object description when provided', async () => {
107
+ render(FormHelpFixture, {
108
+ props: {
109
+ policy: makePolicy(),
110
+ objectDescription: 'A widget is a configurable thing.',
111
+ },
112
+ });
113
+ await userEvent.click(screen.getByText('Help'));
114
+ expect(screen.getByText('A widget is a configurable thing.')).toBeInTheDocument();
115
+ });
116
+ it('orders glossary by resolved order (SKU before Name)', async () => {
117
+ render(FormHelpFixture, {
118
+ props: { policy: makePolicy() },
119
+ });
120
+ await userEvent.click(screen.getByText('Help'));
121
+ const terms = screen.getAllByRole('term');
122
+ expect(terms[0]).toHaveTextContent('SKU');
123
+ expect(terms[1]).toHaveTextContent('Name');
124
+ });
125
+ it('is axe-clean', async () => {
126
+ const { container } = render(FormHelpFixture, {
127
+ props: { policy: makePolicy() },
128
+ });
129
+ await expectNoA11yViolations(container);
130
+ });
131
+ it('is axe-clean when expanded', async () => {
132
+ const { container } = render(FormHelpFixture, {
133
+ props: { policy: makePolicy() },
134
+ });
135
+ await userEvent.click(screen.getByText('Help'));
136
+ await expectNoA11yViolations(container);
137
+ });
138
+ });