From 997abca10c3b625a8f5a6b33bae7d46c066000bb Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 19 Sep 2024 22:57:30 -0300 Subject: [PATCH 01/26] feat: add `foxy-user-invitation-form` element --- .../UserInvitationForm.stories.ts | 35 ++ .../UserInvitationForm.test.ts | 574 ++++++++++++++++++ .../UserInvitationForm/UserInvitationForm.ts | 327 ++++++++++ .../public/UserInvitationForm/index.ts | 12 + .../InternalUserInvitationFormAsyncAction.ts | 55 ++ .../index.ts | 9 + .../InternalUserInvitationFormSyncAction.ts | 34 ++ .../index.ts | 12 + .../public/UserInvitationForm/types.ts | 40 ++ src/elements/public/index.defined.ts | 1 + src/elements/public/index.ts | 1 + src/server/hapi/createDataset.ts | 18 + src/server/hapi/defaults.ts | 16 + src/server/hapi/links.ts | 8 + .../translations/user-invitation-form/en.json | 94 +++ src/utils/get-gravatar-url.ts | 8 + web-test-runner.groups.js | 4 + 17 files changed, 1248 insertions(+) create mode 100644 src/elements/public/UserInvitationForm/UserInvitationForm.stories.ts create mode 100644 src/elements/public/UserInvitationForm/UserInvitationForm.test.ts create mode 100644 src/elements/public/UserInvitationForm/UserInvitationForm.ts create mode 100644 src/elements/public/UserInvitationForm/index.ts create mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts create mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/index.ts create mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts create mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts create mode 100644 src/elements/public/UserInvitationForm/types.ts create mode 100644 src/static/translations/user-invitation-form/en.json create mode 100644 src/utils/get-gravatar-url.ts diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.stories.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.stories.ts new file mode 100644 index 00000000..78d03174 --- /dev/null +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.stories.ts @@ -0,0 +1,35 @@ +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/user_invitations/0', + parent: 'https://demo.api/hapi/user_invitations', + nucleon: true, + localName: 'foxy-user-invitation-form', + translatable: true, + configurable: { + sections: ['header'], + buttons: ['delete', 'create', 'accept', 'reject', 'resend', 'revoke', 'leave', 'invite-again'], + inputs: ['user:email', 'store:store-domain', 'store:store-email', 'store:store-url'], + }, +}; + +const extAdmin = `default-domain="foxycart.com" layout="admin"`; +const extUser = `default-domain="foxycart.com" layout="user"`; + +export default getMeta(summary); + +export const AdminLayoutPlayground = getStory({ ...summary, ext: extAdmin, code: true }); +export const UserLayoutPlayground = getStory({ ...summary, ext: extUser, code: true }); +export const AdminLayoutEmpty = getStory({ ...summary, ext: extAdmin }); +export const UserLayoutEmpty = getStory({ ...summary, ext: extUser }); +export const Error = getStory({ ...summary, ext: extAdmin }); +export const Busy = getStory({ ...summary, ext: extAdmin }); + +AdminLayoutEmpty.args.href = ''; +UserLayoutEmpty.args.href = ''; +Error.args.href = 'https://demo.api/virtual/empty?status=404'; +Busy.args.href = 'https://demo.api/virtual/stall'; diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts new file mode 100644 index 00000000..50d9cb07 --- /dev/null +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts @@ -0,0 +1,574 @@ +import type { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; +import type { FetchEvent } from '../NucleonElement/FetchEvent'; +import type { Data } from './types'; + +import './index'; + +import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { UserInvitationForm as Form } from './UserInvitationForm'; +import { InternalForm } from '../../internal/InternalForm'; +import { createRouter } from '../../../server'; +import { getTestData } from '../../../testgen/getTestData'; +import { getByKey } from '../../../testgen/getByKey'; +import { spy } from 'sinon'; + +describe('UserInvitationForm', () => { + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-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-internal-user-invitation-form-async-action', () => { + expect(customElements.get('foxy-internal-user-invitation-form-async-action')).to.exist; + }); + + it('imports and defines foxy-internal-user-invitation-form-sync-action', () => { + expect(customElements.get('foxy-internal-user-invitation-form-sync-action')).to.exist; + }); + + it('defines itself as foxy-user-invitation-form', () => { + expect(customElements.get('foxy-user-invitation-form')).to.equal(Form); + }); + + it('has a default i18next namespace of "user-invitation-form"', () => { + expect(Form.defaultNS).to.equal('user-invitation-form'); + expect(new Form().ns).to.equal('user-invitation-form'); + }); + + it('has a reactive property "defaultDomain"', () => { + expect(new Form()).to.have.property('defaultDomain', null); + expect(Form).to.have.deep.nested.property('properties.defaultDomain', { + attribute: 'default-domain', + }); + }); + + it('has a reactive property "layout"', () => { + expect(new Form()).to.have.property('layout', null); + expect(Form).to.have.deep.nested.property('properties.layout', {}); + }); + + it('extends InternalForm', () => { + expect(new Form()).to.be.instanceOf(InternalForm); + }); + + it('produces "email:v8n_required" error when email is empty', () => { + const form = new Form(); + expect(form.errors).to.include('email:v8n_required'); + form.edit({ email: 'test@example.com' }); + expect(form.errors).not.to.include('email:v8n_required'); + }); + + it('makes store info always readonly', () => { + const form = new Form(); + expect(form.readonlySelector.matches('store', true)).to.be.true; + }); + + it('always hides timestamps, Submit and Undo buttons', () => { + const form = new Form(); + expect(form.hiddenSelector.matches('timestamps', true)).to.be.true; + expect(form.hiddenSelector.matches('submit', true)).to.be.true; + expect(form.hiddenSelector.matches('undo', true)).to.be.true; + }); + + it('hides Delete button when status is not "rejected"', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('delete', true)).to.be.true; + + const data = await getTestData('./hapi/user_invitations/0'); + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('delete', true)).to.be.true; + + data.status = 'rejected'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('delete', true)).to.be.false; + }); + + it('hides Leave button when status is not "accepted"', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('leave', true)).to.be.true; + + const data = await getTestData('./hapi/user_invitations/0'); + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('leave', true)).to.be.true; + + data.status = 'accepted'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('leave', true)).to.be.false; + }); + + it('hides Revoke button when status is not "accepted" or "sent"', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('revoke', true)).to.be.true; + + const data = await getTestData('./hapi/user_invitations/0'); + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('revoke', true)).to.be.false; + + data.status = 'accepted'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('revoke', true)).to.be.false; + + data.status = 'revoked'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('revoke', true)).to.be.true; + }); + + it('hides Invite Again button when status is not "revoked"', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('invite-again', true)).to.be.true; + + const data = await getTestData('./hapi/user_invitations/0'); + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('invite-again', true)).to.be.true; + + data.status = 'revoked'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('invite-again', true)).to.be.false; + }); + + it('hides Resend, Accept and Reject buttons when status is not "sent"', async () => { + const form = new Form(); + expect(form.hiddenSelector.matches('resend', true)).to.be.true; + expect(form.hiddenSelector.matches('accept', true)).to.be.true; + expect(form.hiddenSelector.matches('reject', true)).to.be.true; + + const data = await getTestData('./hapi/user_invitations/0'); + data.status = 'accepted'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('resend', true)).to.be.true; + expect(form.hiddenSelector.matches('accept', true)).to.be.true; + expect(form.hiddenSelector.matches('reject', true)).to.be.true; + + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('resend', true)).to.be.false; + expect(form.hiddenSelector.matches('accept', true)).to.be.false; + expect(form.hiddenSelector.matches('reject', true)).to.be.false; + }); + + it('produces "error:invitation_exists" general error when invitation for the email already exists', async () => { + const form = await fixture
(html``); + + form.data = await getTestData('./hapi/user_invitations/0'); + form.addEventListener('fetch', (evt: Event) => { + const event = evt as FetchEvent; + const body = JSON.stringify({ + _embedded: { + 'fx:errors': [ + { message: 'Error: invitation has already been created for this email and store.' }, + ], + }, + }); + + event.respondWith(Promise.resolve(new Response(body, { status: 400 }))); + }); + + form.edit({ email: 'test@example.com' }); + form.submit(); + + await waitUntil(() => !!form.in('idle')); + expect(form.errors).to.include('error:invitation_exists'); + }); + + it('produces "error:already_has_access" general error when the email is already associated with the store', async () => { + const form = await fixture(html``); + + form.data = await getTestData('./hapi/user_invitations/0'); + form.addEventListener('fetch', (evt: Event) => { + const event = evt as FetchEvent; + const body = JSON.stringify({ + _embedded: { + 'fx:errors': [{ message: 'Error: user already has access to this store.' }], + }, + }); + + event.respondWith(Promise.resolve(new Response(body, { status: 400 }))); + }); + + form.edit({ email: 'test@example.com' }); + form.submit(); + + await waitUntil(() => !!form.in('idle')); + expect(form.errors).to.include('error:already_has_access'); + }); + + it('renders header in template admin layout', async () => { + const form = await fixture(html` + + `); + + const renderHeaderSpy = spy(form, 'renderHeader'); + await form.requestUpdate(); + expect(renderHeaderSpy).to.have.been.called; + }); + + it('renders email field in template admin layout', async () => { + const form = await fixture(html` + + `); + + const email = form.renderRoot.querySelector('foxy-internal-text-control[infer="email"]'); + expect(email).to.exist; + }); + + it('renders splash screen in template user layout', async () => { + const form = await fixture(html` + + `); + + const spinner = form.renderRoot.querySelector('foxy-spinner[infer="unavailable"]'); + expect(spinner).to.exist; + expect(spinner).to.have.attribute('layout', 'vertical'); + expect(spinner).to.have.attribute('state', 'empty'); + }); + + it('renders gravatar in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil( + () => !!form.renderRoot.querySelector('img[data-testid="gravatar"]'), + undefined, + { timeout: 5000 } + ); + + const gravatar = form.renderRoot.querySelector('img[data-testid="gravatar"]'); + expect(gravatar).to.exist; + expect(gravatar).to.have.attribute( + 'src', + 'https://www.gravatar.com/avatar/bd78de94bcefac7efde2e44ec8199ba1a484adc08eb6ddad887e10e225266e51?s=256&d=identicon' + ); + }); + + it('renders full name in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil( + () => !!form.renderRoot.querySelector('foxy-i18n[key="full_name"]'), + undefined, + { timeout: 5000 } + ); + + const fullName = form.renderRoot.querySelector('foxy-i18n[key="full_name"]'); + expect(fullName).to.exist; + expect(fullName).to.have.attribute('infer', ''); + expect(fullName).to.have.deep.property('options', { + first_name: 'Sally', + last_name: 'Sims', + context: '', + }); + }); + + it('renders full name in snapshot admin layout (empty name)', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil( + () => !!form.renderRoot.querySelector('foxy-i18n[key="full_name"]'), + undefined, + { timeout: 5000 } + ); + + form.data!.first_name = ''; + form.data!.last_name = ''; + form.data = { ...form.data! }; + await form.requestUpdate(); + + const fullName = form.renderRoot.querySelector('foxy-i18n[key="full_name"]'); + expect(fullName).to.exist; + expect(fullName).to.have.attribute('infer', ''); + expect(fullName).to.have.deep.property('options', { + first_name: '', + last_name: '', + context: 'empty', + }); + }); + + it('renders email in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + expect(form.renderRoot).to.include.text(form.data!.email); + }); + + it('renders status info in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + + const title = await getByKey(form, 'admin_status_title'); + const text = await getByKey(form, 'admin_status_text'); + expect(title).to.have.attribute('infer', ''); + expect(text).to.have.attribute('infer', ''); + + for (const status of ['sent', 'accepted', 'rejected', 'revoked'] as const) { + form.data!.status = status; + await form.requestUpdate(); + expect(title).to.have.deep.property('options', { context: status }); + expect(text).to.have.deep.property('options', { context: status }); + } + }); + + it('renders sync action for inviting user again in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-sync-action[infer="invite-again"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('status', 'sent'); + }); + + it('renders sync action for revoking access in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-sync-action[infer="revoke"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('status', 'revoked'); + expect(action).to.have.attribute('theme', 'error'); + }); + + it('renders async action for resending invitation in snapshot admin layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-async-action[infer="resend"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('href', form.data!._links['fx:resend'].href); + }); + + it('renders status info in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const title = await getByKey(form, 'user_status_title'); + const text = await getByKey(form, 'user_status_text'); + + expect(title).to.have.attribute('infer', ''); + expect(text).to.have.attribute('infer', ''); + + for (const status of ['sent', 'accepted', 'rejected', 'revoked'] as const) { + form.data!.status = status; + await form.requestUpdate(); + + expect(title).to.have.attribute('infer', ''); + expect(title).to.have.deep.property('options', { + store_name: 'Example Store', + context: status, + }); + + expect(text).to.have.attribute('infer', ''); + expect(text).to.have.deep.property('options', { + store_name: 'Example Store', + context: status, + }); + } + }); + + it('renders store info in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const summary = form.renderRoot.querySelector('foxy-internal-summary-control[infer="store"]'); + expect(summary).to.exist; + + const storeDomain = summary?.querySelector( + 'foxy-internal-text-control[infer="store-domain"]' + ); + const storeEmail = summary?.querySelector( + 'foxy-internal-text-control[infer="store-email"]' + ); + const storeUrl = summary?.querySelector( + 'foxy-internal-text-control[infer="store-url"]' + ); + + expect(storeDomain).to.exist; + expect(storeDomain).to.have.attribute('layout', 'summary-item'); + expect(storeDomain?.getValue()).to.equal('example.foxycart.com'); + + expect(storeEmail).to.exist; + expect(storeEmail).to.have.attribute('layout', 'summary-item'); + + expect(storeUrl).to.exist; + expect(storeUrl).to.have.attribute('layout', 'summary-item'); + }); + + it('renders sync action for leaving the store in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-sync-action[infer="leave"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('status', 'revoked'); + expect(action).to.have.attribute('theme', 'error'); + }); + + it('renders sync action for rejecting the invitation in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-sync-action[infer="reject"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('status', 'rejected'); + expect(action).to.have.attribute('theme', 'error primary'); + }); + + it('renders sync action for accepting the invitation in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const action = form.renderRoot.querySelector( + 'foxy-internal-user-invitation-form-sync-action[infer="accept"]' + ); + + expect(action).to.exist; + expect(action).to.have.attribute('status', 'accepted'); + expect(action).to.have.attribute('theme', 'success primary'); + }); + + it('renders Delete button in snapshot user layout', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + const button = form.renderRoot.querySelector('foxy-internal-delete-control'); + expect(button).to.exist; + expect(button).to.have.attribute('infer', 'delete'); + }); +}); diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.ts new file mode 100644 index 00000000..f2697150 --- /dev/null +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.ts @@ -0,0 +1,327 @@ +import type { CSSResultArray, PropertyDeclarations } from 'lit-element'; +import type { TemplateResult } from 'lit-html'; +import type { NucleonV8N } from '../NucleonElement/types'; +import type { Data } from './types'; + +import { TranslatableMixin } from '../../../mixins/translatable'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { getGravatarUrl } from '../../../utils/get-gravatar-url'; +import { html, svg, css } from 'lit-element'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { asyncReplace } from 'lit-html/directives/async-replace'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { classMap } from '../../../utils/class-map'; + +const NS = 'user-invitation-form'; +const Base = TranslatableMixin(InternalForm, NS); + +export class UserInvitationForm extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultDomain: { attribute: 'default-domain' }, + layout: {}, + }; + } + + static get styles(): CSSResultArray { + return [ + ...super.styles, + css` + .inner-curve { + --r: var(--lumo-border-radius-l); /* control the rounded part */ + --s: 3rem; /* control the size of the cut */ + --a: 12deg; /* control the depth of the curvature */ + --m: 0 / calc(2 * var(--r)) var(--r) no-repeat + radial-gradient(50% 100% at bottom, #000 calc(100% - 1px), #0000); + --d: (var(--s) + var(--r)) * cos(var(--a)); + + border-radius: var(--r); + width: 100%; + mask: calc(50% + var(--d)) var(--m), calc(50% - var(--d)) var(--m), + radial-gradient( + var(--s) at 50% calc(-1 * sin(var(--a)) * var(--s)), + #0000 100%, + #000 calc(100% + 1px) + ) + 0 calc(var(--r) * (1 - sin(var(--a)))) no-repeat, + linear-gradient(90deg, #000 calc(50% - var(--d)), #0000 0 calc(50% + var(--d)), #000 0); + } + `, + ]; + } + + static get v8n(): NucleonV8N { + return [({ email: v }) => !!v || 'email:v8n_required']; + } + + /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ + defaultDomain: string | null = null; + + /** Admin layout will display user info, user layout (default) will display store info. */ + layout: 'admin' | 'user' | null = null; + + private readonly __storeDomainGetValue = () => { + const defaultD = this.defaultDomain; + const domain = this.data?.store_domain; + return domain?.includes('.') || !defaultD ? domain : `${domain}.${defaultD}`; + }; + + get readonlySelector(): BooleanSelector { + const alwaysMatch = ['store', super.readonlySelector.toString()]; + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + get hiddenSelector(): BooleanSelector { + const alwaysMatch = ['timestamps', 'submit', 'undo', super.hiddenSelector.toString()]; + const status = this.data?.status; + + if (status !== 'accepted' && status !== 'sent') alwaysMatch.unshift('revoke'); + if (status !== 'rejected') alwaysMatch.unshift('delete'); + if (status !== 'accepted') alwaysMatch.unshift('leave'); + if (status !== 'revoked') alwaysMatch.unshift('invite-again'); + if (status !== 'sent') alwaysMatch.unshift('resend', 'accept', 'reject'); + + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + renderBody(): TemplateResult { + const { layout, data } = this; + + if (layout === 'admin') { + return data ? this.__renderAdminSnapshotState(data) : this.__renderAdminTemplateState(); + } else { + return data ? this.__renderUserSnapshotState(data) : this.__renderUserTemplateState(); + } + } + + protected async _fetch(...args: Parameters): Promise { + try { + return await super._fetch(...args); + } catch (err) { + let message; + + try { + message = (await (err as Response).json())._embedded['fx:errors'][0].message; + } catch { + throw err; + } + + if (message.includes('already been created for this email and store')) { + throw ['error:invitation_exists']; + } else if (message.includes('already has access to this store')) { + throw ['error:already_has_access']; + } else { + throw err; + } + } + } + + private async *__getGravatar(email?: string) { + yield html` +
+ `; + + if (email) { + yield html` + + `; + } + } + + private __renderAdminTemplateState() { + return html` + ${this.renderHeader()} + + ${super.renderBody()} + `; + } + + private __renderUserTemplateState() { + return html` +
+ +
+ `; + } + + private __renderAdminSnapshotState({ first_name, last_name }: Data) { + const hasName = first_name?.trim() || last_name?.trim(); + const nameOptions = { first_name, last_name, context: hasName ? '' : 'empty' }; + const hidden = this.hiddenSelector; + + return html` +
+
+
+ ${asyncReplace(this.__getGravatar(this.data?.email))} + +
+
+ + +
${this.data?.email}
+
+ +
+

+ ${this.data?.status === 'revoked' + ? svg`` + : this.data?.status === 'sent' + ? svg`` + : this.data?.status === 'rejected' + ? svg`` + : this.data?.status === 'accepted' + ? svg`` + : ''} + + +

+ +

+ + +

+
+ + + + +
+ + + + + +
+
+
+
+ `; + } + + private __renderUserSnapshotState({ status, store_name }: Data) { + const hidden = this.hiddenSelector; + const textColorMap = { + 'text-primary': status === 'sent', + 'text-success': status === 'accepted', + 'text-error': status === 'rejected', + 'text-body': status === 'revoked', + }; + + return html` +
+
+ ${svg``} +
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + `; + } +} diff --git a/src/elements/public/UserInvitationForm/index.ts b/src/elements/public/UserInvitationForm/index.ts new file mode 100644 index 00000000..1052ae34 --- /dev/null +++ b/src/elements/public/UserInvitationForm/index.ts @@ -0,0 +1,12 @@ +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalTextControl/index'; +import '../../internal/InternalForm/index'; + +import './internal/InternalUserInvitationFormAsyncAction/index'; +import './internal/InternalUserInvitationFormSyncAction/index'; + +import { UserInvitationForm } from './UserInvitationForm'; + +customElements.define('foxy-user-invitation-form', UserInvitationForm); + +export { UserInvitationForm }; diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts new file mode 100644 index 00000000..51663c6a --- /dev/null +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts @@ -0,0 +1,55 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; + +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-element'; + +export class InternalUserInvitationFormAsyncAction extends InternalControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + __state: { type: String }, + theme: { type: String }, + href: { type: String }, + }; + } + + theme: string | null = null; + + href: string | null = null; + + private __state = 'idle'; + + renderControl(): TemplateResult { + const state = this.__state; + const theme = state === 'fail' ? 'error' : state === 'idle' ? this.theme : ''; + + return html` + + + + `; + } + + private async __submit(): Promise { + if (this.__state === 'busy') return; + + try { + this.__state = 'busy'; + + const api = new NucleonElement.API(this); + const response = await api.fetch(this.href ?? '', { method: 'POST' }); + + this.__state = response.ok ? 'idle' : 'fail'; + if (response.ok) this.dispatchEvent(new CustomEvent('done')); + } catch { + this.__state = 'fail'; + } + } +} diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/index.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/index.ts new file mode 100644 index 00000000..e1bff4d3 --- /dev/null +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/index.ts @@ -0,0 +1,9 @@ +import '@vaadin/vaadin-button'; +import '../../../../internal/InternalControl/index'; +import '../../../I18n/index'; + +import { InternalUserInvitationFormAsyncAction as Control } from './InternalUserInvitationFormAsyncAction'; + +customElements.define('foxy-internal-user-invitation-form-async-action', Control); + +export { Control as InternalUserInvitationFormAsyncAction }; diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts new file mode 100644 index 00000000..a8dc9795 --- /dev/null +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts @@ -0,0 +1,34 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { UserInvitationForm } from '../../UserInvitationForm'; +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 InternalUserInvitationFormSyncAction extends InternalControl { + static get properties(): PropertyDeclarations { + return { ...super.properties, status: {}, theme: {} }; + } + + status: Data['status'] | null = null; + + theme: string | null = null; + + renderControl(): TemplateResult { + return html` + { + const nucleon = this.nucleon as UserInvitationForm | null; + const status = this.status; + if (status) nucleon?.edit({ status }), nucleon?.submit(); + }} + > + + + `; + } +} diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts new file mode 100644 index 00000000..b69dfe82 --- /dev/null +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts @@ -0,0 +1,12 @@ +import '@vaadin/vaadin-button'; +import '../../../../internal/InternalControl/index'; +import '../../../I18n/index'; + +import { InternalUserInvitationFormSyncAction } from './InternalUserInvitationFormSyncAction'; + +customElements.define( + 'foxy-internal-user-invitation-form-sync-action', + InternalUserInvitationFormSyncAction +); + +export { InternalUserInvitationFormSyncAction }; diff --git a/src/elements/public/UserInvitationForm/types.ts b/src/elements/public/UserInvitationForm/types.ts new file mode 100644 index 00000000..79ced444 --- /dev/null +++ b/src/elements/public/UserInvitationForm/types.ts @@ -0,0 +1,40 @@ +import type { + CollectionGraphLinks, + CollectionGraphProps, +} from '@foxy.io/sdk/dist/types/core/defaults'; + +import type { Graph, Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +// TODO: replace with SDK import when SDK has the types +export interface UserInvitation extends Graph { + curie: 'fx:user_invitation'; + links: { + 'self': UserInvitation; + 'fx:user': Rels.User; + 'fx:store': Rels.Store; + 'fx:resend': { curie: 'fx:resend' }; + }; + props: { + store_url: string; + store_name: string; + store_email: string; + store_domain: string; + first_name: string | null; + last_name: string | null; + email: string; + status: 'sent' | 'accepted' | 'rejected' | 'revoked'; + date_created: string; + date_modified: string; + }; +} + +// TODO: replace with SDK import when SDK has the types +export interface UserInvitations extends Graph { + curie: 'fx:user_invitations'; + links: CollectionGraphLinks; + props: CollectionGraphProps; + child: UserInvitation; +} + +export type Data = Resource; diff --git a/src/elements/public/index.defined.ts b/src/elements/public/index.defined.ts index 7b5172ae..9e5e77ed 100644 --- a/src/elements/public/index.defined.ts +++ b/src/elements/public/index.defined.ts @@ -111,6 +111,7 @@ export { TransactionsTable } from './TransactionsTable/index'; export { UpdatePaymentMethodForm } from './UpdatePaymentMethodForm/index'; export { UserCard } from './UserCard/index'; export { UserForm } from './UserForm/index'; +export { UserInvitationForm } from './UserInvitationForm/index'; export { UsersTable } from './UsersTable/index'; export { WebhookCard } from './WebhookCard/index'; export { WebhookForm } from './WebhookForm/index'; diff --git a/src/elements/public/index.ts b/src/elements/public/index.ts index da7b1509..4fe03129 100644 --- a/src/elements/public/index.ts +++ b/src/elements/public/index.ts @@ -111,6 +111,7 @@ export { TransactionsTable } from './TransactionsTable/TransactionsTable'; export { UpdatePaymentMethodForm } from './UpdatePaymentMethodForm/UpdatePaymentMethodForm'; export { UserCard } from './UserCard/UserCard'; export { UserForm } from './UserForm/UserForm'; +export { UserInvitationForm } from './UserInvitationForm/UserInvitationForm'; export { UsersTable } from './UsersTable/UsersTable'; export { WebhookCard } from './WebhookCard/WebhookCard'; export { WebhookForm } from './WebhookForm/WebhookForm'; diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index 5454d606..358e4662 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -1963,4 +1963,22 @@ export const createDataset: () => Dataset = () => ({ date_modified: '2018-01-01T04:30:53-0700', }, ], + + user_invitations: [ + { + id: 0, + store_id: 0, + user_id: 0, + store_url: 'https://example.com', + store_name: 'Example Store', + store_email: 'admin@example.com', + store_domain: 'example', + first_name: 'Sally', + last_name: 'Sims', + email: 'sally.sims@example.com', + status: 'sent', + date_created: '2022-12-01T10:07:05-0800', + date_modified: '2022-12-01T10:07:05-0800', + }, + ], }); diff --git a/src/server/hapi/defaults.ts b/src/server/hapi/defaults.ts index 0c9b39b1..22a7a449 100644 --- a/src/server/hapi/defaults.ts +++ b/src/server/hapi/defaults.ts @@ -1070,4 +1070,20 @@ export const defaults: Defaults = { date_created: new Date().toISOString(), date_modified: new Date().toISOString(), }), + + user_invitations: (query, dataset) => ({ + id: increment('user_invitations', dataset), + store_id: parseInt(query.get('store_id') ?? '0'), + user_id: parseInt(query.get('user_id') ?? '0'), + store_url: '', + store_name: '', + store_email: '', + store_domain: '', + first_name: '', + last_name: '', + email: '', + status: 'sent', + date_created: new Date().toISOString(), + date_modified: new Date().toISOString(), + }), }; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index de950110..6eb9faf9 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -163,6 +163,7 @@ export const links: Links = { 'fx:cart_templates': { href: `./cart_templates?store_id=${id}` }, 'fx:email_templates': { href: `./email_templates?store_id=${id}` }, 'fx:item_categories': { href: `./item_categories?store_id=${id}` }, + 'fx:user_invitations': { href: `./user_invitations?store_id=${id}` }, 'fx:fraud_protections': { href: `./fraud_protections?store_id=${id}` }, 'fx:receipt_templates': { href: `./receipt_templates?store_id=${id}` }, 'fx:checkout_templates': { href: `./checkout_templates?store_id=${id}` }, @@ -228,6 +229,7 @@ export const links: Links = { 'fx:stores': { href: `./stores?user_id=${id}` }, 'fx:attributes': { href: `./user_attributes?user_id=${id}` }, 'fx:default_store': { href: `./stores/${default_store_id}` }, + 'fx:user_invitations': { href: `./user_invitations?user_id=${id}` }, }), template_configs: ({ store_id, id }) => ({ @@ -545,4 +547,10 @@ export const links: Links = { native_integrations: ({ store_id }) => ({ 'fx:store': { href: `./stores/${store_id}` }, }), + + user_invitations: ({ user_id }) => ({ + 'fx:resend': { href: 'https://demo.api/virtual/empty?status=200' }, + 'fx:store': { href: `./stores/${user_id}` }, + 'fx:user': { href: `./users/${user_id}` }, + }), }; diff --git a/src/static/translations/user-invitation-form/en.json b/src/static/translations/user-invitation-form/en.json new file mode 100644 index 00000000..2e4c9b47 --- /dev/null +++ b/src/static/translations/user-invitation-form/en.json @@ -0,0 +1,94 @@ +{ + "header": { + "title_new": "New store admin" + }, + "admin_status_title_revoked": "Access revoked", + "admin_status_title_sent": "Invitation sent", + "admin_status_title_rejected": "Invitation rejected", + "admin_status_title_accepted": "This user is a store admin", + "admin_status_text_revoked": "This user may have been an admin in the past but has no access to this store at the moment. We will keep this record for historical purposes.", + "admin_status_text_sent": "We've sent an email to this user asking them to join this store. Once they accept, they will be granted full admin access.", + "admin_status_text_rejected": "If this was a mistake, ask the user to delete this invitation in profile settings. You will be able to invite them again after that.", + "admin_status_text_accepted": "They have full access to this store, including the ability to add and remove users.", + "user_status_title_revoked": "You no longer have access to {{ store_name }}", + "user_status_text_revoked": "If you'd like to join this store again, please ask the store owner to reactivate your access in settings.", + "user_status_title_sent": "You've been invited to join {{ store_name }} as administrator", + "user_status_text_sent": "Accepting this invitation will grant you full access to this store. Rejecting it will prevent this store from inviting you again.", + "user_status_title_rejected": "You've rejected an invitation to join {{ store_name }} as administrator", + "user_status_text_rejected": "This store won't be able to contact you again. If you changed your mind, delete this invitation and ask the store owner to invite you again.", + "user_status_title_accepted": "You're a store administrator at {{ store_name }}", + "user_status_text_accepted": "If you'd like to leave this store, you can do so by pressing the button below. Please note that if you'd like to join again, the store owner will need to invite you.", + "full_name": "{{ first_name }} {{ last_name }}", + "full_name_empty": "Unknown User", + "leave": { + "caption": "Leave this store" + }, + "revoke": { + "caption": "Revoke access" + }, + "invite-again": { + "caption": "Invite again" + }, + "resend": { + "idle": "Resend email", + "busy": "Resending...", + "fail": "Failed to resend", + "confirm": { + "header": "Resend email", + "message": "Please confirm that you'd like to resend the invitation email to this user.", + "confirm": "Resend", + "cancel": "Cancel" + } + }, + "error": { + "invitation_exists": "This user has already been invited to this store.", + "already_has_access": "This user already has access to this store." + }, + "store": { + "label": "", + "helper_text": "", + "store-domain": { + "label": "Store domain", + "placeholder": "Unknown", + "helper_text": "" + }, + "store-url": { + "label": "Website", + "placeholder": "Unknown", + "helper_text": "" + }, + "store-email": { + "label": "Contact email", + "placeholder": "Unknown", + "helper_text": "" + } + }, + "email": { + "label": "Email", + "placeholder": "Required", + "helper_text": "If this user doesn't have an account with us, they will be able to create one.", + "v8n_required": "Please enter an email address." + }, + "accept": { + "caption": "Accept" + }, + "reject": { + "caption": "Reject" + }, + "create": { + "caption": "Send invitation" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "Deleting an invitation allows the store owner to invite you again. Would you like to proceed?" + }, + "unavailable": { + "loading_empty": "Creating an invitation is not available in this context." + }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } +} \ No newline at end of file diff --git a/src/utils/get-gravatar-url.ts b/src/utils/get-gravatar-url.ts new file mode 100644 index 00000000..2cd7cb6b --- /dev/null +++ b/src/utils/get-gravatar-url.ts @@ -0,0 +1,8 @@ +export async function getGravatarUrl(email: string): Promise { + const encoder = new TextEncoder(); + const buffer = await crypto.subtle.digest('SHA-256', encoder.encode(email)); + const array = Array.from(new Uint8Array(buffer)); + const hex = array.map(b => b.toString(16).padStart(2, '0')).join(''); + + return `https://www.gravatar.com/avatar/${hex}?s=256&d=identicon`; +} diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index ea0e09fb..e8021a6b 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -663,6 +663,10 @@ export const groups = [ name: 'foxy-user-form', files: './src/elements/public/UserForm/**/*.test.ts', }, + { + name: 'foxy-user-invitation-form', + files: './src/elements/public/UserInvitationForm/**/*.test.ts', + }, { name: 'foxy-users-table', files: './src/elements/public/UsersTable/**/*.test.ts', From 2a0888f0724132c868ffc5f9df9362e0469afea2 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 19 Sep 2024 22:58:39 -0300 Subject: [PATCH 02/26] feat: add `foxy-user-invitation-card` element --- .../UserInvitationCard.stories.ts | 29 +++ .../UserInvitationCard.test.ts | 219 ++++++++++++++++++ .../UserInvitationCard/UserInvitationCard.ts | 119 ++++++++++ .../public/UserInvitationCard/index.ts | 8 + .../public/UserInvitationCard/types.ts | 1 + src/elements/public/index.defined.ts | 1 + src/elements/public/index.ts | 1 + .../translations/user-invitation-card/en.json | 17 ++ web-test-runner.groups.js | 4 + 9 files changed, 399 insertions(+) create mode 100644 src/elements/public/UserInvitationCard/UserInvitationCard.stories.ts create mode 100644 src/elements/public/UserInvitationCard/UserInvitationCard.test.ts create mode 100644 src/elements/public/UserInvitationCard/UserInvitationCard.ts create mode 100644 src/elements/public/UserInvitationCard/index.ts create mode 100644 src/elements/public/UserInvitationCard/types.ts create mode 100644 src/static/translations/user-invitation-card/en.json diff --git a/src/elements/public/UserInvitationCard/UserInvitationCard.stories.ts b/src/elements/public/UserInvitationCard/UserInvitationCard.stories.ts new file mode 100644 index 00000000..9d21e814 --- /dev/null +++ b/src/elements/public/UserInvitationCard/UserInvitationCard.stories.ts @@ -0,0 +1,29 @@ +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/user_invitations/0', + parent: 'https://demo.api/hapi/user_invitations', + nucleon: true, + localName: 'foxy-user-invitation-card', + translatable: true, + configurable: { sections: ['title', 'subtitle'] }, +}; + +const extUser = `default-domain="foxycart.com" layout="user"`; +const extAdmin = `default-domain="foxycart.com" layout="admin"`; + +export default getMeta(summary); + +export const AdminLayoutPlayground = getStory({ ...summary, ext: extAdmin, code: true }); +export const UserLayoutPlayground = getStory({ ...summary, ext: extUser, code: true }); +export const Empty = getStory(summary); +export const Error = getStory(summary); +export const Busy = getStory(summary); + +Empty.args.href = ''; +Error.args.href = 'https://demo.api/virtual/empty?status=404'; +Busy.args.href = 'https://demo.api/virtual/stall'; diff --git a/src/elements/public/UserInvitationCard/UserInvitationCard.test.ts b/src/elements/public/UserInvitationCard/UserInvitationCard.test.ts new file mode 100644 index 00000000..109953e2 --- /dev/null +++ b/src/elements/public/UserInvitationCard/UserInvitationCard.test.ts @@ -0,0 +1,219 @@ +import type { FetchEvent } from '../NucleonElement/FetchEvent'; + +import './index'; + +import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { UserInvitationCard as Card } from './UserInvitationCard'; +import { createRouter } from '../../../server/index'; +import { InternalCard } from '../../internal/InternalCard/InternalCard'; +import { getByKey } from '../../../testgen/getByKey'; + +describe('UserInvitationCard', () => { + 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-card', () => { + expect(customElements.get('foxy-internal-card')).to.exist; + }); + + it('imports and defines foxy-spinner', () => { + expect(customElements.get('foxy-spinner')).to.exist; + }); + + it('imports and defines foxy-i18n', () => { + expect(customElements.get('foxy-i18n')).to.exist; + }); + + it('defines itself as foxy-user-invitation-card', () => { + expect(customElements.get('foxy-user-invitation-card')).to.equal(Card); + }); + + it('has a default i18next namespace of user-invitation-card', () => { + expect(Card.defaultNS).to.equal('user-invitation-card'); + expect(new Card().ns).to.equal('user-invitation-card'); + }); + + it('has a reactive property "defaultDomain"', () => { + expect(new Card()).to.have.property('defaultDomain', null); + expect(Card).to.have.deep.nested.property('properties.defaultDomain', { + attribute: 'default-domain', + }); + }); + + it('has a reactive property "layout"', () => { + expect(new Card()).to.have.property('layout', null); + expect(Card).to.have.deep.nested.property('properties.layout', {}); + }); + + it('extends InternalCard', () => { + expect(new Card()).to.be.instanceOf(InternalCard); + }); + + it('renders store name as title when layout is user', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + expect(card.renderRoot).to.include.text('Example Store'); + }); + + it('renders status info as subtitle when layout is user', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + const status = await getByKey(card, 'status'); + + expect(status).to.exist; + expect(status).to.have.attribute('infer', ''); + expect(status).to.have.deep.property('options', { + context: 'user_sent', + domain: 'example.foxycart.com', + }); + }); + + it('renders store ID when layout is user', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + expect(card.renderRoot).to.include.text('ID 0'); + }); + + it('renders gravatar when layout is admin', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.renderRoot.querySelector('img'), undefined, { timeout: 5000 }); + const img = card.renderRoot.querySelector('img') as HTMLImageElement; + expect(img.src).to.equal( + 'https://www.gravatar.com/avatar/bd78de94bcefac7efde2e44ec8199ba1a484adc08eb6ddad887e10e225266e51?s=256&d=identicon' + ); + }); + + it('renders full name in title when layout is admin', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + const title = await getByKey(card, 'full_name'); + + expect(title).to.exist; + expect(title).to.have.attribute('infer', ''); + expect(title).to.have.deep.property('options', { + first_name: 'Sally', + last_name: 'Sims', + context: '', + }); + }); + + it('renders No Name in title when layout is admin and there is no name', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + card.data!.first_name = ''; + card.data!.last_name = ''; + await card.requestUpdate(); + + const title = await getByKey(card, 'full_name'); + expect(title).to.exist; + expect(title).to.have.attribute('infer', ''); + expect(title).to.have.deep.property('options', { + first_name: '', + last_name: '', + context: 'empty', + }); + }); + + it('renders status info as subtitle when layout is admin', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + const status = await getByKey(card, 'status'); + + expect(status).to.exist; + expect(status).to.have.attribute('infer', ''); + expect(status).to.have.deep.property('options', { + context: 'admin_sent', + email: 'sally.sims@example.com', + }); + }); + + it('renders user ID if available when layout is admin', async () => { + const router = createRouter(); + const card = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!card.data, undefined, { timeout: 5000 }); + await card.requestUpdate(); + expect(card.renderRoot).to.include.text('ID 0'); + }); +}); diff --git a/src/elements/public/UserInvitationCard/UserInvitationCard.ts b/src/elements/public/UserInvitationCard/UserInvitationCard.ts new file mode 100644 index 00000000..178835c5 --- /dev/null +++ b/src/elements/public/UserInvitationCard/UserInvitationCard.ts @@ -0,0 +1,119 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { Data } from './types'; + +import { TranslatableMixin } from '../../../mixins/translatable'; +import { getGravatarUrl } from '../../../utils/get-gravatar-url'; +import { getResourceId } from '@foxy.io/sdk/core'; +import { asyncReplace } from 'lit-html/directives/async-replace'; +import { InternalCard } from '../../internal/InternalCard/InternalCard'; +import { html } from 'lit-html'; + +const NS = 'user-invitation-card'; +const Base = TranslatableMixin(InternalCard, NS); + +export class UserInvitationCard extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultDomain: { attribute: 'default-domain' }, + layout: {}, + }; + } + + /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ + defaultDomain: string | null = null; + + /** Admin layout will display user info, user layout (default) will display store info. */ + layout: 'admin' | 'user' | null = null; + + renderBody(): TemplateResult { + const layout = this.layout ?? 'user'; + + if (layout === 'admin') { + const { first_name, last_name, status, email } = this.data ?? {}; + const hasName = first_name?.trim() || last_name?.trim(); + const titleOptions = { first_name, last_name, context: hasName ? '' : 'empty' }; + const subtitleOptions = { context: `admin_${status}`, email }; + const userLink = this.data?._links['fx:user']?.href; + + return html` +
+ ${asyncReplace(this.__getGravatar(this.data?.email))} + +
+
+ + + ${userLink + ? html` + + ID ${getResourceId(userLink)} + + ` + : ''} +
+ + +
+
+ `; + } + + const storeLink = this.data?._links['fx:store'].href; + const defaultD = this.defaultDomain; + const d = this.data?.store_domain; + const domain = d?.includes('.') || !defaultD ? d : `${d}.${defaultD}`; + const subtitleOptions = { context: `user_${this.data?.status}`, domain }; + + return html` +
+
+ + ${this.data?.store_name}​ + + ${storeLink + ? html` + + ID ${getResourceId(storeLink)} + + ` + : ''} +
+ + +
+ `; + } + + private async *__getGravatar(email?: string) { + yield html`
`; + + if (email) { + yield html` + + `; + } + } +} diff --git a/src/elements/public/UserInvitationCard/index.ts b/src/elements/public/UserInvitationCard/index.ts new file mode 100644 index 00000000..406e577b --- /dev/null +++ b/src/elements/public/UserInvitationCard/index.ts @@ -0,0 +1,8 @@ +import '../../internal/InternalCard/index'; +import '../I18n/index'; + +import { UserInvitationCard } from './UserInvitationCard'; + +customElements.define('foxy-user-invitation-card', UserInvitationCard); + +export { UserInvitationCard }; diff --git a/src/elements/public/UserInvitationCard/types.ts b/src/elements/public/UserInvitationCard/types.ts new file mode 100644 index 00000000..d725b2b8 --- /dev/null +++ b/src/elements/public/UserInvitationCard/types.ts @@ -0,0 +1 @@ +export * from '../UserInvitationForm/types'; diff --git a/src/elements/public/index.defined.ts b/src/elements/public/index.defined.ts index 9e5e77ed..31346bb5 100644 --- a/src/elements/public/index.defined.ts +++ b/src/elements/public/index.defined.ts @@ -111,6 +111,7 @@ export { TransactionsTable } from './TransactionsTable/index'; export { UpdatePaymentMethodForm } from './UpdatePaymentMethodForm/index'; export { UserCard } from './UserCard/index'; export { UserForm } from './UserForm/index'; +export { UserInvitationCard } from './UserInvitationCard/index'; export { UserInvitationForm } from './UserInvitationForm/index'; export { UsersTable } from './UsersTable/index'; export { WebhookCard } from './WebhookCard/index'; diff --git a/src/elements/public/index.ts b/src/elements/public/index.ts index 4fe03129..1f100eea 100644 --- a/src/elements/public/index.ts +++ b/src/elements/public/index.ts @@ -111,6 +111,7 @@ export { TransactionsTable } from './TransactionsTable/TransactionsTable'; export { UpdatePaymentMethodForm } from './UpdatePaymentMethodForm/UpdatePaymentMethodForm'; export { UserCard } from './UserCard/UserCard'; export { UserForm } from './UserForm/UserForm'; +export { UserInvitationCard } from './UserInvitationCard/UserInvitationCard'; export { UserInvitationForm } from './UserInvitationForm/UserInvitationForm'; export { UsersTable } from './UsersTable/UsersTable'; export { WebhookCard } from './WebhookCard/WebhookCard'; diff --git a/src/static/translations/user-invitation-card/en.json b/src/static/translations/user-invitation-card/en.json new file mode 100644 index 00000000..7333bcef --- /dev/null +++ b/src/static/translations/user-invitation-card/en.json @@ -0,0 +1,17 @@ +{ + "status_admin_accepted": "{{ email }} • Store admin", + "status_admin_rejected": "{{ email }} • Invitation rejected", + "status_admin_revoked": "{{ email }} • Access revoked", + "status_admin_sent": "{{ email }} • Invited", + "status_user_accepted": "{{ domain }} • Accepted", + "status_user_rejected": "{{ domain }} • Rejected", + "status_user_revoked": "{{ domain }} • Revoked", + "status_user_sent": "{{ domain }} • Pending", + "full_name": "{{ first_name }} {{ last_name }}", + "full_name_empty": "Unknown user", + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No data", + "loading_error": "Unknown error" + } +} \ No newline at end of file diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index e8021a6b..5ea9c265 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -663,6 +663,10 @@ export const groups = [ name: 'foxy-user-form', files: './src/elements/public/UserForm/**/*.test.ts', }, + { + name: 'foxy-user-invitation-card', + files: './src/elements/public/UserInvitationCard/**/*.test.ts', + }, { name: 'foxy-user-invitation-form', files: './src/elements/public/UserInvitationForm/**/*.test.ts', From 410bff62bd56553b90356f626df1733110365e9b Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 19 Sep 2024 23:02:51 -0300 Subject: [PATCH 03/26] feat(foxy-user-card): add gravatar and invitations counter --- src/elements/public/UserCard/UserCard.test.ts | 52 ++++++++++ src/elements/public/UserCard/UserCard.ts | 98 +++++++++++++++++-- src/elements/public/UserCard/index.ts | 1 + 3 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/elements/public/UserCard/UserCard.test.ts b/src/elements/public/UserCard/UserCard.test.ts index 8d96e124..d6bd6f7e 100644 --- a/src/elements/public/UserCard/UserCard.test.ts +++ b/src/elements/public/UserCard/UserCard.test.ts @@ -11,6 +11,8 @@ import { getByTestId } from '../../../testgen/getByTestId'; import { getTestData } from '../../../testgen/getTestData'; import { UserCard } from './UserCard'; import { I18n } from '../I18n/I18n'; +import { getGravatarUrl } from '../../../utils/get-gravatar-url'; +import { NucleonElement } from '../NucleonElement'; describe('UserCard', () => { const OriginalResizeObserver = window.ResizeObserver; @@ -35,6 +37,14 @@ describe('UserCard', () => { expect(UserCard.defaultNS).to.equal('user-card'); }); + it('has a reactive property "showInvitations"', () => { + expect(new UserCard()).to.have.property('showInvitations', false); + expect(UserCard).to.have.deep.nested.property('properties.showInvitations', { + type: Boolean, + attribute: 'show-invitations', + }); + }); + it('extends TwoLineCard', () => { expect(new UserCard()).to.be.instanceOf(TwoLineCard); }); @@ -82,4 +92,46 @@ describe('UserCard', () => { await waitUntil(() => !!element.data, undefined, { timeout: 5000 }); expect(await getByTestId(element, 'subtitle')).to.include.text(data.email); }); + + it('renders gravatar', async () => { + const router = createRouter(); + const href = 'https://demo.api/hapi/users/0'; + const data = await getTestData(href); + const element = await fixture(html` + router.handleEvent(evt)}> + + `); + + await waitUntil(() => !!element.shadowRoot!.querySelector('img'), undefined, { timeout: 5000 }); + const img = element.shadowRoot!.querySelector('img') as HTMLImageElement; + expect(img.src).to.equal(await getGravatarUrl(data.email)); + }); + + it('displays the number of unanswered invitations when "showInvitations" is true', async () => { + const router = createRouter(); + const href = 'https://demo.api/hapi/users/0'; + const element = await fixture(html` + router.handleEvent(evt)}> + + `); + + await waitUntil(() => !!element.data, undefined, { timeout: 5000 }); + const invitationsCount = await getByTestId(element, 'invitations-count'); + expect(invitationsCount?.textContent?.trim()).to.equal('0'); + expect(invitationsCount?.classList.contains('scale-0')).to.be.true; + + element.showInvitations = true; + await element.requestUpdate(); + await waitUntil( + () => { + const nucleons = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...nucleons].every(nucleon => !!nucleon.data); + }, + undefined, + { timeout: 5000 } + ); + await element.requestUpdate(); + expect(invitationsCount?.textContent?.trim()).to.equal('1'); + expect(invitationsCount?.classList.contains('scale-0')).to.be.false; + }); }); diff --git a/src/elements/public/UserCard/UserCard.ts b/src/elements/public/UserCard/UserCard.ts index 7cd29276..4abaf963 100644 --- a/src/elements/public/UserCard/UserCard.ts +++ b/src/elements/public/UserCard/UserCard.ts @@ -1,8 +1,16 @@ +import type { PropertyDeclarations } from 'lit-element'; +import type { UserInvitations } from '../UserInvitationForm/types'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { TemplateResult } from 'lit-html'; +import type { Resource } from '@foxy.io/sdk/core'; import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; +import { getGravatarUrl } from '../../../utils/get-gravatar-url'; +import { asyncReplace } from 'lit-html/directives/async-replace'; import { TwoLineCard } from '../CustomFieldCard/TwoLineCard'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { classMap } from '../../../utils/class-map'; import { html } from 'lit-html'; const NS = 'user-card'; @@ -15,13 +23,89 @@ const Base = TranslatableMixin(TwoLineCard, NS); * @since 1.22.0 */ export class UserCard extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + showInvitations: { type: Boolean, attribute: 'show-invitations' }, + }; + } + + /** When true, displays the number of unanswered invitations next to profile picture if there are some. */ + showInvitations = false; + renderBody(): TemplateResult { - return super.renderBody({ - title: data => { - const name = [data.first_name.trim(), data.last_name.trim()]; - return html`${name.filter(v => !!v).join(' ') || this.t('no_name')}`; - }, - subtitle: data => html`${data.email}`, - }); + const invitationsCount = this.__invitationsLoader?.data?.total_items ?? 0; + + return html` +
+
+ ${asyncReplace(this.__getGravatar(this.data?.email))} + + ${invitationsCount} + +
+
+ ${super.renderBody({ + title: data => { + const name = [data.first_name.trim(), data.last_name.trim()]; + return html`${name.filter(v => !!v).join(' ') || this.t('no_name')}`; + }, + subtitle: data => html`${data.email}`, + })} +
+
+ + + + `; + } + + private get __invitationsLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#invitationsLoader'); + } + + private get __invitationsHref() { + try { + if (!this.showInvitations) return; + // TODO remove this when SDK has types for invitations. + // @ts-expect-error SDK does not have types for invitations yet. + const url = new URL(this.data?._links['fx:user_invitations'].href ?? ''); + url.searchParams.set('status', 'sent'); + url.searchParams.set('limit', '1'); + return url.toString(); + } catch { + return; + } + } + + private async *__getGravatar(email?: string) { + yield html`
`; + + if (email) { + yield html` + + `; + } } } diff --git a/src/elements/public/UserCard/index.ts b/src/elements/public/UserCard/index.ts index b8b43d40..856e58f7 100644 --- a/src/elements/public/UserCard/index.ts +++ b/src/elements/public/UserCard/index.ts @@ -1,5 +1,6 @@ import '../../internal/InternalSandbox/index'; +import '../NucleonElement/index'; import '../Spinner/index'; import '../I18n/index'; From 240f0d986094a42832533f02bc09e07c03ae0b3e Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 19 Sep 2024 23:04:15 -0300 Subject: [PATCH 04/26] chore: regenerate `custom-elements.json` --- custom-elements.json | 601 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 601 insertions(+) diff --git a/custom-elements.json b/custom-elements.json index 3951c2a3..9bf4c44d 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -29492,6 +29492,12 @@ "path": "./src/elements/public/UserCard/index.ts", "description": "Card element representing a `fx:user` resource.", "attributes": [ + { + "name": "show-invitations", + "description": "When true, displays the number of unanswered invitations next to profile picture if there are some.", + "type": "boolean", + "default": "false" + }, { "name": "simplify-ns-loading", "type": "boolean", @@ -29574,6 +29580,13 @@ } ], "properties": [ + { + "name": "showInvitations", + "attribute": "show-invitations", + "description": "When true, displays the number of unanswered invitations next to profile picture if there are some.", + "type": "boolean", + "default": "false" + }, { "name": "simplifyNsLoading", "attribute": "simplify-ns-loading", @@ -30041,6 +30054,594 @@ } ] }, + { + "name": "foxy-user-invitation-card", + "path": "./src/elements/public/UserInvitationCard/index.ts", + "attributes": [ + { + "name": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "layout", + "description": "Admin layout will display user info, user layout (default) will display store info." + }, + { + "name": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "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": "layout", + "attribute": "layout", + "description": "Admin layout will display user info, user layout (default) will display store info." + }, + { + "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": "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": "isBodyReady", + "type": "boolean" + }, + { + "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-user-invitation-form", + "path": "./src/elements/public/UserInvitationForm/index.ts", + "attributes": [ + { + "name": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "layout", + "description": "Admin layout will display user info, user layout (default) will display store info." + }, + { + "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": "layout", + "attribute": "layout", + "description": "Admin layout will display user info, user layout (default) will display store info." + }, + { + "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-users-table", "path": "./src/elements/public/UsersTable/index.ts", From 43ce7ae4a819c17ec47192ac312bc7699a57eea9 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 20 Sep 2024 23:06:00 -0300 Subject: [PATCH 05/26] feat(foxy-copy-to-clipboard): add support for text layout --- .../CopyToClipboard/CopyToClipboard.test.ts | 155 +++++++++++++++++- .../public/CopyToClipboard/CopyToClipboard.ts | 99 +++++++---- 2 files changed, 210 insertions(+), 44 deletions(-) diff --git a/src/elements/public/CopyToClipboard/CopyToClipboard.test.ts b/src/elements/public/CopyToClipboard/CopyToClipboard.test.ts index b576878a..9de50127 100644 --- a/src/elements/public/CopyToClipboard/CopyToClipboard.test.ts +++ b/src/elements/public/CopyToClipboard/CopyToClipboard.test.ts @@ -24,6 +24,16 @@ describe('CopyToClipboard', () => { expect(new CopyToClipboard()).to.be.instanceOf(LitElement); }); + it('has a reactive property/attribite named "layout" (String)', () => { + expect(CopyToClipboard).to.have.deep.nested.property('properties.layout', {}); + expect(new CopyToClipboard()).to.have.property('layout', null); + }); + + it('has a reactive property/attribite named "theme" (String)', () => { + expect(CopyToClipboard).to.have.deep.nested.property('properties.theme', {}); + expect(new CopyToClipboard()).to.have.property('theme', null); + }); + it('has a reactive property/attribite named "text" (String)', () => { expect(CopyToClipboard).to.have.nested.property('properties.text.type', String); }); @@ -37,7 +47,7 @@ describe('CopyToClipboard', () => { expect(new CopyToClipboard()).to.have.property('ns', 'copy-to-clipboard'); }); - it('renders in the idle state by default', async () => { + it('renders in the idle state by default (icon layout)', async () => { const layout = html``; const element = await fixture(layout); const tooltip = element.renderRoot.querySelector('vcf-tooltip foxy-i18n') as HTMLElement; @@ -46,7 +56,16 @@ describe('CopyToClipboard', () => { expect(tooltip).to.have.property('key', 'click_to_copy'); }); - it('renders default icon when icon attribute is not set', async () => { + it('renders in the idle state by default (text layout)', async () => { + const layout = html``; + const element = await fixture(layout); + const tooltip = element.renderRoot.querySelector('vaadin-button foxy-i18n') as HTMLElement; + + expect(tooltip).to.have.property('infer', ''); + expect(tooltip).to.have.property('key', 'click_to_copy'); + }); + + it('renders default icon in icon layout when icon attribute is not set', async () => { const layout = html``; const element = await fixture(layout); const icon = element.renderRoot.querySelector('iron-icon') as HTMLElement; @@ -54,7 +73,7 @@ describe('CopyToClipboard', () => { expect(icon).to.have.property('icon', 'icons:content-copy'); }); - it('renders custom icon when icon attribute is set', async () => { + it('renders custom icon in icon layout when icon attribute is set', async () => { const layout = html``; const element = await fixture(layout); const icon = element.renderRoot.querySelector('iron-icon') as HTMLElement; @@ -62,7 +81,7 @@ describe('CopyToClipboard', () => { expect(icon).to.have.property('icon', 'icons:foo'); }); - it('copies text on click', async () => { + it('copies text on click in icon layout', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); const layout = html``; const element = await fixture(layout); @@ -86,7 +105,31 @@ describe('CopyToClipboard', () => { writeTextMethod.restore(); }); - it('switches to the busy state when copying text', async () => { + it('copies text on click in text layout', async () => { + const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); + const layout = html``; + const element = await fixture(layout); + const button = element.renderRoot.querySelector('vaadin-button'); + + button?.click(); + await waitUntil( + () => { + try { + expect(writeTextMethod).to.have.been.calledOnceWith('Foo'); + return true; + } catch { + return false; + } + }, + undefined, + { timeout: 5000 } + ); + + expect(writeTextMethod).to.have.been.calledOnceWith('Foo'); + writeTextMethod.restore(); + }); + + it('switches to the busy state when copying text in icon layout', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').returns( new Promise(() => void 0) ); @@ -104,7 +147,25 @@ describe('CopyToClipboard', () => { writeTextMethod.restore(); }); - it('switches to the idle state ~2s after copying text successfully', async () => { + it('switches to the busy state when copying text in text layout', async () => { + const writeTextMethod = stub(navigator.clipboard, 'writeText').returns( + new Promise(() => void 0) + ); + + const layout = html``; + const element = await fixture(layout); + const button = element.renderRoot.querySelector('vaadin-button'); + const tooltip = button?.querySelector('foxy-i18n'); + + button?.click(); + await element.requestUpdate(); + + expect(tooltip).to.have.property('infer', ''); + expect(tooltip).to.have.property('key', 'copying'); + writeTextMethod.restore(); + }); + + it('switches to the idle state ~2s after copying text successfully in icon layout', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); const layout = html``; const element = await fixture(layout); @@ -127,7 +188,30 @@ describe('CopyToClipboard', () => { writeTextMethod.restore(); }); - it('switches to the error state when copying text fails', async () => { + it('switches to the idle state ~2s after copying text successfully in text layout', async () => { + const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); + const layout = html``; + const element = await fixture(layout); + const button = element.renderRoot.querySelector('vaadin-button'); + const tooltip = button?.querySelector('foxy-i18n'); + + button?.click(); + + await waitUntil( + async () => { + await element.requestUpdate(); + return tooltip?.getAttribute('key') === 'click_to_copy'; + }, + undefined, + { timeout: 5000 } + ); + + expect(tooltip).to.have.property('infer', ''); + expect(tooltip).to.have.property('key', 'click_to_copy'); + writeTextMethod.restore(); + }); + + it('switches to the error state when copying text fails in icon layout', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').rejects(); const layout = html``; const element = await fixture(layout); @@ -150,7 +234,30 @@ describe('CopyToClipboard', () => { writeTextMethod.restore(); }); - it('switches to the idle state ~2s after copying text fails', async () => { + it('switches to the error state when copying text fails in text layout', async () => { + const writeTextMethod = stub(navigator.clipboard, 'writeText').rejects(); + const layout = html``; + const element = await fixture(layout); + const button = element.renderRoot.querySelector('vaadin-button'); + const tooltip = button?.querySelector('foxy-i18n'); + + button?.click(); + + await waitUntil( + async () => { + await element.requestUpdate(); + return tooltip?.getAttribute('key') === 'failed_to_copy'; + }, + undefined, + { timeout: 5000 } + ); + + expect(tooltip).to.have.property('infer', ''); + expect(tooltip).to.have.property('key', 'failed_to_copy'); + writeTextMethod.restore(); + }); + + it('switches to the idle state ~2s after copying text fails in icon layout', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').rejects(); const layout = html``; const element = await fixture(layout); @@ -172,4 +279,36 @@ describe('CopyToClipboard', () => { expect(tooltip).to.have.property('key', 'click_to_copy'); writeTextMethod.restore(); }); + + it('switches to the idle state ~2s after copying text fails in text layout', async () => { + const writeTextMethod = stub(navigator.clipboard, 'writeText').rejects(); + const layout = html``; + const element = await fixture(layout); + const button = element.renderRoot.querySelector('vaadin-button'); + const tooltip = button?.querySelector('foxy-i18n'); + + button?.click(); + + await waitUntil( + async () => { + await element.requestUpdate(); + return tooltip?.getAttribute('key') === 'click_to_copy'; + }, + undefined, + { timeout: 5000 } + ); + + expect(tooltip).to.have.property('infer', ''); + expect(tooltip).to.have.property('key', 'click_to_copy'); + writeTextMethod.restore(); + }); + + it('propagates theme attribute to vaadin-button in text layout', async () => { + const element = await fixture(html` + + `); + + const button = element.renderRoot.querySelector('vaadin-button'); + expect(button).to.have.attribute('theme', 'foo'); + }); }); diff --git a/src/elements/public/CopyToClipboard/CopyToClipboard.ts b/src/elements/public/CopyToClipboard/CopyToClipboard.ts index bf6a76ed..e486510a 100644 --- a/src/elements/public/CopyToClipboard/CopyToClipboard.ts +++ b/src/elements/public/CopyToClipboard/CopyToClipboard.ts @@ -1,17 +1,13 @@ -import { - CSSResult, - LitElement, - PropertyDeclarations, - TemplateResult, - css, - html, -} from 'lit-element'; +import type { CSSResult, PropertyDeclarations, TemplateResult } from 'lit-element'; +import { LitElement, css, html } from 'lit-element'; +import { TranslatableMixin } from '../../../mixins/translatable'; import { ConfigurableMixin } from '../../../mixins/configurable'; import { InferrableMixin } from '../../../mixins/inferrable'; -import { TranslatableMixin } from '../../../mixins/translatable'; +import { ifDefined } from 'lit-html/directives/if-defined'; -const Base = ConfigurableMixin(TranslatableMixin(InferrableMixin(LitElement), 'copy-to-clipboard')); +const NS = 'copy-to-clipboard'; +const Base = ConfigurableMixin(TranslatableMixin(InferrableMixin(LitElement), NS)); /** * A simple "click to copy" button that takes the size of the font @@ -24,6 +20,8 @@ export class CopyToClipboard extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + layout: {}, + theme: {}, text: { type: String }, icon: { type: String }, __state: { attribute: false }, @@ -32,7 +30,7 @@ export class CopyToClipboard extends Base { static get styles(): CSSResult { return css` - button { + .icon-button { position: relative; appearance: none; background: none; @@ -48,7 +46,7 @@ export class CopyToClipboard extends Base { align-items: center; } - button::before { + .icon-button::before { position: absolute; inset: 0; content: ' '; @@ -59,22 +57,22 @@ export class CopyToClipboard extends Base { border-radius: var(--lumo-border-radius-s); } - button:focus { + .icon-button:focus { outline: none; box-shadow: 0 0 0 2px currentColor; } - button:disabled { + .icon-button:disabled { opacity: 0.5; cursor: default; } @media (hover: hover) { - button:not(:disabled):hover { + .icon-button:not(:disabled):hover { cursor: pointer; } - button:not(:disabled):hover::before { + .icon-button:not(:disabled):hover::before { opacity: 0.16; } } @@ -86,6 +84,12 @@ export class CopyToClipboard extends Base { `; } + /** Icon or text UI. Icon UI by default. */ + layout: 'text' | 'icon' | null = null; + + /** VaadinButton theme for text layout. */ + theme: string | null = null; + /** Default icon. */ icon: string | null = null; @@ -95,6 +99,7 @@ export class CopyToClipboard extends Base { private __state: 'idle' | 'busy' | 'fail' | 'done' = 'idle'; render(): TemplateResult { + const layout = this.layout === 'text' ? 'text' : 'icon'; let label = ''; let icon = ''; @@ -113,26 +118,48 @@ export class CopyToClipboard extends Base { } return html` - - - - + ${layout === 'icon' + ? html` + + + + + + + ` + : html` + + + + `} `; } + + private __copy() { + if (this.__state === 'idle') { + this.__state = 'busy'; + + navigator.clipboard + .writeText(this.text ?? '') + .then(() => (this.__state = 'done')) + .catch(() => (this.__state = 'fail')) + .then(() => setTimeout(() => (this.__state = 'idle'), 2000)); + } + } } From 4d2e9d23e139c4af23c9f35f7ed881976469b97f Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 20 Sep 2024 23:07:08 -0300 Subject: [PATCH 06/26] feat(foxy-cart-form): add View and Copy ID buttons to Customer section --- .../InternalResourcePickerControl.test.ts | 106 +++++++++++++ .../InternalResourcePickerControl.ts | 67 ++++++-- .../InternalResourcePickerControl/index.ts | 3 + src/elements/public/CartForm/CartForm.test.ts | 18 ++- src/elements/public/CartForm/CartForm.ts | 25 +-- src/static/translations/cart-form/en.json | 147 ++++++++++-------- 6 files changed, 269 insertions(+), 97 deletions(-) diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts index 80d721b9..48f90d98 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts @@ -22,6 +22,10 @@ async function waitForIdle(element: Control) { } describe('InternalResourcePickerControl', () => { + it('imports and defines vaadin-button element', () => { + expect(customElements.get('vaadin-button')).to.exist; + }); + it('imports and defines foxy-internal-editable-control element', () => { expect(customElements.get('foxy-internal-resource-picker-control')).to.exist; }); @@ -34,6 +38,10 @@ describe('InternalResourcePickerControl', () => { expect(customElements.get('foxy-internal-form')).to.exist; }); + it('imports and defines foxy-copy-to-clipboard element', () => { + expect(customElements.get('foxy-copy-to-clipboard')).to.exist; + }); + it('imports and defines foxy-form-dialog element', () => { expect(customElements.get('foxy-form-dialog')).to.exist; }); @@ -63,11 +71,24 @@ describe('InternalResourcePickerControl', () => { expect(new Control().getDisplayValueOptions(resource)).to.deep.equal({ resource }); }); + it('has a reactive property "showCopyIdButton"', () => { + expect(new Control()).to.have.property('showCopyIdButton', false); + expect(Control).to.have.deep.nested.property('properties.showCopyIdButton', { + attribute: 'show-copy-id-button', + type: Boolean, + }); + }); + it('has a reactive property "virtualHost"', () => { expect(Control).to.have.deep.nested.property('properties.virtualHost', {}); expect(new Control()).to.have.property('virtualHost').that.is.a('string'); }); + it('has a reactive property "getItemUrl"', () => { + expect(Control).to.have.deep.nested.property('properties.getItemUrl', { attribute: false }); + expect(new Control()).to.have.property('getItemUrl', null); + }); + it('has a reactive property "formProps"', () => { expect(Control).to.have.deep.nested.property('properties.formProps', { type: Object }); expect(new Control()).to.have.deep.property('formProps', {}); @@ -421,6 +442,91 @@ describe('InternalResourcePickerControl', () => { expect(clearBtn).to.have.attribute('hidden'); }); + it('renders View link in standalone layout when value is set and getItemUrl is defined', async () => { + const control = await fixture(html` + value.replace('demo.api', 'example.com')} + > + + `); + + let linkText = control.renderRoot.querySelector('[key="view"]'); + expect(linkText).to.not.exist; + + control.getValue = () => 'https://demo.api/hapi/customers/0'; + await control.requestUpdate(); + linkText = control.renderRoot.querySelector('[key="view"]'); + expect(linkText).to.exist; + expect(linkText).to.have.attribute('infer', ''); + + const viewLink = linkText?.closest('a'); + expect(viewLink).to.exist; + expect(viewLink).to.have.attribute('href', 'https://example.com/hapi/customers/0'); + }); + + it('renders Copy ID button in standalone layout when value is set and showCopyIdButton is true', async () => { + const control = await fixture(html` + + + `); + + const selector = 'foxy-copy-to-clipboard[infer="copy-id"]'; + let copyBtn = control.renderRoot.querySelector(selector); + expect(copyBtn).to.not.exist; + + control.getValue = () => 'https://demo.api/hapi/customers/0'; + await control.requestUpdate(); + copyBtn = control.renderRoot.querySelector(selector); + expect(copyBtn).to.not.exist; + + control.showCopyIdButton = true; + await control.requestUpdate(); + copyBtn = control.renderRoot.querySelector(selector); + expect(copyBtn).to.exist; + expect(copyBtn).to.have.attribute('layout', 'text'); + expect(copyBtn).to.have.attribute('theme', 'contrast tertiary-inline'); + }); + + it('renders Clear button in standalone layout when value is set', async () => { + const control = await fixture(html` + + + `); + + let btnText = control.renderRoot.querySelector('foxy-i18n[key="clear"]'); + expect(btnText).to.not.exist; + + control.getValue = () => 'https://demo.api/hapi/customers/0'; + await control.requestUpdate(); + btnText = control.renderRoot.querySelector('foxy-i18n[key="clear"]'); + expect(btnText).to.exist; + expect(btnText).to.have.attribute('infer', ''); + + const clearBtn = btnText?.closest('vaadin-button'); + expect(clearBtn).to.exist; + expect(clearBtn).to.not.have.attribute('disabled'); + + const setValueStub = stub(); + control.setValue = setValueStub; + clearBtn?.click(); + expect(setValueStub).to.have.been.calledOnceWith(''); + + control.disabled = true; + await control.requestUpdate(); + expect(clearBtn).to.have.attribute('disabled'); + }); + describe('InternalResourcePickerControlForm', () => { it('defines itself as foxy-internal-resource-picker-control-form', () => { expect(customElements.get('foxy-internal-resource-picker-control-form')).to.equal(Form); diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts index 1edd5afe..1fd1f8ac 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts @@ -7,6 +7,7 @@ import type { FormDialog } from '../../public/FormDialog/FormDialog'; import type { Option } from '../../public/QueryBuilder/types'; import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; +import { getResourceId } from '@foxy.io/sdk/core'; import { FetchEvent } from '../../public/NucleonElement/FetchEvent'; import { ifDefined } from 'lit-html/directives/if-defined'; import { html, svg } from 'lit-html'; @@ -23,7 +24,9 @@ export class InternalResourcePickerControl extends InternalEditableControl { return { ...super.properties, getDisplayValueOptions: { attribute: false }, + showCopyIdButton: { type: Boolean, attribute: 'show-copy-id-button' }, virtualHost: {}, + getItemUrl: { attribute: false }, formProps: { type: Object }, filters: { type: Array }, layout: {}, @@ -35,8 +38,12 @@ export class InternalResourcePickerControl extends InternalEditableControl { getDisplayValueOptions: DisplayValueOptionsCb = resource => ({ resource }); + showCopyIdButton = false; + virtualHost = uniqueId('internal-resource-picker-control-'); + getItemUrl: ((href: string) => string) | null = null; + formProps: Record = {}; filters: Option[] = []; @@ -93,6 +100,11 @@ export class InternalResourcePickerControl extends InternalEditableControl { if (changes.has('item')) this.__getItemRenderer.cache.clear?.(); } + private __clear(): void { + this._value = ''; + this.dispatchEvent(new CustomEvent('clear')); + } + private __renderSummaryItemLayout() { const resource = this.renderRoot.querySelector>('#value'); const onClick = (evt: Event) => { @@ -155,10 +167,7 @@ export class InternalResourcePickerControl extends InternalEditableControl { style="width: 1em; height: 1em;" ?disabled=${this.disabled} ?hidden=${this.readonly || !this._value} - @click=${() => { - this._value = ''; - this.dispatchEvent(new CustomEvent('clear')); - }} + @click=${this.__clear} > ${svg``} @@ -176,28 +185,58 @@ export class InternalResourcePickerControl extends InternalEditableControl { } private __renderStandaloneLayout() { + const selectionUrl = typeof this._value === 'string' ? this.getItemUrl?.(this._value) : void 0; + const selectionId = typeof this._value === 'string' ? getResourceId(this._value) : void 0; + return html`
- ${this.label} + ${this.label} + ${selectionUrl + ? html` + + + + ` + : ''} + ${this.showCopyIdButton && selectionId !== null + ? html` + + + ` + : ''} + ${this.readonly || !this._value + ? '' + : html` + + + + `}
+
+ + `; + } + + private __renderInput(params: InputParams) { + return html` + + `; + } +} + +export { InternalArrayMapControl }; diff --git a/src/elements/internal/InternalArrayMapControl/index.ts b/src/elements/internal/InternalArrayMapControl/index.ts new file mode 100644 index 00000000..1e5c3985 --- /dev/null +++ b/src/elements/internal/InternalArrayMapControl/index.ts @@ -0,0 +1,9 @@ +import '../../public/I18n/index'; + +import '../InternalEditableControl/index'; + +import { InternalArrayMapControl } from './InternalArrayMapControl'; + +customElements.define('foxy-internal-array-map-control', InternalArrayMapControl); + +export { InternalArrayMapControl }; diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index af599d79..207d616e 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -8,10 +8,10 @@ import type { Rels } from '@foxy.io/sdk/backend'; import './index'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; -import { Operator, Type } from '../QueryBuilder/types'; import { createRouter } from '../../../server/index'; import { getTestData } from '../../../testgen/getTestData'; import { CouponForm } from './CouponForm'; +import { Type } from '../QueryBuilder/types'; import { stub } from 'sinon'; describe('CouponForm', () => { @@ -41,6 +41,10 @@ describe('CouponForm', () => { expect(customElements.get('foxy-internal-query-builder-control')).to.exist; }); + it('imports and defines foxy-internal-array-map-control', () => { + expect(customElements.get('foxy-internal-array-map-control')).to.exist; + }); + it('imports and defines foxy-internal-number-control', () => { expect(customElements.get('foxy-internal-number-control')).to.exist; }); @@ -407,24 +411,13 @@ describe('CouponForm', () => { ]); }); - it('renders query builder control for item option restrictions', async () => { + it('renders array map control for item option restrictions', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-query-builder-control[infer=item-option-restrictions]' + 'foxy-internal-array-map-control[infer=item-option-restrictions]' ) as InternalQueryBuilderControl; expect(control).to.exist; - expect(control).to.have.attribute('disable-or'); - expect(control).to.have.deep.property('operators', [Operator.In]); - - expect(control.getValue()).to.equal(''); - control.setValue('foo:in=bar,baz'); - expect(element).to.have.deep.nested.property('form.item_option_restrictions', { - foo: ['bar', 'baz'], - }); - - element.edit({ item_option_restrictions: { a: ['b', 'c'] } }); - expect(control.getValue()).to.equal('a%3Ain=b%2Cc'); }); it('renders editable list control for product code restrictions', async () => { diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index 4e685078..e47b76f3 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -11,9 +11,9 @@ import type { Rels } from '@foxy.io/sdk/backend'; import { TranslatableMixin } from '../../../mixins/translatable'; import { ResponsiveMixin } from '../../../mixins/responsive'; import { BooleanSelector } from '@foxy.io/sdk/core'; -import { Operator, Type } from '../QueryBuilder/types'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; +import { Type } from '../QueryBuilder/types'; import { html } from 'lit-html'; const NS = 'coupon-form'; @@ -112,29 +112,6 @@ export class CouponForm extends Base { }); }; - private readonly __itemOptionRestrictionsOperators = [Operator.In]; - - private readonly __itemOptionRestrictionsGetValue = () => { - const query = new URLSearchParams(); - const rules = this.form.item_option_restrictions ?? {}; - - for (const key in rules) { - query.set(`${key}:in`, rules[key].join(',')); - } - - return query.toString(); - }; - - private readonly __itemOptionRestrictionsSetValue = (newValue: string) => { - const rules = Object.fromEntries( - Array.from(new URLSearchParams(newValue).entries()).map(([key, value]) => { - return [key.replace(':in', ''), value.split(',').filter(v => !!v.trim())]; - }) - ); - - this.edit({ item_option_restrictions: rules }); - }; - private readonly __storeLoaderId = 'storeLoader'; private readonly __codesFilters: Option[] = [ @@ -251,14 +228,8 @@ export class CouponForm extends Base { > - - + + Dataset = () => ({ number_of_uses_allowed: 100, number_of_uses_to_date: 31, number_of_uses_allowed_per_code: 0, + item_option_restrictions: { color: ['red', 'blue'], model: ['CT-*'] }, coupon_discount_type: 'quantity_percentage', coupon_discount_details: 'repeat|6-10', customer_auto_apply: false, diff --git a/src/server/hapi/defaults.ts b/src/server/hapi/defaults.ts index 22a7a449..47c3ade4 100644 --- a/src/server/hapi/defaults.ts +++ b/src/server/hapi/defaults.ts @@ -520,6 +520,7 @@ export const defaults: Defaults = { number_of_uses_allowed: 0, number_of_uses_to_date: 0, number_of_uses_allowed_per_code: 0, + item_option_restrictions: {}, coupon_discount_type: 'quantity_amount', coupon_discount_details: '', combinable: false, diff --git a/src/static/translations/coupon-form/en.json b/src/static/translations/coupon-form/en.json index f9a51376..a4e84308 100644 --- a/src/static/translations/coupon-form/en.json +++ b/src/static/translations/coupon-form/en.json @@ -535,42 +535,10 @@ "label": "Item option restrictions", "helper_text": "This restricts the usage of a coupon code based on an item option's name and value. When defined, the coupon will only apply to items with the specified option name and value. Wildcards are allowed.", "v8n_too_long": "Unfortunately we are unable to store that many item option restrictions at the moment. Please reduce the number of rules in this section until this message disappears.", - "query-builder": { - "add_or_clause": "Add OR clause", - "add_value": "Add option value", - "code": "Code", - "date": "{{value, date}}", - "date_created": "Created on", - "date_modified": "Last updated on", - "delete": "Delete", - "field": "Option name", - "hidden": "Hidden", - "is_defined_false": "Not defined", - "is_defined_true": "Defined", - "name": "Name", - "operator_equal": "Equal", - "operator_greaterthan": "Greater than", - "operator_greaterthanorequal": "Greater than or equal", - "operator_in": "One of", - "operator_isdefined": "Is defined", - "operator_lessthan": "Less than", - "operator_lessthanorequal": "Less than or equal", - "operator_not": "Not equal", - "or": "Or", - "query_builder_group": "Group of filters", - "query_builder_rule": "Filter", - "range_from": "From", - "range_to": "To", - "type": "Type", - "type_any": "Field of unknown type", - "type_attribute": "Key-value resource", - "type_date": "Date field", - "type_here": "Type here...", - "type_number": "Numeric field", - "type_string": "Text field", - "used_codes": "Codes used", - "value": "Option value" - } + "delete": "Delete", + "add_value": "Add value", + "option_name": "Option name", + "rule": "Rule" }, "category-restrictions": { "label": "Item category restrictions", diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index 5ea9c265..d6dc5246 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -1,4 +1,8 @@ export const groups = [ + { + name: 'foxy-internal-array-map-control', + files: './src/elements/internal/InternalArrayMapControl/**/*.test.ts', + }, { name: 'foxy-internal-async-combo-box-control', files: './src/elements/internal/InternalAsyncComboBoxControl/**/*.test.ts', @@ -215,6 +219,10 @@ export const groups = [ name: 'foxy-access-recovery-form', files: './src/elements/public/AccessRecoveryForm/**/*.test.ts', }, + { + name: 'foxy-activate-store-form', + files: './src/elements/public/ActivateStoreForm/**/*.test.ts', + }, { name: 'foxy-address-card', files: './src/elements/public/AddressCard/**/*.test.ts', From 3b2c935beafc5685e33412bad17acfa020f03353 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Wed, 2 Oct 2024 11:50:08 -0300 Subject: [PATCH 10/26] fix(foxy-query-builder): fix parsing and encoding of complex alternative attribute rules --- src/elements/public/QueryBuilder/utils/parse.ts | 4 +++- src/elements/public/QueryBuilder/utils/stringify.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/elements/public/QueryBuilder/utils/parse.ts b/src/elements/public/QueryBuilder/utils/parse.ts index 35263d1b..ec190aff 100644 --- a/src/elements/public/QueryBuilder/utils/parse.ts +++ b/src/elements/public/QueryBuilder/utils/parse.ts @@ -1,7 +1,9 @@ import { Operator, ParsedValue } from '../types'; function parseGroup(search: string): ParsedValue { - const [fullPath, value] = search.split('=').map(decodeURIComponent); + const separatorIndex = search.indexOf('='); + const fullPath = decodeURIComponent(search.substring(0, separatorIndex)); + const value = decodeURIComponent(search.substring(separatorIndex + 1)); const operators = Object.values(Operator) as Operator[]; const operator = operators.find(operator => fullPath.endsWith(`:${operator}`)) ?? null; diff --git a/src/elements/public/QueryBuilder/utils/stringify.ts b/src/elements/public/QueryBuilder/utils/stringify.ts index e01c3f1a..d6c54b79 100644 --- a/src/elements/public/QueryBuilder/utils/stringify.ts +++ b/src/elements/public/QueryBuilder/utils/stringify.ts @@ -11,10 +11,16 @@ function stringifyGroup(parsedValue: ParsedValue): string { function stringify(newValue: (ParsedValue | ParsedValue[])[]): string { const toQuery = (rules: string[], rule: ParsedValue | ParsedValue[]) => { if (Array.isArray(rule)) { - const alternatives = [rule[0].value, rule.slice(1).map(or => stringifyGroup(or))]; - const orValue = alternatives.join('|'); + let key = rule[0].path; + if (rule[0].name) key += `:name[${rule[0].name}]`; + if (rule[0].operator) key += `:${rule[0].operator}`; - rules.push(`${rule[0].path}=${encodeURIComponent(orValue)}`); + const alternatives = [ + rule[0].value, + ...rule.slice(1).map(or => decodeURIComponent(stringifyGroup(or))), + ]; + + rules.push(`${key}=${encodeURIComponent(alternatives.join('|'))}`); } else if (rule.path !== 'zoom') { rules.push(stringifyGroup(rule)); } From c1f9feade7e8a21b7ee50e557417fd330f039d22 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Wed, 2 Oct 2024 11:51:14 -0300 Subject: [PATCH 11/26] fix(foxy-coupon-form): update handling for customer attribute coupon field --- .../public/CouponForm/CouponForm.test.ts | 26 ++++++++ src/elements/public/CouponForm/CouponForm.ts | 63 ++++++++++++++++++- src/static/translations/coupon-form/en.json | 20 ++---- 3 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 207d616e..6d6a837d 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -637,6 +637,32 @@ describe('CouponForm', () => { expect(control).to.exist; expect(control).to.have.attribute('layout', 'summary-item'); + expect(control.getValue()).to.equal(''); + + const cases = [ + ['attributes:name[color]', 'red'], + ['attributes:name[color]:in', 'red,blue'], + ['attributes:name[color]:in', 'red,blue|attributes:name[color]=orange'], + ]; + + const results = [ + ['color', 'red'], + ['color:in', 'red,blue'], + ['color:in', 'red,blue|color=orange'], + ]; + + cases.forEach((c, i) => { + element.edit({ customer_attribute_restrictions: new URLSearchParams([c]).toString() }); + expect(control.getValue()).to.equal(new URLSearchParams([results[i]]).toString()); + }); + + results.forEach((r, i) => { + control.setValue(new URLSearchParams([r]).toString()); + expect(element).to.have.nested.property( + 'form.customer_attribute_restrictions', + new URLSearchParams([cases[i]]).toString() + ); + }); }); it('renders switch control for auto-apply inside of the Customer Restrictions summary', async () => { diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index e47b76f3..3ed67ad3 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -4,7 +4,7 @@ import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { SwipeAction } from '../../internal/InternalAsyncListControl/types'; import type { NucleonV8N } from '../NucleonElement/types'; import type { Resource } from '@foxy.io/sdk/core'; -import type { Option } from '../QueryBuilder/types'; +import type { Option, ParsedValue } from '../QueryBuilder/types'; import type { Item } from '../../internal/InternalEditableListControl/types'; import type { Rels } from '@foxy.io/sdk/backend'; @@ -15,6 +15,8 @@ import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; import { Type } from '../QueryBuilder/types'; import { html } from 'lit-html'; +import { parse } from '../QueryBuilder/utils/parse'; +import { stringify } from '../QueryBuilder/utils/stringify'; const NS = 'coupon-form'; const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); @@ -66,6 +68,63 @@ export class CouponForm extends Base { getTransactionPageHref: TransactionPageHrefGetter | null = null; + private readonly __customerAttributeRestrictionsGetValue = () => { + const params = new URLSearchParams( + stringify( + parse(this.form.customer_attribute_restrictions ?? '') + .filter(value => Array.isArray(value) || typeof value.name === 'string') + .map(value => { + if (Array.isArray(value)) { + return value + .filter(({ name }) => typeof name === 'string') + .map(({ name, operator, value }) => { + const output: ParsedValue = { path: name as string, operator, value }; + return output; + }); + } + + const output: ParsedValue = { + operator: value.operator, + value: value.value, + path: value.name as string, + }; + + return output; + }) + ) + ); + + params.delete('zoom'); + return params.toString(); + }; + + private readonly __customerAttributeRestrictionsSetValue = (newValue: string) => { + const params = new URLSearchParams( + stringify( + parse(newValue).map(value => { + if (Array.isArray(value)) { + return value.map(({ path, operator, value }) => { + const output: ParsedValue = { name: path, path: 'attributes', operator, value }; + return output; + }); + } else { + const output: ParsedValue = { + operator: value.operator, + value: value.value, + path: 'attributes', + name: value.path, + }; + + return output; + } + }) + ) + ); + + params.delete('zoom'); + this.edit({ customer_attribute_restrictions: params.toString() }); + }; + private readonly __customerSubscriptionRestrictionsGetValue = () => { const items = this.form.customer_subscription_restrictions ?.split(',') @@ -235,6 +294,8 @@ export class CouponForm extends Base { diff --git a/src/static/translations/coupon-form/en.json b/src/static/translations/coupon-form/en.json index a4e84308..fb82a6ec 100644 --- a/src/static/translations/coupon-form/en.json +++ b/src/static/translations/coupon-form/en.json @@ -571,15 +571,10 @@ "add_or_clause": "Add OR clause", "add_value": "Add value", "code": "Code", - "date": "{{value, date}}", - "date_created": "Created on", - "date_modified": "Last updated on", "delete": "Delete", - "field": "Field", - "hidden": "Hidden", + "field": "Attribute name", "is_defined_false": "Not defined", "is_defined_true": "Defined", - "name": "Name", "operator_equal": "Equal", "operator_greaterthan": "Greater than", "operator_greaterthanorequal": "Greater than or equal", @@ -589,19 +584,12 @@ "operator_lessthanorequal": "Less than or equal", "operator_not": "Not equal", "or": "Or", - "query_builder_group": "Group of filters", - "query_builder_rule": "Filter", - "range_from": "From", - "range_to": "To", + "query_builder_group": "Group of rules", + "query_builder_rule": "Rule", "type": "Type", "type_any": "Field of unknown type", - "type_attribute": "Key-value resource", - "type_date": "Date field", "type_here": "Type here...", - "type_number": "Numeric field", - "type_string": "Text field", - "used_codes": "Codes used", - "value": "Value" + "value": "Attribute value" } }, "customer-subscription-restrictions": { From 093b10ca65ea09ff5d8d2a313cfdb63cbb23b245 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 13:26:15 -0300 Subject: [PATCH 12/26] feat(foxy-query-builder): add an option to create queries without `zoom` parameter --- .../InternalQueryBuilderControl.test.ts | 20 ++++++++++++++-- .../InternalQueryBuilderControl.ts | 8 +++++-- .../public/QueryBuilder/QueryBuilder.test.ts | 24 +++++++++++++++++++ .../public/QueryBuilder/QueryBuilder.ts | 10 +++++--- .../public/QueryBuilder/utils/stringify.ts | 11 +++++---- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts index 6f2ce207..eebfea1b 100644 --- a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts +++ b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts @@ -32,6 +32,14 @@ describe('InternalQueryBuilderControl', () => { }); }); + it('has a reactive property "disableZoom"', () => { + expect(new Control()).to.have.property('disableZoom', false); + expect(Control).to.have.deep.nested.property('properties.disableZoom', { + type: Boolean, + attribute: 'disable-zoom', + }); + }); + it('has a reactive property "layout"', () => { expect(new Control()).to.have.property('layout', null); expect(Control).to.have.deep.nested.property('properties.layout', {}); @@ -95,7 +103,6 @@ describe('InternalQueryBuilderControl', () => { const control = await fixture(html` value} .setValue=${(newValue: string) => (value = newValue)} > @@ -107,11 +114,20 @@ describe('InternalQueryBuilderControl', () => { expect(builder).to.exist; expect(builder).to.have.attribute('infer', 'query-builder'); expect(builder).to.have.property('operators', control.operators); - expect(builder).to.have.property('disableOr', control.disableOr); expect(builder).to.have.property('value', value); builder.value = 'bar=baz'; builder.dispatchEvent(new CustomEvent('change')); expect(value).to.equal('bar=baz'); + + expect(builder).to.have.property('disableOr', false); + expect(builder).to.have.property('disableZoom', false); + + builder.disableOr = true; + builder.disableZoom = true; + await control.requestUpdate(); + + expect(builder).to.have.property('disableOr', true); + expect(builder).to.have.property('disableZoom', true); }); }); diff --git a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts index d2826f31..04d91e19 100644 --- a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts +++ b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts @@ -11,16 +11,19 @@ export class InternalQueryBuilderControl extends InternalEditableControl { static get properties(): PropertyDeclarations { return { ...super.properties, - operators: { type: Array }, + disableZoom: { type: Boolean, attribute: 'disable-zoom' }, disableOr: { type: Boolean, attribute: 'disable-or' }, + operators: { type: Array }, layout: {}, }; } - operators: Operator[] = Object.values(Operator); + disableZoom = false; disableOr = false; + operators: Operator[] = Object.values(Operator); + layout: 'standalone' | 'summary-item' | null = null; renderControl(): TemplateResult { @@ -36,6 +39,7 @@ export class InternalQueryBuilderControl extends InternalEditableControl { : ''} .operators=${this.operators} .value=${this._value} + ?disable-zoom=${this.disableZoom} ?disable-or=${this.disableOr} @change=${(evt: CustomEvent) => { const queryBuilder = evt.currentTarget as QueryBuilder; diff --git a/src/elements/public/QueryBuilder/QueryBuilder.test.ts b/src/elements/public/QueryBuilder/QueryBuilder.test.ts index a65e0ddc..663f4a1b 100644 --- a/src/elements/public/QueryBuilder/QueryBuilder.test.ts +++ b/src/elements/public/QueryBuilder/QueryBuilder.test.ts @@ -32,6 +32,14 @@ describe('QueryBuilder', () => { ); }); + it('has a reactive property "disableZoom"', () => { + expect(new QueryBuilder()).to.have.property('disableZoom', false); + expect(QueryBuilder).to.have.deep.nested.property('properties.disableZoom', { + type: Boolean, + attribute: 'disable-zoom', + }); + }); + it('has a reactive property "disableOr"', () => { expect(new QueryBuilder()).to.have.property('disableOr', false); expect(QueryBuilder).to.have.deep.nested.property('properties.disableOr', { @@ -1012,4 +1020,20 @@ describe('QueryBuilder', () => { await element.requestUpdate(); expect(or).to.have.class('opacity-0'); }); + + it('does not add zoom query param when .disableZoom=true', async () => { + const element = await fixture(html``); + const root = element.renderRoot; + const path = root.querySelector('[aria-label="query_builder_rule"] input') as HTMLInputElement; + + path.value = 'one:two:three'; + path.dispatchEvent(new InputEvent('input')); + await element.requestUpdate(); + expect(root.querySelectorAll(`[aria-label="query_builder_rule"]`)).to.have.length(2); + expect(element).to.have.value('one%3Atwo%3Athree=&zoom=one%2Ctwo'); + + element.disableZoom = true; + path.dispatchEvent(new InputEvent('input')); + expect(element).to.have.value('one%3Atwo%3Athree='); + }); }); diff --git a/src/elements/public/QueryBuilder/QueryBuilder.ts b/src/elements/public/QueryBuilder/QueryBuilder.ts index 6b361281..162b6143 100644 --- a/src/elements/public/QueryBuilder/QueryBuilder.ts +++ b/src/elements/public/QueryBuilder/QueryBuilder.ts @@ -37,6 +37,7 @@ class QueryBuilder extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + disableZoom: { type: Boolean, attribute: 'disable-zoom' }, disableOr: { type: Boolean, attribute: 'disable-or' }, operators: { type: Array }, options: { type: Array }, @@ -48,12 +49,15 @@ class QueryBuilder extends Base { return [super.styles, styles]; } - /** List of operators available in the builder UI. */ - operators: Operator[] = Object.values(Operator); + /** If true, doesn't add `zoom` query parameter for complex paths. */ + disableZoom = false; /** If true, hides the UI for the "OR" operator in queries. */ disableOr = false; + /** List of operators available in the builder UI. */ + operators: Operator[] = Object.values(Operator); + /** Autocomplete suggestions. */ options: Option[] | null = null; @@ -79,7 +83,7 @@ class QueryBuilder extends Base { options: this.options ?? [], t: this.t.bind(this), onChange: newValue => { - this.value = stringify([...newValue, ...hiddenValues]); + this.value = stringify([...newValue, ...hiddenValues], this.disableZoom); this.dispatchEvent(new QueryBuilder.ChangeEvent('change')); }, }); diff --git a/src/elements/public/QueryBuilder/utils/stringify.ts b/src/elements/public/QueryBuilder/utils/stringify.ts index d6c54b79..c4ffd099 100644 --- a/src/elements/public/QueryBuilder/utils/stringify.ts +++ b/src/elements/public/QueryBuilder/utils/stringify.ts @@ -8,7 +8,7 @@ function stringifyGroup(parsedValue: ParsedValue): string { return result === '=' ? '' : result; } -function stringify(newValue: (ParsedValue | ParsedValue[])[]): string { +function stringify(newValue: (ParsedValue | ParsedValue[])[], disableZoom = false): string { const toQuery = (rules: string[], rule: ParsedValue | ParsedValue[]) => { if (Array.isArray(rule)) { let key = rule[0].path; @@ -20,7 +20,7 @@ function stringify(newValue: (ParsedValue | ParsedValue[])[]): string { ...rule.slice(1).map(or => decodeURIComponent(stringifyGroup(or))), ]; - rules.push(`${key}=${encodeURIComponent(alternatives.join('|'))}`); + rules.push(`${encodeURIComponent(key)}=${encodeURIComponent(alternatives.join('|'))}`); } else if (rule.path !== 'zoom') { rules.push(stringifyGroup(rule)); } @@ -29,9 +29,12 @@ function stringify(newValue: (ParsedValue | ParsedValue[])[]): string { }; const query = newValue.reduce(toQuery, [] as string[]); - const zoom = getZoomedRels(newValue).join(','); - if (zoom) query.push(`zoom=${encodeURIComponent(zoom)}`); + if (!disableZoom) { + const zoom = getZoomedRels(newValue).join(','); + if (zoom) query.push(`zoom=${encodeURIComponent(zoom)}`); + } + return query.join('&'); } From b811e6d6b1342a55b0e3da0cf384b58c15ab861e Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 13:32:35 -0300 Subject: [PATCH 13/26] fix(foxy-coupon-form): fix spaces being encoded as `+` --- .../public/CouponForm/CouponForm.test.ts | 1 + src/elements/public/CouponForm/CouponForm.ts | 93 +++++++++---------- 2 files changed, 43 insertions(+), 51 deletions(-) diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 6d6a837d..7aa4052b 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -637,6 +637,7 @@ describe('CouponForm', () => { expect(control).to.exist; expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('disable-zoom'); expect(control.getValue()).to.equal(''); const cases = [ diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index 3ed67ad3..fb18ba78 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -69,60 +69,50 @@ export class CouponForm extends Base { getTransactionPageHref: TransactionPageHrefGetter | null = null; private readonly __customerAttributeRestrictionsGetValue = () => { - const params = new URLSearchParams( - stringify( - parse(this.form.customer_attribute_restrictions ?? '') - .filter(value => Array.isArray(value) || typeof value.name === 'string') - .map(value => { - if (Array.isArray(value)) { - return value - .filter(({ name }) => typeof name === 'string') - .map(({ name, operator, value }) => { - const output: ParsedValue = { path: name as string, operator, value }; - return output; - }); - } - - const output: ParsedValue = { - operator: value.operator, - value: value.value, - path: value.name as string, - }; - - return output; - }) - ) - ); - - params.delete('zoom'); - return params.toString(); + const simplifiedValue = parse(this.form.customer_attribute_restrictions ?? '') + .filter(value => Array.isArray(value) || typeof value.name === 'string') + .map(value => { + if (Array.isArray(value)) { + return value + .filter(({ name }) => typeof name === 'string') + .map(({ name, operator, value }) => { + const output: ParsedValue = { path: name as string, operator, value }; + return output; + }); + } + + const output: ParsedValue = { + operator: value.operator, + value: value.value, + path: value.name as string, + }; + + return output; + }); + + return stringify(simplifiedValue, true); }; private readonly __customerAttributeRestrictionsSetValue = (newValue: string) => { - const params = new URLSearchParams( - stringify( - parse(newValue).map(value => { - if (Array.isArray(value)) { - return value.map(({ path, operator, value }) => { - const output: ParsedValue = { name: path, path: 'attributes', operator, value }; - return output; - }); - } else { - const output: ParsedValue = { - operator: value.operator, - value: value.value, - path: 'attributes', - name: value.path, - }; - - return output; - } - }) - ) - ); - - params.delete('zoom'); - this.edit({ customer_attribute_restrictions: params.toString() }); + const augmentedValue = parse(newValue).map(value => { + if (Array.isArray(value)) { + return value.map(({ path, operator, value }) => { + const output: ParsedValue = { name: path, path: 'attributes', operator, value }; + return output; + }); + } else { + const output: ParsedValue = { + operator: value.operator, + value: value.value, + path: 'attributes', + name: value.path, + }; + + return output; + } + }); + + this.edit({ customer_attribute_restrictions: stringify(augmentedValue, true) }); }; private readonly __customerSubscriptionRestrictionsGetValue = () => { @@ -294,6 +284,7 @@ export class CouponForm extends Base { From e676046f69897a0f95390fbc0df03db9633978e6 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 13:51:27 -0300 Subject: [PATCH 14/26] fix(foxy-coupon-form): limit available operators in customer attribute restrictions control --- src/elements/public/CouponForm/CouponForm.test.ts | 1 + src/elements/public/CouponForm/CouponForm.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 7aa4052b..2bf366bd 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -638,6 +638,7 @@ describe('CouponForm', () => { expect(control).to.exist; expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.attribute('disable-zoom'); + expect(control).to.have.deep.property('operators', ['not', 'in']); expect(control.getValue()).to.equal(''); const cases = [ diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index fb18ba78..178c965f 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -1,19 +1,19 @@ import type { TemplateResult, PropertyDeclarations } from 'lit-element'; import type { Data, TransactionPageHrefGetter } from './types'; +import type { Option, ParsedValue } from '../QueryBuilder/types'; import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { SwipeAction } from '../../internal/InternalAsyncListControl/types'; import type { NucleonV8N } from '../NucleonElement/types'; import type { Resource } from '@foxy.io/sdk/core'; -import type { Option, ParsedValue } from '../QueryBuilder/types'; import type { Item } from '../../internal/InternalEditableListControl/types'; import type { Rels } from '@foxy.io/sdk/backend'; import { TranslatableMixin } from '../../../mixins/translatable'; import { ResponsiveMixin } from '../../../mixins/responsive'; import { BooleanSelector } from '@foxy.io/sdk/core'; +import { Type, Operator } from '../QueryBuilder/types'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; -import { Type } from '../QueryBuilder/types'; import { html } from 'lit-html'; import { parse } from '../QueryBuilder/utils/parse'; import { stringify } from '../QueryBuilder/utils/stringify'; @@ -68,6 +68,11 @@ export class CouponForm extends Base { getTransactionPageHref: TransactionPageHrefGetter | null = null; + private readonly __customerAttributeRestrictionsOperators: Operator[] = [ + Operator.Not, + Operator.In, + ]; + private readonly __customerAttributeRestrictionsGetValue = () => { const simplifiedValue = parse(this.form.customer_attribute_restrictions ?? '') .filter(value => Array.isArray(value) || typeof value.name === 'string') @@ -285,6 +290,7 @@ export class CouponForm extends Base { layout="summary-item" infer="customer-attribute-restrictions" disable-zoom + .operators=${this.__customerAttributeRestrictionsOperators} .getValue=${this.__customerAttributeRestrictionsGetValue} .setValue=${this.__customerAttributeRestrictionsSetValue} > From 91ad863b63515cee8ee8a42af0caf73ea6805c16 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 15:16:52 -0300 Subject: [PATCH 15/26] refactor: remove unused code --- custom-elements.json | 31 +- .../InternalAsyncDetailsControl.test.ts | 365 ------------------ .../InternalAsyncDetailsControl.ts | 174 --------- .../InternalAsyncDetailsControl/index.ts | 13 - web-test-runner.groups.js | 8 - 5 files changed, 22 insertions(+), 569 deletions(-) delete mode 100644 src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.test.ts delete mode 100644 src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.ts delete mode 100644 src/elements/internal/InternalAsyncDetailsControl/index.ts diff --git a/custom-elements.json b/custom-elements.json index 3cd6064f..826c3423 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -22059,10 +22059,10 @@ "description": "UI component for creating Foxy hAPI filters visually. Compatible with\nBackend API, Customer API or any other API using the same format as described\nin our [docs](https://api.foxy.io/docs/cheat-sheet).", "attributes": [ { - "name": "operators", - "description": "List of operators available in the builder UI.", - "type": "array", - "default": "\"Object.values(Operator)\"" + "name": "disable-zoom", + "description": "If true, doesn't add `zoom` query parameter for complex paths.", + "type": "boolean", + "default": "false" }, { "name": "disable-or", @@ -22070,6 +22070,12 @@ "type": "boolean", "default": "false" }, + { + "name": "operators", + "description": "List of operators available in the builder UI.", + "type": "array", + "default": "\"Object.values(Operator)\"" + }, { "name": "options", "description": "Autocomplete suggestions.", @@ -22153,11 +22159,11 @@ "default": "\"Type\"" }, { - "name": "operators", - "attribute": "operators", - "description": "List of operators available in the builder UI.", - "type": "array", - "default": "\"Object.values(Operator)\"" + "name": "disableZoom", + "attribute": "disable-zoom", + "description": "If true, doesn't add `zoom` query parameter for complex paths.", + "type": "boolean", + "default": "false" }, { "name": "disableOr", @@ -22166,6 +22172,13 @@ "type": "boolean", "default": "false" }, + { + "name": "operators", + "attribute": "operators", + "description": "List of operators available in the builder UI.", + "type": "array", + "default": "\"Object.values(Operator)\"" + }, { "name": "options", "attribute": "options", diff --git a/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.test.ts b/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.test.ts deleted file mode 100644 index f2785072..00000000 --- a/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import '../../public/CustomerCard'; -import '../../public/CustomerForm'; - -import { Rels } from '@foxy.io/sdk/backend'; -import { Resource } from '@foxy.io/sdk/core'; -import { expect, fixture, waitUntil } from '@open-wc/testing'; -import { html } from 'lit-html'; -import { createRouter } from '../../../server/index'; -import { getTestData } from '../../../testgen/getTestData'; -import { CollectionPage, FormDialog } from '../../public/index'; -import { FetchEvent } from '../../public/NucleonElement/FetchEvent'; -import { Pagination } from '../../public/Pagination/Pagination'; -import { InternalDetails } from '../InternalDetails/InternalDetails'; -import { InternalAsyncDetailsControl } from './index'; -import { stub } from 'sinon'; - -describe('InternalAsyncDetailsControl', () => { - it('imports and defines foxy-collection-page', () => { - expect(customElements.get('foxy-collection-page')).to.exist; - }); - - it('imports and defines foxy-form-dialog', () => { - expect(customElements.get('foxy-form-dialog')).to.exist; - }); - - it('imports and defines foxy-pagination', () => { - expect(customElements.get('foxy-pagination')).to.exist; - }); - - it('imports and defines foxy-i18n', () => { - expect(customElements.get('foxy-i18n')).to.exist; - }); - - it('imports and defines foxy-internal-control', () => { - expect(customElements.get('foxy-internal-control')).to.exist; - }); - - it('imports and defines foxy-internal-details', () => { - expect(customElements.get('foxy-internal-details')).to.exist; - }); - - it('imports and defines itself as foxy-internal-async-details-control', () => { - expect(customElements.get('foxy-internal-async-details-control')).to.equal( - InternalAsyncDetailsControl - ); - }); - - it('has a reactive property "related" (Array, empty by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.related.type', Array); - expect(new InternalAsyncDetailsControl()).to.have.deep.property('related', []); - }); - - it('has a reactive property "first" (String, empty by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.first.type', String); - expect(new InternalAsyncDetailsControl()).to.have.property('first', ''); - }); - - it('has a reactive property "limit" (Number, 20 by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.limit.type', Number); - expect(new InternalAsyncDetailsControl()).to.have.property('limit', 20); - }); - - it('has a reactive property "form" (String, empty by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.form.type', String); - expect(new InternalAsyncDetailsControl()).to.have.property('form', ''); - }); - - it('has a reactive property "item" (String, empty by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.item.type', String); - expect(new InternalAsyncDetailsControl()).to.have.property('item', ''); - }); - - it('has a reactive property "open" (Boolean, false by default)', () => { - expect(InternalAsyncDetailsControl).to.have.nested.property('properties.open.type', Boolean); - expect(new InternalAsyncDetailsControl()).to.have.property('open', false); - }); - - it('renders foxy-internal-details bound to the control state', async () => { - const control = await fixture(html` - - `); - - const details = control.renderRoot.querySelector('foxy-internal-details') as InternalDetails; - - expect(details).to.exist; - expect(details).to.have.property('infer', ''); - expect(details).to.have.property('summary', 'title'); - - control.open = true; - await control.requestUpdate(); - - expect(details).to.have.property('open', true); - - control.open = false; - await control.requestUpdate(); - - expect(details).to.have.property('open', false); - - details.open = true; - details.dispatchEvent(new CustomEvent('toggle')); - - expect(control).to.have.property('open', true); - - details.open = false; - details.dispatchEvent(new CustomEvent('toggle')); - - expect(control).to.have.property('open', false); - }); - - it('renders a form dialog when "form" is defined', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - const dialog = control.renderRoot.querySelector('foxy-form-dialog') as FormDialog; - - expect(dialog).to.have.property('parent', 'https://demo.api/hapi/customers?limit=20'); - expect(dialog).to.have.deep.property('related', ['https://demo.api/hapi/customer_attributes']); - expect(dialog).to.have.property('infer', 'dialog'); - expect(dialog).to.have.property('form', 'foxy-customer-form'); - }); - - it('renders Add button when "form" is defined and the control is editable', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - const dialog = control.renderRoot.querySelector('foxy-form-dialog') as FormDialog; - const button = control.renderRoot.querySelector('button[slot=actions]') as HTMLButtonElement; - const showMethod = stub(dialog, 'show'); - - expect(button).to.exist; - expect(button).to.have.property('disabled', false); - - button.click(); - - expect(showMethod).to.have.been.calledWith(button); - - expect(dialog).to.have.property('parent', 'https://demo.api/hapi/customers?limit=20'); - expect(dialog).to.have.property('header', 'header_create'); - expect(dialog).to.have.property('href', ''); - - showMethod.restore(); - }); - - it('hides Add button when "form" is not defined', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - expect(control.renderRoot.querySelector('button[slot=actions]')).to.not.exist; - }); - - it('hides Add button when "form" is defined but the control is readonly', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - expect(control.renderRoot.querySelector('button[slot=actions]')).to.not.exist; - }); - - it('disables Add button when the control is disabled', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - expect(control.renderRoot.querySelector('button[slot=actions]')).to.have.property( - 'disabled', - true - ); - }); - - it('renders pagination with limit applied', async () => { - const control = await fixture(html` - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - - expect(pagination).to.have.property('first', ''); - expect(pagination).to.have.property('infer', 'pagination'); - - control.first = 'https://demo.api/hapi/customers'; - control.limit = 10; - await control.requestUpdate(); - - expect(pagination).to.have.property('first', 'https://demo.api/hapi/customers?limit=10'); - }); - - it('renders a collection page inside of the pagination element', async () => { - const control = await fixture(html` - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - const page = pagination.querySelector('foxy-collection-page') as CollectionPage; - - expect(page).to.exist; - expect(page).to.have.property('infer', 'card'); - }); - - it('passes related links down to the collection page element', async () => { - const control = await fixture(html` - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - const page = pagination.querySelector('foxy-collection-page') as CollectionPage; - - expect(page).to.exist; - expect(page).to.have.deep.property('related', []); - - control.related = ['https://demo.api/hapi/customers/0']; - await control.requestUpdate(); - - expect(page).to.have.deep.property('related', ['https://demo.api/hapi/customers/0']); - }); - - it('renders collection items in a page', async () => { - type Data = Resource; - - const router = createRouter(); - const data = await getTestData('https://demo.api/hapi/customers?limit=20'); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - const page = pagination.querySelector('foxy-collection-page') as CollectionPage; - - await waitUntil(() => page.in({ idle: 'snapshot' })); - - const cards = page.querySelectorAll('foxy-customer-card'); - - for (let i = 0; i < data._embedded['fx:customers'].length; ++i) { - const item = data._embedded['fx:customers'][i]; - const card = cards[i]; - - expect(card).to.exist; - expect(card).to.have.deep.property('related', control.related); - expect(card).to.have.property('parent', control.first); - expect(card).to.have.property('infer', ''); - expect(card).to.have.property('href', item._links.self.href); - } - }); - - it('renders clickable collection items when "form" is defined', async () => { - type Data = Resource; - - const router = createRouter(); - const data = await getTestData('https://demo.api/hapi/customers?limit=20'); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - const page = pagination.querySelector('foxy-collection-page') as CollectionPage; - - await waitUntil(() => page.in({ idle: 'snapshot' })); - - const buttons = page.querySelectorAll('button'); - const dialog = control.renderRoot.querySelector('foxy-form-dialog') as FormDialog; - const showMethod = stub(dialog, 'show'); - - for (let i = 0; i < data._embedded['fx:customers'].length; ++i) { - const item = data._embedded['fx:customers'][i]; - const button = buttons[i]; - - expect(button).to.exist; - expect(button).to.have.property('disabled', false); - - button.click(); - - expect(showMethod).to.have.been.calledWith(button); - expect(dialog).to.have.property('header', 'header_update'); - expect(dialog).to.have.property('href', item._links.self.href); - - const card = button.querySelector('foxy-customer-card'); - - expect(card).to.exist; - expect(card).to.have.deep.property('related', control.related); - expect(card).to.have.property('parent', control.first); - expect(card).to.have.property('infer', ''); - expect(card).to.have.property('href', item._links.self.href); - - showMethod.reset(); - } - - showMethod.restore(); - }); - - it('disables clickable collection items when the control is disabled', async () => { - const router = createRouter(); - const control = await fixture(html` - router.handleEvent(evt)} - > - - `); - - const pagination = control.renderRoot.querySelector('foxy-pagination') as Pagination; - const page = pagination.querySelector('foxy-collection-page') as CollectionPage; - - await waitUntil(() => page.in({ idle: 'snapshot' })); - - for (const button of page.querySelectorAll('button')) { - expect(button).to.exist; - expect(button).to.have.property('disabled', true); - } - }); -}); diff --git a/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.ts b/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.ts deleted file mode 100644 index b51129f9..00000000 --- a/src/elements/internal/InternalAsyncDetailsControl/InternalAsyncDetailsControl.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { PropertyDeclarations, TemplateResult } from 'lit-element'; -import type { CollectionPage } from '../../public'; -import type { ItemRenderer } from '../../public/CollectionPage/types'; -import type { FormDialog } from '../../index'; - -import { InternalControl } from '../InternalControl/InternalControl'; -import { classMap } from '../../../utils/class-map'; -import { html } from 'lit-element'; - -/** - * Internal control displaying a collapsible card with - * optionally editable hAPI collection items. - * - * @element foxy-internal-async-details-control - * @since 1.17.0 - */ -export class InternalAsyncDetailsControl extends InternalControl { - static get properties(): PropertyDeclarations { - return { - ...super.properties, - related: { type: Array }, - first: { type: String }, - limit: { type: Number }, - form: { type: String }, - item: { type: String }, - open: { type: Boolean }, - }; - } - - /** Same as the `related` property of `NucleonElement`. */ - related = [] as string[]; - - /** Limit query parameter to apply to the `first` URL. */ - limit = 20; - - /** URI of the first page of the hAPI collection to display. */ - first = ''; - - /** Same as the `form` property of `FormDialog`. If set, will open a dialog on item click. */ - form: FormDialog['form'] = ''; - - /** Same as the `item` property of `CollectionPage`. */ - item: CollectionPage['item'] = ''; - - /** Same as the `open` property of `InternalDetails`. */ - open = false; - - private __cachedCardRenderer: { - item: InternalAsyncDetailsControl['item']; - render: ItemRenderer; - } | null = null; - - private __itemRenderer: ItemRenderer = ctx => { - if (!this.form || !ctx.data) return this.__cardRenderer(ctx); - - const isDisabled = this.disabledSelector.matches('card', true); - - return html` - - `; - }; - - renderControl(): TemplateResult { - let first: string; - - try { - const url = new URL(this.first); - url.searchParams.set('limit', String(this.limit)); - first = url.toString(); - } catch { - first = this.first; - } - - return html` - - (this.open = (evt.currentTarget as InternalAsyncDetailsControl).open)} - > - ${this.form - ? html` - - - - ${this.readonly - ? '' - : html` - - `} - ` - : ''} - - - - - - - `; - } - - private get __dialog() { - return this.renderRoot.querySelector('#form') as FormDialog; - } - - private get __cardRenderer() { - const item = this.item; - - if (this.__cachedCardRenderer?.item !== item) { - let render: ItemRenderer; - - if (item === null) { - render = () => html``; - } else if (typeof item === 'string') { - render = new Function( - 'ctx', - `return ctx.html\`<${item} related=\${JSON.stringify(ctx.related)} parent=\${ctx.parent} class="p-m" infer href=\${ctx.href}>\`` - ) as ItemRenderer; - } else { - render = item; - } - - this.__cachedCardRenderer = { item, render }; - } - - return this.__cachedCardRenderer.render; - } -} diff --git a/src/elements/internal/InternalAsyncDetailsControl/index.ts b/src/elements/internal/InternalAsyncDetailsControl/index.ts deleted file mode 100644 index 4e3670bf..00000000 --- a/src/elements/internal/InternalAsyncDetailsControl/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import '../../public/CollectionPage/index'; -import '../../public/FormDialog/index'; -import '../../public/Pagination/index'; -import '../../public/I18n/index'; - -import '../InternalControl/index'; -import '../InternalDetails/index'; - -import { InternalAsyncDetailsControl as Control } from './InternalAsyncDetailsControl'; - -customElements.define('foxy-internal-async-details-control', Control); - -export { Control as InternalAsyncDetailsControl }; diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index d6dc5246..63696664 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -7,10 +7,6 @@ export const groups = [ name: 'foxy-internal-async-combo-box-control', files: './src/elements/internal/InternalAsyncComboBoxControl/**/*.test.ts', }, - { - name: 'foxy-internal-async-details-control', - files: './src/elements/internal/InternalAsyncDetailsControl/**/*.test.ts', - }, { name: 'foxy-internal-async-list-control', files: './src/elements/internal/InternalAsyncListControl/**/*.test.ts', @@ -219,10 +215,6 @@ export const groups = [ name: 'foxy-access-recovery-form', files: './src/elements/public/AccessRecoveryForm/**/*.test.ts', }, - { - name: 'foxy-activate-store-form', - files: './src/elements/public/ActivateStoreForm/**/*.test.ts', - }, { name: 'foxy-address-card', files: './src/elements/public/AddressCard/**/*.test.ts', From 96a6e35e5430b9d330b522cfa4de5bec436db3eb Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 15:17:38 -0300 Subject: [PATCH 16/26] refactor: update more components to match nextgen design guidelines --- .../InternalAsyncResourceLinkListControl.ts | 40 +- .../InternalEditableListControl.ts | 427 +++++++++--------- .../InternalQueryBuilderControl.test.ts | 5 - .../InternalQueryBuilderControl.ts | 74 ++- .../InternalCouponFormRulesControl.ts | 36 +- .../public/DiscountBuilder/DiscountBuilder.ts | 6 +- .../public/QueryBuilder/components/Rule.ts | 7 +- 7 files changed, 258 insertions(+), 337 deletions(-) diff --git a/src/elements/internal/InternalAsyncResourceLinkListControl/InternalAsyncResourceLinkListControl.ts b/src/elements/internal/InternalAsyncResourceLinkListControl/InternalAsyncResourceLinkListControl.ts index 54efb6df..e8cf968d 100644 --- a/src/elements/internal/InternalAsyncResourceLinkListControl/InternalAsyncResourceLinkListControl.ts +++ b/src/elements/internal/InternalAsyncResourceLinkListControl/InternalAsyncResourceLinkListControl.ts @@ -132,20 +132,17 @@ export class InternalAsyncResourceLinkListControl extends InternalEditableContro return html`
-
- ${this.label} - - +
+
+ ${this.label} + + +
+
${this.helperText}
@@ -153,7 +150,7 @@ export class InternalAsyncResourceLinkListControl extends InternalEditableContro infer="card" class=${classMap({ 'block transition-colors divide-y rounded overflow-hidden': true, - 'divide-contrast-10 ring-1 ring-inset ring-contrast-10': true, + 'bg-contrast-5 divide-contrast-10': true, })} .item=${this.__renderItem} > @@ -161,18 +158,7 @@ export class InternalAsyncResourceLinkListControl extends InternalEditableContro
- ${this.helperText} -
- -
${this._errorMessage} diff --git a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts index 14c29bed..cb39f31a 100644 --- a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts +++ b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts @@ -78,9 +78,8 @@ export class InternalEditableListControl extends InternalEditableControl { 'transition-colors flex items-center': true, 'text-secondary': this.readonly, 'text-disabled': this.disabled, - 'group-hover-divide-contrast-20': !isSummaryItem && isInteractive, - 'pl-s border border-contrast-10 rounded-s': isSummaryItem, - 'ml-s h-m': !isSummaryItem, + 'border border-contrast-10 rounded-s': isSummaryItem, + 'h-m': !isSummaryItem, }); const isAddButtonDisabled = this.disabled || !this.__newItem; @@ -103,250 +102,234 @@ export class InternalEditableListControl extends InternalEditableControl { this.__newItem = ''; }; - const helperAndError = html` -
- ${this.helperText} -
- -
- ${this._errorMessage} -
- `; - return html` -
+
+
${this.label}
- ${this.label} + ${this.helperText}
+
- ${isSummaryItem ? helperAndError : ''} +
+
    + ${repeat( + this._value, + item => item.value, + (item, index) => { + return html` +
  1. +
    ${item.label ?? item.value}
    + + +
  2. + `; + } + )} +
0, + 'rounded': !isSummaryItem && this._value.length === 0, + 'rounded-b': !isSummaryItem && this._value.length > 0, })} > -
    - ${repeat( - this._value, - item => item.value, - (item, index) => { - return html` -
  1. -
    ${item.label ?? item.value}
    - - -
  2. - `; - } - )} -
+ ${this.range + ? html` + + + evt.key === 'Enter' && addItem()} + @change=${(evt: Event) => evt.stopPropagation()} + @input=${(evt: InputEvent) => { + const newFrom = (evt.currentTarget as HTMLInputElement).value.trim(); + const oldTo = this.__newItem.split('..')[1] ?? ''; + this.__newItem = oldTo ? `${newFrom}..${oldTo}` : newFrom; + }} + @paste=${(evt: ClipboardEvent) => { + evt.preventDefault(); + + const newFrom = evt.clipboardData?.getData('text') ?? ''; + const oldTo = this.__newItem.split('..')[1] ?? ''; + this.__newItem = oldTo ? `${newFrom}..${oldTo}` : newFrom; + + addItem(); + }} + @blur=${() => { + this.__isErrorVisible = true; + }} + /> + + + + evt.key === 'Enter' && addItem()} + @change=${(evt: Event) => evt.stopPropagation()} + @input=${(evt: InputEvent) => { + const newTo = (evt.currentTarget as HTMLInputElement).value.trim(); + const oldFrom = this.__newItem.split('..')[0] ?? ''; + this.__newItem = oldFrom ? `${oldFrom}..${newTo}` : newTo; + }} + @paste=${(evt: ClipboardEvent) => { + evt.preventDefault(); + + const newTo = evt.clipboardData?.getData('text') ?? ''; + const oldFrom = this.__newItem.split('..')[0] ?? ''; + this.__newItem = oldFrom ? `${oldFrom}..${newTo}` : newTo; + + addItem(); + }} + @blur=${() => { + this.__isErrorVisible = true; + }} + /> + ` + : html` + evt.key === 'Enter' && addItem()} + @change=${(evt: Event) => evt.stopPropagation()} + @input=${(evt: InputEvent) => { + this.__newItem = (evt.currentTarget as HTMLInputElement).value.trim(); + }} + @paste=${(evt: ClipboardEvent) => { + evt.preventDefault(); + this.__newItem = evt.clipboardData?.getData('text') ?? ''; + addItem(); + }} + @blur=${() => { + this.__isErrorVisible = true; + }} + /> + `}
0, + 'relative': true, + 'hover-text-base focus-within-text-primary-contrast': !this.disabled, + 'text-disabled': this.disabled, })} + ?hidden=${this.units.length === 0} > - ${this.range - ? html` - - - - evt.key === 'Enter' && addItem()} - @change=${(evt: Event) => evt.stopPropagation()} - @input=${(evt: InputEvent) => { - const newFrom = (evt.currentTarget as HTMLInputElement).value.trim(); - const oldTo = this.__newItem.split('..')[1] ?? ''; - this.__newItem = oldTo ? `${newFrom}..${oldTo}` : newFrom; - }} - @paste=${(evt: ClipboardEvent) => { - evt.preventDefault(); - - const newFrom = evt.clipboardData?.getData('text') ?? ''; - const oldTo = this.__newItem.split('..')[1] ?? ''; - this.__newItem = oldTo ? `${newFrom}..${oldTo}` : newFrom; - - addItem(); - }} - @blur=${() => { - this.__isErrorVisible = true; - }} - /> - - - - evt.key === 'Enter' && addItem()} - @change=${(evt: Event) => evt.stopPropagation()} - @input=${(evt: InputEvent) => { - const newTo = (evt.currentTarget as HTMLInputElement).value.trim(); - const oldFrom = this.__newItem.split('..')[0] ?? ''; - this.__newItem = oldFrom ? `${oldFrom}..${newTo}` : newTo; - }} - @paste=${(evt: ClipboardEvent) => { - evt.preventDefault(); - - const newTo = evt.clipboardData?.getData('text') ?? ''; - const oldFrom = this.__newItem.split('..')[0] ?? ''; - this.__newItem = oldFrom ? `${oldFrom}..${newTo}` : newTo; - - addItem(); - }} - @blur=${() => { - this.__isErrorVisible = true; - }} - /> - ` - : html` - evt.key === 'Enter' && addItem()} - @change=${(evt: Event) => evt.stopPropagation()} - @input=${(evt: InputEvent) => { - this.__newItem = (evt.currentTarget as HTMLInputElement).value.trim(); - }} - @paste=${(evt: ClipboardEvent) => { - evt.preventDefault(); - this.__newItem = evt.clipboardData?.getData('text') ?? ''; - addItem(); - }} - @blur=${() => { - this.__isErrorVisible = true; - }} - /> - `} - -
- -
- - - ${this.options.map(({ label, value }) => { - if (this._value.some(item => item.value === value)) return; + ${this.units.map(({ label, value }) => { return html``; })} - - -
- -
+ +
+ + + ${this.options.map(({ label, value }) => { + if (this._value.some(item => item.value === value)) return; + return html``; + })} + + +
+
+
- ${isSummaryItem ? '' : helperAndError} +
+ ${this._errorMessage}
`; } diff --git a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts index eebfea1b..29975ada 100644 --- a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts +++ b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.test.ts @@ -40,11 +40,6 @@ describe('InternalQueryBuilderControl', () => { }); }); - it('has a reactive property "layout"', () => { - expect(new Control()).to.have.property('layout', null); - expect(Control).to.have.deep.nested.property('properties.layout', {}); - }); - it('extends foxy-internal-editable-control', () => { expect(new Control()).to.be.instanceOf(customElements.get('foxy-internal-editable-control')); }); diff --git a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts index 04d91e19..ea4e3c63 100644 --- a/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts +++ b/src/elements/internal/InternalQueryBuilderControl/InternalQueryBuilderControl.ts @@ -4,7 +4,6 @@ import type { QueryBuilder } from '../../public/QueryBuilder/QueryBuilder'; import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; import { Operator } from '../../public/QueryBuilder/types'; -import { classMap } from '../../../utils/class-map'; import { html } from 'lit-html'; export class InternalQueryBuilderControl extends InternalEditableControl { @@ -14,7 +13,6 @@ export class InternalQueryBuilderControl extends InternalEditableControl { disableZoom: { type: Boolean, attribute: 'disable-zoom' }, disableOr: { type: Boolean, attribute: 'disable-or' }, operators: { type: Array }, - layout: {}, }; } @@ -24,57 +22,37 @@ export class InternalQueryBuilderControl extends InternalEditableControl { operators: Operator[] = Object.values(Operator); - layout: 'standalone' | 'summary-item' | null = null; - renderControl(): TemplateResult { const { label, helperText, _errorMessage: error } = this; const showError = error && !this.disabled && !this.readonly; - const layout = this.layout ?? 'standalone'; - const builder = html` - { - const queryBuilder = evt.currentTarget as QueryBuilder; - this._value = queryBuilder.value ?? ''; - }} - > - - `; return html` -
- ${label - ? html` -

- ${label} -

- ` - : ''} - ${layout === 'standalone' ? builder : ''} - ${helperText ? html`

${helperText}

` : ''} - ${showError ? html`

${error}

` : ''} - ${layout === 'summary-item' ? builder : ''} +
+
+ ${label ? html`

${label}

` : ''} + ${helperText ? html`

${helperText}

` : ''} +
+ +
+ { + const queryBuilder = evt.currentTarget as QueryBuilder; + this._value = queryBuilder.value ?? ''; + }} + > + +
+ + ${showError ? html`

${error}

` : ''}
`; } diff --git a/src/elements/public/CouponForm/internal/InternalCouponFormRulesControl/InternalCouponFormRulesControl.ts b/src/elements/public/CouponForm/internal/InternalCouponFormRulesControl/InternalCouponFormRulesControl.ts index f5ed79ee..3385da85 100644 --- a/src/elements/public/CouponForm/internal/InternalCouponFormRulesControl/InternalCouponFormRulesControl.ts +++ b/src/elements/public/CouponForm/internal/InternalCouponFormRulesControl/InternalCouponFormRulesControl.ts @@ -17,18 +17,13 @@ export class InternalCouponFormRulesControl extends InternalEditableControl { return html`
-

- - ${this.label} - - ${this.__renderPreset()} -

+
+

+ ${this.label} + ${this.__renderPreset()} +

+

${helperText}

+
- ${helperText || description - ? html` -

- ${description || helperText} -

- ` - : ''} + ${description ? html`

${description}

` : ''} ${errorMessage && !this.disabled && !this.readonly - ? html`

${this._errorMessage}

` + ? html`

${this._errorMessage}

` : ''}
`; diff --git a/src/elements/public/DiscountBuilder/DiscountBuilder.ts b/src/elements/public/DiscountBuilder/DiscountBuilder.ts index a4721b69..f1cc8228 100644 --- a/src/elements/public/DiscountBuilder/DiscountBuilder.ts +++ b/src/elements/public/DiscountBuilder/DiscountBuilder.ts @@ -282,11 +282,7 @@ export class DiscountBuilder extends Base {
Date: Thu, 3 Oct 2024 17:04:50 -0300 Subject: [PATCH 17/26] fix(foxy-coupon-form): disable `OR` rules in customer attribute restrictions --- src/elements/public/CouponForm/CouponForm.test.ts | 1 + src/elements/public/CouponForm/CouponForm.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 2bf366bd..972896a8 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -638,6 +638,7 @@ describe('CouponForm', () => { expect(control).to.exist; expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.attribute('disable-zoom'); + expect(control).to.have.attribute('disable-or'); expect(control).to.have.deep.property('operators', ['not', 'in']); expect(control.getValue()).to.equal(''); diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index 178c965f..c077427d 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -290,6 +290,7 @@ export class CouponForm extends Base { layout="summary-item" infer="customer-attribute-restrictions" disable-zoom + disable-or .operators=${this.__customerAttributeRestrictionsOperators} .getValue=${this.__customerAttributeRestrictionsGetValue} .setValue=${this.__customerAttributeRestrictionsSetValue} From 35677b19b1a8e35b26780c9c9d8eaf9786591d0c Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 3 Oct 2024 17:10:57 -0300 Subject: [PATCH 18/26] refactor(foxy-coupon-form): reorganize controls --- .../public/CouponForm/CouponForm.stories.ts | 8 +- .../public/CouponForm/CouponForm.test.ts | 19 ++--- src/elements/public/CouponForm/CouponForm.ts | 34 ++++---- src/static/translations/coupon-form/en.json | 78 +++++++++---------- 4 files changed, 67 insertions(+), 72 deletions(-) diff --git a/src/elements/public/CouponForm/CouponForm.stories.ts b/src/elements/public/CouponForm/CouponForm.stories.ts index f7551590..53b3ba92 100644 --- a/src/elements/public/CouponForm/CouponForm.stories.ts +++ b/src/elements/public/CouponForm/CouponForm.stories.ts @@ -30,9 +30,9 @@ const summary: Summary = { 'options:exclude-line-item-discounts', 'options:is-taxable', 'options:shared-codes-allowed', - 'customer-restrictions:customer-auto-apply', - 'customer-restrictions:customer-subscription-restrictions', - 'customer-restrictions:customer-attribute-restrictions', + 'customer-subscription-restrictions', + 'customer-attribute-restrictions', + 'auto-apply:customer-auto-apply', 'attributes', ], sections: [ @@ -43,7 +43,7 @@ const summary: Summary = { 'timeframe', 'options', 'taxes', - 'customer-restrictions', + 'auto-apply', ], buttons: [ 'import', diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 972896a8..3abcbb4d 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -603,24 +603,22 @@ describe('CouponForm', () => { }); } - it('renders a Customer Restrictions summary control', async () => { + it('renders an Auto-Apply summary control', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-summary-control[infer="customer-restrictions"]' + 'foxy-internal-summary-control[infer="auto-apply"]' ); expect(control).to.exist; }); - it('renders editable list control for subscription restrictions inside of the Customer Restrictions summary', async () => { + it('renders editable list control for subscription restrictions', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer=customer-restrictions] foxy-internal-editable-list-control[infer=customer-subscription-restrictions]' + 'foxy-internal-editable-list-control[infer=customer-subscription-restrictions]' ) as InternalEditableListControl; expect(control).to.exist; - expect(control).to.have.attribute('layout', 'summary-item'); - expect(control.getValue()).to.deep.equal([]); control.setValue([{ value: 'a' }, { value: 'b' }]); expect(element).to.have.deep.nested.property('form.customer_subscription_restrictions', 'a,b'); @@ -629,14 +627,13 @@ describe('CouponForm', () => { expect(control.getValue()).to.deep.equal([{ value: 'foo' }, { value: 'bar' }]); }); - it('renders query builder control for customer attribute restrictions inside of the Customer Restrictions summary', async () => { + it('renders query builder control for customer attribute restrictions', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer=customer-restrictions] foxy-internal-query-builder-control[infer=customer-attribute-restrictions]' + 'foxy-internal-query-builder-control[infer=customer-attribute-restrictions]' ) as InternalQueryBuilderControl; expect(control).to.exist; - expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.attribute('disable-zoom'); expect(control).to.have.attribute('disable-or'); expect(control).to.have.deep.property('operators', ['not', 'in']); @@ -668,10 +665,10 @@ describe('CouponForm', () => { }); }); - it('renders switch control for auto-apply inside of the Customer Restrictions summary', async () => { + it('renders switch control for auto-apply inside of the Auto Apply summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer=customer-restrictions] foxy-internal-switch-control[infer=customer-auto-apply]' + '[infer=auto-apply] foxy-internal-switch-control[infer=customer-auto-apply]' ); expect(control).to.exist; diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index c077427d..1b9ff58c 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -285,26 +285,24 @@ export class CouponForm extends Base { - - - + + - - + + + diff --git a/src/static/translations/coupon-form/en.json b/src/static/translations/coupon-form/en.json index fb82a6ec..b742772c 100644 --- a/src/static/translations/coupon-form/en.json +++ b/src/static/translations/coupon-form/en.json @@ -560,46 +560,46 @@ }, "helper_text": "Limit which categories this coupon applies to. All changes are saved automatically." }, - "customer-restrictions": { - "label": "Customer restrictions", + "customer-attribute-restrictions": { + "label": "Customer attribute restrictions", + "helper_text": "This restricts the usage of the coupon based on an attribute name and value. When defined, the coupon will only apply if the customer has the specified attribute name and value. Wildcards are allowed.", + "v8n_too_long": "Unfortunately we are unable to store that many customer attribute restrictions at the moment. Please reduce the number of rules in this section until this message disappears.", + "query-builder": { + "add_or_clause": "Add OR clause", + "add_value": "Add value", + "code": "Code", + "delete": "Delete", + "field": "Attribute name", + "is_defined_false": "Not defined", + "is_defined_true": "Defined", + "operator_equal": "Equal", + "operator_greaterthan": "Greater than", + "operator_greaterthanorequal": "Greater than or equal", + "operator_in": "One of", + "operator_isdefined": "Is defined", + "operator_lessthan": "Less than", + "operator_lessthanorequal": "Less than or equal", + "operator_not": "Not equal", + "or": "Or", + "query_builder_group": "Group of rules", + "query_builder_rule": "Rule", + "type": "Type", + "type_any": "Field of unknown type", + "type_here": "Type here...", + "value": "Attribute value" + } + }, + "customer-subscription-restrictions": { + "label": "Customer subscription restrictions", + "placeholder": "Enter a product code and hit Enter", + "helper_text": "This restricts the usage of the coupon based on product codes in the current customer's active subscriptions. When defined, the coupon will only apply if the customer has an active subscription with a matching product code. Wildcards are allowed.", + "caption": "Add this code", + "delete": "Delete this code", + "v8n_too_long": "Unfortunately we are unable to store that many subscription restrictions at the moment. Please reduce the number of rules in this section until this message disappears." + }, + "auto-apply": { + "label": "Auto-apply", "helper_text": "", - "customer-attribute-restrictions": { - "label": "Customer attribute restrictions", - "helper_text": "This restricts the usage of the coupon based on an attribute name and value. When defined, the coupon will only apply if the customer has the specified attribute name and value. Wildcards are allowed.", - "v8n_too_long": "Unfortunately we are unable to store that many customer attribute restrictions at the moment. Please reduce the number of rules in this section until this message disappears.", - "query-builder": { - "add_or_clause": "Add OR clause", - "add_value": "Add value", - "code": "Code", - "delete": "Delete", - "field": "Attribute name", - "is_defined_false": "Not defined", - "is_defined_true": "Defined", - "operator_equal": "Equal", - "operator_greaterthan": "Greater than", - "operator_greaterthanorequal": "Greater than or equal", - "operator_in": "One of", - "operator_isdefined": "Is defined", - "operator_lessthan": "Less than", - "operator_lessthanorequal": "Less than or equal", - "operator_not": "Not equal", - "or": "Or", - "query_builder_group": "Group of rules", - "query_builder_rule": "Rule", - "type": "Type", - "type_any": "Field of unknown type", - "type_here": "Type here...", - "value": "Attribute value" - } - }, - "customer-subscription-restrictions": { - "label": "Customer subscription restrictions", - "placeholder": "Enter a product code and hit Enter", - "helper_text": "This restricts the usage of the coupon based on product codes in the current customer's active subscriptions. When defined, the coupon will only apply if the customer has an active subscription with a matching product code. Wildcards are allowed.", - "caption": "Add this code", - "delete": "Delete this code", - "v8n_too_long": "Unfortunately we are unable to store that many subscription restrictions at the moment. Please reduce the number of rules in this section until this message disappears." - }, "customer-auto-apply": { "label": "Auto apply coupon for matching customers", "helper_text": "If enabled, when a customer authenticates on the checkout, this coupon will automatically apply if the above two customer restrictions are met.", From 27299238ee37f6c9f55d1acbe17802e1b9e7a928 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 11 Oct 2024 23:47:19 -0300 Subject: [PATCH 19/26] fix: update user invitation elements to support recent api changes --- .../UserInvitationForm.test.ts | 113 +++++++++++------- .../UserInvitationForm/UserInvitationForm.ts | 102 +++++++++------- .../public/UserInvitationForm/index.ts | 2 +- .../InternalUserInvitationFormAsyncAction.ts | 2 +- .../InternalUserInvitationFormSyncAction.ts | 34 ------ .../index.ts | 12 -- .../public/UserInvitationForm/types.ts | 5 +- src/server/hapi/links.ts | 3 + .../translations/user-invitation-card/en.json | 2 + .../translations/user-invitation-form/en.json | 48 ++++---- 10 files changed, 170 insertions(+), 153 deletions(-) delete mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts delete mode 100644 src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts index 50d9cb07..6deb6712 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts @@ -17,6 +17,10 @@ describe('UserInvitationForm', () => { expect(customElements.get('foxy-internal-summary-control')).to.exist; }); + it('imports and defines foxy-internal-delete-control', () => { + expect(customElements.get('foxy-internal-delete-control')).to.exist; + }); + it('imports and defines foxy-internal-text-control', () => { expect(customElements.get('foxy-internal-text-control')).to.exist; }); @@ -29,10 +33,6 @@ describe('UserInvitationForm', () => { expect(customElements.get('foxy-internal-user-invitation-form-async-action')).to.exist; }); - it('imports and defines foxy-internal-user-invitation-form-sync-action', () => { - expect(customElements.get('foxy-internal-user-invitation-form-sync-action')).to.exist; - }); - it('defines itself as foxy-user-invitation-form', () => { expect(customElements.get('foxy-user-invitation-form')).to.equal(Form); }); @@ -42,6 +42,11 @@ describe('UserInvitationForm', () => { expect(new Form().ns).to.equal('user-invitation-form'); }); + it('has a reactive property "getStorePageHref"', () => { + expect(new Form()).to.have.property('getStorePageHref', null); + expect(Form).to.have.deep.nested.property('properties.getStorePageHref', { attribute: false }); + }); + it('has a reactive property "defaultDomain"', () => { expect(new Form()).to.have.property('defaultDomain', null); expect(Form).to.have.deep.nested.property('properties.defaultDomain', { @@ -77,7 +82,7 @@ describe('UserInvitationForm', () => { expect(form.hiddenSelector.matches('undo', true)).to.be.true; }); - it('hides Delete button when status is not "rejected"', async () => { + it('hides Delete button when status is not "rejected" or "expired" (in admin layout)', async () => { const form = new Form(); expect(form.hiddenSelector.matches('delete', true)).to.be.true; @@ -89,6 +94,13 @@ describe('UserInvitationForm', () => { data.status = 'rejected'; form.data = { ...data }; expect(form.hiddenSelector.matches('delete', true)).to.be.false; + + data.status = 'expired'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('delete', true)).to.be.true; + + form.layout = 'admin'; + expect(form.hiddenSelector.matches('delete', true)).to.be.false; }); it('hides Leave button when status is not "accepted"', async () => { @@ -123,18 +135,29 @@ describe('UserInvitationForm', () => { expect(form.hiddenSelector.matches('revoke', true)).to.be.true; }); - it('hides Invite Again button when status is not "revoked"', async () => { + it('hides Resend button when status is not "revoked" or "sent" or "expired" (in admin layout)', async () => { const form = new Form(); - expect(form.hiddenSelector.matches('invite-again', true)).to.be.true; + expect(form.hiddenSelector.matches('resend', true)).to.be.true; const data = await getTestData('./hapi/user_invitations/0'); - data.status = 'sent'; + data.status = 'accepted'; form.data = { ...data }; - expect(form.hiddenSelector.matches('invite-again', true)).to.be.true; + expect(form.hiddenSelector.matches('resend', true)).to.be.true; data.status = 'revoked'; form.data = { ...data }; - expect(form.hiddenSelector.matches('invite-again', true)).to.be.false; + expect(form.hiddenSelector.matches('resend', true)).to.be.false; + + data.status = 'sent'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('resend', true)).to.be.false; + + data.status = 'expired'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('resend', true)).to.be.true; + + form.layout = 'admin'; + expect(form.hiddenSelector.matches('resend', true)).to.be.false; }); it('hides Resend, Accept and Reject buttons when status is not "sent"', async () => { @@ -358,7 +381,7 @@ describe('UserInvitationForm', () => { } }); - it('renders sync action for inviting user again in snapshot admin layout', async () => { + it('renders async action for revoking access in snapshot admin layout', async () => { const router = createRouter(); const form = await fixture(html` { await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); const action = form.renderRoot.querySelector( - 'foxy-internal-user-invitation-form-sync-action[infer="invite-again"]' + 'foxy-internal-user-invitation-form-async-action[infer="revoke"]' ); expect(action).to.exist; - expect(action).to.have.attribute('status', 'sent'); - }); - - it('renders sync action for revoking access in snapshot admin layout', async () => { - const router = createRouter(); - const form = await fixture(html` - router.handleEvent(evt)} - > - - `); - - await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); - const action = form.renderRoot.querySelector( - 'foxy-internal-user-invitation-form-sync-action[infer="revoke"]' - ); - - expect(action).to.exist; - expect(action).to.have.attribute('status', 'revoked'); + expect(action).to.have.attribute('href', form.data!._links['fx:revoke'].href); expect(action).to.have.attribute('theme', 'error'); }); @@ -492,7 +495,7 @@ describe('UserInvitationForm', () => { expect(storeUrl).to.have.attribute('layout', 'summary-item'); }); - it('renders sync action for leaving the store in snapshot user layout', async () => { + it('renders async action for leaving the store in snapshot user layout', async () => { const router = createRouter(); const form = await fixture(html` { await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); const action = form.renderRoot.querySelector( - 'foxy-internal-user-invitation-form-sync-action[infer="leave"]' + 'foxy-internal-user-invitation-form-async-action[infer="leave"]' ); expect(action).to.exist; - expect(action).to.have.attribute('status', 'revoked'); + expect(action).to.have.attribute('href', form.data!._links['fx:revoke'].href); expect(action).to.have.attribute('theme', 'error'); }); - it('renders sync action for rejecting the invitation in snapshot user layout', async () => { + it('renders async action for rejecting the invitation in snapshot user layout', async () => { const router = createRouter(); const form = await fixture(html` { await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); const action = form.renderRoot.querySelector( - 'foxy-internal-user-invitation-form-sync-action[infer="reject"]' + 'foxy-internal-user-invitation-form-async-action[infer="reject"]' ); expect(action).to.exist; - expect(action).to.have.attribute('status', 'rejected'); + expect(action).to.have.attribute('href', form.data!._links['fx:reject'].href); expect(action).to.have.attribute('theme', 'error primary'); }); - it('renders sync action for accepting the invitation in snapshot user layout', async () => { + it('renders async action for accepting the invitation in snapshot user layout', async () => { const router = createRouter(); const form = await fixture(html` { await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); const action = form.renderRoot.querySelector( - 'foxy-internal-user-invitation-form-sync-action[infer="accept"]' + 'foxy-internal-user-invitation-form-async-action[infer="accept"]' ); expect(action).to.exist; - expect(action).to.have.attribute('status', 'accepted'); + expect(action).to.have.attribute('href', form.data!._links['fx:accept'].href); expect(action).to.have.attribute('theme', 'success primary'); }); @@ -571,4 +574,30 @@ describe('UserInvitationForm', () => { expect(button).to.exist; expect(button).to.have.attribute('infer', 'delete'); }); + + it('renders store dashboard link in user layout when getStorePageHref is set', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + expect(form.renderRoot.querySelector('[infer="store"] [key="store_link"]')).to.not.exist; + + form.getStorePageHref = (href: string) => `https://example.com?href=${href}`; + await form.requestUpdate(); + const caption = form.renderRoot.querySelector('[infer="store"] [key="store_link"]'); + expect(caption).to.exist; + + const link = caption?.closest('a'); + expect(link).to.exist; + expect(link).to.have.attribute( + 'href', + 'https://example.com?href=https://demo.api/hapi/stores/0' + ); + }); }); diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.ts index f2697150..298c7cc5 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.ts @@ -19,6 +19,7 @@ export class UserInvitationForm extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + getStorePageHref: { attribute: false }, defaultDomain: { attribute: 'default-domain' }, layout: {}, }; @@ -55,6 +56,9 @@ export class UserInvitationForm extends Base { return [({ email: v }) => !!v || 'email:v8n_required']; } + /** When provided, displays a link to Store Dashboard in user layout. */ + getStorePageHref: ((storeHref: string) => string) | null = null; + /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ defaultDomain: string | null = null; @@ -75,12 +79,22 @@ export class UserInvitationForm extends Base { get hiddenSelector(): BooleanSelector { const alwaysMatch = ['timestamps', 'submit', 'undo', super.hiddenSelector.toString()]; const status = this.data?.status; + const layout = this.layout ?? 'user'; if (status !== 'accepted' && status !== 'sent') alwaysMatch.unshift('revoke'); - if (status !== 'rejected') alwaysMatch.unshift('delete'); if (status !== 'accepted') alwaysMatch.unshift('leave'); - if (status !== 'revoked') alwaysMatch.unshift('invite-again'); - if (status !== 'sent') alwaysMatch.unshift('resend', 'accept', 'reject'); + if (status !== 'sent') alwaysMatch.unshift('accept', 'reject'); + + if ( + (status !== 'rejected' || layout !== 'user') && + (status !== 'expired' || layout !== 'admin') + ) { + alwaysMatch.unshift('delete'); + } + + if (status !== 'sent' && status !== 'revoked' && (status !== 'expired' || layout !== 'admin')) { + alwaysMatch.unshift('resend'); + } return new BooleanSelector(alwaysMatch.join(' ').trim()); } @@ -157,6 +171,7 @@ export class UserInvitationForm extends Base { private __renderAdminSnapshotState({ first_name, last_name }: Data) { const hasName = first_name?.trim() || last_name?.trim(); const nameOptions = { first_name, last_name, context: hasName ? '' : 'empty' }; + const status = this.data?.status; const hidden = this.hiddenSelector; return html` @@ -184,27 +199,25 @@ export class UserInvitationForm extends Base { style="padding: calc(0.625em + (var(--lumo-border-radius) / 4) - 1px)" class=${classMap({ 'border rounded': true, - 'border-contrast text-contrast': this.data?.status === 'revoked', - 'border-success text-success': this.data?.status === 'accepted', - 'border-primary text-primary': this.data?.status === 'sent', - 'border-error text-error': this.data?.status === 'rejected', + 'border-contrast text-contrast': status === 'revoked' || status === 'expired', + 'border-success text-success': status === 'accepted', + 'border-primary text-primary': status === 'sent', + 'border-error text-error': status === 'rejected', })} >

- ${this.data?.status === 'revoked' + ${status === 'revoked' ? svg`` - : this.data?.status === 'sent' + : status === 'sent' ? svg`` - : this.data?.status === 'rejected' + : status === 'rejected' ? svg`` - : this.data?.status === 'accepted' + : status === 'accepted' ? svg`` + : status === 'expired' + ? svg`` : ''} - +

@@ -212,36 +225,34 @@ export class UserInvitationForm extends Base { class="text-body" style="padding-left: calc((1.25 * var(--lumo-font-size-m)) + var(--lumo-space-s))" > - +

- - -
- - + + + +
@@ -255,7 +266,6 @@ export class UserInvitationForm extends Base { 'text-primary': status === 'sent', 'text-success': status === 'accepted', 'text-error': status === 'rejected', - 'text-body': status === 'revoked', }; return html` @@ -284,6 +294,18 @@ export class UserInvitationForm extends Base { + ${status === 'accepted' && this.getStorePageHref && this.data + ? html` +
+ + + +
+ ` + : ''} { > - - +
- - + - - +
diff --git a/src/elements/public/UserInvitationForm/index.ts b/src/elements/public/UserInvitationForm/index.ts index 1052ae34..65e03799 100644 --- a/src/elements/public/UserInvitationForm/index.ts +++ b/src/elements/public/UserInvitationForm/index.ts @@ -1,9 +1,9 @@ import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalDeleteControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; import './internal/InternalUserInvitationFormAsyncAction/index'; -import './internal/InternalUserInvitationFormSyncAction/index'; import { UserInvitationForm } from './UserInvitationForm'; diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts index 51663c6a..60d98cc5 100644 --- a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts @@ -47,7 +47,7 @@ export class InternalUserInvitationFormAsyncAction extends InternalControl { const response = await api.fetch(this.href ?? '', { method: 'POST' }); this.__state = response.ok ? 'idle' : 'fail'; - if (response.ok) this.dispatchEvent(new CustomEvent('done')); + if (response.ok) this.nucleon?.refresh(); } catch { this.__state = 'fail'; } diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts deleted file mode 100644 index a8dc9795..00000000 --- a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/InternalUserInvitationFormSyncAction.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { PropertyDeclarations, TemplateResult } from 'lit-element'; -import type { UserInvitationForm } from '../../UserInvitationForm'; -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 InternalUserInvitationFormSyncAction extends InternalControl { - static get properties(): PropertyDeclarations { - return { ...super.properties, status: {}, theme: {} }; - } - - status: Data['status'] | null = null; - - theme: string | null = null; - - renderControl(): TemplateResult { - return html` - { - const nucleon = this.nucleon as UserInvitationForm | null; - const status = this.status; - if (status) nucleon?.edit({ status }), nucleon?.submit(); - }} - > - - - `; - } -} diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts deleted file mode 100644 index b69dfe82..00000000 --- a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormSyncAction/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import '@vaadin/vaadin-button'; -import '../../../../internal/InternalControl/index'; -import '../../../I18n/index'; - -import { InternalUserInvitationFormSyncAction } from './InternalUserInvitationFormSyncAction'; - -customElements.define( - 'foxy-internal-user-invitation-form-sync-action', - InternalUserInvitationFormSyncAction -); - -export { InternalUserInvitationFormSyncAction }; diff --git a/src/elements/public/UserInvitationForm/types.ts b/src/elements/public/UserInvitationForm/types.ts index 79ced444..5a71ba1f 100644 --- a/src/elements/public/UserInvitationForm/types.ts +++ b/src/elements/public/UserInvitationForm/types.ts @@ -14,6 +14,9 @@ export interface UserInvitation extends Graph { 'fx:user': Rels.User; 'fx:store': Rels.Store; 'fx:resend': { curie: 'fx:resend' }; + 'fx:accept': { curie: 'fx:accept' }; + 'fx:reject': { curie: 'fx:reject' }; + 'fx:revoke': { curie: 'fx:revoke' }; }; props: { store_url: string; @@ -23,7 +26,7 @@ export interface UserInvitation extends Graph { first_name: string | null; last_name: string | null; email: string; - status: 'sent' | 'accepted' | 'rejected' | 'revoked'; + status: 'sent' | 'accepted' | 'rejected' | 'revoked' | 'expired'; date_created: string; date_modified: string; }; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 6eb9faf9..2620166c 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -549,6 +549,9 @@ export const links: Links = { }), user_invitations: ({ user_id }) => ({ + 'fx:reject': { href: 'https://demo.api/virtual/empty?status=200' }, + 'fx:accept': { href: 'https://demo.api/virtual/empty?status=200' }, + 'fx:revoke': { href: 'https://demo.api/virtual/empty?status=200' }, 'fx:resend': { href: 'https://demo.api/virtual/empty?status=200' }, 'fx:store': { href: `./stores/${user_id}` }, 'fx:user': { href: `./users/${user_id}` }, diff --git a/src/static/translations/user-invitation-card/en.json b/src/static/translations/user-invitation-card/en.json index 7333bcef..1a2ff02d 100644 --- a/src/static/translations/user-invitation-card/en.json +++ b/src/static/translations/user-invitation-card/en.json @@ -3,10 +3,12 @@ "status_admin_rejected": "{{ email }} • Invitation rejected", "status_admin_revoked": "{{ email }} • Access revoked", "status_admin_sent": "{{ email }} • Invited", + "status_admin_expired": "{{ email }} • Expired", "status_user_accepted": "{{ domain }} • Accepted", "status_user_rejected": "{{ domain }} • Rejected", "status_user_revoked": "{{ domain }} • Revoked", "status_user_sent": "{{ domain }} • Pending", + "status_user_expired": "{{ domain }} • Expired", "full_name": "{{ first_name }} {{ last_name }}", "full_name_empty": "Unknown user", "spinner": { diff --git a/src/static/translations/user-invitation-form/en.json b/src/static/translations/user-invitation-form/en.json index 2e4c9b47..11ba9fb0 100644 --- a/src/static/translations/user-invitation-form/en.json +++ b/src/static/translations/user-invitation-form/en.json @@ -6,39 +6,38 @@ "admin_status_title_sent": "Invitation sent", "admin_status_title_rejected": "Invitation rejected", "admin_status_title_accepted": "This user is a store admin", - "admin_status_text_revoked": "This user may have been an admin in the past but has no access to this store at the moment. We will keep this record for historical purposes.", - "admin_status_text_sent": "We've sent an email to this user asking them to join this store. Once they accept, they will be granted full admin access.", + "admin_status_title_expired": "Invitation expired", + "admin_status_text_revoked": "This user may have been an admin in the past but has no access to this store at the moment. We will keep this record for historical purposes. To restore this user's access, resend the invitation using the button below.", + "admin_status_text_sent": "We've sent an email to this user asking them to join this store. Once they accept, they will be granted full admin access. If they don't accept within 2 weeks, this invitation will expire.", "admin_status_text_rejected": "If this was a mistake, ask the user to delete this invitation in profile settings. You will be able to invite them again after that.", "admin_status_text_accepted": "They have full access to this store, including the ability to add and remove users.", + "admin_status_text_expired": "This invitation has expired. You can resend this invitation using the button below.", + "user_status_title_rejected": "You've rejected an invitation to join {{ store_name }} as administrator", + "user_status_title_accepted": "You're a store administrator at {{ store_name }}", "user_status_title_revoked": "You no longer have access to {{ store_name }}", - "user_status_text_revoked": "If you'd like to join this store again, please ask the store owner to reactivate your access in settings.", "user_status_title_sent": "You've been invited to join {{ store_name }} as administrator", - "user_status_text_sent": "Accepting this invitation will grant you full access to this store. Rejecting it will prevent this store from inviting you again.", - "user_status_title_rejected": "You've rejected an invitation to join {{ store_name }} as administrator", + "user_status_title_expired": "This invitation to join {{ store_name }} as administrator has expired", "user_status_text_rejected": "This store won't be able to contact you again. If you changed your mind, delete this invitation and ask the store owner to invite you again.", - "user_status_title_accepted": "You're a store administrator at {{ store_name }}", "user_status_text_accepted": "If you'd like to leave this store, you can do so by pressing the button below. Please note that if you'd like to join again, the store owner will need to invite you.", + "user_status_text_revoked": "If you'd like to join this store again, please ask the store owner to reactivate your access in settings.", + "user_status_text_sent": "Accepting this invitation will grant you full access to this store. Rejecting it will prevent this store from inviting you again.", + "user_status_text_expired": "To join this store, please ask the store owner to delete this invitation and invite you again.", "full_name": "{{ first_name }} {{ last_name }}", "full_name_empty": "Unknown User", "leave": { - "caption": "Leave this store" + "idle": "Leave this store", + "busy": "Leaving...", + "fail": "Failed to leave" }, "revoke": { - "caption": "Revoke access" - }, - "invite-again": { - "caption": "Invite again" + "idle": "Revoke access", + "busy": "Revoking...", + "fail": "Failed to revoke" }, "resend": { - "idle": "Resend email", + "idle": "Resend invitation", "busy": "Resending...", - "fail": "Failed to resend", - "confirm": { - "header": "Resend email", - "message": "Please confirm that you'd like to resend the invitation email to this user.", - "confirm": "Resend", - "cancel": "Cancel" - } + "fail": "Failed to resend" }, "error": { "invitation_exists": "This user has already been invited to this store.", @@ -47,6 +46,7 @@ "store": { "label": "", "helper_text": "", + "store_link": "Open store dashboard", "store-domain": { "label": "Store domain", "placeholder": "Unknown", @@ -70,10 +70,14 @@ "v8n_required": "Please enter an email address." }, "accept": { - "caption": "Accept" + "idle": "Accept", + "busy": "Accepting...", + "fail": "Failed to accept" }, "reject": { - "caption": "Reject" + "idle": "Reject", + "busy": "Rejecting...", + "fail": "Failed to reject" }, "create": { "caption": "Send invitation" @@ -81,7 +85,7 @@ "delete": { "delete": "Delete", "cancel": "Cancel", - "delete_prompt": "Deleting an invitation allows the store owner to invite you again. Would you like to proceed?" + "delete_prompt": "This invitation will be permanently deleted. Would you like to proceed?" }, "unavailable": { "loading_empty": "Creating an invitation is not available in this context." From 58910bb4c94f584c462851effb8c9d35746bbe58 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 14 Oct 2024 18:22:12 -0300 Subject: [PATCH 20/26] feat(foxy-store-form): add support for captcha requirement on store creation --- .../public/StoreForm/StoreForm.stories.ts | 1 + .../public/StoreForm/StoreForm.test.ts | 117 +++++++++++++++++- src/elements/public/StoreForm/StoreForm.ts | 62 ++++++++++ src/elements/public/StoreForm/index.ts | 2 + src/static/translations/store-form/en.json | 7 +- 5 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/elements/public/StoreForm/StoreForm.stories.ts b/src/elements/public/StoreForm/StoreForm.stories.ts index 28954a0c..212b4a32 100644 --- a/src/elements/public/StoreForm/StoreForm.stories.ts +++ b/src/elements/public/StoreForm/StoreForm.stories.ts @@ -90,6 +90,7 @@ export default getMeta(summary); const ext = ` customer-password-hash-types="https://demo.api/hapi/property_helpers/9" shipping-address-types="https://demo.api/hapi/property_helpers/5" + h-captcha-site-key="10000000-ffff-ffff-ffff-000000000001" timezones="https://demo.api/hapi/property_helpers/2" countries="https://demo.api/hapi/property_helpers/3" regions="https://demo.api/hapi/property_helpers/4" diff --git a/src/elements/public/StoreForm/StoreForm.test.ts b/src/elements/public/StoreForm/StoreForm.test.ts index f7d25a3e..87768ffe 100644 --- a/src/elements/public/StoreForm/StoreForm.test.ts +++ b/src/elements/public/StoreForm/StoreForm.test.ts @@ -2,7 +2,7 @@ import type { FetchEvent } from '../NucleonElement/FetchEvent'; import './index'; -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { InternalEditableListControl } from '../../internal/InternalEditableListControl/InternalEditableListControl'; import { InternalFrequencyControl } from '../../internal/InternalFrequencyControl/InternalFrequencyControl'; import { InternalPasswordControl } from '../../internal/InternalPasswordControl/InternalPasswordControl'; @@ -17,6 +17,7 @@ import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { createRouter } from '../../../server/index'; import { I18n } from '../I18n/I18n'; import { stub } from 'sinon'; +import { VanillaHCaptchaWebComponent } from 'vanilla-hcaptcha'; describe('StoreForm', () => { const OriginalResizeObserver = window.ResizeObserver; @@ -80,6 +81,11 @@ describe('StoreForm', () => { expect(element).to.equal(I18n); }); + it('imports and defines h-captcha', () => { + const element = customElements.get('h-captcha'); + expect(element).to.exist; + }); + it('imports and defines itself as foxy-store-form', () => { const element = customElements.get('foxy-store-form'); expect(element).to.equal(Form); @@ -114,6 +120,16 @@ describe('StoreForm', () => { ); }); + it('has a reactive property "hCaptchaSiteKey"', () => { + expect(new Form()).to.have.property('hCaptchaSiteKey', null); + expect(Form).to.have.nested.property('properties.hCaptchaSiteKey'); + expect(Form).to.not.have.nested.property('properties.hCaptchaSiteKey.type'); + expect(Form).to.have.nested.property( + 'properties.hCaptchaSiteKey.attribute', + 'h-captcha-site-key' + ); + }); + it('has a reactive property "storeVersions"', () => { expect(new Form()).to.have.property('storeVersions', null); expect(Form).to.have.nested.property('properties.storeVersions'); @@ -2285,4 +2301,103 @@ describe('StoreForm', () => { JSON.stringify({ cart_signing: 'test', xml_datafeed: 'foo', api_legacy: 'test', sso: 'test' }) ); }); + + it('renders a hCaptcha element when hCaptchaSiteKey is set', async () => { + const form = await fixture(html``); + let control = form.renderRoot.querySelector('h-captcha'); + + expect(control).to.not.exist; + expect(form.renderRoot.querySelector('[infer="hcaptcha"][key="disclaimer]')).to.not.exist; + expect(form.renderRoot.querySelector('[infer="hcaptcha"][key="terms_of_service]')).to.not.exist; + expect(form.renderRoot.querySelector('[infer="hcaptcha"][key="privacy_policy]')).to.not.exist; + + form.hCaptchaSiteKey = '10000000-ffff-ffff-ffff-000000000001'; + form.lang = 'en-AU'; + await form.requestUpdate(); + control = form.renderRoot.querySelector('h-captcha'); + + expect(control).to.exist; + expect(control).to.have.attribute('site-key', '10000000-ffff-ffff-ffff-000000000001'); + expect(control).to.have.attribute('size', 'invisible'); + expect(control).to.have.attribute('hl', 'en-AU'); + + const disclaimer = form.renderRoot.querySelector('[infer="hcaptcha"][key="disclaimer"]'); + const terms = form.renderRoot.querySelector('[infer="hcaptcha"][key="terms_of_service"]'); + const termsLink = terms?.closest('a'); + const privacy = form.renderRoot.querySelector('[infer="hcaptcha"][key="privacy_policy"]'); + const privacyLink = privacy?.closest('a'); + + expect(disclaimer).to.exist; + expect(terms).to.exist; + expect(privacy).to.exist; + + expect(termsLink).to.have.attribute('href', 'https://www.hcaptcha.com/terms'); + expect(termsLink).to.have.attribute('target', '_blank'); + expect(termsLink).to.have.attribute('rel', 'noopener noreferrer'); + + expect(privacyLink).to.have.attribute('href', 'https://www.hcaptcha.com/privacy'); + expect(privacyLink).to.have.attribute('target', '_blank'); + expect(privacyLink).to.have.attribute('rel', 'noopener noreferrer'); + }); + + it('includes hCaptcha token on submission when hCaptchaSiteKey is set', async () => { + const VerifiedEvent = class extends CustomEvent { + token = '456'; + + eKey = '789'; + }; + + const form = await fixture(html``); + + form.hCaptchaSiteKey = '10000000-ffff-ffff-ffff-000000000001'; + form.edit({ + store_name: 'Test Store', + store_domain: 'teststore', + store_email: 'test@example.com', + store_url: 'https://example.com', + postal_code: '012345', + country: 'US', + region: 'TX', + }); + + await form.requestUpdate(); + + const captcha = form.renderRoot.querySelector('h-captcha') as VanillaHCaptchaWebComponent; + stub(captcha, 'reset').resolves(); + stub(captcha, 'execute').callsFake(() => { + captcha.dispatchEvent(new VerifiedEvent('verified')); + }); + + const whenFetchIsFired = oneEvent(form, 'fetch'); + form.submit(); + const evt = (await whenFetchIsFired) as unknown as FetchEvent; + evt.preventDefault(); + + const headers = evt.request.headers; + expect(headers.get('h-captcha-code')).to.equal('456'); + }); + + it('submits without hCaptcha token when hCaptchaSiteKey is not set', async () => { + const form = await fixture(html``); + + form.edit({ + store_name: 'Test Store', + store_domain: 'teststore', + store_email: 'test@example.com', + store_url: 'https://example.com', + postal_code: '012345', + country: 'US', + region: 'TX', + }); + + await form.requestUpdate(); + + const whenFetchIsFired = oneEvent(form, 'fetch'); + form.submit(); + const evt = (await whenFetchIsFired) as unknown as FetchEvent; + evt.preventDefault(); + + const headers = evt.request.headers; + expect(headers.get('h-captcha-code')).to.be.null; + }); }); diff --git a/src/elements/public/StoreForm/StoreForm.ts b/src/elements/public/StoreForm/StoreForm.ts index cc3ebbaf..84bd4b93 100644 --- a/src/elements/public/StoreForm/StoreForm.ts +++ b/src/elements/public/StoreForm/StoreForm.ts @@ -1,3 +1,4 @@ +import type { VanillaHCaptchaWebComponent } from 'vanilla-hcaptcha'; import type { PropertyDeclarations } from 'lit-element'; import type { Resource, Graph } from '@foxy.io/sdk/core'; import type { TemplateResult } from 'lit-html'; @@ -36,6 +37,7 @@ export class StoreForm extends Base { ...super.properties, customerPasswordHashTypes: { attribute: 'customer-password-hash-types' }, shippingAddressTypes: { attribute: 'shipping-address-types' }, + hCaptchaSiteKey: { attribute: 'h-captcha-site-key' }, storeVersions: { attribute: 'store-versions' }, checkoutTypes: { attribute: 'checkout-types' }, localeCodes: { attribute: 'locale-codes' }, @@ -106,6 +108,9 @@ export class StoreForm extends Base { /** URL of the `fx:shipping_address_types` property helper resource. */ shippingAddressTypes: string | null = null; + /** hCaptcha site key for signup verification. If provided, requires users to complete a captcha before creating a store. */ + hCaptchaSiteKey: string | null = null; + /** * URL of the `fx:store_versions` property helper resource. * @deprecated All elements in this library are designed to work with store version 2.0. @@ -326,6 +331,8 @@ export class StoreForm extends Base { this.__setWebhookKey('sso', newValue); }; + private __hCaptchaToken: string | null = null; + get headerSubtitleOptions(): Record { return { context: this.data?.is_active ? 'active' : 'inactive' }; } @@ -850,6 +857,42 @@ export class StoreForm extends Base { : ''} + ${this.href || !this.hCaptchaSiteKey + ? '' + : html` +
+ +
+ + + + + + + + +
+ `} ${super.renderBody()} ${customerPasswordHashTypesLoader.render(this.customerPasswordHashTypes)} ${shippingAddressTypesLoader.render(this.shippingAddressTypes)} @@ -858,6 +901,21 @@ export class StoreForm extends Base { `; } + submit(): void { + if (!this.href && this.hCaptchaSiteKey) { + this.__hCaptcha?.reset(); + this.__hCaptcha?.execute(); + } else { + super.submit(); + } + } + + protected async _fetch(...args: Parameters): Promise { + const request = new StoreForm.API.WHATWGRequest(...args); + if (this.__hCaptchaToken) request.headers.set('h-captcha-code', this.__hCaptchaToken); + return super._fetch(request); + } + private get __displayIdExamples() { const config = this.__getCustomDisplayIdConfig(); const startAsInt = parseInt(config.start || '0'); @@ -914,6 +972,10 @@ export class StoreForm extends Base { } } + private get __hCaptcha() { + return this.renderRoot.querySelector('h-captcha'); + } + private __getWebhookKey() { let parsedKey: ParsedWebhookKey; diff --git a/src/elements/public/StoreForm/index.ts b/src/elements/public/StoreForm/index.ts index 8709df07..32a90b78 100644 --- a/src/elements/public/StoreForm/index.ts +++ b/src/elements/public/StoreForm/index.ts @@ -1,3 +1,5 @@ +import 'vanilla-hcaptcha'; + import '../../internal/InternalEditableListControl/index'; import '../../internal/InternalFrequencyControl/index'; import '../../internal/InternalPasswordControl/index'; diff --git a/src/static/translations/store-form/en.json b/src/static/translations/store-form/en.json index 32fcc94d..d9c9c5ba 100644 --- a/src/static/translations/store-form/en.json +++ b/src/static/translations/store-form/en.json @@ -353,6 +353,11 @@ "helper_text": "" } }, + "hcaptcha": { + "disclaimer": "We use hCaptcha to protect this page from spam and abuse.", + "privacy_policy": "Privacy Policy", + "terms_of_service": "Terms of Service" + }, "timestamps": { "date_created": "Created on", "date_modified": "Last updated on", @@ -377,4 +382,4 @@ "create": { "caption": "Create" } -} +} \ No newline at end of file From b64a43d87a1ec8d646f4b12b38dd2cb5c27d2f3b Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 15 Oct 2024 12:09:28 -0300 Subject: [PATCH 21/26] fix(foxy-nucleon): remove result hint from the update event if state context includes errors --- src/elements/public/NucleonElement/NucleonElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/public/NucleonElement/NucleonElement.ts b/src/elements/public/NucleonElement/NucleonElement.ts index 8cb61581..33dfe666 100644 --- a/src/elements/public/NucleonElement/NucleonElement.ts +++ b/src/elements/public/NucleonElement/NucleonElement.ts @@ -441,7 +441,7 @@ export class NucleonElement extends InferrableMix let result: UpdateResult | undefined = undefined; - if (state.matches('idle')) { + if (state.matches('idle') && state.context.errors.length === 0) { if (state.history?.matches({ busy: 'deleting' })) { result = UpdateResult.ResourceDeleted; } else if (state.history?.matches({ busy: 'creating' })) { From 4fe11d1bef0b9188fc9797d14826e446cfd7ea77 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 15 Oct 2024 19:03:46 -0300 Subject: [PATCH 22/26] refactor: ongoing ui improvements --- .../InternalAsyncListControl.ts | 30 ++++----- .../InternalResourcePickerControl.ts | 17 ++--- .../GiftCardCodeForm.stories.ts | 4 +- .../GiftCardCodeForm/GiftCardCodeForm.test.ts | 65 +++++++++++++++---- .../GiftCardCodeForm/GiftCardCodeForm.ts | 35 ++++++++-- src/elements/public/GiftCardCodeForm/index.ts | 3 +- ...nternalGiftCardCodeFormItemControl.test.ts | 63 ------------------ .../InternalGiftCardCodeFormItemControl.ts | 38 ----------- .../index.ts | 12 ---- .../translations/gift-card-code-form/en.json | 47 ++++++++------ .../translations/gift-card-form/en.json | 47 ++++++++------ 11 files changed, 153 insertions(+), 208 deletions(-) delete mode 100644 src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/InternalGiftCardCodeFormItemControl.test.ts delete mode 100644 src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/InternalGiftCardCodeFormItemControl.ts delete mode 100644 src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/index.ts diff --git a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts index 13a36f6c..7e7a2a64 100644 --- a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts +++ b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts @@ -307,7 +307,7 @@ export class InternalAsyncListControl extends InternalEditableControl { > `} -
+
${this.label && this.label !== 'label' ? this.label : ''} @@ -436,13 +436,20 @@ export class InternalAsyncListControl extends InternalEditableControl { `}
+
+ ${this.helperText} +
+
- ${this.helperText} -
- -
${this._errorMessage} diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts index 1fd1f8ac..8f7ff86c 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts @@ -192,7 +192,7 @@ export class InternalResourcePickerControl extends InternalEditableControl {
@@ -231,12 +231,15 @@ export class InternalResourcePickerControl extends InternalEditableControl { `}
+
${this.helperText}
+ -
- ${this.helperText} -
-
{ expect(customElements.get('foxy-internal-async-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-number-control', () => { expect(customElements.get('foxy-internal-number-control')).to.exist; }); @@ -44,10 +48,6 @@ describe('GiftCardCodeForm', () => { expect(customElements.get('foxy-customer-card')).to.exist; }); - it('imports and defines foxy-internal-gift-card-code-form-item-control', () => { - expect(customElements.get('foxy-internal-gift-card-code-form-item-control')).to.exist; - }); - it('imports and defines itself as foxy-gift-card-code-form', () => { expect(customElements.get('foxy-gift-card-code-form')).to.equal(GiftCardCodeForm); }); @@ -111,6 +111,11 @@ describe('GiftCardCodeForm', () => { expect(element.hiddenSelector.matches('logs', true)).to.be.false; }); + it('always keeps cart-item readonly', () => { + const element = new GiftCardCodeForm(); + expect(element.readonlySelector.matches('cart-item', true)).to.be.true; + }); + it('renders a form header', () => { const form = new GiftCardCodeForm(); const renderHeaderMethod = stub(form, 'renderHeader'); @@ -118,31 +123,51 @@ describe('GiftCardCodeForm', () => { expect(renderHeaderMethod).to.have.been.called; }); - it('renders a text control for code', async () => { + it('renders a summary control for settings', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('foxy-internal-text-control[infer="code"]'); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="settings"]' + ); + expect(control).to.exist; }); - it('renders a number control for current balance', async () => { + it('renders a text control for code inside of the settings summary', async () => { const element = await fixture( html`` ); + const control = element.renderRoot.querySelector( - 'foxy-internal-number-control[infer="current-balance"]' + '[infer="settings"] foxy-internal-text-control[infer="code"]' ); + expect(control).to.exist; }); - it('renders a date control for end date', async () => { + it('renders a number control for current balance inside of the settings summary', async () => { const element = await fixture( html`` ); + const control = element.renderRoot.querySelector( - 'foxy-internal-date-control[infer="end-date"]' + '[infer="settings"] foxy-internal-number-control[infer="current-balance"]' ); + + expect(control).to.exist; + }); + + it('renders a date control for end date inside of the settings summary', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + '[infer="settings"] foxy-internal-date-control[infer="end-date"]' + ); + expect(control).to.exist; }); @@ -198,14 +223,26 @@ describe('GiftCardCodeForm', () => { expect(control.getValue()).to.equal('https://demo.api/hapi/customers/2'); }); - it('renders a custom control for associated cart item', async () => { + it('renders a resource picker control for associated cart item', async () => { + const router = createRouter(); const element = await fixture( - html`` + html` + router.handleEvent(evt)} + > + + ` ); - const control = element.renderRoot.querySelector( - 'foxy-internal-gift-card-code-form-item-control[infer="cart-item"]' + + await waitUntil(() => !!element.data); + const control = element.renderRoot.querySelector( + 'foxy-internal-resource-picker-control[infer="cart-item"]' ); + expect(control).to.exist; + expect(control).to.have.attribute('item', 'foxy-item-card'); + expect(control?.getValue()).to.equal('https://demo.api/hapi/items/0?zoom=item_options'); }); it('renders a list control for logs', async () => { diff --git a/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts b/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts index ca719115..7589f6dc 100644 --- a/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts +++ b/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts @@ -77,6 +77,11 @@ export class GiftCardCodeForm extends Base { private readonly __storeLoaderId = 'storeLoader'; + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString(), 'cart-item']; + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + get hiddenSelector(): BooleanSelector { const alwaysMatch = [super.hiddenSelector.toString()]; if (!this.href) alwaysMatch.push('customer', 'cart-item', 'logs'); @@ -84,12 +89,28 @@ export class GiftCardCodeForm extends Base { } renderBody(): TemplateResult { + let href: string | undefined; + + try { + const url = new URL( + this.data?._links?.['fx:provisioned_by_transaction_detail_id'].href ?? '' + ); + url.searchParams.set('zoom', 'item_options'); + href = url.toString(); + } catch { + href = undefined; + } + return html` ${this.renderHeader()} - - - + + + + + + + { > - - + href} + > + { - describe('InternalGiftCardCodeFormItemControl', () => { - it('imports and defines foxy-internal-control', () => { - expect(customElements.get('foxy-internal-control')).to.exist; - }); - - it('imports and defines foxy-item-card', () => { - expect(customElements.get('foxy-item-card')).to.exist; - }); - - it('imports and defines foxy-i18n', () => { - expect(customElements.get('foxy-i18n')).to.exist; - }); - - it('defines itself as foxy-internal-gift-card-code-form-item-control', () => { - expect(customElements.get('foxy-internal-gift-card-code-form-item-control')).to.equal( - InternalGiftCardCodeFormItemControl - ); - }); - - it('renders a translatable label', async () => { - const element = await fixture( - html`` - ); - - const label = element.renderRoot.querySelector('foxy-i18n[key="label"]'); - - expect(label).to.exist; - expect(label).to.have.attribute('infer', ''); - }); - - it('renders a foxy-item-card with a link to the transaction detail', async () => { - const element = await fixture( - html`` - ); - - const card = element.renderRoot.querySelector('foxy-item-card'); - expect(card).to.exist; - expect(card).to.not.have.attribute('href'); - expect(card).to.have.attribute('infer', 'card'); - - const nucleon = await fixture>(html``); - nucleon.data = await getTestData('./hapi/gift_card_codes/0'); - nucleon.append(element); - element.inferProperties(); - await element.requestUpdate(); - - const url = new URL( - nucleon.data._links?.['fx:provisioned_by_transaction_detail_id'].href ?? '' - ); - url.searchParams.set('zoom', 'item_options'); - expect(card).to.have.attribute('href', url.toString()); - }); - }); -}); diff --git a/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/InternalGiftCardCodeFormItemControl.ts b/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/InternalGiftCardCodeFormItemControl.ts deleted file mode 100644 index 9555515e..00000000 --- a/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/InternalGiftCardCodeFormItemControl.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { TemplateResult } from 'lit-html'; - -import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; -import { ifDefined } from 'lit-html/directives/if-defined'; -import { html } from 'lit-html'; - -export class InternalGiftCardCodeFormItemControl extends InternalControl { - renderControl(): TemplateResult { - let href: string | undefined; - - try { - const url = new URL( - this.nucleon?.data?._links?.['fx:provisioned_by_transaction_detail_id'].href ?? '' - ); - url.searchParams.set('zoom', 'item_options'); - href = url.toString(); - } catch { - href = undefined; - } - - return html` - - - - - - `; - } -} diff --git a/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/index.ts b/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/index.ts deleted file mode 100644 index 973f561b..00000000 --- a/src/elements/public/GiftCardCodeForm/internal/InternalGiftCardCodeFormItemControl/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import '../../../../internal/InternalControl/index'; -import '../../../ItemCard/index'; -import '../../../I18n/index'; - -import { InternalGiftCardCodeFormItemControl } from './InternalGiftCardCodeFormItemControl'; - -customElements.define( - 'foxy-internal-gift-card-code-form-item-control', - InternalGiftCardCodeFormItemControl -); - -export { InternalGiftCardCodeFormItemControl }; diff --git a/src/static/translations/gift-card-code-form/en.json b/src/static/translations/gift-card-code-form/en.json index a11fb1bd..2a223e55 100644 --- a/src/static/translations/gift-card-code-form/en.json +++ b/src/static/translations/gift-card-code-form/en.json @@ -16,34 +16,39 @@ "done": "Copied to clipboard" } }, - "code": { - "label": "Code", - "placeholder": "Required", - "helper_text": "The string value of this gift card code which your customer will add to their cart to use this gift card.", - "v8n_required": "Please enter a code", - "v8n_too_long": "Please enter a code with no more than 50 characters", - "v8n_has_spaces": "Please enter a code without spaces" - }, - "current-balance": { - "label": "Current balance", - "placeholder": "Required", - "helper_text": "The current balance of this gift card.", - "v8n_required": "Please enter a current balance" - }, - "end-date": { - "label": "End date", - "placeholder": "Optional", - "helper_text": "The date when this gift card will expire." + "settings": { + "label": "", + "helper_text": "", + "code": { + "label": "Code", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a code", + "v8n_too_long": "Please enter a code with no more than 50 characters", + "v8n_has_spaces": "Please enter a code without spaces" + }, + "current-balance": { + "label": "Current balance", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a current balance" + }, + "end-date": { + "label": "Expires on", + "placeholder": "Optional", + "helper_text": "" + } }, "customer": { "label": "Customer", + "helper_text": "", "dialog": { "cancel": "Cancel", "close": "Close", "header": "Select a customer", "selection": { "label": "Customers", - "helper_text": "Select a customer to assign this gift card code to them. You won't be able to clear this selection once saved but you will able able to reassign it to another customer.", + "helper_text": "", "search": "Search", "clear": "Clear", "pagination": { @@ -118,11 +123,11 @@ "loading_empty": "Not assigned – click to select", "loading_error": "Unknown error" } - }, - "helper_text": "Select a customer to assign this gift card code to them. You won't be able to clear this selection once saved but you will able able to reassign it to another customer." + } }, "cart-item": { "label": "Cart item", + "helper_text": "", "card": { "daily": "Daily", "daily_plural": "Every {{count}} days", diff --git a/src/static/translations/gift-card-form/en.json b/src/static/translations/gift-card-form/en.json index 8b10140d..be256e90 100644 --- a/src/static/translations/gift-card-form/en.json +++ b/src/static/translations/gift-card-form/en.json @@ -376,34 +376,39 @@ "done": "Copied to clipboard" } }, - "code": { - "label": "Code", - "placeholder": "Required", - "helper_text": "The string value of this gift card code which your customer will add to their cart to use this gift card.", - "v8n_required": "Please enter a code", - "v8n_too_long": "Please enter a code with no more than 50 characters", - "v8n_has_spaces": "Please enter a code without spaces" - }, - "current-balance": { - "label": "Current balance", - "placeholder": "Required", - "helper_text": "The current balance of this gift card.", - "v8n_required": "Please enter a current balance" - }, - "end-date": { - "label": "End date", - "placeholder": "Optional", - "helper_text": "The date when this gift card will expire." + "settings": { + "label": "", + "helper_text": "", + "code": { + "label": "Code", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a code", + "v8n_too_long": "Please enter a code with no more than 50 characters", + "v8n_has_spaces": "Please enter a code without spaces" + }, + "current-balance": { + "label": "Current balance", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a current balance" + }, + "end-date": { + "label": "Expires on", + "placeholder": "Optional", + "helper_text": "" + } }, "customer": { "label": "Customer", + "helper_text": "", "dialog": { "cancel": "Cancel", "close": "Close", "header": "Select a customer", "selection": { "label": "Customers", - "helper_text": "Select a customer to assign this gift card code to them. You won't be able to clear this selection once saved but you will able able to reassign it to another customer.", + "helper_text": "", "search": "Search", "clear": "Clear", "pagination": { @@ -478,11 +483,11 @@ "loading_empty": "Not assigned – click to select", "loading_error": "Unknown error" } - }, - "helper_text": "Select a customer to assign this gift card code to them. You won't be able to clear this selection once saved but you will able able to reassign it to another customer." + } }, "cart-item": { "label": "Cart item", + "helper_text": "", "card": { "daily": "Daily", "daily_plural": "Every {{count}} days", From a1a31f401582b7d459cfd98bb2eb757c7984558a Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 15 Oct 2024 21:33:42 -0300 Subject: [PATCH 23/26] refactor(foxy-gift-card-form): ongoing ui improvements --- .../GiftCardForm/GiftCardForm.stories.ts | 12 +- .../public/GiftCardForm/GiftCardForm.test.ts | 137 +++++- .../public/GiftCardForm/GiftCardForm.ts | 101 ++++- src/elements/public/GiftCardForm/index.ts | 5 +- ...InternalGiftCardFormProvisioningControl.ts | 97 ----- .../index.ts | 13 - .../translations/gift-card-form/en.json | 409 +++++++++--------- 7 files changed, 442 insertions(+), 332 deletions(-) delete mode 100644 src/elements/public/GiftCardForm/internal/InternalGiftCardFormProvisioningControl/InternalGiftCardFormProvisioningControl.ts delete mode 100644 src/elements/public/GiftCardForm/internal/InternalGiftCardFormProvisioningControl/index.ts diff --git a/src/elements/public/GiftCardForm/GiftCardForm.stories.ts b/src/elements/public/GiftCardForm/GiftCardForm.stories.ts index 41b612d9..0b8b5815 100644 --- a/src/elements/public/GiftCardForm/GiftCardForm.stories.ts +++ b/src/elements/public/GiftCardForm/GiftCardForm.stories.ts @@ -12,9 +12,15 @@ const summary: Summary = { translatable: true, configurable: { inputs: [ - 'name', - 'currency', - 'expires', + 'general', + 'general:name', + 'general:currency', + 'general:expires', + 'provisioning', + 'provisioning:toggle', + 'provisioning:min-balance', + 'provisioning:max-balance', + 'provisioning:sku', 'codes', 'product-restrictions', 'category-restrictions', diff --git a/src/elements/public/GiftCardForm/GiftCardForm.test.ts b/src/elements/public/GiftCardForm/GiftCardForm.test.ts index 2015cfcc..82e5d1e8 100644 --- a/src/elements/public/GiftCardForm/GiftCardForm.test.ts +++ b/src/elements/public/GiftCardForm/GiftCardForm.test.ts @@ -1,6 +1,9 @@ import type { InternalEditableListControl } from '../../internal/InternalEditableListControl/InternalEditableListControl'; import type { InternalAsyncListControl } from '../../internal/InternalAsyncListControl/InternalAsyncListControl'; import type { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; +import type { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; +import type { InternalNumberControl } from '../../internal/InternalNumberControl/InternalNumberControl'; +import type { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import type { FetchEvent } from '../NucleonElement/FetchEvent'; import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; @@ -42,10 +45,22 @@ describe('GiftCardForm', () => { expect(customElements.get('foxy-internal-frequency-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-select-control', () => { expect(customElements.get('foxy-internal-select-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-number-control', () => { + expect(customElements.get('foxy-internal-number-control')).to.exist; + }); + it('imports and defines foxy-internal-text-control', () => { expect(customElements.get('foxy-internal-text-control')).to.exist; }); @@ -252,24 +267,38 @@ describe('GiftCardForm', () => { ]); }); - it('renders text control for name', async () => { + it('renders general summary', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('foxy-internal-text-control[infer=name]'); + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="general"]' + ); + expect(control).to.exist; }); - it('renders select control for currencies', async () => { + it('renders text control for name in the general summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-select-control[infer=currency]' + '[infer="general"] foxy-internal-text-control[infer=name]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders select control for currencies in the general summary', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="general"] foxy-internal-select-control[infer=currency]' ) as InternalSelectControl; expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.attribute('property', 'currency_code'); expect(control).to.have.deep.property( 'options', currencies.map(value => ({ - label: `currency.code_${value}`, + label: `general.currency.code_${value}`, value, })) ); @@ -280,25 +309,115 @@ describe('GiftCardForm', () => { expect(control.getValue()).to.equal('usd'); }); - it('renders frequency control for expiration period', async () => { + it('renders frequency control for expiration period in the general summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-frequency-control[infer=expires]' + '[infer="general"] foxy-internal-frequency-control[infer=expires]' ); expect(control).to.exist; expect(control).to.have.attribute('property', 'expires_after'); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders provisioning control', async () => { + it('renders provisioning summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-gift-card-form-provisioning-control[infer="provisioning"]' + 'foxy-internal-summary-control[infer="provisioning"]' ); expect(control).to.exist; }); + it('renders switch control for autoprovisioning in the provisioning summary', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="provisioning"] foxy-internal-switch-control[infer="toggle"]' + ) as InternalSwitchControl; + + expect(control).to.exist; + expect(control.getValue()).to.be.false; + + control.setValue(true); + expect(control.getValue()).to.be.true; + expect(element).to.have.deep.nested.property( + 'form.provisioning_config.allow_autoprovisioning', + true + ); + }); + + it('renders text control for SKU in the provisioning summary', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="provisioning"] foxy-internal-text-control[infer="sku"]' + ) as InternalTextControl; + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders number control for minimum balance in the provisioning summary', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="provisioning"] foxy-internal-number-control[infer="min-balance"]' + ) as InternalNumberControl; + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('min', '0'); + expect(control).to.not.have.attribute('suffix'); + + element.edit({ currency_code: 'usd' }); + await element.requestUpdate(); + expect(control).to.have.attribute('suffix', 'USD'); + + control.setValue(10); + expect(element).to.have.deep.nested.property( + 'form.provisioning_config.initial_balance_min', + 10 + ); + + element.edit({ + provisioning_config: { + allow_autoprovisioning: true, + initial_balance_max: 0, + initial_balance_min: 20, + }, + }); + expect(control.getValue()).to.equal(20); + }); + + it('renders number control for maximum balance in the provisioning summary', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="provisioning"] foxy-internal-number-control[infer="max-balance"]' + ) as InternalNumberControl; + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); + expect(control).to.have.attribute('min', '0'); + expect(control).to.not.have.attribute('suffix'); + + element.edit({ currency_code: 'usd' }); + await element.requestUpdate(); + expect(control).to.have.attribute('suffix', 'USD'); + + control.setValue(10); + expect(element).to.have.deep.nested.property( + 'form.provisioning_config.initial_balance_max', + 10 + ); + + element.edit({ + provisioning_config: { + allow_autoprovisioning: true, + initial_balance_min: 0, + initial_balance_max: 20, + }, + }); + expect(control.getValue()).to.equal(20); + }); + it('renders async list control for codes', async () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); diff --git a/src/elements/public/GiftCardForm/GiftCardForm.ts b/src/elements/public/GiftCardForm/GiftCardForm.ts index 8859ed6d..85eb9def 100644 --- a/src/elements/public/GiftCardForm/GiftCardForm.ts +++ b/src/elements/public/GiftCardForm/GiftCardForm.ts @@ -69,6 +69,56 @@ export class GiftCardForm extends Base { return `https://api.foxycart.com/customers/${id}`; }; + private readonly __provisioningMaxBalanceValueGetter = () => { + return this.form.provisioning_config?.initial_balance_max; + }; + + private readonly __provisioningMaxBalanceValueSetter = (newMax: number) => { + const newMin = this.form.provisioning_config?.initial_balance_min ?? newMax; + + this.edit({ + provisioning_config: { + allow_autoprovisioning: true, + initial_balance_min: newMin > newMax ? newMax : newMin, + initial_balance_max: newMax, + }, + }); + }; + + private readonly __provisioningMinBalanceValueGetter = () => { + return this.form.provisioning_config?.initial_balance_min; + }; + + private readonly __provisioningMinBalanceValueSetter = (newMin: number) => { + const newMax = this.form.provisioning_config?.initial_balance_max ?? newMin; + + this.edit({ + provisioning_config: { + allow_autoprovisioning: true, + initial_balance_min: newMin, + initial_balance_max: newMax < newMin ? newMin : newMax, + }, + }); + }; + + private readonly __provisioningToggleValueGetter = () => { + return !!this.form.provisioning_config?.allow_autoprovisioning; + }; + + private readonly __provisioningToggleValueSetter = (newValue: boolean) => { + if (newValue) { + this.edit({ + provisioning_config: { + allow_autoprovisioning: true, + initial_balance_min: this.form.provisioning_config?.initial_balance_min ?? 0, + initial_balance_max: this.form.provisioning_config?.initial_balance_max ?? 0, + }, + }); + } else { + this.edit({ provisioning_config: null }); + } + }; + private readonly __productCodeRestrictionsGetValue = () => { return this.form.product_code_restrictions ?.split(',') @@ -136,6 +186,10 @@ export class GiftCardForm extends Base { alwaysMatch.push('codes', 'category-restrictions', 'attributes'); } + if (!this.form.provisioning_config?.allow_autoprovisioning) { + alwaysMatch.push('provisioning:sku', 'provisioning:min-balance', 'provisioning:max-balance'); + } + return new BooleanSelector(alwaysMatch.join(' ').trim()); } @@ -180,27 +234,60 @@ export class GiftCardForm extends Base { return html` ${this.renderHeader()} -
- + + ({ - label: this.t(`currency.code_${value}`), + label: this.t(`general.currency.code_${value}`), value, }))} > - + -
+ + + + + - - + + + + + + + + { - return this.nucleon?.form.provisioning_config?.initial_balance_max; - }; - - private __maxBalanceValueSetter = (newMax: number) => { - const newMin = this.nucleon?.form.provisioning_config?.initial_balance_min ?? newMax; - - this.nucleon?.edit({ - provisioning_config: { - allow_autoprovisioning: true, - initial_balance_min: newMin > newMax ? newMax : newMin, - initial_balance_max: newMax, - }, - }); - }; - - private __minBalanceValueGetter = () => { - return this.nucleon?.form.provisioning_config?.initial_balance_min; - }; - - private __minBalanceValueSetter = (newMin: number) => { - const newMax = this.nucleon?.form.provisioning_config?.initial_balance_max ?? newMin; - - this.nucleon?.edit({ - provisioning_config: { - allow_autoprovisioning: true, - initial_balance_min: newMin, - initial_balance_max: newMax < newMin ? newMin : newMax, - }, - }); - }; - - private __toggleValueGetter = () => { - return this.nucleon?.form.provisioning_config?.allow_autoprovisioning ? ['allow'] : []; - }; - - private __toggleValueSetter = (newValue: string[]) => { - if (newValue.includes('allow')) { - this.nucleon?.edit({ - provisioning_config: { - allow_autoprovisioning: true, - initial_balance_min: this.nucleon?.form.provisioning_config?.initial_balance_min ?? 0, - initial_balance_max: this.nucleon?.form.provisioning_config?.initial_balance_max ?? 0, - }, - }); - } else { - this.nucleon?.edit({ provisioning_config: null }); - } - }; - - private __toggleOptions: Option[] = [{ label: 'text', value: 'allow' }]; - - renderControl(): TemplateResult { - return html` - - - - ${this.nucleon?.form.provisioning_config?.allow_autoprovisioning - ? html` -
- - - - - - - - -
- ` - : ''} - `; - } -} diff --git a/src/elements/public/GiftCardForm/internal/InternalGiftCardFormProvisioningControl/index.ts b/src/elements/public/GiftCardForm/internal/InternalGiftCardFormProvisioningControl/index.ts deleted file mode 100644 index cf4fc9b1..00000000 --- a/src/elements/public/GiftCardForm/internal/InternalGiftCardFormProvisioningControl/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import '../../../../internal/InternalCheckboxGroupControl/index'; -import '../../../../internal/InternalEditableControl/index'; -import '../../../../internal/InternalIntegerControl/index'; -import '../../../../internal/InternalTextControl/index'; - -import { InternalGiftCardFormProvisioningControl } from './InternalGiftCardFormProvisioningControl'; - -customElements.define( - 'foxy-internal-gift-card-form-provisioning-control', - InternalGiftCardFormProvisioningControl -); - -export { InternalGiftCardFormProvisioningControl }; diff --git a/src/static/translations/gift-card-form/en.json b/src/static/translations/gift-card-form/en.json index be256e90..1af3dbb3 100644 --- a/src/static/translations/gift-card-form/en.json +++ b/src/static/translations/gift-card-form/en.json @@ -120,223 +120,230 @@ } } }, - "name": { - "label": "Name", - "placeholder": "Required", - "helper_text": "Gift card name is visible to the customers.", - "v8n_required": "Please enter a name for this gift card.", - "v8n_too_long": "Please shorten the name to 50 characters or less." - }, - "expires": { - "label": "Expires after", - "placeholder": "Optional", - "helper_text": "Set to make the gift card codes expire after a certain period of time. Check with your local laws to ensure expiring gift cards is legal, according to your use case.", - "year": "Year", - "year_plural": "Years", - "month": "Month", - "month_plural": "Months", - "week": "Week", - "week_plural": "Weeks", - "day": "Day", - "day_plural": "Days" - }, - "currency": { - "label": "Currency", - "placeholder": "Optional", - "helper_text": "Currency code for this gift card. Note that gift cards are only usable if the cart's currency matches.", - "code_aed": "United Arab Emirates Dirham (AED)", - "code_afn": "Afghan Afghani (AFN)", - "code_all": "Albanian Lek (ALL)", - "code_amd": "Armenian Dram (AMD)", - "code_ang": "Netherlands Antillean Guilder (ANG)", - "code_aoa": "Angolan Kwanza (AOA)", - "code_ars": "Argentine Peso (ARS)", - "code_aud": "Australian Dollar (AUD)", - "code_awg": "Aruban Florin (AWG)", - "code_azn": "Azerbaijani Manat (AZN)", - "code_bam": "Bosnia-Herzegovina Convertible Mark (BAM)", - "code_bbd": "Barbadian Dollar (BBD)", - "code_bdt": "Bangladeshi Taka (BDT)", - "code_bgn": "Bulgarian Lev (BGN)", - "code_bhd": "Bahraini Dinar (BHD)", - "code_bif": "Burundian Franc (BIF)", - "code_bmd": "Bermudan Dollar (BMD)", - "code_bnd": "Brunei Dollar (BND)", - "code_bob": "Bolivian Boliviano (BOB)", - "code_brl": "Brazilian Real (BRL)", - "code_bsd": "Bahamian Dollar (BSD)", - "code_btc": "Bitcoin (BTC)", - "code_btn": "Bhutanese Ngultrum (BTN)", - "code_bwp": "Botswanan Pula (BWP)", - "code_byn": "Belarusian Ruble (BYN)", - "code_bzd": "Belize Dollar (BZD)", - "code_cad": "Canadian Dollar (CAD)", - "code_cdf": "Congolese Franc (CDF)", - "code_chf": "Swiss Franc (CHF)", - "code_clf": "Chilean Unit of Account (UF) (CLF)", - "code_clp": "Chilean Peso (CLP)", - "code_cnh": "Chinese Yuan (Offshore) (CNH)", - "code_cny": "Chinese Yuan (CNY)", - "code_cop": "Colombian Peso (COP)", - "code_crc": "Costa Rican Colón (CRC)", - "code_cuc": "Cuban Convertible Peso (CUC)", - "code_cup": "Cuban Peso (CUP)", - "code_cve": "Cape Verdean Escudo (CVE)", - "code_czk": "Czech Republic Koruna (CZK)", - "code_djf": "Djiboutian Franc (DJF)", - "code_dkk": "Danish Krone (DKK)", - "code_dop": "Dominican Peso (DOP)", - "code_dzd": "Algerian Dinar (DZD)", - "code_egp": "Egyptian Pound (EGP)", - "code_ern": "Eritrean Nakfa (ERN)", - "code_etb": "Ethiopian Birr (ETB)", - "code_eur": "Euro (EUR)", - "code_fjd": "Fijian Dollar (FJD)", - "code_fkp": "Falkland Islands Pound (FKP)", - "code_gbp": "British Pound Sterling (GBP)", - "code_gel": "Georgian Lari (GEL)", - "code_ggp": "Guernsey Pound (GGP)", - "code_ghs": "Ghanaian Cedi (GHS)", - "code_gip": "Gibraltar Pound (GIP)", - "code_gmd": "Gambian Dalasi (GMD)", - "code_gnf": "Guinean Franc (GNF)", - "code_gtq": "Guatemalan Quetzal (GTQ)", - "code_gyd": "Guyanaese Dollar (GYD)", - "code_hkd": "Hong Kong Dollar (HKD)", - "code_hnl": "Honduran Lempira (HNL)", - "code_hrk": "Croatian Kuna (HRK)", - "code_htg": "Haitian Gourde (HTG)", - "code_huf": "Hungarian Forint (HUF)", - "code_idr": "Indonesian Rupiah (IDR)", - "code_ils": "Israeli New Sheqel (ILS)", - "code_imp": "Manx pound (IMP)", - "code_inr": "Indian Rupee (INR)", - "code_iqd": "Iraqi Dinar (IQD)", - "code_irr": "Iranian Rial (IRR)", - "code_isk": "Icelandic Króna (ISK)", - "code_jep": "Jersey Pound (JEP)", - "code_jmd": "Jamaican Dollar (JMD)", - "code_jod": "Jordanian Dinar (JOD)", - "code_jpy": "Japanese Yen (JPY)", - "code_kes": "Kenyan Shilling (KES)", - "code_kgs": "Kyrgystani Som (KGS)", - "code_khr": "Cambodian Riel (KHR)", - "code_kmf": "Comorian Franc (KMF)", - "code_kpw": "North Korean Won (KPW)", - "code_krw": "South Korean Won (KRW)", - "code_kwd": "Kuwaiti Dinar (KWD)", - "code_kyd": "Cayman Islands Dollar (KYD)", - "code_kzt": "Kazakhstani Tenge (KZT)", - "code_lak": "Laotian Kip (LAK)", - "code_lbp": "Lebanese Pound (LBP)", - "code_lkr": "Sri Lankan Rupee (LKR)", - "code_lrd": "Liberian Dollar (LRD)", - "code_lsl": "Lesotho Loti (LSL)", - "code_lyd": "Libyan Dinar (LYD)", - "code_mad": "Moroccan Dirham (MAD)", - "code_mdl": "Moldovan Leu (MDL)", - "code_mga": "Malagasy Ariary (MGA)", - "code_mkd": "Macedonian Denar (MKD)", - "code_mmk": "Myanma Kyat (MMK)", - "code_mnt": "Mongolian Tugrik (MNT)", - "code_mop": "Macanese Pataca (MOP)", - "code_mru": "Mauritanian Ouguiya (MRU)", - "code_mur": "Mauritian Rupee (MUR)", - "code_mvr": "Maldivian Rufiyaa (MVR)", - "code_mwk": "Malawian Kwacha (MWK)", - "code_mxn": "Mexican Peso (MXN)", - "code_myr": "Malaysian Ringgit (MYR)", - "code_mzn": "Mozambican Metical (MZN)", - "code_nad": "Namibian Dollar (NAD)", - "code_ngn": "Nigerian Naira (NGN)", - "code_nio": "Nicaraguan Córdoba (NIO)", - "code_nok": "Norwegian Krone (NOK)", - "code_npr": "Nepalese Rupee (NPR)", - "code_nzd": "New Zealand Dollar (NZD)", - "code_omr": "Omani Rial (OMR)", - "code_pab": "Panamanian Balboa (PAB)", - "code_pen": "Peruvian Nuevo Sol (PEN)", - "code_pgk": "Papua New Guinean Kina (PGK)", - "code_php": "Philippine Peso (PHP)", - "code_pkr": "Pakistani Rupee (PKR)", - "code_pln": "Polish Zloty (PLN)", - "code_pyg": "Paraguayan Guarani (PYG)", - "code_qar": "Qatari Rial (QAR)", - "code_ron": "Romanian Leu (RON)", - "code_rsd": "Serbian Dinar (RSD)", - "code_rub": "Russian Ruble (RUB)", - "code_rwf": "Rwandan Franc (RWF)", - "code_sar": "Saudi Riyal (SAR)", - "code_sbd": "Solomon Islands Dollar (SBD)", - "code_scr": "Seychellois Rupee (SCR)", - "code_sdg": "Sudanese Pound (SDG)", - "code_sek": "Swedish Krona (SEK)", - "code_sgd": "Singapore Dollar (SGD)", - "code_shp": "Saint Helena Pound (SHP)", - "code_sll": "Sierra Leonean Leone (SLL)", - "code_sos": "Somali Shilling (SOS)", - "code_srd": "Surinamese Dollar (SRD)", - "code_ssp": "South Sudanese Pound (SSP)", - "code_std": "São Tomé and Príncipe Dobra (pre-2018) (STD)", - "code_stn": "São Tomé and Príncipe Dobra (STN)", - "code_svc": "Salvadoran Colón (SVC)", - "code_syp": "Syrian Pound (SYP)", - "code_szl": "Swazi Lilangeni (SZL)", - "code_thb": "Thai Baht (THB)", - "code_tjs": "Tajikistani Somoni (TJS)", - "code_tmt": "Turkmenistani Manat (TMT)", - "code_tnd": "Tunisian Dinar (TND)", - "code_top": "Tongan Pa'anga (TOP)", - "code_try": "Turkish Lira (TRY)", - "code_ttd": "Trinidad and Tobago Dollar (TTD)", - "code_twd": "New Taiwan Dollar (TWD)", - "code_tzs": "Tanzanian Shilling (TZS)", - "code_uah": "Ukrainian Hryvnia (UAH)", - "code_ugx": "Ugandan Shilling (UGX)", - "code_usd": "United States Dollar (USD)", - "code_uyu": "Uruguayan Peso (UYU)", - "code_uzs": "Uzbekistan Som (UZS)", - "code_vef": "Venezuelan Bolívar Fuerte (Old) (VEF)", - "code_ves": "Venezuelan Bolívar Soberano (VES)", - "code_vnd": "Vietnamese Dong (VND)", - "code_vuv": "Vanuatu Vatu (VUV)", - "code_wst": "Samoan Tala (WST)", - "code_xaf": "CFA Franc BEAC (XAF)", - "code_xag": "Silver Ounce (XAG)", - "code_xau": "Gold Ounce (XAU)", - "code_xcd": "East Caribbean Dollar (XCD)", - "code_xdr": "Special Drawing Rights (XDR)", - "code_xof": "CFA Franc BCEAO (XOF)", - "code_xpd": "Palladium Ounce (XPD)", - "code_xpf": "CFP Franc (XPF)", - "code_xpt": "Platinum Ounce (XPT)", - "code_yer": "Yemeni Rial (YER)", - "code_zar": "South African Rand (ZAR)", - "code_zmw": "Zambian Kwacha (ZMW)", - "code_zwl": "Zimbabwean Dollar (ZWL)" + "general": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "placeholder": "Required", + "helper_text": "Visible to customers.", + "v8n_required": "Please enter a name for this gift card.", + "v8n_too_long": "Please shorten the name to 50 characters or less." + }, + "expires": { + "label": "Expires after", + "placeholder": "Optional", + "helper_text": "Check with your local laws to ensure expiring gift cards is legal, according to your use case.", + "year": "Year", + "year_plural": "Years", + "month": "Month", + "month_plural": "Months", + "week": "Week", + "week_plural": "Weeks", + "day": "Day", + "day_plural": "Days" + }, + "currency": { + "label": "Currency", + "placeholder": "Optional", + "helper_text": "Must match cart currency to apply.", + "code_aed": "United Arab Emirates Dirham (AED)", + "code_afn": "Afghan Afghani (AFN)", + "code_all": "Albanian Lek (ALL)", + "code_amd": "Armenian Dram (AMD)", + "code_ang": "Netherlands Antillean Guilder (ANG)", + "code_aoa": "Angolan Kwanza (AOA)", + "code_ars": "Argentine Peso (ARS)", + "code_aud": "Australian Dollar (AUD)", + "code_awg": "Aruban Florin (AWG)", + "code_azn": "Azerbaijani Manat (AZN)", + "code_bam": "Bosnia-Herzegovina Convertible Mark (BAM)", + "code_bbd": "Barbadian Dollar (BBD)", + "code_bdt": "Bangladeshi Taka (BDT)", + "code_bgn": "Bulgarian Lev (BGN)", + "code_bhd": "Bahraini Dinar (BHD)", + "code_bif": "Burundian Franc (BIF)", + "code_bmd": "Bermudan Dollar (BMD)", + "code_bnd": "Brunei Dollar (BND)", + "code_bob": "Bolivian Boliviano (BOB)", + "code_brl": "Brazilian Real (BRL)", + "code_bsd": "Bahamian Dollar (BSD)", + "code_btc": "Bitcoin (BTC)", + "code_btn": "Bhutanese Ngultrum (BTN)", + "code_bwp": "Botswanan Pula (BWP)", + "code_byn": "Belarusian Ruble (BYN)", + "code_bzd": "Belize Dollar (BZD)", + "code_cad": "Canadian Dollar (CAD)", + "code_cdf": "Congolese Franc (CDF)", + "code_chf": "Swiss Franc (CHF)", + "code_clf": "Chilean Unit of Account (UF) (CLF)", + "code_clp": "Chilean Peso (CLP)", + "code_cnh": "Chinese Yuan (Offshore) (CNH)", + "code_cny": "Chinese Yuan (CNY)", + "code_cop": "Colombian Peso (COP)", + "code_crc": "Costa Rican Colón (CRC)", + "code_cuc": "Cuban Convertible Peso (CUC)", + "code_cup": "Cuban Peso (CUP)", + "code_cve": "Cape Verdean Escudo (CVE)", + "code_czk": "Czech Republic Koruna (CZK)", + "code_djf": "Djiboutian Franc (DJF)", + "code_dkk": "Danish Krone (DKK)", + "code_dop": "Dominican Peso (DOP)", + "code_dzd": "Algerian Dinar (DZD)", + "code_egp": "Egyptian Pound (EGP)", + "code_ern": "Eritrean Nakfa (ERN)", + "code_etb": "Ethiopian Birr (ETB)", + "code_eur": "Euro (EUR)", + "code_fjd": "Fijian Dollar (FJD)", + "code_fkp": "Falkland Islands Pound (FKP)", + "code_gbp": "British Pound Sterling (GBP)", + "code_gel": "Georgian Lari (GEL)", + "code_ggp": "Guernsey Pound (GGP)", + "code_ghs": "Ghanaian Cedi (GHS)", + "code_gip": "Gibraltar Pound (GIP)", + "code_gmd": "Gambian Dalasi (GMD)", + "code_gnf": "Guinean Franc (GNF)", + "code_gtq": "Guatemalan Quetzal (GTQ)", + "code_gyd": "Guyanaese Dollar (GYD)", + "code_hkd": "Hong Kong Dollar (HKD)", + "code_hnl": "Honduran Lempira (HNL)", + "code_hrk": "Croatian Kuna (HRK)", + "code_htg": "Haitian Gourde (HTG)", + "code_huf": "Hungarian Forint (HUF)", + "code_idr": "Indonesian Rupiah (IDR)", + "code_ils": "Israeli New Sheqel (ILS)", + "code_imp": "Manx pound (IMP)", + "code_inr": "Indian Rupee (INR)", + "code_iqd": "Iraqi Dinar (IQD)", + "code_irr": "Iranian Rial (IRR)", + "code_isk": "Icelandic Króna (ISK)", + "code_jep": "Jersey Pound (JEP)", + "code_jmd": "Jamaican Dollar (JMD)", + "code_jod": "Jordanian Dinar (JOD)", + "code_jpy": "Japanese Yen (JPY)", + "code_kes": "Kenyan Shilling (KES)", + "code_kgs": "Kyrgystani Som (KGS)", + "code_khr": "Cambodian Riel (KHR)", + "code_kmf": "Comorian Franc (KMF)", + "code_kpw": "North Korean Won (KPW)", + "code_krw": "South Korean Won (KRW)", + "code_kwd": "Kuwaiti Dinar (KWD)", + "code_kyd": "Cayman Islands Dollar (KYD)", + "code_kzt": "Kazakhstani Tenge (KZT)", + "code_lak": "Laotian Kip (LAK)", + "code_lbp": "Lebanese Pound (LBP)", + "code_lkr": "Sri Lankan Rupee (LKR)", + "code_lrd": "Liberian Dollar (LRD)", + "code_lsl": "Lesotho Loti (LSL)", + "code_lyd": "Libyan Dinar (LYD)", + "code_mad": "Moroccan Dirham (MAD)", + "code_mdl": "Moldovan Leu (MDL)", + "code_mga": "Malagasy Ariary (MGA)", + "code_mkd": "Macedonian Denar (MKD)", + "code_mmk": "Myanma Kyat (MMK)", + "code_mnt": "Mongolian Tugrik (MNT)", + "code_mop": "Macanese Pataca (MOP)", + "code_mru": "Mauritanian Ouguiya (MRU)", + "code_mur": "Mauritian Rupee (MUR)", + "code_mvr": "Maldivian Rufiyaa (MVR)", + "code_mwk": "Malawian Kwacha (MWK)", + "code_mxn": "Mexican Peso (MXN)", + "code_myr": "Malaysian Ringgit (MYR)", + "code_mzn": "Mozambican Metical (MZN)", + "code_nad": "Namibian Dollar (NAD)", + "code_ngn": "Nigerian Naira (NGN)", + "code_nio": "Nicaraguan Córdoba (NIO)", + "code_nok": "Norwegian Krone (NOK)", + "code_npr": "Nepalese Rupee (NPR)", + "code_nzd": "New Zealand Dollar (NZD)", + "code_omr": "Omani Rial (OMR)", + "code_pab": "Panamanian Balboa (PAB)", + "code_pen": "Peruvian Nuevo Sol (PEN)", + "code_pgk": "Papua New Guinean Kina (PGK)", + "code_php": "Philippine Peso (PHP)", + "code_pkr": "Pakistani Rupee (PKR)", + "code_pln": "Polish Zloty (PLN)", + "code_pyg": "Paraguayan Guarani (PYG)", + "code_qar": "Qatari Rial (QAR)", + "code_ron": "Romanian Leu (RON)", + "code_rsd": "Serbian Dinar (RSD)", + "code_rub": "Russian Ruble (RUB)", + "code_rwf": "Rwandan Franc (RWF)", + "code_sar": "Saudi Riyal (SAR)", + "code_sbd": "Solomon Islands Dollar (SBD)", + "code_scr": "Seychellois Rupee (SCR)", + "code_sdg": "Sudanese Pound (SDG)", + "code_sek": "Swedish Krona (SEK)", + "code_sgd": "Singapore Dollar (SGD)", + "code_shp": "Saint Helena Pound (SHP)", + "code_sll": "Sierra Leonean Leone (SLL)", + "code_sos": "Somali Shilling (SOS)", + "code_srd": "Surinamese Dollar (SRD)", + "code_ssp": "South Sudanese Pound (SSP)", + "code_std": "São Tomé and Príncipe Dobra (pre-2018) (STD)", + "code_stn": "São Tomé and Príncipe Dobra (STN)", + "code_svc": "Salvadoran Colón (SVC)", + "code_syp": "Syrian Pound (SYP)", + "code_szl": "Swazi Lilangeni (SZL)", + "code_thb": "Thai Baht (THB)", + "code_tjs": "Tajikistani Somoni (TJS)", + "code_tmt": "Turkmenistani Manat (TMT)", + "code_tnd": "Tunisian Dinar (TND)", + "code_top": "Tongan Pa'anga (TOP)", + "code_try": "Turkish Lira (TRY)", + "code_ttd": "Trinidad and Tobago Dollar (TTD)", + "code_twd": "New Taiwan Dollar (TWD)", + "code_tzs": "Tanzanian Shilling (TZS)", + "code_uah": "Ukrainian Hryvnia (UAH)", + "code_ugx": "Ugandan Shilling (UGX)", + "code_usd": "United States Dollar (USD)", + "code_uyu": "Uruguayan Peso (UYU)", + "code_uzs": "Uzbekistan Som (UZS)", + "code_vef": "Venezuelan Bolívar Fuerte (Old) (VEF)", + "code_ves": "Venezuelan Bolívar Soberano (VES)", + "code_vnd": "Vietnamese Dong (VND)", + "code_vuv": "Vanuatu Vatu (VUV)", + "code_wst": "Samoan Tala (WST)", + "code_xaf": "CFA Franc BEAC (XAF)", + "code_xag": "Silver Ounce (XAG)", + "code_xau": "Gold Ounce (XAU)", + "code_xcd": "East Caribbean Dollar (XCD)", + "code_xdr": "Special Drawing Rights (XDR)", + "code_xof": "CFA Franc BCEAO (XOF)", + "code_xpd": "Palladium Ounce (XPD)", + "code_xpf": "CFP Franc (XPF)", + "code_xpt": "Platinum Ounce (XPT)", + "code_yer": "Yemeni Rial (YER)", + "code_zar": "South African Rand (ZAR)", + "code_zmw": "Zambian Kwacha (ZMW)", + "code_zwl": "Zimbabwean Dollar (ZWL)" + } }, "provisioning": { + "label": "Provisioning", + "helper_text": "", "toggle": { - "label": "Provisioning", - "text": "Allow customers to buy this gift card", + "label": "Allow customers to buy this gift card", + "checked": "Yes", + "unchecked": "No", "helper_text": "" }, "sku": { "label": "SKU", "placeholder": "E.g. 100_usd", - "helper_text": "You'll use this value to add this gift card to a customer's online cart.", + "helper_text": "", "v8n_required": "SKU is required for auto-provisioning" }, "min-balance": { - "label": "Min balance", + "label": "Minimum balance", "placeholder": "0", - "helper_text": "Customers won't be able to load this card with a balance less than this value.", + "helper_text": "", "v8n_negative": "Please enter a positive number" }, "max-balance": { - "label": "Max balance", + "label": "Maximum balance", "placeholder": "0", - "helper_text": "Customers won't be able to load this card with a balance greater than this value.", + "helper_text": "", "v8n_negative": "Please enter a positive number" } }, From b015540d4799b9d480c354b3b675df24bed9508d Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 15 Oct 2024 21:45:35 -0300 Subject: [PATCH 24/26] refactor(foxy-item-option-form): ongoing ui improvements --- .../ItemOptionForm/ItemOptionForm.stories.ts | 9 +++- .../ItemOptionForm/ItemOptionForm.test.ts | 45 +++++++++++++---- .../public/ItemOptionForm/ItemOptionForm.ts | 18 ++++--- src/elements/public/ItemOptionForm/index.ts | 1 + .../admin-subscription-form/en.json | 48 +++++++++++-------- src/static/translations/cart-form/en.json | 48 +++++++++++-------- src/static/translations/item-form/en.json | 48 +++++++++++-------- .../translations/item-option-form/en.json | 48 +++++++++++-------- src/static/translations/transaction/en.json | 48 +++++++++++-------- 9 files changed, 198 insertions(+), 115 deletions(-) diff --git a/src/elements/public/ItemOptionForm/ItemOptionForm.stories.ts b/src/elements/public/ItemOptionForm/ItemOptionForm.stories.ts index c1d609e6..34151303 100644 --- a/src/elements/public/ItemOptionForm/ItemOptionForm.stories.ts +++ b/src/elements/public/ItemOptionForm/ItemOptionForm.stories.ts @@ -11,7 +11,14 @@ const summary: Summary = { localName: 'foxy-item-option-form', translatable: true, configurable: { - inputs: ['name', 'value', 'price-mod', 'weight-mod'], + inputs: [ + 'general', + 'general:name', + 'general:value', + 'mods', + 'mods:price-mod', + 'mods:weight-mod', + ], buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], sections: ['timestamps', 'header'], }, diff --git a/src/elements/public/ItemOptionForm/ItemOptionForm.test.ts b/src/elements/public/ItemOptionForm/ItemOptionForm.test.ts index 24115cdd..acfc19f8 100644 --- a/src/elements/public/ItemOptionForm/ItemOptionForm.test.ts +++ b/src/elements/public/ItemOptionForm/ItemOptionForm.test.ts @@ -2,6 +2,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import { stub } from 'sinon'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { InternalNumberControl } from '../../internal/InternalNumberControl/InternalNumberControl'; +import { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; import { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import { ItemOptionForm } from './index'; @@ -10,6 +11,10 @@ describe('ItemOptionForm', () => { expect(customElements.get('foxy-internal-number-control')).to.exist; }); + it('imports and defines foxy-internal-summary-control element', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; + }); + it('imports and defines foxy-internal-text-control element', () => { expect(customElements.get('foxy-internal-text-control')).to.exist; }); @@ -60,43 +65,67 @@ describe('ItemOptionForm', () => { expect(renderHeaderMethod).to.have.been.called; }); - it('renders item option name as text control', async () => { + it('renders general summary control', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector('[infer="general"]'); + expect(control).to.exist; + expect(control).to.be.instanceOf(InternalSummaryControl); + }); + + it('renders item option name as text control in the general summary', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('[infer="name"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="name"]'); expect(control).to.exist; expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders item option value as text control', async () => { + it('renders item option value as text control in the general summary', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('[infer="value"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="value"]'); expect(control).to.exist; expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.have.attribute('layout', 'summary-item'); + }); + + it('renders item option modifications summary control', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector('[infer="mods"]'); + expect(control).to.exist; + expect(control).to.be.instanceOf(InternalSummaryControl); }); - it('renders item option price modification as text control', async () => { + it('renders item option price modification as text control in the mods summary', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('[infer="price-mod"]'); + const control = element.renderRoot.querySelector('[infer="mods"] [infer="price-mod"]'); expect(control).to.exist; expect(control).to.be.instanceOf(InternalNumberControl); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders item option weight modification as text control', async () => { + it('renders item option weight modification as text control in the mods summary', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('[infer="weight-mod"]'); + const control = element.renderRoot.querySelector('[infer="mods"] [infer="weight-mod"]'); expect(control).to.exist; expect(control).to.be.instanceOf(InternalNumberControl); + expect(control).to.have.attribute('layout', 'summary-item'); }); }); diff --git a/src/elements/public/ItemOptionForm/ItemOptionForm.ts b/src/elements/public/ItemOptionForm/ItemOptionForm.ts index cafc6ad2..c070fdc4 100644 --- a/src/elements/public/ItemOptionForm/ItemOptionForm.ts +++ b/src/elements/public/ItemOptionForm/ItemOptionForm.ts @@ -26,12 +26,18 @@ export class ItemOptionForm extends TranslatableMixin(InternalForm, 'item-option return html` ${this.renderHeader()} -
- - - - -
+ + + + + + + + + + + + ${super.renderBody()} `; diff --git a/src/elements/public/ItemOptionForm/index.ts b/src/elements/public/ItemOptionForm/index.ts index 19383b1a..3315d8e9 100644 --- a/src/elements/public/ItemOptionForm/index.ts +++ b/src/elements/public/ItemOptionForm/index.ts @@ -1,3 +1,4 @@ +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalNumberControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; diff --git a/src/static/translations/admin-subscription-form/en.json b/src/static/translations/admin-subscription-form/en.json index 83face8e..0aaebbe3 100644 --- a/src/static/translations/admin-subscription-form/en.json +++ b/src/static/translations/admin-subscription-form/en.json @@ -704,29 +704,37 @@ "done": "Copied to clipboard" } }, - "name": { - "label": "Name", + "general": { + "label": "", "helper_text": "", - "placeholder": "Color", - "v8n_required": "Name is required", - "v8n_too_long": "Name mustn't exceed 100 characters" + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Name is required", + "v8n_too_long": "Name mustn't exceed 100 characters" + }, + "value": { + "label": "Value", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Value is required", + "v8n_too_long": "Value mustn't exceed 1024 characters" + } }, - "value": { - "label": "Value", + "mods": { + "label": "", "helper_text": "", - "placeholder": "Red", - "v8n_required": "Value is required", - "v8n_too_long": "Value mustn't exceed 1024 characters" - }, - "price-mod": { - "label": "Price", - "helper_text": "Same currency as item", - "placeholder": "10" - }, - "weight-mod": { - "label": "Weight", - "helper_text": "Arbitrary units", - "placeholder": "5" + "price-mod": { + "label": "Price (item currency)", + "helper_text": "", + "placeholder": "0" + }, + "weight-mod": { + "label": "Weight (arbitrary units)", + "helper_text": "", + "placeholder": "0" + } }, "timestamps": { "date_created": "Created on", diff --git a/src/static/translations/cart-form/en.json b/src/static/translations/cart-form/en.json index 267805ec..9ecb0563 100644 --- a/src/static/translations/cart-form/en.json +++ b/src/static/translations/cart-form/en.json @@ -970,29 +970,37 @@ "done": "Copied to clipboard" } }, - "name": { - "label": "Name", + "general": { + "label": "", "helper_text": "", - "placeholder": "Color", - "v8n_required": "Name is required", - "v8n_too_long": "Name mustn't exceed 100 characters" + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Name is required", + "v8n_too_long": "Name mustn't exceed 100 characters" + }, + "value": { + "label": "Value", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Value is required", + "v8n_too_long": "Value mustn't exceed 1024 characters" + } }, - "value": { - "label": "Value", + "mods": { + "label": "", "helper_text": "", - "placeholder": "Red", - "v8n_required": "Value is required", - "v8n_too_long": "Value mustn't exceed 1024 characters" - }, - "price-mod": { - "label": "Price", - "helper_text": "Same currency as item", - "placeholder": "10" - }, - "weight-mod": { - "label": "Weight", - "helper_text": "Arbitrary units", - "placeholder": "5" + "price-mod": { + "label": "Price (item currency)", + "helper_text": "", + "placeholder": "0" + }, + "weight-mod": { + "label": "Weight (arbitrary units)", + "helper_text": "", + "placeholder": "0" + } }, "timestamps": { "date_created": "Created on", diff --git a/src/static/translations/item-form/en.json b/src/static/translations/item-form/en.json index 05cb33bf..f896ba3a 100644 --- a/src/static/translations/item-form/en.json +++ b/src/static/translations/item-form/en.json @@ -369,29 +369,37 @@ "done": "Copied to clipboard" } }, - "name": { - "label": "Name", + "general": { + "label": "", "helper_text": "", - "placeholder": "Color", - "v8n_required": "Name is required", - "v8n_too_long": "Name mustn't exceed 100 characters" + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Name is required", + "v8n_too_long": "Name mustn't exceed 100 characters" + }, + "value": { + "label": "Value", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Value is required", + "v8n_too_long": "Value mustn't exceed 1024 characters" + } }, - "value": { - "label": "Value", + "mods": { + "label": "", "helper_text": "", - "placeholder": "Red", - "v8n_required": "Value is required", - "v8n_too_long": "Value mustn't exceed 1024 characters" - }, - "price-mod": { - "label": "Price", - "helper_text": "Same currency as item", - "placeholder": "10" - }, - "weight-mod": { - "label": "Weight", - "helper_text": "Arbitrary units", - "placeholder": "5" + "price-mod": { + "label": "Price (item currency)", + "helper_text": "", + "placeholder": "0" + }, + "weight-mod": { + "label": "Weight (arbitrary units)", + "helper_text": "", + "placeholder": "0" + } }, "timestamps": { "date_created": "Created on", diff --git a/src/static/translations/item-option-form/en.json b/src/static/translations/item-option-form/en.json index 2db6ae4f..431532ea 100644 --- a/src/static/translations/item-option-form/en.json +++ b/src/static/translations/item-option-form/en.json @@ -16,29 +16,37 @@ "done": "Copied to clipboard" } }, - "name": { - "label": "Name", + "general": { + "label": "", "helper_text": "", - "placeholder": "Color", - "v8n_required": "Name is required", - "v8n_too_long": "Name mustn't exceed 100 characters" + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Name is required", + "v8n_too_long": "Name mustn't exceed 100 characters" + }, + "value": { + "label": "Value", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Value is required", + "v8n_too_long": "Value mustn't exceed 1024 characters" + } }, - "value": { - "label": "Value", + "mods": { + "label": "", "helper_text": "", - "placeholder": "Red", - "v8n_required": "Value is required", - "v8n_too_long": "Value mustn't exceed 1024 characters" - }, - "price-mod": { - "label": "Price", - "helper_text": "Same currency as item", - "placeholder": "10" - }, - "weight-mod": { - "label": "Weight", - "helper_text": "Arbitrary units", - "placeholder": "5" + "price-mod": { + "label": "Price (item currency)", + "helper_text": "", + "placeholder": "0" + }, + "weight-mod": { + "label": "Weight (arbitrary units)", + "helper_text": "", + "placeholder": "0" + } }, "timestamps": { "date_created": "Created on", diff --git a/src/static/translations/transaction/en.json b/src/static/translations/transaction/en.json index 84ffbfb6..ed171a78 100644 --- a/src/static/translations/transaction/en.json +++ b/src/static/translations/transaction/en.json @@ -465,29 +465,37 @@ "done": "Copied to clipboard" } }, - "name": { - "label": "Name", + "general": { + "label": "", "helper_text": "", - "placeholder": "Color", - "v8n_required": "Name is required", - "v8n_too_long": "Name mustn't exceed 100 characters" + "name": { + "label": "Name", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Name is required", + "v8n_too_long": "Name mustn't exceed 100 characters" + }, + "value": { + "label": "Value", + "helper_text": "", + "placeholder": "Required", + "v8n_required": "Value is required", + "v8n_too_long": "Value mustn't exceed 1024 characters" + } }, - "value": { - "label": "Value", + "mods": { + "label": "", "helper_text": "", - "placeholder": "Red", - "v8n_required": "Value is required", - "v8n_too_long": "Value mustn't exceed 1024 characters" - }, - "price-mod": { - "label": "Price", - "helper_text": "Same currency as item", - "placeholder": "10" - }, - "weight-mod": { - "label": "Weight", - "helper_text": "Arbitrary units", - "placeholder": "5" + "price-mod": { + "label": "Price (item currency)", + "helper_text": "", + "placeholder": "0" + }, + "weight-mod": { + "label": "Weight (arbitrary units)", + "helper_text": "", + "placeholder": "0" + } }, "timestamps": { "date_created": "Created on", From b431519169c38c8e08e1ede62ab3388c5942a490 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 15 Oct 2024 21:52:25 -0300 Subject: [PATCH 25/26] refactor(foxy-transaction): ongoing ui improvements --- .../public/Transaction/Transaction.ts | 43 ++++++++----------- .../InternalTransactionCustomerControl.ts | 8 ++-- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/elements/public/Transaction/Transaction.ts b/src/elements/public/Transaction/Transaction.ts index 195c436c..83486cb0 100644 --- a/src/elements/public/Transaction/Transaction.ts +++ b/src/elements/public/Transaction/Transaction.ts @@ -295,32 +295,27 @@ export class Transaction extends Base { >
-
- - + - - -
+ + - + Date: Tue, 15 Oct 2024 21:57:46 -0300 Subject: [PATCH 26/26] refactor(foxy-coupon-code-form): ongoing ui improvements --- .../CouponCodeForm/CouponCodeForm.stories.ts | 2 +- .../CouponCodeForm/CouponCodeForm.test.ts | 25 +++++++++++++++++-- .../public/CouponCodeForm/CouponCodeForm.ts | 5 +++- src/elements/public/CouponCodeForm/index.ts | 1 + .../translations/coupon-code-form/en.json | 18 +++++++------ src/static/translations/coupon-form/en.json | 18 +++++++------ 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/elements/public/CouponCodeForm/CouponCodeForm.stories.ts b/src/elements/public/CouponCodeForm/CouponCodeForm.stories.ts index a922b7d3..ac336e93 100644 --- a/src/elements/public/CouponCodeForm/CouponCodeForm.stories.ts +++ b/src/elements/public/CouponCodeForm/CouponCodeForm.stories.ts @@ -13,7 +13,7 @@ const summary: Summary = { configurable: { sections: ['timestamps', 'header'], buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], - inputs: ['code'], + inputs: ['general', 'general:code'], }, }; diff --git a/src/elements/public/CouponCodeForm/CouponCodeForm.test.ts b/src/elements/public/CouponCodeForm/CouponCodeForm.test.ts index de2a40b0..2a8146f3 100644 --- a/src/elements/public/CouponCodeForm/CouponCodeForm.test.ts +++ b/src/elements/public/CouponCodeForm/CouponCodeForm.test.ts @@ -11,6 +11,10 @@ describe('foxy-coupon-code-form', () => { expect(customElements.get('foxy-internal-async-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-text-control', () => { expect(customElements.get('foxy-internal-text-control')).to.exist; }); @@ -83,12 +87,29 @@ describe('foxy-coupon-code-form', () => { expect(renderHeaderMethod).to.have.been.called; }); - it('renders a text control for code', async () => { + it('renders a general summary control', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="general"]' + ); + + expect(control).to.exist; + }); + + it('renders a text control for code in the general summary', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('foxy-internal-text-control[infer="code"]'); + + const control = element.renderRoot.querySelector( + '[infer="general"] foxy-internal-text-control[infer="code"]' + ); + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'summary-item'); }); it('renders a list of transactions', async () => { diff --git a/src/elements/public/CouponCodeForm/CouponCodeForm.ts b/src/elements/public/CouponCodeForm/CouponCodeForm.ts index e4e208dd..ee76d2ad 100644 --- a/src/elements/public/CouponCodeForm/CouponCodeForm.ts +++ b/src/elements/public/CouponCodeForm/CouponCodeForm.ts @@ -56,7 +56,10 @@ export class CouponCodeForm extends Base { return html` ${this.renderHeader()} - + + + +