@happyvertical/smrt-fields 0.40.61 → 0.40.63

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 (78) hide show
  1. package/AGENTS.md +18 -0
  2. package/README.md +125 -112
  3. package/dist/chunks/FieldPolicyCollection--RxukfCX.js +1218 -0
  4. package/dist/chunks/FieldPolicyCollection--RxukfCX.js.map +1 -0
  5. package/dist/collections/FieldPolicySuggestionCollection.d.ts +160 -0
  6. package/dist/collections/FieldPolicySuggestionCollection.d.ts.map +1 -0
  7. package/dist/collections/FieldUsageCounterCollection.d.ts +143 -0
  8. package/dist/collections/FieldUsageCounterCollection.d.ts.map +1 -0
  9. package/dist/deterministic-id.d.ts +18 -0
  10. package/dist/deterministic-id.d.ts.map +1 -0
  11. package/dist/field-definitions.d.ts +8 -0
  12. package/dist/field-definitions.d.ts.map +1 -1
  13. package/dist/index.d.ts +8 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1565 -1077
  16. package/dist/index.js.map +1 -1
  17. package/dist/manifest.json +1442 -188
  18. package/dist/models/FieldPolicySuggestion.d.ts +132 -0
  19. package/dist/models/FieldPolicySuggestion.d.ts.map +1 -0
  20. package/dist/models/FieldUsageCounter.d.ts +178 -0
  21. package/dist/models/FieldUsageCounter.d.ts.map +1 -0
  22. package/dist/models/FieldUsageReportReceipt.d.ts +25 -0
  23. package/dist/models/FieldUsageReportReceipt.d.ts.map +1 -0
  24. package/dist/smrt-knowledge.json +404 -7
  25. package/dist/svelte/__tests__/FieldPolicyControlPanel.test.js +54 -0
  26. package/dist/svelte/__tests__/FormHelp.test.js +8 -0
  27. package/dist/svelte/__tests__/ObjectForm.test.js +131 -0
  28. package/dist/svelte/__tests__/UsageLearning.test.js +140 -0
  29. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte +21 -4
  30. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.d.ts +5 -1
  31. package/dist/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.d.ts.map +1 -1
  32. package/dist/svelte/__tests__/fixtures/SuggestionGearFixture.svelte +23 -0
  33. package/dist/svelte/__tests__/fixtures/SuggestionGearFixture.svelte.d.ts +10 -0
  34. package/dist/svelte/__tests__/fixtures/SuggestionGearFixture.svelte.d.ts.map +1 -0
  35. package/dist/svelte/__tests__/playground.test.js +66 -0
  36. package/dist/svelte/components/FieldPolicyControlPanel.svelte +28 -1
  37. package/dist/svelte/components/FieldPolicyControlPanel.svelte.d.ts +3 -0
  38. package/dist/svelte/components/FieldPolicyControlPanel.svelte.d.ts.map +1 -1
  39. package/dist/svelte/components/FieldPolicyGearButton.svelte +8 -1
  40. package/dist/svelte/components/FieldPolicyGearButton.svelte.d.ts.map +1 -1
  41. package/dist/svelte/components/FieldPolicyGearProvider.svelte +34 -0
  42. package/dist/svelte/components/FieldPolicyGearProvider.svelte.d.ts +3 -0
  43. package/dist/svelte/components/FieldPolicyGearProvider.svelte.d.ts.map +1 -1
  44. package/dist/svelte/components/FieldPolicySuggestionQueue.svelte +135 -0
  45. package/dist/svelte/components/FieldPolicySuggestionQueue.svelte.d.ts +11 -0
  46. package/dist/svelte/components/FieldPolicySuggestionQueue.svelte.d.ts.map +1 -0
  47. package/dist/svelte/components/FormHelp.svelte +16 -1
  48. package/dist/svelte/components/FormHelp.svelte.d.ts.map +1 -1
  49. package/dist/svelte/components/ObjectForm.svelte +91 -2
  50. package/dist/svelte/components/ObjectForm.svelte.d.ts +11 -1
  51. package/dist/svelte/components/ObjectForm.svelte.d.ts.map +1 -1
  52. package/dist/svelte/context.svelte.d.ts +15 -0
  53. package/dist/svelte/context.svelte.d.ts.map +1 -1
  54. package/dist/svelte/context.svelte.js +15 -0
  55. package/dist/svelte/gear-context.svelte.d.ts +2 -0
  56. package/dist/svelte/gear-context.svelte.d.ts.map +1 -1
  57. package/dist/svelte/index.d.ts +6 -1
  58. package/dist/svelte/index.d.ts.map +1 -1
  59. package/dist/svelte/index.js +4 -1
  60. package/dist/svelte/playground/FieldPolicyFormPreview.svelte +18 -12
  61. package/dist/svelte/playground/FieldPolicyFormPreview.svelte.d.ts.map +1 -1
  62. package/dist/svelte/suggestions.d.ts +38 -0
  63. package/dist/svelte/suggestions.d.ts.map +1 -0
  64. package/dist/svelte/suggestions.js +68 -0
  65. package/dist/svelte/usage-capture.d.ts +63 -0
  66. package/dist/svelte/usage-capture.d.ts.map +1 -0
  67. package/dist/svelte/usage-capture.js +61 -0
  68. package/dist/types.d.ts +40 -0
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/types.js +7 -1
  71. package/dist/types.js.map +1 -1
  72. package/dist/usage-learning.d.ts +201 -0
  73. package/dist/usage-learning.d.ts.map +1 -0
  74. package/dist/usage-schedules.d.ts +110 -0
  75. package/dist/usage-schedules.d.ts.map +1 -0
  76. package/dist/users-module.d.ts +30 -0
  77. package/dist/users-module.d.ts.map +1 -0
  78. package/package.json +9 -9
@@ -254,6 +254,137 @@ describe('ObjectForm component', () => {
254
254
  await userEvent.click(submit);
255
255
  expect(onsubmit).toHaveBeenCalledTimes(1);
256
256
  });
257
+ it('reports only after a synchronous host persistence acknowledgement', async () => {
258
+ const onsubmit = vi.fn(() => true);
259
+ const reportUsage = vi.fn(() => {
260
+ throw new Error('metrics unavailable');
261
+ });
262
+ render(ObjectFormActionsFixture, {
263
+ props: { fields, policy, onsubmit, usageReporter: { reportUsage } },
264
+ });
265
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Deluxe');
266
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
267
+ expect(onsubmit).toHaveBeenCalledTimes(1);
268
+ expect(reportUsage).toHaveBeenCalledWith({
269
+ entries: [{ objectRef: '@test:Product', fieldName: 'name' }],
270
+ });
271
+ const metadata = screen.getByRole('textbox', {
272
+ name: 'Metadata',
273
+ });
274
+ metadata.value = '{not json}';
275
+ await fireEvent.input(metadata);
276
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
277
+ expect(reportUsage).toHaveBeenCalledTimes(1);
278
+ });
279
+ it('reports after an asynchronous host persistence acknowledgement', async () => {
280
+ const onsubmit = vi.fn(async () => true);
281
+ const reportUsage = vi.fn();
282
+ render(ObjectFormActionsFixture, {
283
+ props: { fields, policy, onsubmit, usageReporter: { reportUsage } },
284
+ });
285
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Deluxe');
286
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
287
+ await vi.waitFor(() => expect(reportUsage).toHaveBeenCalledTimes(1));
288
+ });
289
+ it('reports the submitted values when a successful create clears its bound record', async () => {
290
+ const reportUsage = vi.fn();
291
+ render(ObjectFormActionsFixture, {
292
+ props: {
293
+ fields,
294
+ policy,
295
+ onsubmit: async () => true,
296
+ usageReporter: { reportUsage },
297
+ resetAfterSuccessfulSubmit: true,
298
+ },
299
+ });
300
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Deluxe');
301
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
302
+ await vi.waitFor(() => expect(reportUsage).toHaveBeenCalledWith({
303
+ entries: [{ objectRef: '@test:Product', fieldName: 'name' }],
304
+ }));
305
+ });
306
+ it('keeps named void and Promise<void> persistence handlers compatible without treating either as success', async () => {
307
+ function legacyVoidHandler(_event) { }
308
+ async function legacyAsyncVoidHandler(_event) { }
309
+ const handlers = [
310
+ legacyVoidHandler,
311
+ legacyAsyncVoidHandler,
312
+ ];
313
+ for (const onsubmit of handlers) {
314
+ const reportUsage = vi.fn();
315
+ const view = render(ObjectFormActionsFixture, {
316
+ props: { fields, policy, onsubmit, usageReporter: { reportUsage } },
317
+ });
318
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Deluxe');
319
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
320
+ await Promise.resolve();
321
+ expect(reportUsage).not.toHaveBeenCalled();
322
+ view.unmount();
323
+ }
324
+ });
325
+ it('marks a count-only text default as matched after persistence', async () => {
326
+ const policyWithTextDefault = {
327
+ ...policy,
328
+ fields: {
329
+ ...policy.fields,
330
+ name: {
331
+ ...policy.fields.name,
332
+ hasDefault: true,
333
+ defaultValue: 'Default product name',
334
+ },
335
+ },
336
+ };
337
+ const reportUsage = vi.fn();
338
+ render(ObjectFormActionsFixture, {
339
+ props: {
340
+ fields,
341
+ policy: policyWithTextDefault,
342
+ onsubmit: () => true,
343
+ usageReporter: { reportUsage },
344
+ },
345
+ });
346
+ await tick();
347
+ await userEvent.click(screen.getByRole('button', { name: 'Save product' }));
348
+ expect(reportUsage).toHaveBeenCalledWith({
349
+ entries: [
350
+ {
351
+ objectRef: '@test:Product',
352
+ fieldName: 'name',
353
+ matchedDefault: true,
354
+ },
355
+ ],
356
+ });
357
+ });
358
+ it('does not report when the host cancels, omits success, rejects, or local validation fails', async () => {
359
+ let attempt = 0;
360
+ const onsubmit = vi.fn(async () => {
361
+ attempt++;
362
+ if (attempt === 1)
363
+ return undefined;
364
+ if (attempt === 2)
365
+ return false;
366
+ throw new Error('persistence failed');
367
+ });
368
+ const reportUsage = vi.fn();
369
+ render(ObjectFormActionsFixture, {
370
+ props: { fields, policy, onsubmit, usageReporter: { reportUsage } },
371
+ });
372
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Deluxe');
373
+ const submit = screen.getByRole('button', { name: 'Save product' });
374
+ await userEvent.click(submit);
375
+ await userEvent.click(submit);
376
+ await userEvent.click(submit);
377
+ await vi.waitFor(() => expect(onsubmit).toHaveBeenCalledTimes(3));
378
+ expect(reportUsage).not.toHaveBeenCalled();
379
+ const metadata = screen.getByRole('textbox', {
380
+ name: 'Metadata',
381
+ });
382
+ metadata.value = '{not json}';
383
+ await fireEvent.input(metadata);
384
+ await userEvent.click(submit);
385
+ expect(onsubmit).toHaveBeenCalledTimes(3);
386
+ expect(reportUsage).not.toHaveBeenCalled();
387
+ });
257
388
  it('is axe-clean for its generated basic form', async () => {
258
389
  const { container } = render(ObjectFormFixture, {
259
390
  props: { fields, policy },
@@ -0,0 +1,140 @@
1
+ // @vitest-environment jsdom
2
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import FieldPolicySuggestionQueue from '../components/FieldPolicySuggestionQueue.svelte';
5
+ import { parsePendingFieldPolicySuggestions, } from '../suggestions.js';
6
+ import { collectFieldUsageEntries, reportFieldUsage, } from '../usage-capture.js';
7
+ import SuggestionGearFixture from './fixtures/SuggestionGearFixture.svelte';
8
+ const pending = {
9
+ suggestions: [
10
+ {
11
+ id: 'suggestion-1',
12
+ objectRef: '@test:Widget',
13
+ fieldName: 'status',
14
+ kind: 'promote',
15
+ proposedValue: null,
16
+ evidence: { summary: '6 users set this field 12 times.' },
17
+ status: 'pending',
18
+ },
19
+ ],
20
+ total: 1,
21
+ };
22
+ function suggestionAdapter() {
23
+ return {
24
+ pendingSuggestions: vi.fn(async () => pending),
25
+ acceptSuggestion: vi.fn(async () => undefined),
26
+ dismissSuggestion: vi.fn(async () => undefined),
27
+ };
28
+ }
29
+ const editorAdapter = {
30
+ load: async () => ({
31
+ capabilities: { manage: true, personalize: false },
32
+ personalLowerDefaultUsable: {},
33
+ policy: { objectRef: '@test:Widget', fields: {}, layers: {} },
34
+ rows: { app: [], tenant: [], user: [] },
35
+ }),
36
+ create: async () => undefined,
37
+ update: async () => undefined,
38
+ delete: async () => undefined,
39
+ };
40
+ describe('field usage capture', () => {
41
+ it('keeps free-form values out of browser telemetry while retaining count signals', () => {
42
+ expect(collectFieldUsageEntries({
43
+ objectRef: '@test:Widget',
44
+ fields: {
45
+ title: 'text',
46
+ enabled: 'boolean',
47
+ count: 'integer',
48
+ ownerId: 'foreignKey',
49
+ metadata: 'json',
50
+ },
51
+ values: {
52
+ title: 'A title',
53
+ enabled: false,
54
+ count: 0,
55
+ ownerId: '4f552b7e-88a4-499a-9ce4-6a88bec3b439',
56
+ metadata: { email: 'member@example.test' },
57
+ omitted: 'not rendered',
58
+ },
59
+ defaults: {
60
+ title: { hasDefault: true, defaultValue: 'A title' },
61
+ count: { hasDefault: true, defaultValue: 1 },
62
+ metadata: {
63
+ hasDefault: true,
64
+ defaultValue: { email: 'member@example.test' },
65
+ },
66
+ },
67
+ })).toEqual([
68
+ {
69
+ objectRef: '@test:Widget',
70
+ fieldName: 'title',
71
+ matchedDefault: true,
72
+ },
73
+ { objectRef: '@test:Widget', fieldName: 'enabled', value: false },
74
+ { objectRef: '@test:Widget', fieldName: 'count' },
75
+ {
76
+ objectRef: '@test:Widget',
77
+ fieldName: 'ownerId',
78
+ value: '4f552b7e-88a4-499a-9ce4-6a88bec3b439',
79
+ },
80
+ {
81
+ objectRef: '@test:Widget',
82
+ fieldName: 'metadata',
83
+ matchedDefault: true,
84
+ },
85
+ ]);
86
+ });
87
+ it('contains synchronous and asynchronous reporter failures outside the submit path', async () => {
88
+ const sync = {
89
+ reportUsage: () => {
90
+ throw new Error('offline');
91
+ },
92
+ };
93
+ const rejected = {
94
+ reportUsage: async () => {
95
+ throw new Error('offline');
96
+ },
97
+ };
98
+ expect(() => reportFieldUsage(sync, [
99
+ { objectRef: '@test:Widget', fieldName: 'title' },
100
+ ])).not.toThrow();
101
+ expect(() => reportFieldUsage(rejected, [
102
+ { objectRef: '@test:Widget', fieldName: 'title' },
103
+ ])).not.toThrow();
104
+ await Promise.resolve();
105
+ });
106
+ });
107
+ describe('field policy suggestion review', () => {
108
+ it('fails closed for malformed generated-action output', () => {
109
+ expect(() => parsePendingFieldPolicySuggestions({ suggestions: [], total: '1' })).toThrow(/Unable to load pending/i);
110
+ expect(() => parsePendingFieldPolicySuggestions({
111
+ suggestions: [{ id: 'missing fields' }],
112
+ total: 1,
113
+ })).toThrow(/Unable to load pending/i);
114
+ });
115
+ it('renders an accessible reviewed queue and refreshes after approve or dismiss', async () => {
116
+ const adapter = suggestionAdapter();
117
+ const { container } = render(FieldPolicySuggestionQueue, {
118
+ props: { adapter, objectRefs: ['@test:Widget'] },
119
+ });
120
+ await vi.waitFor(() => expect(screen.getByText(/6 users set/i)).toBeVisible());
121
+ expect(screen.getByRole('heading', { name: 'Pending field suggestions' })).toBeVisible();
122
+ await userEvent.click(screen.getByRole('button', { name: 'Approve' }));
123
+ await vi.waitFor(() => expect(adapter.acceptSuggestion).toHaveBeenCalledWith({
124
+ id: 'suggestion-1',
125
+ }));
126
+ expect(adapter.pendingSuggestions).toHaveBeenCalledWith({
127
+ objectRefs: ['@test:Widget'],
128
+ });
129
+ await expectNoA11yViolations(container);
130
+ });
131
+ it('announces a pending-suggestion count on the policy gear without exposing suggestion details', async () => {
132
+ render(SuggestionGearFixture, {
133
+ props: { adapter: editorAdapter, suggestionAdapter: suggestionAdapter() },
134
+ });
135
+ await vi.waitFor(() => expect(screen.getByRole('button', {
136
+ name: 'Field settings, 1 pending suggestion',
137
+ })).toBeVisible());
138
+ expect(screen.getByText('1')).toHaveAttribute('aria-hidden', 'true');
139
+ });
140
+ });
@@ -1,19 +1,36 @@
1
1
  <script lang="ts">
2
2
  import type { ResolvedObjectFieldPolicy } from '../../../types.js';
3
- import ObjectForm from '../../components/ObjectForm.svelte';
3
+ import ObjectForm, {
4
+ type ObjectFormProps,
5
+ } from '../../components/ObjectForm.svelte';
4
6
  import type { ObjectFormFieldDefinition } from '../../types.js';
7
+ import type { FieldUsageReporter } from '../../usage-capture.js';
5
8
 
6
9
  interface Props {
7
10
  fields: Record<string, ObjectFormFieldDefinition>;
8
11
  policy: ResolvedObjectFieldPolicy;
9
- onsubmit: (event: SubmitEvent) => void;
12
+ onsubmit: NonNullable<ObjectFormProps['onsubmit']>;
13
+ usageReporter?: FieldUsageReporter;
14
+ resetAfterSuccessfulSubmit?: boolean;
10
15
  }
11
16
 
12
- let { fields, policy, onsubmit }: Props = $props();
17
+ let {
18
+ fields,
19
+ policy,
20
+ onsubmit,
21
+ usageReporter,
22
+ resetAfterSuccessfulSubmit = false,
23
+ }: Props = $props();
13
24
  let record = $state<Record<string, unknown>>({});
25
+
26
+ async function submit(event: SubmitEvent): Promise<boolean | undefined> {
27
+ const result = await onsubmit(event);
28
+ if (result === true && resetAfterSuccessfulSubmit) record = {};
29
+ return result === true ? true : undefined;
30
+ }
14
31
  </script>
15
32
 
16
- <ObjectForm objectRef={policy.objectRef} {fields} {policy} bind:value={record} {onsubmit}>
33
+ <ObjectForm objectRef={policy.objectRef} {fields} {policy} bind:value={record} onsubmit={submit} {usageReporter}>
17
34
  {#snippet actions()}
18
35
  <button type="submit">Save product</button>
19
36
  {/snippet}
@@ -1,9 +1,13 @@
1
1
  import type { ResolvedObjectFieldPolicy } from '../../../types.js';
2
+ import { type ObjectFormProps } from '../../components/ObjectForm.svelte';
2
3
  import type { ObjectFormFieldDefinition } from '../../types.js';
4
+ import type { FieldUsageReporter } from '../../usage-capture.js';
3
5
  interface Props {
4
6
  fields: Record<string, ObjectFormFieldDefinition>;
5
7
  policy: ResolvedObjectFieldPolicy;
6
- onsubmit: (event: SubmitEvent) => void;
8
+ onsubmit: NonNullable<ObjectFormProps['onsubmit']>;
9
+ usageReporter?: FieldUsageReporter;
10
+ resetAfterSuccessfulSubmit?: boolean;
7
11
  }
8
12
  declare const ObjectFormActionsFixture: import("svelte").Component<Props, {}, "">;
9
13
  type ObjectFormActionsFixture = ReturnType<typeof ObjectFormActionsFixture>;
@@ -1 +1 @@
1
- {"version":3,"file":"ObjectFormActionsFixture.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAEnE,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAGhE,UAAU,KAAK;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC;IAClD,MAAM,EAAE,yBAAyB,CAAC;IAClC,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CACxC;AAmBD,QAAA,MAAM,wBAAwB,2CAAwC,CAAC;AACvE,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAC5E,eAAe,wBAAwB,CAAC"}
1
+ {"version":3,"file":"ObjectFormActionsFixture.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/__tests__/fixtures/ObjectFormActionsFixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAmB,EACjB,KAAK,eAAe,EACrB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAGjE,UAAU,KAAK;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC;IAClD,MAAM,EAAE,yBAAyB,CAAC;IAClC,QAAQ,EAAE,WAAW,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,CAAC;IACnD,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC,0BAA0B,CAAC,EAAE,OAAO,CAAC;CACtC;AAgCD,QAAA,MAAM,wBAAwB,2CAAwC,CAAC;AACvE,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAC5E,eAAe,wBAAwB,CAAC"}
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import FieldPolicyGearButton from '../../components/FieldPolicyGearButton.svelte';
3
+ import FieldPolicyGearProvider from '../../components/FieldPolicyGearProvider.svelte';
4
+ import type { FieldPolicyEditorAdapter } from '../../field-policy-editor.js';
5
+ import type { FieldPolicySuggestionAdapter } from '../../suggestions.js';
6
+
7
+ let {
8
+ adapter,
9
+ suggestionAdapter,
10
+ }: {
11
+ adapter: FieldPolicyEditorAdapter;
12
+ suggestionAdapter: FieldPolicySuggestionAdapter;
13
+ } = $props();
14
+ </script>
15
+
16
+ <FieldPolicyGearProvider
17
+ objectRef="@test:Widget"
18
+ fields={{ title: { type: 'text' } }}
19
+ {adapter}
20
+ {suggestionAdapter}
21
+ >
22
+ <FieldPolicyGearButton />
23
+ </FieldPolicyGearProvider>
@@ -0,0 +1,10 @@
1
+ import type { FieldPolicyEditorAdapter } from '../../field-policy-editor.js';
2
+ import type { FieldPolicySuggestionAdapter } from '../../suggestions.js';
3
+ type $$ComponentProps = {
4
+ adapter: FieldPolicyEditorAdapter;
5
+ suggestionAdapter: FieldPolicySuggestionAdapter;
6
+ };
7
+ declare const SuggestionGearFixture: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type SuggestionGearFixture = ReturnType<typeof SuggestionGearFixture>;
9
+ export default SuggestionGearFixture;
10
+ //# sourceMappingURL=SuggestionGearFixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SuggestionGearFixture.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/__tests__/fixtures/SuggestionGearFixture.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC;AAExE,KAAK,gBAAgB,GAAI;IACxB,OAAO,EAAE,wBAAwB,CAAC;IAClC,iBAAiB,EAAE,4BAA4B,CAAC;CACjD,CAAC;AAmBF,QAAA,MAAM,qBAAqB,sDAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Playground smoke tests (#2272).
4
+ *
5
+ * Registering `@happyvertical/smrt-fields/playground` publishes EVERY entry in
6
+ * the module, and `PlaygroundHost` mounts the selected one bare — no
7
+ * `<svelte:boundary>` around it. So a preview that throws on mount is not a
8
+ * contained failure the host can route around; a host cannot offer the sound
9
+ * entry without also offering one that breaks the page.
10
+ *
11
+ * That is exactly how #2272 shipped: the `Policy-Driven Form` preview rendered
12
+ * `<FormHelp>` outside its `<FieldPolicyProvider>`, and no unit test could see
13
+ * it, because `FormHelp`'s own tests always mount it through a provider
14
+ * fixture — they exercise the component's contract, never the preview's
15
+ * composition.
16
+ *
17
+ * These tests therefore load and MOUNT every exported entry the way
18
+ * `PlaygroundHost` does — one mount per declared mode, with the entry props and
19
+ * mode props merged (`PlaygroundHost.svelte`). Nothing wraps the component, so
20
+ * a preview that needs an ancestor provider must supply its own.
21
+ *
22
+ * Structural types below: this package must not depend on
23
+ * `@happyvertical/smrt-playground`, so the module shape is asserted, not
24
+ * imported.
25
+ */
26
+ import { cleanup, render } from '@happyvertical/smrt-vitest/svelte';
27
+ import { afterEach, describe, expect, it } from 'vitest';
28
+ import playground from '../playground.js';
29
+ const entries = playground.entries;
30
+ afterEach(() => {
31
+ cleanup();
32
+ });
33
+ describe('smrt-fields playground module', () => {
34
+ it('publishes the field-policy previews', () => {
35
+ expect(playground.packageName).toBe('@happyvertical/smrt-fields');
36
+ expect(entries.map((entry) => entry.id)).toEqual([
37
+ 'policy-form',
38
+ 'object-form',
39
+ ]);
40
+ for (const entry of entries) {
41
+ expect(entry.loadComponent).toEqual(expect.any(Function));
42
+ expect(entry.modes).toHaveProperty('mock');
43
+ }
44
+ });
45
+ // One case per entry/mode so a failure names the broken preview.
46
+ for (const entry of entries) {
47
+ for (const [mode, config] of Object.entries(entry.modes ?? {})) {
48
+ it(`mounts the "${entry.title}" preview in ${mode} mode`, async () => {
49
+ const loaded = await entry.loadComponent();
50
+ const component = typeof loaded === 'function'
51
+ ? loaded
52
+ : loaded.default;
53
+ expect(component).toEqual(expect.any(Function));
54
+ // Mirror PlaygroundHost: entry props, then mode props. Nothing else.
55
+ const props = {
56
+ ...(entry.props ?? {}),
57
+ ...(config === true ? {} : (config.props ?? {})),
58
+ };
59
+ const { container } = render(component, {
60
+ props: props,
61
+ });
62
+ expect(container.textContent?.trim()).not.toBe('');
63
+ });
64
+ }
65
+ }
66
+ });
@@ -29,7 +29,9 @@ import {
29
29
  orgRowIdsForObject,
30
30
  prunableDriftRows,
31
31
  } from '../settings-catalog.js';
32
+ import type { FieldPolicySuggestionAdapter } from '../suggestions.js';
32
33
  import FieldPolicyEditor from './FieldPolicyEditor.svelte';
34
+ import FieldPolicySuggestionQueue from './FieldPolicySuggestionQueue.svelte';
33
35
 
34
36
  export interface FieldPolicyCatalogComponentProps {
35
37
  page: FieldPolicySettingsCatalogPage;
@@ -48,6 +50,8 @@ export interface FieldPolicyControlPanelProps {
48
50
  heading?: string;
49
51
  onchanged?: () => void;
50
52
  confirmAction?: (message: string) => boolean | Promise<boolean>;
53
+ /** Optional reviewed-suggestion queue transport. */
54
+ suggestionAdapter?: FieldPolicySuggestionAdapter;
51
55
  }
52
56
 
53
57
  let {
@@ -59,6 +63,7 @@ let {
59
63
  heading = 'Field settings',
60
64
  onchanged,
61
65
  confirmAction,
66
+ suggestionAdapter,
62
67
  }: FieldPolicyControlPanelProps = $props();
63
68
 
64
69
  // Before client effects run (including SSR), render the server snapshot.
@@ -70,6 +75,7 @@ let loadingEditor = $state(false);
70
75
  let busy = $state(false);
71
76
  let error = $state<string | null>(null);
72
77
  let generation = 0;
78
+ let auditGeneration = 0;
73
79
 
74
80
  const Catalog = $derived(catalog);
75
81
  const visibleAudit = $derived(audit ?? data.audit);
@@ -83,6 +89,9 @@ const preservedParams = $derived(fieldPolicyCatalogPreservedParams(data));
83
89
 
84
90
  $effect(() => {
85
91
  data;
92
+ // A prop snapshot supersedes every in-flight mutation refresh. Without this
93
+ // invalidation, an older response can overwrite newer SSR/navigation data.
94
+ auditGeneration++;
86
95
  audit = data.audit;
87
96
  editorState = null;
88
97
  editorOpen = false;
@@ -114,11 +123,15 @@ async function loadEditor(): Promise<void> {
114
123
  }
115
124
 
116
125
  async function refreshAudit(): Promise<boolean> {
126
+ const token = ++auditGeneration;
117
127
  const refs = auditObjectRefs(data.page);
118
128
  try {
119
- audit = await adapter.loadAudit({ ...refs, includeDrift: true });
129
+ const refreshed = await adapter.loadAudit({ ...refs, includeDrift: true });
130
+ if (token !== auditGeneration) return false;
131
+ audit = refreshed;
120
132
  return true;
121
133
  } catch (cause) {
134
+ if (token !== auditGeneration) return false;
122
135
  error =
123
136
  cause instanceof Error
124
137
  ? cause.message
@@ -195,6 +208,13 @@ function valueText(cell: FieldPolicyLayerCell): string {
195
208
  return String(cell.defaultValue);
196
209
  }
197
210
  }
211
+
212
+ async function suggestionChanged(): Promise<void> {
213
+ if (await refreshAudit()) {
214
+ await loadEditor();
215
+ onchanged?.();
216
+ }
217
+ }
198
218
  </script>
199
219
 
200
220
  {#if canManage}
@@ -234,6 +254,13 @@ function valueText(cell: FieldPolicyLayerCell): string {
234
254
  onmutated={editorMutated}
235
255
  />
236
256
  {/if}
257
+ {#if suggestionAdapter}
258
+ <FieldPolicySuggestionQueue
259
+ adapter={suggestionAdapter}
260
+ objectRefs={auditObjectRefs(data.page).objectRefs}
261
+ onchanged={suggestionChanged}
262
+ />
263
+ {/if}
237
264
  {#if prunableDriftRows(visibleAudit).length}
238
265
  <section class="field-policy-control-panel__drift" aria-label="Manifest drift">
239
266
  <h2>Manifest drift</h2>
@@ -1,6 +1,7 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
2
  import type { FieldPolicyDetailItem, FieldPolicySettingsCatalogData, FieldPolicySettingsCatalogPage } from '../../settings-catalog.js';
3
3
  import { type FieldPolicyControlPanelAdapter } from '../settings-catalog.js';
4
+ import type { FieldPolicySuggestionAdapter } from '../suggestions.js';
4
5
  export interface FieldPolicyCatalogComponentProps {
5
6
  page: FieldPolicySettingsCatalogPage;
6
7
  baseUrl: string;
@@ -19,6 +20,8 @@ export interface FieldPolicyControlPanelProps {
19
20
  heading?: string;
20
21
  onchanged?: () => void;
21
22
  confirmAction?: (message: string) => boolean | Promise<boolean>;
23
+ /** Optional reviewed-suggestion queue transport. */
24
+ suggestionAdapter?: FieldPolicySuggestionAdapter;
22
25
  }
23
26
  declare const FieldPolicyControlPanel: Component<FieldPolicyControlPanelProps, {}, "">;
24
27
  type FieldPolicyControlPanel = ReturnType<typeof FieldPolicyControlPanel>;
@@ -1 +1 @@
1
- {"version":3,"file":"FieldPolicyControlPanel.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/FieldPolicyControlPanel.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,8BAA8B,EAC/B,MAAM,2BAA2B,CAAC;AASnC,OAAO,EAGL,KAAK,8BAA8B,EAMpC,MAAM,wBAAwB,CAAC;AAIhC,MAAM,WAAW,gCAAgC;IAC/C,IAAI,EAAE,8BAA8B,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC,CAAC;QAAE,IAAI,EAAE,qBAAqB,CAAA;KAAE,CAAC,CAAC,CAAC;IACnD,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,4BAA4B;IAC3C,IAAI,EAAE,8BAA8B,CAAC;IACrC,OAAO,EAAE,8BAA8B,CAAC;IACxC,OAAO,EAAE,SAAS,CAAC,gCAAgC,CAAC,CAAC;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACjE;AAiND,QAAA,MAAM,uBAAuB,iDAAwC,CAAC;AACtE,KAAK,uBAAuB,GAAG,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAC1E,eAAe,uBAAuB,CAAC"}
1
+ {"version":3,"file":"FieldPolicyControlPanel.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/FieldPolicyControlPanel.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,8BAA8B,EAC/B,MAAM,2BAA2B,CAAC;AASnC,OAAO,EAGL,KAAK,8BAA8B,EAMpC,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AAKtE,MAAM,WAAW,gCAAgC;IAC/C,IAAI,EAAE,8BAA8B,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC,CAAC;QAAE,IAAI,EAAE,qBAAqB,CAAA;KAAE,CAAC,CAAC,CAAC;IACnD,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,4BAA4B;IAC3C,IAAI,EAAE,8BAA8B,CAAC;IACrC,OAAO,EAAE,8BAA8B,CAAC;IACxC,OAAO,EAAE,SAAS,CAAC,gCAAgC,CAAC,CAAC;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChE,oDAAoD;IACpD,iBAAiB,CAAC,EAAE,4BAA4B,CAAC;CAClD;AAsOD,QAAA,MAAM,uBAAuB,iDAAwC,CAAC;AACtE,KAAK,uBAAuB,GAAG,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAC1E,eAAe,uBAAuB,CAAC"}
@@ -10,8 +10,15 @@ const gear = getFieldPolicyGearContext();
10
10
  </script>
11
11
 
12
12
  {#if gear.canEdit}
13
- <Button type="button" variant="ghost" class={className} aria-haspopup="dialog" onclick={() => gear.show()}>
13
+ <Button type="button" variant="ghost" class={className} aria-haspopup="dialog" aria-label={gear.pendingSuggestionCount > 0 ? `${label}, ${gear.pendingSuggestionCount} pending suggestion${gear.pendingSuggestionCount === 1 ? '' : 's'}` : label} onclick={() => gear.show()}>
14
14
  <span aria-hidden="true">⚙</span>
15
15
  <span>{label}</span>
16
+ {#if gear.pendingSuggestionCount > 0}
17
+ <span class="field-policy-gear-button__badge" aria-hidden="true">{gear.pendingSuggestionCount}</span>
18
+ {/if}
16
19
  </Button>
17
20
  {/if}
21
+
22
+ <style>
23
+ .field-policy-gear-button__badge { border-radius: 999px; background: var(--smrt-color-secondary-container, #e8def8); min-inline-size: 1.25rem; padding-inline: 0.25rem; text-align: center; }
24
+ </style>
@@ -1 +1 @@
1
- {"version":3,"file":"FieldPolicyGearButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/FieldPolicyGearButton.svelte.ts"],"names":[],"mappings":"AAMC,KAAK,gBAAgB,GAAI;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAqB7D,QAAA,MAAM,qBAAqB,sDAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"FieldPolicyGearButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/FieldPolicyGearButton.svelte.ts"],"names":[],"mappings":"AAMC,KAAK,gBAAgB,GAAI;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AA0B7D,QAAA,MAAM,qBAAqB,sDAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
@@ -12,6 +12,10 @@ import {
12
12
  setFieldPolicyGearContext,
13
13
  } from '../gear-context.svelte.js';
14
14
  import type { FieldInputRegistry } from '../input-registry.js';
15
+ import {
16
+ type FieldPolicySuggestionAdapter,
17
+ parsePendingFieldPolicySuggestions,
18
+ } from '../suggestions.js';
15
19
  import type { ObjectFormFieldDefinition } from '../types.js';
16
20
  import FieldPolicyEditor from './FieldPolicyEditor.svelte';
17
21
 
@@ -22,6 +26,8 @@ interface Props {
22
26
  inputRegistry?: FieldInputRegistry;
23
27
  /** Hosts in an app-wide context pass app; tenant hosts retain the default. */
24
28
  organizationScope?: FieldPolicyOrganizationScope;
29
+ /** Optional reviewed-suggestion transport for the gear badge. */
30
+ suggestionAdapter?: FieldPolicySuggestionAdapter;
25
31
  children?: Snippet;
26
32
  }
27
33
 
@@ -31,6 +37,7 @@ let {
31
37
  adapter,
32
38
  inputRegistry,
33
39
  organizationScope = 'tenant',
40
+ suggestionAdapter,
34
41
  children,
35
42
  }: Props = $props();
36
43
 
@@ -38,6 +45,7 @@ let editorState = $state<FieldPolicyEditorState | null>(null);
38
45
  let error = $state<string | null>(null);
39
46
  let open = $state(false);
40
47
  let loading = $state(false);
48
+ let pendingSuggestionCount = $state(0);
41
49
 
42
50
  const controller: FieldPolicyGearController = {
43
51
  get state() {
@@ -55,6 +63,9 @@ const controller: FieldPolicyGearController = {
55
63
  (editorState.capabilities.manage || editorState.capabilities.personalize)
56
64
  );
57
65
  },
66
+ get pendingSuggestionCount() {
67
+ return pendingSuggestionCount;
68
+ },
58
69
  show() {
59
70
  if (this.canEdit) open = true;
60
71
  },
@@ -104,16 +115,39 @@ async function reload(generation = ++loadGeneration): Promise<void> {
104
115
  }
105
116
  }
106
117
 
118
+ async function reloadPendingSuggestions(
119
+ generation = ++suggestionGeneration,
120
+ ): Promise<void> {
121
+ if (!suggestionAdapter) {
122
+ pendingSuggestionCount = 0;
123
+ return;
124
+ }
125
+ try {
126
+ const result = parsePendingFieldPolicySuggestions(
127
+ await suggestionAdapter.pendingSuggestions({ objectRefs: [objectRef] }),
128
+ );
129
+ if (generation === suggestionGeneration) {
130
+ pendingSuggestionCount = result.total;
131
+ }
132
+ } catch {
133
+ // A metrics/read failure must not suppress ordinary policy editing.
134
+ if (generation === suggestionGeneration) pendingSuggestionCount = 0;
135
+ }
136
+ }
137
+
107
138
  let loadGeneration = 0;
139
+ let suggestionGeneration = 0;
108
140
  $effect(() => {
109
141
  const generation = ++loadGeneration;
110
142
  editorState = null;
111
143
  error = null;
112
144
  open = false;
113
145
  void reload(generation);
146
+ void reloadPendingSuggestions();
114
147
  return () => {
115
148
  // A delayed transport response must never repopulate a prior object.
116
149
  if (loadGeneration === generation) loadGeneration++;
150
+ suggestionGeneration++;
117
151
  };
118
152
  });
119
153
  </script>