From 402ce5eee48ac761acb8ba78765d773e2090f992 Mon Sep 17 00:00:00 2001 From: Brett Florio Date: Thu, 18 Jun 2026 11:11:29 -0700 Subject: [PATCH 1/3] feat(DataRetentionSettingsForm): per-store data retention settings element foxy-data-retention-settings-form edits fx:data_retention_settings: a toggle (auto_anonymize) and a conditional days-of-inactivity field with a 90-day minimum (v8n + min). Mirrors CustomerPortalSettingsForm (PATCH-only settings singleton: delete/timestamps hidden). Includes i18n, tests, and a story. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DataRetentionSettingsForm.stories.ts | 25 ++++ .../DataRetentionSettingsForm.test.ts | 118 ++++++++++++++++++ .../DataRetentionSettingsForm.ts | 65 ++++++++++ .../public/DataRetentionSettingsForm/index.ts | 10 ++ .../public/DataRetentionSettingsForm/types.ts | 4 + src/elements/public/index.ts | 1 + .../data-retention-settings-form/en.json | 52 ++++++++ 7 files changed, 275 insertions(+) create mode 100644 src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts create mode 100644 src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts create mode 100644 src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts create mode 100644 src/elements/public/DataRetentionSettingsForm/index.ts create mode 100644 src/elements/public/DataRetentionSettingsForm/types.ts create mode 100644 src/static/translations/data-retention-settings-form/en.json diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts new file mode 100644 index 000000000..9cc0787f3 --- /dev/null +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts @@ -0,0 +1,25 @@ +import './index'; + +import { Summary } from '../../../storygen/Summary'; +import { getMeta } from '../../../storygen/getMeta'; +import { getStory } from '../../../storygen/getStory'; + +const summary: Summary = { + href: 'https://demo.api/hapi/data_retention_settings/0', + parent: 'https://demo.api/hapi/data_retention_settings', + nucleon: true, + localName: 'foxy-data-retention-settings-form', + translatable: true, + configurable: {}, +}; + +export default getMeta(summary); + +export const Playground = getStory({ ...summary, code: true }); +export const Empty = getStory(summary); +export const Error = getStory(summary); +export const Busy = getStory(summary); + +Empty.args.href = ''; +Error.args.href = 'https://demo.api/virtual/empty?status=404'; +Busy.args.href = 'https://demo.api/virtual/stall'; diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts new file mode 100644 index 000000000..f4e13aa6c --- /dev/null +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts @@ -0,0 +1,118 @@ +import './index'; + +import { expect, fixture, html } from '@open-wc/testing'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { DataRetentionSettingsForm as Form } from './DataRetentionSettingsForm'; + +describe('DataRetentionSettingsForm', () => { + it('imports and registers foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; + }); + + it('imports and registers foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; + }); + + it('imports and registers foxy-internal-number-control', () => { + expect(customElements.get('foxy-internal-number-control')).to.exist; + }); + + it('imports and registers foxy-internal-form', () => { + expect(customElements.get('foxy-internal-form')).to.exist; + }); + + it('registers as foxy-data-retention-settings-form', () => { + expect(customElements.get('foxy-data-retention-settings-form')).to.equal(Form); + }); + + it('extends InternalForm', () => { + expect(new Form()).to.be.instanceOf(InternalForm); + }); + + it('has a default i18n namespace of "data-retention-settings-form"', () => { + expect(Form).to.have.property('defaultNS', 'data-retention-settings-form'); + }); + + describe('v8n', () => { + it('allows an unset auto_anonymize_days', () => { + const element = new Form(); + expect(element.errors).to.not.include('auto-anonymize-days:v8n_too_small'); + expect(element.errors).to.not.include('auto-anonymize-days:v8n_required'); + }); + + it('produces "auto-anonymize-days:v8n_too_small" when below 90', () => { + const element = new Form(); + element.edit({ auto_anonymize_days: 89 }); + expect(element.errors).to.include('auto-anonymize-days:v8n_too_small'); + + element.edit({ auto_anonymize_days: 90 }); + expect(element.errors).to.not.include('auto-anonymize-days:v8n_too_small'); + }); + + it('requires days when auto_anonymize is enabled', () => { + const element = new Form(); + element.edit({ auto_anonymize: true }); + expect(element.errors).to.include('auto-anonymize-days:v8n_required'); + + element.edit({ auto_anonymize_days: 365 }); + expect(element.errors).to.not.include('auto-anonymize-days:v8n_required'); + }); + }); + + describe('hiddenSelector', () => { + it('always hides delete and timestamps', async () => { + const element = await fixture
( + html`` + ); + + expect(element.hiddenSelector.matches('delete', true)).to.be.true; + expect(element.hiddenSelector.matches('timestamps', true)).to.be.true; + }); + + it('hides the days field when auto_anonymize is off', async () => { + const element = await fixture( + html`` + ); + + element.edit({ auto_anonymize: false }); + expect(element.hiddenSelector.matches('general:auto-anonymize-days', true)).to.be.true; + }); + + it('shows the days field when auto_anonymize is on', async () => { + const element = await fixture( + html`` + ); + + element.edit({ auto_anonymize: true }); + expect(element.hiddenSelector.matches('general:auto-anonymize-days', true)).to.be.false; + }); + }); + + it('renders a switch control for auto_anonymize', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer="auto-anonymize"]' + ); + + expect(control).to.exist; + }); + + it('renders a number control for auto_anonymize_days when enabled', async () => { + const element = await fixture( + html`` + ); + + element.edit({ auto_anonymize: true }); + await element.requestUpdate(); + + const control = element.renderRoot.querySelector( + 'foxy-internal-number-control[infer="auto-anonymize-days"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('min', '90'); + }); +}); diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts new file mode 100644 index 000000000..f81501a30 --- /dev/null +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts @@ -0,0 +1,65 @@ +import type { TemplateResult } from 'lit-html'; +import type { NucleonV8N } from '../NucleonElement/types'; +import type { Data } from './types'; + +import { TranslatableMixin } from '../../../mixins/translatable'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { html } from 'lit-html'; + +const NS = 'data-retention-settings-form'; +const Base = TranslatableMixin(InternalForm, NS); + +/** + * Form element for editing `fx:data_retention_settings` resources. + * + * Per-store auto-anonymization config: a toggle and a "days of inactivity" + * value with a hard 90-day minimum (enforced client-side and by the API). + * + * @element foxy-data-retention-settings-form + * @since 1.52.0 + */ +export class DataRetentionSettingsForm extends Base { + static get v8n(): NucleonV8N { + return [ + ({ auto_anonymize_days: v }) => { + return ( + v === null || + v === undefined || + (Number.isInteger(v) && v >= 90) || + 'auto-anonymize-days:v8n_too_small' + ); + }, + ({ auto_anonymize: enabled, auto_anonymize_days: v }) => { + return !enabled || (typeof v === 'number' && v >= 90) || 'auto-anonymize-days:v8n_required'; + }, + ]; + } + + get hiddenSelector(): BooleanSelector { + // No DELETE route for this resource, and it carries no timestamps. + const alwaysMatch = ['delete', 'timestamps', super.hiddenSelector.toString()]; + // The "days" field only applies when auto-anonymization is on. + if (!this.form.auto_anonymize) alwaysMatch.unshift('general:auto-anonymize-days'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + renderBody(): TemplateResult { + return html` + + + + + + + + + ${super.renderBody()} + `; + } +} diff --git a/src/elements/public/DataRetentionSettingsForm/index.ts b/src/elements/public/DataRetentionSettingsForm/index.ts new file mode 100644 index 000000000..04d412a4d --- /dev/null +++ b/src/elements/public/DataRetentionSettingsForm/index.ts @@ -0,0 +1,10 @@ +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalNumberControl/index'; +import '../../internal/InternalForm/index'; + +import { DataRetentionSettingsForm } from './DataRetentionSettingsForm'; + +customElements.define('foxy-data-retention-settings-form', DataRetentionSettingsForm); + +export { DataRetentionSettingsForm }; diff --git a/src/elements/public/DataRetentionSettingsForm/types.ts b/src/elements/public/DataRetentionSettingsForm/types.ts new file mode 100644 index 000000000..e97f24df7 --- /dev/null +++ b/src/elements/public/DataRetentionSettingsForm/types.ts @@ -0,0 +1,4 @@ +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +export type Data = Resource; diff --git a/src/elements/public/index.ts b/src/elements/public/index.ts index 84537ddb8..ebc8a9896 100644 --- a/src/elements/public/index.ts +++ b/src/elements/public/index.ts @@ -35,6 +35,7 @@ export { CustomerPortalSettingsForm } from './CustomerPortalSettingsForm/Custome export { CustomersTable } from './CustomersTable/CustomersTable'; export { CustomFieldCard } from './CustomFieldCard/CustomFieldCard'; export { CustomFieldForm } from './CustomFieldForm/CustomFieldForm'; +export { DataRetentionSettingsForm } from './DataRetentionSettingsForm/DataRetentionSettingsForm'; export { DiscountBuilder } from './DiscountBuilder/DiscountBuilder'; export { DiscountCard } from './DiscountCard/DiscountCard'; export { DiscountDetailCard } from './DiscountDetailCard/DiscountDetailCard'; diff --git a/src/static/translations/data-retention-settings-form/en.json b/src/static/translations/data-retention-settings-form/en.json new file mode 100644 index 000000000..a64b84cd5 --- /dev/null +++ b/src/static/translations/data-retention-settings-form/en.json @@ -0,0 +1,52 @@ +{ + "header": { + "title_existing": "Data retention", + "title_new": "Data retention", + "subtitle": "Automatic anonymization of inactive customers", + "copy-id": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy ID", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "copy-json": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy source as JSON", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, + "general": { + "label": "", + "helper_text": "", + "auto-anonymize": { + "label": "Auto-anonymize inactive customers", + "helper_text": "When enabled, customers with no activity for the configured number of days are automatically and irreversibly anonymized." + }, + "auto-anonymize-days": { + "label": "Days of inactivity", + "placeholder": "365", + "helper_text": "Minimum 90 days. Customers inactive this long will be anonymized.", + "v8n_required": "Enter the number of inactive days (at least 90).", + "v8n_too_small": "Must be at least 90 days." + } + }, + "timestamps": { + "date_created": "Created on", + "date_modified": "Last updated on", + "date": "{{value, date}}" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "Are you sure you'd like to reset these settings?" + }, + "undo": { "caption": "Undo" }, + "submit": { "caption": "Save changes" }, + "create": { "caption": "Create" }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } +} From d63a4379e5e8f7cf7ea391170fb320f6b5167d75 Mon Sep 17 00:00:00 2001 From: Brett Florio Date: Thu, 18 Jun 2026 11:16:52 -0700 Subject: [PATCH 2/3] test(DataRetentionSettingsForm): add data_retention_settings to demo dataset Populated fixture + store link so the storybook Playground loads an editable data_retention_settings resource. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/hapi/createDataset.ts | 9 +++++++++ src/server/hapi/links.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index 662b12729..b251da248 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -753,6 +753,15 @@ export const createDataset: () => Dataset = () => ({ }, ], + data_retention_settings: [ + { + id: 0, + store_id: 0, + auto_anonymize: true, + auto_anonymize_days: 365, + }, + ], + customer_portal_settings: [ { id: 0, diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 55b7a86d9..e175b7adb 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -240,6 +240,10 @@ export const links: Links = { 'fx:store': { href: `./stores/${store_id}` }, }), + data_retention_settings: ({ store_id }) => ({ + 'fx:store': { href: `./stores/${store_id}` }, + }), + taxes: ({ id, store_id }) => ({ 'fx:store': { href: `./stores/${store_id}` }, 'fx:tax_item_categories': { href: `./tax_item_categories?tax_id=${id}` }, From 3cf1cb7b1efee43b1ba088d6c23fd2ff13b24835 Mon Sep 17 00:00:00 2001 From: Brett Florio Date: Wed, 29 Jul 2026 18:15:28 -0700 Subject: [PATCH 3/3] refactor(DataRetentionSettingsForm): edit the store's data_retention field data_retention now lives on the Store resource (not a sub-resource), so the form binds to a store and edits the nested data_retention object via custom get/set + this.edit() (the StoreForm pattern). Update v8n/hiddenSelector to read data_retention.*, stories to a store href, and tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UGvyb3Yx7kGiTWfaVnBa4u --- .../DataRetentionSettingsForm.stories.ts | 4 +- .../DataRetentionSettingsForm.test.ts | 35 ++++++++--- .../DataRetentionSettingsForm.ts | 60 +++++++++++++++---- .../public/DataRetentionSettingsForm/types.ts | 9 ++- 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts index 9cc0787f3..0532018fd 100644 --- a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts @@ -5,8 +5,8 @@ import { getMeta } from '../../../storygen/getMeta'; import { getStory } from '../../../storygen/getStory'; const summary: Summary = { - href: 'https://demo.api/hapi/data_retention_settings/0', - parent: 'https://demo.api/hapi/data_retention_settings', + href: 'https://demo.api/hapi/stores/0', + parent: 'https://demo.api/hapi/stores', nucleon: true, localName: 'foxy-data-retention-settings-form', translatable: true, diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts index f4e13aa6c..b8b5efee2 100644 --- a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.test.ts @@ -42,19 +42,19 @@ describe('DataRetentionSettingsForm', () => { it('produces "auto-anonymize-days:v8n_too_small" when below 90', () => { const element = new Form(); - element.edit({ auto_anonymize_days: 89 }); + element.edit({ data_retention: { auto_anonymize: false, auto_anonymize_days: 89 } }); expect(element.errors).to.include('auto-anonymize-days:v8n_too_small'); - element.edit({ auto_anonymize_days: 90 }); + element.edit({ data_retention: { auto_anonymize: false, auto_anonymize_days: 90 } }); expect(element.errors).to.not.include('auto-anonymize-days:v8n_too_small'); }); it('requires days when auto_anonymize is enabled', () => { const element = new Form(); - element.edit({ auto_anonymize: true }); + element.edit({ data_retention: { auto_anonymize: true, auto_anonymize_days: null } }); expect(element.errors).to.include('auto-anonymize-days:v8n_required'); - element.edit({ auto_anonymize_days: 365 }); + element.edit({ data_retention: { auto_anonymize: true, auto_anonymize_days: 365 } }); expect(element.errors).to.not.include('auto-anonymize-days:v8n_required'); }); }); @@ -74,7 +74,7 @@ describe('DataRetentionSettingsForm', () => { html`` ); - element.edit({ auto_anonymize: false }); + element.edit({ data_retention: { auto_anonymize: false, auto_anonymize_days: null } }); expect(element.hiddenSelector.matches('general:auto-anonymize-days', true)).to.be.true; }); @@ -83,7 +83,7 @@ describe('DataRetentionSettingsForm', () => { html`` ); - element.edit({ auto_anonymize: true }); + element.edit({ data_retention: { auto_anonymize: true, auto_anonymize_days: 365 } }); expect(element.hiddenSelector.matches('general:auto-anonymize-days', true)).to.be.false; }); }); @@ -105,7 +105,7 @@ describe('DataRetentionSettingsForm', () => { html`` ); - element.edit({ auto_anonymize: true }); + element.edit({ data_retention: { auto_anonymize: true, auto_anonymize_days: 365 } }); await element.requestUpdate(); const control = element.renderRoot.querySelector( @@ -115,4 +115,25 @@ describe('DataRetentionSettingsForm', () => { expect(control).to.exist; expect(control).to.have.attribute('min', '90'); }); + + it('reads and writes auto_anonymize via the store data_retention field', async () => { + const element = await fixture( + html`` + ); + + element.edit({ data_retention: { auto_anonymize: true, auto_anonymize_days: 120 } }); + await element.requestUpdate(); + + const control = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer="auto-anonymize"]' + ) as HTMLElement & { getValue: () => unknown; setValue: (v: unknown) => void }; + + expect(control.getValue()).to.equal(true); + + control.setValue(false); + expect(element.form.data_retention).to.deep.equal({ + auto_anonymize: false, + auto_anonymize_days: 120, + }); + }); }); diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts index f81501a30..53c60bb57 100644 --- a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts +++ b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from 'lit-html'; import type { NucleonV8N } from '../NucleonElement/types'; -import type { Data } from './types'; +import type { Data, ParsedDataRetention } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; import { BooleanSelector } from '@foxy.io/sdk/core'; @@ -11,10 +11,12 @@ const NS = 'data-retention-settings-form'; const Base = TranslatableMixin(InternalForm, NS); /** - * Form element for editing `fx:data_retention_settings` resources. + * Form element for editing a store's data retention settings. * - * Per-store auto-anonymization config: a toggle and a "days of inactivity" - * value with a hard 90-day minimum (enforced client-side and by the API). + * Data retention lives in the `data_retention` field of the `fx:store` + * resource, so this form binds to a store and edits that nested object: an + * auto-anonymization toggle and a "days of inactivity" value with a hard + * 90-day minimum (enforced client-side and by the API). * * @element foxy-data-retention-settings-form * @since 1.52.0 @@ -22,7 +24,8 @@ const Base = TranslatableMixin(InternalForm, NS); export class DataRetentionSettingsForm extends Base { static get v8n(): NucleonV8N { return [ - ({ auto_anonymize_days: v }) => { + ({ data_retention: dr }) => { + const v = dr?.auto_anonymize_days; return ( v === null || v === undefined || @@ -30,24 +33,51 @@ export class DataRetentionSettingsForm extends Base { 'auto-anonymize-days:v8n_too_small' ); }, - ({ auto_anonymize: enabled, auto_anonymize_days: v }) => { - return !enabled || (typeof v === 'number' && v >= 90) || 'auto-anonymize-days:v8n_required'; + ({ data_retention: dr }) => { + const v = dr?.auto_anonymize_days; + return ( + !dr?.auto_anonymize || + (typeof v === 'number' && v >= 90) || + 'auto-anonymize-days:v8n_required' + ); }, ]; } + private readonly __getAutoAnonymize = (): boolean => { + return this.__getDataRetention().auto_anonymize; + }; + + private readonly __setAutoAnonymize = (newValue: boolean): void => { + this.edit({ data_retention: { ...this.__getDataRetention(), auto_anonymize: newValue } }); + }; + + private readonly __getAutoAnonymizeDays = (): number | null => { + return this.__getDataRetention().auto_anonymize_days; + }; + + private readonly __setAutoAnonymizeDays = (newValue: number): void => { + this.edit({ data_retention: { ...this.__getDataRetention(), auto_anonymize_days: newValue } }); + }; + get hiddenSelector(): BooleanSelector { - // No DELETE route for this resource, and it carries no timestamps. + // No DELETE route (this edits a store field) and it carries no timestamps. const alwaysMatch = ['delete', 'timestamps', super.hiddenSelector.toString()]; // The "days" field only applies when auto-anonymization is on. - if (!this.form.auto_anonymize) alwaysMatch.unshift('general:auto-anonymize-days'); + if (!this.form.data_retention?.auto_anonymize) + alwaysMatch.unshift('general:auto-anonymize-days'); return new BooleanSelector(alwaysMatch.join(' ').trim()); } renderBody(): TemplateResult { return html` - + { infer="auto-anonymize-days" min="90" step="1" + .getValue=${this.__getAutoAnonymizeDays} + .setValue=${this.__setAutoAnonymizeDays} > @@ -62,4 +94,12 @@ export class DataRetentionSettingsForm extends Base { ${super.renderBody()} `; } + + private __getDataRetention(): ParsedDataRetention { + const dr = this.form.data_retention; + return { + auto_anonymize: dr?.auto_anonymize ?? false, + auto_anonymize_days: dr?.auto_anonymize_days ?? null, + }; + } } diff --git a/src/elements/public/DataRetentionSettingsForm/types.ts b/src/elements/public/DataRetentionSettingsForm/types.ts index e97f24df7..becfe174f 100644 --- a/src/elements/public/DataRetentionSettingsForm/types.ts +++ b/src/elements/public/DataRetentionSettingsForm/types.ts @@ -1,4 +1,11 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -export type Data = Resource; +// This form edits the `data_retention` field of a store, so it binds to the +// store resource itself. +export type Data = Resource; + +export type ParsedDataRetention = { + auto_anonymize: boolean; + auto_anonymize_days: number | null; +};