diff --git a/custom-elements.json b/custom-elements.json index 826c3423..d3327b8a 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -12010,6 +12010,21 @@ "path": "./src/elements/public/EmailTemplateForm/index.ts", "description": "Form element for creating or editing email templates (`fx:email_template`).", "attributes": [ + { + "name": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, { "name": "mode", "type": "string", @@ -12042,16 +12057,6 @@ "name": "hiddencontrols", "default": "\"False\"" }, - { - "name": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, { "name": "lang", "description": "Optional ISO 639-1 code describing the language element content is written in.\nChanging the `lang` attribute will update the value of this property.", @@ -12092,6 +12097,60 @@ } ], "properties": [ + { + "name": "simplifyNsLoading", + "attribute": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "attribute": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "t", + "type": "Translator", + "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" + }, + { + "name": "generalErrorPrefix", + "description": "Validation errors with this prefix will show up at the top of the form.", + "type": "string", + "default": "\"error:\"" + }, + { + "name": "status", + "attribute": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, + { + "name": "headerTitleKey", + "description": "Getter that returns a i18n key for the optional form header title.", + "type": "string" + }, + { + "name": "headerTitleOptions", + "description": "I18next options to pass to the header title translation function.", + "type": "Record" + }, + { + "name": "headerSubtitleKey", + "description": "Getter that returns a i18n key for the optional form header subtitle. Note that subtitle is shown only when data is avaiable.", + "type": "string" + }, + { + "name": "headerSubtitleOptions", + "description": "I18next options to pass to the header subtitle translation function. Note that subtitle is shown only when data is avaiable.", + "type": "Record" + }, + { + "name": "headerCopyIdValue", + "description": "ID that will be written to clipboard when Copy ID button in header is clicked.", + "type": "string | number" + }, { "name": "templates", "default": "{}" @@ -12147,23 +12206,6 @@ "name": "hiddenSelector", "type": "BooleanSelector" }, - { - "name": "simplifyNsLoading", - "attribute": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "attribute": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, - { - "name": "t", - "type": "Translator", - "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" - }, { "name": "UpdateEvent", "description": "Instances of this event are dispatched on an element whenever it changes its\nstate (e.g. when going from `busy` to `idle` or on `form` data change).\nThis event isn't cancelable, and it does not bubble.", @@ -24573,6 +24615,10 @@ "name": "shipping-address-types", "description": "URL of the `fx:shipping_address_types` property helper resource." }, + { + "name": "h-captcha-site-key", + "description": "hCaptcha site key for signup verification. If provided, requires users to complete a captcha before creating a store." + }, { "name": "store-versions", "description": "URL of the `fx:store_versions` property helper resource.", @@ -24702,6 +24748,11 @@ "attribute": "shipping-address-types", "description": "URL of the `fx:shipping_address_types` property helper resource." }, + { + "name": "hCaptchaSiteKey", + "attribute": "h-captcha-site-key", + "description": "hCaptcha site key for signup verification. If provided, requires users to complete a captcha before creating a store." + }, { "name": "storeVersions", "attribute": "store-versions", @@ -27502,6 +27553,21 @@ "path": "./src/elements/public/TemplateForm/index.ts", "description": "Form element for creating or editing templates (`fx:cart_include_template`, `fx:checkout_template`, `fx:cart_template`).", "attributes": [ + { + "name": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, { "name": "mode", "type": "string", @@ -27534,16 +27600,6 @@ "name": "hiddencontrols", "default": "\"False\"" }, - { - "name": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, { "name": "lang", "description": "Optional ISO 639-1 code describing the language element content is written in.\nChanging the `lang` attribute will update the value of this property.", @@ -27584,6 +27640,60 @@ } ], "properties": [ + { + "name": "simplifyNsLoading", + "attribute": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "attribute": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "t", + "type": "Translator", + "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" + }, + { + "name": "generalErrorPrefix", + "description": "Validation errors with this prefix will show up at the top of the form.", + "type": "string", + "default": "\"error:\"" + }, + { + "name": "status", + "attribute": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, + { + "name": "headerTitleKey", + "description": "Getter that returns a i18n key for the optional form header title.", + "type": "string" + }, + { + "name": "headerTitleOptions", + "description": "I18next options to pass to the header title translation function.", + "type": "Record" + }, + { + "name": "headerSubtitleKey", + "description": "Getter that returns a i18n key for the optional form header subtitle. Note that subtitle is shown only when data is avaiable.", + "type": "string" + }, + { + "name": "headerSubtitleOptions", + "description": "I18next options to pass to the header subtitle translation function. Note that subtitle is shown only when data is avaiable.", + "type": "Record" + }, + { + "name": "headerCopyIdValue", + "description": "ID that will be written to clipboard when Copy ID button in header is clicked.", + "type": "string | number" + }, { "name": "templates", "default": "{}" @@ -27639,23 +27749,6 @@ "name": "hiddenSelector", "type": "BooleanSelector" }, - { - "name": "simplifyNsLoading", - "attribute": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "attribute": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, - { - "name": "t", - "type": "Translator", - "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" - }, { "name": "UpdateEvent", "description": "Instances of this event are dispatched on an element whenever it changes its\nstate (e.g. when going from `busy` to `idle` or on `form` data change).\nThis event isn't cancelable, and it does not bubble.", @@ -30376,6 +30469,10 @@ } ], "properties": [ + { + "name": "getStorePageHref", + "description": "When provided, displays a link to Store Dashboard in user layout." + }, { "name": "defaultDomain", "attribute": "default-domain", diff --git a/package-lock.json b/package-lock.json index 68f946d0..5787c653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", + "@sindresorhus/slugify": "^2.2.1", "@vaadin-component-factory/vcf-tooltip": "^1.3.14", "@vaadin/vaadin": "^14.8.5", "check-password-strength": "^2.0.7", @@ -3694,6 +3695,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", diff --git a/package.json b/package.json index ab950d77..41e60118 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", + "@sindresorhus/slugify": "^2.2.1", "@vaadin-component-factory/vcf-tooltip": "^1.3.14", "@vaadin/vaadin": "^14.8.5", "check-password-strength": "^2.0.7", @@ -158,4 +159,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts index 9c6fa887..27d92505 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts @@ -1,7 +1,7 @@ import '../../public/NucleonElement/index'; import '../../public/AddressForm/index'; -import { expect, fixture, oneEvent } from '@open-wc/testing'; +import { expect, fixture, oneEvent, waitUntil } from '@open-wc/testing'; import { html } from 'lit-html'; import { getTestData } from '../../../testgen/getTestData'; import { InternalEditableControl } from './index'; @@ -23,6 +23,13 @@ describe('InternalEditableControl', () => { expect(new InternalEditableControl()).to.be.instanceOf(InternalControl); }); + it('has a reactive property "checkValidityAsync" (Function)', () => { + expect(new InternalEditableControl()).to.have.property('checkValidityAsync', null); + expect(InternalEditableControl).to.have.deep.nested.property('properties.checkValidityAsync', { + attribute: false, + }); + }); + it('has a reactive property "placeholder" (String)', () => { expect(InternalEditableControl).to.have.nested.property('properties.placeholder.type', String); }); @@ -240,4 +247,25 @@ describe('InternalEditableControl', () => { expect(control).to.have.property('_error', undefined); expect(control._checkValidity()).to.equal(true); }); + + it('returns an async error if checkValidityAsync is set and there are no sync errors', async () => { + const wrapper = await fixture(html` + + + + `); + + const control = wrapper.firstElementChild as InternalEditableControl; + // @ts-expect-error accessing protected member for testing purposes + control._value = 'foo'; + expect(control).to.have.property('_error', undefined); + + control.checkValidityAsync = async () => 'address-name:v8n_async_error'; + // @ts-expect-error accessing protected member for testing purposes + control._value = 'bar'; + // @ts-expect-error accessing protected member for testing purposes + await waitUntil(() => control._error === 'address-name:v8n_async_error', undefined, { + timeout: 5000, + }); + }); }); diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts index 8fff6c54..25de0715 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts @@ -1,6 +1,9 @@ import type { PropertyDeclarations } from 'lit-element'; + import { InternalControl } from '../InternalControl/InternalControl'; +import debounce from 'lodash-es/debounce'; + /** * An internal base class for controls that have editing functionality, e.g. a text field. * Instances of this class will provide shortcuts for translatable placeholder, label, helper @@ -14,6 +17,7 @@ export class InternalEditableControl extends InternalControl { static get properties(): PropertyDeclarations { return { ...super.properties, + checkValidityAsync: { attribute: false }, placeholder: { type: String, noAccessor: true }, helperText: { type: String, attribute: 'helper-text', noAccessor: true }, v8nPrefix: { type: String, attribute: 'v8n-prefix', noAccessor: true }, @@ -21,17 +25,30 @@ export class InternalEditableControl extends InternalControl { setValue: { attribute: false }, property: { type: String, noAccessor: true }, label: { type: String, noAccessor: true }, + __asyncError: { attribute: false }, }; } + checkValidityAsync: ((value: unknown) => Promise) | null = null; + getValue = (): unknown => this.nucleon?.form[this.property]; setValue = (newValue: unknown): void => this.nucleon?.edit({ [this.property]: newValue }); + private __debouncedCheckValidityAsync = debounce(async (newValue: unknown) => { + const validOrError = await this.checkValidityAsync?.(newValue); + if (this._value !== newValue) return; + this.__asyncError = validOrError === true ? null : validOrError ?? null; + }, 300); + + private __previousValue: unknown | null = null; + private __placeholder: string | null = null; private __helperText: string | null = null; + private __asyncError: string | null = null; + private __v8nPrefix: string | null = null; private __property: string | null = null; @@ -169,28 +186,34 @@ export class InternalEditableControl extends InternalControl { * Assigning a value to this property will dispatch a cancelable `change` event * with the new value in the detail and write changes to the NucleonElement instance if permitted. */ - protected get _value(): unknown | undefined { return this.getValue(); } protected set _value(newValue: unknown | undefined) { + this.__previousValue = this._value; + this.__asyncError = null; + if (!this._error && this.__previousValue !== newValue) { + this.__debouncedCheckValidityAsync(newValue); + } + const event = new CustomEvent('change', { cancelable: true, detail: newValue }); const useDefaultAction = this.dispatchEvent(event); if (useDefaultAction) this.setValue(newValue); } - /** A shortcut returning the first v8n error associated with this control. */ + /** A shortcut returning the first v8n error associated with this control. */ protected get _error(): string | undefined { - return this.nucleon?.errors.find(v => v.startsWith(this.v8nPrefix)); + const syncError = this.nucleon?.errors.find(v => v.startsWith(this.v8nPrefix)); + return syncError ?? this.__asyncError ?? void 0; } - /** A shortcut returning the localized text of the first v8n error associated with this control. */ + /** A shortcut returning the localized text of the first v8n error associated with this control. */ protected get _errorMessage(): string | undefined { return this._error ? this.t(this._error.substring(this.v8nPrefix.length)) : undefined; } - /** A helper returning a `.checkValidity()` function for use with Vaadin elements. */ + /** A helper returning a `.checkValidity()` function for use with Vaadin elements. */ protected get _checkValidity(): () => boolean { return () => !this._error; } diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts index 48f90d98..f0dc5251 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts @@ -537,14 +537,6 @@ describe('InternalResourcePickerControl', () => { expect(new Form()).to.have.deep.property('selectionProps', {}); }); - it('produces v8n error "silent:selection_required" if selection is undefined', () => { - const form = new Form(); - expect(form.errors).to.include('silent:selection_required'); - - form.edit({ selection: 'https://demo.api/hapi/customers/0' }); - expect(form.errors).to.not.include('silent:selection_required'); - }); - it('renders an async list control for selection', async () => { const form = await fixture
( html` diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControlForm.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControlForm.ts index 3ae5fa76..723422ff 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControlForm.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControlForm.ts @@ -1,5 +1,5 @@ import type { PropertyDeclarations, TemplateResult } from 'lit-element'; -import type { HALJSONResource, NucleonV8N } from '../../public/NucleonElement/types'; +import type { HALJSONResource } from '../../public/NucleonElement/types'; import { InternalForm } from '../InternalForm/InternalForm'; import { spread } from '@open-wc/lit-helpers'; @@ -15,10 +15,6 @@ export class InternalResourcePickerControlForm extends InternalForm { }; } - static get v8n(): NucleonV8N { - return [({ selection: v }) => (v === undefined ? 'silent:selection_required' : true)]; - } - selectionProps: Record = {}; renderBody(): TemplateResult { diff --git a/src/elements/internal/InternalSourceControl/InternalSourceControl.ts b/src/elements/internal/InternalSourceControl/InternalSourceControl.ts index 14a7f904..10adf4a7 100644 --- a/src/elements/internal/InternalSourceControl/InternalSourceControl.ts +++ b/src/elements/internal/InternalSourceControl/InternalSourceControl.ts @@ -51,16 +51,12 @@ export class InternalSourceControl extends InternalEditableControl { renderControl(): TemplateResult { let lineNumbersClass: string; - let helperTextClass: string; let containerClass: string; let textAreaClass: string; - let labelClass: string; if (this.disabled) { lineNumbersClass = 'bg-contrast-5 text-disabled'; - helperTextClass = 'text-disabled'; textAreaClass = 'text-disabled'; - labelClass = 'text-disabled'; if (this.readonly) { lineNumbersClass += ' border-dashed border-contrast-20'; @@ -71,29 +67,21 @@ export class InternalSourceControl extends InternalEditableControl { } } else if (this.readonly) { lineNumbersClass = 'border-dashed border-contrast-30 bg-transparent text-secondary'; - helperTextClass = 'text-secondary'; containerClass = 'border-dashed border-contrast-30'; textAreaClass = 'text-secondary'; - labelClass = 'text-secondary'; if (this.__focused) containerClass += ' ring-2 ring-primary-50'; } else if (this.__focused) { lineNumbersClass = 'border-transparent bg-contrast-10 text-tertiary'; - helperTextClass = 'text-secondary'; containerClass = 'border-primary-50 ring-1 ring-primary-50'; textAreaClass = 'text-body'; - labelClass = 'text-primary'; } else if (this.__hovered) { lineNumbersClass = 'border-transparent bg-contrast-20 text-tertiary'; - helperTextClass = 'text-body'; containerClass = 'border-contrast-20'; textAreaClass = 'text-body'; - labelClass = 'text-body'; } else { lineNumbersClass = 'border-transparent bg-contrast-10 text-tertiary'; - helperTextClass = 'text-secondary'; containerClass = 'border-contrast-10'; textAreaClass = 'text-body'; - labelClass = 'text-secondary'; } return html` @@ -102,11 +90,11 @@ export class InternalSourceControl extends InternalEditableControl { @mouseenter=${() => (this.__hovered = true)} @mouseleave=${() => (this.__hovered = false)} > -
- ${this.label} +
+
${this.label}
+
+ ${this.helperText} +
- ${this.helperText} -
- -
${this._errorMessage} diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts index da418beb..58657185 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts @@ -23,7 +23,7 @@ export class InternalSummaryControl extends InternalEditableControl { renderControl(): TemplateResult { return html` -
+

${this.label}

${this.helperText}

diff --git a/src/elements/internal/InternalTimestampsControl/InternalTimestampsControl.ts b/src/elements/internal/InternalTimestampsControl/InternalTimestampsControl.ts index d7131fee..1b2fc962 100644 --- a/src/elements/internal/InternalTimestampsControl/InternalTimestampsControl.ts +++ b/src/elements/internal/InternalTimestampsControl/InternalTimestampsControl.ts @@ -14,7 +14,7 @@ import { get } from 'lodash-es'; export class InternalTimestampsControl extends InternalControl { renderControl(): TemplateResult { return html` -

+

{ const link = { cart_link: 'https://example.com/cart' }; evt.respondWith(Promise.resolve(new Response(JSON.stringify(link)))); } else { - console.log('FETCH HANDLER', evt); router.handleEvent(evt); } }} diff --git a/src/elements/public/CartForm/internal/InternalCartFormCreateSessionAction/InternalCartFormCreateSessionAction.ts b/src/elements/public/CartForm/internal/InternalCartFormCreateSessionAction/InternalCartFormCreateSessionAction.ts index e42164f2..741e6ae0 100644 --- a/src/elements/public/CartForm/internal/InternalCartFormCreateSessionAction/InternalCartFormCreateSessionAction.ts +++ b/src/elements/public/CartForm/internal/InternalCartFormCreateSessionAction/InternalCartFormCreateSessionAction.ts @@ -62,7 +62,6 @@ export class InternalCartFormCreateSessionAction extends InternalControl { } private async __reloadSessionHref(href: string | null) { - console.log('RELOAD SESSION HREF', href); if (this.__loader?.href === href) return; const nucleon = this.nucleon as CartForm | null; diff --git a/src/elements/public/Customer/Customer.test.ts b/src/elements/public/Customer/Customer.test.ts index 00fd37a6..4507a36b 100644 --- a/src/elements/public/Customer/Customer.test.ts +++ b/src/elements/public/Customer/Customer.test.ts @@ -1164,7 +1164,6 @@ describe('Customer', () => { const cards = list!.querySelectorAll('[data-testclass="attributes:list:card"]'); - console.log(listData, cards); for (let index = 0; index < cards.length; ++index) { const wrapper = cards[index]; const card = wrapper.firstElementChild as AttributeCard; diff --git a/src/elements/public/EmailTemplateForm/EmailTemplateForm.stories.ts b/src/elements/public/EmailTemplateForm/EmailTemplateForm.stories.ts index c835a004..af9a6e12 100644 --- a/src/elements/public/EmailTemplateForm/EmailTemplateForm.stories.ts +++ b/src/elements/public/EmailTemplateForm/EmailTemplateForm.stories.ts @@ -11,9 +11,20 @@ const summary: Summary = { localName: 'foxy-email-template-form', translatable: true, configurable: { - sections: ['timestamps'], - buttons: ['delete', 'create'], - inputs: ['description', 'template-language', 'content'], + sections: ['timestamps', 'header', 'general', 'html-source', 'text-source'], + buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], + inputs: [ + 'general:description', + 'general:template-language', + 'general:toggle', + 'general:subject', + 'content-html', + 'html-source:content-html-url', + 'html-source:cache', + 'content-text', + 'text-source:content-text-url', + 'text-source:cache', + ], }, }; diff --git a/src/elements/public/EmailTemplateForm/EmailTemplateForm.test.ts b/src/elements/public/EmailTemplateForm/EmailTemplateForm.test.ts index 26945550..94adc3ca 100644 --- a/src/elements/public/EmailTemplateForm/EmailTemplateForm.test.ts +++ b/src/elements/public/EmailTemplateForm/EmailTemplateForm.test.ts @@ -1,966 +1,394 @@ +import type { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; +import type { FetchEvent } from '../NucleonElement/FetchEvent'; +import type { Data } from './types'; + import './index'; -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; - -import { ButtonElement } from '@vaadin/vaadin-button'; -import { Choice } from '../../private'; -import { ChoiceChangeEvent } from '../../private/events'; -import { Data } from './types'; -import { EmailTemplateForm } from './EmailTemplateForm'; -import { FetchEvent } from '../NucleonElement/FetchEvent'; -import { I18n } from '../I18n/I18n'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { InternalSandbox } from '../../internal/InternalSandbox/InternalSandbox'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { TextFieldElement } from '@vaadin/vaadin-text-field'; -import { getByKey } from '../../../testgen/getByKey'; -import { getByName } from '../../../testgen/getByName'; -import { getByTestId } from '../../../testgen/getByTestId'; +import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { EmailTemplateForm as Form } from './EmailTemplateForm'; +import { createRouter } from '../../../server/index'; import { getTestData } from '../../../testgen/getTestData'; import { stub } from 'sinon'; -import { unsafeHTML } from 'lit-html/directives/unsafe-html'; -import { createRouter } from '../../../server/index'; -import { InternalSourceControl } from '../../internal/InternalSourceControl/InternalSourceControl'; describe('EmailTemplateForm', () => { - it('extends NucleonElement', () => { - expect(new EmailTemplateForm()).to.be.instanceOf(NucleonElement); + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); - it('registers as foxy-email-template-form', () => { - expect(customElements.get('foxy-email-template-form')).to.equal(EmailTemplateForm); + it('imports and defines foxy-internal-source-control', () => { + expect(customElements.get('foxy-internal-source-control')).to.exist; }); - it('has a default i18next namespace of "email-template-form"', () => { - expect(new EmailTemplateForm()).to.have.property('ns', 'email-template-form'); + it('imports and defines foxy-internal-select-control', () => { + expect(customElements.get('foxy-internal-select-control')).to.exist; }); - describe('description', () => { - it('has i18n label key "description"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - - expect(control).to.have.property('label', 'description'); - }); - - it('has value of form.description', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ description: 'Test template' }); - - const control = await getByTestId(element, 'description'); - expect(control).to.have.property('value', 'Test template'); - }); - - it('writes to form.description on input', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - - control!.value = 'Test template'; - control!.dispatchEvent(new CustomEvent('input')); - - expect(element).to.have.nested.property('form.description', 'Test template'); - }); - - it('submits valid form on enter', async () => { - const validData = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - const submit = stub(element, 'submit'); - - element.data = validData; - element.edit({ description: 'Test template', content_html: '', content_text: '' }); - control!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('renders "description:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByName(element, 'description:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "description:before" slot with template "description:before" if available', async () => { - const description = 'description:before'; - const value = `

Value of the "${description}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, description); - const sandbox = (await getByTestId(element, description))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders "description:after" slot by default', async () => { - const element = await fixture( - html`` - ); - - const slot = await getByName(element, 'description:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "description:after" slot with template "description:after" if available', async () => { - const description = 'description:after'; - const value = `

Value of the "${description}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, description); - const sandbox = (await getByTestId(element, description))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes description', async () => { - const element = await fixture(html` - - `); - - expect(await getByTestId(element, 'description')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is loading', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when form has failed to load data', async () => { - const href = 'https://demo.api/virtual/empty?status=404'; - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes description', async () => { - const element = await fixture(html` - - `); - - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes description', async () => { - const element = await fixture(html` - - `); - - expect(await getByTestId(element, 'description')).to.not.exist; - }); + it('imports and defines foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; }); - describe('content', () => { - ['html', 'text'].forEach(type => { - describe(`${type} content`, () => { - it('has i18n label key "template"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(await getByKey(control, `${type}_template`)).to.exist; - }); - - it('renders a choice element with content types', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - - expect(choice).to.be.instanceOf(Choice); - expect(choice).to.have.deep.property('items', ['default', 'url', 'clipboard']); - }); - - it('pre-selects default content type by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - - expect(choice).to.have.property('value', 'default'); - }); - - it('pre-selects url content type if content_url is set', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}_url`]: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - expect(choice).to.have.property('value', 'url'); - }); - - it('pre-selects clipboard content type if content is set', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}`]: 'Test Template' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - expect(choice).to.have.property('value', 'clipboard'); - }); - - it('clears content and content_url on choice change', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ - [`content_${type}`]: 'Test Template', - [`content_${type}_url`]: 'https://example.com', - }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - choice.dispatchEvent(new ChoiceChangeEvent('url')); - await element.requestUpdate(); - - expect(choice).to.have.property('value', 'url'); - expect(element.form).to.have.property(`content_${type}`, ''); - expect(element.form).to.have.property(`content_${type}_url`, ''); - }); - - ['default', 'url', 'clipboard'].forEach(contentType => { - it(`renders title for choice "${contentType}"`, async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector(`[slot="${contentType}-label"]`) as HTMLElement; - - expect(await getByKey(wrapper, `template_${contentType}`)).to.exist; - }); - }); - - it('shows url field for url content type', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}_url`]: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]'); - expect(wrapper).to.not.have.attribute('hidden'); - }); - - it(`sets value of form.content_${type}_url to the text field`, async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}_url`]: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = await getByTestId(wrapper, `content-${type}-url`); - - expect(field).to.have.value('https://example.com'); - }); - - it(`content url field writes to form.content_${type}_url on input`, async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}_url`]: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = (await getByTestId(wrapper, `content-${type}-url`)) as TextFieldElement; - - field.value = 'https://example.com/foo'; - field.dispatchEvent(new CustomEvent('input')); - - expect(element.form).to.have.property(`content_${type}_url`, 'https://example.com/foo'); - }); - - it(`${type} content url field submits valid form on Enter`, async () => { - const layout = html``; - const element = await fixture(layout); - const submit = stub(element, 'submit'); - - element.edit({ - description: 'Test', - content_html_url: 'https://example.com', - content_html: '', - content_text_url: 'https://example.com', - content_text: '', - }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = (await getByTestId(wrapper, `content-${type}-url`)) as TextFieldElement; - field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('shows Cache button next to the content url field', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}_url`]: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const button = (await getByTestId(wrapper, `content-${type}-cache`)) as ButtonElement; - - expect(button).to.exist; - expect(button.firstElementChild).to.have.property('key', 'cache'); - expect(button.firstElementChild).to.be.instanceOf(I18n); - }); - - it('POSTs to fx:cache once Cache button is clicked', async () => { - const layout = html``; - const element = await fixture(layout); - element.data = await getTestData('./hapi/email_templates/0'); - - const whenFetchEventFired = oneEvent(element, 'fetch') as unknown as Promise; - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const button = (await getByTestId(wrapper, `content-${type}-cache`)) as ButtonElement; - button.click(); - - const event = await whenFetchEventFired; - expect(event).to.have.nested.property( - 'request.url', - element.data?._links['fx:cache'].href - ); - expect(event).to.have.nested.property('request.method', 'POST'); - }); - - it('shows source control for clipboard content type', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ [`content_${type}`]: 'Test Template' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const field = choice.querySelector('[slot="clipboard"]'); - - expect(field).to.have.attribute('placeholder', 'clipboard_source_placeholder'); - expect(field).to.have.attribute('label', 'clipboard_source_label'); - expect(field).to.have.attribute('property', `content_${type}`); - expect(field).to.have.attribute('infer', 'content'); - - expect(field).to.not.have.attribute('hidden'); - expect(field).to.be.instanceOf(InternalSourceControl); - }); - - it('shows source control for url content type', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ - [`content_${type}_url`]: 'https://example.com/template', - [`content_${type}`]: 'Test Template', - }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, `content-${type}-type`)) as Choice; - const group = choice.querySelector('[slot="url"]'); - const field = group?.querySelector('foxy-internal-source-control'); - - expect(field).to.have.attribute('placeholder', 'url_source_placeholder'); - expect(field).to.have.attribute('label', 'url_source_label'); - expect(field).to.have.attribute('property', `content_${type}`); - expect(field).to.have.attribute('infer', 'content'); - - expect(field).to.not.have.attribute('hidden'); - expect(field).to.be.instanceOf(InternalSourceControl); - }); - - it('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - - expect(choice).not.to.have.attribute('readonly'); - expect(urlField).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - - expect(choice).to.have.attribute('readonly'); - expect(urlField).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - - expect(choice).to.have.attribute('readonly'); - expect(urlField).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - const cacheButton = await getByTestId(control, `content-${type}-cache`); - - expect(choice).not.to.have.attribute('disabled'); - expect(urlField).not.to.have.attribute('disabled'); - expect(cacheButton).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is loading', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - const cacheButton = await getByTestId(control, `content-${type}-cache`); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when form has failed to load data', async () => { - const href = 'https://demo.api/virtual/empty?status=404'; - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - const cacheButton = await getByTestId(control, `content-${type}-cache`); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - const cacheButton = await getByTestId(control, `content-${type}-cache`); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, `content-${type}-type`); - const urlField = await getByTestId(control, `content-${type}-url`); - const cacheButton = await getByTestId(control, `content-${type}-cache`); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.not.exist; - }); - - it('is hidden when hiddencontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.not.exist; - }); - }); - }); + it('imports and defines foxy-internal-text-control', () => { + expect(customElements.get('foxy-internal-text-control')).to.exist; }); - describe('timestamps', () => { - it('once form data is loaded, renders a property table with created and modified dates', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'timestamps'); - const items = [ - { name: 'date_modified', value: 'date' }, - { name: 'date_created', value: 'date' }, - ]; - - expect(control).to.have.deep.property('items', items); - }); - - it('once form data is loaded, renders "timestamps:before" slot', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'timestamps:before'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('once form data is loaded, replaces "timestamps:before" slot with template "timestamps:before" if available', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const name = 'timestamps:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('once form data is loaded, renders "timestamps:after" slot', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'timestamps:after'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('once form data is loaded, replaces "timestamps:after" slot with template "timestamps:after" if available', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const name = 'timestamps:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + it('imports and defines foxy-internal-form', () => { + expect(customElements.get('foxy-internal-form')).to.exist; }); - describe('create', () => { - it('if data is empty, renders create button', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.exist; - }); - - it('renders with i18n key "create" for caption', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'create'); - const caption = control?.firstElementChild; - - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'create'); - expect(caption).to.have.attribute('ns', 'email-template-form'); - }); - - it('renders disabled if form is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is invalid', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is sending changes', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ description: 'Foo' }); - element.submit(); - - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); + it('imports and defines foxy-internal-email-template-form-async-action', () => { + expect(customElements.get('foxy-internal-email-template-form-async-action')).to.exist; + }); - it('renders disabled if disabledcontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); + it('defines itself as foxy-email-template-form', () => { + expect(customElements.get('foxy-email-template-form')).to.equal(Form); + }); - it('submits valid form on click', async () => { - const element = await fixture( - html`` - ); - const submit = stub(element, 'submit'); - element.edit({ description: 'Foo' }); + it('extends foxy-internal-form', () => { + expect(new Form()).to.be.instanceOf(customElements.get('foxy-internal-form')); + }); - const control = await getByTestId(element, 'create'); - control!.dispatchEvent(new CustomEvent('click')); + it('has a default i18next namespace of "email-template-form"', () => { + expect(new Form().ns).to.equal('email-template-form'); + expect(Form.defaultNS).to.equal('email-template-form'); + }); - expect(submit).to.have.been.called; - }); + it('makes content-html control read-only when content_html_url and subject are set', () => { + const form = new Form(); + expect(form.readonlySelector.matches('content-html', true)).to.be.false; + form.edit({ content_html_url: 'foo', subject: 'bar' }); + expect(form.readonlySelector.matches('content-html', true)).to.be.true; + }); - it("doesn't render if form is hidden", async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); + it('makes content-text control read-only when content_text_url and subject are set', () => { + const form = new Form(); + expect(form.readonlySelector.matches('content-text', true)).to.be.false; + form.edit({ content_text_url: 'foo', subject: 'bar' }); + expect(form.readonlySelector.matches('content-text', true)).to.be.true; + }); - it('doesn\'t render if hiddencontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); + it('makes Cache buttons disabled when content_html_url or content_text_url are not set in data or when form is dirty', async () => { + const form = new Form(); + expect(form.disabledSelector.matches('html-source:cache', true)).to.be.true; + expect(form.disabledSelector.matches('text-source:cache', true)).to.be.true; + + const data = await getTestData('./hapi/email_templates/0'); + data.subject = 'baz'; + + data.content_html_url = ''; + data.content_text_url = ''; + form.data = { ...data }; + expect(form.disabledSelector.matches('html-source:cache', true)).to.be.true; + expect(form.disabledSelector.matches('text-source:cache', true)).to.be.true; + + data.content_html_url = 'foo'; + data.content_text_url = 'bar'; + form.data = { ...data }; + expect(form.disabledSelector.matches('html-source:cache', true)).to.be.false; + expect(form.disabledSelector.matches('text-source:cache', true)).to.be.false; + + form.edit({ subject: 'qux' }); + expect(form.disabledSelector.matches('html-source:cache', true)).to.be.true; + expect(form.disabledSelector.matches('text-source:cache', true)).to.be.true; + }); - it('renders with "create:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'create:before'); + it('disables template controls when subject is not set', () => { + const form = new Form(); + expect(form.disabledSelector.matches('general:template-language', true)).to.be.true; + expect(form.disabledSelector.matches('html-source', true)).to.be.true; + expect(form.disabledSelector.matches('text-source', true)).to.be.true; + expect(form.disabledSelector.matches('content-html', true)).to.be.true; + expect(form.disabledSelector.matches('content-text', true)).to.be.true; + + form.edit({ subject: 'foo' }); + expect(form.disabledSelector.matches('general:template-language', true)).to.be.false; + expect(form.disabledSelector.matches('html-source', true)).to.be.false; + expect(form.disabledSelector.matches('text-source', true)).to.be.false; + expect(form.disabledSelector.matches('content-html', true)).to.be.false; + expect(form.disabledSelector.matches('content-text', true)).to.be.false; + }); - expect(slot).to.have.property('localName', 'slot'); - }); + it('hides Cache HTML button when content_html_url is not set in data', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('html-source:cache', true)).to.be.true; - it('replaces "create:before" slot with template "create:before" if available and rendered', async () => { - const name = 'create:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + const data = await getTestData('./hapi/email_templates/0'); + data.subject = 'foo'; - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + data.content_html_url = ''; + form.data = { ...data }; + expect(form.hiddenSelector.matches('html-source:cache', true)).to.be.true; - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + data.content_html_url = 'foo'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('html-source:cache', true)).to.be.false; + }); - it('renders with "create:after" slot by default', async () => { - const element = await fixture( - html`` - ); - const slot = await getByName(element, 'create:after'); - expect(slot).to.have.property('localName', 'slot'); - }); + it('hides Cache Text button when content_text_url is not set in data', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('text-source:cache', true)).to.be.true; - it('replaces "create:after" slot with template "create:after" if available and rendered', async () => { - const name = 'create:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + const data = await getTestData('./hapi/email_templates/0'); + data.subject = 'foo'; - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + data.content_text_url = ''; + form.data = { ...data }; + expect(form.hiddenSelector.matches('text-source:cache', true)).to.be.true; - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + data.content_text_url = 'foo'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('text-source:cache', true)).to.be.false; }); - describe('delete', () => { - it('renders delete button once resource is loaded', async () => { - const href = './hapi/email_templates/0'; - const data = await getTestData(href); - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'delete')).to.exist; - }); + it('hides template controls when subject is not set both in data and form edits', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('general:template-language', true)).to.be.true; + expect(form.hiddenSelector.matches('general:subject', true)).to.be.true; + expect(form.hiddenSelector.matches('html-source', true)).to.be.true; + expect(form.hiddenSelector.matches('text-source', true)).to.be.true; + expect(form.hiddenSelector.matches('content-html', true)).to.be.true; + expect(form.hiddenSelector.matches('content-text', true)).to.be.true; + + form.edit({ subject: 'foo' }); + expect(form.hiddenSelector.matches('general:template-language', true)).to.be.false; + expect(form.hiddenSelector.matches('general:subject', true)).to.be.false; + expect(form.hiddenSelector.matches('html-source', true)).to.be.false; + expect(form.hiddenSelector.matches('text-source', true)).to.be.false; + expect(form.hiddenSelector.matches('content-html', true)).to.be.false; + expect(form.hiddenSelector.matches('content-text', true)).to.be.false; + }); - it('renders with i18n key "delete" for caption', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const element = await fixture(html` - - `); + it('renders a form header', () => { + const form = new Form(); + const renderHeaderMethod = stub(form, 'renderHeader'); + form.render(); + expect(renderHeaderMethod).to.have.been.called; + }); - const control = await getByTestId(element, 'delete'); - const caption = control?.firstElementChild; + it('renders General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="general"]'); + expect(control?.localName).to.equal('foxy-internal-summary-control'); + }); - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'delete'); - expect(caption).to.have.attribute('ns', 'email-template-form'); - }); + it('renders a text control for Description in General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="general"] foxy-internal-text-control[infer="description"]' + ); - it('renders disabled if form is disabled', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const element = await fixture(html` - - `); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + it('renders a switch control for On/Off Toggle in General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="general"] foxy-internal-switch-control[infer="toggle"]' + ); - it('renders disabled if form is sending changes', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); + expect(control).to.exist; + form.edit({ subject: '' }); + expect(control?.getValue()).to.be.false; - element.edit({ description: 'Foo' }); - element.submit(); + control?.setValue(true); + expect(control?.getValue()).to.be.true; + expect(form.form.subject).to.equal('general.subject.default_value'); + }); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + it('renders a text control for Subject in General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="general"] foxy-internal-text-control[infer="subject"]' + ); - it('renders disabled if disabledcontrols includes "delete"', async () => { - const element = await fixture(html` - ('./hapi/email_templates/0')} - disabledcontrols="delete" - > - - `); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + it('renders a select control for Template Language in General summary', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, '', { timeout: 5000 }); + await form.requestUpdate(); + + const control = form.renderRoot.querySelector( + '[infer="general"] foxy-internal-select-control[infer="template-language"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { rawLabel: 'Nunjucks', value: 'nunjucks' }, + { rawLabel: 'Handlebars', value: 'handlebars' }, + { rawLabel: 'Pug', value: 'pug' }, + { rawLabel: 'Twig', value: 'twig' }, + { rawLabel: 'EJS', value: 'ejs' }, + ]); + }); - it('shows deletion confirmation dialog on click', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'delete'); - const confirm = await getByTestId(element, 'confirm'); - const showMethod = stub(confirm!, 'show'); + it('renders a default slot', async () => { + const form = await fixture(html` `); + expect(form.renderRoot.querySelector('slot:not([name])')).to.exist; + }); - control!.dispatchEvent(new CustomEvent('click')); + it('renders a source control for HTML Content', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="content-html"]'); + expect(control?.localName).to.equal('foxy-internal-source-control'); + }); - expect(showMethod).to.have.been.called; - }); + it('renders a summary control for HTML Source', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="html-source"]'); + expect(control?.localName).to.equal('foxy-internal-summary-control'); + }); - it('deletes resource if deletion is confirmed', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + it('renders a text control for HTML Content URL in HTML Source summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="html-source"] foxy-internal-text-control[infer="content-html-url"]' + ); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(false)); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(deleteMethod).to.have.been.called; - }); + it('renders an async action control for caching HTML Content in HTML Source summary', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, '', { timeout: 5000 }); + await form.requestUpdate(); + const control = form.renderRoot.querySelector( + '[infer="html-source"] foxy-internal-email-template-form-async-action[infer="cache"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('theme', 'tertiary-inline'); + expect(control).to.have.attribute('href', form.data!._links['fx:cache'].href); + }); - it('keeps resource if deletion is cancelled', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + it('renders a source control for Text Content', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="content-text"]'); + expect(control?.localName).to.equal('foxy-internal-source-control'); + }); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(true)); + it('renders a summary control for Text Source', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="text-source"]'); + expect(control?.localName).to.equal('foxy-internal-summary-control'); + }); - expect(deleteMethod).not.to.have.been.called; - }); + it('renders a text control for Text Content URL in Text Source summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="text-source"] foxy-internal-text-control[infer="content-text-url"]' + ); - it("doesn't render if form is hidden", async () => { - const data = await getTestData('./hapi/email_templates/0'); - const element = await fixture(html` - - `); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(await getByTestId(element, 'delete')).to.not.exist; - }); + it('renders an async action control for caching Text Content in Text Source summary', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, '', { timeout: 5000 }); + await form.requestUpdate(); + const control = form.renderRoot.querySelector( + '[infer="text-source"] foxy-internal-email-template-form-async-action[infer="cache"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('theme', 'tertiary-inline'); + expect(control).to.have.attribute('href', form.data!._links['fx:cache'].href); + }); - it('doesn\'t render if hiddencontrols includes "delete"', async () => { - const element = await fixture(html` + it('caches content on POST', async () => { + const requests: Request[] = []; + const router = createRouter(); + const form = await fixture( + html` ('./hapi/email_templates/0')} - hiddencontrols="delete" + parent="https://demo.api/hapi/email_templates" + @fetch=${(evt: FetchEvent) => { + if (evt.defaultPrevented) return; + requests.push(evt.request); + router.handleEvent(evt); + }} > - `); + ` + ); - expect(await getByTestId(element, 'delete')).to.not.exist; + form.edit({ + content_html_url: 'https://example.com', + content_text_url: 'https://example.com', + subject: 'Test', }); - it('renders with "delete:before" slot by default', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:before'); + requests.length = 0; + form.submit(); + await waitUntil(() => requests.length >= 3, '', { timeout: 5000 }); + const cacheRequest = requests.find( + req => req.method === 'POST' && req.url === form.data?._links['fx:cache'].href + ); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "delete:before" slot with template "delete:before" if available and rendered', async () => { - const href = './hapi/email_templates/0'; - const name = 'delete:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders with "delete:after" slot by default', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:after'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "delete:after" slot with template "delete:after" if available and rendered', async () => { - const href = './hapi/email_templates/0'; - const name = 'delete:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + expect(cacheRequest).to.exist; }); - describe('spinner', () => { - it('renders foxy-spinner in "busy" state while loading data', async () => { - const router = createRouter(); - const element = await fixture(html` + it('caches content on PATCH', async () => { + const requests: Request[] = []; + const router = createRouter(); + const form = await fixture( + html` router.handleEvent(evt)} + href="https://demo.api/hapi/email_templates/0" + @fetch=${(evt: FetchEvent) => { + if (evt.defaultPrevented) return; + requests.push(evt.request); + router.handleEvent(evt); + }} > - `); + ` + ); - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; + await waitUntil(() => !!form.data, '', { timeout: 5000 }); - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'busy'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'email-template-form spinner'); + form.edit({ + content_html_url: 'https://example.com', + content_text_url: 'https://example.com', + subject: 'Test', }); - it('renders foxy-spinner in "error" state if loading data fails', async () => { - const href = './hapi/not-found'; - const element = await fixture(html` - - `); - - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; - - await waitUntil(() => element.in('fail'), undefined, { timeout: 5000 }); + requests.length = 0; + form.submit(); + await waitUntil(() => requests.length >= 3, '', { timeout: 5000 }); + const cacheRequest = requests.find( + req => req.method === 'POST' && req.url === form.data?._links['fx:cache'].href + ); - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'error'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'email-template-form spinner'); - }); - - it('hides spinner once loaded', async () => { - const data = await getTestData('./hapi/email_templates/0'); - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - - expect(spinnerWrapper).to.have.class('opacity-0'); - }); + expect(cacheRequest).to.exist; }); }); diff --git a/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts b/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts index 26e0e7b6..c529ed79 100644 --- a/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts +++ b/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts @@ -1,25 +1,14 @@ -import type { PropertyDeclarations, TemplateResult } from 'lit-element'; -import type { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import type { ScopedElementsMap } from '@open-wc/scoped-elements'; -import type { TextFieldElement } from '@vaadin/vaadin-text-field'; +import type { TemplateResult } from 'lit-element'; import type { Data } from './types'; -import { Choice, Group, Metadata } from '../../private/index'; -import { ScopedElementsMixin } from '@open-wc/scoped-elements'; -import { ChoiceChangeEvent } from '../../private/events'; -import { ConfigurableMixin } from '../../../mixins/configurable'; import { TranslatableMixin } from '../../../mixins/translatable'; -import { ThemeableMixin } from '../../../mixins/themeable'; -import { DialogHideEvent } from '../../private/Dialog/DialogHideEvent'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; -import { classMap } from '../../../utils/class-map'; import { html } from 'lit-element'; const NS = 'email-template-form'; -const Base = ScopedElementsMixin( - ThemeableMixin(ConfigurableMixin(TranslatableMixin(NucleonElement, NS))) -); +const Base = TranslatableMixin(InternalForm, NS); /** * Form element for creating or editing email templates (`fx:email_template`). @@ -28,370 +17,151 @@ const Base = ScopedElementsMixin( * @since 1.14.0 */ export class EmailTemplateForm extends Base { - static get properties(): PropertyDeclarations { - return { - ...super.properties, - __cacheState: { attribute: false }, - __contentChoice: { attribute: false }, - }; - } - - static get scopedElements(): ScopedElementsMap { - return { - 'foxy-internal-select-control': customElements.get('foxy-internal-select-control'), - 'foxy-internal-confirm-dialog': customElements.get('foxy-internal-confirm-dialog'), - 'foxy-internal-source-control': customElements.get('foxy-internal-source-control'), - 'foxy-internal-text-control': customElements.get('foxy-internal-text-control'), - 'foxy-internal-sandbox': customElements.get('foxy-internal-sandbox'), - 'foxy-spinner': customElements.get('foxy-spinner'), - 'foxy-i18n': customElements.get('foxy-i18n'), - 'vaadin-text-field': customElements.get('vaadin-text-field'), - 'vaadin-button': customElements.get('vaadin-button'), - 'x-metadata': Metadata, - 'x-choice': Choice, - 'x-group': Group, - }; - } - - private __templateLanguageOptions = [ - { label: 'Nunjucks', value: 'nunjucks' }, - { label: 'Handlebars', value: 'handlebars' }, - { label: 'Pug', value: 'pug' }, - { label: 'Twig', value: 'twig' }, - { label: 'EJS', value: 'ejs' }, + private readonly __templateLanguageOptions = [ + { rawLabel: 'Nunjucks', value: 'nunjucks' }, + { rawLabel: 'Handlebars', value: 'handlebars' }, + { rawLabel: 'Pug', value: 'pug' }, + { rawLabel: 'Twig', value: 'twig' }, + { rawLabel: 'EJS', value: 'ejs' }, ]; - private __textContentChoice: 'default' | 'url' | 'clipboard' = 'default'; + private readonly __toggleGetValue = () => !!this.form.subject; - private __htmlContentChoice: 'default' | 'url' | 'clipboard' = 'default'; + private readonly __toggleSetValue = (newValue: boolean) => { + this.edit({ subject: newValue ? this.t('general.subject.default_value') : '' }); + }; - private __cacheState: 'idle' | 'busy' | 'fail' = 'idle'; + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + const subject = this.form.subject; - render(): TemplateResult { - const { hiddenSelector, href, lang, ns } = this; - const action = href ? 'delete' : 'create'; - const isBusy = this.in('busy'); - const isFail = this.in('fail'); + if (this.form.content_html_url && subject) alwaysMatch.unshift('content-html'); + if (this.form.content_text_url && subject) alwaysMatch.unshift('content-text'); - return html` -
- ${hiddenSelector.matches('description', true) ? '' : this.__renderDescription()} - - - - ${this.data?.description === 'Email Receipt Template' - ? '' - : html` - - - `} - - - - ${hiddenSelector.matches('content', true) ? '' : this.__renderContent()} - ${hiddenSelector.matches('timestamps', true) || !href ? '' : this.__renderTimestamps()} - ${hiddenSelector.matches(action) ? '' : this.__renderAction(action)} - -
- - -
-
- `; + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - protected async _sendPost(edits: Partial): Promise { - const data = await super._sendPost(edits); - if (!data.content_html_url && !data.content_text_url) return data; + get disabledSelector(): BooleanSelector { + const alwaysMatch = [super.disabledSelector.toString()]; - this.__cacheState = 'busy'; - const url = data._links['fx:cache'].href; - const response = await new EmailTemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; - - return await this._fetch(data._links.self.href); - } - - protected async _sendPatch(edits: Partial): Promise { - const data = await super._sendPatch(edits); - if (!data.content_html_url && !data.content_text_url) return data; + if ( + !this.in({ idle: { snapshot: 'clean' } }) || + !this.data.content_html_url || + !this.data.content_text_url + ) { + alwaysMatch.unshift('html-source:cache', 'text-source:cache'); + } - this.__cacheState = 'busy'; - const url = data._links['fx:cache'].href; - const response = await new EmailTemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; + if (!this.form.subject) { + alwaysMatch.unshift( + 'general:template-language', + 'html-source', + 'text-source', + 'content-html', + 'content-text' + ); + } - return await this._fetch(data._links.self.href); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - private __renderDescription() { - const scope = 'description'; - - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - evt.key === 'Enter' && this.submit()} - @input=${(evt: CustomEvent) => { - this.edit({ description: (evt.currentTarget as TextFieldElement).value }); - }} - > - + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + + if (!this.data?.content_html_url) alwaysMatch.unshift('html-source:cache'); + if (!this.data?.content_text_url) alwaysMatch.unshift('text-source:cache'); + if (!this.data?.subject && !this.form.subject) { + alwaysMatch.unshift( + 'general:template-language', + 'general:subject', + 'html-source', + 'text-source', + 'content-html', + 'content-text' + ); + } - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - private __renderContent() { - const variants = ['text', 'html'] as const; + renderBody(): TemplateResult { return html` -
-
${variants.map(v => this.__renderContentVariant(v))}
-
- `; - } + ${this.renderHeader()} - private __renderContentVariant(variant: 'text' | 'html') { - const urlPath = variant === 'text' ? 'content_text_url' : 'content_html_url'; - const textPath = variant === 'text' ? 'content_text' : 'content_html'; - const header = variant === 'text' ? 'text_template' : 'html_template'; - const urlValue = this.form[urlPath]; - const textValue = this.form[textPath]; - const contentChoiceKey = variant === 'text' ? '__textContentChoice' : '__htmlContentChoice'; - const contentChoice = urlValue ? 'url' : textValue ? 'clipboard' : this[contentChoiceKey]; + + + - const isDisabled = !this.in('idle') || this.disabledSelector.matches('content', true); - const isReadonly = this.readonlySelector.matches('content', true); - const isSyncProhibited = isReadonly || !this.data?.[urlPath] || urlValue !== this.data[urlPath]; - - return html` - - - - - { - if (evt instanceof ChoiceChangeEvent) { - this.edit({ [textPath]: '', [urlPath]: '' }); - this[contentChoiceKey] = evt.detail as 'url' | 'clipboard' | 'default'; - } - }} - > - ${['default', 'url', 'clipboard'].map(value => { - return html` -
- - -
- `; - })} - -
-
- evt.key === 'Enter' && this.submit()} - @input=${(evt: CustomEvent) => { - const value = (evt.currentTarget as TextFieldElement).value; - this.edit({ [textPath]: '', [urlPath]: value }); - }} - > - - - - - - -
- - -
-
-
- - - -
- - - -
-
- `; - } + - private __renderTimestamps() { - const scope = 'timestamps'; + + - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - ({ - name: this.t(field), - value: this.data?.[field] - ? this.t('date', { value: new Date(this.data[field] as string) }) - : '', - }))} + - - - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; - } - - private __renderAction(action: string) { - const { disabledSelector, href, lang, ns } = this; + +
- const isTemplateValid = this.in({ idle: { template: { dirty: 'valid' } } }); - const isSnapshotValid = this.in({ idle: { snapshot: { dirty: 'valid' } } }); - const isDisabled = !this.in('idle') || disabledSelector.matches(action, true); - const isValid = isTemplateValid || isSnapshotValid; + ${this.renderTemplateOrSlot()} - const handleClick = (evt: Event) => { - if (action === 'delete') { - const confirm = this.renderRoot.querySelector('#confirm'); - (confirm as InternalConfirmDialog).show(evt.currentTarget as HTMLElement); - } else { - this.submit(); - } - }; + - return html` -
- ${this.renderTemplateOrSlot(`${action}:before`)} - - { - if (!evt.detail.cancelled) this.delete(); - }} + + + + - - - + + + + + + + + - - + + - ${this.renderTemplateOrSlot(`${action}:after`)} -
+ ${super.renderBody()} `; } - private async __cache(): Promise { - this.__cacheState = 'busy'; + protected async _sendPost(edits: Partial): Promise { + const data = await super._sendPost(edits); - try { - const url = this.data?._links['fx:cache'].href ?? ''; + if (edits.content_html_url && edits.content_text_url) { + const url = data._links['fx:cache'].href; const response = await new EmailTemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; - this.refresh(); - } catch { - this.__cacheState = 'fail'; + if (!response.ok) throw ['error:failed_to_cache']; } + + return await this._fetch(data._links.self.href); + } + + protected async _sendPatch(edits: Partial): Promise { + const data = await super._sendPatch(edits); + if (!edits.content_html_url && !edits.content_text_url) return data; + + const url = data._links['fx:cache'].href; + const response = await new EmailTemplateForm.API(this).fetch(url, { method: 'POST' }); + if (!response.ok) throw ['error:failed_to_cache']; + + return await this._fetch(data._links.self.href); } } diff --git a/src/elements/public/EmailTemplateForm/index.ts b/src/elements/public/EmailTemplateForm/index.ts index d327eeb6..bf72b454 100644 --- a/src/elements/public/EmailTemplateForm/index.ts +++ b/src/elements/public/EmailTemplateForm/index.ts @@ -1,14 +1,11 @@ -import '@vaadin/vaadin-text-field/vaadin-text-field'; -import '@vaadin/vaadin-button'; - -import '../../internal/InternalSelectControl/index'; -import '../../internal/InternalConfirmDialog/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalSourceControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalSwitchControl/index'; import '../../internal/InternalTextControl/index'; -import '../../internal/InternalSandbox/index'; +import '../../internal/InternalForm/index'; -import '../Spinner/index'; -import '../I18n/index'; +import './internal/InternalEmailTemplateFormAsyncAction/index'; import { EmailTemplateForm } from './EmailTemplateForm'; diff --git a/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.test.ts b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.test.ts new file mode 100644 index 00000000..13fc5acb --- /dev/null +++ b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.test.ts @@ -0,0 +1,187 @@ +import type { I18n } from '../../../I18n/I18n'; + +import './index'; + +import { InternalEmailTemplateFormAsyncAction as Control } from './InternalEmailTemplateFormAsyncAction'; +import { expect, fixture, oneEvent, waitUntil } from '@open-wc/testing'; +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { createRouter } from '../../../../../server/index'; +import { FetchEvent } from '../../../NucleonElement/FetchEvent'; +import { html } from 'lit-html'; + +describe('EmailTemplateForm', () => { + describe('InternalEmailTemplateFormAsyncAction', () => { + it('imports and defines vaadin-button', () => { + expect(customElements.get('vaadin-button')).to.exist; + }); + + it('imports and defines foxy-internal-control', () => { + expect(customElements.get('foxy-internal-control')).to.exist; + }); + + it('imports and defines foxy-i18n', () => { + expect(customElements.get('foxy-i18n')).to.exist; + }); + + it('imports and defines itself as foxy-internal-email-template-form-async-action', () => { + expect(customElements.get('foxy-internal-email-template-form-async-action')).to.equal( + Control + ); + }); + + it('extends InternalControl', () => { + expect(new Control()).to.be.instanceOf(InternalControl); + }); + + it('has a reactive property "theme" (String, null by default)', () => { + expect(new Control()).to.have.property('theme', null); + expect(Control).to.have.nested.property('properties.theme.type', String); + }); + + it('has a reactive property "href" (String, null by default)', () => { + expect(new Control()).to.have.property('href', null); + expect(Control).to.have.nested.property('properties.href.type', String); + }); + + it('renders themed action button with translatable label', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + expect(button).to.exist; + expect(button).to.not.have.attribute('disabled'); + expect(button).to.have.property('theme', 'error'); + + expect(label).to.exist; + expect(label).to.have.property('infer', ''); + expect(label).to.have.property('key', 'idle'); + }); + + it('sends a POST request to .href on click', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const whenGotEvent = oneEvent(control, 'fetch'); + + button.click(); + const event = await whenGotEvent; + + expect(event).to.be.instanceOf(FetchEvent); + expect(event).to.have.nested.property('request.url', 'https://demo.api/virtual/empty'); + expect(event).to.have.nested.property('request.method', 'POST'); + }); + + it('disables the button and changes its label when sending data', async () => { + const router = createRouter(); + const control = await fixture(html` + router.handleEvent(evt)} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + button.click(); + await control.requestUpdate(); + + expect(button).to.have.attribute('disabled'); + expect(label).to.have.property('key', 'busy'); + }); + + it('switches back to idle display when POST succeeds', async () => { + let fetchCount = 0; + + const router = createRouter(); + const control = await fixture(html` + { + fetchCount++; + router.handleEvent(evt)?.handlerPromise; + }} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + fetchCount = 0; + button.click(); + await waitUntil(() => fetchCount >= 1, undefined, { timeout: 5000 }); + await waitUntil( + () => { + control.requestUpdate(); + return label.key === 'idle'; + }, + undefined, + { timeout: 5000 } + ); + + await control.requestUpdate(); + expect(button).to.not.have.attribute('disabled'); + expect(label).to.have.property('key', 'idle'); + }); + + it('switches to error display when POST fails', async () => { + let fetchCount = 0; + + const router = createRouter(); + const control = await fixture(html` + { + fetchCount++; + router.handleEvent(evt)?.handlerPromise; + }} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + fetchCount = 0; + button.click(); + await waitUntil(() => fetchCount >= 1, undefined, { timeout: 5000 }); + await waitUntil( + () => { + control.requestUpdate(); + return label.key === 'fail'; + }, + undefined, + { timeout: 5000 } + ); + + await control.requestUpdate(); + expect(button).to.not.have.attribute('disabled'); + expect(label).to.have.property('key', 'fail'); + }); + + it('disables the action button when the control is disabled', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + expect(button).to.have.attribute('disabled'); + }); + }); +}); diff --git a/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.ts b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.ts new file mode 100644 index 00000000..c6efaedc --- /dev/null +++ b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/InternalEmailTemplateFormAsyncAction.ts @@ -0,0 +1,53 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; + +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import { html } from 'lit-element'; + +export class InternalEmailTemplateFormAsyncAction extends InternalControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + __state: { type: String }, + theme: { type: String }, + href: { type: String }, + }; + } + + theme: string | null = null; + + href: string | null = null; + + private __state = 'idle'; + + renderControl(): TemplateResult { + const state = this.__state; + const theme = state === 'fail' ? 'error' : state === 'idle' ? '' : ''; + + return html` + + + + `; + } + + private async __submit(): Promise { + if (this.__state === 'busy') return; + + try { + this.__state = 'busy'; + + const api = new NucleonElement.API(this); + const response = await api.fetch(this.href ?? '', { method: 'POST' }); + + this.__state = response.ok ? 'idle' : 'fail'; + if (response.ok) this.nucleon?.refresh(); + } catch { + this.__state = 'fail'; + } + } +} diff --git a/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/index.ts b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/index.ts new file mode 100644 index 00000000..e54b4fcc --- /dev/null +++ b/src/elements/public/EmailTemplateForm/internal/InternalEmailTemplateFormAsyncAction/index.ts @@ -0,0 +1,9 @@ +import '@vaadin/vaadin-button'; +import '../../../../internal/InternalControl/index'; +import '../../../I18n/index'; + +import { InternalEmailTemplateFormAsyncAction as Control } from './InternalEmailTemplateFormAsyncAction'; + +customElements.define('foxy-internal-email-template-form-async-action', Control); + +export { Control as InternalEmailTemplateFormAsyncAction }; diff --git a/src/elements/public/EmailTemplateForm/types.ts b/src/elements/public/EmailTemplateForm/types.ts index 45449430..f3b76f1a 100644 --- a/src/elements/public/EmailTemplateForm/types.ts +++ b/src/elements/public/EmailTemplateForm/types.ts @@ -1,4 +1,5 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -export type Data = Resource; +// TODO: simplify once SDK types are updated +export type Data = Resource & { subject: string }; diff --git a/src/elements/public/StoreForm/StoreForm.stories.ts b/src/elements/public/StoreForm/StoreForm.stories.ts index 212b4a32..101a2ed1 100644 --- a/src/elements/public/StoreForm/StoreForm.stories.ts +++ b/src/elements/public/StoreForm/StoreForm.stories.ts @@ -88,6 +88,7 @@ const summary: Summary = { export default getMeta(summary); const ext = ` + reporting-store-domain-exists="https://demo.api/virtual/empty?status=200" customer-password-hash-types="https://demo.api/hapi/property_helpers/9" shipping-address-types="https://demo.api/hapi/property_helpers/5" h-captcha-site-key="10000000-ffff-ffff-ffff-000000000001" diff --git a/src/elements/public/StoreForm/StoreForm.test.ts b/src/elements/public/StoreForm/StoreForm.test.ts index 87768ffe..96f8247b 100644 --- a/src/elements/public/StoreForm/StoreForm.test.ts +++ b/src/elements/public/StoreForm/StoreForm.test.ts @@ -18,6 +18,7 @@ import { createRouter } from '../../../server/index'; import { I18n } from '../I18n/I18n'; import { stub } from 'sinon'; import { VanillaHCaptchaWebComponent } from 'vanilla-hcaptcha'; +import { getTestData } from '../../../testgen/getTestData'; describe('StoreForm', () => { const OriginalResizeObserver = window.ResizeObserver; @@ -100,6 +101,13 @@ describe('StoreForm', () => { expect(new Form()).to.have.property('ns', 'store-form'); }); + it('has a reactive property "reportingStoreDomainExists"', () => { + expect(new Form()).to.have.property('reportingStoreDomainExists', null); + expect(Form).to.have.deep.nested.property('properties.reportingStoreDomainExists', { + attribute: 'reporting-store-domain-exists', + }); + }); + it('has a reactive property "customerPasswordHashTypes"', () => { expect(new Form()).to.have.property('customerPasswordHashTypes', null); expect(Form).to.have.nested.property('properties.customerPasswordHashTypes'); @@ -504,6 +512,21 @@ describe('StoreForm', () => { expect(control).to.have.attribute('layout', 'summary-item'); }); + it('generates a store domain based on the store name unless a custom one is provided', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="essentials"] foxy-internal-text-control[infer="store-name"]' + ); + + control?.setValue('My Test Store'); + expect(element).to.have.nested.property('form.store_domain', 'my-test-store'); + + element.data = await getTestData('./hapi/stores/0'); + element.data = { ...element.data!, store_domain: 'test' }; + control?.setValue('My Test Store'); + expect(element).to.have.nested.property('form.store_domain', 'test'); + }); + it('renders a text control for logo url in the Essentials section', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( @@ -515,7 +538,11 @@ describe('StoreForm', () => { }); it('renders a text control for store domain in the Essentials section', async () => { - const element = await fixture(html``); + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)}> + `); + const control = element.renderRoot.querySelector( '[infer="essentials"] foxy-internal-text-control[infer="store-domain"]' ) as InternalTextControl; @@ -539,9 +566,27 @@ describe('StoreForm', () => { expect(element).to.have.nested.property('form.use_remote_domain', false); expect(element).to.have.nested.property('form.store_domain', 'test'); + control.setValue('test.com'); + expect(element).to.have.nested.property('form.use_remote_domain', false); + expect(element).to.have.nested.property('form.store_domain', 'test'); + + element.data = await getTestData('./hapi/stores/0'); control.setValue('test.com'); expect(element).to.have.nested.property('form.use_remote_domain', true); expect(element).to.have.nested.property('form.store_domain', 'test.com'); + + expect(control).to.have.property('checkValidityAsync', null); + element.reportingStoreDomainExists = 'https://demo.api/virtual/empty?status=200'; + element.edit({ store_domain: 'domain-one' }); + await element.requestUpdate(); + expect(await control.checkValidityAsync?.('domain-one')).to.be.true; + + element.reportingStoreDomainExists = 'https://demo.api/virtual/empty?status=400'; + element.edit({ store_domain: 'domain-two' }); + await element.requestUpdate(); + expect(await control.checkValidityAsync?.('domain-two')).to.equal( + 'store-domain:v8n_unavailable' + ); }); it('renders a text control for store url in the Essentials section', async () => { @@ -2302,6 +2347,11 @@ describe('StoreForm', () => { ); }); + it('renders default slot', async () => { + const element = await fixture(html``); + expect(element.renderRoot.querySelector('slot:not([name])')).to.exist; + }); + it('renders a hCaptcha element when hCaptchaSiteKey is set', async () => { const form = await fixture(html``); let control = form.renderRoot.querySelector('h-captcha'); @@ -2400,4 +2450,64 @@ describe('StoreForm', () => { const headers = evt.request.headers; expect(headers.get('h-captcha-code')).to.be.null; }); + + it('produces error:store_domain_reserved when trying to use a reserved domain', async () => { + const element = await fixture(html` + { + const message = 'store_domain is invalid because it has a reserved format'; + const body = JSON.stringify({ _embedded: { 'fx:errors': [{ message }] } }); + evt.respondWith(Promise.resolve(new Response(body, { status: 500 }))); + }} + > + + `); + + expect(element.errors).to.not.include('error:store_domain_reserved'); + + element.href = 'https://demo.api/hapi/stores/0'; + await waitUntil(() => element.in('idle')); + + expect(element.errors).to.include('error:store_domain_reserved'); + }); + + it('produces error:store_domain_reserved when trying to use an internal domain', async () => { + const element = await fixture(html` + { + const message = 'store_domain can not end with "-internal"'; + const body = JSON.stringify({ _embedded: { 'fx:errors': [{ message }] } }); + evt.respondWith(Promise.resolve(new Response(body, { status: 500 }))); + }} + > + + `); + + expect(element.errors).to.not.include('error:store_domain_reserved'); + + element.href = 'https://demo.api/hapi/stores/0'; + await waitUntil(() => element.in('idle')); + + expect(element.errors).to.include('error:store_domain_reserved'); + }); + + it('produces error:store_domain_exists when trying to use the domain of another store', async () => { + const element = await fixture(html` + { + const message = 'store_domain is already in use'; + const body = JSON.stringify({ _embedded: { 'fx:errors': [{ message }] } }); + evt.respondWith(Promise.resolve(new Response(body, { status: 500 }))); + }} + > + + `); + + expect(element.errors).to.not.include('error:store_domain_exists'); + + element.href = 'https://demo.api/hapi/stores/0'; + await waitUntil(() => element.in('idle')); + + expect(element.errors).to.include('error:store_domain_exists'); + }); }); diff --git a/src/elements/public/StoreForm/StoreForm.ts b/src/elements/public/StoreForm/StoreForm.ts index 84bd4b93..8ebd7667 100644 --- a/src/elements/public/StoreForm/StoreForm.ts +++ b/src/elements/public/StoreForm/StoreForm.ts @@ -21,6 +21,7 @@ import { ifDefined } from 'lit-html/directives/if-defined'; import { html } from 'lit-html'; import cloneDeep from 'lodash-es/cloneDeep'; +import slugify from '@sindresorhus/slugify'; const NS = 'store-form'; const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); @@ -35,6 +36,7 @@ export class StoreForm extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + reportingStoreDomainExists: { attribute: 'reporting-store-domain-exists' }, customerPasswordHashTypes: { attribute: 'customer-password-hash-types' }, shippingAddressTypes: { attribute: 'shipping-address-types' }, hCaptchaSiteKey: { attribute: 'h-captcha-site-key' }, @@ -52,8 +54,31 @@ export class StoreForm extends Base { return [ ({ store_name: v }) => !!v || 'store-name:v8n_required', ({ store_name: v }) => (v && v.length <= 50) || 'store-name:v8n_too_long', - ({ store_domain: v }) => !!v || 'store-domain:v8n_required', - ({ store_domain: v }) => (v && v.length <= 100) || 'store-domain:v8n_too_long', + ({ store_domain: storeDomain, use_remote_domain: useRemoteDomain }) => { + if (!storeDomain) return 'store-domain:v8n_required'; + if (storeDomain.length > 100) return 'store-domain:v8n_too_long'; + + const [tld, ...slds] = storeDomain.split('.').reverse(); + + if ( + useRemoteDomain && + slds.length > 1 && + slds.every(s => /^(?!-)[a-z0-9-]{1,63}(? !!v || 'store-url:v8n_required', ({ store_url: v }) => (v && v.length <= 300) || 'store-url:v8n_too_long', ({ receipt_continue_url: v }) => !v || v.length <= 300 || 'receipt-continue-url:v8n_too_long', @@ -102,6 +127,9 @@ export class StoreForm extends Base { ]; } + /** URL of the `fx:reporting_store_domain_exists` endpoint. */ + reportingStoreDomainExists: string | null = null; + /** URL of the `fx:customer_password_hash_types` property helper resource. */ customerPasswordHashTypes: string | null = null; @@ -191,6 +219,32 @@ export class StoreForm extends Base { { value: 'd', label: 'day' }, ]; + private readonly __checkStoreDomainAvailability = async (value: string) => { + if (this.reportingStoreDomainExists) { + if (value === this.data?.store_domain) return true; + + const url = new URL(this.reportingStoreDomainExists); + url.searchParams.set('store_domain', value); + + const response = await new StoreForm.API(this).fetch(url.toString()); + return response.ok || 'store-domain:v8n_unavailable'; + } else { + throw new Error('reportingStoreDomainExists is not set.'); + } + }; + + private readonly __storeNameSetValue = (newValue: string) => { + const previousSuggestedDomain = slugify(this.form.store_name ?? ''); + const currentStoreDomain = this.form.store_domain ?? ''; + const newSuggestedDomain = slugify(newValue); + + if (previousSuggestedDomain === currentStoreDomain) { + this.edit({ store_domain: newSuggestedDomain }); + } + + this.edit({ store_name: newValue }); + }; + private readonly __getStoreEmailValue = (): Item[] => { const emails = this.form.store_email ?? ''; return emails @@ -242,11 +296,15 @@ export class StoreForm extends Base { }; private readonly __setStoreDomainValue = (newValue: string) => { - if (newValue.endsWith('.foxycart.com')) { - const domain = newValue.substring(0, newValue.length - 13); - this.edit({ store_domain: domain, use_remote_domain: domain.includes('.') }); + if (this.data) { + if (newValue.endsWith('.foxycart.com')) { + const domain = newValue.substring(0, newValue.length - 13); + this.edit({ store_domain: domain, use_remote_domain: false }); + } else { + this.edit({ store_domain: newValue, use_remote_domain: newValue.includes('.') }); + } } else { - this.edit({ store_domain: newValue, use_remote_domain: newValue.includes('.') }); + this.edit({ store_domain: newValue.split('.')[0] ?? '', use_remote_domain: false }); } }; @@ -400,7 +458,11 @@ export class StoreForm extends Base { ${this.renderHeader()} - + @@ -412,6 +474,9 @@ export class StoreForm extends Base { suffix=${storeDomainSuffix} infer="store-domain" .setValue=${this.__setStoreDomainValue} + .checkValidityAsync=${this.reportingStoreDomainExists + ? this.__checkStoreDomainAvailability + : null} > @@ -857,6 +922,7 @@ export class StoreForm extends Base { : ''} + ${this.renderTemplateOrSlot()} ${this.href || !this.hCaptchaSiteKey ? '' : html` @@ -911,9 +977,32 @@ export class StoreForm extends Base { } protected async _fetch(...args: Parameters): Promise { - const request = new StoreForm.API.WHATWGRequest(...args); - if (this.__hCaptchaToken) request.headers.set('h-captcha-code', this.__hCaptchaToken); - return super._fetch(request); + try { + const request = new StoreForm.API.WHATWGRequest(...args); + if (this.__hCaptchaToken) request.headers.set('h-captcha-code', this.__hCaptchaToken); + return await super._fetch(request); + } catch (err) { + let errorText; + + try { + errorText = await (err as Response).text(); + } catch { + throw err; + } + + if ( + errorText.includes('store_domain is invalid because it has a reserved format') || + errorText.includes('store_domain can not end with') + ) { + throw ['error:store_domain_reserved']; + } + + if (errorText.includes('store_domain is already in use')) { + throw ['error:store_domain_exists']; + } + + throw err; + } } private get __displayIdExamples() { diff --git a/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.test.ts b/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.test.ts index d9873849..e5e0a561 100644 --- a/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.test.ts +++ b/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.test.ts @@ -718,5 +718,8 @@ describe('StoreShippingMethodForm', () => { 'links-href', 'https://demo.api/hapi/store_shipping_services?shipping_method_id=0' ); + + await waitUntil(() => !!control.ownUri, undefined, { timeout: 5000 }); + expect(control.ownUri).to.equal('https://demo.api/hapi/shipping_methods/0'); }); }); diff --git a/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.ts b/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.ts index 4deefc8a..012c1842 100644 --- a/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.ts +++ b/src/elements/public/StoreShippingMethodForm/StoreShippingMethodForm.ts @@ -186,7 +186,7 @@ export class StoreShippingMethodForm extends Base { foreign-key-for-uri="shipping_service_uri" foreign-key-for-id="shipping_service_id" own-key-for-uri="shipping_method_uri" - own-uri=${ifDefined(this.data?._links.self.href)} + own-uri=${ifDefined(shippingMethod?._links.self.href)} embed-key="fx:store_shipping_services" options-href=${ifDefined(shippingMethod?._links['fx:shipping_services'].href)} links-href=${ifDefined(this.data?._links['fx:store_shipping_services'].href)} diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts index 6772afbd..00f7c602 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts @@ -25,11 +25,11 @@ const summary: Summary = { 'past-due-amount-group:past-due-amount-handling', 'past-due-amount-group:automatically-charge-past-due-amount', 'past-due-amount-group:reset-nextdate-on-makeup-payment', + 'past-due-amount-group:send-email-receipts-for-automated-billing', 'past-due-amount-group:prevent-customer-cancel-with-past-due', 'reattempts-group:reattempt-bypass-logic', 'reattempts-group:reattempt-bypass-strings', 'reattempts-group:reattempt-schedule', - 'emails-group:send-email-receipts-for-automated-billing', 'emails-group:reminder-email-schedule', 'emails-group:expiring-soon-payment-reminder-schedule', 'cancellation-group:cancellation-schedule', diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts index 0d39c316..2d881a0b 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts @@ -144,6 +144,12 @@ export class SubscriptionSettingsForm extends Base { > + + + { - - - { - it('extends NucleonElement', () => { - expect(new TemplateForm()).to.be.instanceOf(NucleonElement); + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); - it('registers as foxy-template-form', () => { - expect(customElements.get('foxy-template-form')).to.equal(TemplateForm); + it('imports and defines foxy-internal-source-control', () => { + expect(customElements.get('foxy-internal-source-control')).to.exist; }); - it('has a default i18next namespace of "template-form"', () => { - expect(new TemplateForm()).to.have.property('ns', 'template-form'); + it('imports and defines foxy-internal-text-control', () => { + expect(customElements.get('foxy-internal-text-control')).to.exist; }); - describe('description', () => { - it('has i18n label key "description"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - - expect(control).to.have.property('label', 'description'); - }); - - it('has value of form.description', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ description: 'Test template' }); - - const control = await getByTestId(element, 'description'); - expect(control).to.have.property('value', 'Test template'); - }); - - it('writes to form.description on input', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - - control!.value = 'Test template'; - control!.dispatchEvent(new CustomEvent('input')); - - expect(element).to.have.nested.property('form.description', 'Test template'); - }); - - it('submits valid form on enter', async () => { - const validData = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'description'); - const submit = stub(element, 'submit'); - - element.data = validData; - element.edit({ description: 'Test template', content: '' }); - control!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('renders "description:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByName(element, 'description:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "description:before" slot with template "description:before" if available', async () => { - const description = 'description:before'; - const value = `

Value of the "${description}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, description); - const sandbox = (await getByTestId(element, description))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders "description:after" slot by default', async () => { - const element = await fixture(html``); - const slot = await getByName(element, 'description:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "description:after" slot with template "description:after" if available', async () => { - const description = 'description:after'; - const value = `

Value of the "${description}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, description); - const sandbox = (await getByTestId(element, description))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes description', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is loading', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when form has failed to load data', async () => { - const href = 'https://demo.api/virtual/empty?status=404'; - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes description', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes description', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'description')).to.not.exist; - }); + it('imports and defines foxy-internal-form', () => { + expect(customElements.get('foxy-internal-form')).to.exist; }); - describe('content', () => { - it('has i18n label key "template"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(await getByKey(control, 'template')).to.exist; - }); - - it('renders a choice element with content types', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - - expect(choice).to.be.instanceOf(Choice); - expect(choice).to.have.deep.property('items', ['default', 'url', 'clipboard']); - }); - - it('pre-selects default content type by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - - expect(choice).to.have.property('value', 'default'); - }); - - it('pre-selects url content type if content_url is set', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - expect(choice).to.have.property('value', 'url'); - }); - - it('pre-selects clipboard content type if content is set', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content: 'Test Template' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - expect(choice).to.have.property('value', 'clipboard'); - }); - - it('clears content and content_url on choice change', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content: 'Test Template', content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - choice.dispatchEvent(new ChoiceChangeEvent('url')); - await element.requestUpdate(); - - expect(choice).to.have.property('value', 'url'); - expect(element.form).to.have.property('content', ''); - expect(element.form).to.have.property('content_url', ''); - }); - - ['default', 'url', 'clipboard'].forEach(contentType => { - it(`renders title for choice "${contentType}"`, async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector(`[slot="${contentType}-label"]`) as HTMLElement; - - expect(await getByKey(wrapper, `template_${contentType}`)).to.exist; - }); - }); - - it('shows url field for url content type', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]'); - expect(wrapper).to.not.have.attribute('hidden'); - }); - - it('sets value of form.content_url to the text field', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = await getByTestId(wrapper, 'content-url'); - - expect(field).to.have.value('https://example.com'); - }); - - it('content url field writes to form.content_url on input', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = (await getByTestId(wrapper, 'content-url')) as TextFieldElement; - - field.value = 'https://example.com/foo'; - field.dispatchEvent(new CustomEvent('input')); - - expect(element.form).to.have.property('content_url', 'https://example.com/foo'); - }); - - it('content url field submits valid form on Enter', async () => { - const layout = html``; - const element = await fixture(layout); - const submit = stub(element, 'submit'); - element.edit({ description: 'Test', content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const field = (await getByTestId(wrapper, 'content-url')) as TextFieldElement; - field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('shows Cache button next to the content url field', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content_url: 'https://example.com' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const button = (await getByTestId(wrapper, 'cache')) as ButtonElement; - - expect(button).to.exist; - expect(button.firstElementChild).to.have.property('key', 'cache'); - expect(button.firstElementChild).to.be.instanceOf(I18n); - }); - - it('POSTs to fx:cache once Cache button is clicked', async () => { - const layout = html``; - const element = await fixture(layout); - element.data = await getTestData('./hapi/cart_templates/0'); - - const whenFetchEventFired = oneEvent(element, 'fetch') as unknown as Promise; - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const wrapper = choice.querySelector('[slot="url"]') as HTMLElement; - const button = (await getByTestId(wrapper, 'cache')) as ButtonElement; - button.click(); - - const event = await whenFetchEventFired; - expect(event).to.have.nested.property('request.url', element.data?._links['fx:cache'].href); - expect(event).to.have.nested.property('request.method', 'POST'); - }); - - it('shows source control for clipboard content type', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ content: 'Test Template' }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const field = choice.querySelector('[slot="clipboard"]'); - - expect(field).to.have.attribute('placeholder', 'clipboard_source_placeholder'); - expect(field).to.have.attribute('label', 'clipboard_source_label'); - expect(field).to.have.attribute('infer', 'content'); - - expect(field).to.not.have.attribute('hidden'); - expect(field).to.be.instanceOf(InternalSourceControl); - }); - - it('shows source control for url content type', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ - content_url: 'https://example.com/template', - content: 'Test Template', - }); - - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = (await getByTestId(control, 'content-type')) as Choice; - const group = choice.querySelector('[slot="url"]'); - const field = group?.querySelector('foxy-internal-source-control'); - - expect(field).to.have.attribute('placeholder', 'url_source_placeholder'); - expect(field).to.have.attribute('label', 'url_source_label'); - expect(field).to.have.attribute('infer', 'content'); - - expect(field).to.be.instanceOf(InternalSourceControl); - }); - - it('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - - expect(choice).not.to.have.attribute('readonly'); - expect(urlField).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - - expect(choice).to.have.attribute('readonly'); - expect(urlField).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - - expect(choice).to.have.attribute('readonly'); - expect(urlField).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - const cacheButton = await getByTestId(control, 'cache'); - - expect(choice).not.to.have.attribute('disabled'); - expect(urlField).not.to.have.attribute('disabled'); - expect(cacheButton).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is loading', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - const cacheButton = await getByTestId(control, 'cache'); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when form has failed to load data', async () => { - const href = 'https://demo.api/virtual/empty?status=404'; - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - const cacheButton = await getByTestId(control, 'cache'); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - const cacheButton = await getByTestId(control, 'cache'); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - const choice = await getByTestId(control, 'content-type'); - const urlField = await getByTestId(control, 'content-url'); - const cacheButton = await getByTestId(control, 'cache'); - - expect(choice).to.have.attribute('disabled'); - expect(urlField).to.have.attribute('disabled'); - expect(cacheButton).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.not.exist; - }); - - it('is hidden when hiddencontrols includes content', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'content')) as HTMLElement; - - expect(control).to.not.exist; - }); + it('imports and defines foxy-internal-template-form-async-action', () => { + expect(customElements.get('foxy-internal-template-form-async-action')).to.exist; }); - describe('timestamps', () => { - it('once form data is loaded, renders a property table with created and modified dates', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'timestamps'); - const items = [ - { name: 'date_modified', value: 'date' }, - { name: 'date_created', value: 'date' }, - ]; - - expect(control).to.have.deep.property('items', items); - }); - - it('once form data is loaded, renders "timestamps:before" slot', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'timestamps:before'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('once form data is loaded, replaces "timestamps:before" slot with template "timestamps:before" if available', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const name = 'timestamps:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('once form data is loaded, renders "timestamps:after" slot', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'timestamps:after'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('once form data is loaded, replaces "timestamps:after" slot with template "timestamps:after" if available', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const name = 'timestamps:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + it('defines itself as foxy-template-form', () => { + expect(customElements.get('foxy-template-form')).to.equal(Form); + }); + + it('extends foxy-internal-form', () => { + expect(new Form()).to.be.instanceOf(customElements.get('foxy-internal-form')); + }); - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + it('has a default i18next namespace of "template-form"', () => { + expect(new Form().ns).to.equal('template-form'); + expect(Form.defaultNS).to.equal('template-form'); + }); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + it('makes content control read-only when content_url is set', () => { + const form = new Form(); + expect(form.readonlySelector.matches('content', true)).to.be.false; + form.edit({ content_url: 'foo' }); + expect(form.readonlySelector.matches('content', true)).to.be.true; }); - describe('create', () => { - it('if data is empty, renders create button', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.exist; - }); - - it('renders with i18n key "create" for caption', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'create'); - const caption = control?.firstElementChild; - - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'create'); - expect(caption).to.have.attribute('ns', 'template-form'); - }); - - it('renders disabled if form is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is invalid', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is sending changes', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ description: 'Foo' }); - element.submit(); - - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if disabledcontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('submits valid form on click', async () => { - const element = await fixture(html``); - const submit = stub(element, 'submit'); - element.edit({ description: 'Foo' }); - - const control = await getByTestId(element, 'create'); - control!.dispatchEvent(new CustomEvent('click')); - - expect(submit).to.have.been.called; - }); - - it("doesn't render if form is hidden", async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); - - it('doesn\'t render if hiddencontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); - - it('renders with "create:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'create:before'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "create:before" slot with template "create:before" if available and rendered', async () => { - const name = 'create:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders with "create:after" slot by default', async () => { - const element = await fixture(html``); - const slot = await getByName(element, 'create:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "create:after" slot with template "create:after" if available and rendered', async () => { - const name = 'create:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + it('makes Cache button disabled when content_url is not set in data or when form is dirty', async () => { + const form = new Form(); + expect(form.disabledSelector.matches('source:cache', true)).to.be.true; - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + const data = await getTestData('./hapi/cart_templates/0'); + data.content_url = ''; + form.data = { ...data }; + expect(form.disabledSelector.matches('source:cache', true)).to.be.true; - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + data.content_url = 'foo'; + form.data = { ...data }; + expect(form.disabledSelector.matches('source:cache', true)).to.be.false; }); - describe('delete', () => { - it('renders delete button once resource is loaded', async () => { - const href = './hapi/cart_templates/0'; - const data = await getTestData(href); - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'delete')).to.exist; - }); - - it('renders with i18n key "delete" for caption', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'delete'); - const caption = control?.firstElementChild; - - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'delete'); - expect(caption).to.have.attribute('ns', 'template-form'); - }); - - it('renders disabled if form is disabled', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is sending changes', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - - element.edit({ description: 'Foo' }); - element.submit(); - - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); - - it('renders disabled if disabledcontrols includes "delete"', async () => { - const element = await fixture(html` - ('./hapi/cart_templates/0')} - disabledcontrols="delete" - > - - `); + it('hides Cache HTML button when content_url is not set in data', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('source:cache', true)).to.be.true; - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + const data = await getTestData('./hapi/cart_templates/0'); + data.content_url = ''; + form.data = { ...data }; + expect(form.hiddenSelector.matches('source:cache', true)).to.be.true; - it('shows deletion confirmation dialog on click', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'delete'); - const confirm = await getByTestId(element, 'confirm'); - const showMethod = stub(confirm!, 'show'); + data.content_url = 'foo'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('source:cache', true)).to.be.false; + }); - control!.dispatchEvent(new CustomEvent('click')); + it('renders a form header', () => { + const form = new Form(); + const renderHeaderMethod = stub(form, 'renderHeader'); + form.render(); + expect(renderHeaderMethod).to.have.been.called; + }); - expect(showMethod).to.have.been.called; - }); + it('renders General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="general"]'); + expect(control?.localName).to.equal('foxy-internal-summary-control'); + }); - it('deletes resource if deletion is confirmed', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + it('renders a text control for Description in General summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="general"] foxy-internal-text-control[infer="description"]' + ); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(false)); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(deleteMethod).to.have.been.called; - }); + it('renders a default slot', async () => { + const form = await fixture(html` `); + expect(form.renderRoot.querySelector('slot:not([name])')).to.exist; + }); - it('keeps resource if deletion is cancelled', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + it('renders a source control for Content', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="content"]'); + expect(control?.localName).to.equal('foxy-internal-source-control'); + }); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(true)); + it('renders a summary control for Source', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector('[infer="source"]'); + expect(control?.localName).to.equal('foxy-internal-summary-control'); + }); - expect(deleteMethod).not.to.have.been.called; - }); + it('renders a text control for Content URL in Source summary', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + '[infer="source"] foxy-internal-text-control[infer="content-url"]' + ); - it("doesn't render if form is hidden", async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - expect(await getByTestId(element, 'delete')).to.not.exist; - }); + it('renders an async action control for caching Content in Source summary', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, '', { timeout: 5000 }); + await form.requestUpdate(); + const control = form.renderRoot.querySelector( + '[infer="source"] foxy-internal-template-form-async-action[infer="cache"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('theme', 'tertiary-inline'); + expect(control).to.have.attribute('href', form.data!._links['fx:cache'].href); + }); - it('doesn\'t render if hiddencontrols includes "delete"', async () => { - const element = await fixture(html` + it('caches content on POST', async () => { + const requests: Request[] = []; + const router = createRouter(); + const form = await fixture( + html` ('./hapi/cart_templates/0')} - hiddencontrols="delete" + parent="https://demo.api/hapi/cart_templates" + @fetch=${(evt: FetchEvent) => { + if (evt.defaultPrevented) return; + requests.push(evt.request); + router.handleEvent(evt); + }} > - `); - - expect(await getByTestId(element, 'delete')).to.not.exist; - }); - - it('renders with "delete:before" slot by default', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:before'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "delete:before" slot with template "delete:before" if available and rendered', async () => { - const href = './hapi/cart_templates/0'; - const name = 'delete:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders with "delete:after" slot by default', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:after'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "delete:after" slot with template "delete:after" if available and rendered', async () => { - const href = './hapi/cart_templates/0'; - const name = 'delete:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + ` + ); + + form.edit({ content_url: 'https://example.com' }); + requests.length = 0; + form.submit(); + await waitUntil(() => requests.length >= 3, '', { timeout: 5000 }); + const cacheRequest = requests.find( + req => req.method === 'POST' && req.url === form.data?._links['fx:cache'].href + ); + + expect(cacheRequest).to.exist; }); - describe('spinner', () => { - it('renders foxy-spinner in "busy" state while loading data', async () => { - const router = createRouter(); - const layout = html` + it('caches content on PATCH', async () => { + const requests: Request[] = []; + const router = createRouter(); + const form = await fixture( + html` router.handleEvent(evt)} + href="https://demo.api/hapi/cart_templates/0" + @fetch=${(evt: FetchEvent) => { + if (evt.defaultPrevented) return; + requests.push(evt.request); + router.handleEvent(evt); + }} > - `; - - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; - - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'busy'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'template-form spinner'); - }); - - it('renders foxy-spinner in "error" state if loading data fails', async () => { - const href = './hapi/not-found'; - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; - - await waitUntil(() => element.in('fail'), undefined, { timeout: 5000 }); - - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'error'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'template-form spinner'); - }); - - it('hides spinner once loaded', async () => { - const data = await getTestData('./hapi/cart_templates/0'); - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - - expect(spinnerWrapper).to.have.class('opacity-0'); - }); + ` + ); + + await waitUntil(() => !!form.data, '', { timeout: 5000 }); + + form.edit({ content_url: 'https://example.com' }); + requests.length = 0; + form.submit(); + await waitUntil(() => requests.length >= 3, '', { timeout: 5000 }); + const cacheRequest = requests.find( + req => req.method === 'POST' && req.url === form.data?._links['fx:cache'].href + ); + + expect(cacheRequest).to.exist; }); }); diff --git a/src/elements/public/TemplateForm/TemplateForm.ts b/src/elements/public/TemplateForm/TemplateForm.ts index 498a3429..1392f712 100644 --- a/src/elements/public/TemplateForm/TemplateForm.ts +++ b/src/elements/public/TemplateForm/TemplateForm.ts @@ -1,22 +1,14 @@ -import { PropertyDeclarations, TemplateResult, html } from 'lit-element'; -import { Choice, Group, Metadata } from '../../private/index'; -import { Data } from './types'; -import { ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements'; +import type { TemplateResult } from 'lit-element'; +import type { Data } from './types'; -import { ChoiceChangeEvent } from '../../private/events'; -import { ConfigurableMixin } from '../../../mixins/configurable'; -import { DialogHideEvent } from '../../private/Dialog/DialogHideEvent'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { TextFieldElement } from '@vaadin/vaadin-text-field'; -import { ThemeableMixin } from '../../../mixins/themeable'; import { TranslatableMixin } from '../../../mixins/translatable'; -import { classMap } from '../../../utils/class-map'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-element'; const NS = 'template-form'; -const Base = ScopedElementsMixin( - ThemeableMixin(ConfigurableMixin(TranslatableMixin(NucleonElement, NS))) -); +const Base = TranslatableMixin(InternalForm, NS); /** * Form element for creating or editing templates (`fx:cart_include_template`, `fx:checkout_template`, `fx:cart_template`). @@ -25,74 +17,65 @@ const Base = ScopedElementsMixin( * @since 1.14.0 */ export class TemplateForm extends Base { - static get properties(): PropertyDeclarations { - return { - ...super.properties, - __cacheState: { attribute: false }, - __contentChoice: { attribute: false }, - }; + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + if (this.form.content_url) alwaysMatch.unshift('content'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - static get scopedElements(): ScopedElementsMap { - return { - 'foxy-internal-confirm-dialog': customElements.get('foxy-internal-confirm-dialog'), - 'foxy-internal-source-control': customElements.get('foxy-internal-source-control'), - 'foxy-internal-sandbox': customElements.get('foxy-internal-sandbox'), - 'foxy-spinner': customElements.get('foxy-spinner'), - 'foxy-i18n': customElements.get('foxy-i18n'), - 'vaadin-text-field': customElements.get('vaadin-text-field'), - 'vaadin-button': customElements.get('vaadin-button'), - 'x-metadata': Metadata, - 'x-choice': Choice, - 'x-group': Group, - }; - } + get disabledSelector(): BooleanSelector { + const alwaysMatch = [super.disabledSelector.toString()]; - private __cacheState: 'idle' | 'busy' | 'fail' = 'idle'; + if (!this.in({ idle: { snapshot: 'clean' } }) || !this.data.content_url) { + alwaysMatch.unshift('source:cache'); + } - private __contentChoice: 'default' | 'url' | 'clipboard' = 'default'; + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } - render(): TemplateResult { - const { hiddenSelector, href, lang, ns } = this; - const action = href ? 'delete' : 'create'; - const isBusy = this.in('busy'); - const isFail = this.in('fail'); + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + if (!this.data?.content_url) alwaysMatch.unshift('source:cache'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + renderBody(): TemplateResult { return html` -
- ${hiddenSelector.matches('description', true) ? '' : this.__renderDescription()} - ${hiddenSelector.matches('content', true) ? '' : this.__renderContent()} - ${hiddenSelector.matches('timestamps', true) || !href ? '' : this.__renderTimestamps()} - ${hiddenSelector.matches(action) ? '' : this.__renderAction(action)} + ${this.renderHeader()} + + + + + + + ${this.renderTemplateOrSlot()} + + + + + + -
- - -
-
+ +
+ + ${super.renderBody()} `; } protected async _sendPost(edits: Partial): Promise { const data = await super._sendPost(edits); - if (!data.content_url) return data; - this.__cacheState = 'busy'; - const url = data._links['fx:cache'].href; - const response = await new TemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; + if (edits.content_url) { + const url = data._links['fx:cache'].href; + const response = await new TemplateForm.API(this).fetch(url, { method: 'POST' }); + if (!response.ok) throw ['error:failed_to_cache']; + } return await this._fetch(data._links.self.href); } @@ -101,254 +84,10 @@ export class TemplateForm extends Base { const data = await super._sendPatch(edits); if (!edits.content_url) return data; - this.__cacheState = 'busy'; const url = data._links['fx:cache'].href; const response = await new TemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; + if (!response.ok) throw ['error:failed_to_cache']; return await this._fetch(data._links.self.href); } - - private __renderDescription() { - const scope = 'description'; - - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - evt.key === 'Enter' && this.submit()} - @input=${(evt: CustomEvent) => { - this.edit({ description: (evt.currentTarget as TextFieldElement).value }); - }} - > - - - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; - } - - private __renderContent() { - const scope = 'content'; - const url = this.form.content_url; - const source = this.form.content; - const contentChoice = url ? 'url' : source ? 'clipboard' : this.__contentChoice; - - const isDisabled = !this.in('idle') || this.disabledSelector.matches(scope); - const isReadonly = this.readonlySelector.matches(scope); - const isSyncProhibited = isReadonly || !this.data?.content_url || url !== this.data.content_url; - - return html` -
- - - - - { - if (evt instanceof ChoiceChangeEvent) { - this.edit({ content: '', content_url: '' }); - this.__contentChoice = evt.detail as 'url' | 'clipboard' | 'default'; - } - }} - > - ${['default', 'url', 'clipboard'].map(value => { - return html` -
- - -
- `; - })} - -
-
- evt.key === 'Enter' && this.submit()} - @input=${(evt: CustomEvent) => { - const value = (evt.currentTarget as TextFieldElement).value; - this.edit({ content: '', content_url: value }); - }} - > - - - - - - -
- - -
-
-
- - - -
- - - -
-
-
- `; - } - - private __renderTimestamps() { - const scope = 'timestamps'; - - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - ({ - name: this.t(field), - value: this.data?.[field] - ? this.t('date', { value: new Date(this.data[field] as string) }) - : '', - }))} - > - - - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; - } - - private __renderAction(action: string) { - const { disabledSelector, href, lang, ns } = this; - - const isTemplateValid = this.in({ idle: { template: { dirty: 'valid' } } }); - const isSnapshotValid = this.in({ idle: { snapshot: { dirty: 'valid' } } }); - const isDisabled = !this.in('idle') || disabledSelector.matches(action, true); - const isValid = isTemplateValid || isSnapshotValid; - - const handleClick = (evt: Event) => { - if (action === 'delete') { - const confirm = this.renderRoot.querySelector('#confirm'); - (confirm as InternalConfirmDialog).show(evt.currentTarget as HTMLElement); - } else { - this.submit(); - } - }; - - return html` -
- ${this.renderTemplateOrSlot(`${action}:before`)} - - { - if (!evt.detail.cancelled) this.delete(); - }} - > - - - - - - - ${this.renderTemplateOrSlot(`${action}:after`)} -
- `; - } - - private async __cache(): Promise { - this.__cacheState = 'busy'; - - try { - const url = this.data?._links['fx:cache'].href ?? ''; - const response = await new TemplateForm.API(this).fetch(url, { method: 'POST' }); - this.__cacheState = response.ok ? 'idle' : 'fail'; - this.refresh(); - } catch { - this.__cacheState = 'fail'; - } - } } diff --git a/src/elements/public/TemplateForm/index.ts b/src/elements/public/TemplateForm/index.ts index ffa8f8c1..5caab520 100644 --- a/src/elements/public/TemplateForm/index.ts +++ b/src/elements/public/TemplateForm/index.ts @@ -1,10 +1,9 @@ -import '@vaadin/vaadin-text-field/vaadin-text-field'; -import '@vaadin/vaadin-button'; -import '../../internal/InternalConfirmDialog/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalSourceControl/index'; -import '../../internal/InternalSandbox/index'; -import '../Spinner/index'; -import '../I18n/index'; +import '../../internal/InternalTextControl/index'; +import '../../internal/InternalForm/index'; + +import './internal/InternalTemplateFormAsyncAction/index'; import { TemplateForm } from './TemplateForm'; diff --git a/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.test.ts b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.test.ts new file mode 100644 index 00000000..8ab18581 --- /dev/null +++ b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.test.ts @@ -0,0 +1,182 @@ +import type { I18n } from '../../../I18n/I18n'; + +import './index'; + +import { InternalTemplateFormAsyncAction as Control } from './InternalTemplateFormAsyncAction'; +import { expect, fixture, oneEvent, waitUntil } from '@open-wc/testing'; +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { createRouter } from '../../../../../server/index'; +import { FetchEvent } from '../../../NucleonElement/FetchEvent'; +import { html } from 'lit-html'; + +describe('TemplateForm', () => { + describe('InternalTemplateFormAsyncAction', () => { + it('imports and defines vaadin-button', () => { + expect(customElements.get('vaadin-button')).to.exist; + }); + + it('imports and defines foxy-internal-control', () => { + expect(customElements.get('foxy-internal-control')).to.exist; + }); + + it('imports and defines foxy-i18n', () => { + expect(customElements.get('foxy-i18n')).to.exist; + }); + + it('imports and defines itself as foxy-internal-template-form-async-action', () => { + expect(customElements.get('foxy-internal-template-form-async-action')).to.equal(Control); + }); + + it('extends InternalControl', () => { + expect(new Control()).to.be.instanceOf(InternalControl); + }); + + it('has a reactive property "theme" (String, null by default)', () => { + expect(new Control()).to.have.property('theme', null); + expect(Control).to.have.nested.property('properties.theme.type', String); + }); + + it('has a reactive property "href" (String, null by default)', () => { + expect(new Control()).to.have.property('href', null); + expect(Control).to.have.nested.property('properties.href.type', String); + }); + + it('renders themed action button with translatable label', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + expect(button).to.exist; + expect(button).to.not.have.attribute('disabled'); + expect(button).to.have.property('theme', 'error'); + + expect(label).to.exist; + expect(label).to.have.property('infer', ''); + expect(label).to.have.property('key', 'idle'); + }); + + it('sends a POST request to .href on click', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const whenGotEvent = oneEvent(control, 'fetch'); + + button.click(); + const event = await whenGotEvent; + + expect(event).to.be.instanceOf(FetchEvent); + expect(event).to.have.nested.property('request.url', 'https://demo.api/virtual/empty'); + expect(event).to.have.nested.property('request.method', 'POST'); + }); + + it('disables the button and changes its label when sending data', async () => { + const router = createRouter(); + const control = await fixture(html` + router.handleEvent(evt)} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + button.click(); + await control.requestUpdate(); + + expect(button).to.have.attribute('disabled'); + expect(label).to.have.property('key', 'busy'); + }); + + it('switches back to idle display when POST succeeds', async () => { + let fetchCount = 0; + + const router = createRouter(); + const control = await fixture(html` + { + fetchCount++; + router.handleEvent(evt)?.handlerPromise; + }} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + fetchCount = 0; + button.click(); + await waitUntil(() => fetchCount >= 1, undefined, { timeout: 5000 }); + await waitUntil( + () => { + control.requestUpdate(); + return label.key === 'idle'; + }, + undefined, + { timeout: 5000 } + ); + + await control.requestUpdate(); + expect(button).to.not.have.attribute('disabled'); + expect(label).to.have.property('key', 'idle'); + }); + + it('switches to error display when POST fails', async () => { + let fetchCount = 0; + + const router = createRouter(); + const control = await fixture(html` + { + fetchCount++; + router.handleEvent(evt)?.handlerPromise; + }} + > + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + const label = button.querySelector('foxy-i18n')!; + + fetchCount = 0; + button.click(); + await waitUntil(() => fetchCount >= 1, undefined, { timeout: 5000 }); + await waitUntil( + () => { + control.requestUpdate(); + return label.key === 'fail'; + }, + undefined, + { timeout: 5000 } + ); + + await control.requestUpdate(); + expect(button).to.not.have.attribute('disabled'); + expect(label).to.have.property('key', 'fail'); + }); + + it('disables the action button when the control is disabled', async () => { + const control = await fixture(html` + + + `); + + const button = control.renderRoot.querySelector('vaadin-button')!; + expect(button).to.have.attribute('disabled'); + }); + }); +}); diff --git a/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.ts b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.ts new file mode 100644 index 00000000..e5c44ce2 --- /dev/null +++ b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/InternalTemplateFormAsyncAction.ts @@ -0,0 +1,53 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; + +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import { html } from 'lit-element'; + +export class InternalTemplateFormAsyncAction extends InternalControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + __state: { type: String }, + theme: { type: String }, + href: { type: String }, + }; + } + + theme: string | null = null; + + href: string | null = null; + + private __state = 'idle'; + + renderControl(): TemplateResult { + const state = this.__state; + const theme = state === 'fail' ? 'error' : state === 'idle' ? '' : ''; + + return html` + + + + `; + } + + private async __submit(): Promise { + if (this.__state === 'busy') return; + + try { + this.__state = 'busy'; + + const api = new NucleonElement.API(this); + const response = await api.fetch(this.href ?? '', { method: 'POST' }); + + this.__state = response.ok ? 'idle' : 'fail'; + if (response.ok) this.nucleon?.refresh(); + } catch { + this.__state = 'fail'; + } + } +} diff --git a/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/index.ts b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/index.ts new file mode 100644 index 00000000..de0f92ff --- /dev/null +++ b/src/elements/public/TemplateForm/internal/InternalTemplateFormAsyncAction/index.ts @@ -0,0 +1,9 @@ +import '@vaadin/vaadin-button'; +import '../../../../internal/InternalControl/index'; +import '../../../I18n/index'; + +import { InternalTemplateFormAsyncAction as Control } from './InternalTemplateFormAsyncAction'; + +customElements.define('foxy-internal-template-form-async-action', Control); + +export { Control as InternalTemplateFormAsyncAction }; diff --git a/src/static/translations/email-template-form/en.json b/src/static/translations/email-template-form/en.json index b16c94ba..fdf8bec1 100644 --- a/src/static/translations/email-template-form/en.json +++ b/src/static/translations/email-template-form/en.json @@ -1,35 +1,107 @@ { - "cache": "Sync", - "cancel": "Cancel", - "confirm": "Confirm", - "caption": "Create", - "date": "{{value, date}}", - "date_created": "Created on", - "date_modified": "Last updated on", - "default": "Default", - "delete": "Delete", - "delete_prompt": "This resource will be permanently removed. Are you sure?", - "description": "Description", - "html_template": "HTML template", - "template_clipboard": "Upload source code", - "template_default": "Use default template", - "template_url": "Pull from public URL", - "text_template": "Text template", - "url": "URL", - "url_source_label": "Cached source", - "url_source_placeholder": "Template markup will appear here once cached", - "clipboard_source_label": "Source", - "clipboard_source_placeholder": "Enter your template markup here", - "subject": { - "label": "Subject", - "placeholder": "", + "header": { + "title": "{{ description }}", + "title_new": "New email template", + "subtitle": "", + "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" + } + }, + "error": { + "failed_to_cache": "Failed to download the custom templates from the remote sources. Please check the URLs and try again." + }, + "general": { + "label": "", + "helper_text": "", + "description": { + "label": "Description", + "placeholder": "Required", + "helper_text": "" + }, + "toggle": { + "label": "Send this email", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "subject": { + "label": "Subject", + "placeholder": "None – don't send", + "default_value": "New Message", + "helper_text": "" + }, + "template-language": { + "label": "Template language", + "placeholder": "Default (Nunjucks)", + "helper_text": "" + } + }, + "html-source": { + "label": "", + "helper_text": "", + "content-html-url": { + "label": "HTML template URL", + "placeholder": "None (use inline above)", + "helper_text": "Enter a URL to load a custom HTML template from a remote source." + }, + "cache": { + "idle": "Sync now", + "busy": "Caching...", + "fail": "Failed to cache" + } + }, + "text-source": { + "label": "", + "helper_text": "", + "content-text-url": { + "label": "Text template URL", + "placeholder": "None (use inline above)", + "helper_text": "Enter a URL to load a custom text template from a remote source." + }, + "cache": { + "idle": "Sync now", + "busy": "Caching...", + "fail": "Failed to cache" + } + }, + "content-html": { + "label": "Custom HTML template", + "placeholder": "None (use default)", "helper_text": "" }, - "template-language": { - "label": "Language", - "placeholder": "Default (Nunjucks)", + "content-text": { + "label": "Custom text template", + "placeholder": "None (use default)", "helper_text": "" }, + "timestamps": { + "date_created": "Created on", + "date_modified": "Last updated on", + "date": "{{value, date}}" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "This action is irreversible. Are you sure you want to delete this email template?" + }, + "undo": { + "caption": "Undo" + }, + "submit": { + "caption": "Save changes" + }, + "create": { + "caption": "Create" + }, "spinner": { "refresh": "Refresh", "loading_busy": "Loading", diff --git a/src/static/translations/item-category-form/en.json b/src/static/translations/item-category-form/en.json index 0faefc96..766ad1f6 100644 --- a/src/static/translations/item-category-form/en.json +++ b/src/static/translations/item-category-form/en.json @@ -118,7 +118,7 @@ "label": "Default weight unit", "placeholder": "Select...", "option_kgs": "KG", - "option_lbs": "LG", + "option_lbs": "LBS", "helper_text": "", "v8n_required": "Please select a unit" }, diff --git a/src/static/translations/store-form/en.json b/src/static/translations/store-form/en.json index d9c9c5ba..56a1e3c0 100644 --- a/src/static/translations/store-form/en.json +++ b/src/static/translations/store-form/en.json @@ -17,33 +17,39 @@ "done": "Copied to clipboard" } }, + "error": { + "store_domain_reserved": "Selected domain is reserved for internal use. Please choose another domain.", + "store_domain_exists": "Selected domain is already in use by another store. Please choose another domain." + }, "essentials": { "label": "Essentials", "helper_text": "", "store-name": { "label": "Name", - "placeholder": "My Store", + "placeholder": "Required", "helper_text": "", "v8n_required": "Please enter the name of your store", "v8n_too_long": "Please reduce the name of your store to 50 characters or less" }, "logo-url": { "label": "Logo URL", - "placeholder": "https://example.com/logo.png", + "placeholder": "Optional", "helper_text": "", "v8n_too_long": "Please shorten this link to 200 characters of less" }, "store-domain": { "label": "Domain", - "placeholder": "my-store.foxycart.com", + "placeholder": "Required", "helper_text": "", "custom_domain_note": "To use a custom domain, you must order (at no cost) an SSL certificate through Foxy. This option is only for developers who have full control of their domain settings and may take a few days to fully process.", "v8n_required": "Please enter the domain of your store", + "v8n_invalid": "Please enter a valid domain. For internationalized domain names, please use punycode.", + "v8n_unavailable": "Selected domain is already in use by another store. Please choose another domain.", "v8n_too_long": "Please use a domain that is 100 characters or less" }, "store-url": { "label": "Website", - "placeholder": "https://my.store.example.com", + "placeholder": "Required", "helper_text": "", "v8n_required": "Please enter the URL of your online store", "v8n_too_long": "Please use a URL that is 300 characters or less" @@ -258,7 +264,7 @@ "v8n_too_long": "Please use a URL that is 300 characters or less" }, "bcc-on-receipt-email": { - "label": "Send a copy of each receipt to the store email", + "label": "Send a copy of each receipt to the store email(s)", "helper_text": "", "checked": "Yes", "unchecked": "No" diff --git a/src/static/translations/subscription-settings-form/en.json b/src/static/translations/subscription-settings-form/en.json index 9f5d2821..4a7db924 100644 --- a/src/static/translations/subscription-settings-form/en.json +++ b/src/static/translations/subscription-settings-form/en.json @@ -36,6 +36,12 @@ "checked": "Yes", "unchecked": "No" }, + "send-email-receipts-for-automated-billing": { + "label": "Send email receipts for automated billing", + "helper_text": "Unchecking this option will cause any email receipts related to automated billing renewals to not be sent. This setting will not impact the email receipt sent for the initial purchase of a subscription.", + "checked": "Yes", + "unchecked": "No" + }, "prevent-customer-cancel-with-past-due": { "label": "Prevent modification or cancellation if past due is present", "helper_text": "If enabled, if the customer has a past due amount and wishes to cancel their subscription using a sub_token through the cart interface, they will be prevented from doing so until they first pay their past due amount.", @@ -70,12 +76,6 @@ "emails-group": { "label": "Emails", "helper_text": "", - "send-email-receipts-for-automated-billing": { - "label": "Send email receipts for automated billing", - "helper_text": "When subscriptions run automatically to bill your customers, turning this setting off will prevent the normal receipt emails from being sent for their automated payment.", - "checked": "Yes", - "unchecked": "No" - }, "reminder-email-schedule": { "label": "Failed subscription payment email schedule", "placeholder": "Add period in days, e.g. 14", diff --git a/src/static/translations/template-form/en.json b/src/static/translations/template-form/en.json index 81da28f7..10bb0533 100644 --- a/src/static/translations/template-form/en.json +++ b/src/static/translations/template-form/en.json @@ -1,23 +1,71 @@ { - "cache": "Sync", - "cancel": "Cancel", - "caption": "Create", - "date": "{{value, date}}", - "date_created": "Created on", - "date_modified": "Last updated on", - "default": "Default", - "delete": "Delete", - "delete_prompt": "This resource will be permanently removed. Are you sure?", - "description": "Description", - "template": "Template", - "template_clipboard": "Upload source code", - "template_default": "Use default template", - "template_url": "Pull from public URL", - "url": "URL", - "url_source_label": "Cached source", - "url_source_placeholder": "Template markup will appear here once cached", - "clipboard_source_label": "Source", - "clipboard_source_placeholder": "Enter your template markup here", + "header": { + "title": "{{ description }}", + "title_new": "New template", + "subtitle": "", + "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" + } + }, + "error": { + "failed_to_cache": "Failed to download the custom template from the remote source. Please check the URL and try again." + }, + "general": { + "label": "", + "helper_text": "", + "description": { + "label": "Description", + "placeholder": "Required", + "helper_text": "" + } + }, + "source": { + "label": "", + "helper_text": "", + "content-url": { + "label": "Template URL", + "placeholder": "None (use inline above)", + "helper_text": "Enter a URL to load a custom template from a remote source." + }, + "cache": { + "idle": "Cache now", + "busy": "Caching...", + "fail": "Failed to cache" + } + }, + "content": { + "label": "Custom template", + "placeholder": "None (use default)", + "helper_text": "" + }, + "timestamps": { + "date_created": "Created on", + "date_modified": "Last updated on", + "date": "{{value, date}}" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "This action is irreversible. Are you sure you want to delete this template?" + }, + "undo": { + "caption": "Undo" + }, + "submit": { + "caption": "Save changes" + }, + "create": { + "caption": "Create" + }, "spinner": { "refresh": "Refresh", "loading_busy": "Loading",