From d8a3a92bfce58f31c4b6bbd0b9f6f8dab212fef6 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 20 Dec 2024 09:10:40 -0300 Subject: [PATCH 01/18] build: bump node version for cdn upload action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f4284af..8a4146ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Install dependencies run: npm ci - name: Build From 54dc1967f652855fd8587a483dd630d04eb3a3d9 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 20 Dec 2024 11:30:52 -0300 Subject: [PATCH 02/18] feat(foxy-native-integration-form): enable custom tax endpoint config --- .../NativeIntegrationForm.test.ts | 17 +---------------- .../NativeIntegrationForm.ts | 13 ++----------- .../native-integration-form/en.json | 4 +--- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts index c259dd27..ec4c18e7 100644 --- a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts +++ b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts @@ -314,7 +314,6 @@ describe('NativeIntegrationForm', () => { expect(element.readonlySelector.matches('zapier-events', true)).to.be.true; expect(element.readonlySelector.matches('zapier-url', true)).to.be.true; expect(element.readonlySelector.matches('apple-pay-merchant-id', true)).to.be.true; - expect(element.readonlySelector.matches('custom-tax-url', true)).to.be.true; }); it('produces error:already_configured when trying to add another config for an already configured integration', async () => { @@ -393,6 +392,7 @@ describe('NativeIntegrationForm', () => { { value: 'avalara', label: 'option_avalara' }, { value: 'onesource', label: 'option_onesource' }, { value: 'taxjar', label: 'option_taxjar' }, + { value: 'custom_tax', label: 'option_custom_tax' }, ]); control.setValue('taxjar'); @@ -1337,19 +1337,4 @@ describe('NativeIntegrationForm', () => { expect(control).to.be.instanceOf(InternalTextControl); expect(control.getValue()).to.equal('https://example.com'); }); - - it('renders a readonly content warning for custom tax', async () => { - const data = await getTestData('./hapi/native_integrations/0'); - data.provider = 'custom_tax'; - data.config = JSON.stringify(defaults.customTax); - - const element = await fixture
(html` - - `); - - const warning = element.renderRoot.querySelector('[key="warning_text"]'); - - expect(warning).to.be.instanceOf(I18n); - expect(warning).to.have.attribute('infer', 'custom-tax-warning'); - }); }); diff --git a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts index f532c7ab..3dec384c 100644 --- a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts +++ b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts @@ -156,6 +156,7 @@ export class NativeIntegrationForm extends Base { { value: 'avalara', label: 'option_avalara' }, { value: 'onesource', label: 'option_onesource' }, { value: 'taxjar', label: 'option_taxjar' }, + { value: 'custom_tax', label: 'option_custom_tax' }, ]; private readonly __avalaraConfigOptions: Option[] = [ @@ -263,13 +264,7 @@ export class NativeIntegrationForm extends Base { const match = [super.readonlySelector.toString()]; if (this.href) { - match.push( - 'apple-pay-merchant-id', - 'custom-tax-url', - 'zapier-events', - 'zapier-url', - 'provider' - ); + match.push('apple-pay-merchant-id', 'zapier-events', 'zapier-url', 'provider'); } return new BooleanSelector(match.join(' ').trim()); @@ -722,10 +717,6 @@ export class NativeIntegrationForm extends Base { .setValue=${this.__createConfigSetterFor('url')} > - -

- -

`; } } diff --git a/src/static/translations/native-integration-form/en.json b/src/static/translations/native-integration-form/en.json index f038380b..137315ef 100644 --- a/src/static/translations/native-integration-form/en.json +++ b/src/static/translations/native-integration-form/en.json @@ -33,6 +33,7 @@ "option_onesource": "ONESOURCE", "option_webflow": "Webflow", "option_zapier": "Zapier", + "option_custom_tax": "Custom Tax Endpoint", "helper_text": "Changing service provider is not possible after creation.", "v8n_required": "Please select a provider." }, @@ -274,9 +275,6 @@ "placeholder": "", "helper_text": "The URL of your custom tax service." }, - "custom-tax-warning": { - "warning_text": "It is currently not possible to configure this integration in this new admin app. Please use the legacy app at admin.foxycart.com to make changes." - }, "timestamps": { "date_created": "Created on", "date_modified": "Last updated on", From 602b9b40f52cf97068de057e588b2fc9abfce545 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 3 Jan 2025 19:41:54 -0300 Subject: [PATCH 03/18] refactor(foxy-internal-form): update non-idle styles --- src/elements/internal/InternalForm/InternalForm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/internal/InternalForm/InternalForm.ts b/src/elements/internal/InternalForm/InternalForm.ts index cbdbedc5..42daed7c 100644 --- a/src/elements/internal/InternalForm/InternalForm.ts +++ b/src/elements/internal/InternalForm/InternalForm.ts @@ -183,8 +183,8 @@ export class InternalForm extends Base {
${this.__generalErrors.map(err => this.__renderGeneralError(err))} From f0ac53de77924b4555fb755de7742c3823f6d929 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 3 Jan 2025 19:43:48 -0300 Subject: [PATCH 04/18] feat(foxy-native-integration-form): update ui with new controls --- .../NativeIntegrationForm.stories.ts | 122 ++- .../NativeIntegrationForm.test.ts | 950 ++++++++++-------- .../NativeIntegrationForm.ts | 714 +++++++------ .../public/NativeIntegrationForm/defaults.ts | 36 +- .../public/NativeIntegrationForm/index.ts | 8 +- .../native-integration-form/en.json | 556 ++++++---- 6 files changed, 1373 insertions(+), 1013 deletions(-) diff --git a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.stories.ts b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.stories.ts index 2068229d..a92f5cbd 100644 --- a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.stories.ts +++ b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.stories.ts @@ -11,60 +11,90 @@ const summary: Summary = { localName: 'foxy-native-integration-form', translatable: true, configurable: { - sections: ['timestamps', 'header'], + sections: [ + 'provider-group-one', + 'avalara-group-one', + 'avalara-group-two', + 'avalara-group-three', + 'taxjar-group-one', + 'taxjar-group-two', + 'onesource-group-one', + 'onesource-group-two', + 'onesource-group-three', + 'onesource-group-four', + 'onesource-group-five', + 'webhook-json-group-one', + 'webhook-json-group-two', + 'webhook-legacy-xml-group-one', + 'webflow-group-one', + 'webflow-group-two', + 'webflow-group-three', + 'webflow-group-four', + 'zapier-group-one', + 'apple-pay-group-one', + 'custom-tax-group-one', + 'timestamps', + 'header', + ], buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], inputs: [ - 'provider', - 'avalara-service-url', - 'avalara-id', - 'avalara-key', - 'avalara-company-code', - 'avalara-options', - 'avalara-address-validation-countries', - 'avalara-category-to-product-tax-code-mappings', - 'taxjar-api-token', - 'taxjar-category-to-product-tax-code-mappings', - 'taxjar-options', - 'onesource-service-url', - 'onesource-external-company-id', - 'onesource-calling-system-number', - 'onesource-from-city', - 'onesource-host-system', - 'onesource-company-role', - 'onesource-part-number-product-option', - 'onesource-product-order-priority', - 'onesource-audit-settings', - 'webhook-service', - 'webhook-json-title', - 'webhook-json-encryption-key', - 'webhook-json-url', - 'webhook-json-events', - 'webhook-legacy-xml-title', - 'webhook-legacy-xml-url', - 'webflow-site-id', - 'webflow-site-name', - 'webflow-collection-id', - 'webflow-collection-name', - 'webflow-sku-field-id', - 'webflow-sku-field-name', - 'webflow-inventory-field-id', - 'webflow-inventory-field-name', - 'webflow-auth', - 'zapier-events', - 'zapier-url', - 'apple-pay-merchant-id', - 'custom-tax-url', + 'provider-group-one:provider', + 'avalara-group-one:avalara-service-url', + 'avalara-group-one:avalara-id', + 'avalara-group-one:avalara-key', + 'avalara-group-one:avalara-company-code', + 'avalara-group-two:avalara-category-to-product-tax-code-mappings', + 'avalara-group-three:avalara-use-ava-tax', + 'avalara-group-three:avalara-enable-colorado-delivery-fee', + 'avalara-group-three:avalara-create-invoice', + 'avalara-group-three:avalara-use-address-validation', + 'avalara-group-three:avalara-address-validation-countries', + 'taxjar-group-one:taxjar-api-token', + 'taxjar-group-one:taxjar-create-invoice', + 'taxjar-group-two:taxjar-category-to-product-tax-code-mappings', + 'onesource-group-one:onesource-service-url', + 'onesource-group-one:onesource-external-company-id', + 'onesource-group-one:onesource-from-city', + 'onesource-group-two:onesource-calling-system-number', + 'onesource-group-two:onesource-host-system', + 'onesource-group-three:onesource-company-role', + 'onesource-group-three:onesource-audit-settings', + 'onesource-group-four:onesource-part-number-product-option', + 'onesource-group-five:onesource-product-order-priority', + 'webhook-json-group-one:webhook-json-title', + 'webhook-json-group-one:webhook-json-encryption-key', + 'webhook-json-group-one:webhook-json-url', + 'webhook-json-group-one:webhook-service', + 'webhook-json-group-two:webhook-json-events-subscription-cancelled', + 'webhook-json-group-two:webhook-json-events-transaction-created', + 'webhook-legacy-xml-group-one:webhook-legacy-xml-title', + 'webhook-legacy-xml-group-one:webhook-legacy-xml-url', + 'webhook-legacy-xml-group-one:webhook-service', + 'webflow-group-one:webflow-site-id', + 'webflow-group-one:webflow-site-name', + 'webflow-group-two:webflow-collection-id', + 'webflow-group-two:webflow-collection-name', + 'webflow-group-three:webflow-sku-field-id', + 'webflow-group-three:webflow-sku-field-name', + 'webflow-group-four:webflow-inventory-field-id', + 'webflow-group-four:webflow-inventory-field-name', + 'zapier-group-one:zapier-events', + 'zapier-group-one:zapier-url', + 'apple-pay-group-one:apple-pay-merchant-id', + 'custom-tax-group-one:custom-tax-url', ], }, }; export default getMeta(summary); -export const Playground = getStory({ ...summary, code: true }); -export const Legacy = getStory(summary); -export const Empty = getStory(summary); -export const Error = getStory(summary); -export const Busy = getStory(summary); +const ext = `store="https://demo.api/hapi/stores/0"`; + +export const Playground = getStory({ ...summary, ext, code: true }); +export const Legacy = getStory({ ...summary, ext }); +export const Empty = getStory({ ...summary, ext }); +export const Error = getStory({ ...summary, ext }); +export const Busy = getStory({ ...summary, ext }); Legacy.args.href = 'https://demo.api/hapi/native_integrations/1'; Empty.args.href = ''; diff --git a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts index ec4c18e7..426887de 100644 --- a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts +++ b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.test.ts @@ -4,39 +4,38 @@ import './index'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; import { NativeIntegrationForm as Form } from './NativeIntegrationForm'; -import { InternalCheckboxGroupControl } from '../../internal/InternalCheckboxGroupControl/InternalCheckboxGroupControl'; import { InternalEditableListControl } from '../../internal/InternalEditableListControl/InternalEditableListControl'; -import { InternalRadioGroupControl } from '../../internal/InternalRadioGroupControl/InternalRadioGroupControl'; +import { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; import { InternalPasswordControl } from '../../internal/InternalPasswordControl/InternalPasswordControl'; -import { InternalTextAreaControl } from '../../internal/InternalTextAreaControl/InternalTextAreaControl'; +import { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; import { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import { createRouter } from '../../../server'; import { getTestData } from '../../../testgen/getTestData'; import { FetchEvent } from '../NucleonElement/FetchEvent'; -import { I18n } from '../I18n'; +import { I18n } from '../I18n/I18n'; +import { stub } from 'sinon'; import * as defaults from './defaults'; -import { stub } from 'sinon'; describe('NativeIntegrationForm', () => { - it('imports and defines foxy-internal-checkbox-group-control element', () => { - expect(customElements.get('foxy-internal-checkbox-group-control')).to.exist; - }); - it('imports and defines foxy-internal-editable-list-control element', () => { expect(customElements.get('foxy-internal-editable-list-control')).to.exist; }); - it('imports and defines foxy-internal-radio-group-control element', () => { - expect(customElements.get('foxy-internal-radio-group-control')).to.exist; - }); - it('imports and defines foxy-internal-password-control element', () => { expect(customElements.get('foxy-internal-password-control')).to.exist; }); - it('imports and defines foxy-internal-text-area-control element', () => { - expect(customElements.get('foxy-internal-text-area-control')).to.exist; + it('imports and defines foxy-internal-summary-control element', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; + }); + + it('imports and defines foxy-internal-select-control element', () => { + expect(customElements.get('foxy-internal-select-control')).to.exist; + }); + + it('imports and defines foxy-internal-switch-control element', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; }); it('imports and defines foxy-internal-text-control element', () => { @@ -302,18 +301,15 @@ describe('NativeIntegrationForm', () => { } }); - expect(element.readonlySelector.matches('provider', true)).to.be.false; - expect(element.readonlySelector.matches('zapier-events', true)).to.be.false; - expect(element.readonlySelector.matches('zapier-url', true)).to.be.false; - expect(element.readonlySelector.matches('apple-pay-merchant-id', true)).to.be.false; - expect(element.readonlySelector.matches('custom-tax-url', true)).to.be.false; + expect(element.readonlySelector.matches('zapier-group-one', true)).to.be.false; + expect(element.readonlySelector.matches('provider-group-one', true)).to.be.false; + expect(element.readonlySelector.matches('apple-pay-group-one', true)).to.be.false; element.href = 'https://demo.api/hapi/native_integrations/0'; - expect(element.readonlySelector.matches('provider', true)).to.be.true; - expect(element.readonlySelector.matches('zapier-events', true)).to.be.true; - expect(element.readonlySelector.matches('zapier-url', true)).to.be.true; - expect(element.readonlySelector.matches('apple-pay-merchant-id', true)).to.be.true; + expect(element.readonlySelector.matches('zapier-group-one', true)).to.be.true; + expect(element.readonlySelector.matches('provider-group-one', true)).to.be.true; + expect(element.readonlySelector.matches('apple-pay-group-one', true)).to.be.true; }); it('produces error:already_configured when trying to add another config for an already configured integration', async () => { @@ -359,7 +355,7 @@ describe('NativeIntegrationForm', () => { }); }); - it('does not render provider name for webhooks when href is defined', async () => { + it('does not render provider group when href is defined', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => element.in('idle')); - element.edit({ provider: 'webhook', config: JSON.stringify(defaults.webhookJson) }); + element.edit({ provider: 'webhook', config: defaults.webhookJson }); await element.requestUpdate(); - const control = element.renderRoot.querySelector('[infer="provider"]'); + const control = element.renderRoot.querySelector('[infer="provider-group-one"]'); expect(control).to.not.exist; }); - it('renders provider selector when href is not defined', async () => { + it('renders provider group when href is not defined', async () => { const element = await fixture( html`` ); const control = element.renderRoot.querySelector( - '[infer="provider"]' - ) as InternalRadioGroupControl; + 'foxy-internal-summary-control[infer="provider-group-one"]' + ); - expect(control).to.be.instanceOf(InternalRadioGroupControl); + expect(control).to.exist; + }); + + it('renders provider selector in provider group when href is not defined', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + '[infer="provider-group-one"] [infer="provider"]' + ) as InternalSelectControl; + + expect(control).to.be.instanceOf(InternalSelectControl); expect(control.getValue()).to.equal('avalara'); expect(control).to.have.deep.property('options', [ { value: 'avalara', label: 'option_avalara' }, @@ -398,97 +406,98 @@ describe('NativeIntegrationForm', () => { control.setValue('taxjar'); expect(element).to.have.nested.property('form.provider', 'taxjar'); - expect(element).to.have.nested.property('form.config', JSON.stringify(defaults.taxjar)); + expect(element).to.have.nested.property('form.config', defaults.taxjar); expect(control.getValue()).to.equal('taxjar'); }); - it('renders a text control for avalara service url', async () => { + it('renders a text control for avalara service url in avalara group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify({ ...defaults.avalara, service_url: 'https://example.com' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.avalara), + service_url: 'https://example.com', + }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="avalara-service-url"]' + '[infer="avalara-group-one"] foxy-internal-text-control[infer="avalara-service-url"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('https://example.com'); - - control.setValue('https://foo.example.com/abc'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('service_url', 'https://foo.example.com/abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'service_url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for avalara id', async () => { + it('renders a text control for avalara id in avalara group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify({ ...defaults.avalara, id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), id: 'abc' }); const element = await fixture(html` `); - const control = element.renderRoot.querySelector('[infer="avalara-id"]') as InternalTextControl; - - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); + const control = element.renderRoot.querySelector( + '[infer="avalara-group-one"] foxy-internal-text-control[infer="avalara-id"]' + ); - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a password control for avalara key', async () => { + it('renders a password control for avalara key in avalara group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify({ ...defaults.avalara, key: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), key: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="avalara-key"]' + '[infer="avalara-group-one"] foxy-internal-password-control[infer="avalara-key"]' ) as InternalPasswordControl; - expect(control).to.be.instanceOf(InternalPasswordControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('key', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'key'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for avalara company code', async () => { + it('renders a text control for avalara company code in avalara group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify({ ...defaults.avalara, company_code: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), company_code: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="avalara-company-code"]' + '[infer="avalara-group-one"] [infer="avalara-company-code"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('company_code', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'company_code'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders an editable list control for avalara tax code mappings', async () => { + it('renders an editable list control for avalara tax code mappings in avalara group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; data.config = JSON.stringify({ - ...defaults.avalara, + ...JSON.parse(defaults.avalara), category_to_product_tax_code_mappings: { foo: 'bar', bar: 'qux' }, }); @@ -497,10 +506,11 @@ describe('NativeIntegrationForm', () => { `); const control = element.renderRoot.querySelector( - '[infer="avalara-category-to-product-tax-code-mappings"]' + 'foxy-internal-summary-control[infer="avalara-group-two"] foxy-internal-editable-list-control[infer="avalara-category-to-product-tax-code-mappings"]' ) as InternalEditableListControl; - expect(control).to.be.instanceOf(InternalEditableListControl); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); expect(control.getValue()).to.deep.equal([{ value: 'foo:bar' }, { value: 'bar:qux' }]); control.setValue([{ value: 'a:b' }]); @@ -508,121 +518,153 @@ describe('NativeIntegrationForm', () => { expect(config).to.have.deep.property('category_to_product_tax_code_mappings', { a: 'b' }); }); - it('renders a checkbox group control for avalara options', async () => { + it('renders a switch control for avalara use_ava_tax in avalara group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify(defaults.avalara); + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), use_ava_tax: true }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="avalara-options"]' - ) as InternalCheckboxGroupControl; + 'foxy-internal-summary-control[infer="avalara-group-three"] foxy-internal-switch-control[infer="avalara-use-ava-tax"]' + ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control.getValue()).to.deep.equal([]); - expect(control).to.have.deep.property('options', [ - { - value: 'use_ava_tax', - label: 'option_use_ava_tax', - }, - { - value: 'enable_colorado_delivery_fee', - label: 'option_enable_colorado_delivery_fee', - }, - { - value: 'create_invoice', - label: 'option_create_invoice', - }, - { - value: 'use_address_validation', - label: 'option_use_address_validation', - }, - ]); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'use_ava_tax'); + expect(control).to.have.attribute('property', 'config'); + }); - let config = JSON.parse(element.form.config as string); - expect(config).to.have.property('enable_colorado_delivery_fee', false); - expect(config).to.have.property('use_address_validation', false); - expect(config).to.have.property('create_invoice', false); - expect(config).to.have.property('use_ava_tax', false); + it('renders a switch control for avalara use_address_validation in avalara group three', async () => { + const data = await getTestData('./hapi/native_integrations/0'); + data.provider = 'avalara'; + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), use_address_validation: true }); - control.setValue(['enable_colorado_delivery_fee']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.property('enable_colorado_delivery_fee', true); + const element = await fixture(html` + + `); - control.setValue(['use_address_validation']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.property('use_address_validation', true); + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="avalara-group-three"] foxy-internal-switch-control[infer="avalara-use-address-validation"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'use_address_validation'); + expect(control).to.have.attribute('property', 'config'); + }); + + it('renders an editable list control for avalara address validation countries if address validation is on', async () => { + const data = await getTestData('./hapi/native_integrations/0'); + data.provider = 'avalara'; + data.config = JSON.stringify({ + ...JSON.parse(defaults.avalara), + use_address_validation: true, + }); + + const element = await fixture(html` + + `); - control.setValue(['create_invoice']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.property('create_invoice', true); + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="avalara-group-three"] foxy-internal-editable-list-control[infer="avalara-address-validation-countries"]' + ) as InternalEditableListControl; - control.setValue(['use_ava_tax']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.property('use_ava_tax', true); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'address_validation_countries'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.deep.property('options', [{ value: 'US' }, { value: 'CA' }]); }); - it('renders a checkbox group control for address validation countries if address validation is on', async () => { + it('renders a switch control for avalara create_invoice in avalara group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify({ ...defaults.avalara, use_address_validation: true }); + data.config = JSON.stringify({ ...JSON.parse(defaults.avalara), create_invoice: true }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="avalara-address-validation-countries"]' - ) as InternalCheckboxGroupControl; + 'foxy-internal-summary-control[infer="avalara-group-three"] foxy-internal-switch-control[infer="avalara-create-invoice"]' + ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control.getValue()).to.deep.equal([]); - expect(control).to.have.deep.property('options', [ - { value: 'US', label: 'option_US' }, - { value: 'CA', label: 'option_CA' }, - ]); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'create_invoice'); + expect(control).to.have.attribute('property', 'config'); + }); - let config = JSON.parse(element.form.config as string); - expect(config).to.have.deep.property('address_validation_countries', []); + it('renders a switch control for avalara enable_colorado_delivery_fee in avalara group three', async () => { + const data = await getTestData('./hapi/native_integrations/0'); + data.provider = 'avalara'; + data.config = JSON.stringify({ + ...JSON.parse(defaults.avalara), + enable_colorado_delivery_fee: true, + }); - control.setValue(['US']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.deep.property('address_validation_countries', ['US']); + const element = await fixture(html` + + `); - control.setValue(['CA']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.deep.property('address_validation_countries', ['CA']); + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="avalara-group-three"] foxy-internal-switch-control[infer="avalara-enable-colorado-delivery-fee"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.avalara); + expect(control).to.have.attribute('json-path', 'enable_colorado_delivery_fee'); + expect(control).to.have.attribute('property', 'config'); }); - it('renders a password control for taxjar api token', async () => { + it('renders a password control for taxjar api token inside taxjar group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'taxjar'; - data.config = JSON.stringify({ ...defaults.taxjar, api_token: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.taxjar), api_token: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="taxjar-api-token"]' + '[infer="taxjar-group-one"] foxy-internal-password-control[infer="taxjar-api-token"]' ) as InternalPasswordControl; - expect(control).to.be.instanceOf(InternalPasswordControl); - expect(control.getValue()).to.equal('abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.taxjar); + expect(control).to.have.attribute('json-path', 'api_token'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('api_token', 'def'); + it('renders a switch control for taxjar create_invoice in taxjar group one', async () => { + const data = await getTestData('./hapi/native_integrations/0'); + data.provider = 'taxjar'; + data.config = JSON.stringify({ ...JSON.parse(defaults.taxjar), create_invoice: true }); + + const element = await fixture(html` + + `); + + const control = element.renderRoot.querySelector( + '[infer="taxjar-group-one"] foxy-internal-switch-control[infer="taxjar-create-invoice"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.taxjar); + expect(control).to.have.attribute('json-path', 'create_invoice'); + expect(control).to.have.attribute('property', 'config'); }); - it('renders an editable list control for taxjar product code mappings', async () => { + it('renders an editable list control for taxjar product code mappings in taxjar group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'taxjar'; data.config = JSON.stringify({ - ...defaults.taxjar, + ...JSON.parse(defaults.taxjar), category_to_product_tax_code_mappings: { foo: 'bar', bar: 'qux' }, }); @@ -631,7 +673,7 @@ describe('NativeIntegrationForm', () => { `); const control = element.renderRoot.querySelector( - '[infer="taxjar-category-to-product-tax-code-mappings"]' + '[infer="taxjar-group-two"] foxy-internal-editable-list-control[infer="taxjar-category-to-product-tax-code-mappings"]' ) as InternalEditableListControl; expect(control).to.be.instanceOf(InternalEditableListControl); @@ -642,191 +684,199 @@ describe('NativeIntegrationForm', () => { expect(config).to.have.deep.property('category_to_product_tax_code_mappings', { a: 'b' }); }); - it('renders a checkbox group control for taxjar options', async () => { + it('renders a text control for onesource service url in onesource group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); - data.provider = 'taxjar'; - data.config = JSON.stringify(defaults.taxjar); + data.provider = 'onesource'; + data.config = JSON.stringify({ + ...JSON.parse(defaults.onesource), + service_url: 'https://example.com', + }); const element = await fixture(html` `); - const control = element.renderRoot.querySelector( - '[infer="taxjar-options"]' - ) as InternalCheckboxGroupControl; - - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control.getValue()).to.deep.equal([]); - expect(control).to.have.deep.property('options', [ - { - value: 'create_invoice', - label: 'option_create_invoice', - }, - ]); - let config = JSON.parse(element.form.config as string); - expect(config).to.have.property('create_invoice', false); + const control = element.renderRoot.querySelector( + '[infer="onesource-group-one"] foxy-internal-text-control[infer="onesource-service-url"]' + ) as InternalTextControl; - control.setValue(['create_invoice']); - config = JSON.parse(element.form.config as string); - expect(config).to.have.property('create_invoice', true); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'service_url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for onesource service url', async () => { + it('renders a text control for onesource external company id in onesource group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, service_url: 'https://example.com' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.onesource), external_company_id: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="onesource-service-url"]' + '[infer="onesource-group-one"] foxy-internal-text-control[infer="onesource-external-company-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('https://example.com'); - - control.setValue('https://foo.example.com/abc'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('service_url', 'https://foo.example.com/abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'external_company_id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for onesource external company id', async () => { + it('renders a text control for onesource from city in onesource group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, external_company_id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.onesource), from_city: 'abc' }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="onesource-external-company-id"]' + '[infer="onesource-group-one"] foxy-internal-text-control[infer="onesource-from-city"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('external_company_id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'from_city'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for onesource calling system number', async () => { + it('renders a text control for onesource calling system number in onesource group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, calling_system_number: 'abc' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.onesource), + calling_system_number: 'abc', + }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="onesource-calling-system-number"]' + '[infer="onesource-group-two"] foxy-internal-text-control[infer="onesource-calling-system-number"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('calling_system_number', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'calling_system_number'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for onesource from city', async () => { + it('renders a text control for onesource host system in onesource group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, from_city: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.onesource), host_system: 'abc' }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="onesource-from-city"]' + '[infer="onesource-group-two"] foxy-internal-text-control[infer="onesource-host-system"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('from_city', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'host_system'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for onesource host system', async () => { + it('renders a select control for onesource company role in onesource group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, host_system: 'abc' }); + data.config = defaults.onesource; const element = await fixture(html` `); - const control = element.renderRoot.querySelector( - '[infer="onesource-host-system"]' - ) as InternalTextControl; - - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('host_system', 'def'); + const control = element.renderRoot.querySelector( + '[infer="onesource-group-three"] foxy-internal-select-control[infer="onesource-company-role"]' + ) as InternalSelectControl; + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'company_role'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { value: 'B', label: 'option_buyer' }, + { value: 'M', label: 'option_middleman' }, + { value: 'S', label: 'option_seller' }, + ]); }); - it('renders a radio group control for onesource company role', async () => { + it('renders a select control for onesource audit settings in onesource group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify(defaults.onesource); + data.config = defaults.onesource; const element = await fixture(html` `); - const control = element.renderRoot.querySelector( - '[infer="onesource-company-role"]' - ) as InternalRadioGroupControl; - expect(control).to.be.instanceOf(InternalRadioGroupControl); - expect(control.getValue()).to.equal('B'); + const control = element.renderRoot.querySelector( + '[infer="onesource-group-three"] foxy-internal-select-control[infer="onesource-audit-settings"]' + ) as InternalSelectControl; + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'audit_settings'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ - { value: 'B', label: 'option_buyer' }, - { value: 'M', label: 'option_middleman' }, - { value: 'S', label: 'option_seller' }, + { value: 'capture_only', label: 'option_capture_only' }, + { value: 'auth_and_capture', label: 'option_auth_and_capture' }, + { value: 'never', label: 'option_never' }, ]); - - control.setValue('S'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('company_role', 'S'); }); - it('renders a text control for onesource part number product option', async () => { + it('renders a text control for onesource part number product option in onesource group four', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, part_number_product_option: 'abc' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.onesource), + part_number_product_option: 'abc', + }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="onesource-part-number-product-option"]' + '[infer="onesource-group-four"] foxy-internal-text-control[infer="onesource-part-number-product-option"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('part_number_product_option', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.onesource); + expect(control).to.have.attribute('json-path', 'part_number_product_option'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders an editable list control for onesource product order priority', async () => { + it('renders an editable list control for onesource product order priority in onesource group five', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify({ ...defaults.onesource, product_order_priority: 'foo,bar' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.onesource), + product_order_priority: 'foo,bar', + }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="onesource-product-order-priority"]' + '[infer="onesource-group-five"] foxy-internal-editable-list-control[infer="onesource-product-order-priority"]' ) as InternalEditableListControl; expect(control).to.be.instanceOf(InternalEditableListControl); @@ -837,35 +887,10 @@ describe('NativeIntegrationForm', () => { expect(config).to.have.property('product_order_priority', 'a,b'); }); - it('renders a radio group control for onesource audit settings', async () => { - const data = await getTestData('./hapi/native_integrations/0'); - data.provider = 'onesource'; - data.config = JSON.stringify(defaults.onesource); - - const element = await fixture(html` - - `); - const control = element.renderRoot.querySelector( - '[infer="onesource-audit-settings"]' - ) as InternalRadioGroupControl; - - expect(control).to.be.instanceOf(InternalRadioGroupControl); - expect(control.getValue()).to.equal('never'); - expect(control).to.have.deep.property('options', [ - { value: 'capture_only', label: 'option_capture_only' }, - { value: 'auth_and_capture', label: 'option_auth_and_capture' }, - { value: 'never', label: 'option_never' }, - ]); - - control.setValue('auth_and_capture'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('audit_settings', 'auth_and_capture'); - }); - it('renders a deprecation warning for json and legacy_xml webhooks', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify(defaults.webhookJson); + data.config = defaults.webhookJson; const element = await fixture(html` @@ -875,7 +900,7 @@ describe('NativeIntegrationForm', () => { expect(warning).to.be.instanceOf(I18n); expect(warning).to.have.attribute('infer', 'webhook-warning'); - data.config = JSON.stringify(defaults.webhookLegacyXml); + data.config = defaults.webhookLegacyXml; element.data = { ...data }; await element.requestUpdate(); @@ -884,7 +909,7 @@ describe('NativeIntegrationForm', () => { expect(warning).to.have.attribute('infer', 'webhook-warning'); data.provider = 'avalara'; - data.config = JSON.stringify(defaults.avalara); + data.config = defaults.avalara; element.data = { ...data }; await element.requestUpdate(); @@ -892,393 +917,424 @@ describe('NativeIntegrationForm', () => { expect(warning).to.not.exist; }); - it('renders a text control for webhook json title', async () => { + it('renders a text control for webhook json title in webhook json group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookJson, title: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webhookJson), title: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-json-title"]' + 'foxy-internal-summary-control[infer="webhook-json-group-one"] foxy-internal-text-control[infer="webhook-json-title"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('title', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookJson); + expect(control).to.have.attribute('json-path', 'title'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text area control for webhook json url', async () => { + it('renders a text control for webhook json url in webhook json group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookJson, url: 'https://example.com' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.webhookJson), + url: 'https://example.com', + }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-json-url"]' - ) as InternalTextAreaControl; - - expect(control).to.be.instanceOf(InternalTextAreaControl); - expect(control.getValue()).to.equal('https://example.com'); + 'foxy-internal-summary-control[infer="webhook-json-group-one"] foxy-internal-text-control[infer="webhook-json-url"]' + ) as InternalTextControl; - control.setValue('https://foo.example.com/abc'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('url', 'https://foo.example.com/abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookJson); + expect(control).to.have.attribute('json-path', 'url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a radio group control for json webhook service', async () => { + it('renders a select control for webhook service in webhook json group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify(defaults.webhookJson); + data.config = defaults.webhookJson; const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-service"]' - ) as InternalRadioGroupControl; - - expect(control).to.be.instanceOf(InternalRadioGroupControl); - expect(control.getValue()).to.equal('json'); + 'foxy-internal-summary-control[infer="webhook-json-group-one"] foxy-internal-select-control[infer="webhook-service"]' + ) as InternalSelectControl; + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookJson); + expect(control).to.have.attribute('json-path', 'service'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ { value: 'json', label: 'option_json' }, { value: 'legacy_xml', label: 'option_legacy_xml' }, ]); - - control.setValue('legacy_xml'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('service', 'legacy_xml'); }); - it('renders a checkbox group control for webhook json events', async () => { + it('renders a password control for webhook json encryption key in webhook json group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify(defaults.webhookJson); + data.config = JSON.stringify({ ...JSON.parse(defaults.webhookJson), encryption_key: 'abc' }); const element = await fixture(html` - + `); const control = element.renderRoot.querySelector( - '[infer="webhook-json-events"]' - ) as InternalCheckboxGroupControl; - - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control.getValue()).to.deep.equal([]); - expect(control).to.have.deep.property('options', [ - { value: 'transaction/created', label: 'option_transaction_created' }, - { value: 'subscription/cancelled', label: 'option_subscription_cancelled' }, - ]); + 'foxy-internal-summary-control[infer="webhook-json-group-one"] foxy-internal-password-control[infer="webhook-json-encryption-key"]' + ) as InternalPasswordControl; - control.setValue(['transaction/created']); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.deep.property('events', ['transaction/created']); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookJson); + expect(control).to.have.attribute('json-path', 'encryption_key'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a password control for webhook json encryption key', async () => { + it('renders a switch control that toggles transaction/created event in webhook json group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookJson, encryption_key: 'abc' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.webhookJson), + events: ['transaction/created'], + }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-json-encryption-key"]' - ) as InternalPasswordControl; + 'foxy-internal-summary-control[infer="webhook-json-group-two"] foxy-internal-switch-control[infer="webhook-json-events-transaction-created"]' + ) as InternalSwitchControl; + + expect(control).to.exist; + control.setValue(false); + expect(control.getValue()).to.be.false; + expect(JSON.parse(element.form.config as string)).to.have.deep.property('events', []); + + control.setValue(true); + expect(control.getValue()).to.be.true; + expect(JSON.parse(element.form.config as string)).to.have.deep.property('events', [ + 'transaction/created', + ]); + }); - expect(control).to.be.instanceOf(InternalPasswordControl); - expect(control.getValue()).to.equal('abc'); + it('renders a switch control that toggles subscription/cancelled event in webhook json group two', async () => { + const data = await getTestData('./hapi/native_integrations/0'); + data.provider = 'webhook'; + data.config = JSON.stringify({ + ...JSON.parse(defaults.webhookJson), + events: ['subscription/cancelled'], + }); - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('encryption_key', 'def'); + const element = await fixture(html` + + `); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="webhook-json-group-two"] foxy-internal-switch-control[infer="webhook-json-events-subscription-cancelled"]' + ) as InternalSwitchControl; + + expect(control).to.exist; + control.setValue(false); + expect(control.getValue()).to.be.false; + expect(JSON.parse(element.form.config as string)).to.have.deep.property('events', []); + + control.setValue(true); + expect(control.getValue()).to.be.true; + expect(JSON.parse(element.form.config as string)).to.have.deep.property('events', [ + 'subscription/cancelled', + ]); }); - it('renders a text control for legacy xml webhook title', async () => { + it('renders a text control for legacy xml webhook title in webhook legacy xml group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookLegacyXml, title: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webhookLegacyXml), title: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-legacy-xml-title"]' + 'foxy-internal-summary-control[infer="webhook-legacy-xml-group-one"] foxy-internal-text-control[infer="webhook-legacy-xml-title"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('title', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookLegacyXml); + expect(control).to.have.attribute('json-path', 'title'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text area control for legacy xml webhook url', async () => { + it('renders a text control for legacy xml webhook url in webhook legacy xml group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookLegacyXml, url: 'https://example.com' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.webhookLegacyXml), + url: 'https://example.com', + }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-legacy-xml-url"]' - ) as InternalTextAreaControl; - - expect(control).to.be.instanceOf(InternalTextAreaControl); - expect(control.getValue()).to.equal('https://example.com'); + 'foxy-internal-summary-control[infer="webhook-legacy-xml-group-one"] foxy-internal-text-control[infer="webhook-legacy-xml-url"]' + ) as InternalTextControl; - control.setValue('https://foo.example.com/abc'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('url', 'https://foo.example.com/abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookLegacyXml); + expect(control).to.have.attribute('json-path', 'url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a radio group control for legacy xml webhook service', async () => { + it('renders a select control for webhook service in webhook legacy xml group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify(defaults.webhookLegacyXml); + data.config = defaults.webhookLegacyXml; const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webhook-service"]' - ) as InternalRadioGroupControl; - - expect(control).to.be.instanceOf(InternalRadioGroupControl); - expect(control.getValue()).to.equal('legacy_xml'); + 'foxy-internal-summary-control[infer="webhook-legacy-xml-group-one"] foxy-internal-select-control[infer="webhook-service"]' + ) as InternalSelectControl; + + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webhookLegacyXml); + expect(control).to.have.attribute('json-path', 'service'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ { value: 'json', label: 'option_json' }, { value: 'legacy_xml', label: 'option_legacy_xml' }, ]); - - control.setValue('json'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('service', 'json'); }); - it('renders a text control for webflow site id', async () => { + it('renders a text control for webflow site id in webflow group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, site_id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), site_id: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-site-id"]' + 'foxy-internal-summary-control[infer="webflow-group-one"] foxy-internal-text-control[infer="webflow-site-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('site_id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'site_id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow site name', async () => { + it('renders a text control for webflow site name in webflow group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, site_name: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), site_name: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-site-name"]' + 'foxy-internal-summary-control[infer="webflow-group-one"] foxy-internal-text-control[infer="webflow-site-name"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('site_name', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'site_name'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow collection id', async () => { + it('renders a text control for webflow collection id in webflow group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, collection_id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), collection_id: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-collection-id"]' + 'foxy-internal-summary-control[infer="webflow-group-two"] foxy-internal-text-control[infer="webflow-collection-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('collection_id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'collection_id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow collection name', async () => { + it('renders a text control for webflow collection name in webflow group two', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, collection_name: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), collection_name: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-collection-name"]' + 'foxy-internal-summary-control[infer="webflow-group-two"] foxy-internal-text-control[infer="webflow-collection-name"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('collection_name', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'collection_name'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow sku field id', async () => { + it('renders a text control for webflow sku field id in webflow group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, sku_field_id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), sku_field_id: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-sku-field-id"]' + 'foxy-internal-summary-control[infer="webflow-group-three"] foxy-internal-text-control[infer="webflow-sku-field-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('sku_field_id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'sku_field_id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow sku field name', async () => { + it('renders a text control for webflow sku field name in webflow group three', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, sku_field_name: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), sku_field_name: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-sku-field-name"]' + 'foxy-internal-summary-control[infer="webflow-group-three"] foxy-internal-text-control[infer="webflow-sku-field-name"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('sku_field_name', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'sku_field_name'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow inventory field id', async () => { + it('renders a text control for webflow inventory field id in webflow group four', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, inventory_field_id: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), inventory_field_id: 'abc' }); const element = await fixture(html` `); + const control = element.renderRoot.querySelector( - '[infer="webflow-inventory-field-id"]' + 'foxy-internal-summary-control[infer="webflow-group-four"] foxy-internal-text-control[infer="webflow-inventory-field-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('inventory_field_id', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'inventory_field_id'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a text control for webflow inventory field name', async () => { + it('renders a text control for webflow inventory field name in webflow group four', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify({ ...defaults.webflow, inventory_field_name: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webflow), inventory_field_name: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="webflow-inventory-field-name"]' + 'foxy-internal-summary-control[infer="webflow-group-four"] foxy-internal-text-control[infer="webflow-inventory-field-name"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); - - control.setValue('def'); - - const config = JSON.parse(element.form.config as string); - expect(config).to.have.property('inventory_field_name', 'def'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.webflow); + expect(control).to.have.attribute('json-path', 'inventory_field_name'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders a readonly list control for zapier events', async () => { + it('renders a readonly list control for zapier events in zapier group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'zapier'; - data.config = JSON.stringify({ ...defaults.zapier, events: ['abc'] }); + data.config = JSON.stringify({ ...JSON.parse(defaults.zapier), events: ['abc'] }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="zapier-events"]' + 'foxy-internal-summary-control[infer="zapier-group-one"] foxy-internal-editable-list-control[infer="zapier-events"]' ) as InternalEditableListControl; - expect(control).to.be.instanceOf(InternalEditableListControl); - expect(control.getValue()).to.deep.equal(['abc']); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.zapier); + expect(control).to.have.attribute('json-path', 'events'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('simple-value'); }); - it('renders a readonly text area control for zapier url', async () => { + it('renders a readonly text control for zapier url in zapier group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'zapier'; - data.config = JSON.stringify({ ...defaults.zapier, url: 'https://hooks.zapier.com/abc' }); + data.config = JSON.stringify({ + ...JSON.parse(defaults.zapier), + url: 'https://hooks.zapier.com/abc', + }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="zapier-url"]' - ) as InternalTextAreaControl; + 'foxy-internal-summary-control[infer="zapier-group-one"] foxy-internal-text-control[infer="zapier-url"]' + ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextAreaControl); - expect(control.getValue()).to.equal('https://hooks.zapier.com/abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.zapier); + expect(control).to.have.attribute('json-path', 'url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); it('renders a readonly content warning for zapier', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'zapier'; - data.config = JSON.stringify(defaults.zapier); + data.config = defaults.zapier; const element = await fixture(html` @@ -1289,27 +1345,30 @@ describe('NativeIntegrationForm', () => { expect(warning).to.have.attribute('infer', 'zapier-warning'); }); - it('renders a readonly text control for apple pay merchant ID', async () => { + it('renders a readonly text control for apple pay merchant ID in apple pay group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'apple_pay'; - data.config = JSON.stringify({ ...defaults.applePay, merchantID: 'abc' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.applePay), merchantID: 'abc' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="apple-pay-merchant-id"]' + 'foxy-internal-summary-control[infer="apple-pay-group-one"] foxy-internal-text-control[infer="apple-pay-merchant-id"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('abc'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.applePay); + expect(control).to.have.attribute('json-path', 'merchantID'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); it('renders a readonly content warning for apple pay', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'apple_pay'; - data.config = JSON.stringify(defaults.applePay); + data.config = defaults.applePay; const element = await fixture(html` @@ -1321,20 +1380,23 @@ describe('NativeIntegrationForm', () => { expect(warning).to.have.attribute('infer', 'apple-pay-warning'); }); - it('renders a readonly text control for custom tax url', async () => { + it('renders a readonly text control for custom tax url in custom tax group one', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'custom_tax'; - data.config = JSON.stringify({ ...defaults.customTax, url: 'https://example.com' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.customTax), url: 'https://example.com' }); const element = await fixture(html` `); const control = element.renderRoot.querySelector( - '[infer="custom-tax-url"]' + 'foxy-internal-summary-control[infer="custom-tax-group-one"] foxy-internal-text-control[infer="custom-tax-url"]' ) as InternalTextControl; - expect(control).to.be.instanceOf(InternalTextControl); - expect(control.getValue()).to.equal('https://example.com'); + expect(control).to.exist; + expect(control).to.have.attribute('json-template', defaults.customTax); + expect(control).to.have.attribute('json-path', 'url'); + expect(control).to.have.attribute('property', 'config'); + expect(control).to.have.attribute('layout', 'summary-item'); }); }); diff --git a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts index 3dec384c..6dfdfff5 100644 --- a/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts +++ b/src/elements/public/NativeIntegrationForm/NativeIntegrationForm.ts @@ -1,12 +1,16 @@ -import type { TemplateResult } from 'lit-html'; +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { NucleonV8N } from '../NucleonElement/types'; +import type { Resource } from '@foxy.io/sdk/core'; import type { Option } from '../../internal/InternalRadioGroupControl/types'; import type { Data } from './types'; import type { Item } from '../../internal/InternalEditableListControl/types'; +import type { Rels } from '@foxy.io/sdk/backend'; import { TranslatableMixin } from '../../../mixins/translatable'; import { BooleanSelector } from '@foxy.io/sdk/core'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { ifDefined } from 'lit-html/directives/if-defined'; import { html, svg } from 'lit-html'; import * as defaults from './defaults'; @@ -22,6 +26,13 @@ const Base = TranslatableMixin(InternalForm, NS); * @since 1.25.0 */ export class NativeIntegrationForm extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + store: {}, + }; + } + static get v8n(): NucleonV8N { const parse = memoize(JSON.parse); const isURL = memoize((value: string) => { @@ -135,6 +146,8 @@ export class NativeIntegrationForm extends Base { ]; } + store: string | null = null; + private readonly __createConfigGetterFor = memoize((key: string) => { return () => this.__config?.[key]; }); @@ -159,57 +172,7 @@ export class NativeIntegrationForm extends Base { { value: 'custom_tax', label: 'option_custom_tax' }, ]; - private readonly __avalaraConfigOptions: Option[] = [ - { - value: 'use_ava_tax', - label: 'option_use_ava_tax', - }, - { - value: 'enable_colorado_delivery_fee', - label: 'option_enable_colorado_delivery_fee', - }, - { - value: 'create_invoice', - label: 'option_create_invoice', - }, - { - value: 'use_address_validation', - label: 'option_use_address_validation', - }, - ]; - - private readonly __taxjarConfigOptions: Option[] = [ - { - value: 'create_invoice', - label: 'option_create_invoice', - }, - ]; - - private readonly __configOptionsGetValue = () => { - const config = this.__config; - const value: string[] = []; - - if (config?.enable_colorado_delivery_fee) value.push('enable_colorado_delivery_fee'); - if (config?.use_address_validation) value.push('use_address_validation'); - if (config?.create_invoice) value.push('create_invoice'); - if (config?.use_ava_tax) value.push('use_ava_tax'); - - return value; - }; - - private readonly __configOptionsSetValue = (value: string[]) => { - this.__config = { - enable_colorado_delivery_fee: value.includes('enable_colorado_delivery_fee'), - use_address_validation: value.includes('use_address_validation'), - create_invoice: value.includes('create_invoice'), - use_ava_tax: value.includes('use_ava_tax'), - }; - }; - - private readonly __avalaraAddressValidationCountriesOptions = [ - { value: 'US', label: 'option_US' }, - { value: 'CA', label: 'option_CA' }, - ]; + private readonly __avalaraAddressValidationCountriesOptions = [{ value: 'US' }, { value: 'CA' }]; private readonly __codeMappingsGetValue = () => { const mappings = this.__config?.category_to_product_tax_code_mappings ?? {}; @@ -264,7 +227,7 @@ export class NativeIntegrationForm extends Base { const match = [super.readonlySelector.toString()]; if (this.href) { - match.push('apple-pay-merchant-id', 'zapier-events', 'zapier-url', 'provider'); + match.push('apple-pay-group-one', 'zapier-group-one', 'provider-group-one'); } return new BooleanSelector(match.join(' ').trim()); @@ -285,13 +248,16 @@ export class NativeIntegrationForm extends Base { ${this.href ? '' : html` - - + + + + `} ${provider === 'avalara' ? this.__renderAvalaraConfig() @@ -311,6 +277,15 @@ export class NativeIntegrationForm extends Base { ? this.__renderCustomTaxConfig() : ''} ${super.renderBody()} + + `; } @@ -345,7 +320,7 @@ export class NativeIntegrationForm extends Base { private set __config(value: any) { const config = this.__config; const provider = this.form.provider ?? 'avalara'; - const defaultConfig = (() => { + const serializedDefaultConfig = (() => { if (provider === 'avalara') return defaults.avalara; if (provider === 'taxjar') return defaults.taxjar; if (provider === 'onesource') return defaults.onesource; @@ -357,161 +332,248 @@ export class NativeIntegrationForm extends Base { } })(); + const defaultConfig = serializedDefaultConfig ? JSON.parse(serializedDefaultConfig) : {}; const newConfig = JSON.stringify({ ...defaultConfig, ...config, ...value }); this.edit({ provider, config: newConfig }); } private __renderAvalaraConfig() { + const isActive = this.__storeLoader?.data?.is_active; + const serviceUrlPlaceholder = + typeof isActive === 'boolean' + ? this.t( + `avalara-group-one.avalara-service-url.placeholder_${isActive ? 'active' : 'inactive'}` + ) + : void 0; + return html` - - + + + - - + + - - + + - - + + + + + + + + + + + + - - + + + + ${this.__config?.use_address_validation + ? html` + + + ` + : ''} + + + - - - - ${this.__config?.use_address_validation - ? html` - - - ` - : ''} + + + `; } private __renderTaxJarConfig() { return html` - - - - - + + + - - + + + + + + + + `; } private __renderOneSourceConfig() { return html` - - - - - - - - - - - + + + - - + + - - + + + - - + + + - - + + + + + + + + + + + - - + + + + + + + + + `; } @@ -545,178 +607,250 @@ export class NativeIntegrationForm extends Base { private __renderWebhookJsonConfig() { return html` - - - - - - - - + + + - - + + - - + + + + + + + + + ${this.__webhookJsonEventsOptions.map(option => { + return html` + this.__config?.events?.includes(option.value)} + .setValue=${(value: boolean) => { + const events = this.__config?.events ?? []; + this.__config = { + events: value + ? [...events, option.value] + : events.filter((v: string) => v !== option.value), + }; + }} + > + + `; + })} + `; } private __renderWebhookLegacyXmlConfig() { return html` - - + + + - - + + - - + + + `; } private __renderWebflowConfig() { return html` -
+ + + + + + + -
+ `; } private __renderZapierConfig() { return html` - - + + + - - + + -

- -

+

+ +

+
`; } private __renderApplePayConfig() { return html` - - + + + -

- -

+

+ +

+
`; } private __renderCustomTaxConfig() { return html` - - + + + + `; } + + private get __storeLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#storeLoader'); + } } diff --git a/src/elements/public/NativeIntegrationForm/defaults.ts b/src/elements/public/NativeIntegrationForm/defaults.ts index 878361e3..bd4af6e7 100644 --- a/src/elements/public/NativeIntegrationForm/defaults.ts +++ b/src/elements/public/NativeIntegrationForm/defaults.ts @@ -1,21 +1,21 @@ -export const webhookLegacyXml = { +export const webhookLegacyXml = JSON.stringify({ service: 'legacy_xml', version: 1, events: ['transaction/created'], title: '', url: '', -}; +}); -export const webhookJson = { +export const webhookJson = JSON.stringify({ encryption_key: '', service: 'json', version: 1, events: [], title: '', url: '', -}; +}); -export const onesource = { +export const onesource = JSON.stringify({ part_number_product_option: '', product_order_priority: '', calling_system_number: '', @@ -25,17 +25,17 @@ export const onesource = { service_url: '', host_system: '', from_city: '', -}; +}); -export const customTax = { +export const customTax = JSON.stringify({ url: '', -}; +}); -export const applePay = { +export const applePay = JSON.stringify({ merchantID: '', -}; +}); -export const avalara = { +export const avalara = JSON.stringify({ category_to_product_tax_code_mappings: {}, enable_colorado_delivery_fee: false, address_validation_countries: [], @@ -46,9 +46,9 @@ export const avalara = { use_ava_tax: false, key: '', id: '', -}; +}); -export const webflow = { +export const webflow = JSON.stringify({ inventory_field_name: '', inventory_field_id: '', collection_name: '', @@ -61,16 +61,16 @@ export const webflow = { site_id: '', events: ['transaction/created'], auth: '', -}; +}); -export const zapier = { +export const zapier = JSON.stringify({ service: 'zapier', events: [], url: '', -}; +}); -export const taxjar = { +export const taxjar = JSON.stringify({ category_to_product_tax_code_mappings: {}, create_invoice: false, api_token: '', -}; +}); diff --git a/src/elements/public/NativeIntegrationForm/index.ts b/src/elements/public/NativeIntegrationForm/index.ts index dd863a70..f37a3557 100644 --- a/src/elements/public/NativeIntegrationForm/index.ts +++ b/src/elements/public/NativeIntegrationForm/index.ts @@ -1,11 +1,13 @@ -import '../../internal/InternalCheckboxGroupControl/index'; import '../../internal/InternalEditableListControl/index'; -import '../../internal/InternalRadioGroupControl/index'; import '../../internal/InternalPasswordControl/index'; -import '../../internal/InternalTextAreaControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalSwitchControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; +import '../NucleonElement/index'; + import { NativeIntegrationForm } from './NativeIntegrationForm'; customElements.define('foxy-native-integration-form', NativeIntegrationForm); diff --git a/src/static/translations/native-integration-form/en.json b/src/static/translations/native-integration-form/en.json index 137315ef..d4573d45 100644 --- a/src/static/translations/native-integration-form/en.json +++ b/src/static/translations/native-integration-form/en.json @@ -26,168 +26,272 @@ "error": { "already_configured": "This integration is already configured. Please edit the existing integration instead." }, - "provider": { - "label": "Provider", - "option_avalara": "Avalara", - "option_taxjar": "TaxJar", - "option_onesource": "ONESOURCE", - "option_webflow": "Webflow", - "option_zapier": "Zapier", - "option_custom_tax": "Custom Tax Endpoint", - "helper_text": "Changing service provider is not possible after creation.", - "v8n_required": "Please select a provider." - }, - "avalara-service-url": { - "label": "Service URL", - "placeholder": "Required", - "helper_text": "If in test mode, it should be https://development.avalara.net. If in production, it should be https://avatax.avalara.net unless a different Service URL has been provided to you by Avalara.", - "v8n_required": "Please enter a service URL.", - "v8n_invalid": "Please enter a valid URL." - }, - "avalara-id": { - "label": "Account number", - "placeholder": "Required", - "helper_text": "Be sure to use either a development or production value based on your Service URL.", - "v8n_required": "Please enter an account number." - }, - "avalara-key": { - "label": "License key", - "placeholder": "Required", - "helper_text": "Be sure to use either a development or production value based on your Service URL.", - "v8n_required": "Please enter a license key." - }, - "avalara-company-code": { - "label": "Company code", - "placeholder": "Required", - "helper_text": "Be sure to use either a development or production value based on your Service URL.", - "v8n_required": "Please enter a company code." - }, - "avalara-options": { - "label": "Options", - "option_use_ava_tax": "Use for live taxes", - "option_enable_colorado_delivery_fee": "Enable Colorado Delivery Fee", - "option_create_invoice": "Enable Committed Sales Invoice", - "option_use_address_validation": "Validate customer addresses", - "helper_text": "" - }, - "avalara-address-validation-countries": { - "label": "Address validation countries", - "option_US": "United States", - "option_CA": "Canada", - "helper_text": "" - }, - "avalara-category-to-product-tax-code-mappings": { - "label": "Category to product tax code mappings", - "helper_text": "Replace Foxy category codes with AvaTax tax codes when sending data to Avalara. If left empty, the category code will be sent to AvaTax instead.", - "placeholder": "foxy_category_code:avatax_tax_code", - "v8n_required": "Please add at least one tax code mapping." - }, - "taxjar-api-token": { - "label": "API token", - "placeholder": "Required", - "helper_text": "Your SmartCalcs API token obtained from TaxJar.", - "v8n_required": "Please enter an API token." - }, - "taxjar-category-to-product-tax-code-mappings": { - "label": "Category to product tax code mappings", - "helper_text": "Replace Foxy category codes with TaxJar tax codes when sending data to TaxJar. If left empty, the category code will be sent to TaxJar instead.", - "placeholder": "foxy_category_code:taxjar_tax_code" - }, - "taxjar-options": { - "label": "Options", - "option_create_invoice": "Enable Committed Sales Invoice", - "helper_text": "" - }, - "onesource-service-url": { - "label": "Service URL", - "placeholder": "Required", - "helper_text": "The ONESOURCE service URL you want to connect to.", - "v8n_required": "Please enter a service URL.", - "v8n_invalid": "Please enter a valid URL." - }, - "onesource-external-company-id": { - "label": "External company ID", - "placeholder": "Required", - "helper_text": "External company ID mapping to the Determination company owning the audit data.", - "v8n_required": "Please enter an external company ID." + "provider-group-one": { + "label": "", + "helper_text": "", + "provider": { + "label": "Provider", + "placeholder": "Select", + "option_avalara": "Avalara", + "option_taxjar": "TaxJar", + "option_onesource": "ONESOURCE", + "option_webflow": "Webflow", + "option_zapier": "Zapier", + "option_custom_tax": "Custom Tax Endpoint", + "helper_text": "Changing service provider is not possible after creation.", + "v8n_required": "Please select a provider." + } }, - "onesource-calling-system-number": { - "label": "Calling system number", - "placeholder": "Required", - "helper_text": "A unique identifier for your ERP system. The combination of Calling System Number, Host System, and Unique Invoice Number form a unique key for an invoice in the Audit Database.", - "v8n_required": "Please enter a calling system number." + "avalara-group-one": { + "label": "", + "helper_text": "", + "avalara-service-url": { + "label": "Service URL", + "placeholder": "Required", + "placeholder_active": "https://avatax.avalara.net", + "placeholder_inactive": "https://development.avalara.net", + "helper_text": "", + "v8n_required": "Please enter a service URL.", + "v8n_invalid": "Please enter a valid URL." + }, + "avalara-id": { + "label": "Account number", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an account number." + }, + "avalara-key": { + "label": "License key", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a license key." + }, + "avalara-company-code": { + "label": "Company code", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a company code." + } }, - "onesource-from-city": { - "label": "From city", - "placeholder": "Required", - "helper_text": "This city should match the postal code and country you have configured in your settings.", - "v8n_required": "Please enter a city." + "avalara-group-two": { + "label": "", + "helper_text": "", + "avalara-category-to-product-tax-code-mappings": { + "label": "Category to product tax code mappings", + "helper_text": "Replace Foxy category codes with AvaTax tax codes when sending data to Avalara. If left empty, the category code will be sent to AvaTax instead.", + "placeholder": "foxy_category_code:avatax_tax_code", + "v8n_required": "Please add at least one tax code mapping." + } }, - "onesource-host-system": { - "label": "Host system", - "placeholder": "Required", - "helper_text": "A unique name for your ERP system. The combination of Calling System Number, Host System, and Unique Invoice Number form a unique key for an invoice in the Audit Database.", - "v8n_required": "Please enter a host system." + "avalara-group-three": { + "label": "", + "helper_text": "", + "avalara-use-ava-tax": { + "label": "Use for live taxes", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "avalara-enable-colorado-delivery-fee": { + "label": "Enable Colorado Delivery Fee", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "avalara-create-invoice": { + "label": "Enable Committed Sales Invoice", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "avalara-use-address-validation": { + "label": "Validate customer addresses", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "avalara-address-validation-countries": { + "label": "Address validation countries", + "placeholder": "Enter (only US and Canada are supported at this time)", + "helper_text": "" + } }, - "onesource-company-role": { - "label": "Company role", - "option_buyer": "Buyer", - "option_seller": "Seller", - "option_middleman": "Middleman", - "helper_text": "The role the company plays in a given transaction. Each role results in different transaction tax and reporting requirements." + "taxjar-group-one": { + "label": "", + "helper_text": "", + "taxjar-api-token": { + "label": "API token", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an API token." + }, + "taxjar-create-invoice": { + "label": "Enable Committed Sales Invoice", + "checked": "Yes", + "unchecked": "No", + "helper_text": "" + } }, - "onesource-part-number-product-option": { - "label": "Custom product option name", - "placeholder": "Optional", - "helper_text": "If you use a custom product option name to specify your part number such as SKU or ISBN, enter that name here here. You can also set it to \"code\" to use the standard product code attribute." + "taxjar-group-two": { + "label": "", + "helper_text": "", + "taxjar-category-to-product-tax-code-mappings": { + "label": "Category to product tax code mappings", + "helper_text": "Replace Foxy category codes with TaxJar tax codes when sending data to TaxJar. If left empty, the category code will be sent to TaxJar instead.", + "placeholder": "foxy_category_code:taxjar_tax_code" + } }, - "onesource-product-order-priority": { - "label": "Custom product order", - "placeholder": "Optional", - "helper_text": "The PRODUCT_CODE field in ONESOURCE is populated via the Foxy category code. The first shippable product will have the RELATED_LINE_NUMBER associated with it. To control which product is listed first, add a list of Foxy category codes here based on the order priority you'd like." + "onesource-group-one": { + "label": "", + "helper_text": "", + "onesource-service-url": { + "label": "Service URL", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a service URL.", + "v8n_invalid": "Please enter a valid URL." + }, + "onesource-external-company-id": { + "label": "External company ID", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an external company ID." + }, + "onesource-from-city": { + "label": "From city", + "placeholder": "Required", + "helper_text": "Should match the postal code and country you have configured in your settings.", + "v8n_required": "Please enter a city." + } }, - "onesource-audit-settings": { - "label": "Audit", - "option_capture_only": "On capture", - "option_auth_and_capture": "On auth and capture", - "option_never": "Never", - "helper_text": "When completing a transaction, use this setting to determine if the information sent to Onesource will be audited and reported." + "onesource-group-two": { + "label": "", + "helper_text": "", + "onesource-calling-system-number": { + "label": "Calling system number", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a calling system number." + }, + "onesource-host-system": { + "label": "Host system", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a host system." + } }, - "webhook-service": { - "label": "Payload format", - "option_json": "JSON", - "option_legacy_xml": "XML", - "helper_text": "Use JSON for new services. XML webhooks are deprecated and may be removed in the future." + "onesource-group-three": { + "label": "", + "helper_text": "", + "onesource-company-role": { + "label": "Company role", + "option_buyer": "Buyer", + "option_seller": "Seller", + "option_middleman": "Middleman", + "placeholder": "Select", + "helper_text": "" + }, + "onesource-audit-settings": { + "label": "Audit", + "option_capture_only": "On capture", + "option_auth_and_capture": "On auth and capture", + "option_never": "Never", + "placeholder": "Select", + "helper_text": "" + } }, - "webhook-json-title": { - "label": "Provider", - "placeholder": "Required", - "helper_text": "A descriptive title that identies your custom service.", - "v8n_required": "Please enter a title." + "onesource-group-four": { + "label": "", + "helper_text": "", + "onesource-part-number-product-option": { + "label": "Custom product option name", + "placeholder": "Optional", + "helper_text": "If you use a custom product option name to specify your part number such as SKU or ISBN, enter that name here here. You can also set it to \"code\" to use the standard product code attribute." + } }, - "webhook-json-encryption-key": { - "label": "Encryption key", - "placeholder": "Required", - "helper_text": "This value is used as they key to encrypt the and verify the payload sent for the webhook.", - "v8n_required": "Please enter an encryption key." + "onesource-group-five": { + "label": "", + "helper_text": "", + "onesource-product-order-priority": { + "label": "Custom product order", + "placeholder": "Optional", + "helper_text": "The PRODUCT_CODE field in ONESOURCE is populated via the Foxy category code. The first shippable product will have the RELATED_LINE_NUMBER associated with it. To control which product is listed first, add a list of Foxy category codes here based on the order priority you'd like." + } }, - "webhook-json-url": { - "label": "Webhook URL", - "placeholder": "Required", - "helper_text": "The absolute URL (beginning with https:// or http://) to which Foxy will send the webhook on selected events.", - "v8n_required": "Please enter a webhook URL.", - "v8n_invalid": "Please enter a valid URL." + "webhook-json-group-one": { + "label": "", + "helper_text": "", + "webhook-json-title": { + "label": "Name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a name for your webhook." + }, + "webhook-json-encryption-key": { + "label": "Encryption key", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an encryption key." + }, + "webhook-json-url": { + "label": "URL", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a webhook URL.", + "v8n_invalid": "Please enter a valid URL." + }, + "webhook-service": { + "label": "Format", + "option_json": "JSON", + "option_legacy_xml": "XML", + "helper_text": "", + "placeholder": "Select" + } }, - "webhook-json-events": { - "label": "Events", - "option_transaction_created": "Transaction created", - "option_subscription_cancelled": "Subscription cancelled", - "helper_text": "Select at least one event that will trigger this webhook." + "webhook-json-group-two": { + "label": "", + "helper_text": "", + "webhook-json-events-subscription-cancelled": { + "label": "Send when subscription is cancelled", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "webhook-json-events-transaction-created": { + "label": "Send when transaction is created", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } }, "webhook-warning": { "warning_text": "We are winding down support for custom services that rely on legacy webhooks and XML datafeed. Please consider using the new JSON webhooks instead.", "link_text": "Read the announcement" }, + "webhook-legacy-xml-group-one": { + "label": "", + "helper_text": "", + "webhook-legacy-xml-title": { + "label": "Name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a name for your webhook." + }, + "webhook-legacy-xml-url": { + "label": "URL", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a webhook URL.", + "v8n_invalid": "Please enter a valid URL." + }, + "webhook-service": { + "label": "Format", + "option_json": "JSON", + "option_legacy_xml": "XML", + "helper_text": "", + "placeholder": "Select" + } + }, "webhook-legacy-xml-title": { "label": "Provider", "placeholder": "Required", @@ -201,79 +305,107 @@ "v8n_required": "Please enter a webhook URL.", "v8n_invalid": "Please enter a valid URL." }, - "webflow-site-id": { - "label": "Site ID", - "placeholder": "Required", - "helper_text": "The Site ID of your Webflow site.", - "v8n_required": "Please enter a site ID." - }, - "webflow-site-name": { - "label": "Site name", - "placeholder": "Required", - "helper_text": "The name of your Webflow site.", - "v8n_required": "Please enter a site name." - }, - "webflow-collection-id": { - "label": "Collection ID", - "placeholder": "Required", - "helper_text": "The ID of the collection that products are stored in.", - "v8n_required": "Please enter a collection ID." - }, - "webflow-collection-name": { - "label": "Collection name", - "placeholder": "Required", - "helper_text": "The name of your products collection.", - "v8n_required": "Please enter a collection name." - }, - "webflow-sku-field-id": { - "label": "SKU field ID", - "placeholder": "Required", - "helper_text": "The ID of the code field in your products collection.", - "v8n_required": "Please enter a SKU field ID." - }, - "webflow-sku-field-name": { - "label": "SKU field name", - "placeholder": "Required", - "helper_text": "The name of the code field in your products collection.", - "v8n_required": "Please enter a SKU field name." - }, - "webflow-inventory-field-id": { - "label": "Inventory field ID", - "placeholder": "Required", - "helper_text": "The ID of the inventory field in your products collection.", - "v8n_required": "Please enter an inventory field ID." - }, - "webflow-inventory-field-name": { - "label": "Inventory field name", - "placeholder": "Required", - "helper_text": "The name of the inventory field in your products collection.", - "v8n_required": "Please enter an inventory field name." + "webflow-group-one": { + "label": "", + "helper_text": "", + "webflow-site-id": { + "label": "Site ID", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a site ID." + }, + "webflow-site-name": { + "label": "Site name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a site name." + } }, - "zapier-event": { - "label": "Event", - "placeholder": "Defined by Zapier", - "helper_text": "The event this zap is subscribed to." + "webflow-group-two": { + "label": "", + "helper_text": "", + "webflow-collection-id": { + "label": "Collection ID", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a collection ID." + }, + "webflow-collection-name": { + "label": "Collection name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a collection name." + } }, - "zapier-url": { - "label": "URL", - "placeholder": "Defined by Zapier", - "helper_text": "The Zapier webhook subscription URL." + "webflow-group-three": { + "label": "", + "helper_text": "", + "webflow-sku-field-id": { + "label": "SKU field ID", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a SKU field ID." + }, + "webflow-sku-field-name": { + "label": "SKU field name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a SKU field name." + } }, - "zapier-warning": { - "warning_text": "Zapier webhooks are read-only and can not be created or modified in the Foxy Admin. Please connect your Zapier webhooks at zapier.com." + "webflow-group-four": { + "label": "", + "helper_text": "", + "webflow-inventory-field-id": { + "label": "Inventory field ID", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an inventory field ID." + }, + "webflow-inventory-field-name": { + "label": "Inventory field name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter an inventory field name." + } }, - "apple-pay-merchant-id": { - "label": "Merchant ID", - "placeholder": "", - "helper_text": "This identifier is configured automatically when you enable Apple Pay in your store settings." + "zapier-group-one": { + "label": "", + "helper_text": "", + "zapier-events": { + "label": "Events", + "placeholder": "", + "helper_text": "" + }, + "zapier-url": { + "label": "URL", + "placeholder": "", + "helper_text": "" + }, + "zapier-warning": { + "warning_text": "Zapier webhooks are read-only and can not be created or modified in the Foxy Admin. Please connect your Zapier webhooks at zapier.com." + } }, - "apple-pay-warning": { - "warning_text": "Apple Pay native integration entry on this page is informational. To configure Apple Pay, please go to your gateway settings." + "apple-pay-group-one": { + "label": "", + "helper_text": "", + "apple-pay-merchant-id": { + "label": "Merchant ID", + "placeholder": "", + "helper_text": "" + }, + "apple-pay-warning": { + "warning_text": "Apple Pay native integration entry on this page is informational. To configure Apple Pay, please go to your gateway settings." + } }, - "custom-tax-url": { - "label": "URL", - "placeholder": "", - "helper_text": "The URL of your custom tax service." + "custom-tax-group-one": { + "label": "", + "helper_text": "", + "custom-tax-url": { + "label": "URL", + "placeholder": "Required", + "helper_text": "" + } }, "timestamps": { "date_created": "Created on", From ef00aa736df7e2b37b3b778a3c2228cc93abc46b Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 7 Jan 2025 15:46:28 -0300 Subject: [PATCH 05/18] test(foxy-native-integration-card): fix tests --- .../NativeIntegrationCard.test.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/elements/public/NativeIntegrationCard/NativeIntegrationCard.test.ts b/src/elements/public/NativeIntegrationCard/NativeIntegrationCard.test.ts index 16bebdb0..1d1ad19a 100644 --- a/src/elements/public/NativeIntegrationCard/NativeIntegrationCard.test.ts +++ b/src/elements/public/NativeIntegrationCard/NativeIntegrationCard.test.ts @@ -36,7 +36,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify(defaults.avalara); + data.config = defaults.avalara; const card = await fixture(html` @@ -53,7 +53,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'taxjar'; - data.config = JSON.stringify(defaults.taxjar); + data.config = defaults.taxjar; const card = await fixture(html` @@ -70,7 +70,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify(defaults.onesource); + data.config = defaults.onesource; const card = await fixture(html` @@ -87,7 +87,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'zapier'; - data.config = JSON.stringify(defaults.zapier); + data.config = defaults.zapier; const card = await fixture(html` @@ -104,7 +104,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify(defaults.webflow); + data.config = defaults.webflow; const card = await fixture(html` @@ -121,7 +121,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify({ ...defaults.webhookJson, title: 'ABC' }); + data.config = JSON.stringify({ ...JSON.parse(defaults.webhookJson), title: 'ABC' }); const card = await fixture(html` @@ -138,7 +138,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'apple_pay'; - data.config = JSON.stringify(defaults.applePay); + data.config = defaults.applePay; const card = await fixture(html` @@ -154,7 +154,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'custom_tax'; - data.config = JSON.stringify(defaults.customTax); + data.config = defaults.customTax; const card = await fixture(html` @@ -170,7 +170,7 @@ describe('NativeIntegrationCard', () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'avalara'; - data.config = JSON.stringify(defaults.avalara); + data.config = defaults.avalara; const card = await fixture(html` @@ -180,14 +180,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.avalara); + expect(text).to.have.deep.property('options', JSON.parse(defaults.avalara)); }); it('renders line 2 text for taxjar when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'taxjar'; - data.config = JSON.stringify(defaults.taxjar); + data.config = defaults.taxjar; const card = await fixture(html` @@ -197,14 +197,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.taxjar); + expect(text).to.have.deep.property('options', JSON.parse(defaults.taxjar)); }); it('renders line 2 text for onesource when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'onesource'; - data.config = JSON.stringify(defaults.onesource); + data.config = defaults.onesource; const card = await fixture(html` @@ -214,14 +214,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.onesource); + expect(text).to.have.deep.property('options', JSON.parse(defaults.onesource)); }); it('renders line 2 text for zapier when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'zapier'; - data.config = JSON.stringify(defaults.zapier); + data.config = defaults.zapier; const card = await fixture(html` @@ -230,14 +230,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.zapier); + expect(text).to.have.deep.property('options', JSON.parse(defaults.zapier)); }); it('renders line 2 text for webflow when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webflow'; - data.config = JSON.stringify(defaults.webflow); + data.config = defaults.webflow; const card = await fixture(html` @@ -246,14 +246,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.webflow); + expect(text).to.have.deep.property('options', JSON.parse(defaults.webflow)); }); it('renders line 2 text for webhook when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'webhook'; - data.config = JSON.stringify(defaults.webhookJson); + data.config = defaults.webhookJson; const card = await fixture(html` @@ -262,14 +262,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.webhookJson); + expect(text).to.have.deep.property('options', JSON.parse(defaults.webhookJson)); }); it('renders line 2 text for apple pay when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'apple_pay'; - data.config = JSON.stringify(defaults.applePay); + data.config = defaults.applePay; const card = await fixture(html` @@ -278,14 +278,14 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.applePay); + expect(text).to.have.deep.property('options', JSON.parse(defaults.applePay)); }); it('renders line 2 text for custom tax when loaded', async () => { const data = await getTestData('./hapi/native_integrations/0'); data.provider = 'custom_tax'; - data.config = JSON.stringify(defaults.customTax); + data.config = defaults.customTax; const card = await fixture(html` @@ -294,6 +294,6 @@ describe('NativeIntegrationCard', () => { expect(text).to.exist; expect(text).to.have.attribute('infer', ''); - expect(text).to.have.deep.property('options', defaults.customTax); + expect(text).to.have.deep.property('options', JSON.parse(defaults.customTax)); }); }); From e0a9304c99423bc86917836e0c2636c17fc6ec43 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 7 Jan 2025 15:50:28 -0300 Subject: [PATCH 06/18] fix(foxy-tax-card): fix subtitle text for custom tax endpoint --- src/elements/public/TaxCard/TaxCard.test.ts | 22 +++++++++++++++++++++ src/elements/public/TaxCard/TaxCard.ts | 2 ++ src/static/translations/tax-card/en.json | 10 ++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/elements/public/TaxCard/TaxCard.test.ts b/src/elements/public/TaxCard/TaxCard.test.ts index fa358255..2c3a18af 100644 --- a/src/elements/public/TaxCard/TaxCard.test.ts +++ b/src/elements/public/TaxCard/TaxCard.test.ts @@ -65,6 +65,16 @@ describe('TaxCard', () => { expect(subtitle).to.include.text('tax_union'); }); + it('renders tax_custom_tax key for custom tax endpoint taxes in the subtitle', async () => { + const data = await getTestData('./hapi/taxes/0'); + data.type = 'custom_tax' as Data['type']; + + const element = await fixture(html``); + const subtitle = await getByTestId(element, 'subtitle'); + + expect(subtitle).to.include.text('tax_custom_tax'); + }); + it('renders country code for country-wide taxes in the subtitle', async () => { const data = await getTestData('./hapi/taxes/0'); @@ -155,6 +165,18 @@ describe('TaxCard', () => { expect(subtitle).to.include.text('TaxJar'); }); + it('renders custom label for taxes using custom_tax_endpoint provider in the subtitle', async () => { + const data = await getTestData('./hapi/taxes/0'); + + data.is_live = true; + data.service_provider = 'custom_tax_endpoint' as Data['service_provider']; + + const element = await fixture(html``); + const subtitle = await getByTestId(element, 'subtitle'); + + expect(subtitle).to.include.text('tax_rate_provider_custom'); + }); + it('renders tax_rate_provider_default key for taxes using default provider in the subtitle', async () => { const data = await getTestData('./hapi/taxes/0'); diff --git a/src/elements/public/TaxCard/TaxCard.ts b/src/elements/public/TaxCard/TaxCard.ts index 45f6c878..682c6545 100644 --- a/src/elements/public/TaxCard/TaxCard.ts +++ b/src/elements/public/TaxCard/TaxCard.ts @@ -25,6 +25,7 @@ export class TaxCard extends TranslatableMixin(TwoLineCard, 'tax-card') { if (type === 'country') return country; if (type === 'region') return `${country}, ${region}`; if (type === 'local') return `${country}, ${region}, ${city}`; + if (type === 'custom_tax') return this.t('tax_custom_tax'); } private getRateLabel({ is_live, rate }: Data) { @@ -34,6 +35,7 @@ export class TaxCard extends TranslatableMixin(TwoLineCard, 'tax-card') { if (provider === 'onesource') return 'Thomson Reuters ONESOURCE'; if (provider === 'avalara') return 'Avalara AvaTax 15'; if (provider === 'taxjar') return 'TaxJar'; + if (provider === 'custom_tax_endpoint') return this.t('tax_rate_provider_custom'); return this.t('tax_rate_provider_default'); } diff --git a/src/static/translations/tax-card/en.json b/src/static/translations/tax-card/en.json index c0ba054d..e23b3654 100644 --- a/src/static/translations/tax-card/en.json +++ b/src/static/translations/tax-card/en.json @@ -1,11 +1,13 @@ { "percent": "{{fraction, percent}}", - "tax_global": "Global tax", - "tax_rate_provider_default": "Default (Thomson Reuters; others)", - "tax_union": "European Union tax", + "tax_global": "Global", + "tax_rate_provider_default": "Default", + "tax_union": "European Union", + "tax_custom_tax": "Live", + "tax_rate_provider_custom": "Custom Tax Endpoint", "spinner": { "loading_busy": "Loading", "loading_empty": "No data", "loading_error": "Unknown error" } -} +} \ No newline at end of file From 363df6abe1a33560627c7f2d78dfd6ae153c2729 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 7 Jan 2025 17:36:08 -0300 Subject: [PATCH 07/18] feat(foxy-tax-form): rebuild with updated base class and controls --- .../public/TaxForm/TaxForm.stories.ts | 37 +- src/elements/public/TaxForm/TaxForm.test.ts | 2821 ++++------------- src/elements/public/TaxForm/TaxForm.ts | 956 ++---- src/elements/public/TaxForm/index.ts | 19 +- src/static/translations/tax-form/en.json | 193 +- 5 files changed, 1048 insertions(+), 2978 deletions(-) diff --git a/src/elements/public/TaxForm/TaxForm.stories.ts b/src/elements/public/TaxForm/TaxForm.stories.ts index 8ed7f2e9..7d4bb53d 100644 --- a/src/elements/public/TaxForm/TaxForm.stories.ts +++ b/src/elements/public/TaxForm/TaxForm.stories.ts @@ -11,24 +11,37 @@ const summary: Summary = { localName: 'foxy-tax-form', translatable: true, configurable: { - sections: ['timestamps'], - buttons: [ - 'exempt-all-customer-tax-ids', - 'use-origin-rates', - 'apply-to-shipping', - 'create', - 'delete', + sections: ['timestamps', 'header', 'general', 'group-one', 'group-two', 'group-three'], + buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], + inputs: [ + 'group-one:name', + 'group-one:type', + 'group-one:service-provider', + 'group-one:rate', + 'group-two:apply-to-shipping', + 'group-two:exempt-all-customer-tax-ids', + 'group-two:use-origin-rates', + 'group-three:country', + 'group-three:region-select', + 'group-three:region-input', + 'group-three:city', + 'native-integrations', ], - inputs: ['name', 'type', 'country', 'region', 'city', 'provider', 'rate'], }, }; export default getMeta(summary); -export const Playground = getStory({ ...summary, code: true }); -export const Empty = getStory(summary); -export const Error = getStory(summary); -export const Busy = getStory(summary); +const ext = ` + native-integrations="https://demo.api/hapi/native_integrations" + countries="https://demo.api/hapi/property_helpers/3" + regions="https://demo.api/hapi/property_helpers/4" +`; + +export const Playground = getStory({ ...summary, ext, code: true }); +export const Empty = getStory({ ...summary, ext }); +export const Error = getStory({ ...summary, ext }); +export const Busy = getStory({ ...summary, ext }); Empty.args.href = ''; Error.args.href = 'https://demo.api/virtual/empty?status=404'; diff --git a/src/elements/public/TaxForm/TaxForm.test.ts b/src/elements/public/TaxForm/TaxForm.test.ts index 52646cf7..ae044961 100644 --- a/src/elements/public/TaxForm/TaxForm.test.ts +++ b/src/elements/public/TaxForm/TaxForm.test.ts @@ -1,2324 +1,653 @@ +import type { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { FetchEvent } from '../NucleonElement/FetchEvent'; +import type { Data } from './types'; + import './index'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; - -import { ButtonElement } from '@vaadin/vaadin-button'; -import { Checkbox } from '../../private/Checkbox/Checkbox'; -import { CheckboxChangeEvent } from '../../private/Checkbox/CheckboxChangeEvent'; -import { ComboBoxElement } from '@vaadin/vaadin-combo-box'; -import { Data } from './types'; -import { DropdownChangeEvent } from '../../private/Dropdown/DropdownChangeEvent'; -import { FetchEvent } from '../NucleonElement/FetchEvent'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { InternalSandbox } from '../../internal/InternalSandbox/InternalSandbox'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { TaxForm } from './TaxForm'; -import { TextFieldElement } from '@vaadin/vaadin-text-field'; -import { getByKey } from '../../../testgen/getByKey'; -import { getByName } from '../../../testgen/getByName'; -import { getByTestId } from '../../../testgen/getByTestId'; -import { getTestData } from '../../../testgen/getTestData'; -import { stub } from 'sinon'; -import { unsafeHTML } from 'lit-html/directives/unsafe-html'; +import { TaxForm as Form } from './TaxForm'; import { createRouter } from '../../../server/index'; +import { stub } from 'sinon'; + +async function waitForIdle(element: Form) { + await waitUntil( + () => { + const loaders = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...loaders].every(loader => loader.in('idle')); + }, + '', + { timeout: 5000 } + ); +} describe('TaxForm', () => { - it('extends NucleonElement', () => { - expect(new TaxForm()).to.be.instanceOf(NucleonElement); + it('imports and defines dependencies', () => { + expect(customElements.get('foxy-internal-async-list-control')).to.exist; + expect(customElements.get('foxy-internal-summary-control')).to.exist; + expect(customElements.get('foxy-internal-select-control')).to.exist; + expect(customElements.get('foxy-internal-switch-control')).to.exist; + expect(customElements.get('foxy-internal-number-control')).to.exist; + expect(customElements.get('foxy-internal-text-control')).to.exist; + expect(customElements.get('foxy-internal-form')).to.exist; + expect(customElements.get('foxy-native-integration-card')).to.exist; + expect(customElements.get('foxy-nucleon')).to.exist; }); - it('registers as foxy-tax-form', () => { - expect(customElements.get('foxy-tax-form')).to.equal(TaxForm); + it('imports and defines itself as foxy-tax-form', () => { + expect(customElements.get('foxy-tax-form')).to.equal(Form); }); it('has a default i18next namespace of "tax-form"', () => { - expect(new TaxForm()).to.have.property('ns', 'tax-form'); - }); - - it('has an empty fx:countries URI by default', () => { - expect(new TaxForm()).to.have.property('countries', ''); + expect(Form.defaultNS).to.equal('tax-form'); }); - it('has an empty fx:regions URI by default', () => { - expect(new TaxForm()).to.have.property('regions', ''); + it('extends foxy-internal-form', () => { + expect(new Form()).to.be.an.instanceOf(customElements.get('foxy-internal-form')); }); - describe('name', () => { - it('has i18n label key "name"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'name'); - - expect(control).to.have.property('label', 'name'); - }); - - it('has value of form.name', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ name: 'Test Tax' }); - - const control = await getByTestId(element, 'name'); - expect(control).to.have.property('value', 'Test Tax'); - }); - - it('writes to form.name on input', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'name'); - - control!.value = 'Test Tax'; - control!.dispatchEvent(new CustomEvent('input')); - - expect(element).to.have.nested.property('form.name', 'Test Tax'); - }); - - it('submits valid form on enter', async () => { - const validData = await getTestData('./hapi/taxes/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'name'); - const submit = stub(element, 'submit'); - - element.data = validData; - element.edit({ name: 'Test Tax' }); - control!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('renders "name:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByName(element, 'name:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "name:before" slot with template "name:before" if available', async () => { - const name = 'name: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 "name:after" slot by default', async () => { - const element = await fixture(html``); - const slot = await getByName(element, 'name:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "name:after" slot with template "name:after" if available', async () => { - const name = 'name: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('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).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, 'name')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes name', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).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, 'name')).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, 'name')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes name', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes name', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.not.exist; + it('has a reactive property "nativeIntegrations"', () => { + expect(new Form()).to.have.property('nativeIntegrations', null); + expect(Form.properties).to.have.deep.property('nativeIntegrations', { + attribute: 'native-integrations', }); }); - describe('type', () => { - it('has i18n label key "type"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'type'); - expect(control).to.have.property('label', 'type'); - }); - - it('has value of form.type', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - const control = await getByTestId(element, 'type'); - expect(control).to.have.property('value', 'union'); - }); - - it('writes to form.type on change', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'type'); - - control!.value = 'union'; - control!.dispatchEvent(new DropdownChangeEvent('union')); - - expect(element).to.have.nested.property('form.type', 'union'); - }); - - it('renders "type:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByName(element, 'type:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "type:before" slot with template "type:before" if available', async () => { - const type = 'type:before'; - const value = `

Value of the "${type}" template.

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

Value of the "${type}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, type); - const sandbox = (await getByTestId(element, type))!.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, 'type')).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, 'type')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes type', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'global', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'type')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes type', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes type', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'type')).to.not.exist; - }); + it('has a reactive property "countries"', () => { + expect(new Form()).to.have.property('countries', null); + expect(Form.properties).to.have.deep.property('countries', {}); }); - describe('country', () => { - it('has i18n label key "country"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - const control = await getByTestId(element, 'country'); - expect(control).to.have.property('label', 'country'); - }); - - it('has value of form.country', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country', country: 'AL' }); - - const control = await getByTestId(element, 'country'); - expect(control).to.have.property('value', 'AL'); - }); - - it('writes to form.country on input', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - const control = await getByTestId(element, 'country'); - - control!.value = 'AL'; - control!.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.country', 'AL'); - }); - - it('loads countries for a country', async () => { - const layout = html``; - const element = await fixture(layout); - const handleFetch = (evt: Event) => { - if (!(evt instanceof FetchEvent)) return; - if (evt.request.url !== 'test://countries') return; - const countries = JSON.stringify({ values: { AB: { cc2: 'AB', default: 'Foo' } } }); - evt.respondWith(Promise.resolve(new Response(countries))); - }; - - element.edit({ type: 'country', country: 'BY' }); - element.addEventListener('fetch', handleFetch); - element.countries = 'test://countries'; - - const control = await getByTestId(element, 'country'); - await waitUntil(() => control!.items!.length > 0, undefined, { timeout: 5000 }); - element.removeEventListener('fetch', handleFetch); - - expect(control).to.have.deep.property('items', [{ cc2: 'AB', default: 'Foo' }]); - }); - - it('renders "country:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByName(element, 'country:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "country:before" slot with template "country:before" if available', async () => { - const country = 'country:before'; - const value = `

Value of the "${country}" template.

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

Value of the "${country}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'country' }); - - const slot = await getByName(element, country); - const sandbox = (await getByTestId(element, country))!.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); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes country', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'country', - country: 'US', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'country')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes country', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).to.have.attribute('disabled'); - }); - - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'country')).to.not.exist; - }); - - it('is visible when tax type is "country"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'country')).to.exist; - }); + it('has a reactive property "regions"', () => { + expect(new Form()).to.have.property('regions', null); + expect(Form.properties).to.have.deep.property('regions', {}); + }); - it('is visible when tax type is "region"', async () => { - const layout = html``; - const element = await fixture(layout); + it('produces "name:v8n_required" error when name is missing', () => { + const form = new Form(); + expect(form.errors).to.include('name:v8n_required'); + form.edit({ name: 'Test' }); + expect(form.errors).to.not.include('name:v8n_required'); + }); - element.edit({ type: 'region' }); + it('produces "name:v8n_too_long" error when name is too long', () => { + const form = new Form(); + expect(form.errors).to.not.include('name:v8n_too_long'); + form.edit({ name: 'a'.repeat(31) }); + expect(form.errors).to.include('name:v8n_too_long'); + }); - expect(await getByTestId(element, 'country')).to.exist; - }); + it('produces "country:v8n_required" error when country is missing for country-level tax', () => { + const form = new Form(); + expect(form.errors).to.not.include('country:v8n_required'); + form.edit({ type: 'country' }); + expect(form.errors).to.include('country:v8n_required'); + form.edit({ country: 'US' }); + expect(form.errors).to.not.include('country:v8n_required'); + }); - it('is visible when tax type is "local"', async () => { - const layout = html``; - const element = await fixture(layout); + it('produces "country:v8n_required" error when country is missing for region-level tax', () => { + const form = new Form(); + expect(form.errors).to.not.include('country:v8n_required'); + form.edit({ type: 'region' }); + expect(form.errors).to.include('country:v8n_required'); + form.edit({ country: 'US' }); + expect(form.errors).to.not.include('country:v8n_required'); + }); - element.edit({ type: 'local' }); + it('produces "country:v8n_required" error when origin rates are enabled and country is missing', () => { + const form = new Form(); + expect(form.errors).to.not.include('country:v8n_required'); + form.edit({ use_origin_rates: true }); + expect(form.errors).to.include('country:v8n_required'); + form.edit({ country: 'US' }); + expect(form.errors).to.not.include('country:v8n_required'); + }); - expect(await getByTestId(element, 'country')).to.exist; - }); + it('produces "region:v8n_required" error when region is missing', () => { + const form = new Form(); + expect(form.errors).to.not.include('region:v8n_required'); + form.edit({ type: 'region' }); + expect(form.errors).to.include('region:v8n_required'); + form.edit({ region: 'CA' }); + expect(form.errors).to.not.include('region:v8n_required'); + }); - it('is visible when tax type is "union" and origin rates are used', async () => { - const layout = html``; - const element = await fixture(layout); + it('produces "region:v8n_too_long" error when region is too long', () => { + const form = new Form(); + expect(form.errors).to.not.include('region:v8n_too_long'); + form.edit({ region: 'a'.repeat(21) }); + expect(form.errors).to.include('region:v8n_too_long'); + }); - element.edit({ type: 'local', use_origin_rates: true }); + it('produces "city:v8n_too_long" error when city is too long', () => { + const form = new Form(); + expect(form.errors).to.not.include('city:v8n_too_long'); + form.edit({ city: 'a'.repeat(51) }); + expect(form.errors).to.include('city:v8n_too_long'); + }); - expect(await getByTestId(element, 'country')).to.exist; - }); + it('produces "city:v8n_required" error when city is missing for local tax', () => { + const form = new Form(); + expect(form.errors).to.not.include('city:v8n_required'); + form.edit({ type: 'local' }); + expect(form.errors).to.include('city:v8n_required'); + form.edit({ city: 'San Francisco' }); + expect(form.errors).to.not.include('city:v8n_required'); + }); - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); + it('produces "rate:v8n_invalid" error when rate is zero or less', () => { + const form = new Form(); + expect(form.errors).to.not.include('rate:v8n_invalid'); + form.edit({ rate: 0 }); + expect(form.errors).to.include('rate:v8n_invalid'); + form.edit({ rate: -1 }); + expect(form.errors).to.include('rate:v8n_invalid'); + form.edit({ rate: 1 }); + expect(form.errors).to.not.include('rate:v8n_invalid'); + }); - element.edit({ type: 'country' }); + it('always makes native integrations list readonly', () => { + const form = new Form(); + expect(form.readonlySelector.matches('native-integrations', true)).to.be.true; + }); - expect(await getByTestId(element, 'country')).to.not.exist; - }); + it('conditionally hides native integrations list', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('native-integrations', true)).to.be.true; + form.nativeIntegrations = 'https://demo.api/hapi/native_integrations'; + form.edit({ type: 'union', service_provider: 'avalara' }); + expect(form.hiddenSelector.matches('native-integrations', true)).to.be.false; + }); - it('is hidden when hiddencontrols includes country', async () => { - const layout = html``; - const element = await fixture(layout); + it('conditionally hides country select', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-three:country', true)).to.be.true; + form.edit({ type: 'union', service_provider: 'avalara' }); + expect(form.hiddenSelector.matches('group-three:country', true)).to.be.false; + form.edit({ type: 'country' }); + expect(form.hiddenSelector.matches('group-three:country', true)).to.be.false; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-three:country', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-three:country', true)).to.be.false; + }); - element.edit({ type: 'country' }); + it('conditionally hides region input', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-three:region-input', true)).to.be.true; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-three:region-input', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-three:region-input', true)).to.be.false; + }); - expect(await getByTestId(element, 'country')).to.not.exist; - }); + it('conditionally hides region select', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + + expect(form.hiddenSelector.matches('group-three:region-select', true)).to.be.true; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-three:region-select', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-three:region-select', true)).to.be.false; }); - describe('region', () => { - it('has i18n label key "region"', async () => { - const layout = html``; - const element = await fixture(layout); + it('conditionally hides city input', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-three:city', true)).to.be.true; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-three:city', true)).to.be.false; + }); - element.edit({ type: 'region' }); + it('conditionally hides service provider select', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.true; + form.edit({ type: 'union' }); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.false; + form.edit({ type: 'country' }); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.false; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.true; + form.edit({ type: 'custom_tax_endpoint' as Data['type'] }); + expect(form.hiddenSelector.matches('group-one:service-provider', true)).to.be.true; + }); - const control = await getByTestId(element, 'region'); - expect(control).to.have.property('label', 'region'); - }); + it('conditionally hides rate input', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.true; + form.edit({ type: 'union' }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.false; + form.edit({ type: 'country' }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.false; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.false; + form.edit({ type: 'custom_tax_endpoint' as Data['type'] }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.true; + form.edit({ type: 'local', is_live: true }); + expect(form.hiddenSelector.matches('group-one:rate', true)).to.be.true; + }); - it('has value of form.region', async () => { - const layout = html``; - const element = await fixture(layout); + it('conditionally hides apply to shipping switch', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.true; + form.edit({ type: 'union' }); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.false; + form.edit({ type: 'country', is_live: true }); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.true; + form.edit({ type: 'region', is_live: true }); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.true; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.false; + form.edit({ type: 'custom_tax_endpoint' as Data['type'] }); + expect(form.hiddenSelector.matches('group-two:apply-to-shipping', true)).to.be.true; + }); - element.edit({ type: 'region', region: 'AL' }); + it('conditionally hides use origin rates switch', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'union' }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'union', is_live: true }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.false; + form.edit({ type: 'union', service_provider: 'avalara' }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'union', service_provider: 'taxjar' as Data['service_provider'] }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'country' }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + form.edit({ type: 'custom_tax_endpoint' as Data['type'] }); + expect(form.hiddenSelector.matches('group-two:use-origin-rates', true)).to.be.true; + }); - const control = await getByTestId(element, 'region'); - expect(control).to.have.property('value', 'AL'); - }); + it('conditionally hides exempt all customer tax ids switch', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.true; + form.edit({ type: 'union' }); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.true; + form.edit({ type: 'country' }); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.false; + form.edit({ type: 'region' }); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.false; + form.edit({ type: 'local' }); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.false; + form.edit({ type: 'custom_tax_endpoint' as Data['type'] }); + expect(form.hiddenSelector.matches('group-two:exempt-all-customer-tax-ids', true)).to.be.true; + }); - it('writes to form.region on input', async () => { - const layout = html``; - const element = await fixture(layout); + it('renders a form header', () => { + const form = new Form(); + const renderHeaderMethod = stub(form, 'renderHeader'); + form.render(); + expect(renderHeaderMethod).to.have.been.called; + }); - element.edit({ type: 'region' }); + it('renders a text control for name in group one', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-text-control[infer="name"]' + ); - const control = await getByTestId(element, 'region'); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - control!.value = 'AL'; - control!.dispatchEvent(new CustomEvent('change')); + it('renders a select control for type in group one', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-select-control[infer="type"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute( + 'options', + JSON.stringify([ + { label: 'option_custom_tax_endpoint', value: 'custom_tax_endpoint' }, + { label: 'option_global', value: 'global' }, + { label: 'option_union', value: 'union' }, + { label: 'option_country', value: 'country' }, + { label: 'option_region', value: 'region' }, + { label: 'option_local', value: 'local' }, + ]) + ); + }); - expect(element).to.have.nested.property('form.region', 'AL'); + it('resets some form values on type change', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-select-control[infer="type"]' + ); + + form.edit({ + type: 'global', + country: 'US', + region: 'TX', + city: 'Test', + service_provider: 'avalara', + apply_to_shipping: true, + use_origin_rates: true, + exempt_all_customer_tax_ids: true, + is_live: true, + rate: 123, + }); + + control?.setValue('local'); + + expect(form.form).to.deep.equal({ + type: 'local', + country: '', + region: '', + city: '', + service_provider: '', + apply_to_shipping: false, + use_origin_rates: false, + exempt_all_customer_tax_ids: false, + is_live: false, + rate: 0, }); + }); - it('loads regions for a country', async () => { - const layout = html``; - const element = await fixture(layout); - const handleFetch = (evt: Event) => { - if (!(evt instanceof FetchEvent)) return; - if (evt.request.url !== 'test://regions?country_code=BY') return; - const regions = JSON.stringify({ values: { AB: { code: 'AB', default: 'Foo' } } }); - evt.respondWith(Promise.resolve(new Response(regions))); - }; - - element.edit({ type: 'region', country: 'BY' }); - element.addEventListener('fetch', handleFetch); - element.regions = 'test://regions'; - - const control = await getByTestId(element, 'region'); - await waitUntil(() => control!.items!.length > 0, undefined, { timeout: 5000 }); - element.removeEventListener('fetch', handleFetch); - - expect(control).to.have.deep.property('items', [{ code: 'AB', default: 'Foo' }]); - }); + it('renders a select control for service provider in group one', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-select-control[infer="service-provider"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute( + 'options', + JSON.stringify([ + { label: 'option_default', value: 'default' }, + { label: 'option_none', value: 'none' }, + { label: 'option_avalara', value: 'avalara' }, + { label: 'option_onesource', value: 'onesource' }, + { label: 'option_taxjar', value: 'taxjar' }, + ]) + ); + + expect(control?.getValue()).to.equal('none'); + + form.edit({ is_live: true }); + expect(control?.getValue()).to.equal('default'); + + form.edit({ service_provider: 'avalara' }); + expect(control?.getValue()).to.equal('avalara'); + }); - it('renders "region:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); + it('conditionally hides some options for service provider', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-select-control[infer="service-provider"]' + ); + + form.edit({ type: 'union' }); + await form.requestUpdate(); + expect(control?.options).to.deep.equal([ + { label: 'option_default', value: 'default' }, + { label: 'option_none', value: 'none' }, + { label: 'option_avalara', value: 'avalara' }, + { label: 'option_onesource', value: 'onesource' }, + { label: 'option_taxjar', value: 'taxjar' }, + ]); + + form.edit({ type: 'country', country: 'AZ' }); + await form.requestUpdate(); + expect(control?.options).to.deep.equal([ + { label: 'option_none', value: 'none' }, + { label: 'option_avalara', value: 'avalara' }, + { label: 'option_onesource', value: 'onesource' }, + ]); + }); - element.edit({ type: 'region' }); + it('resets some form values on service provider change', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-select-control[infer="service-provider"]' + ); - expect(await getByName(element, 'region:before')).to.have.property('localName', 'slot'); + form.edit({ + type: 'local', + service_provider: 'avalara', + use_origin_rates: true, + is_live: true, + exempt_all_customer_tax_ids: true, + apply_to_shipping: true, }); - it('replaces "region:before" slot with template "region:before" if available', async () => { - const region = 'region:before'; - const value = `

Value of the "${region}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'region' }); - - const slot = await getByName(element, region); - const sandbox = (await getByTestId(element, region))!.renderRoot; + control?.setValue('none'); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); + expect(form.form).to.deep.equal({ + type: 'local', + service_provider: '', + use_origin_rates: false, + is_live: false, + exempt_all_customer_tax_ids: false, + apply_to_shipping: false, }); + }); - it('renders "region:after" slot by default', async () => { - const element = await fixture(html``); - - element.edit({ type: 'region' }); + it('renders a number control for rate in group one', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-one"] foxy-internal-number-control[infer="rate"]' + ); - const slot = await getByName(element, 'region:after'); - expect(slot).to.have.property('localName', 'slot'); - }); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('suffix', '%'); + expect(control).to.have.attribute('min', '0'); + }); - it('replaces "region:after" slot with template "region:after" if available', async () => { - const region = 'region:after'; - const value = `

Value of the "${region}" template.

`; - const element = await fixture(html` - - + it('renders an async list control for native integrations', async () => { + const form = await fixture( + html` + - `); - - element.edit({ type: 'region' }); - - const slot = await getByName(element, region); - const sandbox = (await getByTestId(element, region))!.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); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'region')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'region')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes region', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'region')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'region')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'region', - country: 'US', - region: 'AL', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'region')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); + ` + ); + + form.edit({ type: 'union', service_provider: 'avalara' }); + await form.requestUpdate(); + const control = form.renderRoot.querySelector( + 'foxy-internal-async-list-control[infer="native-integrations"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('item', 'foxy-native-integration-card'); + expect(control).to.have.attribute( + 'first', + 'https://demo.api/hapi/native_integrations?store_id=0&provider=avalara' + ); + }); - expect(await getByTestId(element, 'region')).to.have.attribute('disabled'); - }); + it('renders a switch control for apply to shipping in group two', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-two"] foxy-internal-switch-control[infer="apply-to-shipping"]' + ); - it('is disabled when disabledcontrols includes region', async () => { - const layout = html``; - const element = await fixture(layout); + expect(control).to.exist; + }); - element.edit({ type: 'region' }); + it('renders a switch control for use origin rates in group two', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-two"] foxy-internal-switch-control[infer="use-origin-rates"]' + ); - expect(await getByTestId(element, 'region')).to.have.attribute('disabled'); - }); + expect(control).to.exist; + }); - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'region')).to.not.exist; - }); + it('renders a switch control for exempt all customer tax ids in group two', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-two"] foxy-internal-switch-control[infer="exempt-all-customer-tax-ids"]' + ); - it('is visible when tax type is "region"', async () => { - const layout = html``; - const element = await fixture(layout); + expect(control).to.exist; + }); - element.edit({ type: 'region' }); + it('renders a select control for country in group three', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-select-control[infer="country"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute( + 'options', + JSON.stringify([ + { rawLabel: 'United Kingdom', value: 'GB' }, + { rawLabel: 'United States', value: 'US' }, + { rawLabel: 'United States Minor Outlying Islands', value: 'UM' }, + ]) + ); + }); - expect(await getByTestId(element, 'region')).to.exist; + it('resets some form values on country change', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-select-control[infer="country"]' + ); + + form.edit({ type: 'country', region: 'TX', city: 'Test' }); + control?.setValue('US'); + + expect(form.form).to.deep.equal({ + type: 'country', + country: 'US', + region: '', + city: '', + apply_to_shipping: false, }); + }); - it('is visible when tax type is "local"', async () => { - const layout = html``; - const element = await fixture(layout); + it('renders a select control for region in group three', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-select-control[infer="region-select"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute( + 'options', + JSON.stringify([ + { rawLabel: 'South Dakota', value: 'SD' }, + { rawLabel: 'Tennessee', value: 'TN' }, + { rawLabel: 'Texas', value: 'TX' }, + ]) + ); + }); - element.edit({ type: 'local' }); + it('resets some form values on region change in region select', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-select-control[infer="region-select"]' + ); + + form.edit({ type: 'region', city: 'Test' }); + control?.setValue('TX'); + expect(form.form).to.deep.equal({ type: 'region', region: 'TX', city: '' }); + }); - expect(await getByTestId(element, 'region')).to.exist; - }); + it('renders a text control for region input in group three', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-text-control[infer="region-input"]' + ); - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); - element.edit({ type: 'region' }); + it('resets some form values on region change in region input', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitForIdle(form); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-text-control[infer="region-input"]' + ); + + form.edit({ type: 'region', city: 'Test' }); + control?.setValue('TX'); + expect(form.form).to.deep.equal({ type: 'region', region: 'TX', city: '' }); + }); - expect(await getByTestId(element, 'region')).to.not.exist; - }); + it('renders a text control for city in group three', async () => { + const form = await fixture(html``); + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="group-three"] foxy-internal-text-control[infer="city"]' + ); - it('is hidden when hiddencontrols includes region', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'region')).to.not.exist; - }); - }); - - describe('city', () => { - it('has i18n label key "city"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - const control = await getByTestId(element, 'city'); - expect(control).to.have.property('label', 'city'); - }); - - it('has value of form.city', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local', city: 'Nullville' }); - - const control = await getByTestId(element, 'city'); - expect(control).to.have.property('value', 'Nullville'); - }); - - it('writes to form.city on input', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - const control = await getByTestId(element, 'city'); - - control!.value = 'Nullville'; - control!.dispatchEvent(new CustomEvent('input')); - - expect(element).to.have.nested.property('form.city', 'Nullville'); - }); - - it('submits valid form on enter', async () => { - const layout = html``; - const element = await fixture(layout); - const submit = stub(element, 'submit'); - - element.edit({ - name: 'Test Tax', - type: 'local', - country: 'US', - region: 'AL', - city: 'Nullville', - rate: 12.34, - }); - - const control = await getByTestId(element, 'city'); - control!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('renders "city:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByName(element, 'city:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "city:before" slot with template "city:before" if available', async () => { - const city = 'city:before'; - const value = `

Value of the "${city}" template.

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

Value of the "${city}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'local' }); - - const slot = await getByName(element, city); - const sandbox = (await getByTestId(element, city))!.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); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes city', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'local', - country: 'US', - region: 'AL', - city: 'Nullville', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'city')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes city', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.have.attribute('disabled'); - }); - - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'city')).to.not.exist; - }); - - it('is visible when tax type is "local"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes city', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'local' }); - - expect(await getByTestId(element, 'city')).to.not.exist; - }); - }); - - describe('provider', () => { - it('has i18n label key "tax_rate_provider"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - const control = await getByTestId(element, 'provider'); - expect(control).to.have.property('label', 'tax_rate_provider'); - }); - - it('has value of form.service_provider', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: 'avalara', type: 'union' }); - - const control = await getByTestId(element, 'provider'); - expect(control).to.have.property('value', 'avalara'); - }); - - it('writes to form.service_provider on change', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - const control = await getByTestId(element, 'provider'); - - control!.value = 'avalara'; - control!.dispatchEvent(new DropdownChangeEvent('avalara')); - - expect(element).to.have.nested.property('form.service_provider', 'avalara'); - }); - - it('renders "provider:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByName(element, 'provider:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "provider:before" slot with template "provider:before" if available', async () => { - const provider = 'provider:before'; - const value = `

Value of the "${provider}" template.

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

Value of the "${provider}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'union' }); - - const slot = await getByName(element, provider); - const sandbox = (await getByTestId(element, provider))!.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); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).not.to.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes provider', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'union', - service_provider: 'avalara', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'provider')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes provider', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.have.attribute('disabled'); - }); - - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'provider')).to.not.exist; - }); - - it('is visible when tax type is "union"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.exist; - }); - - it('is visible when tax type is "country"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country' }); - - expect(await getByTestId(element, 'provider')).to.exist; - }); - - it('is visible when tax type is "region"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'region' }); - - expect(await getByTestId(element, 'provider')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes provider', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union' }); - - expect(await getByTestId(element, 'provider')).to.not.exist; - }); - }); - - describe('rate', () => { - it('has i18n label key "tax_rate"', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'rate'); - expect(control).to.have.property('label', 'tax_rate'); - }); - - it('has value of form.rate', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ rate: 12.34 }); - - const control = await getByTestId(element, 'rate'); - expect(control).to.have.property('value', '12.34'); - }); - - it('writes to form.rate on change', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'rate'); - - control!.value = '12.34'; - control!.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.rate', 12.34); - }); - - it('submits valid form on enter', async () => { - const layout = html``; - const element = await fixture(layout); - const submit = stub(element, 'submit'); - - element.edit({ - name: 'Test Tax', - type: 'global', - rate: 12.34, - }); - - const control = await getByTestId(element, 'rate'); - control!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - - expect(submit).to.have.been.called; - }); - - it('renders "rate:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByName(element, 'rate:before')).to.have.property('localName', 'slot'); - }); - - it('replaces "rate:before" slot with template "rate:before" if available', async () => { - const rate = 'rate:before'; - const value = `

Value of the "${rate}" template.

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

Value of the "${rate}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, rate); - const sandbox = (await getByTestId(element, rate))!.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, 'rate')).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, 'rate')).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes rate', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.have.attribute('readonly'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'global', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'rate')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes rate', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.exist; - }); - - it('is hidden when live tax rates are used', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ is_live: true }); - - expect(await getByTestId(element, 'rate')).to.not.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes rate', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'rate')).to.not.exist; - }); - }); - - describe('apply-to-shipping', () => { - it('has i18n label with key "tax_apply_to_shipping"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - expect(await getByKey(element, 'tax_apply_to_shipping')).to.exist; - }); - - it('has i18n explainer with key "tax_apply_to_shipping_explainer"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - expect(await getByKey(element, 'tax_apply_to_shipping_explainer')).to.exist; - }); - - it('has value of form.apply_to_shipping', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global', apply_to_shipping: true }); - - const control = await getByTestId(element, 'apply-to-shipping'); - expect(control).to.have.property('checked', true); - }); - - it('writes to form.apply_to_shipping on change', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - const control = await getByTestId(element, 'apply-to-shipping'); - control!.checked = true; - control!.dispatchEvent(new CheckboxChangeEvent(true)); - - expect(element).to.have.nested.property('form.apply_to_shipping', true); - }); - - it('renders "apply-to-shipping:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - const slot = await getByName(element, 'apply-to-shipping:before'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "apply-to-shipping:before" slot with template "apply-to-shipping:before" if available', async () => { - const name = 'apply-to-shipping:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'global' }); - - 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 "apply-to-shipping:after" slot by default', async () => { - const element = await fixture(html``); - element.edit({ type: 'global' }); - - const slot = await getByName(element, 'apply-to-shipping:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "apply-to-shipping:after" slot with template "apply-to-shipping:after" if available', async () => { - const name = 'apply-to-shipping:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'global' }); - - 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('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - const control = await getByTestId(element, 'apply-to-shipping'); - expect(control).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - name: 'Test Tax', - type: 'global', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'apply-to-shipping')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - expect(await getByTestId(element, 'apply-to-shipping')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes apply-to-shipping', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - expect(await getByTestId(element, 'apply-to-shipping')).to.have.attribute('disabled'); - }); - - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'apply-to-shipping')).to.exist; - }); - - // prettier-ignore - ['US', 'CA', 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'HU', 'IE', 'IM', 'IT', 'LT', 'LU', 'LV', 'MC', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK'].forEach(country => { - it(`is hidden in ${country} when live tax rates are enabled`, async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'country', is_live: true, country }) - - expect(await getByTestId(element, 'apply-to-shipping')).to.not.exist; - }); - }) - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - expect(await getByTestId(element, 'apply-to-shipping')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes apply-to-shipping', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'global' }); - - expect(await getByTestId(element, 'apply-to-shipping')).to.not.exist; - }); - }); - - describe('use-origin-rates', () => { - it('has i18n label with key "tax_use_origin_rates"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - expect(await getByKey(element, 'tax_use_origin_rates')).to.exist; - }); - - it('has i18n explainer with key "tax_use_origin_rates_explainer"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - expect(await getByKey(element, 'tax_use_origin_rates_explainer')).to.exist; - }); - - it('has value of form.use_origin_rates', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '', use_origin_rates: true }); - - const control = await getByTestId(element, 'use-origin-rates'); - expect(control).to.have.property('checked', true); - }); - - it('writes to form.use_origin_rates on change', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - const control = await getByTestId(element, 'use-origin-rates'); - control!.checked = true; - control!.dispatchEvent(new CheckboxChangeEvent(true)); - - expect(element).to.have.nested.property('form.use_origin_rates', true); - }); - - it('renders "use-origin-rates:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - const slot = await getByName(element, 'use-origin-rates:before'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "use-origin-rates:before" slot with template "use-origin-rates:before" if available', async () => { - const name = 'use-origin-rates:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - 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 "use-origin-rates:after" slot by default', async () => { - const element = await fixture(html``); - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - const slot = await getByName(element, 'use-origin-rates:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "use-origin-rates:after" slot with template "use-origin-rates:after" if available', async () => { - const name = 'use-origin-rates:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - 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('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - const control = await getByTestId(element, 'use-origin-rates'); - expect(control).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - is_live: true, - service_provider: '', - name: 'Test Tax', - type: 'union', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'use-origin-rates')).to.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - expect(await getByTestId(element, 'use-origin-rates')).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes use-origin-rates', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - expect(await getByTestId(element, 'use-origin-rates')).to.have.attribute('disabled'); - }); - - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'use-origin-rates')).to.not.exist; - }); - - it('is visible when tax type is "union"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - expect(await getByTestId(element, 'use-origin-rates')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - expect(await getByTestId(element, 'use-origin-rates')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes use-origin-rates', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ type: 'union', is_live: true, service_provider: '' }); - - expect(await getByTestId(element, 'use-origin-rates')).to.not.exist; - }); - }); - - describe('exempt-all-customer-tax-ids', () => { - it('has i18n label with key "tax_exempt_all_customer_tax_ids"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: '' }); - expect(await getByKey(element, 'tax_exempt_all_customer_tax_ids')).to.exist; - }); - - it('has i18n explainer with key "tax_exempt_all_customer_tax_ids_explainer"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: '' }); - expect(await getByKey(element, 'tax_exempt_all_customer_tax_ids_explainer')).to.exist; - }); - - it('has value of form.exempt_all_customer_tax_ids', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: '', exempt_all_customer_tax_ids: true }); - - const control = await getByTestId(element, 'exempt-all-customer-tax-ids'); - expect(control).to.have.property('checked', true); - }); - - it('writes to form.exempt_all_customer_tax_ids on change', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: '' }); - - const control = await getByTestId(element, 'exempt-all-customer-tax-ids'); - control!.checked = true; - control!.dispatchEvent(new CheckboxChangeEvent(true)); - - expect(element).to.have.nested.property('form.exempt_all_customer_tax_ids', true); - }); - - it('renders "exempt-all-customer-tax-ids:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ service_provider: '' }); - - const slot = await getByName(element, 'exempt-all-customer-tax-ids:before'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "exempt-all-customer-tax-ids:before" slot with template "exempt-all-customer-tax-ids:before" if available', async () => { - const name = 'exempt-all-customer-tax-ids:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ service_provider: '' }); - - 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 "exempt-all-customer-tax-ids:after" slot by default', async () => { - const element = await fixture(html``); - element.edit({ service_provider: '' }); - - const slot = await getByName(element, 'exempt-all-customer-tax-ids:after'); - expect(slot).to.have.property('localName', 'slot'); - }); - - it('replaces "exempt-all-customer-tax-ids:after" slot with template "exempt-all-customer-tax-ids:after" if available', async () => { - const name = 'exempt-all-customer-tax-ids:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - element.edit({ service_provider: '' }); - - 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('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ service_provider: '' }); - - const control = await getByTestId(element, 'exempt-all-customer-tax-ids'); - expect(control).not.to.have.attribute('disabled'); - }); - - it('is disabled when form is in busy state', async () => { - const href = 'https://demo.api/virtual/stall'; - const layout = html``; - const element = await fixture(layout); - - element.edit({ - service_provider: '', - name: 'Test Tax', - type: 'global', - rate: 12.34, - }); - - element.submit(); - - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.have.attribute( - 'disabled' - ); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - element.edit({ service_provider: '' }); - - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.have.attribute( - 'disabled' - ); - }); - - it('is disabled when disabledcontrols includes exempt-all-customer-tax-ids', async () => { - const layout = html` - - `; - - const element = await fixture(layout); - element.edit({ service_provider: '' }); - - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.have.attribute( - 'disabled' - ); - }); - - it('is hidden by default', async () => { - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.not.exist; - }); - - it('is visible when using the default tax service', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ service_provider: '' }); - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes exempt-all-customer-tax-ids', async () => { - const layout = html` - - `; - - const element = await fixture(layout); - expect(await getByTestId(element, 'exempt-all-customer-tax-ids')).to.not.exist; - }); - }); - - describe('timestamps', () => { - it('once form data is loaded, renders a property table with created and modified dates', async () => { - const data = await getTestData('./hapi/taxes/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/taxes/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/taxes/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/taxes/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/taxes/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); - }); - }); - - 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', 'tax-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({ name: '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({ name: 'Foo', type: 'global', rate: 12.34 }); - - 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` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - }); - - describe('delete', () => { - it('renders delete button once resource is loaded', async () => { - const href = './hapi/taxes/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/taxes/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', 'tax-form'); - }); - - it('renders disabled if form is disabled', async () => { - const data = await getTestData('./hapi/taxes/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/taxes/0'); - const layout = html``; - const element = await fixture(layout); - - element.edit({ name: '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/taxes/0')} disabledcontrols="delete"> - - `); - - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); - - it('shows deletion confirmation dialog on click', async () => { - const data = await getTestData('./hapi/taxes/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'); - - control!.dispatchEvent(new CustomEvent('click')); - - expect(showMethod).to.have.been.called; - }); - - it('deletes resource if deletion is confirmed', async () => { - const data = await getTestData('./hapi/taxes/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); - - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(false)); - - expect(deleteMethod).to.have.been.called; - }); - - it('keeps resource if deletion is cancelled', async () => { - const data = await getTestData('./hapi/taxes/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); - - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(true)); - - expect(deleteMethod).not.to.have.been.called; - }); - - it("doesn't render if form is hidden", async () => { - const data = await getTestData('./hapi/taxes/0'); - const layout = html``; - const element = await fixture(layout); - - expect(await getByTestId(element, 'delete')).to.not.exist; - }); - - it('doesn\'t render if hiddencontrols includes "delete"', async () => { - const element = await fixture(html` - ('./hapi/taxes/0')} hiddencontrols="delete"> - - `); - - expect(await getByTestId(element, 'delete')).to.not.exist; - }); - - it('renders with "delete:before" slot by default', async () => { - const data = await getTestData('./hapi/taxes/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/taxes/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/taxes/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/taxes/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); - }); - }); - - describe('spinner', () => { - it('renders foxy-spinner in "busy" state while loading data', async () => { - const router = createRouter(); - const layout = html` - 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', 'tax-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', 'tax-form spinner'); - }); - - it('hides spinner once loaded', async () => { - const data = await getTestData('./hapi/taxes/0'); - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - - expect(spinnerWrapper).to.have.class('opacity-0'); - }); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); }); }); diff --git a/src/elements/public/TaxForm/TaxForm.ts b/src/elements/public/TaxForm/TaxForm.ts index 41ed2ea3..39362734 100644 --- a/src/elements/public/TaxForm/TaxForm.ts +++ b/src/elements/public/TaxForm/TaxForm.ts @@ -1,36 +1,23 @@ -import { Checkbox, Dropdown, Metadata } from '../../private/index'; -import { CheckboxChangeEvent, DropdownChangeEvent } from '../../private/events'; -import { Data } from './types'; -import { Nucleon, Resource } from '@foxy.io/sdk/core'; -import { ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements'; -import { TemplateResult, html } from 'lit-html'; - -import { ButtonElement } from '@vaadin/vaadin-button'; -import { ComboBoxElement } from '@vaadin/vaadin-combo-box'; -import { ConfigurableMixin } from '../../../mixins/configurable'; -import { DialogHideEvent } from '../../private/Dialog/DialogHideEvent'; -import { IntegerFieldElement } from '@vaadin/vaadin-text-field/vaadin-integer-field'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { NucleonV8N } from '../NucleonElement/types'; -import { PropertyDeclarations } from 'lit-element'; -import { Rels } from '@foxy.io/sdk/backend'; -import { TextFieldElement } from '@vaadin/vaadin-text-field'; -import { ThemeableMixin } from '../../../mixins/themeable'; +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { NucleonV8N } from '../NucleonElement/types'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; +import type { Data } from './types'; + 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 { interpret } from 'xstate'; +import { html } from 'lit-html'; + +const NS = 'tax-form'; +const Base = TranslatableMixin(InternalForm, NS); // prettier-ignore const defaultLiveRateCountries: (string | undefined)[] = ['US', 'CA', 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'HU', 'IE', 'IM', 'IT', 'LT', 'LU', 'LV', 'MC', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK']; const taxJarLiveRateCountries = [...defaultLiveRateCountries, 'AU']; -const NS = 'tax-form'; -const Base = ConfigurableMixin( - ThemeableMixin(ScopedElementsMixin(TranslatableMixin(NucleonElement, NS))) -); - /** * Form element for creating or editing taxes (`fx:tax`). * @@ -38,717 +25,328 @@ const Base = ConfigurableMixin( * @since 1.13.0 */ export class TaxForm extends Base { - static get scopedElements(): ScopedElementsMap { - return { - 'vaadin-number-field': customElements.get('vaadin-number-field'), - 'vaadin-text-field': customElements.get('vaadin-text-field'), - 'vaadin-combo-box': customElements.get('vaadin-combo-box'), - 'vaadin-button': customElements.get('vaadin-button'), - 'foxy-internal-confirm-dialog': customElements.get('foxy-internal-confirm-dialog'), - 'foxy-internal-sandbox': customElements.get('foxy-internal-sandbox'), - 'foxy-spinner': customElements.get('foxy-spinner'), - 'foxy-i18n': customElements.get('foxy-i18n'), - 'x-metadata': Metadata, - 'x-checkbox': Checkbox, - 'x-dropdown': Dropdown, - }; - } - static get properties(): PropertyDeclarations { return { ...super.properties, - countries: { type: String, noAccessor: true }, - regions: { type: String, noAccessor: true }, + nativeIntegrations: { attribute: 'native-integrations' }, + countries: {}, + regions: {}, }; } static get v8n(): NucleonV8N { return [ - ({ name: v }) => !!v || 'name_required', - ({ name: v }) => !v || v.length <= 30 || 'name_too_long', - ({ country: c, type: t }) => t !== 'country' || !!c || 'country_required', - ({ country: c, type: t }) => t !== 'region' || !!c || 'country_required', - ({ country: c, use_origin_rates: r }) => !r || !!c || 'country_required', - ({ country: v }) => !v || !!v.match(/[A-Z]{2}/) || 'country_invalid', - ({ region: v, type: t }) => t != 'region' || !!v || 'region_required', - ({ region: v }) => !v || v.length <= 20 || 'region_too_long', - ({ city: v }) => !v || v.length <= 50 || 'city_too_long', - ({ city: c, type: t }) => t != 'local' || !!c || 'city_required', - ({ rate: v }) => !v || v <= 100 || 'rate_invalid', + ({ name: v }) => !!v || 'name:v8n_required', + ({ name: v }) => !v || v.length <= 30 || 'name:v8n_too_long', + ({ country: c, type: t }) => t !== 'country' || !!c || 'country:v8n_required', + ({ country: c, type: t }) => t !== 'region' || !!c || 'country:v8n_required', + ({ country: c, use_origin_rates: r }) => !r || !!c || 'country:v8n_required', + ({ region: v, type: t }) => t != 'region' || !!v || 'region:v8n_required', + ({ region: v }) => !v || v.length <= 20 || 'region:v8n_too_long', + ({ city: v }) => !v || v.length <= 50 || 'city:v8n_too_long', + ({ city: c, type: t }) => t != 'local' || !!c || 'city:v8n_required', + ({ rate: v }) => v === void 0 || v > 0 || 'rate:v8n_invalid', ]; } - private __previousCountry: string | undefined = undefined; - - private __countries = ''; - - private __regions = ''; - - private __countriesService = interpret( - Nucleon.machine.withConfig({ - services: { - sendGet: async () => { - const response = await new TaxForm.API(this).fetch(this.countries); - if (!response.ok) throw new Error(await response.text()); - return await response.json(); - }, - }, - }) - ); - - private __regionsService = interpret( - Nucleon.machine.withConfig({ - services: { - sendGet: async () => { - const url = new URL(this.regions); - url.searchParams.set('country_code', this.form.country ?? 'US'); - const response = await new TaxForm.API(this).fetch(url.toString()); - if (!response.ok) throw new Error(await response.text()); - return await response.json(); - }, - }, - }) - ); - - /** URI of the `fx:countries` hAPI resource. */ - get countries(): string { - return this.__countries; - } - - set countries(value: string) { - this.__countries = value; - - if (value) { - this.__countriesService.send({ type: 'FETCH' }); - } else { - this.__countriesService.send({ type: 'SET_DATA', data: null }); + /** URL of the `fx:native_integrations` collection for the store. */ + nativeIntegrations: string | null = null; + + /** URL of the `fx:countries` property helper resource. */ + countries: string | null = null; + + /** URL of the `fx:regions` property helper resource. */ + regions: string | null = null; + + private __serviceProviderGetValue = () => { + return this.form.service_provider || (this.form.is_live ? 'default' : 'none'); + }; + + private __serviceProviderSetValue = (newValue: string) => { + const newProvider = ['none', 'default'].includes(newValue) ? '' : newValue; + + this.edit({ + service_provider: newProvider as Data['service_provider'], + use_origin_rates: false, + is_live: newValue !== 'none', + }); + + this.edit({ + exempt_all_customer_tax_ids: this.__isExemptAllCustomerTaxIdsHidden, + apply_to_shipping: this.__isApplyToShippingHidden, + }); + }; + + private __countrySetValue = (newValue: string) => { + this.edit({ country: newValue, region: '', city: '' }); + this.edit({ apply_to_shipping: this.__isApplyToShippingHidden }); + }; + + private __regionSetValue = (newValue: string) => { + this.edit({ region: newValue, city: '' }); + }; + + private __typeSetValue = (newValue: Data['type']) => { + this.edit({ + type: newValue, + country: '', + region: '', + city: '', + // @ts-expect-error SDK types are not up to date. + service_provider: newValue === 'custom_tax_endpoint' ? 'custom_tax' : '', + apply_to_shipping: false, + use_origin_rates: false, + exempt_all_customer_tax_ids: false, + is_live: false, + rate: 0, + }); + }; + + private readonly __typeOptions = JSON.stringify([ + { label: 'option_custom_tax_endpoint', value: 'custom_tax_endpoint' }, + { label: 'option_global', value: 'global' }, + { label: 'option_union', value: 'union' }, + { label: 'option_country', value: 'country' }, + { label: 'option_region', value: 'region' }, + { label: 'option_local', value: 'local' }, + ]); + + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + alwaysMatch.unshift('native-integrations'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + + if (this.__nativeIntegrationsUrl === void 0) alwaysMatch.unshift('native-integrations'); + if (this.__isCountryHidden) alwaysMatch.unshift('group-three:country'); + if (this.__isRegionHidden) { + alwaysMatch.unshift('group-three:region-select', 'group-three:region-input'); } - } - - /** URI of the `fx:regions` hAPI resource. */ - get regions(): string { - return this.__regions; - } - - set regions(value: string) { - this.__regions = value; - if (value) { - this.__regionsService.send({ type: 'FETCH' }); - } else { - this.__regionsService.send({ type: 'SET_DATA', data: null }); - } - } - - connectedCallback(): void { - super.connectedCallback(); - - this.__countriesService.onTransition(({ changed }) => changed && this.requestUpdate()); - this.__countriesService.onChange(() => this.requestUpdate()); - this.__countriesService.start(); - - this.__regionsService.onTransition(({ changed }) => changed && this.requestUpdate()); - this.__regionsService.onChange(() => this.requestUpdate()); - this.__regionsService.start(); - } - - render(): TemplateResult { - return html` -
- ${this.__isNameHidden ? null : this.__renderName()} - ${this.__isTypeHidden ? null : this.__renderType()} - ${this.__isCountryHidden ? null : this.__renderCountry()} - ${this.__isRegionHidden ? null : this.__renderRegion()} - ${this.__isCityHidden ? null : this.__renderCity()} - ${this.__isProviderHidden ? null : this.__renderProvider()} - ${this.__isRateHidden ? null : this.__renderRate()} - ${this.__isApplyToShippingHidden ? null : this.__renderApplyToShipping()} - ${this.__isUseOriginRatesHidden ? null : this.__renderUseOriginRates()} - ${this.__isExemptAllCustomerTaxIdsHidden ? null : this.__renderExemptAllCustomerTaxIds()} - ${this.__isTimestampsHidden ? null : this.__renderTimestamps()} - ${this.__isCreateHidden ? null : this.__renderCreate()} - ${this.__isDeleteHidden ? null : this.__renderDelete()} - -
- - -
-
- `; - } - - updated(changes: Map): void { - super.updated(changes); - - if (this.form.country !== this.__previousCountry) { - this.__previousCountry = this.form.country; - this.__regionsService.send({ type: 'FETCH' }); + if (this.__isCityHidden) alwaysMatch.unshift('group-three:city'); + if (this.__isProviderHidden) alwaysMatch.unshift('group-one:service-provider'); + if (this.__isRateHidden) alwaysMatch.unshift('group-one:rate'); + if (this.__isApplyToShippingHidden) alwaysMatch.unshift('group-two:apply-to-shipping'); + if (this.__isUseOriginRatesHidden) alwaysMatch.unshift('group-two:use-origin-rates'); + if (this.__isExemptAllCustomerTaxIdsHidden) { + alwaysMatch.unshift('group-two:exempt-all-customer-tax-ids'); } - // vaadin's combo box doesn't seem to validate on its own - this.renderRoot.querySelectorAll('vaadin-combo-box').forEach(e => e.validate()); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); + const regions = Object.values(this.__regionsLoader?.data?.values ?? {}); + alwaysMatch.unshift(`group-three:region-${regions.length ? 'input' : 'select'}`); - this.__countriesService.stop(); - this.__regionsService.stop(); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - private get __isNameHidden(): boolean { - return this.hiddenSelector.matches('name', true); - } - - private get __isTypeHidden(): boolean { - return this.hiddenSelector.matches('type', true); - } - - private get __isCountryHidden(): boolean { - if (this.hiddenSelector.matches('country', true)) return true; - if (this.form.type === 'union') return !this.form.use_origin_rates; - return !(['country', 'region', 'local'] as unknown[]).includes(this.form.type); - } + renderBody(): TemplateResult { + const countries = Object.values(this.__countriesLoader?.data?.values ?? {}); + const countryOptions = countries.map(c => ({ rawLabel: c.default, value: c.cc2 })); - private get __isRegionHidden(): boolean { - if (this.hiddenSelector.matches('region', true)) return true; - return this.form.type !== 'local' && this.form.type !== 'region'; - } - - private get __isCityHidden(): boolean { - if (this.hiddenSelector.matches('city', true)) return true; - return this.form.type !== 'local'; - } - - private get __isProviderHidden(): boolean { - if (this.hiddenSelector.matches('provider', true)) return true; - return !this.form.type || this.form.type === 'global' || this.form.type === 'local'; - } - - private get __isRateHidden(): boolean { - if (this.hiddenSelector.matches('rate', true)) return true; - return !this.form.type || this.form.is_live === true; - } - - private get __isApplyToShippingHidden(): boolean { - if (this.hiddenSelector.matches('apply-to-shipping', true)) return true; - if (this.form.type === undefined) return true; - return !!this.form.is_live && defaultLiveRateCountries.includes(this.form.country); - } - - private get __isUseOriginRatesHidden(): boolean { - if (this.hiddenSelector.matches('use-origin-rates', true)) return true; - return this.form.type !== 'union' || !this.form.is_live || !!this.form.service_provider; - } - - private get __isExemptAllCustomerTaxIdsHidden(): boolean { - if (this.hiddenSelector.matches('exempt-all-customer-tax-ids', true)) return true; - const provider = this.form.service_provider as string | undefined; - return provider === undefined || provider === 'onesource' || provider === 'avalara'; - } - - private get __isTimestampsHidden(): boolean { - if (this.hiddenSelector.matches('timestamps', true)) return true; - return !this.data; - } - - private get __isCreateHidden(): boolean { - if (this.hiddenSelector.matches('create', true)) return true; - return !!this.data; - } - - private get __isDeleteHidden(): boolean { - if (this.hiddenSelector.matches('delete', true)) return true; - return !this.data; - } - - private __getErrorMessage(prefix: string) { - const error = this.errors.find(err => err.startsWith(prefix)); - return error ? this.t(error.replace(prefix, 'v8n')).toString() : ''; - } - - private __getValidator(prefix: string) { - return () => !this.errors.some(err => err.startsWith(prefix)); - } + const regions = Object.values(this.__regionsLoader?.data?.values ?? {}); + const regionOptions = regions.map(r => ({ rawLabel: r.default, value: r.code })); - private __renderName(): TemplateResult { return html` -
- ${this.renderTemplateOrSlot('name:before')} - - { - const newName = (evt.currentTarget as TextFieldElement).value; - this.edit({ name: newName }); - }} - > - + ${this.renderHeader()} - ${this.renderTemplateOrSlot('name:after')} -
- `; - } + + - private __renderType(): TemplateResult { - if (!this.form.type) this.edit({ type: 'global' }); - - return html` -
- ${this.renderTemplateOrSlot('type:before')} - - this.t(`tax_${value}`)} - ?disabled=${!this.in('idle') || this.disabledSelector.matches('type', true)} - ?readonly=${this.readonlySelector.matches('type', true)} - @change=${(evt: DropdownChangeEvent) => { - this.edit({ - type: evt.detail as Data['type'], - country: '', - region: '', - city: '', - service_provider: '', - apply_to_shipping: false, - use_origin_rates: false, - exempt_all_customer_tax_ids: false, - is_live: false, - rate: 0, - }); - }} + - - - ${this.renderTemplateOrSlot('type:after')} -
- `; - } - - private __renderCountry(): TemplateResult { - const isLoadingItems = !!this.__countriesService.state?.matches('busy'); - const isLoadingData = this.in('busy'); - const isLoading = isLoadingItems || isLoadingData; - const isFail = this.in('fail'); - const json = this.__countriesService.state?.context.data as - | Resource - | undefined - | null; - - const items = Object.values(json?.values ?? {}); - - return html` -
- ${this.renderTemplateOrSlot('country:before')} - - { - this.edit({ - country: (evt.currentTarget as ComboBoxElement).value, - region: '', - city: '', - is_live: false, - service_provider: '', - }); - - if (this.__isApplyToShippingHidden) this.edit({ apply_to_shipping: false }); - this.__regionsService.send({ type: 'FETCH' }); - }} + + + - - - ${this.renderTemplateOrSlot('country:after')} -
- `; - } - - private __renderRegion(): TemplateResult { - const isLoadingItems = !!this.__regionsService.state?.matches('busy'); - const isLoadingData = this.in('busy'); - const isLoading = isLoadingItems || isLoadingData; - const isFail = this.in('fail'); - const json = this.__regionsService.state?.context.data as - | Resource - | undefined - | null; - - const items = Object.values(json?.values ?? {}); - - return html` -
- ${this.renderTemplateOrSlot('region:before')} - - { - const newRegion = (evt.currentTarget as ComboBoxElement).value; - this.edit({ region: newRegion, city: '' }); - }} + + + + + + + + + + + + + + + + + + - - - ${this.renderTemplateOrSlot('region:after')} -
- `; - } - - private __renderCity(): TemplateResult { - return html` -
- ${this.renderTemplateOrSlot('city:before')} - - { - const newCity = (evt.currentTarget as TextFieldElement).value; - this.edit({ city: newCity }); - }} + + + - + - ${this.renderTemplateOrSlot('city:after')} -
+ + + + +
+ + ${super.renderBody()} + + + + `; } - private __renderProvider(): TemplateResult { - const items = [ - { label: this.t('tax_rate_provider_none'), value: 'none' }, - { label: 'Avalara AvaTax 15', value: 'avalara' }, - { label: 'Thomson Reuters ONESOURCE', value: 'onesource' }, + private get __serviceProviderOptions() { + const options = [ + { label: 'option_none', value: 'none' }, + { label: 'option_avalara', value: 'avalara' }, + { label: 'option_onesource', value: 'onesource' }, ]; - if (this.form.type === 'union' || defaultLiveRateCountries.includes(this.form.country)) { - items.push({ label: this.t('tax_rate_provider_default'), value: 'default' }); + if ( + this.form.type === 'union' || + !this.form.country || + defaultLiveRateCountries.includes(this.form.country) + ) { + options.unshift({ label: 'option_default', value: 'default' }); } - if (taxJarLiveRateCountries.includes(this.form.country)) { - items.push({ label: 'TaxJar', value: 'taxjar' }); + if ( + this.form.type === 'union' || + !this.form.country || + taxJarLiveRateCountries.includes(this.form.country) + ) { + options.push({ label: 'option_taxjar', value: 'taxjar' }); } - return html` -
- ${this.renderTemplateOrSlot('provider:before')} - - item.value)} - .getText=${(value: string) => items.find(item => item.value === value)?.label} - ?disabled=${!this.in('idle') || this.disabledSelector.matches('provider', true)} - ?readonly=${this.readonlySelector.matches('provider', true)} - @change=${(evt: DropdownChangeEvent) => { - const newValue = evt.detail as string; - const newProvider = ['none', 'default'].includes(newValue) ? '' : newValue; - - this.edit({ - service_provider: newProvider as Data['service_provider'], - is_live: newValue !== 'none', - }); - - if (this.__isExemptAllCustomerTaxIdsHidden) { - this.edit({ exempt_all_customer_tax_ids: false }); - } - - if (this.__isApplyToShippingHidden) this.edit({ apply_to_shipping: false }); - if (this.__isUseOriginRatesHidden) this.edit({ use_origin_rates: false }); - }} - > - - - ${this.renderTemplateOrSlot('provider:after')} -
- `; + return options; } - private __renderRate(): TemplateResult { - return html` -
- ${this.renderTemplateOrSlot('rate:before')} - - { - const newRate = parseFloat((evt.currentTarget as IntegerFieldElement).value); - if (!isNaN(newRate)) this.edit({ rate: newRate }); - }} - > - + private get __nativeIntegrationsUrl() { + try { + const url = new URL(this.nativeIntegrations ?? ''); + const provider = this.form.service_provider; + if (provider) { + url.searchParams.set('provider', provider); + return url.toString(); + } + } catch { + // do nothing + } + } - ${this.renderTemplateOrSlot('rate:after')} -
- `; + private get __regionsUrl() { + try { + const regionsURL = new URL(this.regions ?? ''); + const country = this.form.country; + if (country) regionsURL.searchParams.set('country_code', country); + return regionsURL.toString(); + } catch { + // do nothing + } } - private __renderApplyToShipping(): TemplateResult { - return html` -
- ${this.renderTemplateOrSlot('apply-to-shipping:before')} - - this.edit({ apply_to_shipping: evt.detail })} - > - - - - - - - - ${this.renderTemplateOrSlot('apply-to-shipping:after')} -
- `; + private get __countriesLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#countriesLoader'); } - private __renderUseOriginRates(): TemplateResult { - return html` -
- ${this.renderTemplateOrSlot('use-origin-rates:before')} - - this.edit({ use_origin_rates: evt.detail })} - > - - - - - - - - ${this.renderTemplateOrSlot('use-origin-rates:after')} -
- `; + private get __regionsLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#regionsLoader'); } - private __renderExemptAllCustomerTaxIds(): TemplateResult { - const scope = 'exempt-all-customer-tax-ids'; + private get __isExemptAllCustomerTaxIdsHidden() { + const type = this.form.type as string | undefined; + if (type === 'custom_tax_endpoint') return true; + if (type === 'country' || type === 'region' || type === 'local') return !!this.form.is_live; - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - { - this.edit({ exempt_all_customer_tax_ids: evt.detail }); - }} - > - - - - - - - - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; + const provider = this.form.service_provider as string | undefined; + return !provider || provider === 'onesource' || provider === 'avalara' || provider === 'taxjar'; } - private __renderTimestamps(): TemplateResult { - return html` -
- ${this.renderTemplateOrSlot('timestamps:before')} - - ({ - name: this.t(field), - value: this.data?.[field] - ? this.t('date', { value: new Date(this.data[field] as string) }) - : '', - }))} - > - + private get __isApplyToShippingHidden() { + const type = this.form.type as string | undefined; - ${this.renderTemplateOrSlot('timestamps:after')} -
- `; + if (!type || type === 'custom_tax_endpoint') return true; + if (type === 'union') return false; + if (type === 'country' && this.form.is_live) return true; + if (type === 'region' && this.form.is_live) return true; + + return !!this.form.is_live && defaultLiveRateCountries.includes(this.form.country); } - private __renderCreate(): TemplateResult { - const isCleanTemplateInvalid = this.in({ idle: { template: { clean: 'invalid' } } }); - const isDirtyTemplateInvalid = this.in({ idle: { template: { dirty: 'invalid' } } }); - const isCleanSnapshotInvalid = this.in({ idle: { snapshot: { clean: 'invalid' } } }); - const isDirtySnapshotInvalid = this.in({ idle: { snapshot: { dirty: 'invalid' } } }); - const isTemplateInvalid = isCleanTemplateInvalid || isDirtyTemplateInvalid; - const isSnaphotInvalid = isCleanSnapshotInvalid || isDirtySnapshotInvalid; - const isInvalid = isTemplateInvalid || isSnaphotInvalid; - const isIdle = this.in('idle'); + private get __isUseOriginRatesHidden() { + return this.form.type !== 'union' || !this.form.is_live || !!this.form.service_provider; + } - return html` -
- ${this.renderTemplateOrSlot('create:before')} - - - - + private get __isProviderHidden() { + const type = this.form.type as string; + return !type || type === 'global' || type === 'local' || type === 'custom_tax_endpoint'; + } - ${this.renderTemplateOrSlot('create:after')} -
- `; + private get __isCountryHidden() { + if (this.form.type === 'union') { + return (!this.form.service_provider || this.form.is_live) && !this.form.use_origin_rates; + } else { + return !(['country', 'region', 'local'] as unknown[]).includes(this.form.type); + } } - private __renderDelete(): TemplateResult { - return html` -
- !evt.detail.cancelled && this.delete()} - > - - - ${this.renderTemplateOrSlot('delete:before')} - - { - const confirm = this.renderRoot.querySelector('#confirm') as InternalConfirmDialog; - confirm.show(evt.currentTarget as ButtonElement); - }} - > - - + private get __isRegionHidden() { + return this.form.type !== 'local' && this.form.type !== 'region'; + } - ${this.renderTemplateOrSlot('delete:after')} -
- `; + private get __isCityHidden() { + return this.form.type !== 'local'; } - private __submitOnEnter(evt: KeyboardEvent) { - if (evt.key === 'Enter') this.submit(); + private get __isRateHidden() { + const type = this.form.type as string; + return !type || type === 'custom_tax_endpoint' || this.form.is_live === true; } } diff --git a/src/elements/public/TaxForm/index.ts b/src/elements/public/TaxForm/index.ts index 3d34916f..b8166bfc 100644 --- a/src/elements/public/TaxForm/index.ts +++ b/src/elements/public/TaxForm/index.ts @@ -1,12 +1,13 @@ -import '../../internal/InternalConfirmDialog/index'; -import '../../internal/InternalSandbox/index'; -import '../Spinner/index'; -import '../I18n/index'; -import '@vaadin/vaadin-text-field/vaadin-number-field'; -import '@vaadin/vaadin-text-field/vaadin-text-field'; -import '@vaadin/vaadin-combo-box'; -import '@vaadin/vaadin-button'; -import '@vaadin/vaadin-select'; +import '../../internal/InternalAsyncListControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalNumberControl/index'; +import '../../internal/InternalTextControl/index'; +import '../../internal/InternalForm/index'; + +import '../NativeIntegrationCard/index'; +import '../NucleonElement/index'; import { TaxForm } from './TaxForm'; diff --git a/src/static/translations/tax-form/en.json b/src/static/translations/tax-form/en.json index 51be150d..75c031d2 100644 --- a/src/static/translations/tax-form/en.json +++ b/src/static/translations/tax-form/en.json @@ -1,36 +1,165 @@ { - "cancel": "Cancel", - "city": "City", - "country": "Country", - "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?", - "error": "Error", - "loading_busy": "Loading", - "name": "Name", - "region": "Region", - "tax_apply_to_shipping": "Apply to shipping", - "tax_apply_to_shipping_explainer": "Check to apply this tax to the shipping costs.", - "tax_country": "Country tax", - "tax_exempt_all_customer_tax_ids": "Exempt all customers with Tax ID", - "tax_exempt_all_customer_tax_ids_explainer": "Check to skip tax collection for customers with Tax ID on file.", - "tax_global": "Global tax", - "tax_local": "Local tax", - "tax_rate": "Rate (%)", - "tax_rate_provider": "Rate provider", - "tax_rate_provider_default": "Default (Thomson Reuters; others)", - "tax_rate_provider_none": "None (custom rate)", - "tax_region": "Regional tax", - "tax_union": "European Union tax", - "tax_use_origin_rates": "Use origin tax rates", - "tax_use_origin_rates_explainer": "Check to use the tax rate of your country regardless of where customers are located.", - "type": "Type", - "v8n_required": "Required", - "v8n_too_long": "Too long", + "header": { + "title_existing": "Tax #{{ id }}", + "title_new": "New tax", + "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" + } + }, + "group-one": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Please enter a name for this tax.", + "v8n_too_long": "Please choose a name that is 30 characters or less." + }, + "type": { + "label": "Type", + "helper_text": "", + "placeholder": "Select", + "option_global": "Global", + "option_union": "European Union", + "option_country": "Country", + "option_region": "Regional", + "option_local": "Local", + "option_custom_tax_endpoint": "Custom tax endpoint" + }, + "service-provider": { + "label": "Live rate provider", + "helper_text": "", + "placeholder": "Select", + "option_avalara": "Avalara AvaTax 15", + "option_custom_tax": "Custom Tax Endpoint", + "option_taxjar": "TaxJar", + "option_onesource": "Thomson Reuters ONESOURCE", + "option_none": "None", + "option_default": "Default" + }, + "rate": { + "label": "Fixed rate", + "helper_text": "", + "placeholder": "Required", + "v8n_invalid": "Please enter a non-zero, positive rate." + } + }, + "group-two": { + "label": "", + "helper_text": "", + "apply-to-shipping": { + "label": "Apply to shipping and handling", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "exempt-all-customer-tax-ids": { + "label": "Exempt all customers with Tax ID", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "use-origin-rates": { + "label": "Use the origin country tax rates", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "group-three": { + "label": "", + "helper_text": "", + "country": { + "label": "Country", + "helper_text": "", + "placeholder": "Select", + "v8n_required": "Please select a country." + }, + "region-select": { + "label": "Region", + "helper_text": "", + "placeholder": "Select", + "v8n_required": "Please select a region." + }, + "region-input": { + "label": "Region", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Please enter a region.", + "v8n_too_long": "Please shorten this value to 20 characters or less. We suggest using a region code if available." + }, + "city": { + "label": "City", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Please enter a city.", + "v8n_too_long": "Please shorten this value to 50 characters or less." + } + }, + "native-integrations": { + "label": "", + "pagination": { + "card": { + "image_alt": "Native integration logo", + "title_avalara": "Avalara", + "title_taxjar": "TaxJar", + "title_onesource": "ONESOURCE", + "title_webflow": "Webflow", + "title_zapier": "Zapier", + "title_webhook": "{{ title }}", + "title_apple_pay": "Apple Pay", + "title_custom_tax": "Custom Tax Endpoint", + "subtitle_avalara": "Account #{{ id }} • {{ service_url }}", + "subtitle_taxjar": "SmartCalcs API token ends in ****{{ api_token }}", + "subtitle_onesource": "{{ service_url }}", + "subtitle_webflow": "{{ site_name }} • ID {{ site_id }}", + "subtitle_zapier": "{{ url }}", + "subtitle_webhook": "{{ url }}", + "subtitle_apple_pay": "Merchant #{{ merchantID }}", + "subtitle_custom_tax": "{{ url }}", + "spinner": { + "loading_busy": "Loading", + "loading_empty": "Please configure a rate provider on the Integrations page", + "loading_error": "Unknown error" + } + }, + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous" + } + }, + "timestamps": { + "date_created": "Created on", + "date_modified": "Last updated on", + "date": "{{value, date}}" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "Are you sure you'd like to remove this tax? You won't be able to bring it back." + }, + "undo": { + "caption": "Undo" + }, + "submit": { + "caption": "Save changes" + }, + "create": { + "caption": "Create" + }, "spinner": { "refresh": "Refresh", "loading_busy": "Loading", From 9c88266929a118ffcb5f387fd6e40c5aa133e722 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 7 Jan 2025 17:37:21 -0300 Subject: [PATCH 08/18] chore: regenerate custom-elements.json --- custom-elements.json | 122 +++++++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 34 deletions(-) diff --git a/custom-elements.json b/custom-elements.json index 56e62ef8..ab1c591b 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -18848,6 +18848,9 @@ "path": "./src/elements/public/NativeIntegrationForm/index.ts", "description": "Form element for configuring native integrations (`fx:native_integration`).", "attributes": [ + { + "name": "store" + }, { "name": "simplify-ns-loading", "type": "boolean", @@ -18935,6 +18938,10 @@ } ], "properties": [ + { + "name": "store", + "attribute": "store" + }, { "name": "simplifyNsLoading", "attribute": "simplify-ns-loading", @@ -27341,15 +27348,32 @@ "path": "./src/elements/public/TaxForm/index.ts", "description": "Form element for creating or editing taxes (`fx:tax`).", "attributes": [ + { + "name": "native-integrations", + "description": "URL of the `fx:native_integrations` collection for the store." + }, { "name": "countries", - "description": "URI of the `fx:countries` hAPI resource.", - "type": "string" + "description": "URL of the `fx:countries` property helper resource." }, { "name": "regions", - "description": "URI of the `fx:regions` hAPI resource.", - "type": "string" + "description": "URL of the `fx:regions` property helper resource." + }, + { + "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", @@ -27383,16 +27407,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.", @@ -27433,18 +27447,75 @@ } ], "properties": [ + { + "name": "nativeIntegrations", + "attribute": "native-integrations", + "description": "URL of the `fx:native_integrations` collection for the store." + }, { "name": "countries", "attribute": "countries", - "description": "URI of the `fx:countries` hAPI resource.", - "type": "string" + "description": "URL of the `fx:countries` property helper resource." }, { "name": "regions", "attribute": "regions", - "description": "URI of the `fx:regions` hAPI resource.", + "description": "URL of the `fx:regions` property helper resource." + }, + { + "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": "{}" @@ -27500,23 +27571,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.", From ebcfc6967660fa6a0f232ac46894a23653aed052 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Wed, 8 Jan 2025 13:24:43 -0300 Subject: [PATCH 09/18] feat(foxy-payments-api-payment-method-form): add a switch for `use_auth_only` --- .../PaymentsApiPaymentMethodForm.test.ts | 44 +++++++++++++++++++ .../PaymentsApiPaymentMethodForm.ts | 6 +++ .../payments-api-payment-method-form/en.json | 6 +++ .../payments-api-payment-preset-form/en.json | 6 +++ 4 files changed, 62 insertions(+) diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts index f227676e..5b3bdd99 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts @@ -1298,6 +1298,50 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(control).to.have.attribute('layout', 'summary-item'); }); + it('renders a switch control for auth-only transactions if applicable', async () => { + const router = createRouter(); + + const wrapper = await fixture(html` +
router.handleEvent(evt)}> + + + + +
+ `); + + const element = wrapper.firstElementChild!.firstElementChild as Form; + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + element.data!.helper.supports_auth_only = 0; + element.data = { ...element.data! }; + await element.requestUpdate(); + + expect(element.renderRoot.querySelector('[infer="use-auth-only"]')).to.not.exist; + + element.data!.helper.supports_auth_only = 1; + element.data = { ...element.data! }; + await element.requestUpdate(); + const control = element.renderRoot.querySelector( + '[infer="use-auth-only"]' + ) as InternalSwitchControl; + + expect(control).to.exist; + expect(control).to.be.instanceOf(InternalSwitchControl); + }); + it('renders a select control for toggling 3DS on and off if supported', async () => { const router = createRouter(); diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts index 7d671703..90268085 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts @@ -340,6 +340,12 @@ export class PaymentsApiPaymentMethodForm extends Base { + ${this.form.helper?.supports_auth_only + ? html` + + + ` + : ''} ${this.form.helper?.supports_3d_secure ? html` Date: Wed, 8 Jan 2025 13:26:34 -0300 Subject: [PATCH 10/18] fix(foxy-payments-api-payment-method-form): use password controls for `key` and `third_party_key` --- .../PaymentsApiPaymentMethodForm.test.ts | 14 ++++++++++---- .../PaymentsApiPaymentMethodForm.ts | 8 ++++---- .../public/PaymentsApiPaymentMethodForm/index.ts | 1 + 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts index 5b3bdd99..aa88944d 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.test.ts @@ -6,6 +6,7 @@ import './index'; import { PaymentsApiPaymentMethodForm as Form } from './PaymentsApiPaymentMethodForm'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { InternalPasswordControl } from '../../internal/InternalPasswordControl/InternalPasswordControl'; import { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; import { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; import { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; @@ -30,6 +31,11 @@ describe('PaymentsApiPaymentMethodForm', () => { expect(customElements.get('vaadin-button')).to.exist; }); + it('imports and defines foxy-internal-password-control', () => { + const element = customElements.get('foxy-internal-password-control'); + expect(element).to.equal(InternalPasswordControl); + }); + it('imports and defines foxy-internal-switch-control', () => { const element = customElements.get('foxy-internal-switch-control'); expect(element).to.equal(InternalSwitchControl); @@ -621,7 +627,7 @@ describe('PaymentsApiPaymentMethodForm', () => { } }); - it('renders a text control for live and test 3rd-party key if applicable', async () => { + it('renders a password control for live and test 3rd-party key if applicable', async () => { const router = createRouter(); const wrapper = await fixture(html` @@ -664,7 +670,7 @@ describe('PaymentsApiPaymentMethodForm', () => { const field = tabPanel.querySelector(`[infer="${prefix}third-party-key"]`); expect(field).to.exist; - expect(field).to.be.instanceOf(InternalTextControl); + expect(field).to.be.instanceOf(InternalPasswordControl); expect(field).to.have.attribute('placeholder', 'default_additional_field_placeholder'); expect(field).to.have.attribute('helper-text', ''); expect(field).to.have.attribute('layout', 'summary-item'); @@ -684,7 +690,7 @@ describe('PaymentsApiPaymentMethodForm', () => { } }); - it('renders a text control for live and test account key if applicable', async () => { + it('renders a password control for live and test account key if applicable', async () => { const router = createRouter(); const wrapper = await fixture(html` @@ -721,7 +727,7 @@ describe('PaymentsApiPaymentMethodForm', () => { const field = tabPanel.querySelector(`[infer="${prefix}account-key"]`); expect(field).to.exist; - expect(field).to.be.instanceOf(InternalTextControl); + expect(field).to.be.instanceOf(InternalPasswordControl); expect(field).to.have.attribute('placeholder', 'default_additional_field_placeholder'); expect(field).to.have.attribute('helper-text', ''); expect(field).to.have.attribute('layout', 'summary-item'); diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts index 90268085..d35735c0 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts @@ -419,26 +419,26 @@ export class PaymentsApiPaymentMethodForm extends Base { : ''} ${this.form.helper?.third_party_key_description ? html` - - + ` : ''} ${this.form.helper?.key_description ? html` - - + ` : ''} ${blocks.map(block => this.__renderBlock(block))} diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/index.ts b/src/elements/public/PaymentsApiPaymentMethodForm/index.ts index 796757a4..1e495d5f 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/index.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/index.ts @@ -1,5 +1,6 @@ import '@vaadin/vaadin-button'; +import '../../internal/InternalPasswordControl/index'; import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalSwitchControl/index'; import '../../internal/InternalSelectControl/index'; From 2e7542ce3f601e837d47e58d98d6423fb0927ea9 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Wed, 8 Jan 2025 14:09:22 -0300 Subject: [PATCH 11/18] fix(foxy-nucleon): fix default content type --- src/elements/public/NucleonElement/API.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/elements/public/NucleonElement/API.ts b/src/elements/public/NucleonElement/API.ts index ed8a9b04..5d07e6e6 100644 --- a/src/elements/public/NucleonElement/API.ts +++ b/src/elements/public/NucleonElement/API.ts @@ -20,8 +20,14 @@ export class API extends CoreAPI { const request = typeof args[0] === 'string' ? new API.WHATWGRequest(...args) : args[0]; request.headers.set('FOXY-API-VERSION', '1'); - if (!request.headers.has('Content-Type')) { - request.headers.set('Content-Type', 'application/json'); + + // WHATWGRequest adds text/plain content type by default. + // Our default is application/json so we need to override it. + if (['POST', 'PATCH', 'PUT'].includes(request.method)) { + const s = typeof args[0] === 'string' ? args[1]?.headers : args[0].headers; + if (new API.WHATWGHeaders(s).get('Content-Type') === null) { + request.headers.set('Content-Type', 'application/json'); + } } const event = new FetchEvent('fetch', { From 6f836524c743a1e69291772d06cc3ca2e8328599 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 9 Jan 2025 13:21:20 -0300 Subject: [PATCH 12/18] fix(foxy-admin-subscription-form): use yyyy-mm-dd format for start/end/next dates --- .../AdminSubscriptionForm.test.ts | 3 --- .../AdminSubscriptionForm/AdminSubscriptionForm.ts | 10 +++------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.test.ts b/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.test.ts index 8d238075..4daaad0b 100644 --- a/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.test.ts +++ b/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.test.ts @@ -193,7 +193,6 @@ describe('AdminSubscriptionForm', () => { expect(control?.localName).to.equal('foxy-internal-date-control'); expect(control).to.have.attribute('layout', 'summary-item'); - expect(control).to.have.attribute('format', 'iso-long'); }); it('renders frequency control inside of the general summary control', async () => { @@ -233,7 +232,6 @@ describe('AdminSubscriptionForm', () => { expect(control?.localName).to.equal('foxy-internal-date-control'); expect(control).to.have.attribute('layout', 'summary-item'); - expect(control).to.have.attribute('format', 'iso-long'); }); it('renders date control for end date inside of the general summary control', async () => { @@ -253,7 +251,6 @@ describe('AdminSubscriptionForm', () => { expect(control?.localName).to.equal('foxy-internal-date-control'); expect(control).to.have.attribute('layout', 'summary-item'); - expect(control).to.have.attribute('format', 'iso-long'); }); it('renders summary control with overdue information', async () => { diff --git a/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.ts b/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.ts index 7fba6d77..a3e5505b 100644 --- a/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.ts +++ b/src/elements/public/AdminSubscriptionForm/AdminSubscriptionForm.ts @@ -53,7 +53,7 @@ export class AdminSubscriptionForm extends Base { - + { allow-twice-a-month > - + - + From f21b5f15e3693d68beef4b4df3d12232d03bc8ee Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 9 Jan 2025 13:42:48 -0300 Subject: [PATCH 13/18] feat(foxy-payments-api): update virtual property helper for google recaptcha config --- .../public/PaymentsApi/PaymentsApi.test.ts | 56 +++++++++---------- .../composers/available_fraud_protections.ts | 14 ++--- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/elements/public/PaymentsApi/PaymentsApi.test.ts b/src/elements/public/PaymentsApi/PaymentsApi.test.ts index 719fb98c..4ddcd40b 100644 --- a/src/elements/public/PaymentsApi/PaymentsApi.test.ts +++ b/src/elements/public/PaymentsApi/PaymentsApi.test.ts @@ -578,7 +578,7 @@ describe('PaymentsApi', () => { id: 'config', name: 'Configuration', type: 'select', - description: 'Determines how reCAPTCHA is configured to operate.', + description: '', default_value: 'disabled', options: [ { name: 'Disabled', value: 'disabled' }, @@ -587,19 +587,19 @@ describe('PaymentsApi', () => { ], }, { - id: 'private_key', - name: 'Private Key', + id: 'site_key', + name: 'Site Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Private Key here.', + description: '', default_value: '', }, { - id: 'site_key', - name: 'Site Key', + id: 'private_key', + name: 'Secret Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Site Key here.', + description: '', default_value: '', }, ], @@ -748,7 +748,7 @@ describe('PaymentsApi', () => { id: 'config', name: 'Configuration', type: 'select', - description: 'Determines how reCAPTCHA is configured to operate.', + description: '', default_value: 'disabled', options: [ { name: 'Disabled', value: 'disabled' }, @@ -757,19 +757,19 @@ describe('PaymentsApi', () => { ], }, { - id: 'private_key', - name: 'Private Key', + id: 'site_key', + name: 'Site Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Private Key here.', + description: '', default_value: '', }, { - id: 'site_key', - name: 'Site Key', + id: 'private_key', + name: 'Secret Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Site Key here.', + description: '', default_value: '', }, ], @@ -3130,7 +3130,7 @@ describe('PaymentsApi', () => { id: 'config', name: 'Configuration', type: 'select', - description: 'Determines how reCAPTCHA is configured to operate.', + description: '', default_value: 'disabled', options: [ { name: 'Disabled', value: 'disabled' }, @@ -3139,19 +3139,19 @@ describe('PaymentsApi', () => { ], }, { - id: 'private_key', - name: 'Private Key', + id: 'site_key', + name: 'Site Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Private Key here.', + description: '', default_value: '', }, { - id: 'site_key', - name: 'Site Key', + id: 'private_key', + name: 'Secret Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Site Key here.', + description: '', default_value: '', }, ], @@ -3356,7 +3356,7 @@ describe('PaymentsApi', () => { id: 'config', name: 'Configuration', type: 'select', - description: 'Determines how reCAPTCHA is configured to operate.', + description: '', default_value: 'disabled', options: [ { name: 'Disabled', value: 'disabled' }, @@ -3365,19 +3365,19 @@ describe('PaymentsApi', () => { ], }, { - id: 'private_key', - name: 'Private Key', + id: 'site_key', + name: 'Site Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Private Key here.', + description: '', default_value: '', }, { - id: 'site_key', - name: 'Site Key', + id: 'private_key', + name: 'Secret Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Site Key here.', + description: '', default_value: '', }, ], diff --git a/src/elements/public/PaymentsApi/api/composers/available_fraud_protections.ts b/src/elements/public/PaymentsApi/api/composers/available_fraud_protections.ts index 55ae138b..01c381aa 100644 --- a/src/elements/public/PaymentsApi/api/composers/available_fraud_protections.ts +++ b/src/elements/public/PaymentsApi/api/composers/available_fraud_protections.ts @@ -29,7 +29,7 @@ export function compose(params: Params): AvailableFraudProtections { id: 'config', name: 'Configuration', type: 'select', - description: 'Determines how reCAPTCHA is configured to operate.', + description: '', default_value: 'disabled', options: [ { name: 'Disabled', value: 'disabled' }, @@ -38,19 +38,19 @@ export function compose(params: Params): AvailableFraudProtections { ], }, { - id: 'private_key', - name: 'Private Key', + id: 'site_key', + name: 'Site Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Private Key here.', + description: '', default_value: '', }, { - id: 'site_key', - name: 'Site Key', + id: 'private_key', + name: 'Secret Key', type: 'text', optional: true, - description: 'If using a custom subdomain, enter your Site Key here.', + description: '', default_value: '', }, ], From e8522fec955a655428e81d41a17c0d1547ee387d Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 13 Jan 2025 17:09:53 -0300 Subject: [PATCH 14/18] fix(foxy-customer-portal): support `0000-00-00` date format in `end_date` for subscriptions --- .../CustomerPortal/InternalCustomerPortalSubscriptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalSubscriptions.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalSubscriptions.ts index a28e2b67..ee9cf967 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalSubscriptions.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalSubscriptions.ts @@ -62,7 +62,7 @@ export class InternalCustomerPortalSubscriptions extends Base { }; private readonly __renderFormHeaderActionsEnd: Renderer = (html, host) => { - const hasEndDate = !!host.data?.end_date; + const hasEndDate = !!host.data?.end_date && host.data.end_date !== '0000-00-00'; let cancelLink = ''; if (!hasEndDate && host.in({ idle: 'snapshot' })) { From 58ba0d42e6f9b2d35f8d3722ec9f55c2cdff56f7 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 14 Jan 2025 14:15:25 -0300 Subject: [PATCH 15/18] chore: update foxy sdk --- package-lock.json | 8 ++-- package.json | 6 +-- src/elements/public/CartForm/types.ts | 11 +---- .../public/CustomerForm/CustomerForm.test.ts | 2 - .../public/EmailTemplateForm/types.ts | 3 +- src/elements/public/StoreForm/types.ts | 44 ++++--------------- .../public/SubscriptionSettingsForm/types.ts | 5 +-- .../public/Transaction/Transaction.ts | 6 +-- src/elements/public/UserCard/UserCard.ts | 6 +-- .../public/UserInvitationCard/types.ts | 5 ++- .../public/UserInvitationForm/types.ts | 43 +----------------- 11 files changed, 27 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 608614eb..b52a8612 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@foxy.io/sdk": "^1.12.0", + "@foxy.io/sdk": "^1.13.0", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", @@ -2264,9 +2264,9 @@ } }, "node_modules/@foxy.io/sdk": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.12.0.tgz", - "integrity": "sha512-7qUxhakr6TTXXuajSjyTTng3EP1N+83nXvm5P2+V8e0/qXTnORwMsas7iXpw+kPmgPa2/jSOqfmlZ10BywyOuA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.13.0.tgz", + "integrity": "sha512-8LbZ+eWR9zTVo3XEtWkjtdqKyJGLgB7Tlb8yAfSCwc6jVxlPN8Yie42lzBahUlK2LpDwEsBfxe1tgMONX/D5Kg==", "dependencies": { "@types/jsdom": "^16.2.5", "@types/traverse": "^0.6.32", diff --git a/package.json b/package.json index 93111972..eee244e1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prepack": "npm run lint && rimraf dist && node ./.build/compile-for-npm.js && rollup -c" }, "dependencies": { - "@foxy.io/sdk": "^1.12.0", + "@foxy.io/sdk": "^1.13.0", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", @@ -38,6 +38,7 @@ "cookie-storage": "^6.1.0", "dedent": "^1.5.3", "email-validator": "^2.0.4", + "highlight.js": "^10.7.3", "html-entities": "^2.4.0", "i18next": "^19.7.0", "i18next-http-backend": "^1.0.18", @@ -49,7 +50,6 @@ "uainfer": "^0.5.0", "vanilla-hcaptcha": "^1.0.2", "webcomponent-qr-code": "^1.0.5", - "highlight.js": "^10.7.3", "xstate": "^4.16.0" }, "devDependencies": { @@ -160,4 +160,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/src/elements/public/CartForm/types.ts b/src/elements/public/CartForm/types.ts index de76a7fd..267511b0 100644 --- a/src/elements/public/CartForm/types.ts +++ b/src/elements/public/CartForm/types.ts @@ -1,13 +1,4 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -// TODO remove this once SDK is fixed -type OriginalData = Resource; -type FixedData = Omit & { - /** Corresponds to the `region` field in `fx:customer_address`. API quirk. */ - billing_state: string; - /** Corresponds to the `region` field in `fx:customer_address`. API quirk. */ - shipping_state: string; -}; - -export type Data = FixedData; +export type Data = Resource; diff --git a/src/elements/public/CustomerForm/CustomerForm.test.ts b/src/elements/public/CustomerForm/CustomerForm.test.ts index d2fb99fc..d3dec668 100644 --- a/src/elements/public/CustomerForm/CustomerForm.test.ts +++ b/src/elements/public/CustomerForm/CustomerForm.test.ts @@ -231,8 +231,6 @@ describe('CustomerForm', () => { form.data = { ...data, first_name: '', last_name: '' }; expect(form.headerTitleOptions).to.have.property('context', 'no_name'); - // TODO: remove this when SDK types are fixed - // @ts-expect-error SDK types are incomplete form.data = { ...data, first_name: null, last_name: null }; expect(form.headerTitleOptions).to.have.property('context', 'no_name'); }); diff --git a/src/elements/public/EmailTemplateForm/types.ts b/src/elements/public/EmailTemplateForm/types.ts index f3b76f1a..45449430 100644 --- a/src/elements/public/EmailTemplateForm/types.ts +++ b/src/elements/public/EmailTemplateForm/types.ts @@ -1,5 +1,4 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -// TODO: simplify once SDK types are updated -export type Data = Resource & { subject: string }; +export type Data = Resource; diff --git a/src/elements/public/StoreForm/types.ts b/src/elements/public/StoreForm/types.ts index 95781298..6aa0a3fe 100644 --- a/src/elements/public/StoreForm/types.ts +++ b/src/elements/public/StoreForm/types.ts @@ -1,40 +1,12 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; +import type { + StoreCustomDisplayIdConfigJson, + StoreSmtpConfigJson, + StoreWebhookKeyJson, +} from '@foxy.io/sdk/dist/types/backend/Rels'; export type Data = Resource; - -// TODO add this to SDK -export type ParsedWebhookKey = { - cart_signing: string; - xml_datafeed: string; - api_legacy: string; - sso: string; -}; - -// TODO add this to SDK -export type ParsedSmtpConfig = { - username: string; - password: string; - security: string; - host: string; - port: string; -}; - -// TODO add this to SDK -export type ParsedCustomDisplayIdConfig = { - enabled: boolean; - start: string; - length: string; - prefix: string; - suffix: string; - transaction_journal_entries: { - enabled: boolean; - transaction_separator: string; - log_detail_request_types: { - transaction_authcapture: { prefix: string }; - transaction_capture: { prefix: string }; - transaction_refund: { prefix: string }; - transaction_void: { prefix: string }; - }; - }; -}; +export type ParsedWebhookKey = StoreWebhookKeyJson; +export type ParsedSmtpConfig = StoreSmtpConfigJson; +export type ParsedCustomDisplayIdConfig = StoreCustomDisplayIdConfigJson; diff --git a/src/elements/public/SubscriptionSettingsForm/types.ts b/src/elements/public/SubscriptionSettingsForm/types.ts index 0cf855fd..9b9638df 100644 --- a/src/elements/public/SubscriptionSettingsForm/types.ts +++ b/src/elements/public/SubscriptionSettingsForm/types.ts @@ -1,7 +1,4 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -export type Data = Omit, 'reattempt_bypass_logic'> & { - // TODO remove once SDK is updated - reattempt_bypass_logic: 'skip_if_exists' | 'reattempt_if_exists' | ''; -}; +export type Data = Resource; diff --git a/src/elements/public/Transaction/Transaction.ts b/src/elements/public/Transaction/Transaction.ts index 83486cb0..b2af370f 100644 --- a/src/elements/public/Transaction/Transaction.ts +++ b/src/elements/public/Transaction/Transaction.ts @@ -51,8 +51,6 @@ export class Transaction extends Base { onClick: async (selection: Resource[]) => { if (!this.data) return; - // TODO remove ts-expect-error when SDK has the types - // @ts-expect-error SDK types are incomplete const url = this.data._links['fx:send_webhooks'].href; const api = new Transaction.API(this); const response = await api.fetch(url, { @@ -213,9 +211,7 @@ export class Transaction extends Base { if (this.data) { try { const shipmentsUrl = new URL(this.data._links['fx:shipments'].href); - // TODO: Remove the ts-expect-error comment when SDK has the types - // @ts-expect-error SDK doesn't have the types - const webhooksUrl = new URL(this.__storeLoader?.data._links['fx:webhooks'].href ?? ''); + const webhooksUrl = new URL(this.__storeLoader?.data?._links['fx:webhooks'].href ?? ''); const itemsUrl = new URL(this.data._links['fx:items'].href); shipmentsUrl.searchParams.set('zoom', 'items:item_category'); diff --git a/src/elements/public/UserCard/UserCard.ts b/src/elements/public/UserCard/UserCard.ts index 4abaf963..2e9b88bd 100644 --- a/src/elements/public/UserCard/UserCard.ts +++ b/src/elements/public/UserCard/UserCard.ts @@ -1,8 +1,8 @@ import type { PropertyDeclarations } from 'lit-element'; -import type { UserInvitations } from '../UserInvitationForm/types'; import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { TemplateResult } from 'lit-html'; import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; @@ -77,15 +77,13 @@ export class UserCard extends Base { } private get __invitationsLoader() { - type Loader = NucleonElement>; + type Loader = NucleonElement>; return this.renderRoot.querySelector('#invitationsLoader'); } private get __invitationsHref() { try { if (!this.showInvitations) return; - // TODO remove this when SDK has types for invitations. - // @ts-expect-error SDK does not have types for invitations yet. const url = new URL(this.data?._links['fx:user_invitations'].href ?? ''); url.searchParams.set('status', 'sent'); url.searchParams.set('limit', '1'); diff --git a/src/elements/public/UserInvitationCard/types.ts b/src/elements/public/UserInvitationCard/types.ts index d725b2b8..6a21c6c3 100644 --- a/src/elements/public/UserInvitationCard/types.ts +++ b/src/elements/public/UserInvitationCard/types.ts @@ -1 +1,4 @@ -export * from '../UserInvitationForm/types'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +export type Data = Resource; diff --git a/src/elements/public/UserInvitationForm/types.ts b/src/elements/public/UserInvitationForm/types.ts index 5a71ba1f..6a21c6c3 100644 --- a/src/elements/public/UserInvitationForm/types.ts +++ b/src/elements/public/UserInvitationForm/types.ts @@ -1,43 +1,4 @@ -import type { - CollectionGraphLinks, - CollectionGraphProps, -} from '@foxy.io/sdk/dist/types/core/defaults'; - -import type { Graph, Resource } from '@foxy.io/sdk/core'; +import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -// TODO: replace with SDK import when SDK has the types -export interface UserInvitation extends Graph { - curie: 'fx:user_invitation'; - links: { - 'self': UserInvitation; - 'fx:user': Rels.User; - 'fx:store': Rels.Store; - 'fx:resend': { curie: 'fx:resend' }; - 'fx:accept': { curie: 'fx:accept' }; - 'fx:reject': { curie: 'fx:reject' }; - 'fx:revoke': { curie: 'fx:revoke' }; - }; - props: { - store_url: string; - store_name: string; - store_email: string; - store_domain: string; - first_name: string | null; - last_name: string | null; - email: string; - status: 'sent' | 'accepted' | 'rejected' | 'revoked' | 'expired'; - date_created: string; - date_modified: string; - }; -} - -// TODO: replace with SDK import when SDK has the types -export interface UserInvitations extends Graph { - curie: 'fx:user_invitations'; - links: CollectionGraphLinks; - props: CollectionGraphProps; - child: UserInvitation; -} - -export type Data = Resource; +export type Data = Resource; From 5b3292d6ee6aed757b6e7a6e0482608b3b527b3f Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 14 Jan 2025 14:18:00 -0300 Subject: [PATCH 16/18] feat(foxy-customer-portal): show Update Password form when logged in with temporary password --- custom-elements.json | 318 ++++++++++++++++++ .../CustomerPortal/CustomerPortal.test.ts | 66 +++- .../public/CustomerPortal/CustomerPortal.ts | 56 ++- .../InternalCustomerPortalLoggedOutView.ts | 7 +- ...nalCustomerPortalPasswordResetView.test.ts | 102 ++++++ ...InternalCustomerPortalPasswordResetView.ts | 92 +++++ src/elements/public/CustomerPortal/index.ts | 8 + src/server/portal/index.ts | 1 + .../translations/customer-portal/de.json | 18 + .../translations/customer-portal/en.json | 18 + .../translations/customer-portal/es.json | 18 + .../translations/customer-portal/fr.json | 18 + .../translations/customer-portal/nl.json | 18 + .../translations/customer-portal/pl.json | 18 + .../translations/customer-portal/sv.json | 18 + .../translations/customer-portal/zh-hk.json | 18 + 16 files changed, 781 insertions(+), 13 deletions(-) create mode 100644 src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.test.ts create mode 100644 src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.ts diff --git a/custom-elements.json b/custom-elements.json index ab1c591b..0ca1947c 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -8989,6 +8989,311 @@ "type": "string" } ], + "events": [ + { + "name": "password" + }, + { + "name": "update", + "description": "Instance of `NucleonElement.UpdateEvent`. Dispatched on an element whenever it changes its state." + }, + { + "name": "fetch", + "description": "Instance of `NucleonElement.API.FetchEvent`. Emitted before each API request." + } + ] + }, + { + "name": "foxy-internal-customer-portal-password-reset-view", + "path": "./src/elements/public/CustomerPortal/index.ts", + "attributes": [ + { + "name": "password-old" + }, + { + "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", + "default": "\"production\"" + }, + { + "name": "readonly", + "type": "boolean", + "default": "false" + }, + { + "name": "readonlycontrols", + "default": "\"False\"" + }, + { + "name": "disabled", + "type": "boolean", + "default": "false" + }, + { + "name": "disabledcontrols", + "default": "\"False\"" + }, + { + "name": "hidden", + "type": "boolean", + "default": "false" + }, + { + "name": "hiddencontrols", + "default": "\"False\"" + }, + { + "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.", + "type": "string", + "default": "\"\"" + }, + { + "name": "parent", + "description": "Optional URL of the collection this element's resource belongs to.\nChanging the `parent` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "related", + "description": "Optional URI list of the related resources. If Rumour encounters a related\nresource on creation or deletion, it will be reloaded from source.", + "type": "array", + "default": "[]" + }, + { + "name": "virtual-host", + "description": "Unique identifier for the virtual host used by the element to serve internal requests.\n\nCurrently only one endpoint is supported: `foxy:///form/`.\nThis endpoint supports POST, GET, PATCH and DELETE methods and functions like a hAPI server.\n\nFor example, `GET foxy://nucleon-123/form/subscriptions/allowNextDateModification/0` on a NucleonElement\nwith `fx:customer_portal_settings` will return the first item of the `subscriptions.allowNextDateModification` array.", + "default": "\"uniqueId('nucleon-')\"" + }, + { + "name": "group", + "description": "Rumour group. Elements in different groups will not share updates. Empty by default.", + "type": "string" + }, + { + "name": "href", + "description": "Optional URL of the resource to load. Switches element to `idle.template` state if empty (default).", + "type": "string" + }, + { + "name": "infer", + "description": "Set a name for this element here to enable property inference. Set to `null` to disable.", + "type": "string" + } + ], + "properties": [ + { + "name": "passwordOld", + "attribute": "password-old" + }, + { + "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": "{}" + }, + { + "name": "mode", + "attribute": "mode", + "type": "string", + "default": "\"production\"" + }, + { + "name": "readonly", + "attribute": "readonly", + "type": "boolean", + "default": "false" + }, + { + "name": "readonlyControls", + "attribute": "readonlycontrols", + "default": "\"False\"" + }, + { + "name": "disabled", + "attribute": "disabled", + "type": "boolean", + "default": "false" + }, + { + "name": "disabledControls", + "attribute": "disabledcontrols", + "default": "\"False\"" + }, + { + "name": "hidden", + "attribute": "hidden", + "type": "boolean", + "default": "false" + }, + { + "name": "hiddenControls", + "attribute": "hiddencontrols", + "default": "\"False\"" + }, + { + "name": "readonlySelector", + "type": "BooleanSelector" + }, + { + "name": "disabledSelector", + "type": "BooleanSelector" + }, + { + "name": "hiddenSelector", + "type": "BooleanSelector" + }, + { + "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.", + "type": "typeof UpdateEvent", + "default": "\"UpdateEvent\"" + }, + { + "name": "Rumour", + "description": "Creates a tagged [Rumour](https://sdk.foxy.dev/classes/_core_index_.rumour.html)\ninstance if it doesn't exist or returns cached one otherwise. NucleonElements\nuse empty Rumour group by default.", + "type": "((group: string) => Rumour) & MemoizedFunction", + "default": "\"memoize<(group: string) => Rumour>(() => new Rumour())\"" + }, + { + "name": "API", + "description": "Universal [API](https://sdk.foxy.dev/classes/_core_index_.api.html) client\nthat dispatches cancellable `FetchEvent` on an element before each request.", + "type": "typeof API", + "default": "\"API\"" + }, + { + "name": "lang", + "attribute": "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.", + "type": "string", + "default": "\"\"" + }, + { + "name": "parent", + "attribute": "parent", + "description": "Optional URL of the collection this element's resource belongs to.\nChanging the `parent` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "related", + "attribute": "related", + "description": "Optional URI list of the related resources. If Rumour encounters a related\nresource on creation or deletion, it will be reloaded from source.", + "type": "array", + "default": "[]" + }, + { + "name": "virtualHost", + "attribute": "virtual-host", + "description": "Unique identifier for the virtual host used by the element to serve internal requests.\n\nCurrently only one endpoint is supported: `foxy:///form/`.\nThis endpoint supports POST, GET, PATCH and DELETE methods and functions like a hAPI server.\n\nFor example, `GET foxy://nucleon-123/form/subscriptions/allowNextDateModification/0` on a NucleonElement\nwith `fx:customer_portal_settings` will return the first item of the `subscriptions.allowNextDateModification` array.", + "default": "\"uniqueId('nucleon-')\"" + }, + { + "name": "failure", + "description": "If network request returns non-2XX code, the entire error response\nwill be available via this getter.\n\nThis property is readonly. Changing failure records via this property is\nnot guaranteed to work. NucleonElement does not provide a way to override error status.", + "type": "Response | null" + }, + { + "name": "errors", + "description": "Array of validation errors returned from `NucleonElement.v8n` checks.\n\nThis property is readonly. Adding or removing error codes via this property is\nnot guaranteed to work. NucleonElement does not provide a way to override validity status.", + "type": "string[]" + }, + { + "name": "form", + "description": "Resource snapshot with edits applied. Empty object if unavailable.\n\nThis property and its value are readonly. Assignments like `element.data.foo = 'bar'`\nare not guaranteed to work. Please use `element.edit({ foo: 'bar' })` instead.\nIf you need to replace the entire data object, consider using `element.data`.", + "type": "Partial" + }, + { + "name": "data", + "description": "Resource snapshot as-is, no edits applied. Null if unavailable.\n\nReturned value is not reactive. Assignments like `element.data.foo = 'bar'`\nare not guaranteed to work. Please set the property instead: `element.data = { ...element.data, foo: 'bar' }`.\nIf you're processing user input, consider using `element.form` and `element.edit()` instead.", + "type": "TData | null" + }, + { + "name": "group", + "attribute": "group", + "description": "Rumour group. Elements in different groups will not share updates. Empty by default.", + "type": "string" + }, + { + "name": "href", + "attribute": "href", + "description": "Optional URL of the resource to load. Switches element to `idle.template` state if empty (default).", + "type": "string" + }, + { + "name": "infer", + "attribute": "infer", + "description": "Set a name for this element here to enable property inference. Set to `null` to disable.", + "type": "string" + } + ], "events": [ { "name": "update", @@ -9368,6 +9673,12 @@ "name": "foxy-customer-portal", "path": "./src/elements/public/CustomerPortal/index.ts", "attributes": [ + { + "name": "skip-password-reset", + "description": "When set to true, portal won't display Password Reset screen if customer logs in with a temporary password.", + "type": "boolean", + "default": "false" + }, { "name": "embed-url", "description": "URL of the Payment Card Embed for updating payment method.\nWhen set, the payment method will be editable. Otherwise, the customers\nwill only be able to view and delete it.\n\n```html\n\n\n```" @@ -9452,6 +9763,13 @@ "description": "Same as `.columns` property on `foxy-transactions-table`. Sets columns of that table.", "default": "[\"priceColumn\",\"summaryColumn\",\"statusColumn\",\"idColumn\",\"dateColumn\",\"receiptColumn\"]" }, + { + "name": "skipPasswordReset", + "attribute": "skip-password-reset", + "description": "When set to true, portal won't display Password Reset screen if customer logs in with a temporary password.", + "type": "boolean", + "default": "false" + }, { "name": "embedUrl", "attribute": "embed-url", diff --git a/src/elements/public/CustomerPortal/CustomerPortal.test.ts b/src/elements/public/CustomerPortal/CustomerPortal.test.ts index 7b0049e0..baa99bb3 100644 --- a/src/elements/public/CustomerPortal/CustomerPortal.test.ts +++ b/src/elements/public/CustomerPortal/CustomerPortal.test.ts @@ -8,12 +8,42 @@ import { CustomerPortal } from './CustomerPortal'; import { TransactionsTable } from '../TransactionsTable/TransactionsTable'; import { InternalCustomerPortalLoggedInView } from './InternalCustomerPortalLoggedInView'; import { InternalCustomerPortalLoggedOutView } from './InternalCustomerPortalLoggedOutView'; +import { InternalCustomerPortalPasswordResetView } from './InternalCustomerPortalPasswordResetView'; describe('CustomerPortal', () => { + before(() => localStorage.clear()); + it('extends CustomerApi', () => { expect(new CustomerPortal()).to.be.instanceOf(CustomerApi); }); + it('imports and defines dependencies', () => { + expect(customElements.get('iron-icon')).to.exist; + expect(customElements.get('vaadin-button')).to.exist; + expect(customElements.get('foxy-internal-password-control')).to.exist; + expect(customElements.get('foxy-internal-sandbox')).to.exist; + expect(customElements.get('foxy-internal-form')).to.exist; + expect(customElements.get('foxy-access-recovery-form')).to.exist; + expect(customElements.get('foxy-payment-method-card')).to.exist; + expect(customElements.get('foxy-transactions-table')).to.exist; + expect(customElements.get('foxy-subscription-card')).to.exist; + expect(customElements.get('foxy-subscription-form')).to.exist; + expect(customElements.get('foxy-collection-pages')).to.exist; + expect(customElements.get('foxy-collection-page')).to.exist; + expect(customElements.get('foxy-customer-form')).to.exist; + expect(customElements.get('foxy-sign-in-form')).to.exist; + expect(customElements.get('foxy-form-dialog')).to.exist; + expect(customElements.get('foxy-spinner')).to.exist; + expect(customElements.get('foxy-i18n')).to.exist; + expect(customElements.get('foxy-customer')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-logged-in-view')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-logged-out-view')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-password-reset-view')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-subscriptions')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-transactions')).to.exist; + expect(customElements.get('foxy-internal-customer-portal-link')).to.exist; + }); + it('registers as foxy-customer-portal', () => { expect(customElements.get('foxy-customer-portal')).to.equal(CustomerPortal); }); @@ -84,7 +114,15 @@ describe('CustomerPortal', () => { }); it('renders foxy-internal-customer-portal-logged-in-view when logged in', async () => { - localStorage.setItem(API.SESSION, 'session-stub'); + localStorage.setItem( + API.SESSION, + JSON.stringify({ + force_password_reset: false, + session_token: `dasjhf348tuhrgskjfhw48ourowi4rshajdhf`, + expires_in: 2419200, + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U', + }) + ); const transactionsTableColumns = [TransactionsTable.idColumn]; @@ -110,4 +148,30 @@ describe('CustomerPortal', () => { localStorage.clear(); }); + + it('renders foxy-internal-customer-portal-password-reset-view when logged in with temporary password', async () => { + localStorage.setItem( + API.SESSION, + JSON.stringify({ + force_password_reset: true, + session_token: `dasjhf348tuhrgskjfhw48ourowi4rshajdhf`, + expires_in: 2419200, + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U', + }) + ); + + const layout = html` + + `; + + const element = await fixture(layout); + const view = element.renderRoot.firstElementChild as InternalCustomerPortalPasswordResetView; + + expect(view).to.be.instanceOf(InternalCustomerPortalPasswordResetView); + expect(view).to.have.property('localName', 'foxy-internal-customer-portal-password-reset-view'); + expect(view).to.have.attribute('href', 'https://demo.api/portal/'); + expect(view).to.have.attribute('infer', 'password-reset'); + + localStorage.clear(); + }); }); diff --git a/src/elements/public/CustomerPortal/CustomerPortal.ts b/src/elements/public/CustomerPortal/CustomerPortal.ts index 03a0ab19..9be93a0a 100644 --- a/src/elements/public/CustomerPortal/CustomerPortal.ts +++ b/src/elements/public/CustomerPortal/CustomerPortal.ts @@ -5,6 +5,7 @@ import { CustomerApi } from '../CustomerApi/CustomerApi'; import { ThemeableMixin } from '../../../mixins/themeable'; import { TranslatableMixin } from '../../../mixins/translatable'; import { TransactionsTable } from '../TransactionsTable/TransactionsTable'; +import { UpdateEvent } from '../NucleonElement/UpdateEvent'; import { ifDefined } from 'lit-html/directives/if-defined'; export class CustomerPortal extends TranslatableMixin( @@ -15,6 +16,7 @@ export class CustomerPortal extends TranslatableMixin( return { ...super.properties, transactionsTableColumns: { attribute: false }, + skipPasswordReset: { type: Boolean, attribute: 'skip-password-reset' }, embedUrl: { attribute: 'embed-url' }, group: { type: String }, }; @@ -30,6 +32,11 @@ export class CustomerPortal extends TranslatableMixin( TransactionsTable.receiptColumn, ]; + #temporaryPassword: string | null = null; + + /** When set to true, portal won't display Password Reset screen if customer logs in with a temporary password. */ + skipPasswordReset = false; + /** * URL of the Payment Card Embed for updating payment method. * When set, the payment method will be editable. Otherwise, the customers @@ -58,24 +65,51 @@ export class CustomerPortal extends TranslatableMixin( } return this.api.storage.getItem(API.SESSION) - ? html` - - - ` + ? !this.skipPasswordReset && this.api.usesTemporaryPassword + ? html` + { + if (evt.detail?.result === UpdateEvent.UpdateResult.ResourceUpdated) { + this.api.usesTemporaryPassword = false; + this.#temporaryPassword = null; + this.requestUpdate(); + } + }} + > + + ` + : html` + + + ` : html` ) => { + this.#temporaryPassword = evt.detail; + }} > `; } + + updated(changedProperties: Map): void { + super.updated(changedProperties); + const isLoggedIn = this.api.storage.getItem(API.SESSION) !== null; + if (isLoggedIn && (this.skipPasswordReset || !this.api.usesTemporaryPassword)) { + this.#temporaryPassword = null; + } + } } diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts index e409f530..81b7ef49 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts @@ -247,7 +247,12 @@ export class InternalCustomerPortalLoggedOutView extends Base { id="sign-in-form" ns="${this.ns} ${customElements.get('foxy-sign-in-form')?.defaultNS ?? ''}" .templates=${this.getNestedTemplates('sign-in:form')} - @update=${() => this.requestUpdate()} + @update=${(evt: CustomEvent) => { + const target = evt.currentTarget as SignInForm; + const password = target.form.credential?.password ?? null; + this.dispatchEvent(new CustomEvent('password', { detail: password })); + this.requestUpdate(); + }} > diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.test.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.test.ts new file mode 100644 index 00000000..0e638c2f --- /dev/null +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.test.ts @@ -0,0 +1,102 @@ +import type { InternalPasswordControl } from '../../internal/InternalPasswordControl/InternalPasswordControl'; + +import './index'; + +import { InternalCustomerPortalPasswordResetView as View } from './InternalCustomerPortalPasswordResetView'; +import { expect, fixture, html } from '@open-wc/testing'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { stub } from 'sinon'; + +describe('InternalCustomerPortalPasswordResetView', () => { + it('extends InternalForm', () => { + expect(new View()).to.be.instanceOf(InternalForm); + }); + + it('has a reactive property "passwordOld"', () => { + expect(new View()).to.have.property('passwordOld', null); + expect(View.properties).to.have.deep.property('passwordOld', { attribute: 'password-old' }); + }); + + it('produces "password:v8n_required" error when password is empty', () => { + const view = new View(); + expect(view.errors).to.include('password:v8n_required'); + }); + + it('produces "password:v8n_too_long" error when password is longer than 50 characters', () => { + const view = new View(); + expect(view.errors).to.not.include('password:v8n_too_long'); + view.edit({ password: 'a'.repeat(50) }); + expect(view.errors).to.not.include('password:v8n_too_long'); + view.edit({ password: 'a'.repeat(51) }); + expect(view.errors).to.include('password:v8n_too_long'); + }); + + it('produces "password:v8n_too_weak" error when password is too weak', () => { + const view = new View(); + expect(view.errors).to.not.include('password:v8n_too_weak'); + view.edit({ password: 'a' }); + expect(view.errors).to.include('password:v8n_too_weak'); + view.edit({ password: 'a'.repeat(10) }); + expect(view.errors).to.include('password:v8n_too_weak'); + view.edit({ password: 'abc34a-Yl18na-prl1Hb' }); + expect(view.errors).to.not.include('password:v8n_too_weak'); + }); + + it('renders translatable title', async () => { + const view = await fixture(html` + + `); + + const i18n = view.renderRoot.querySelector('foxy-i18n[key="title"]')!; + expect(i18n).to.exist; + expect(i18n).to.have.attribute('infer', ''); + }); + + it('renders translatable subtitle', async () => { + const view = await fixture(html` + + `); + + const i18n = view.renderRoot.querySelector('foxy-i18n[key="subtitle"]')!; + expect(i18n).to.exist; + expect(i18n).to.have.attribute('infer', ''); + }); + + it('renders password control for new password', async () => { + const view = await fixture(html` + + `); + + const control = view.renderRoot.querySelector( + 'foxy-internal-password-control' + )!; + + expect(control).to.exist; + expect(control).to.have.attribute('infer', 'password'); + expect(control).to.have.attribute('show-generator'); + expect(control).to.have.property('generatorOptions'); + + expect(control.generatorOptions?.checkStrength?.('a')).to.be.false; + expect(control.generatorOptions?.checkStrength?.('a'.repeat(10))).to.be.false; + expect(control.generatorOptions?.checkStrength?.('abc34a-Yl18na-prl1Hb')).to.be.true; + }); + + it('renders submit button', async () => { + const view = await fixture(html` + + `); + + const label = view.renderRoot.querySelector('foxy-i18n[infer=""][key="submit"]')!; + const button = label.closest('vaadin-button')!; + expect(button).to.exist; + + const submitMethod = stub(view, 'submit'); + button.click(); + expect(submitMethod).to.have.been.called; + + expect(button).to.not.have.attribute('disabled'); + view.disabled = true; + await view.requestUpdate(); + expect(button).to.have.attribute('disabled'); + }); +}); diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.ts new file mode 100644 index 00000000..d3a32258 --- /dev/null +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalPasswordResetView.ts @@ -0,0 +1,92 @@ +import type { CSSResultArray, PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { GeneratorOptions } from '../../internal/InternalPasswordControl/generateRandomPassword'; +import type { NucleonV8N } from '../NucleonElement/types'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Graph } from '@foxy.io/sdk/customer'; + +import { TranslatableMixin } from '../../../mixins/translatable'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { html, css } from 'lit-element'; + +import checkPasswordStrength from 'check-password-strength'; +const passwordStrength = checkPasswordStrength.passwordStrength; + +type Data = Resource< + Graph & { + props: { + /** When updating the password using Customer API, these values are required to complete the request. */ + password_old?: string; + password?: string; + }; + } +>; + +export class InternalCustomerPortalPasswordResetView extends TranslatableMixin(InternalForm) { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + passwordOld: { attribute: 'password-old' }, + }; + } + + static get styles(): CSSResultArray { + return [ + super.styles, + css` + .max-w-25rem { + max-width: 25rem; + } + `, + ]; + } + + static get v8n(): NucleonV8N { + return [ + ({ password: v }) => !!v || 'password:v8n_required', + ({ password: v }) => !v || v.length <= 50 || 'password:v8n_too_long', + ({ password: v }) => !v || passwordStrength(v).id >= 2 || 'password:v8n_too_weak', + ]; + } + + passwordOld: string | null = null; + + private readonly __generatorOptions: GeneratorOptions = { + checkStrength: value => passwordStrength(value).id >= 2, + }; + + renderBody(): TemplateResult { + return html` +
+

+ +

+ +

+ +

+ + + + + this.submit()} + > + + +
+ `; + } + + submit(): void { + this.edit({ password_old: this.passwordOld ?? '' }); + super.submit(); + } +} diff --git a/src/elements/public/CustomerPortal/index.ts b/src/elements/public/CustomerPortal/index.ts index 6a16a471..555d10cc 100644 --- a/src/elements/public/CustomerPortal/index.ts +++ b/src/elements/public/CustomerPortal/index.ts @@ -2,6 +2,7 @@ import '@polymer/iron-icons/editor-icons'; import '@vaadin/vaadin-button'; import '@polymer/iron-icons'; import '@polymer/iron-icon'; +import '../../internal/InternalPasswordControl/index'; import '../../internal/InternalSandbox/index'; import '../../internal/InternalForm/index'; import '../AccessRecoveryForm/index'; @@ -15,6 +16,7 @@ import '../CustomerForm/index'; import '../SignInForm/index'; import '../FormDialog/index'; import '../Spinner/index'; +import '../I18n/index'; import '../Customer/index'; import { CustomerPortal } from './CustomerPortal'; @@ -23,6 +25,7 @@ import { InternalCustomerPortalLoggedInView } from './InternalCustomerPortalLogg import { InternalCustomerPortalLoggedOutView } from './InternalCustomerPortalLoggedOutView'; import { InternalCustomerPortalSubscriptions } from './InternalCustomerPortalSubscriptions'; import { InternalCustomerPortalTransactions } from './InternalCustomerPortalTransactions'; +import { InternalCustomerPortalPasswordResetView } from './InternalCustomerPortalPasswordResetView'; customElements.define( 'foxy-internal-customer-portal-logged-in-view', @@ -34,6 +37,11 @@ customElements.define( InternalCustomerPortalLoggedOutView ); +customElements.define( + 'foxy-internal-customer-portal-password-reset-view', + InternalCustomerPortalPasswordResetView +); + customElements.define( 'foxy-internal-customer-portal-subscriptions', InternalCustomerPortalSubscriptions diff --git a/src/server/portal/index.ts b/src/server/portal/index.ts index e6bd075e..ec0a554b 100644 --- a/src/server/portal/index.ts +++ b/src/server/portal/index.ts @@ -74,6 +74,7 @@ export function createRouter(): Router { if (customer) { body = JSON.stringify({ + force_password_reset: false, session_token: `${customer.id}-${Date.now() + 2419200 * 1000}`, expires_in: 2419200, jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U', diff --git a/src/static/translations/customer-portal/de.json b/src/static/translations/customer-portal/de.json index c9e85436..0d42b12e 100644 --- a/src/static/translations/customer-portal/de.json +++ b/src/static/translations/customer-portal/de.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Passwort zurücksetzen", + "subtitle": "Bitte aktualisieren Sie jetzt Ihr Passwort, um Ihr Konto zu sichern.", + "submit": "Weiter", + "password": { + "label": "Neues Passwort", + "placeholder": "Erforderlich", + "helper_text": "Klicken Sie auf ✨, um ein sicheres Zufallspasswort für dieses Konto zu generieren.", + "v8n_too_long": "Bitte verwenden Sie ein Passwort, das nicht länger als 50 Zeichen ist.", + "v8n_too_weak": "Bitte verwenden Sie ein stärkeres Passwort. Um dieses Passwort stärker zu machen, sollte es mindestens 8 Zeichen lang sein und einige Groß- und Kleinbuchstaben, Zahlen und Sonderzeichen enthalten.", + "v8n_required": "Bitte füllen Sie dieses Feld aus." + }, + "spinner": { + "refresh": "Aktualisierung", + "loading_busy": "Wird geladen", + "loading_error": "Unbekannter Fehler" + } + }, "access-recovery-form": { "back": "Zurückkehren", "email": "Email", diff --git a/src/static/translations/customer-portal/en.json b/src/static/translations/customer-portal/en.json index f4404b16..e7078a61 100644 --- a/src/static/translations/customer-portal/en.json +++ b/src/static/translations/customer-portal/en.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Password reset", + "subtitle": "Please update your password now to keep your account secure.", + "submit": "Continue", + "password": { + "label": "New password", + "placeholder": "Required", + "helper_text": "Click ✨ to generate a secure random password for this account.", + "v8n_too_long": "Please use a password that is no longer than 50 characters.", + "v8n_too_weak": "Please use a stronger password. To make this password stronger, make it at least 8 characters long and include a few upper and lower case letters, numbers, and special characters.", + "v8n_required": "Please fill out this field." + }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } + }, "access-recovery-form": { "back": "Go back", "email": "Email", diff --git a/src/static/translations/customer-portal/es.json b/src/static/translations/customer-portal/es.json index e5ecdd32..aedeaef7 100644 --- a/src/static/translations/customer-portal/es.json +++ b/src/static/translations/customer-portal/es.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Restablecimiento de contraseña", + "subtitle": "Por favor, actualice su contraseña ahora para mantener su cuenta segura.", + "submit": "Continuar", + "password": { + "label": "Nueva contraseña", + "placeholder": "Requerido", + "helper_text": "Haga clic en ✨ para generar una contraseña segura aleatoria para esta cuenta.", + "v8n_too_long": "Por favor, use una contraseña que no tenga más de 50 caracteres.", + "v8n_too_weak": "Por favor, use una contraseña más segura. Para hacer esta contraseña más segura, hágala de al menos 8 caracteres e incluya algunas letras mayúsculas y minúsculas, números y caracteres especiales.", + "v8n_required": "Por favor, rellene este campo." + }, + "spinner": { + "refresh": "Actualizar", + "loading_busy": "Cargando", + "loading_error": "Error desconocido" + } + }, "access-recovery-form": { "back": "Volver", "email": "Correo electrónico", diff --git a/src/static/translations/customer-portal/fr.json b/src/static/translations/customer-portal/fr.json index 7a7b035d..37a2ceaa 100644 --- a/src/static/translations/customer-portal/fr.json +++ b/src/static/translations/customer-portal/fr.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Réinitialisation du mot de passe", + "subtitle": "Veuillez mettre à jour votre mot de passe maintenant pour sécuriser votre compte.", + "submit": "Continuer", + "password": { + "label": "Nouveau mot de passe", + "placeholder": "Requis", + "helper_text": "Cliquez sur ✨ pour générer un mot de passe aléatoire sécurisé pour ce compte.", + "v8n_too_long": "Veuillez utiliser un mot de passe ne dépassant pas 50 caractères.", + "v8n_too_weak": "Veuillez utiliser un mot de passe plus fort. Pour renforcer ce mot de passe, faites-le d'au moins 8 caractères et incluez quelques lettres majuscules et minuscules, des chiffres et des caractères spéciaux.", + "v8n_required": "Merci de remplir ce champ." + }, + "spinner": { + "refresh": "Rafraîchir", + "loading_busy": "Chargement", + "loading_error": "Erreur inconnue" + } + }, "access-recovery-form": { "back": "Retourner", "email": "E-mail", diff --git a/src/static/translations/customer-portal/nl.json b/src/static/translations/customer-portal/nl.json index 7bdb427d..6500c7bd 100644 --- a/src/static/translations/customer-portal/nl.json +++ b/src/static/translations/customer-portal/nl.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Wachtwoord resetten", + "subtitle": "Werk nu uw wachtwoord bij om uw account veilig te houden.", + "submit": "Doorgaan", + "password": { + "label": "Nieuw wachtwoord", + "placeholder": "Vereist", + "helper_text": "Klik op ✨ om een ​​veilig willekeurig wachtwoord voor dit account te genereren.", + "v8n_too_long": "Gebruik een wachtwoord dat niet langer is dan 50 tekens.", + "v8n_too_weak": "Gebruik een sterker wachtwoord. Maak dit wachtwoord sterker door het minstens 8 tekens lang te maken en een paar hoofdletters en kleine letters, cijfers en speciale tekens op te nemen.", + "v8n_required": "Vul alstublieft dit veld in." + }, + "spinner": { + "refresh": "Vernieuwen", + "loading_busy": "Bezig met laden", + "loading_error": "Onbekende fout" + } + }, "access-recovery-form": { "back": "Ga terug", "email": "E-mail", diff --git a/src/static/translations/customer-portal/pl.json b/src/static/translations/customer-portal/pl.json index 625c0a86..57b7afb7 100644 --- a/src/static/translations/customer-portal/pl.json +++ b/src/static/translations/customer-portal/pl.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Resetowanie hasła", + "subtitle": "Proszę zaktualizować swoje hasło, aby zabezpieczyć konto.", + "submit": "Kontynuuj", + "password": { + "label": "Nowe hasło", + "placeholder": "Wymagany", + "helper_text": "Kliknij ✨, aby wygenerować bezpieczne losowe hasło dla tego konta.", + "v8n_too_long": "Proszę używać hasła nie dłuższego niż 50 znaków.", + "v8n_too_weak": "Proszę użyć silniejszego hasła. Aby wzmocnić to hasło, powinno mieć co najmniej 8 znaków i zawierać kilka wielkich i małych liter, cyfr oraz znaków specjalnych.", + "v8n_required": "Proszę wypełnić to pole." + }, + "spinner": { + "refresh": "Odświeżać", + "loading_busy": "Ładowanie", + "loading_error": "Nieznany błąd" + } + }, "access-recovery-form": { "back": "Wróć", "email": "E-mail", diff --git a/src/static/translations/customer-portal/sv.json b/src/static/translations/customer-portal/sv.json index eda78c21..451a066a 100644 --- a/src/static/translations/customer-portal/sv.json +++ b/src/static/translations/customer-portal/sv.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "Återställ lösenord", + "subtitle": "Uppdatera ditt lösenord nu för att hålla ditt konto säkert.", + "submit": "Fortsätt", + "password": { + "label": "Nytt lösenord", + "placeholder": "Nödvändig", + "helper_text": "Klicka på ✨ för att generera ett säkert slumpmässigt lösenord för detta konto.", + "v8n_too_long": "Använd ett lösenord som inte är längre än 50 tecken.", + "v8n_too_weak": "Använd ett starkare lösenord. För att göra detta lösenord starkare, gör det minst 8 tecken långt och inkludera några stora och små bokstäver, siffror och specialtecken.", + "v8n_required": "Var vänlig fyll i det här fältet." + }, + "spinner": { + "refresh": "Uppdatera", + "loading_busy": "Läser in", + "loading_error": "Okänt fel" + } + }, "access-recovery-form": { "back": "Gå tillbaka", "email": "E-post", diff --git a/src/static/translations/customer-portal/zh-hk.json b/src/static/translations/customer-portal/zh-hk.json index 9f864e5f..1d8346e8 100644 --- a/src/static/translations/customer-portal/zh-hk.json +++ b/src/static/translations/customer-portal/zh-hk.json @@ -1,4 +1,22 @@ { + "password-reset": { + "title": "重置密码", + "subtitle": "请立即更新您的密码以确保帐户安全。", + "submit": "继续", + "password": { + "label": "新密码", + "placeholder": "必需的", + "helper_text": "点击 ✨ 为此帐户生成一个安全的随机密码。", + "v8n_too_long": "请使用不超过 50 个字符的密码。", + "v8n_too_weak": "请使用更强的密码。为了使此密码更强,请使其至少 8 个字符长,并包含一些大写和小写字母、数字和特殊字符。", + "v8n_required": "请填写此字段。" + }, + "spinner": { + "refresh": "刷新", + "loading_busy": "加载中", + "loading_error": "未知错误" + } + }, "access-recovery-form": { "back": "回去", "email": "电子邮件", From c7da0deb0db87792a4afafea52ee9462346b3ac9 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 14 Jan 2025 14:27:09 -0300 Subject: [PATCH 17/18] test(foxy-subscription-settings-form): fix tests --- .../SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts index 1a3ff283..7c9f2c72 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts @@ -441,7 +441,7 @@ describe('SubscriptionSettingsForm', () => { await waitUntil(() => !!element.data, '', { timeout: 5000 }); const control = element.renderRoot.querySelector( - '[infer="emails-group"] [infer="send-email-receipts-for-automated-billing"]' + '[infer="past-due-amount-group"] [infer="send-email-receipts-for-automated-billing"]' ); expect(control).to.be.instanceOf(InternalSwitchControl); From d86716a06b8c6c76fdebb62e26d530bd2f606646 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 14 Jan 2025 14:29:25 -0300 Subject: [PATCH 18/18] test(foxy-user-invitation-form): fix tests --- .../public/UserInvitationForm/UserInvitationForm.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts index 6deb6712..c9740625 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts @@ -589,6 +589,7 @@ describe('UserInvitationForm', () => { expect(form.renderRoot.querySelector('[infer="store"] [key="store_link"]')).to.not.exist; form.getStorePageHref = (href: string) => `https://example.com?href=${href}`; + form.data = { ...form.data!, status: 'accepted' }; await form.requestUpdate(); const caption = form.renderRoot.querySelector('[infer="store"] [key="store_link"]'); expect(caption).to.exist;