diff --git a/custom-elements.json b/custom-elements.json index d760d23f4..56e62ef88 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -12481,6 +12481,338 @@ } ] }, + { + "name": "foxy-experimental-add-to-cart-builder", + "path": "./src/elements/public/ExperimentalAddToCartBuilder/index.ts", + "description": "WARNING: this element is marked as experimental and is subject to change in future releases.\nWe will not be maintaining backwards compatibility for elements in the experimental namespace.\nIf you are using this element, please make sure to use a fixed version of the package in your `package.json`.\n\nThis element allows you to create an add-to-cart form and link for your store.", + "attributes": [ + { + "name": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "encode-helper", + "description": "URL of the HMAC encoder endpoint." + }, + { + "name": "locale-codes", + "description": "URL of the `fx:locale_codes` property helper. This will be used to determine the currency code." + }, + { + "name": "store", + "description": "URL of the store this add-to-cart code is created for." + }, + { + "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": "defaultDomain", + "attribute": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "encodeHelper", + "attribute": "encode-helper", + "description": "URL of the HMAC encoder endpoint." + }, + { + "name": "localeCodes", + "attribute": "locale-codes", + "description": "URL of the `fx:locale_codes` property helper. This will be used to determine the currency code." + }, + { + "name": "store", + "attribute": "store", + "description": "URL of the store this add-to-cart code is created for." + }, + { + "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", + "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-filter-attribute-card", "path": "./src/elements/public/FilterAttributeCard/index.ts", diff --git a/package-lock.json b/package-lock.json index 5787c6538..608614eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,9 @@ "@vaadin/vaadin": "^14.8.5", "check-password-strength": "^2.0.7", "cookie-storage": "^6.1.0", - "dedent": "^0.7.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", @@ -46,6 +47,7 @@ "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-typescript": "^8.2.0", "@stylelint/postcss-css-in-js": "^0.37.3", + "@types/dedent": "^0.7.2", "@types/lodash-es": "^4.17.3", "@types/mocha": "^8.0.3", "@types/node": "^14.6.3", @@ -74,7 +76,6 @@ "eslint-plugin-html": "^6.1.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-sort-class-members": "^1.8.0", - "highlight.js": "^10.7.2", "husky": "^4.2.5", "lint-staged": "^10.3.0", "lit-html": "^1.3.0", @@ -3972,6 +3973,12 @@ "integrity": "sha512-ow0L7we5RXNQocEO9LNBRJCk/ecBc8M0aTg0DLrlg1nsnKAcjvFmYFUbsxujlrbngRslmKIA4mKoOxIJdUElhw==", "dev": true }, + "node_modules/@types/dedent": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@types/dedent/-/dedent-0.7.2.tgz", + "integrity": "sha512-kRiitIeUg1mPV9yH4VUJ/1uk2XjyANfeL8/7rH1tsjvHeO9PJLBHJIYsFWmAvmGj5u8rj+1TZx7PZzW2qLw3Lw==", + "dev": true + }, "node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -8149,9 +8156,17 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } }, "node_modules/deep-eql": { "version": "4.1.3", @@ -10614,7 +10629,6 @@ "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, "engines": { "node": "*" } @@ -12437,6 +12451,12 @@ "node": ">= 6" } }, + "node_modules/lint-staged/node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, "node_modules/lint-staged/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 41e60118b..931119726 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@vaadin/vaadin": "^14.8.5", "check-password-strength": "^2.0.7", "cookie-storage": "^6.1.0", - "dedent": "^0.7.0", + "dedent": "^1.5.3", "email-validator": "^2.0.4", "html-entities": "^2.4.0", "i18next": "^19.7.0", @@ -49,6 +49,7 @@ "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": { @@ -64,6 +65,7 @@ "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-typescript": "^8.2.0", "@stylelint/postcss-css-in-js": "^0.37.3", + "@types/dedent": "^0.7.2", "@types/lodash-es": "^4.17.3", "@types/mocha": "^8.0.3", "@types/node": "^14.6.3", @@ -92,7 +94,6 @@ "eslint-plugin-html": "^6.1.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-sort-class-members": "^1.8.0", - "highlight.js": "^10.7.2", "husky": "^4.2.5", "lint-staged": "^10.3.0", "lit-html": "^1.3.0", @@ -159,4 +160,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 88189ece0..2df5cb7d7 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -1,5 +1,6 @@ declare module '@vaadin/vaadin-overlay/src/vaadin-overlay-position-mixin'; - +declare module 'highlight.js/lib/languages/xml.js'; +declare module 'highlight.js/lib/core.js'; declare module 'uainfer/src/uainfer.js' { function analyze(ua: string): { toString: () => string }; function scan(ua: string): { name: string; version: string }[]; diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts index 9c570dba8..3f5b74708 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts @@ -45,12 +45,28 @@ export class InternalEditableControl extends InternalControl { }; setValue = (newValue: unknown): void => { - if (this.jsonPath) { - const json = JSON.parse(this.nucleon?.form[this.property] ?? this.jsonTemplate); - set(json, this.jsonPath, newValue); - this.nucleon?.edit({ [this.property]: JSON.stringify(json) }); + const [formProperty, ...nestedPath] = this.property.split('.'); + + if (nestedPath.length) { + const nestedForm = this.nucleon?.form[formProperty] ?? {}; + + if (this.jsonPath) { + const json = JSON.parse(this.nucleon?.form[formProperty] ?? this.jsonTemplate); + set(json, this.jsonPath, newValue); + set(nestedForm, nestedPath, JSON.stringify(json)); + } else { + set(nestedForm, nestedPath, newValue); + } + + this.nucleon?.edit({ [formProperty]: nestedForm }); } else { - this.nucleon?.edit({ [this.property]: newValue }); + if (this.jsonPath) { + const json = JSON.parse(this.nucleon?.form[formProperty] ?? this.jsonTemplate); + set(json, this.jsonPath, newValue); + this.nucleon?.edit({ [formProperty]: JSON.stringify(json) }); + } else { + this.nucleon?.edit({ [formProperty]: newValue }); + } } }; diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts index aa6bc619b..fccdfb399 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts @@ -20,11 +20,21 @@ describe('InternalSummaryControl', () => { expect(new Control()).to.be.instanceOf(customElements.get('foxy-internal-editable-control')); }); + it('has a reactive property "layout" that defaults to null', () => { + expect(Control).to.have.deep.nested.property('properties.layout', {}); + expect(new Control()).to.have.property('layout', null); + }); + + it('has a reactive property "open" that defaults to false', () => { + expect(Control).to.have.deep.nested.property('properties.open', { type: Boolean }); + expect(new Control()).to.have.property('open', false); + }); + it('renders nothing in light DOM', () => { expect(new Control().renderLightDom()).to.be.undefined; }); - it('renders label', async () => { + it('renders label in default layout', async () => { const control = await fixture(html` `); @@ -38,7 +48,7 @@ describe('InternalSummaryControl', () => { expect(control.renderRoot).to.include.text('Foo bar'); }); - it('renders helper text', async () => { + it('renders helper text in default layout', async () => { const control = await fixture(html` `); @@ -52,11 +62,100 @@ describe('InternalSummaryControl', () => { expect(control.renderRoot).to.include.text('Test helper text'); }); - it('renders default slot', async () => { + it('renders default slot in default layout', async () => { const control = await fixture(html` `); expect(control.renderRoot).to.include.html(''); }); + + it('renders details/summary in "details" layout', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + const summary = control.renderRoot.querySelector('details > summary')!; + + expect(details).to.exist; + expect(summary).to.exist; + expect(details.open).to.be.false; + + control.open = true; + await control.requestUpdate(); + expect(details.open).to.be.true; + }); + + it('renders label inside of the details summary in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('summary'); + expect(summary).to.include.text('label'); + + control.label = 'Foo bar'; + await control.requestUpdate(); + + expect(summary).to.not.include.text('label'); + expect(summary).to.include.text('Foo bar'); + }); + + it('renders helper text inside of the details summary in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('summary'); + expect(summary).to.include.text('helper_text'); + + control.helperText = 'Test helper text'; + await control.requestUpdate(); + + expect(summary).to.not.include.text('helper_text'); + expect(summary).to.include.text('Test helper text'); + }); + + it('renders default slot inside of the details content in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('details'); + expect(summary).to.include.html(''); + }); + + it('toggles open state when details is toggled', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + expect(control.open).to.be.false; + + details.open = true; + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(control.open).to.be.true; + + details.open = false; + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(control.open).to.be.false; + }); + + it('dispatches "toggle" event when details is toggled', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + let eventCount = 0; + + control.addEventListener('toggle', () => eventCount++); + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(eventCount).to.equal(1); + }); }); diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts index 58657185d..c2ee5c58f 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts @@ -1,27 +1,86 @@ -import type { CSSResultArray } from 'lit-element'; -import type { TemplateResult } from 'lit-html'; +import type { CSSResultArray, PropertyDeclarations } from 'lit-element'; +import { svg, TemplateResult } from 'lit-html'; import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; import { html, css } from 'lit-element'; export class InternalSummaryControl extends InternalEditableControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + layout: {}, + open: { type: Boolean }, + }; + } + static get styles(): CSSResultArray { return [ ...super.styles, css` - ::slotted(*) { + :host(:not([layout='section'])) slot::slotted(*) { background-color: var(--lumo-contrast-5pct); padding: calc(0.625em + (var(--lumo-border-radius) / 4) - 1px); } + + details summary > div { + border-radius: var(--lumo-border-radius); + } + + details[open] summary > div { + border-radius: var(--lumo-border-radius) var(--lumo-border-radius) 0 0; + } `, ]; } + layout: null | 'section' | 'details' = null; + + open = false; + renderLightDom(): void { return; } renderControl(): TemplateResult { + if (this.layout === 'details') { + return html` +
{ + const details = evt.currentTarget as HTMLDetailsElement; + this.open = details.open; + if (!evt.composed) this.dispatchEvent(new CustomEvent('toggle')); + }} + > + +
+

+ ${this.label} + + ${svg``} + +

+

${this.helperText}

+
+
+
+ +
+
+ `; + } + return html`

${this.label}

diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts index 73d585672..cfaca3478 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts @@ -725,7 +725,7 @@ describe('InternalCustomerPortalLoggedOutView', () => { expect(form).to.have.attribute('disabledcontrols', 'abc:def'); expect(form).to.have.attribute( 'hiddencontrols', - 'tax-id is-anonymous password-old forgot-password timestamps delete qux' + 'header tax-id is-anonymous password-old forgot-password timestamps delete qux' ); expect(form).to.have.attribute('parent', 'foxy://customer-api/signup'); expect(form).to.have.attribute('group', 'test'); diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts index f860937c7..e409f530f 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts @@ -341,6 +341,7 @@ export class InternalCustomerPortalLoggedOutView extends Base { private readonly __renderSignUpForm = () => { const scope = 'sign-up:form'; const hidden = [ + 'header', 'tax-id', 'is-anonymous', 'password-old', diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts new file mode 100644 index 000000000..7fa4eb883 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts @@ -0,0 +1,28 @@ +import './index'; + +import { Summary } from '../../../storygen/Summary'; +import { getMeta } from '../../../storygen/getMeta'; +import { getStory } from '../../../storygen/getStory'; + +const summary: Summary = { + href: 'https://demo.api/hapi/experimental_add_to_cart_snippets/0', + parent: 'https://demo.api/hapi/experimental_add_to_cart_snippets', + nucleon: true, + localName: 'foxy-experimental-add-to-cart-builder', + translatable: true, + configurable: {}, +}; + +export default getMeta(summary); + +const ext = ` + default-domain="foxycart.com" + encode-helper="https://demo.api/virtual/encode" + locale-codes="https://demo.api/hapi/property_helpers/7" + store="https://demo.api/hapi/stores/0" +`; + +export const WithData = getStory({ ...summary, ext, code: true }); +export const Empty = getStory({ ...summary, ext }); + +Empty.args.href = ''; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.test.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.test.ts new file mode 100644 index 000000000..fe149a29d --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.test.ts @@ -0,0 +1,987 @@ +import type { ExperimentalAddToCartSnippet } from './types'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { FetchEvent } from '../NucleonElement/FetchEvent'; + +import './index'; + +import { ExperimentalAddToCartBuilder as Builder } from './ExperimentalAddToCartBuilder'; +import { expect, fixture, waitUntil } from '@open-wc/testing'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { createRouter } from '../../../server'; +import { getTestData } from '../../../testgen/getTestData'; +import { html } from 'lit-html'; + +import dedent from 'dedent'; + +async function waitForIdle(element: Builder) { + await waitUntil( + () => { + const loaders = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...loaders].every(loader => loader.in('idle')); + }, + '', + { timeout: 5000 } + ); +} + +describe('ExperimentalAddToCartBuilder', () => { + const OriginalResizeObserver = window.ResizeObserver; + + // @ts-expect-error disabling ResizeObserver because it errors in test env + before(() => (window.ResizeObserver = undefined)); + after(() => (window.ResizeObserver = OriginalResizeObserver)); + + it('imports and defines foxy-internal-resource-picker-control', () => { + expect(customElements.get('foxy-internal-resource-picker-control')).to.exist; + }); + + it('imports and defines foxy-internal-editable-list-control', () => { + expect(customElements.get('foxy-internal-editable-list-control')).to.exist; + }); + + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; + }); + + it('imports and defines foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; + }); + + it('imports and defines foxy-internal-select-control', () => { + expect(customElements.get('foxy-internal-select-control')).to.exist; + }); + + it('imports and defines foxy-internal-text-control', () => { + expect(customElements.get('foxy-internal-text-control')).to.exist; + }); + + it('imports and defines foxy-internal-form', () => { + expect(customElements.get('foxy-internal-form')).to.exist; + }); + + it('imports and defines foxy-template-set-card', () => { + expect(customElements.get('foxy-template-set-card')).to.exist; + }); + + it('imports and defines foxy-copy-to-clipboard', () => { + expect(customElements.get('foxy-copy-to-clipboard')).to.exist; + }); + + it('imports and defines foxy-nucleon', () => { + expect(customElements.get('foxy-nucleon')).to.exist; + }); + + it('imports and defines itself as foxy-experimental-add-to-cart-builder', () => { + expect(customElements.get('foxy-experimental-add-to-cart-builder')).to.equal(Builder); + }); + + it('extends InternalForm', () => { + expect(new Builder()).to.be.instanceOf(InternalForm); + }); + + it('has a default i18n namespace "experimental-add-to-cart-builder"', () => { + expect(Builder).to.have.property('defaultNS', 'experimental-add-to-cart-builder'); + expect(new Builder()).to.have.property('ns', 'experimental-add-to-cart-builder'); + }); + + it('has a reactive property/attribute "defaultDomain"', () => { + expect(Builder).to.have.nested.property('properties.defaultDomain'); + expect(Builder).to.have.nested.property('properties.defaultDomain.attribute', 'default-domain'); + + expect(new Builder()).to.have.property('defaultDomain', null); + }); + + it('has a reactive property/attribute "encodeHelper"', () => { + expect(Builder).to.have.nested.property('properties.encodeHelper'); + expect(Builder).to.have.nested.property('properties.encodeHelper.attribute', 'encode-helper'); + + expect(new Builder()).to.have.property('encodeHelper', null); + }); + + it('has a reactive property/attribute "localeCodes"', () => { + expect(Builder).to.have.nested.property('properties.localeCodes'); + expect(Builder).to.have.nested.property('properties.localeCodes.attribute', 'locale-codes'); + + expect(new Builder()).to.have.property('localeCodes', null); + }); + + it('has a reactive property/attribute "store"', () => { + expect(Builder).to.have.nested.property('properties.store'); + expect(new Builder()).to.have.property('store', null); + }); + + it('renders summary control for items', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="items"]' + ); + + expect(control).to.exist; + }); + + it('renders summary control for each one of the items inside of the items summary', async () => { + const element = await fixture( + html`` + ); + + element.edit({ + items: [ + { name: 'first item', price: 1, quantity: 1, custom_options: [] }, + { name: 'second item', price: 2, quantity: 2, custom_options: [] }, + ], + }); + + await element.requestUpdate(); + const items = element.renderRoot.querySelectorAll( + '[infer="items"] foxy-internal-summary-control[infer="item-group"]' + ); + + expect(items).to.have.length(2); + + expect(items[0]).to.have.attribute('layout', 'details'); + expect(items[0]).to.have.attribute('label', 'first item'); + expect(items[0]).to.have.attribute('open'); + + expect(items[1]).to.have.attribute('layout', 'details'); + expect(items[1]).to.have.attribute('label', 'second item'); + expect(items[1]).to.not.have.attribute('open'); + }); + + it('renders internal item control for each one of the items inside of the item summary', async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + element.edit({ + items: [ + { name: 'first item', price: 1, quantity: 1, custom_options: [] }, + { name: 'second item', price: 2, quantity: 2, custom_options: [] }, + ], + }); + + await element.requestUpdate(); + const defaultItemCategory = await getTestData('./hapi/item_categories/0'); + const items = element.renderRoot.querySelectorAll( + '[infer="items"] [infer="item-group"] foxy-internal-experimental-add-to-cart-builder-item-control[infer="item"]' + ); + + expect(items).to.have.length(2); + await waitForIdle(element); + await element.requestUpdate(); + + for (let index = 0; index < items.length; ++index) { + expect(items[index]).to.have.deep.property('defaultItemCategory', defaultItemCategory); + expect(items[index]).to.have.attribute('currency-code', 'AUD'); + expect(items[index]).to.have.attribute('store', 'https://demo.api/hapi/stores/0'); + expect(items[index]).to.have.attribute('index', String(index)); + expect(items[index]).to.have.attribute( + 'item-categories', + 'https://demo.api/hapi/item_categories?store_id=0' + ); + } + }); + + it('removes item when "remove" event is dispatched', async () => { + const element = await fixture( + html`` + ); + + element.edit({ + items: [ + { name: 'first item', price: 1, quantity: 1, custom_options: [] }, + { name: 'second item', price: 2, quantity: 2, custom_options: [] }, + ], + }); + + await element.requestUpdate(); + const item = element.renderRoot.querySelector('[infer="item"]'); + item?.dispatchEvent(new CustomEvent('remove')); + + await element.requestUpdate(); + expect(element).to.have.deep.nested.property('form.items', [ + { name: 'second item', price: 2, quantity: 2, custom_options: [] }, + ]); + }); + + it('renders Add Product button', async () => { + const element = await fixture( + html`` + ); + + const label = element.renderRoot.querySelector('foxy-i18n[infer="add-product"][key="caption"]'); + const button = label?.closest('vaadin-button'); + + expect(button).to.exist; + expect(element.form.items).to.have.length(1); + + button?.dispatchEvent(new CustomEvent('click')); + expect(element.form.items).to.have.length(2); + + expect(button).to.not.have.attribute('disabled'); + element.disabled = true; + await element.requestUpdate(); + expect(button).to.have.attribute('disabled'); + }); + + it('renders summary control for preview', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="preview"]' + ); + + expect(control).to.exist; + }); + + it('renders Unavailable message when preview is not available', async () => { + const router = createRouter(); + const element1 = await fixture( + html` ` + ); + + const selector = 'foxy-spinner[infer="unavailable"]'; + expect(element1.renderRoot.querySelector(selector)).to.exist; + + const element2 = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element2); + await element2.requestUpdate(); + expect(element2.renderRoot.querySelector(selector)).to.not.exist; + }); + + it('renders iframe with preview when preview is available', async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit({ items: [{ name: 'item', price: 1, quantity: 1, custom_options: [] }] }); + await element.requestUpdate(); + + const iframe = element.renderRoot.querySelector('[infer="preview"] iframe'); + expect(iframe).to.exist; + expect(iframe).to.have.attribute('srcdoc'); + expect(iframe?.getAttribute('srcdoc')).to.include('
{ + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit({ items: [{ name: 'item', price: 1, quantity: 1, custom_options: [] }] }); + await element.requestUpdate(); + + const code = element.renderRoot.querySelector('[infer="preview"] code'); + expect(code).to.exist; + expect(code?.textContent).to.include(' { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit({ items: [{ name: 'item', price: 1, quantity: 1, custom_options: [] }] }); + await element.requestUpdate(); + + const button = element.renderRoot.querySelector('[infer="preview"] foxy-copy-to-clipboard'); + expect(button).to.exist; + expect(button).to.have.attribute('infer', 'copy-to-clipboard'); + expect(button).to.have.attribute('text'); + expect(button?.getAttribute('text')).to.include(' { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit({ items: [{ name: 'item', price: 1, quantity: 1, custom_options: [] }] }); + await element.requestUpdate(); + + const link = element.renderRoot.querySelector('[infer="link"] a'); + expect(link).to.exist; + expect(link).to.have.attribute('target', '_blank'); + expect(link).to.have.attribute('href'); + expect(link?.getAttribute('href')).to.include('.foxycart.com/cart?'); + }); + + it('renders Copy button for cart URL when preview is available', async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit({ items: [{ name: 'item', price: 1, quantity: 1, custom_options: [] }] }); + await element.requestUpdate(); + + const button = element.renderRoot.querySelector('[infer="link"] foxy-copy-to-clipboard'); + expect(button).to.exist; + expect(button).to.have.attribute('infer', 'copy-to-clipboard'); + expect(button).to.have.attribute('text'); + expect(button?.getAttribute('text')).to.include('.foxycart.com/cart?'); + }); + + it('renders summary control for cart settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="cart-settings"]' + ); + + expect(control).to.exist; + }); + + it('renders text control for coupon code in cart settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + '[infer="cart-settings"] foxy-internal-text-control[infer="coupon"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders resource picker control for template set in cart settings', async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + await element.requestUpdate(); + const control = element.renderRoot.querySelector( + '[infer="cart-settings"] foxy-internal-resource-picker-control[infer="template-set-uri"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('first', 'https://demo.api/hapi/template_sets?store_id=0'); + expect(control).to.have.attribute('item', 'foxy-template-set-card'); + }); + + it('renders select control for "empty" parameter in cart settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + '[infer="cart-settings"] foxy-internal-select-control[infer="empty"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { label: 'option_false', value: 'false' }, + { label: 'option_true', value: 'true' }, + { label: 'option_reset', value: 'reset' }, + ]); + }); + + it('renders select control for "cart" parameter in cart settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + '[infer="cart-settings"] foxy-internal-select-control[infer="cart"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { label: 'option_add', value: 'add' }, + { label: 'option_checkout', value: 'checkout' }, + { label: 'option_redirect', value: 'redirect' }, + ]); + }); + + it('renders text control for "redirect" parameter in cart settings', async () => { + const element = await fixture( + html`` + ); + + element.edit({ cart: 'redirect' }); + await element.requestUpdate(); + const control = element.renderRoot.querySelector( + '[infer="cart-settings"] foxy-internal-text-control[infer="redirect"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('hides redirect control in cart settings when "cart" parameter is "redirect"', async () => { + const element = await fixture( + html`` + ); + + element.edit({ cart: 'checkout' }); + await element.requestUpdate(); + let control = element.renderRoot.querySelector('[infer="cart-settings"] [infer="redirect"]'); + expect(control).to.not.exist; + + element.edit({ cart: 'redirect' }); + await element.requestUpdate(); + control = element.renderRoot.querySelector('[infer="cart-settings"] [infer="redirect"]'); + expect(control).to.exist; + }); + + const testDate1 = new Date(2055, 2, 6); + const testDate2 = new Date(2065, 4, 12); + + const testCases: { + name: string; + form: ExperimentalAddToCartSnippet['props']; + html: string; + link?: string; + }[] = [ + { + name: 'simple product with just name and price', + form: { items: [{ name: 'Test0', price: 15, custom_options: [] }] }, + link: 'https://acme.foxycart.com/cart?name=Test0&price=15AUD', + html: dedent` + + + + + + + `, + }, + { + name: 'complex product without custom options', + form: { + items: [ + { + name: 'Test1', + item_category_uri: 'https://demo.api/hapi/item_categories/1', + price: 25, + price_configurable: true, + code: 'CODETEST1', + parent_code: 'PARENTCODETEST1', + image: 'https://picsum.photos/200/300', + url: 'https://example.com', + sub_enabled: true, + sub_frequency: '2y', + discount_details: 'allunits|1-10|2-20', + discount_type: 'quantity_amount', + discount_name: 'TestDiscount', + quantity: 3, + quantity_max: 10, + quantity_min: 2, + weight: 10, + length: 20, + width: 30, + height: 40, + custom_options: [], + }, + ], + }, + html: dedent` +
+ + + + + + + + + + + + + + + + + +
+ `, + }, + { + name: 'complex product with DD subscription start date format', + form: { + items: [ + { + name: 'Test2', + price: 25, + sub_enabled: true, + sub_frequency: '2y', + sub_startdate_format: 'dd', + sub_startdate: 12, + custom_options: [], + }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test2&price=25AUD&sub_frequency=2y&sub_startdate=12', + html: dedent` +
+ + + + + + +
+ `, + }, + { + name: 'complex product with duration subscription start/end date format', + form: { + items: [ + { + name: 'Test3', + price: 30, + sub_enabled: true, + sub_frequency: '1m', + sub_startdate_format: 'duration', + sub_startdate: '3d', + sub_enddate_format: 'duration', + sub_enddate: '2y', + custom_options: [], + }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test3&price=30AUD&sub_frequency=1m&sub_startdate=3d&sub_enddate=2y', + html: dedent` +
+ + + + + + + +
+ `, + }, + { + name: 'complex product with YYYYMMDD subscription start/end date format', + form: { + items: [ + { + name: 'Test3', + price: 30, + sub_enabled: true, + sub_frequency: '1m', + sub_startdate_format: 'yyyymmdd', + sub_startdate: testDate1.getTime(), + sub_enddate_format: 'yyyymmdd', + sub_enddate: testDate2.getTime(), + custom_options: [], + }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test3&price=30AUD&sub_frequency=1m&sub_startdate=20550306&sub_enddate=20650512', + html: dedent` +
+ + + + + + + +
+ `, + }, + { + name: 'complex product with expiration timestamp', + form: { + items: [ + { + name: 'Test4', + price: 35, + expires_format: 'timestamp', + expires_value: 2687914800, + custom_options: [], + }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test4&price=35AUD&expires=2687914800', + html: dedent` +
+ + + + + +
+ `, + }, + { + name: 'complex product with expiration duration (minutes)', + form: { + items: [ + { + name: 'Test5', + price: 40, + expires_format: 'minutes', + expires_value: 15, + custom_options: [], + }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test5&price=40AUD&expires=15', + html: dedent` +
+ + + + +
+ `, + }, + { + name: 'complex product with custom options', + form: { + items: [ + { + name: 'Test6', + price: 45, + custom_options: [ + { name: 'Option1', value: 'Option1DefaultValue', value_configurable: true }, + { name: 'Option2', value: 'Option2Value1' }, + { name: 'Option2', value: 'Option2Value2', price: 5 }, + { name: 'Option2', value: 'Option2Value3', price: 5, replace_price: true }, + { name: 'Option2', value: 'Option2Value4', price: -5 }, + { name: 'Option2', value: 'Option2Value5', weight: 5 }, + { name: 'Option2', value: 'Option2Value6', weight: 5, replace_weight: true }, + { name: 'Option2', value: 'Option2Value7', weight: -5 }, + { name: 'Option2', value: 'Option2Value8', code: 'FOOBAR' }, + { name: 'Option2', value: 'Option2Value9', code: 'FOOBAR', replace_code: true }, + { + name: 'Option2', + value: 'Option2Value10', + item_category_uri: 'https://demo.api/hapi/item_categories/1', + }, + { + name: 'Option2', + value: 'Option2Value11', + price: 5, + weight: -10, + replace_weight: true, + code: 'FOOBAR', + item_category_uri: 'https://demo.api/hapi/item_categories/1', + }, + ], + }, + ], + }, + html: dedent` +
+ + + + + + +
+ `, + }, + { + name: 'simple product with cart options', + form: { + template_set_uri: 'https://demo.api/hapi/template_sets/1', + redirect: 'https://example.com', + coupon: 'TEST-COUPON-CODE', + empty: 'reset', + cart: 'checkout', + items: [{ name: 'Test7', price: 50, custom_options: [] }], + }, + link: 'https://acme.foxycart.com/cart?template_set=TEST&cart=checkout&redirect=https%3A%2F%2Fexample.com&coupon=TEST-COUPON-CODE&empty=reset&name=Test7&price=50CAD', + html: dedent` +
+ + + + + + + + + +
+ `, + }, + { + name: 'multiple simple products', + form: { + items: [ + { name: 'Test8Product1', price: 55, custom_options: [] }, + { name: 'Test8Product2', price: 60, custom_options: [] }, + ], + }, + link: 'https://acme.foxycart.com/cart?name=Test8Product1&price=55AUD&2%3Aname=Test8Product2&2%3Aprice=60AUD', + html: dedent` +
+
+ Test8Product1 + + + +
+
+ Test8Product2 + + + +
+ +
+ `, + }, + { + name: 'multiple complex products with custom options', + form: { + items: [ + { + name: 'Test9Product1', + price: 65, + sub_enabled: true, + sub_frequency: '1m', + sub_enddate_format: 'duration', + sub_enddate: '2y', + custom_options: [ + { name: 'Option1', value: 'Option1DefaultValue', value_configurable: true }, + ], + }, + { + name: 'Test9Product2', + price: 70, + image: 'https://picsum.photos/200/300', + url: 'https://example.com', + custom_options: [ + { name: 'Option2', value: 'Option2Value1' }, + { name: 'Option2', value: 'Option2Value2', price: 5 }, + { name: 'Option2', value: 'Option2Value3', price: 5, replace_price: true }, + ], + }, + ], + }, + html: dedent` +
+
+ Test9Product1 + + + + + + +
+
+ Test9Product2 + + + + + + +
+ +
+ `, + }, + ]; + + for (const testCase of testCases) { + it(`generates form and link for ${testCase.name}`, async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitForIdle(element); + element.edit(testCase.form); + await element.requestUpdate(); + await waitForIdle(element); + + const form = element.renderRoot.querySelector('[infer="preview"] code'); + const link = element.renderRoot.querySelector('[infer="link"] a'); + const linkCopyButton = element.renderRoot.querySelector( + '[infer="link"] foxy-copy-to-clipboard' + ); + const formCopyButton = element.renderRoot.querySelector( + '[infer="preview"] foxy-copy-to-clipboard' + ); + + expect(form?.textContent).to.equal(testCase.html); + expect(link?.getAttribute('href')).to.equal(testCase.link); + expect(linkCopyButton?.getAttribute('text')).to.equal(testCase.link); + expect(formCopyButton?.getAttribute('text')).to.equal(testCase.html); + }); + } +}); diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts new file mode 100644 index 000000000..59bbc70e9 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts @@ -0,0 +1,962 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +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 { ResponsiveMixin } from '../../../mixins/responsive'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { decode, encode } from 'html-entities'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { previewCSS } from './preview.css'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html'; +import { html, svg } from 'lit-element'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { classMap } from '../../../utils/class-map'; + +import debounce from 'lodash-es/debounce'; +import hljsxml from 'highlight.js/lib/languages/xml.js'; +import hljs from 'highlight.js/lib/core.js'; + +const NS = 'experimental-add-to-cart-builder'; +const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); +hljs.registerLanguage('xml', hljsxml); + +/** + * WARNING: this element is marked as experimental and is subject to change in future releases. + * We will not be maintaining backwards compatibility for elements in the experimental namespace. + * If you are using this element, please make sure to use a fixed version of the package in your `package.json`. + * + * This element allows you to create an add-to-cart form and link for your store. + * + * @element foxy-experimental-add-to-cart-builder + */ +export class ExperimentalAddToCartBuilder extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultDomain: { attribute: 'default-domain' }, + encodeHelper: { attribute: 'encode-helper' }, + localeCodes: { attribute: 'locale-codes' }, + store: {}, + __previousUnsignedCode: { attribute: false }, + __previousSignedCode: { attribute: false }, + __signingState: { attribute: false }, + __openState: { attribute: false }, + }; + } + + /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ + defaultDomain: string | null = null; + + /** URL of the HMAC encoder endpoint. */ + encodeHelper: string | null = null; + + /** URL of the `fx:locale_codes` property helper. This will be used to determine the currency code. */ + localeCodes: string | null = null; + + /** URL of the store this add-to-cart code is created for. */ + store: string | null = null; + + private readonly __signingSeparator = `--${Date.now()}${(Math.random() * 100000).toFixed(0)}--`; + + private readonly __emptyOptions = [ + { label: 'option_false', value: 'false' }, + { label: 'option_true', value: 'true' }, + { label: 'option_reset', value: 'reset' }, + ]; + + private readonly __cartOptions = [ + { label: 'option_add', value: 'add' }, + { label: 'option_checkout', value: 'checkout' }, + { label: 'option_redirect', value: 'redirect' }, + ]; + + private readonly __signAsync = debounce(async (html: string, encodeHelper: string) => { + if (html === this.__previousUnsignedCode && this.__previousSignedCode) return; + + const isCancelled = () => html !== this.__previousUnsignedCode; + this.__signingState = 'busy'; + + try { + const res = await new ExperimentalAddToCartBuilder.API(this).fetch(encodeHelper, { + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', + body: html, + }); + + if (!isCancelled()) { + if (res.ok) { + const result = (await res.json()).result as string; + if (!isCancelled()) { + this.__signingState = 'idle'; + this.__previousSignedCode = result.replace(/value="--OPEN--" data-replace/gi, 'value'); + } + } else { + this.__signingState = 'fail'; + } + } + } catch { + if (!isCancelled()) this.__signingState = 'fail'; + } + }, 500); + + private __previousUnsignedCode = ''; + + private __previousSignedCode = ''; + + private __signingState: 'idle' | 'busy' | 'fail' = 'idle'; + + private __openState: boolean[] = []; + + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + alwaysMatch.unshift('header:copy-id', 'header:copy-json', 'undo'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + renderBody(): TemplateResult { + const addToCartCode = this.__getAddToCartCode(); + const storeUrl = this.data?._links['fx:store'].href ?? this.store ?? void 0; + const store = this.__storeLoader?.data; + + return html` +
+ + ${this.form.items?.map((product, index) => { + return html` + { + const details = evt.currentTarget as InternalSummaryControl; + this.__openState[index] = details.open; + this.__openState = [...this.__openState]; + }} + > + { + const newProducts = this.form.items?.filter((_, i) => i !== index); + this.edit({ items: newProducts }); + this.__openState = this.__openState.filter((_, i) => i !== index); + }} + > + + + `; + })} + + { + const newItem = { name: '', price: 0, custom_options: [], sub_frequency: '1m' }; + const existingItems = this.form.items ?? []; + this.edit({ items: [...existingItems, newItem] }); + this.__openState = [...new Array(existingItems.length).fill(false), true]; + }} + > + + + + +
+ ${addToCartCode + ? html` + +
+ +
+ +
+
+ ${unsafeHTML( + hljs.highlight(addToCartCode.formHTML, { language: 'xml' }).value + )} +
+ +
+
+ + +
+
+ + + +
+ +
+ ${svg``} +

+ + + + +

+
+
+ + + ${addToCartCode.linkHref + ? html` +
+ + + ${addToCartCode.linkHref} + +
+
+ + +
+
+ + +
+ ` + : html` +

+ +

+ `} +
+ ` + : html` + +
+ +
+
+ `} + + + + + + + + + + + + ${this.form.cart === 'redirect' + ? html` + + + ` + : ''} + + + + +
+
+ + + + + + + + + + + + ${this.form.items?.map((product, index) => { + return html` + + + ${product.custom_options.map((option, i) => { + return html` + + `; + })} + `; + })} + `; + } + + updated(changes: Map): void { + super.updated(changes); + + if (this.in('idle') && !this.form.items?.length) { + this.edit({ items: [{ name: '', price: 0, sub_frequency: '1m', custom_options: [] }] }); + this.__openState = [true]; + } + + if (this.form.items?.length && !this.__openState.length) { + this.__openState = [true, ...new Array(this.form.items.length - 1).fill(false)]; + } + } + + submit(): void { + // Do nothing – this form is not meant to be submitted. + } + + private get __defaultTemplateSetHref() { + try { + const url = new URL(this.__storeLoader?.data?._links['fx:template_sets'].href ?? ''); + url.searchParams.set('code', 'DEFAULT'); + return url.toString(); + } catch { + return undefined; + } + } + + private get __defaultItemCategoryHref() { + try { + const url = new URL(this.__storeLoader?.data?._links['fx:item_categories'].href ?? ''); + url.searchParams.set('code', 'DEFAULT'); + return url.toString(); + } catch { + return undefined; + } + } + + private get __resolvedCurrencyCode() { + type Loader = NucleonElement>; + + const localeCodesLoader = this.renderRoot.querySelector('#localeCodesHelperLoader'); + const currentLocale = this.__resolvedTemplateSet?.locale_code; + const allLocales = localeCodesLoader?.data?.values; + const localeInfo = currentLocale ? allLocales?.[currentLocale] : void 0; + + return localeInfo ? /Currency: ([A-Z]{3})/g.exec(localeInfo)?.[1]?.toUpperCase() : void 0; + } + + private get __resolvedTemplateSet() { + type TemplateSetsLoader = NucleonElement>; + type TemplateSetLoader = NucleonElement>; + const $ = (s: string) => this.renderRoot.querySelector(s); + + return ( + $('#templateSetLoader')?.data ?? + $('#defaultTemplateSetLoader')?.data?._embedded['fx:template_sets'][0] + ); + } + + private get __defaultItemCategory() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#defaultItemCategoryLoader')?.data?._embedded[ + 'fx:item_categories' + ][0]; + } + + private get __resolvedCartUrl() { + const store = this.__storeLoader?.data; + + if (store) { + if (store.use_remote_domain) { + return `https://${store.store_domain}/cart`; + } else if (this.defaultDomain !== null) { + return `https://${store.store_domain}.${this.defaultDomain}/cart`; + } + } + + return null; + } + + private get __storeLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#storeLoaderId'); + } + + private __getItemCategoryLoader(productIndex: number, optionIndex?: number) { + type Loader = NucleonElement>; + const prefix = `#itemCategoryLoaderProduct${productIndex}`; + const selector = typeof optionIndex === 'number' ? `${prefix}Option${optionIndex}` : prefix; + return this.renderRoot.querySelector(selector); + } + + private __getAddToCartFormHTML() { + const currencyCode = this.__resolvedCurrencyCode; + const templateSet = this.__resolvedTemplateSet; + const cartUrl = this.__resolvedCartUrl; + const store = this.__storeLoader?.data; + + if (!this.defaultDomain || !templateSet || !store || !currencyCode || !cartUrl) return ''; + + let hasAtLeastOneFieldset = false; + let output = `
`; + let level = 1; + + const newline = () => `\n${' '.repeat(level * 2)}`; + const addHiddenInput = (name: string, value: string) => { + const encodedValue = encode(value); + const encodedName = encode(name); + output += `${newline()}`; + }; + + if (templateSet.code !== 'DEFAULT') addHiddenInput('template_set', templateSet.code); + if (this.form.empty && this.form.empty !== 'false') addHiddenInput('empty', this.form.empty); + if (this.form.cart === 'checkout') addHiddenInput('cart', 'checkout'); + if (this.form.redirect) addHiddenInput('redirect', this.form.redirect); + if (this.form.coupon) addHiddenInput('coupon', this.form.coupon); + + const items = this.form.items ?? []; + const hasMoreThanOneProduct = items.length > 1; + + for (let productIndex = 0; productIndex < items.length; ++productIndex) { + const itemCategoryLoader = this.__getItemCategoryLoader(productIndex); + const itemCategory = itemCategoryLoader?.data; + const product = items[productIndex]; + + if (product.item_category_uri && !itemCategory) return ''; + + const resolvedMinQty = Math.max(1, product.quantity_min ?? 1); + const resolvedMaxQty = Math.max(resolvedMinQty, product.quantity_max ?? Infinity); + const varyQty = + resolvedMinQty !== resolvedMaxQty && + product.expires_format !== 'minutes' && + !product.hide_quantity; + + const varyPrice = product.price_configurable; + const varyptions = product.custom_options.some( + (v, i, a) => v.value_configurable || a.findIndex(vv => vv.name === v.name) !== i + ); + + const resolvedName = + product.name.trim() || this.t('items.item-group.item.basics-group.name.placeholder'); + + const needsFieldset = + hasAtLeastOneFieldset || (hasMoreThanOneProduct && (varyPrice || varyQty || varyptions)); + + if (needsFieldset) { + hasAtLeastOneFieldset = true; + output += `${newline()}
`; + level++; + output += `${newline()}${encode(resolvedName)}`; + } + + const prefix = productIndex === 0 ? '' : `${productIndex + 1}:`; + addHiddenInput(`${prefix}name`, resolvedName); + const price = `${product.price}${currencyCode}`; + + if (product.price_configurable) { + const encodedNoCurrencyPrice = encode(product.price.toFixed(2)); + output += `${newline()}`; + } else { + addHiddenInput(`${prefix}price`, price); + } + + if (itemCategory && itemCategory.code !== 'DEFAULT') { + addHiddenInput(`${prefix}category`, itemCategory.code); + } + + if (product.code) addHiddenInput(`${prefix}code`, product.code); + if (product.parent_code) addHiddenInput(`${prefix}parent_code`, product.parent_code); + + if (product.image) { + addHiddenInput(`${prefix}image`, product.image); + if (product.url) addHiddenInput(`${prefix}url`, product.url); + } + + if (product.sub_enabled) { + if (product.sub_frequency) { + addHiddenInput(`${prefix}sub_frequency`, product.sub_frequency); + + if (product.sub_startdate) { + if (product.sub_startdate_format === 'yyyymmdd') { + const date = new Date(product.sub_startdate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + addHiddenInput(`${prefix}sub_startdate`, `${year}${month}${day}`); + } else { + addHiddenInput(`${prefix}sub_startdate`, String(product.sub_startdate)); + } + } + + if (product.sub_enddate) { + if (product.sub_enddate_format === 'yyyymmdd') { + const date = new Date(product.sub_enddate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + addHiddenInput(`${prefix}sub_enddate`, `${year}${month}${day}`); + } else { + addHiddenInput(`${prefix}sub_enddate`, String(product.sub_enddate)); + } + } + } + } + + if (product.discount_name && product.discount_type && product.discount_details) { + addHiddenInput( + `${prefix}discount_${product.discount_type}`, + `${product.discount_name}{${product.discount_details}}` + ); + } + + if (product.expires_value) { + if (product.expires_format === 'timestamp') { + addHiddenInput(`${prefix}expires`, product.expires_value.toFixed(0)); + } else { + addHiddenInput(`${prefix}expires`, product.expires_value.toFixed(0)); + } + } + + if (varyQty) { + output += `${newline()}`; + } else if ((product.quantity ?? 1) > 1) { + addHiddenInput(`${prefix}quantity`, (product.quantity ?? 1).toString()); + } + + if (product.expires_format !== 'minutes') { + if (resolvedMinQty !== 1) { + addHiddenInput(`${prefix}quantity_min`, resolvedMinQty.toFixed(0)); + } + if (resolvedMaxQty !== Infinity) { + addHiddenInput(`${prefix}quantity_max`, resolvedMaxQty.toFixed(0)); + } + } + + if (product.length) addHiddenInput(`${prefix}length`, product.length.toFixed(3)); + if (product.width) addHiddenInput(`${prefix}width`, product.width.toFixed(3)); + if (product.height) addHiddenInput(`${prefix}height`, product.height.toFixed(3)); + if (product.weight) addHiddenInput(`${prefix}weight`, product.weight.toFixed(3)); + + if (store.features_multiship) { + output += `${newline()}`; + } + + const groupedCustomOptions = product.custom_options.reduce((acc, option) => { + if (!acc[option.name]) acc[option.name] = []; + acc[option.name].push(option); + return acc; + }, {} as Record['items'][number]['custom_options']>); + + for (const optionName in groupedCustomOptions) { + const group = groupedCustomOptions[optionName]; + + if (group.length === 1) { + const optionIndex = product.custom_options.indexOf(group[0]); + const itemCategory = this.__getItemCategoryLoader(productIndex, optionIndex)?.data; + const modifiers = this.__getOptionModifiers(group[0], itemCategory ?? null, currencyCode); + const value = `${group[0].value}${modifiers}`; + const name = `${prefix}${optionName}`; + + if (group[0].value_configurable) { + output += `${newline()}`; + } else { + addHiddenInput(name, value); + } + } else { + output += `${newline()}`; + } + } + + if (needsFieldset) { + level--; + output += `${newline()}
`; + } + } + + const submitCaptionSuffix = this.form.cart === 'checkout' ? 'checkout' : 'cart'; + const encodedSubmitCaption = encode(this.t(`preview.submit_caption_${submitCaptionSuffix}`)); + output += `${newline()}`; + level--; + output += `${newline()}
`; + + return output; + } + + private __getAddToCartLinkHref() { + const currencyCode = this.__resolvedCurrencyCode; + const templateSet = this.__resolvedTemplateSet; + const cartUrl = this.__resolvedCartUrl; + const store = this.__storeLoader?.data; + + if (!this.defaultDomain || !templateSet || !store || !currencyCode || !cartUrl) return ''; + + const url = new URL(cartUrl); + + if (templateSet.code !== 'DEFAULT') url.searchParams.set('template_set', templateSet.code); + if (this.form.cart === 'checkout') url.searchParams.set('cart', 'checkout'); + if (this.form.redirect) url.searchParams.set('redirect', this.form.redirect); + if (this.form.coupon) url.searchParams.set('coupon', this.form.coupon); + if (this.form.empty && this.form.empty !== 'false') { + url.searchParams.set('empty', this.form.empty); + } + + for (let index = 0; index < (this.form.items?.length ?? 0); ++index) { + const product = this.form.items![index]; + const prefix = index === 0 ? '' : `${index + 1}:`; + const itemCategory = this.__getItemCategoryLoader(index)?.data; + + if (product.item_category_uri && !itemCategory) return ''; + if (product.price_configurable) return ''; + if (new Set(product.custom_options.map(v => v.name)).size < product.custom_options.length) { + return ''; + } + + if (itemCategory && itemCategory.code !== 'DEFAULT') { + url.searchParams.set(`${prefix}category`, itemCategory.code); + } + + url.searchParams.set(`${prefix}name`, product.name); + url.searchParams.set(`${prefix}price`, `${product.price}${currencyCode}`); + + if (product.code) url.searchParams.set(`${prefix}code`, product.code); + if (product.parent_code) url.searchParams.set(`${prefix}parent_code`, product.parent_code); + + if (product.image) { + url.searchParams.set(`${prefix}image`, product.image); + if (product.url) url.searchParams.set(`${prefix}url`, product.url); + } + + if (product.sub_enabled) { + if (product.sub_frequency) { + url.searchParams.set(`${prefix}sub_frequency`, product.sub_frequency); + + if (product.sub_startdate) { + if (product.sub_startdate_format === 'yyyymmdd') { + const date = new Date(product.sub_startdate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + url.searchParams.set(`${prefix}sub_startdate`, `${year}${month}${day}`); + } else { + url.searchParams.set(`${prefix}sub_startdate`, String(product.sub_startdate)); + } + } + + if (product.sub_enddate) { + if (product.sub_enddate_format === 'yyyymmdd') { + const date = new Date(product.sub_enddate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + url.searchParams.set(`${prefix}sub_enddate`, `${year}${month}${day}`); + } else { + url.searchParams.set(`${prefix}sub_enddate`, String(product.sub_enddate)); + } + } + } + } + + if (product.discount_name && product.discount_type && product.discount_details) { + url.searchParams.set( + `${prefix}discount_${product.discount_type}`, + `${product.discount_name}{${product.discount_details}}` + ); + } + + if (product.expires_value) { + if (product.expires_format === 'timestamp') { + url.searchParams.set(`${prefix}expires`, product.expires_value.toFixed(0)); + } else { + url.searchParams.set(`${prefix}expires`, product.expires_value.toFixed(0)); + } + } + + if ((product.quantity ?? 1) > 1) { + url.searchParams.set(`${prefix}quantity`, (product.quantity ?? 1).toFixed(0)); + } + + if (product.expires_format !== 'minutes') { + if (product.quantity_min) { + url.searchParams.set(`${prefix}quantity_min`, product.quantity_min.toString()); + } + if (product.quantity_max) { + url.searchParams.set(`${prefix}quantity_max`, product.quantity_max.toString()); + } + } + + if (product.weight) url.searchParams.set(`${prefix}weight`, product.weight.toFixed(3)); + if (product.length) url.searchParams.set(`${prefix}length`, product.length.toFixed(3)); + if (product.width) url.searchParams.set(`${prefix}width`, product.width.toFixed(3)); + if (product.height) url.searchParams.set(`${prefix}height`, product.height.toFixed(3)); + + for (let optionIndex = 0; optionIndex < product.custom_options.length; ++optionIndex) { + const option = product.custom_options[optionIndex]; + if (option.value_configurable) return ''; + + const itemCategory = this.__getItemCategoryLoader(index, optionIndex)?.data; + const modifiers = this.__getOptionModifiers(option, itemCategory ?? null, currencyCode); + url.searchParams.set(`${prefix}${option.name}`, `${option.value ?? ''}${modifiers}`); + } + } + + return url.toString(); + } + + private __getAddToCartCode() { + const store = this.__storeLoader?.data; + if (!this.encodeHelper || !store) return null; + + const formHTML = this.__getAddToCartFormHTML(); + const linkHref = this.__getAddToCartLinkHref(); + if (!formHTML && !linkHref) return null; + + let unsignedCode: string; + + if (linkHref) { + const linkHTML = `Add to cart`; + unsignedCode = `${formHTML}${this.__signingSeparator}${linkHTML}`; + } else { + unsignedCode = formHTML; + } + + if (unsignedCode === this.__previousUnsignedCode && this.__previousSignedCode) { + const [formHTML, linkHTML] = this.__previousSignedCode.split(this.__signingSeparator); + return { + linkHref: linkHTML ? decode(linkHTML.substring(9, linkHTML.length - 17)) : '', + formHTML, + }; + } + + this.__previousUnsignedCode = unsignedCode; + this.__previousSignedCode = ''; + + if (store.use_cart_validation) this.__signAsync(unsignedCode, this.encodeHelper); + return { formHTML, linkHref }; + } + + private __getOptionModifiers( + option: Required['items'][number]['custom_options'][number], + optionItemCategory: Resource | null, + currencyCode: string + ) { + if (option.value_configurable) return ''; + const modifiers = []; + + if (option.price) { + const operator = option.replace_price ? ':' : Math.sign(option.price) === -1 ? '-' : '+'; + if (option.replace_price || option.price !== 0) + modifiers.push(`p${operator}${Math.abs(option.price)}${currencyCode}`); + } + + if (option.weight) { + const operator = option.replace_weight ? ':' : Math.sign(option.weight) === -1 ? '-' : '+'; + if (option.replace_weight || option.weight !== 0) + modifiers.push(`w${operator}${Math.abs(option.weight)}`); + } + + if (option.item_category_uri && !optionItemCategory) return ''; + if (optionItemCategory) modifiers.push(`y:${optionItemCategory.code}`); + + if (option.code) { + const operator = option.replace_code ? ':' : '+'; + if (option.replace_code || option.code !== '') modifiers.push(`c${operator}${option.code}`); + } + + return modifiers.length ? `{${modifiers.join('|')}}` : ''; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/index.ts new file mode 100644 index 000000000..b3d159d24 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/index.ts @@ -0,0 +1,23 @@ +import '@vaadin/vaadin-button'; + +import '../../internal/InternalResourcePickerControl/index'; +import '../../internal/InternalEditableListControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalTextControl/index'; +import '../../internal/InternalForm/index'; + +import '../TemplateSetCard/index'; +import '../CopyToClipboard/index'; +import '../NucleonElement/index'; +import '../Spinner/index'; +import '../I18n/index'; + +import './internal/InternalExperimentalAddToCartBuilderItemControl/index'; + +import { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder'; + +customElements.define('foxy-experimental-add-to-cart-builder', ExperimentalAddToCartBuilder); + +export { ExperimentalAddToCartBuilder }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.test.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.test.ts new file mode 100644 index 000000000..f081e82be --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.test.ts @@ -0,0 +1,39 @@ +import './index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionCard as Card } from './InternalExperimentalAddToCartBuilderCustomOptionCard'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit-html'; + +describe('ExperimentalAddToCartBuilder', () => { + describe('InternalExperimentalAddToCartBuilderCustomOptionCard', () => { + it('imports and defines foxy-internal-card', () => { + expect(customElements.get('foxy-internal-card')).to.exist; + }); + + it('defines itself as foxy-internal-experimental-add-to-cart-builder-custom-option-card', () => { + const localName = 'foxy-internal-experimental-add-to-cart-builder-custom-option-card'; + expect(customElements.get(localName)).to.equal(Card); + }); + + it('extends foxy-internal-card', () => { + expect(new Card()).to.be.instanceOf(customElements.get('foxy-internal-card')); + }); + + it('renders name and value of the custom option', async () => { + const card = await fixture( + html`` + ); + + card.data = { + _links: { self: { href: 'https://demo.api/virtual/empty' } }, + value: 'bar', + name: 'foo', + }; + + await card.requestUpdate(); + + expect(card.renderRoot.textContent).to.include('foo'); + expect(card.renderRoot.textContent).to.include('bar'); + }); + }); +}); diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts new file mode 100644 index 000000000..b29e4ef50 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts @@ -0,0 +1,16 @@ +import type { TemplateResult } from 'lit-html'; +import type { Data } from './types'; + +import { InternalCard } from '../../../../internal/InternalCard/InternalCard'; +import { html } from 'lit-html'; + +export class InternalExperimentalAddToCartBuilderCustomOptionCard extends InternalCard { + renderBody(): TemplateResult { + return html` +
+ ${this.data?.name}​ + ${this.data?.value}​ +
+ `; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts new file mode 100644 index 000000000..ce475815a --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts @@ -0,0 +1,10 @@ +import '../../../../internal/InternalCard/index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionCard } from './InternalExperimentalAddToCartBuilderCustomOptionCard'; + +customElements.define( + 'foxy-internal-experimental-add-to-cart-builder-custom-option-card', + InternalExperimentalAddToCartBuilderCustomOptionCard +); + +export { InternalExperimentalAddToCartBuilderCustomOptionCard }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts new file mode 100644 index 000000000..fbd2e0c20 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts @@ -0,0 +1,10 @@ +import type { ExperimentalAddToCartSnippet } from '../../types'; +import type { Graph, Resource } from '@foxy.io/sdk/core'; + +interface ExperimentalAddToCartSnippetCustomOption extends Graph { + curie: 'fx:experimental_add_to_cart_snippet_custom_option'; + links: { self: ExperimentalAddToCartSnippetCustomOption }; + props: Required['items'][number]['custom_options'][number]; +} + +export type Data = Resource; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.test.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.test.ts new file mode 100644 index 000000000..e392f849d --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.test.ts @@ -0,0 +1,366 @@ +import type { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import type { FetchEvent } from '../../../NucleonElement/FetchEvent'; + +import './index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionForm as Form } from './InternalExperimentalAddToCartBuilderCustomOptionForm'; +import { expect, fixture, waitUntil } from '@open-wc/testing'; +import { createRouter } from '../../../../../server/index'; +import { html } from 'lit-html'; + +async function waitForIdle(element: Form) { + await waitUntil( + () => { + const loaders = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...loaders].every(loader => loader.in('idle')); + }, + '', + { timeout: 5000 } + ); +} + +describe('ExperimentalAddToCartBuilder', () => { + describe('InternalExperimentalAddToCartBuilderCustomOptionForm', () => { + it('imports dependencies', () => { + expect(customElements.get('foxy-internal-resource-picker-control')).to.exist; + expect(customElements.get('foxy-internal-summary-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-nucleon')).to.exist; + }); + + it('defines itself as foxy-internal-experimental-add-to-cart-builder-custom-option-form', () => { + const localName = 'foxy-internal-experimental-add-to-cart-builder-custom-option-form'; + expect(customElements.get(localName)).to.equal(Form); + }); + + it('extends foxy-internal-form', () => { + expect(new Form()).to.be.instanceOf(customElements.get('foxy-internal-form')); + }); + + it('has a reactive property "defaultWeightUnit" that defaults to null', () => { + expect(new Form()).to.have.property('defaultWeightUnit', null); + expect(Form).to.have.deep.nested.property('properties.defaultWeightUnit', { + attribute: 'default-weight-unit', + }); + }); + + it('has a reactive property "existingOptions" that defaults to an empty array', () => { + expect(new Form()).to.have.deep.property('existingOptions', []); + expect(Form).to.have.deep.nested.property('properties.existingOptions', { + attribute: 'existing-options', + type: Array, + }); + }); + + it('has a reactive property "itemCategories" that defaults to null', () => { + expect(new Form()).to.have.property('itemCategories', null); + expect(Form).to.have.deep.nested.property('properties.itemCategories', { + attribute: 'item-categories', + }); + }); + + it('has a reactive property "currencyCode" that defaults to null', () => { + expect(new Form()).to.have.property('currencyCode', null); + expect(Form).to.have.deep.nested.property('properties.currencyCode', { + attribute: 'currency-code', + }); + }); + + it('makes option basics group readonly if href is set', () => { + const form = new Form(); + expect(form.readonlySelector.matches('basics-group', true)).to.be.false; + form.href = 'https://demo.api/virtual/empty'; + expect(form.readonlySelector.matches('basics-group', true)).to.be.true; + }); + + it('makes configurable value toggle disabled if name matches an existing option', () => { + const form = new Form(); + expect(form.disabledSelector.matches('basics-group:value-configurable', true)).to.be.false; + form.existingOptions = [{ name: 'foo', value: 'bar' }]; + form.edit({ name: 'foo' }); + expect(form.disabledSelector.matches('basics-group:value-configurable', true)).to.be.true; + }); + + it('hides price, weight, code and category groups if value is configurable', () => { + const form = new Form(); + + expect(form.hiddenSelector.matches('price-group', true)).to.be.false; + expect(form.hiddenSelector.matches('weight-group', true)).to.be.false; + expect(form.hiddenSelector.matches('code-group', true)).to.be.false; + expect(form.hiddenSelector.matches('category-group', true)).to.be.false; + + form.edit({ value_configurable: true }); + + expect(form.hiddenSelector.matches('price-group', true)).to.be.true; + expect(form.hiddenSelector.matches('weight-group', true)).to.be.true; + expect(form.hiddenSelector.matches('code-group', true)).to.be.true; + expect(form.hiddenSelector.matches('category-group', true)).to.be.true; + }); + + it('renders a summary control for the basics group', async () => { + const form = await fixture
( + html`` + ); + + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="basics-group"]' + ); + + expect(control).to.exist; + }); + + it('renders text control for option name inside of the basics group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-text-control[infer="name"]' + ); + + expect(control).to.exist; + }); + + it('renders text control for option value inside of the basics group if value is not configurable', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-text-control[infer="value"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'value'); + }); + + it('renders text control for option default value inside of the basics group if value is configurable', async () => { + const form = await fixture( + html`` + ); + + form.edit({ value_configurable: true }); + await form.requestUpdate(); + const control = form.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-text-control[infer="default-value"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'value'); + }); + + it('renders switch control for configurable value toggle inside of the basics group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-switch-control[infer="value-configurable"]' + ); + + expect(control).to.exist; + }); + + it('renders summary control for price group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="price-group"]' + ); + + expect(control).to.exist; + }); + + it('renders number control for option price inside of the price group', async () => { + const form = await fixture( + html` + + + ` + ); + + const control = form.renderRoot.querySelector( + '[infer="price-group"] foxy-internal-number-control[infer="price"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('suffix', 'aud'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders switch control for replace price toggle inside of the price group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="price-group"] foxy-internal-switch-control[infer="replace-price"]' + ); + + expect(control).to.exist; + }); + + it('renders summary control for weight group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="weight-group"]' + ); + + expect(control).to.exist; + }); + + it('renders number control for option weight inside of the weight group', async () => { + const router = createRouter(); + const form = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + const control = form.renderRoot.querySelector( + '[infer="weight-group"] foxy-internal-number-control[infer="weight"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('suffix', 'KG'); + expect(control).to.have.attribute('layout', 'summary-item'); + + form.edit({ item_category_uri: 'https://demo.api/hapi/item_categories/1' }); + await form.requestUpdate(); + await waitForIdle(form); + + expect(control).to.have.attribute('suffix', 'LBS'); + }); + + it('renders switch control for replace weight toggle inside of the weight group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="weight-group"] foxy-internal-switch-control[infer="replace-weight"]' + ); + + expect(control).to.exist; + }); + + it('renders summary control for code group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="code-group"]' + ); + + expect(control).to.exist; + }); + + it('renders text control for option code inside of the code group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="code-group"] foxy-internal-text-control[infer="code"]' + ); + + expect(control).to.exist; + }); + + it('renders switch control for replace code toggle inside of the code group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + '[infer="code-group"] foxy-internal-switch-control[infer="replace-code"]' + ); + + expect(control).to.exist; + }); + + it('renders summary control for category group', async () => { + const form = await fixture( + html`` + ); + + const control = form.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="category-group"]' + ); + + expect(control).to.exist; + }); + + it('renders resource picker control for item category inside of the category group', async () => { + const form = await fixture( + html` + + + ` + ); + + const control = form.renderRoot.querySelector( + '[infer="category-group"] foxy-internal-resource-picker-control[infer="item-category-uri"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('item', 'foxy-item-category-card'); + expect(control).to.have.attribute( + 'first', + 'https://demo.api/hapi/item_categories?store_id=0' + ); + }); + + it('produces error:option_exists_configurable when trying to add an option with the same name as an existing configurable option', async () => { + const form = await fixture( + html` + + + ` + ); + + form.edit({ name: 'foo' }); + form.submit(); + await waitUntil(() => form.errors.length > 0); + + expect(form.errors).to.deep.equal(['error:option_exists_configurable']); + }); + + it('produces error:option_exists when trying to add an option with the same name and value as an existing non-configurable option', async () => { + const form = await fixture( + html` + + + ` + ); + + form.edit({ name: 'foo', value: 'bar' }); + form.submit(); + await waitUntil(() => form.errors.length > 0); + + expect(form.errors).to.deep.equal(['error:option_exists']); + }); + }); +}); diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts new file mode 100644 index 000000000..7cfdce75b --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts @@ -0,0 +1,142 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { NucleonElement } from '../../../NucleonElement/NucleonElement'; +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 { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../../../internal/InternalForm/InternalForm'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-html'; + +const NS = 'internal-experimental-add-to-cart-builder-custom-option-form'; +const Base = TranslatableMixin(InternalForm, NS); + +export class InternalExperimentalAddToCartBuilderCustomOptionForm extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultWeightUnit: { attribute: 'default-weight-unit' }, + existingOptions: { type: Array, attribute: 'existing-options' }, + itemCategories: { attribute: 'item-categories' }, + currencyCode: { attribute: 'currency-code' }, + }; + } + + defaultWeightUnit: string | null = null; + + existingOptions: Omit[] = []; + + itemCategories: string | null = null; + + currencyCode: string | null = null; + + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + if (this.href) alwaysMatch.unshift('basics-group'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + get disabledSelector(): BooleanSelector { + const alwaysMatch = [super.disabledSelector.toString()]; + + if (!this.href && this.existingOptions.some(o => o.name === this.form.name)) { + alwaysMatch.unshift('basics-group:value-configurable'); + } + + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + + if (this.form.value_configurable) { + alwaysMatch.unshift('price-group', 'weight-group', 'code-group', 'category-group'); + } + + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + renderBody(): TemplateResult { + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } + + protected async _sendPost(edits: Partial): Promise { + const existingOptions = this.existingOptions.filter(o => o.name === edits.name); + + if (existingOptions.some(o => o.value_configurable)) { + throw ['error:option_exists_configurable']; + } + + if (existingOptions.some(o => o.value === edits.value)) { + throw ['error:option_exists']; + } + + return super._sendPost(edits); + } + + private get __resolvedDefaultWeightUnit() { + type Loader = NucleonElement>; + const loader = this.renderRoot.querySelector('#itemCategoryLoader'); + return loader?.data?.default_weight_unit ?? this.defaultWeightUnit; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts new file mode 100644 index 000000000..72959e6e8 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts @@ -0,0 +1,14 @@ +import '../../../../internal/InternalResourcePickerControl/index'; +import '../../../../internal/InternalSummaryControl/index'; +import '../../../../internal/InternalSwitchControl/index'; +import '../../../../internal/InternalNumberControl/index'; +import '../../../../internal/InternalTextControl/index'; +import '../../../../internal/InternalForm/index'; + +import '../../../NucleonElement/index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionForm as Form } from './InternalExperimentalAddToCartBuilderCustomOptionForm'; + +customElements.define('foxy-internal-experimental-add-to-cart-builder-custom-option-form', Form); + +export { Form as InternalExperimentalAddToCartBuilderCustomOptionForm }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts new file mode 100644 index 000000000..fbd2e0c20 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts @@ -0,0 +1,10 @@ +import type { ExperimentalAddToCartSnippet } from '../../types'; +import type { Graph, Resource } from '@foxy.io/sdk/core'; + +interface ExperimentalAddToCartSnippetCustomOption extends Graph { + curie: 'fx:experimental_add_to_cart_snippet_custom_option'; + links: { self: ExperimentalAddToCartSnippetCustomOption }; + props: Required['items'][number]['custom_options'][number]; +} + +export type Data = Resource; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts new file mode 100644 index 000000000..478c86b50 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts @@ -0,0 +1,929 @@ +import type { DiscountBuilder } from '../../../DiscountBuilder'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +import '../../../NucleonElement/index'; +import './index'; + +import { InternalExperimentalAddToCartBuilderItemControl as Control } from './InternalExperimentalAddToCartBuilderItemControl'; +import { expect, fixture, oneEvent } from '@open-wc/testing'; +import { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import { getTestData } from '../../../../../testgen/getTestData'; +import { html } from 'lit-html'; + +describe('ExperimentalAddToCartBuilder', () => { + describe('InternalExperimentalAddToCartBuilderItemControl', () => { + it('imports dependencies', () => { + expect(customElements.get('foxy-internal-resource-picker-control')).to.exist; + expect(customElements.get('foxy-internal-frequency-control')).to.exist; + expect(customElements.get('foxy-internal-async-list-control')).to.exist; + expect(customElements.get('foxy-internal-summary-control')).to.exist; + expect(customElements.get('foxy-internal-switch-control')).to.exist; + expect(customElements.get('foxy-internal-select-control')).to.exist; + expect(customElements.get('foxy-internal-number-control')).to.exist; + expect(customElements.get('foxy-internal-date-control')).to.exist; + expect(customElements.get('foxy-internal-text-control')).to.exist; + expect(customElements.get('foxy-internal-control')).to.exist; + expect(customElements.get('foxy-item-category-card')).to.exist; + expect(customElements.get('foxy-discount-builder')).to.exist; + expect(customElements.get('foxy-nucleon')).to.exist; + expect(customElements.get('foxy-i18n')).to.exist; + expect( + customElements.get('foxy-internal-experimental-add-to-cart-builder-custom-option-card') + ).to.exist; + expect(customElements.get('foxy-internal-experimental-add-to-cart-builder-item-control')).to + .exist; + }); + + it('defines itself as foxy-internal-experimental-add-to-cart-builder-item-control', () => { + const localName = 'foxy-internal-experimental-add-to-cart-builder-item-control'; + expect(customElements.get(localName)).to.equal(Control); + }); + + it('extends foxy-internal-control', () => { + expect(new Control()).to.be.instanceOf(customElements.get('foxy-internal-control')); + }); + + it('has a reactive property "defaultItemCategory" that defaults to null', () => { + expect(new Control()).to.have.property('defaultItemCategory', null); + expect(Control).to.have.deep.nested.property('properties.defaultItemCategory', { + attribute: 'default-item-category', + type: Object, + }); + }); + + it('has a reactive property "itemCategories" that defaults to null', () => { + expect(new Control()).to.have.property('itemCategories', null); + expect(Control).to.have.deep.nested.property('properties.itemCategories', { + attribute: 'item-categories', + }); + }); + + it('has a reactive property "currencyCode" that defaults to null', () => { + expect(new Control()).to.have.property('currencyCode', null); + expect(Control).to.have.deep.nested.property('properties.currencyCode', { + attribute: 'currency-code', + }); + }); + + it('has a reactive property "index" that defaults to 0', () => { + expect(new Control()).to.have.property('index', 0); + expect(Control).to.have.deep.nested.property('properties.index', { type: Number }); + }); + + it('renders a summary control for the basics group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="basics-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a text control for the name property inside of the basics group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-text-control[infer="name"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.name'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a resource picker control for the item_category_uri property inside of the basics group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="basics-group"] foxy-internal-resource-picker-control[infer="item-category-uri"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.item_category_uri'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('item', 'foxy-item-category-card'); + expect(control).to.have.attribute( + 'first', + 'https://demo.api/hapi/item_categories?store_id=0' + ); + }); + + it('renders a summary control for the price group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="price-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a number control for the price property inside of the price group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="price-group"] foxy-internal-number-control[infer="price"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.price'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('suffix', 'cad'); + }); + + it('renders a switch control for the price_configurable property inside of the price group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="price-group"] foxy-internal-switch-control[infer="price-configurable"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.price_configurable'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a number control for default price if price is configurable inside of the price group', async () => { + const element = await fixture( + html` + + + ` + ); + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ price_configurable: true }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + const control = element.renderRoot.querySelector( + '[infer="price-group"] foxy-internal-number-control[infer="price-default"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.price'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a summary control for the code group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="code-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a text control for the code property inside of the code group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="code-group"] foxy-internal-text-control[infer="code"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.code'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a text control for the parent_code property inside of the code group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="code-group"] foxy-internal-text-control[infer="parent-code"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.parent_code'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a summary control for the appearance group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="appearance-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a number control for the weight property inside of the appearance group if item category allows it', async () => { + const itemCategory = await getTestData>( + './hapi/item_categories/0' + ); + + itemCategory.item_delivery_type = 'pickup'; + itemCategory.default_weight_unit = 'KGS'; + itemCategory.default_weight = 25; + + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="appearance-group"] foxy-internal-number-control[infer="weight"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('placeholder', '25'); + expect(control).to.have.attribute('property', 'items.3.weight'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('suffix', 'KGS'); + expect(control).to.have.attribute('min', '0'); + + itemCategory.item_delivery_type = 'downloaded'; + await element.requestUpdate(); + expect(element.renderRoot.querySelector('[infer="appearance-group"]')).to.not.exist; + + itemCategory.item_delivery_type = 'notshipped'; + await element.requestUpdate(); + expect(element.renderRoot.querySelector('[infer="appearance-group"]')).to.not.exist; + }); + + it('renders a summary control for the subscriptions group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="subscriptions-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a switch control for the sub_enabled property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="subscriptions-group"] foxy-internal-switch-control[infer="sub-enabled"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.sub_enabled'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if sub_enabled is true, renders a frequency control for the sub_frequency property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-frequency-control[infer="sub-frequency"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_frequency'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('allow-twice-a-month'); + }); + + it('if sub_enabled is true, renders a select control for the sub_startdate_format property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-select-control[infer="sub-startdate-format"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_startdate_format'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { label: 'option_none', value: 'none' }, + { label: 'option_yyyymmdd', value: 'yyyymmdd' }, + { label: 'option_dd', value: 'dd' }, + { label: 'option_duration', value: 'duration' }, + ]); + }); + + it('if sub_enabled is true and sub_startdate_format is yyyymmdd, renders a date control for the sub_startdate property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-date-control[infer="sub-startdate-yyyymmdd"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true, sub_startdate_format: 'yyyymmdd' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_startdate'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if sub_enabled is true and sub_startdate_format is duration, renders a frequency control for the sub_startdate property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-frequency-control[infer="sub-startdate-duration"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true, sub_startdate_format: 'duration' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_startdate'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if sub_enabled is true and sub_startdate_format is dd, renders a number control for the sub_startdate property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-number-control[infer="sub-startdate-dd"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true, sub_startdate_format: 'dd' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_startdate'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('step', '1'); + expect(control).to.have.attribute('min', '1'); + expect(control).to.have.attribute('max', '31'); + }); + + it('if sub_enabled is true, renders a select control for the sub_enddate_format property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-select-control[infer="sub-enddate-format"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_enddate_format'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { label: 'option_none', value: 'none' }, + { label: 'option_yyyymmdd', value: 'yyyymmdd' }, + { label: 'option_duration', value: 'duration' }, + ]); + }); + + it('if sub_enabled is true and sub_enddate_format is yyyymmdd, renders a date control for the sub_enddate property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-date-control[infer="sub-enddate-yyyymmdd"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true, sub_enddate_format: 'yyyymmdd' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_enddate'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if sub_enabled is true and sub_enddate_format is duration, renders a frequency control for the sub_enddate property inside of the subscriptions group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="subscriptions-group"] foxy-internal-frequency-control[infer="sub-enddate-duration"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ sub_enabled: true, sub_enddate_format: 'duration' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.sub_enddate'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a summary control for the quantity group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="quantity-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a number control for the quantity property inside of the quantity group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="quantity-group"] foxy-internal-number-control[infer="quantity"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.quantity'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('step', '1'); + expect(control).to.have.attribute('min', '1'); + }); + + it('renders a number control for the quantity_min property inside of the quantity group unless item expiration format is minutes', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="quantity-group"] foxy-internal-number-control[infer="quantity-min"]'; + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.quantity_min'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('step', '1'); + expect(control).to.have.attribute('min', '1'); + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ expires_format: 'minutes' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + expect(element.renderRoot.querySelector(selector)).to.not.exist; + }); + + it('renders a number control for the quantity_max property inside of the quantity group unless item expiration format is minutes', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="quantity-group"] foxy-internal-number-control[infer="quantity-max"]'; + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.quantity_max'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('step', '1'); + expect(control).to.have.attribute('min', '1'); + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ expires_format: 'minutes' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + expect(element.renderRoot.querySelector(selector)).to.not.exist; + }); + + it('renders a switch control for the hide_quantity property inside of the quantity group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="quantity-group"] foxy-internal-switch-control[infer="hide-quantity"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.hide_quantity'); + }); + + it('renders a summary control for the advanced group', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="advanced-group"]' + ); + + expect(control).to.exist; + }); + + it('renders a text control for the discount_name property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="advanced-group"] foxy-internal-text-control[infer="discount-name"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.discount_name'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if discount_name is set, renders a discount builder for the discount_details property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = '[infer="advanced-group"] foxy-discount-builder[infer="discount-builder"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ discount_name: 'foo' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector)!; + expect(control).to.exist; + expect(control).to.have.deep.property('parsedValue', { + details: '', + type: 'quantity_amount', + name: 'foo', + }); + + control.parsedValue = { details: 'bar', type: 'quantity_percentage', name: 'baz' }; + control.dispatchEvent(new CustomEvent('change')); + expect(nucleon).to.have.nested.property('form.items.0.discount_details', 'bar'); + expect(nucleon).to.have.nested.property('form.items.0.discount_type', 'quantity_percentage'); + }); + + it('renders a text control for the image property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="advanced-group"] foxy-internal-text-control[infer="image"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.3.image'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('if image is set, renders a text control for the url property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = '[infer="advanced-group"] foxy-internal-text-control[infer="url"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ image: 'foo' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.url'); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders a select control for the expires_format property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const control = element.renderRoot.querySelector( + '[infer="advanced-group"] foxy-internal-select-control[infer="expires-format"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.expires_format'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.deep.property('options', [ + { label: 'option_none', value: 'none' }, + { label: 'option_minutes', value: 'minutes' }, + { label: 'option_timestamp', value: 'timestamp' }, + ]); + }); + + it('if expires_format is minutes, renders a number control for the expires_value property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="advanced-group"] foxy-internal-number-control[infer="expires-value-minutes"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ expires_format: 'minutes' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.expires_value'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('suffix', 'min'); + }); + + it('if expires_format is timestamp, renders a date control for the expires_value property inside of the advanced group', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = + '[infer="advanced-group"] foxy-internal-date-control[infer="expires-value-timestamp"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ expires_format: 'timestamp' }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector(selector); + expect(control).to.exist; + expect(control).to.have.attribute('property', 'items.0.expires_value'); + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('format', 'unix'); + }); + + it('if itemDeliveryType is notshipped or downloaded, does not render length, width, and height controls inside of the advanced group', async () => { + const itemCategory = await getTestData>( + './hapi/item_categories/0' + ); + + itemCategory.item_delivery_type = 'notshipped'; + + const element = await fixture( + html` + + + ` + ); + + const $ = (selector: string) => element.renderRoot.querySelector(selector); + expect($('[infer="advanced-group"] [infer="length"]')).to.not.exist; + expect($('[infer="advanced-group"] [infer="width"]')).to.not.exist; + expect($('[infer="advanced-group"] [infer="height"]')).to.not.exist; + + itemCategory.item_delivery_type = 'downloaded'; + await element.requestUpdate(); + expect($('[infer="advanced-group"] [infer="length"]')).to.not.exist; + expect($('[infer="advanced-group"] [infer="width"]')).to.not.exist; + expect($('[infer="advanced-group"] [infer="height"]')).to.not.exist; + + itemCategory.item_delivery_type = 'shipped'; + await element.requestUpdate(); + expect($('[infer="advanced-group"] [infer="length"]')).to.exist; + expect($('[infer="advanced-group"] [infer="width"]')).to.exist; + expect($('[infer="advanced-group"] [infer="height"]')).to.exist; + }); + + it('if itemDeliveryType is shipped, renders length, width, and height controls inside of the advanced group', async () => { + const itemCategory = await getTestData>( + './hapi/item_categories/0' + ); + + itemCategory.item_delivery_type = 'shipped'; + itemCategory.default_length_unit = 'CM'; + + const element = await fixture( + html` + + + ` + ); + + const $ = (selector: string) => element.renderRoot.querySelector(selector); + expect($('[infer="advanced-group"] [infer="length"]')).to.exist; + expect($('[infer="advanced-group"] [infer="width"]')).to.exist; + expect($('[infer="advanced-group"] [infer="height"]')).to.exist; + expect($('[infer="advanced-group"] [infer="length"]')).to.have.attribute('suffix', 'CM'); + expect($('[infer="advanced-group"] [infer="width"]')).to.have.attribute('suffix', 'CM'); + expect($('[infer="advanced-group"] [infer="height"]')).to.have.attribute('suffix', 'CM'); + }); + + it('renders an async list control for the custom options', async () => { + const itemCategory = await getTestData>( + './hapi/item_categories/0' + ); + + itemCategory.default_weight_unit = 'KGS'; + + const element = await fixture( + html` + + + ` + ); + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{ custom_options: [{ name: 'foo', value: 'bar' }] }] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const control = element.renderRoot.querySelector( + 'foxy-internal-async-list-control[infer="custom-options"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute( + 'first', + `foxy://${element.nucleon?.virtualHost}/form/items/0/custom_options` + ); + expect(control).to.have.attribute( + 'form', + 'foxy-internal-experimental-add-to-cart-builder-custom-option-form' + ); + expect(control).to.have.attribute( + 'item', + 'foxy-internal-experimental-add-to-cart-builder-custom-option-card' + ); + expect(control).to.have.attribute('alert'); + expect(control).to.have.deep.property('formProps', { + '.defaultWeightUnit': 'KGS', + '.existingOptions': [{ name: 'foo', value: 'bar' }], + '.itemCategories': 'https://demo.api/hapi/item_categories?store_id=0', + '.currencyCode': 'aud', + }); + }); + + it('renders a button to remove the item if there is more than one item', async () => { + const element = await fixture( + html` + + + ` + ); + + const selector = 'foxy-i18n[infer="delete"]'; + expect(element.renderRoot.querySelector(selector)).to.not.exist; + + const nucleon = new NucleonElement(); + nucleon.edit({ items: [{}, {}] }); + element.nucleon = nucleon; + await element.requestUpdate(); + + const caption = element.renderRoot.querySelector(selector); + expect(caption).to.exist; + expect(caption).to.have.attribute('key', 'caption'); + + const button = caption?.closest('vaadin-button'); + expect(button).to.exist; + expect(button).to.not.have.attribute('disabled'); + + const removeEvent = oneEvent(element, 'remove'); + button!.click(); + expect(await removeEvent).to.be.an.instanceOf(CustomEvent); + + element.disabled = true; + await element.requestUpdate(); + expect(button).to.have.attribute('disabled'); + }); + }); +}); diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts new file mode 100644 index 000000000..08b7e1639 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts @@ -0,0 +1,418 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { ExperimentalAddToCartBuilder } from '../../ExperimentalAddToCartBuilder'; +import type { DiscountBuilder } from '../../../DiscountBuilder/DiscountBuilder'; +import type { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; +import type { Data } from '../../types'; + +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-html'; + +export class InternalExperimentalAddToCartBuilderItemControl extends InternalControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultItemCategory: { type: Object, attribute: 'default-item-category' }, + itemCategories: { attribute: 'item-categories' }, + currencyCode: { attribute: 'currency-code' }, + index: { type: Number }, + }; + } + + defaultItemCategory: Resource | null = null; + + itemCategories: string | null = null; + + currencyCode: string | null = null; + + index = 0; + + renderControl(): TemplateResult { + const itemCategory = this.__resolvedItemCategory; + const itemDeliveryType = itemCategory?.item_delivery_type; + const nucleon = this.nucleon as ExperimentalAddToCartBuilder | null; + const index = this.index; + const item = nucleon?.form.items?.[index]; + + return html` +
+ + + + + + + + + + + + + + + + + + + + + + + + + ${itemDeliveryType === 'notshipped' || itemDeliveryType === 'downloaded' + ? '' + : html` + + + + + `} + + + + + + ${item?.sub_enabled + ? html` + + + + + + + ${item.sub_startdate_format === 'yyyymmdd' + ? html` + + + ` + : item.sub_startdate_format === 'duration' + ? html` + + + ` + : item.sub_startdate_format === 'dd' + ? html` + + + ` + : ''} + + + + + ${item.sub_enddate_format === 'yyyymmdd' + ? html` + + + ` + : item.sub_enddate_format === 'duration' + ? html` + + + ` + : ''} + ` + : ''} + + + + + + + ${item?.expires_format === 'minutes' + ? '' + : html` + + + + + + `} + + + + + + + + + + ${item?.discount_name + ? html` + { + this.__handleDiscountBuilderChange(evt, item, index); + }} + > + + ` + : ''} + + + + + ${item?.image + ? html` + + + ` + : ''} + + + + + ${item?.expires_format === 'minutes' + ? html` + + + ` + : item?.expires_format === 'timestamp' + ? html` + + + ` + : ''} + ${itemDeliveryType === 'notshipped' || itemDeliveryType === 'downloaded' + ? '' + : html` + + + + + + + + + `} + + + + + + ${(nucleon?.form.items?.length ?? 0) <= 1 + ? html`` + : html` + this.dispatchEvent(new CustomEvent('remove'))} + > + + + `} + + +
+ `; + } + + private get __resolvedItemCategory() { + return this.__itemCategoryLoader?.data ?? this.defaultItemCategory; + } + + private get __itemCategoryLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#itemCategoryLoader'); + } + + private __handleDiscountBuilderChange( + evt: CustomEvent, + item: Required['items'][number], + index: number + ) { + const builder = evt.currentTarget as DiscountBuilder; + const nucleon = this.nucleon as ExperimentalAddToCartBuilder | null; + + item.discount_details = builder.parsedValue.details; + item.discount_type = builder.parsedValue.type; + item.discount_name = builder.parsedValue.name; + + const items = nucleon?.form.items ?? []; + items.splice(index, 1, item); + nucleon?.edit({ items: [...items] }); + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts new file mode 100644 index 000000000..0bd380d7c --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts @@ -0,0 +1,26 @@ +import '@vaadin/vaadin-button'; + +import '../../../../internal/InternalResourcePickerControl/index'; +import '../../../../internal/InternalFrequencyControl/index'; +import '../../../../internal/InternalAsyncListControl/index'; +import '../../../../internal/InternalSummaryControl/index'; +import '../../../../internal/InternalSwitchControl/index'; +import '../../../../internal/InternalSelectControl/index'; +import '../../../../internal/InternalNumberControl/index'; +import '../../../../internal/InternalDateControl/index'; +import '../../../../internal/InternalTextControl/index'; +import '../../../../internal/InternalControl/index'; + +import '../../../ItemCategoryCard/index'; +import '../../../DiscountBuilder/index'; +import '../../../NucleonElement/index'; +import '../../../I18n/index'; + +import '../InternalExperimentalAddToCartBuilderCustomOptionCard/index'; +import '../InternalExperimentalAddToCartBuilderCustomOptionForm/index'; + +import { InternalExperimentalAddToCartBuilderItemControl as Control } from './InternalExperimentalAddToCartBuilderItemControl'; + +customElements.define('foxy-internal-experimental-add-to-cart-builder-item-control', Control); + +export { Control as InternalExperimentalAddToCartBuilderItem }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts b/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts new file mode 100644 index 000000000..ac81923c5 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts @@ -0,0 +1,152 @@ +/** CSS reset adapted from github.com/necolas/normalize.css */ +export const previewCSS = ` + +`; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/types.ts new file mode 100644 index 000000000..de4c97a5c --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/types.ts @@ -0,0 +1,61 @@ +import type { Graph, Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +/** WARNING: this API doesn't exist. Fields and data types may change without notice. */ +export interface ExperimentalAddToCartSnippet extends Graph { + curie: 'fx:experimental_add_to_cart_snippet'; + links: { + 'self': ExperimentalAddToCartSnippet; + 'fx:store': Rels.Store; + }; + props: { + template_set_uri?: string; + redirect?: string; + coupon?: string; + empty?: 'false' | 'true' | 'reset'; + cart?: 'add' | 'checkout' | 'redirect'; + items?: { + name: string; + item_category_uri?: string; + price: number; + price_configurable?: boolean; + code?: string; + parent_code?: string; + image?: string; + url?: string; + sub_enabled?: boolean; + sub_frequency?: string; + sub_startdate_format?: 'none' | 'yyyymmdd' | 'dd' | 'duration'; + sub_startdate?: string | number; + sub_enddate_format?: 'none' | 'yyyymmdd' | 'duration'; + sub_enddate?: string | number; + discount_details?: string; + discount_type?: string; + discount_name?: string; + expires_format?: 'minutes' | 'timestamp' | 'none'; + expires_value?: number; + hide_quantity?: boolean; + quantity?: number; + quantity_max?: number; + quantity_min?: number; + weight?: number; + length?: number; + width?: number; + height?: number; + custom_options: { + name: string; + value?: string; + value_configurable?: boolean; + price?: number; + replace_price?: boolean; + weight?: number; + replace_weight?: boolean; + code?: string; + replace_code?: boolean; + item_category_uri?: string; + }[]; + }[]; + }; +} + +export type Data = Resource; diff --git a/src/elements/public/NucleonElement/API.ts b/src/elements/public/NucleonElement/API.ts index dcac5adb0..ed8a9b04a 100644 --- a/src/elements/public/NucleonElement/API.ts +++ b/src/elements/public/NucleonElement/API.ts @@ -19,8 +19,10 @@ export class API extends CoreAPI { new Promise((resolve, reject) => { const request = typeof args[0] === 'string' ? new API.WHATWGRequest(...args) : args[0]; - request.headers.set('Content-Type', 'application/json'); request.headers.set('FOXY-API-VERSION', '1'); + if (!request.headers.has('Content-Type')) { + request.headers.set('Content-Type', 'application/json'); + } const event = new FetchEvent('fetch', { cancelable: true, diff --git a/src/elements/public/index.defined.ts b/src/elements/public/index.defined.ts index 31346bb5b..527d90c7f 100644 --- a/src/elements/public/index.defined.ts +++ b/src/elements/public/index.defined.ts @@ -45,6 +45,7 @@ export { DownloadableForm } from './DownloadableForm/index'; export { EmailTemplateCard } from './EmailTemplateCard/index'; export { EmailTemplateForm } from './EmailTemplateForm/index'; export { ErrorEntryCard } from './ErrorEntryCard/index'; +export { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder/index'; export { FilterAttributeCard } from './FilterAttributeCard/index'; export { FilterAttributeForm } from './FilterAttributeForm/index'; export { FormDialog } from './FormDialog/index'; diff --git a/src/elements/public/index.ts b/src/elements/public/index.ts index 1f100eeaa..613462659 100644 --- a/src/elements/public/index.ts +++ b/src/elements/public/index.ts @@ -45,6 +45,7 @@ export { DownloadableForm } from './DownloadableForm/DownloadableForm'; export { EmailTemplateCard } from './EmailTemplateCard/EmailTemplateCard'; export { EmailTemplateForm } from './EmailTemplateForm/EmailTemplateForm'; export { ErrorEntryCard } from './ErrorEntryCard/ErrorEntryCard'; +export { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder'; export { FilterAttributeCard } from './FilterAttributeCard/FilterAttributeCard'; export { FilterAttributeForm } from './FilterAttributeForm/FilterAttributeForm'; export { FormDialog } from './FormDialog/FormDialog'; diff --git a/src/mixins/themeable.ts b/src/mixins/themeable.ts index f1ac90ce2..8bc0838f5 100644 --- a/src/mixins/themeable.ts +++ b/src/mixins/themeable.ts @@ -158,6 +158,26 @@ export const ThemeableMixin = ( .snap-start { scroll-snap-align: start; } + + .hljs-tag { + color: var(--lumo-tertiary-text-color); + font-weight: 500; + } + + .hljs-name { + color: var(--lumo-primary-text-color); + font-weight: 500; + } + + .hljs-attr { + color: var(--lumo-secondary-text-color); + font-weight: 500; + } + + .hljs-string { + color: var(--lumo-success-text-color); + font-weight: 500; + } } } `, diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index 38dd76a8f..17a55a077 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -619,7 +619,7 @@ export const createDataset: () => Dataset = () => ({ use_webhook: false, webhook_url: '', webhook_key: '', - use_cart_validation: false, + use_cart_validation: true, use_single_sign_on: false, single_sign_on_url: '', customer_password_hash_type: 'phpass', @@ -882,8 +882,8 @@ export const createDataset: () => Dataset = () => ({ store_id: 0, admin_email_template_uri: '', customer_email_template_uri: '', - code: `CATEGORY_${id}`, - name: `Test Category ${id}`, + code: id === 0 ? 'DEFAULT' : `CATEGORY_${id}`, + name: id === 0 ? 'Default Item Category' : `Test Category ${id}`, item_delivery_type: 'notshipped', max_downloads_per_customer: 3, max_downloads_time_period: 24, @@ -1780,7 +1780,7 @@ export const createDataset: () => Dataset = () => ({ code: 'DEFAULT', description: 'Default Template Set', language: 'english', - locale_code: 'en_US', + locale_code: 'en_AU', config: '', date_created: '2012-08-10T11:58:54-0700', date_modified: '2012-08-10T11:58:54-0700', @@ -1805,7 +1805,7 @@ export const createDataset: () => Dataset = () => ({ code: 'TEST', description: 'Test (Localdev)', language: 'english', - locale_code: 'en_US', + locale_code: 'en_CA', config: '', date_created: '2012-08-10T11:58:54-0700', date_modified: '2012-08-10T11:58:54-0700', @@ -1982,4 +1982,152 @@ export const createDataset: () => Dataset = () => ({ date_modified: '2022-12-01T10:07:05-0800', }, ], + + experimental_add_to_cart_snippets: [ + { + id: 0, + store_id: 0, + template_set_uri: 'https://demo.api/hapi/template_sets/0', + empty: 'true', + cart: 'checkout', + items: [ + { + name: 'Red T-Shirt', + item_category_uri: 'https://demo.api/hapi/item_categories/0', + price: 24.99, + price_configurable: true, + code: 'REDTSHIRT', + parent_code: null, + image: 'https://picsum.photos/256', + url: 'https://example.com', + sub_enabled: true, + sub_frequency: '6m', + sub_startdate_format: 'dd', + sub_startdate: 12, + sub_enddate_format: 'duration', + sub_enddate: '1y', + discount_details: null, + discount_type: null, + discount_name: null, + expires_format: 'minutes', + expires_value: 60, + quantity: 1, + quantity_options: [1, 2, 3], + quantity_configurable: true, + quantity_max: 3, + quantity_min: 1, + weight: 0.5, + length: 25, + width: 25, + height: 5, + custom_options: [ + { + name: 'Print', + value: 'None', + value_configurable: false, + price: 0, + replace_price: false, + weight: 0, + replace_weight: false, + code: null, + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Abstract', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-ABSTRACT', + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Waves', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-WAVES', + replace_code: false, + item_category_uri: null, + }, + ], + }, + { + name: 'Yellow T-Shirt', + item_category_uri: 'https://demo.api/hapi/item_categories/0', + price: 24.99, + price_configurable: true, + code: 'YELLOWTSHIRT', + parent_code: null, + image: 'https://picsum.photos/256', + url: 'https://example.com', + sub_enabled: true, + sub_frequency: '6m', + sub_startdate_format: 'dd', + sub_startdate: 12, + sub_enddate_format: 'duration', + sub_enddate: '1y', + discount_details: null, + discount_type: null, + discount_name: null, + expires_format: 'minutes', + expires_value: 60, + quantity: 1, + quantity_options: [1, 2, 3], + quantity_configurable: true, + quantity_max: 3, + quantity_min: 1, + weight: 0.5, + length: 25, + width: 25, + height: 5, + custom_options: [ + { + name: 'Print', + value: 'None', + value_configurable: false, + price: 0, + replace_price: false, + weight: 0, + replace_weight: false, + code: null, + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Abstract', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-ABSTRACT', + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Waves', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-WAVES', + replace_code: false, + item_category_uri: null, + }, + ], + }, + ], + }, + ], }); diff --git a/src/server/hapi/defaults.ts b/src/server/hapi/defaults.ts index 47c3ade45..795cade2d 100644 --- a/src/server/hapi/defaults.ts +++ b/src/server/hapi/defaults.ts @@ -1087,4 +1087,14 @@ export const defaults: Defaults = { date_created: new Date().toISOString(), date_modified: new Date().toISOString(), }), + + experimental_add_to_cart_snippets: (query, dataset) => ({ + id: increment('experimental_add_to_cart_snippets', dataset), + store_id: parseInt(query.get('store_id') ?? '0'), + template_set_uri: null, + coupons: [], + empty: null, + cart: null, + custom_options: [], + }), }; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 2620166c6..f2397198d 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -556,4 +556,8 @@ export const links: Links = { 'fx:store': { href: `./stores/${user_id}` }, 'fx:user': { href: `./users/${user_id}` }, }), + + experimental_add_to_cart_snippets: ({ store_id }) => ({ + 'fx:store': { href: `./stores/${store_id}` }, + }), }; diff --git a/src/server/virtual/index.ts b/src/server/virtual/index.ts index 350f41b69..75757aef6 100644 --- a/src/server/virtual/index.ts +++ b/src/server/virtual/index.ts @@ -50,5 +50,14 @@ export function createRouter(): Router { return new Response(JSON.stringify(body)); }); + router.post('/:prefix/encode', async ({ request }) => { + const html = await request.text(); + const result = html + .replace(/value="--OPEN--"/g, 'data-bak="--OPEN--"') + .replace(/(value|name|href)="/g, '$1="demo-signature|') + .replace(/data-bak="--OPEN--"/g, 'value="--OPEN--"'); + return new Response(JSON.stringify({ result })); + }); + return router; } diff --git a/src/static/translations/experimental-add-to-cart-builder/en.json b/src/static/translations/experimental-add-to-cart-builder/en.json new file mode 100644 index 000000000..9e5bfcdd1 --- /dev/null +++ b/src/static/translations/experimental-add-to-cart-builder/en.json @@ -0,0 +1,559 @@ +{ + "items": { + "label": "Items", + "helper_text": "", + "item-group": { + "label": "New item", + "helper_text": "", + "item": { + "basics-group": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "placeholder": "New item", + "helper_text": "" + }, + "item-category-uri": { + "label": "Category", + "helper_text": "", + "placeholder": "DEFAULT", + "value": "{{ resource.name }}", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an item category", + "selection": { + "label": "", + "helper_text": "", + "search": "Search", + "clear": "Clear", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No item categories found", + "loading_error": "Unknown error" + } + } + } + } + }, + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "Not assigned – click to select", + "loading_error": "Unknown error" + } + } + } + }, + "price-group": { + "label": "", + "helper_text": "", + "price": { + "label": "Price", + "placeholder": "0", + "helper_text": "" + }, + "price-default": { + "label": "Default price", + "placeholder": "0", + "helper_text": "" + }, + "price-configurable": { + "label": "Allow custom amount", + "helper_text": "For donations or pay-what-you-want items.", + "checked": "Yes", + "unchecked": "No" + } + }, + "code-group": { + "label": "", + "helper_text": "", + "code": { + "label": "SKU", + "placeholder": "Optional", + "helper_text": "" + }, + "parent-code": { + "label": "Parent SKU", + "placeholder": "Optional", + "helper_text": "For child items in a bundle." + } + }, + "appearance-group": { + "label": "", + "helper_text": "", + "weight": { + "label": "Weight", + "helper_text": "", + "placeholder": "0" + } + }, + "subscriptions-group": { + "label": "", + "helper_text": "", + "sub-enabled": { + "label": "Create subscription", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "sub-frequency": { + "label": "Frequency", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "times_a_month": "times/mo", + "day": "day", + "day_plural": "days", + "week": "week", + "week_plural": "weeks", + "month": "month", + "month_plural": "months", + "year": "year", + "year_plural": "years" + }, + "sub-startdate-format": { + "label": "Start", + "helper_text": "", + "placeholder": "Select", + "option_none": "Immediately", + "option_yyyymmdd": "On a specific date", + "option_dd": "On a specific day of the month", + "option_duration": "After a period of time" + }, + "sub-startdate-yyyymmdd": { + "label": "Start date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "sub-startdate-dd": { + "label": "Start day of the month", + "helper_text": "", + "placeholder": "Required" + }, + "sub-startdate-duration": { + "label": "Start after", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "day": "Day", + "day_plural": "Days", + "week": "Week", + "week_plural": "Weeks", + "month": "Month", + "month_plural": "Months", + "year": "Year", + "year_plural": "Years" + }, + "sub-enddate-format": { + "label": "End", + "helper_text": "", + "placeholder": "Select", + "option_none": "When cancelled", + "option_yyyymmdd": "On a specific date", + "option_duration": "After a period of time" + }, + "sub-enddate-yyyymmdd": { + "label": "End date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "sub-enddate-duration": { + "label": "End after", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "day": "Day", + "day_plural": "Days", + "week": "Week", + "week_plural": "Weeks", + "month": "Month", + "month_plural": "Months", + "year": "Year", + "year_plural": "Years" + } + }, + "quantity-group": { + "label": "Quantity", + "helper_text": "", + "quantity": { + "label": "Quantity", + "placeholder": "1", + "helper_text": "" + }, + "quantity-min": { + "label": "Minimum quantity", + "placeholder": "1", + "helper_text": "" + }, + "quantity-max": { + "label": "Maximum quantity", + "placeholder": "None", + "helper_text": "" + }, + "hide-quantity": { + "label": "Hide quantity field in the form", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "advanced-group": { + "label": "Advanced", + "helper_text": "", + "discount-name": { + "label": "Discount", + "helper_text": "", + "placeholder": "None" + }, + "discount-builder": { + "tier": "Tier", + "tier_by": "by", + "tier_if": "if", + "tier_allunits": "price of each item", + "tier_incremental": "price of additional items", + "tier_repeat": "price of next item", + "tier_single": "order total", + "tier_then": "then", + "quantity": "quantity", + "total": "total", + "reduce": "reduce", + "increase": "increase" + }, + "expires-format": { + "label": "Expiration", + "helper_text": "", + "placeholder": "Select", + "option_none": "Doesn't expire", + "option_minutes": "After a number of minutes", + "option_timestamp": "On a specific date" + }, + "expires-value-timestamp": { + "label": "Expiration date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "expires-value-minutes": { + "label": "Expires after", + "helper_text": "", + "placeholder": "0" + }, + "image": { + "label": "Image URL", + "placeholder": "Optional", + "helper_text": "" + }, + "url": { + "label": "Product URL", + "placeholder": "Optional", + "helper_text": "" + }, + "width": { + "label": "Width", + "helper_text": "", + "placeholder": "0" + }, + "height": { + "label": "Height", + "helper_text": "", + "placeholder": "0" + }, + "length": { + "label": "Length", + "helper_text": "", + "placeholder": "0" + } + }, + "custom-options": { + "label": "Custom options", + "delete_header": "Remove custom option?", + "delete_message": "Please confirm that you'd like to remove this custom option from the product.", + "delete_confirm": "Remove", + "delete_cancel": "Cancel", + "dialog": { + "header_create": "Add option", + "header_update": "Update option", + "close": "Close", + "save": "Save", + "cancel": "Cancel", + "undo_header": "Unsaved changes", + "undo_message": "Looks like you didn't save your changes! What would you like to do with them?", + "undo_cancel": "Review", + "undo_confirm": "Discard", + "internal-experimental-add-to-cart-builder-custom-option-form": { + "error": { + "option_exists": "Option with this name and value already exists.", + "option_exists_configurable": "Only one option with this name can exist when its value is configurable." + }, + "basics-group": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "placeholder": "Required", + "helper_text": "" + }, + "value": { + "label": "Value", + "placeholder": "Required", + "helper_text": "" + }, + "default-value": { + "label": "Default value", + "placeholder": "Optional", + "helper_text": "" + }, + "value-configurable": { + "label": "Let users enter a custom value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "price-group": { + "label": "", + "helper_text": "", + "price": { + "label": "Price modifier", + "placeholder": "0", + "helper_text": "" + }, + "replace-price": { + "label": "Replace item price with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "weight-group": { + "label": "", + "helper_text": "", + "weight": { + "label": "Weight modifier", + "placeholder": "0", + "helper_text": "" + }, + "replace-weight": { + "label": "Replace item weight with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "code-group": { + "label": "", + "helper_text": "", + "code": { + "label": "Code modifier", + "placeholder": "None", + "helper_text": "" + }, + "replace-code": { + "label": "Replace item code with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "category-group": { + "label": "", + "helper_text": "", + "item-category-uri": { + "label": "Change item category to", + "helper_text": "", + "placeholder": "Don't change", + "value": "{{ resource.code }}", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an item category", + "selection": { + "label": "", + "helper_text": "", + "search": "Search", + "clear": "Clear", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No item categories found", + "loading_error": "Unknown error" + } + } + } + } + }, + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "Not assigned – click to select", + "loading_error": "Unknown error" + } + } + } + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "Are you sure you'd like to remove this custom option? 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", + "loading_error": "Unknown error" + } + } + }, + "pagination": { + "card": { + "delete_button_text": "Delete", + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No options", + "loading_error": "Unknown error" + } + }, + "create_button_text": "Add option +", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous" + } + }, + "delete": { + "caption": "Remove from bundle" + } + } + }, + "add-product": { + "caption": "Add another item" + } + }, + "cart-settings": { + "label": "Settings", + "helper_text": "", + "template-set-uri": { + "label": "Template set", + "helper_text": "", + "value": "{{ resource.code }}", + "value_busy": "Loading...", + "value_fail": "Failed to load", + "placeholder": "DEFAULT", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an template set", + "selection": { + "label": "Template sets", + "helper_text": "", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No template sets found", + "loading_error": "Unknown error" + } + } + } + } + } + }, + "empty": { + "label": "Before adding items", + "helper_text": "", + "placeholder": "Select", + "option_false": "Do nothing", + "option_true": "Clear the current cart", + "option_reset": "Create a new cart" + }, + "cart": { + "label": "After adding items", + "helper_text": "", + "placeholder": "Select", + "option_add": "Display the cart", + "option_checkout": "Go to checkout", + "option_redirect": "Redirect to a page" + }, + "redirect": { + "label": "Redirect URL", + "placeholder": "Required", + "helper_text": "" + }, + "coupon": { + "label": "Coupon code", + "helper_text": "", + "placeholder": "Optional" + } + }, + "link": { + "label": "", + "helper_text": "", + "direct_link": "Direct link", + "empty": "Modify the item parameters to see the add-to-cart link.", + "unavailable": "Direct link is not available when one or more items have configurable options.", + "copy-to-clipboard": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy to clipboard", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, + "preview": { + "label": "Preview", + "helper_text": "", + "edits_tip": "You are free to edit this code and add styles to match your website. When editing, make sure to keep the names and values of the form elements intact, and preserve all Foxy URLs in the snippet.", + "edits_docs": "Learn more...", + "price_label": "Price:", + "quantity_label": "Quantity:", + "shipto_label": "Ship to:", + "submit_caption_cart": "Add to cart", + "submit_caption_checkout": "Purchase", + "unavailable": { + "loading_busy": "Loading preview..." + }, + "spinner": { + "loading_busy": "Signing", + "loading_error": "Failed to sign" + }, + "copy-to-clipboard": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy to clipboard", + "copying": "Copying...", + "done": "Copied to clipboard" + } + } +} \ No newline at end of file diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index 636966642..f0e14c29c 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -391,6 +391,10 @@ export const groups = [ name: 'foxy-error-entry-card', files: './src/elements/public/ErrorEntryCard/**/*.test.ts', }, + { + name: 'foxy-experimental-add-to-cart-builder', + files: './src/elements/public/ExperimentalAddToCartBuilder/**/*.test.ts', + }, { name: 'foxy-filter-attribute-card', files: './src/elements/public/FilterAttributeCard/**/*.test.ts',