diff --git a/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts b/src/elements/public/DataRetentionSettingsForm/DataRetentionSettingsForm.stories.ts new file mode 100644 index 00000000..9cc0787f --- /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 00000000..f4e13aa6 --- /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 00000000..f81501a3 --- /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 00000000..04d412a4 --- /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 00000000..e97f24df --- /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 84537ddb..ebc8a989 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/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index 662b1272..b251da24 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 55b7a86d..e175b7ad 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}` }, 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 00000000..a64b84cd --- /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" + } +}