diff --git a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.test.ts b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.test.ts index 2c61677f..e690ca5b 100644 --- a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.test.ts +++ b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.test.ts @@ -1,3 +1,4 @@ +import type { InternalSummaryControl } from '../InternalSummaryControl/InternalSummaryControl'; import type { NotificationElement } from '@vaadin/vaadin-notification'; import type { CheckboxElement } from '@vaadin/vaadin-checkbox'; import type { AttributeCard } from '../../public/AttributeCard/AttributeCard'; @@ -20,6 +21,7 @@ import { SwipeActions } from '../../public/SwipeActions/SwipeActions'; import { getTestData } from '../../../testgen/getTestData'; import { FormDialog } from '../../public/FormDialog/FormDialog'; import { Pagination } from '../../public/Pagination/Pagination'; +import { ifDefined } from 'lit-html/directives/if-defined'; import { getByTag } from '../../../testgen/getByTag'; import { getByKey } from '../../../testgen/getByKey'; import { spread } from '@open-wc/lit-helpers'; @@ -30,6 +32,15 @@ import { getByTestClass } from '../../../testgen/getByTestClass'; import { createRouter } from '../../../server/hapi'; import { Type } from '../../public/QueryBuilder/types'; +customElements.define( + 'x-test-control-with-error-message', + class extends Control { + protected get _errorMessage() { + return 'Test error message'; + } + } +); + describe('InternalAsyncListControl', () => { const OriginalResizeObserver = window.ResizeObserver; @@ -150,6 +161,11 @@ describe('InternalAsyncListControl', () => { expect(Control).to.have.deep.nested.property('properties.related', { type: Array }); }); + it('has a reactive property "layout"', () => { + expect(new Control()).to.have.property('layout', null); + expect(Control).to.have.deep.nested.property('properties.layout', {}); + }); + it('has a reactive property "first"', () => { expect(new Control()).to.have.property('first', null); expect(Control).to.have.deep.nested.property('properties.first', {}); @@ -191,6 +207,19 @@ describe('InternalAsyncListControl', () => { expect(Control).to.have.deep.nested.property('properties.wide', { type: Boolean }); }); + it('has a reactive property "open"', () => { + expect(new Control()).to.have.property('open', false); + expect(Control).to.have.deep.nested.property('properties.open', { type: Boolean }); + }); + + it('has a reactive property "hideWhenEmpty"', () => { + expect(new Control()).to.have.property('hideWhenEmpty', false); + expect(Control).to.have.deep.nested.property('properties.hideWhenEmpty', { + type: Boolean, + attribute: 'hide-when-empty', + }); + }); + it('has a reactive property "alert"', () => { expect(new Control()).to.have.property('alert', false); expect(Control).to.have.deep.nested.property('properties.alert', { type: Boolean }); @@ -211,6 +240,11 @@ describe('InternalAsyncListControl', () => { expect(Control).to.have.deep.nested.property('properties.filters', { type: Array }); }); + it('has a reactive property "filter"', () => { + expect(new Control()).to.have.deep.property('filter', null); + expect(Control).to.have.deep.nested.property('properties.filter', {}); + }); + it('renders a form dialog if "form" is specified', async () => { const control = await fixture(html` @@ -275,7 +309,7 @@ describe('InternalAsyncListControl', () => { expect(dialog).to.have.attribute('infer', ''); }); - it("renders a translated label if it's defined", async () => { + it("renders a translated label in default layout if it's defined", async () => { const control = await fixture(html` `); @@ -288,1110 +322,1234 @@ describe('InternalAsyncListControl', () => { expect(control.renderRoot).to.include.text('Test label'); }); - it('renders a pagination element for collection items', async () => { + it('renders helper text in default layout', async () => { const control = await fixture(html` `); - const pagination = await getByTag(control, 'foxy-pagination'); - - expect(pagination).to.exist; - expect(pagination).to.not.have.attribute('first'); - expect(pagination).to.have.attribute('infer', 'pagination'); - - control.first = 'https://demo.api/virtual/stall'; - control.limit = 5; - await control.requestUpdate(); - - expect(pagination).to.have.attribute('first', 'https://demo.api/virtual/stall?limit=5'); - }); - - it('renders a collection page under pagination', async () => { - const control = await fixture(html` - - `); - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination?.querySelector('foxy-collection-page'); - - expect(page).to.exist; - expect(page).to.have.attribute('infer', 'card'); - expect(page).to.have.deep.property('related', []); - expect(page).to.have.deep.property('props', {}); - - control.related = ['https://demo.api/virtual/stall']; - await control.requestUpdate(); - expect(page).to.have.deep.property('related', ['https://demo.api/virtual/stall']); + expect(control.renderRoot).to.include.text('helper_text'); - control.itemProps = { foo: 'bar' }; + control.helperText = 'Test helper text'; await control.requestUpdate(); - expect(page).to.have.deep.property('props', { foo: 'bar' }); - }); - - it('can render collection page items as simple list items using local name', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: null, - next: null, - ns: '', - }) - ); - - expect(item).to.have.property('localName', 'foxy-attribute-card'); - expect(item).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); - expect(item).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); - expect(item).to.have.attribute('infer', ''); - expect(item).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); - }); - - it('can render collection page items as simple list items using render function', async () => { - const control = await fixture(html` - - `); - - control.item = () => html`
`; - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: null, - next: null, - ns: '', - }) - ); - - expect(item).to.include.html('
'); - }); - - it('can render collection page items as links using local name', async () => { - const control = await fixture(html` - - `); - control.getPageHref = href => `https://demo.api/virtual/stall?href=${encodeURIComponent(href)}`; - control.item = 'foxy-attribute-card'; - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const card = item.querySelector('foxy-attribute-card')!; - const a = card.closest('a')!; - - expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); - expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); - expect(card).to.have.attribute('infer', ''); - expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); - - expect(a).to.have.attribute( - 'href', - `https://demo.api/virtual/stall?href=${encodeURIComponent( - 'https://demo.api/virtual/stall?href' - )}` - ); - }); - - it('can render collection page items as links using render function', async () => { - const control = await fixture(html` - - `); - - control.getPageHref = href => `https://demo.api/virtual/stall?href=${encodeURIComponent(href)}`; - control.item = () => html`
`; - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const a = item.querySelector('a')!; - - expect(a).to.include.html('
'); - expect(a).to.have.attribute( - 'href', - `https://demo.api/virtual/stall?href=${encodeURIComponent( - 'https://demo.api/virtual/stall?href' - )}` - ); - }); - - it('can render collection page items as buttons opening a dialog using local name', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const card = item.querySelector('foxy-attribute-card')!; - const button = card.closest('button')!; - const showMethod = stub(dialog, 'show'); - - expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); - expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); - expect(card).to.have.attribute('infer', ''); - expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); - - button.click(); - - expect(showMethod).to.have.been.called; - expect(dialog).to.have.property('header', 'header_update'); - expect(dialog).to.have.property('href', 'https://demo.api/virtual/stall?href'); - }); - - it('can render collection page items as buttons opening a dialog using render function', async () => { - const control = await fixture(html` - - `); - - control.item = () => html`
`; - control.form = 'foxy-attribute-form'; - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const button = item.querySelector('button')!; - expect(button).to.include.html('
'); - - const showMethod = stub(dialog, 'show'); - button.click(); - - expect(showMethod).to.have.been.called; - expect(dialog).to.have.property('header', 'header_update'); - expect(dialog).to.have.property('href', 'https://demo.api/virtual/stall?href'); - }); - - it('renders Delete button for collection page items', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const swipeActions = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const card = swipeActions.querySelector('foxy-attribute-card') as AttributeCard; - const label = swipeActions.querySelector('[key="delete_button_text"]')!; - const button = label.closest('vaadin-button')!; - const confirm = (await getByTag( - control, - 'foxy-internal-confirm-dialog' - )) as InternalConfirmDialog; - - expect(swipeActions).to.have.property('localName', 'foxy-swipe-actions'); - expect(label).to.have.property('localName', 'foxy-i18n'); - expect(label).to.have.attribute('infer', ''); - expect(button).to.have.attribute('slot', 'action'); - - const confirmShowMethod = stub(confirm, 'show'); - button.click(); - - expect(confirmShowMethod).to.have.been.called; - - const cardDeleteMethod = stub(card, 'delete'); - confirm.dispatchEvent(new DialogHideEvent()); - - expect(cardDeleteMethod).to.have.been.called; - }); - - it('hides delete button for collection page items if hideDeleteButton is true', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - control.hideDeleteButton = true; - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const swipeActions = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const selector = 'vaadin-button foxy-i18n[key="delete_button_text"]'; - expect(swipeActions.querySelector(selector)).to.not.exist; - }); - - it('hides delete button for collection page items when readonly', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - control.readonly = true; - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - - const swipeActions = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const selector = 'vaadin-button foxy-i18n[key="delete_button_text"]'; - expect(swipeActions.querySelector(selector)).to.not.exist; - }); - - it('renders Create button when .form is defined', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - - const label = (await getByKey(control, 'create_button_text'))!; - const button = label.closest('vaadin-button')!; - const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - - expect(label).to.have.property('localName', 'foxy-i18n'); - expect(label).to.have.attribute('infer', 'pagination'); - - const dialogShowMethod = stub(dialog, 'show'); - button.click(); - - expect(dialogShowMethod).to.have.been.called; - expect(dialog).to.have.property('header', 'header_create'); - expect(dialog).to.have.property('href', ''); + expect(control.renderRoot).to.not.include.text('helper_text'); + expect(control.renderRoot).to.include.text('Test helper text'); }); - it('renders Create link when .createPageHref is defined', async () => { + it('renders a summary control in details layout', async () => { + const router = createRouter(); const control = await fixture(html` - + router.handleEvent(evt)} + > + `); - control.item = 'foxy-attribute-card'; - control.createPageHref = 'https://example.com'; + const summary = control.renderRoot.querySelector( + 'foxy-internal-summary-control' + ) as InternalSummaryControl; - const label = (await getByKey(control, 'create_button_text'))!; - const a = label.closest('a')!; - - expect(label).to.have.property('localName', 'foxy-i18n'); - expect(label).to.have.attribute('infer', ''); - expect(a).to.have.attribute('href', 'https://example.com'); - }); - - it("hides Create link/button if there's no form", async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; + const collectionPage = summary.querySelector('foxy-collection-page') as CollectionPage; + await waitUntil(() => !!collectionPage.data, undefined, { timeout: 5000 }); await control.requestUpdate(); - const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; - const aSelector = 'a foxy-i18n[key="create_button_text"]'; - - expect(control.querySelector(buttonSelector)).to.not.exist; - expect(control.querySelector(aSelector)).to.not.exist; - }); - - it('hides Create link/button if control is readonly', async () => { - const control = await fixture(html` - - `); + expect(summary).to.exist; + expect(summary).to.have.attribute('layout', 'details'); + expect(summary).to.have.attribute('count', '2'); + expect(summary).to.not.have.attribute('open'); - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - control.readonly = true; + control.open = true; await control.requestUpdate(); + expect(summary).to.have.attribute('open'); + expect(summary).to.not.have.attribute('count'); - const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; - const aSelector = 'a foxy-i18n[key="create_button_text"]'; - - expect(control.querySelector(buttonSelector)).to.not.exist; - expect(control.querySelector(aSelector)).to.not.exist; - }); - - it('hides Create link/button if hideCreateButton is true', async () => { - const control = await fixture(html` - - `); - - control.item = 'foxy-attribute-card'; - control.form = 'foxy-attribute-form'; - control.hideCreateButton = true; + summary.open = false; + summary.dispatchEvent(new CustomEvent('toggle')); await control.requestUpdate(); - - const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; - const aSelector = 'a foxy-i18n[key="create_button_text"]'; - - expect(control.querySelector(buttonSelector)).to.not.exist; - expect(control.querySelector(aSelector)).to.not.exist; + expect(control).to.have.property('open', false); + expect(summary).to.have.attribute('count', '2'); }); - it('renders helper text', async () => { - const control = await fixture(html` - - `); - - expect(control.renderRoot).to.include.text('helper_text'); - - control.helperText = 'Test helper text'; - await control.requestUpdate(); - - expect(control.renderRoot).to.not.include.text('helper_text'); - expect(control.renderRoot).to.include.text('Test helper text'); - }); - - it('render error text if available', async () => { - let control = await fixture(html` - - `); - - expect(control.renderRoot).to.not.include.text('Test error message'); - - customElements.define( - 'x-test-control', - class extends Control { - protected get _errorMessage() { - return 'Test error message'; - } - } - ); - - control = await fixture(html``); - expect(control.renderRoot).to.include.text('Test error message'); - }); - - it('renders actions if configured', async () => { + it('visually hides itself when collection has 0 items and hideWhenEmpty is true', async () => { const router = createRouter(); const control = await fixture(html` router.handleEvent(evt)} > `); - await waitUntil( - () => !!control.renderRoot.querySelector>('foxy-collection-page')?.data - ); - - let actions = await getByTestClass(control, 'action'); - expect(actions).to.be.empty; - - control.actions = [ - { text: 'action_1', theme: 'primary', state: 'idle', onClick: stub() }, - { text: 'action_2', theme: 'secondary', state: 'busy', onClick: stub() }, - ]; + const collectionPage = control.renderRoot.querySelector( + 'foxy-collection-page' + ) as CollectionPage; + await waitUntil(() => !!collectionPage.data, undefined, { timeout: 5000 }); await control.requestUpdate(); - actions = await getByTestClass(control, 'action'); + const wrapper = control.shadowRoot?.firstElementChild; + expect(wrapper).to.not.have.attribute('class', 'hidden'); - expect(actions).to.have.length(2); - - expect(actions[0].closest('foxy-swipe-actions')).to.exist; - expect(actions[1].closest('foxy-swipe-actions')).to.exist; - expect(actions[0].closest('foxy-swipe-actions')).to.equal( - actions[1].closest('foxy-swipe-actions') - ); + control.hideWhenEmpty = true; + await control.requestUpdate(); + expect(wrapper).to.have.attribute('class', 'hidden'); - expect(actions[0]).to.have.attribute('theme', 'primary'); - expect(actions[1]).to.have.attribute('theme', 'secondary'); + control.first = 'https://demo.api/hapi/transactions'; + await control.requestUpdate(); + await waitUntil(() => !!collectionPage.data, undefined, { timeout: 5000 }); + await control.requestUpdate(); + expect(wrapper).to.not.have.attribute('class', 'hidden'); + }); - actions[0].click(); - expect(control.actions[0].onClick).to.have.been.calledOnce; - expect(control.actions[1].onClick).to.not.have.been.called; + [undefined, 'details'].forEach(layout => { + const layoutLabel = layout ?? 'default'; - actions[1].click(); - expect(control.actions[0].onClick).to.have.been.calledOnce; - expect(control.actions[1].onClick).to.have.been.calledOnce; + it(`renders a pagination element for collection items in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - const action0Text = actions[0].querySelector('foxy-i18n'); - expect(action0Text).to.have.attribute('infer', ''); - expect(action0Text).to.have.attribute('key', 'action_1'); + const pagination = await getByTag(control, 'foxy-pagination'); - const action1Text = actions[1].querySelector('foxy-i18n'); - expect(action1Text).to.have.attribute('infer', ''); - expect(action1Text).to.have.attribute('key', 'action_2'); + expect(pagination).to.exist; + expect(pagination).to.not.have.attribute('first'); + expect(pagination).to.have.attribute('infer', 'pagination'); - const action0Spinner = actions[0].querySelector('foxy-spinner'); - expect(action0Spinner).to.have.attribute('infer', 'spinner'); - expect(action0Spinner).to.have.attribute('state', 'idle'); + control.first = 'https://demo.api/virtual/stall'; + control.limit = 5; + await control.requestUpdate(); - const action1Spinner = actions[1].querySelector('foxy-spinner'); - expect(action1Spinner).to.have.attribute('infer', 'spinner'); - expect(action1Spinner).to.have.attribute('state', 'busy'); - }); + expect(pagination).to.have.attribute('first', 'https://demo.api/virtual/stall?limit=5'); + }); - it('renders filters if configured', async () => { - const control = await fixture(html` - - `); + it(`renders a collection page under pagination in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - expect(await getByKey(control, 'search_button_text')).to.not.exist; + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination?.querySelector('foxy-collection-page'); - control.filters = [ - { - label: 'filter_1', - type: Type.Boolean, - path: 'foo', - }, - ]; + expect(page).to.exist; + expect(page).to.have.attribute('infer', 'card'); + expect(page).to.have.deep.property('related', []); + expect(page).to.have.deep.property('props', {}); - await control.requestUpdate(); + control.related = ['https://demo.api/virtual/stall']; + await control.requestUpdate(); + expect(page).to.have.deep.property('related', ['https://demo.api/virtual/stall']); - const buttonLabel = await getByKey(control, 'search_button_text'); - const button = buttonLabel?.closest('vaadin-button'); - const overlay = control.renderRoot.querySelector( - 'foxy-internal-async-list-control-filter-overlay' - ); - - expect(buttonLabel).to.exist; - expect(buttonLabel).to.have.attribute('infer', 'pagination'); - - expect(button).to.exist; - expect(button).to.not.have.attribute('disabled'); - - expect(overlay).to.exist; - expect(overlay).to.have.property('positionTarget', button); - expect(overlay).to.have.deep.property('model', { - options: control.filters, - value: '', - lang: control.lang, - ns: control.ns, + control.itemProps = { foo: 'bar' }; + await control.requestUpdate(); + expect(page).to.have.deep.property('props', { foo: 'bar' }); }); - expect(overlay).to.not.have.attribute('opened'); - - button?.click(); - await control.requestUpdate(); - expect(overlay).to.have.attribute('opened'); - - overlay?.dispatchEvent(new CustomEvent('vaadin-overlay-close')); - await control.requestUpdate(); - expect(overlay).to.not.have.attribute('opened'); - - overlay?.dispatchEvent(new CustomEvent('search', { detail: 'foo=bar' })); - await control.requestUpdate(); - expect(overlay).to.have.deep.property('model', { - options: control.filters, - value: 'foo=bar', - lang: control.lang, - ns: control.ns, + it(`can render collection page items as simple list items using local name in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = 'foxy-attribute-card'; + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const wrapper = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: null, + next: null, + ns: '', + }) + ); + + const item = wrapper.firstElementChild; + + expect(item).to.have.property('localName', 'foxy-attribute-card'); + expect(item).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); + expect(item).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); + expect(item).to.have.attribute('infer', ''); + expect(item).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); }); - }); - it('emits "itemclick" event when collection page item is clicked', async () => { - const control = await fixture(html` - - - `); - - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const card = item.querySelector('foxy-attribute-card')!; - const button = card.closest('button')!; - const showMethod = stub(dialog, 'show'); - - const whenGotItemClickEvent = oneEvent(control, 'itemclick'); - button.click(); - const itemClickEvent = await whenGotItemClickEvent; - - expect(itemClickEvent).to.be.instanceOf(CustomEvent); - expect(itemClickEvent).to.have.property('cancelable', true); - expect(itemClickEvent).to.have.property('composed', true); - expect(itemClickEvent).to.have.property('bubbles', true); - expect(itemClickEvent).to.have.property('detail', 'https://demo.api/virtual/stall?href'); - expect(showMethod).to.have.been.called; - }); + it(`can render collection page items as simple list items using render function in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = () => html`
`; + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: null, + next: null, + ns: '', + }) + ); + + expect(item).to.include.html('
'); + }); - it('does not show dialog when "itemclick" event is canceled', async () => { - const control = await fixture(html` - - - `); + it(`can render collection page items as links using local name in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.getPageHref = href => + `https://demo.api/virtual/stall?href=${encodeURIComponent(href)}`; + control.item = 'foxy-attribute-card'; + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const card = item.querySelector('foxy-attribute-card')!; + const a = card.closest('a')!; + + expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); + expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); + expect(card).to.have.attribute('infer', ''); + expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); + + expect(a).to.have.attribute( + 'href', + `https://demo.api/virtual/stall?href=${encodeURIComponent( + 'https://demo.api/virtual/stall?href' + )}` + ); + }); - const pagination = await getByTag(control, 'foxy-pagination'); - const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; - const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - const card = item.querySelector('foxy-attribute-card')!; - const button = card.closest('button')!; - const showMethod = stub(dialog, 'show'); - - control.addEventListener('itemclick', evt => evt.preventDefault()); - button.click(); - - expect(showMethod).to.not.have.been.called; - }); + it(`can render collection page items as links using render function in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.getPageHref = href => + `https://demo.api/virtual/stall?href=${encodeURIComponent(href)}`; + control.item = () => html`
`; + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const a = item.querySelector('a')!; + + expect(a).to.include.html('
'); + expect(a).to.have.attribute( + 'href', + `https://demo.api/virtual/stall?href=${encodeURIComponent( + 'https://demo.api/virtual/stall?href' + )}` + ); + }); - it('renders Select button when "first" and non-empty .bulkActions are defined', async () => { - const control = await fixture(html` - - - `); + it(`can render collection page items as buttons opening a dialog using local name in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const card = item.querySelector('foxy-attribute-card')!; + const button = card.closest('button')!; + const showMethod = stub(dialog, 'show'); + + expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); + expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); + expect(card).to.have.attribute('infer', ''); + expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); + + button.click(); + + expect(showMethod).to.have.been.called; + expect(dialog).to.have.property('header', 'header_update'); + expect(dialog).to.have.property('href', 'https://demo.api/virtual/stall?href'); + }); - expect(await getByKey(control, 'select_button_text')).to.not.exist; + it(`can render collection page items as buttons opening a dialog using render function in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + `); + + control.item = () => html`
`; + control.form = 'foxy-attribute-form'; + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const button = item.querySelector('button')!; + expect(button).to.include.html('
'); + + const showMethod = stub(dialog, 'show'); + button.click(); + + expect(showMethod).to.have.been.called; + expect(dialog).to.have.property('header', 'header_update'); + expect(dialog).to.have.property('href', 'https://demo.api/virtual/stall?href'); + }); - control.first = 'https://demo.api/hapi/customer_attributes'; - await control.requestUpdate(); - expect(await getByKey(control, 'select_button_text')).to.not.exist; + it(`renders Delete button for collection page items in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const swipeActions = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const card = swipeActions.querySelector('foxy-attribute-card') as AttributeCard; + const label = swipeActions.querySelector('[key="delete_button_text"]')!; + const button = label.closest('vaadin-button')!; + const confirm = (await getByTag( + control, + 'foxy-internal-confirm-dialog' + )) as InternalConfirmDialog; + + expect(swipeActions).to.have.property('localName', 'foxy-swipe-actions'); + expect(label).to.have.property('localName', 'foxy-i18n'); + expect(label).to.have.attribute('infer', ''); + expect(button).to.have.attribute('slot', 'action'); + + const confirmShowMethod = stub(confirm, 'show'); + button.click(); + + expect(confirmShowMethod).to.have.been.called; + + const cardDeleteMethod = stub(card, 'delete'); + confirm.dispatchEvent(new DialogHideEvent()); + + expect(cardDeleteMethod).to.have.been.called; + }); - control.bulkActions = []; - await control.requestUpdate(); - expect(await getByKey(control, 'select_button_text')).to.not.exist; + it(`hides Delete button for collection page items if hideDeleteButton is true in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + control.hideDeleteButton = true; + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const swipeActions = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const selector = 'vaadin-button foxy-i18n[key="delete_button_text"]'; + expect(swipeActions.querySelector(selector)).to.not.exist; + }); - control.bulkActions = [{ name: 'foo', onClick: stub() }]; - await control.requestUpdate(); + it(`hides Delete button for collection page items when readonly in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + control.readonly = true; + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + + const swipeActions = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const selector = 'vaadin-button foxy-i18n[key="delete_button_text"]'; + expect(swipeActions.querySelector(selector)).to.not.exist; + }); - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); + it(`renders Create button when .form is defined in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - expect(label).to.exist; - expect(button).to.exist; - expect(label).to.have.property('localName', 'foxy-i18n'); - expect(label).to.have.attribute('infer', 'pagination'); - }); + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; - it('renders Cancel button when selection mode is enabled', async () => { - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + const label = (await getByKey(control, 'create_button_text'))!; + const button = label.closest('vaadin-button')!; + const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; - const page = await getByTag>(control, 'foxy-collection-page'); - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + expect(label).to.have.property('localName', 'foxy-i18n'); + expect(label).to.have.attribute('infer', 'pagination'); - expect(await getByKey(control, 'select_button_text')).to.exist; - expect(await getByKey(control, 'cancel_button_text')).to.not.exist; + const dialogShowMethod = stub(dialog, 'show'); + button.click(); - (await getByKey(control, 'select_button_text'))?.closest('vaadin-button')?.click(); - await control.requestUpdate(); + expect(dialogShowMethod).to.have.been.called; + expect(dialog).to.have.property('header', 'header_create'); + expect(dialog).to.have.property('href', ''); + }); - expect(await getByKey(control, 'select_button_text')).to.not.exist; - expect(await getByKey(control, 'cancel_button_text')).to.exist; - }); + it(`renders Create link when .createPageHref is defined in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - it('renders bulk actions when selection mode is enabled and at least one item is selected', async () => { - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + control.item = 'foxy-attribute-card'; + control.createPageHref = 'https://example.com'; - const page = await getByTag>(control, 'foxy-collection-page'); - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); - expect(await getByTestClass(control, 'bulk-action')).to.be.empty; + const label = (await getByKey(control, 'create_button_text'))!; + const a = label.closest('a')!; - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); - button?.click(); - await control.requestUpdate(); - const anyCheckbox = await getByTag(control, 'vaadin-checkbox'); - expect(anyCheckbox).to.exist; - expect(await getByTestClass(control, 'bulk-action')).to.have.length(0); + expect(label).to.have.property('localName', 'foxy-i18n'); + expect(label).to.have.attribute('infer', ''); + expect(a).to.have.attribute('href', 'https://example.com'); + }); - anyCheckbox!.checked = true; - anyCheckbox!.dispatchEvent(new CustomEvent('change')); - await control.requestUpdate(); + it(`hides Create link/button if there's no form in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - const bulkActions = await getByTestClass(control, 'bulk-action'); - expect(bulkActions).to.have.length(1); + control.item = 'foxy-attribute-card'; + await control.requestUpdate(); - const firstBulkAction = bulkActions[0]; - const firstBulkActionLabel = firstBulkAction.querySelector('foxy-i18n'); - expect(firstBulkActionLabel).to.have.attribute('key', 'foo_bulk_action_caption_idle'); - expect(firstBulkActionLabel).to.have.attribute('infer', 'pagination'); - expect(firstBulkActionLabel).to.have.deep.property('options', { count: 1 }); - }); + const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; + const aSelector = 'a foxy-i18n[key="create_button_text"]'; - it('renders collection items inside vaadin-checkbox elements when selection mode is enabled', async () => { - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + expect(control.querySelector(buttonSelector)).to.not.exist; + expect(control.querySelector(aSelector)).to.not.exist; + }); - const page = (await getByTag>(control, 'foxy-collection-page'))!; - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); - expect(await getByTestClass(control, 'vaadin-checkbox')).to.be.empty; + it(`hides Create link/button if control is readonly in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); - button?.click(); - await control.requestUpdate(); + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + control.readonly = true; + await control.requestUpdate(); - const item = await fixture( - (page.item as ItemRenderer)({ - simplifyNsLoading: false, - readonlyControls: BooleanSelector.False, - disabledControls: BooleanSelector.False, - hiddenControls: BooleanSelector.False, - templates: {}, - readonly: false, - disabled: false, - previous: null, - related: ['https://demo.api/virtual/stall?related'], - hidden: false, - parent: 'https://demo.api/virtual/stall?parent', - spread: spread, - props: {}, - group: '', - html: html, - lang: '', - href: 'https://demo.api/virtual/stall?href', - data: await getTestData('./hapi/customer_attributes/0'), - next: null, - ns: '', - }) - ); - - expect(item).to.have.property('localName', 'vaadin-checkbox'); - expect(item).to.not.have.attribute('checked'); - - const card = item.querySelector('foxy-attribute-card')!; - - expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); - expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); - expect(card).to.have.attribute('infer', ''); - expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); - }); + const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; + const aSelector = 'a foxy-i18n[key="create_button_text"]'; - it('can run a bulk action on selected items', async () => { - const onClick = stub(); - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + expect(control.querySelector(buttonSelector)).to.not.exist; + expect(control.querySelector(aSelector)).to.not.exist; + }); - const page = (await getByTag>(control, 'foxy-collection-page'))!; - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + it(`hides Create link/button if hideCreateButton is true in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); - button?.click(); - await control.requestUpdate(); + control.item = 'foxy-attribute-card'; + control.form = 'foxy-attribute-form'; + control.hideCreateButton = true; + await control.requestUpdate(); - const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); - checkbox!.checked = true; - checkbox!.dispatchEvent(new CustomEvent('change')); - await control.requestUpdate(); + const buttonSelector = 'vaadin-button foxy-i18n[key="create_button_text"]'; + const aSelector = 'a foxy-i18n[key="create_button_text"]'; - const bulkActions = await getByTestClass(control, 'bulk-action'); - const fooAction = bulkActions[0]; - fooAction.click(); + expect(control.querySelector(buttonSelector)).to.not.exist; + expect(control.querySelector(aSelector)).to.not.exist; + }); - const customerAttribute0 = await getTestData('./hapi/customer_attributes/0'); - expect(onClick).to.have.been.calledWithMatch([customerAttribute0]); - }); + it(`renders error text if available in ${layoutLabel} layout`, async () => { + let control = await fixture(html` + + + `); - it('communicates bulk action state to the user (positive path, no errors)', async () => { - let next: (() => void) | null = null; - const onClick = () => new Promise(resolve => (next = resolve)); + expect(control.renderRoot).to.not.include.text('Test error message'); - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + control = await fixture( + html`` + ); + expect(control.renderRoot).to.include.text('Test error message'); + }); - const page = (await getByTag>(control, 'foxy-collection-page'))!; - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + it(`renders actions if configured in ${layoutLabel} layout`, async () => { + const router = createRouter(); + const control = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil( + () => !!control.renderRoot.querySelector>('foxy-collection-page')?.data + ); + + let actions = await getByTestClass(control, 'action'); + expect(actions).to.be.empty; + + control.actions = [ + { text: 'action_1', theme: 'primary', state: 'idle', onClick: stub() }, + { text: 'action_2', theme: 'secondary', state: 'busy', onClick: stub() }, + ]; + + await control.requestUpdate(); + actions = await getByTestClass(control, 'action'); + + expect(actions).to.have.length(2); + + expect(actions[0].closest('foxy-swipe-actions')).to.exist; + expect(actions[1].closest('foxy-swipe-actions')).to.exist; + expect(actions[0].closest('foxy-swipe-actions')).to.equal( + actions[1].closest('foxy-swipe-actions') + ); + + expect(actions[0]).to.have.attribute('theme', 'primary'); + expect(actions[1]).to.have.attribute('theme', 'secondary'); + + actions[0].click(); + expect(control.actions[0].onClick).to.have.been.calledOnce; + expect(control.actions[1].onClick).to.not.have.been.called; + + actions[1].click(); + expect(control.actions[0].onClick).to.have.been.calledOnce; + expect(control.actions[1].onClick).to.have.been.calledOnce; + + const action0Text = actions[0].querySelector('foxy-i18n'); + expect(action0Text).to.have.attribute('infer', ''); + expect(action0Text).to.have.attribute('key', 'action_1'); + + const action1Text = actions[1].querySelector('foxy-i18n'); + expect(action1Text).to.have.attribute('infer', ''); + expect(action1Text).to.have.attribute('key', 'action_2'); + + const action0Spinner = actions[0].querySelector('foxy-spinner'); + expect(action0Spinner).to.have.attribute('infer', 'spinner'); + expect(action0Spinner).to.have.attribute('state', 'idle'); + + const action1Spinner = actions[1].querySelector('foxy-spinner'); + expect(action1Spinner).to.have.attribute('infer', 'spinner'); + expect(action1Spinner).to.have.attribute('state', 'busy'); + }); - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); - button?.click(); - await control.requestUpdate(); + it(`renders filters if configured in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + expect(await getByKey(control, 'search_button_text')).to.not.exist; + + control.filters = [ + { + label: 'filter_1', + type: Type.Boolean, + path: 'foo', + }, + ]; + + await control.requestUpdate(); + + const buttonLabel = await getByKey(control, 'search_button_text'); + const button = buttonLabel?.closest('vaadin-button'); + const overlay = control.renderRoot.querySelector( + 'foxy-internal-async-list-control-filter-overlay' + ); + + expect(buttonLabel).to.exist; + expect(buttonLabel).to.have.attribute('infer', 'pagination'); + + expect(button).to.exist; + expect(button).to.not.have.attribute('disabled'); + + expect(overlay).to.exist; + expect(overlay).to.have.property('positionTarget', button); + expect(overlay).to.have.deep.property('model', { + options: control.filters, + value: '', + lang: control.lang, + ns: control.ns, + }); + + expect(overlay).to.not.have.attribute('opened'); + + button?.click(); + await control.requestUpdate(); + expect(overlay).to.have.attribute('opened'); + + overlay?.dispatchEvent(new CustomEvent('vaadin-overlay-close')); + await control.requestUpdate(); + expect(overlay).to.not.have.attribute('opened'); + + overlay?.dispatchEvent(new CustomEvent('search', { detail: 'foo=bar' })); + await control.requestUpdate(); + expect(overlay).to.have.deep.property('model', { + options: control.filters, + value: 'foo=bar', + lang: control.lang, + ns: control.ns, + }); + }); - const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); - checkbox!.checked = true; - checkbox!.dispatchEvent(new CustomEvent('change')); - await control.requestUpdate(); + it(`sets default filter if configured in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + const overlay = control.renderRoot.querySelector( + 'foxy-internal-async-list-control-filter-overlay' + ); + + expect(overlay).to.have.deep.property('model', { + options: control.filters, + value: 'foo=bar&baz=qux', + lang: control.lang, + ns: control.ns, + }); + }); - const bulkActions = await getByTestClass(control, 'bulk-action'); - const fooAction = bulkActions[0]; - fooAction.click(); - await control.requestUpdate(); + it(`emits "itemclick" event when collection page item is clicked in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const card = item.querySelector('foxy-attribute-card')!; + const button = card.closest('button')!; + const showMethod = stub(dialog, 'show'); + + const whenGotItemClickEvent = oneEvent(control, 'itemclick'); + button.click(); + const itemClickEvent = await whenGotItemClickEvent; + + expect(itemClickEvent).to.be.instanceOf(CustomEvent); + expect(itemClickEvent).to.have.property('cancelable', true); + expect(itemClickEvent).to.have.property('composed', true); + expect(itemClickEvent).to.have.property('bubbles', true); + expect(itemClickEvent).to.have.property('detail', 'https://demo.api/virtual/stall?href'); + expect(showMethod).to.have.been.called; + }); - expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; - expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.exist; - // @ts-expect-error it's too hard for TS to figure out that `next` is not null at this point - next?.(); - await control.requestUpdate(); - expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; - expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.not.exist; - expect(await getByKey(control, 'cancel_button_text')).to.not.exist; - expect(await getByKey(control, 'select_button_text')).to.exist; - expect(await getByTestClass(control, 'bulk-action')).to.have.length(0); - - const notification = await getByTag(control, 'vaadin-notification'); - expect(notification).to.have.attribute('theme', 'success'); - expect(notification).to.have.property('opened', true); - - const notificationRoot = document.createElement('div'); - notification?.renderer?.(notificationRoot); - - const notificationText = notificationRoot.querySelector('foxy-i18n'); - expect(notificationText).to.have.attribute('lang', 'es'); - expect(notificationText).to.have.attribute('key', 'foo_bulk_action_notification_done'); - expect(notificationText).to.have.attribute('ns', 'test pagination'); - }); + it(`does not show dialog when "itemclick" event is canceled in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + const pagination = await getByTag(control, 'foxy-pagination'); + const page = pagination!.querySelector('foxy-collection-page') as CollectionPage; + const dialog = (await getByTag(control, 'foxy-form-dialog')) as FormDialog; + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + const card = item.querySelector('foxy-attribute-card')!; + const button = card.closest('button')!; + const showMethod = stub(dialog, 'show'); + + control.addEventListener('itemclick', evt => evt.preventDefault()); + button.click(); + + expect(showMethod).to.not.have.been.called; + }); - it('communicates bulk action state to the user (negative path, failure)', async () => { - let next: (() => void) | null = null; - const onClick = () => new Promise((_, reject) => (next = reject)); + it(`renders Select button when "first" and non-empty .bulkActions are defined in ${layoutLabel} layout`, async () => { + const control = await fixture(html` + + + `); + + expect(await getByKey(control, 'select_button_text')).to.not.exist; + + control.first = 'https://demo.api/hapi/customer_attributes'; + await control.requestUpdate(); + expect(await getByKey(control, 'select_button_text')).to.not.exist; + + control.bulkActions = []; + await control.requestUpdate(); + expect(await getByKey(control, 'select_button_text')).to.not.exist; + + control.bulkActions = [{ name: 'foo', onClick: stub() }]; + await control.requestUpdate(); + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + + expect(label).to.exist; + expect(button).to.exist; + expect(label).to.have.property('localName', 'foxy-i18n'); + expect(label).to.have.attribute('infer', 'pagination'); + }); - const router = createRouter(); - const control = await fixture(html` - !evt.defaultPrevented && router.handleEvent(evt)} - > - - `); + it(`renders Cancel button when selection mode is enabled in ${layoutLabel} layout`, async () => { + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = await getByTag>(control, 'foxy-collection-page'); + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + + expect(await getByKey(control, 'select_button_text')).to.exist; + expect(await getByKey(control, 'cancel_button_text')).to.not.exist; + + (await getByKey(control, 'select_button_text'))?.closest('vaadin-button')?.click(); + await control.requestUpdate(); + + expect(await getByKey(control, 'select_button_text')).to.not.exist; + expect(await getByKey(control, 'cancel_button_text')).to.exist; + }); - const page = (await getByTag>(control, 'foxy-collection-page'))!; - await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + it(`renders bulk actions when selection mode is enabled and at least one item is selected in ${layoutLabel} layout`, async () => { + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = await getByTag>(control, 'foxy-collection-page'); + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + expect(await getByTestClass(control, 'bulk-action')).to.be.empty; + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + button?.click(); + await control.requestUpdate(); + const anyCheckbox = await getByTag(control, 'vaadin-checkbox'); + expect(anyCheckbox).to.exist; + expect(await getByTestClass(control, 'bulk-action')).to.have.length(0); + + anyCheckbox!.checked = true; + anyCheckbox!.dispatchEvent(new CustomEvent('change')); + await control.requestUpdate(); + + const bulkActions = await getByTestClass(control, 'bulk-action'); + expect(bulkActions).to.have.length(1); + + const firstBulkAction = bulkActions[0]; + const firstBulkActionLabel = firstBulkAction.querySelector('foxy-i18n'); + expect(firstBulkActionLabel).to.have.attribute('key', 'foo_bulk_action_caption_idle'); + expect(firstBulkActionLabel).to.have.attribute('infer', 'pagination'); + expect(firstBulkActionLabel).to.have.deep.property('options', { count: 1 }); + }); - const label = await getByKey(control, 'select_button_text'); - const button = label?.closest('vaadin-button'); - button?.click(); - await control.requestUpdate(); + it(`renders collection items inside vaadin-checkbox elements when selection mode is enabled in ${layoutLabel} layout`, async () => { + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = (await getByTag>(control, 'foxy-collection-page'))!; + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + expect(await getByTestClass(control, 'vaadin-checkbox')).to.be.empty; + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + button?.click(); + await control.requestUpdate(); + + const item = await fixture( + (page.item as ItemRenderer)({ + simplifyNsLoading: false, + readonlyControls: BooleanSelector.False, + disabledControls: BooleanSelector.False, + hiddenControls: BooleanSelector.False, + templates: {}, + readonly: false, + disabled: false, + previous: null, + related: ['https://demo.api/virtual/stall?related'], + hidden: false, + parent: 'https://demo.api/virtual/stall?parent', + spread: spread, + props: {}, + group: '', + html: html, + lang: '', + href: 'https://demo.api/virtual/stall?href', + data: await getTestData('./hapi/customer_attributes/0'), + next: null, + ns: '', + }) + ); + + expect(item).to.have.property('localName', 'vaadin-checkbox'); + expect(item).to.not.have.attribute('checked'); + + const card = item.querySelector('foxy-attribute-card')!; + + expect(card).to.have.attribute('related', '["https://demo.api/virtual/stall?related"]'); + expect(card).to.have.attribute('parent', 'https://demo.api/virtual/stall?parent'); + expect(card).to.have.attribute('infer', ''); + expect(card).to.have.attribute('href', 'https://demo.api/virtual/stall?href'); + }); - const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); - checkbox!.checked = true; - checkbox!.dispatchEvent(new CustomEvent('change')); - await control.requestUpdate(); + it(`can run a bulk action on selected items in ${layoutLabel} layout`, async () => { + const onClick = stub(); + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = (await getByTag>(control, 'foxy-collection-page'))!; + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + button?.click(); + await control.requestUpdate(); + + const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); + checkbox!.checked = true; + checkbox!.dispatchEvent(new CustomEvent('change')); + await control.requestUpdate(); + + const bulkActions = await getByTestClass(control, 'bulk-action'); + const fooAction = bulkActions[0]; + fooAction.click(); + + const customerAttribute0 = await getTestData('./hapi/customer_attributes/0'); + expect(onClick).to.have.been.calledWithMatch([customerAttribute0]); + }); - const bulkActions = await getByTestClass(control, 'bulk-action'); - const fooAction = bulkActions[0]; - fooAction.click(); - await control.requestUpdate(); + it(`communicates bulk action state to the user (positive path, no errors) in ${layoutLabel} layout`, async () => { + let next: (() => void) | null = null; + const onClick = () => new Promise(resolve => (next = resolve)); + + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = (await getByTag>(control, 'foxy-collection-page'))!; + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + button?.click(); + await control.requestUpdate(); + + const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); + checkbox!.checked = true; + checkbox!.dispatchEvent(new CustomEvent('change')); + await control.requestUpdate(); + + const bulkActions = await getByTestClass(control, 'bulk-action'); + const fooAction = bulkActions[0]; + fooAction.click(); + await control.requestUpdate(); + + expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; + expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.exist; + // @ts-expect-error it's too hard for TS to figure out that `next` is not null at this point + next?.(); + await control.requestUpdate(); + expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; + expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.not.exist; + expect(await getByKey(control, 'cancel_button_text')).to.not.exist; + expect(await getByKey(control, 'select_button_text')).to.exist; + expect(await getByTestClass(control, 'bulk-action')).to.have.length(0); + + const notification = await getByTag(control, 'vaadin-notification'); + expect(notification).to.have.attribute('theme', 'success'); + expect(notification).to.have.property('opened', true); + + const notificationRoot = document.createElement('div'); + notification?.renderer?.(notificationRoot); + + const notificationText = notificationRoot.querySelector('foxy-i18n'); + expect(notificationText).to.have.attribute('lang', 'es'); + expect(notificationText).to.have.attribute('key', 'foo_bulk_action_notification_done'); + expect(notificationText).to.have.attribute('ns', 'test pagination'); + }); - expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; - expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.exist; - // @ts-expect-error it's too hard for TS to figure out that `next` is not null at this point - next?.(); - await control.requestUpdate(); - expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.exist; - expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.not.exist; - expect(await getByKey(control, 'cancel_button_text')).to.exist; - expect(await getByKey(control, 'select_button_text')).to.not.exist; - expect(await getByTestClass(control, 'bulk-action')).to.have.length(1); - - const notification = await getByTag(control, 'vaadin-notification'); - expect(notification).to.have.attribute('theme', 'error'); - expect(notification).to.have.property('opened', true); - - const notificationRoot = document.createElement('div'); - notification?.renderer?.(notificationRoot); - - const notificationText = notificationRoot.querySelector('foxy-i18n'); - expect(notificationText).to.have.attribute('lang', 'es'); - expect(notificationText).to.have.attribute('key', 'foo_bulk_action_notification_fail'); - expect(notificationText).to.have.attribute('ns', 'test pagination'); + it(`communicates bulk action state to the user (negative path, failure) in ${layoutLabel} layout`, async () => { + let next: (() => void) | null = null; + const onClick = () => new Promise((_, reject) => (next = reject)); + + const router = createRouter(); + const control = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + const page = (await getByTag>(control, 'foxy-collection-page'))!; + await waitUntil(() => !!page?.data, '', { timeout: 5000 }); + + const label = await getByKey(control, 'select_button_text'); + const button = label?.closest('vaadin-button'); + button?.click(); + await control.requestUpdate(); + + const checkbox = control.renderRoot.querySelector('vaadin-checkbox'); + checkbox!.checked = true; + checkbox!.dispatchEvent(new CustomEvent('change')); + await control.requestUpdate(); + + const bulkActions = await getByTestClass(control, 'bulk-action'); + const fooAction = bulkActions[0]; + fooAction.click(); + await control.requestUpdate(); + + expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.not.exist; + expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.exist; + // @ts-expect-error it's too hard for TS to figure out that `next` is not null at this point + next?.(); + await control.requestUpdate(); + expect(await getByKey(control, 'foo_bulk_action_caption_idle')).to.exist; + expect(await getByKey(control, 'foo_bulk_action_caption_busy')).to.not.exist; + expect(await getByKey(control, 'cancel_button_text')).to.exist; + expect(await getByKey(control, 'select_button_text')).to.not.exist; + expect(await getByTestClass(control, 'bulk-action')).to.have.length(1); + + const notification = await getByTag(control, 'vaadin-notification'); + expect(notification).to.have.attribute('theme', 'error'); + expect(notification).to.have.property('opened', true); + + const notificationRoot = document.createElement('div'); + notification?.renderer?.(notificationRoot); + + const notificationText = notificationRoot.querySelector('foxy-i18n'); + expect(notificationText).to.have.attribute('lang', 'es'); + expect(notificationText).to.have.attribute('key', 'foo_bulk_action_notification_fail'); + expect(notificationText).to.have.attribute('ns', 'test pagination'); + }); }); }); diff --git a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts index 7e7a2a64..3c3502ba 100644 --- a/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts +++ b/src/elements/internal/InternalAsyncListControl/InternalAsyncListControl.ts @@ -1,11 +1,13 @@ import type { PropertyDeclarations, TemplateResult } from 'lit-element'; import type { CollectionPage, NucleonElement } from '../../public/index'; import type { BulkAction, SwipeAction } from './types'; +import type { InternalSummaryControl } from '../InternalSummaryControl/InternalSummaryControl'; import type { InternalConfirmDialog } from '../InternalConfirmDialog'; import type { DialogHideEvent } from '../../private/Dialog/DialogHideEvent'; import type { HALJSONResource } from '../../public/NucleonElement/types'; import type { CheckboxElement } from '@vaadin/vaadin-checkbox'; import type { ItemRenderer } from '../../public/CollectionPage/types'; +import type { UpdateEvent } from '../../public/NucleonElement/UpdateEvent'; import type { FormDialog } from '../../index'; import type { Option } from '../../public/QueryBuilder/types'; @@ -26,6 +28,7 @@ export class InternalAsyncListControl extends InternalEditableControl { createPageHref: { attribute: 'create-page-href' }, getPageHref: { attribute: false }, related: { type: Array }, + layout: {}, first: {}, limit: { type: Number }, form: {}, @@ -33,12 +36,15 @@ export class InternalAsyncListControl extends InternalEditableControl { item: {}, itemProps: { type: Object, attribute: 'item-props' }, wide: { type: Boolean }, + open: { type: Boolean }, + hideWhenEmpty: { type: Boolean, attribute: 'hide-when-empty' }, alert: { type: Boolean }, actions: { attribute: false }, bulkActions: { attribute: false }, filters: { type: Array }, - __filter: { attribute: false }, + filter: {}, __selection: { attribute: false }, + __totalItems: { attribute: false }, __isSelecting: { attribute: false }, __notification: { attribute: false }, __isFilterVisible: { attribute: false }, @@ -61,6 +67,9 @@ export class InternalAsyncListControl extends InternalEditableControl { /** Same as the `related` property of `NucleonElement`. */ related = [] as string[]; + /** When set to `details`, makes the entire control collapsible. */ + layout: 'details' | null = null; + /** Swipe actions. */ actions = [] as SwipeAction[]; @@ -88,6 +97,12 @@ export class InternalAsyncListControl extends InternalEditableControl { /** Same as the `wide` property of `FormDialog`. */ wide = false; + /** Visually hides the control when the collection is empty or loading. */ + hideWhenEmpty = false; + + /** Same as the `open` property of `InternalSummaryControl`. */ + open = false; + /** Same as the `alert` property of `FormDialog`. */ alert = false; @@ -100,6 +115,8 @@ export class InternalAsyncListControl extends InternalEditableControl { /** If set, renders list items as tags. */ getPageHref: ((itemHref: string, item: unknown) => string | null) | null = null; + filter: string | null = null; + private __deletionConfimationCallback: (() => void) | null = null; private __cachedCardRenderer: { @@ -115,15 +132,20 @@ export class InternalAsyncListControl extends InternalEditableControl { private __isSelecting = false; + private __totalItems = 0; + private __selection: HALJSONResource[] = []; private __itemRenderer: ItemRenderer = ctx => { - if (!ctx.data) return this.__cardRenderer(ctx); + const item = this.__cardRenderer(ctx); + const readonlyItem = html`
${item}
`; + + if (!ctx.data) return readonlyItem; if (this.__isSelecting) { return html`
- - ${this.label && this.label !== 'label' ? this.label : ''} - - - ${this.filters.length > 0 && !this.__isSelecting - ? html` - (this.__isFilterVisible = false)} - @search=${(evt: CustomEvent) => { - this.__filter = evt.detail ?? ''; - }} - > - - - (this.__isFilterVisible = !this.__isFilterVisible)} - > - - - ` - : ''} - ${this.bulkActions.length > 0 && this.first - ? html` - ${this.__selection.length > 0 - ? this.bulkActions.map(action => { - const nPrefix = `${action.name}_bulk_action_notification`; - const cPrefix = `${action.name}_bulk_action_caption`; - const isActive = this.__activeBulkAction?.name === action.name; - - return html` - { - this.__activeBulkAction = action; - - try { - await action.onClick(this.__selection); - this.__selection = []; - this.__isSelecting = false; - this.__notification = { key: `${nPrefix}_done`, theme: 'success' }; - } catch { - this.__notification = { key: `${nPrefix}_fail`, theme: 'error' }; - } finally { - this.__activeBulkAction = null; - this.renderRoot.querySelector('vaadin-notification')?.open(); - } - }} + ${this.layout === 'details' + ? html` + { + const summary = evt.currentTarget as InternalSummaryControl; + this.open = summary.open; + if (!evt.composed && !evt.bubbles) this.dispatchEvent(new CustomEvent('toggle')); + }} + > + ${actions.length + ? html` +
+ - - - - `; - }) + + ${actions} +
+ ` : ''} - { - this.__isSelecting = !this.__isSelecting; - this.__selection = []; - }} - > - - - - ` - : ''} - ${(!this.form && !this.createPageHref) || - this.readonly || - this.hideCreateButton || - this.__isSelecting - ? '' - : this.createPageHref && !this.disabled - ? html` -
- - - - ` - : html` - { - evt.preventDefault(); - evt.stopPropagation(); - - const dialog = this.__dialog; - const button = evt.currentTarget as HTMLButtonElement; - - dialog.header = 'header_create'; - dialog.href = ''; - dialog.show(button); - }} - > - - - `} -
- -
- ${this.helperText} -
+
${content}
+ + ` + : html` +
+ + ${this.label && this.label !== 'label' ? this.label : ''} + + ${actions} +
+ +
+ ${this.helperText} +
- - - - + ${content} + `}
0 && !this.__isSelecting + ? html` + (this.__isFilterVisible = false)} + @search=${(evt: CustomEvent) => { + this.filter = evt.detail ?? ''; + }} + > + + + (this.__isFilterVisible = !this.__isFilterVisible)} + > + + ${this.filter ? html`(${this.filter.split('&').length})` : ''} + + ` + : '', + + this.bulkActions.length > 0 && this.first + ? html` + ${this.__selection.length > 0 + ? this.bulkActions.map(action => { + const nPrefix = `${action.name}_bulk_action_notification`; + const cPrefix = `${action.name}_bulk_action_caption`; + const isActive = this.__activeBulkAction?.name === action.name; + + return html` + { + this.__activeBulkAction = action; + + try { + await action.onClick(this.__selection); + this.__selection = []; + this.__isSelecting = false; + this.__notification = { key: `${nPrefix}_done`, theme: 'success' }; + } catch { + this.__notification = { key: `${nPrefix}_fail`, theme: 'error' }; + } finally { + this.__activeBulkAction = null; + this.renderRoot.querySelector('vaadin-notification')?.open(); + } + }} + > + + + + `; + }) + : ''} + { + this.__isSelecting = !this.__isSelecting; + this.__selection = []; + }} + > + + + + ` + : '', + + (!this.form && !this.createPageHref) || + this.readonly || + this.hideCreateButton || + this.__isSelecting + ? '' + : this.createPageHref && !this.disabled + ? html` + + + + + ` + : html` + { + evt.preventDefault(); + evt.stopPropagation(); + + const dialog = this.__dialog; + const button = evt.currentTarget as HTMLButtonElement; + + dialog.header = 'header_create'; + dialog.href = ''; + dialog.show(button); + }} + > + + + `, + ].filter(v => !!v); + } + + private __renderContent() { + const helperText = this.helperText; + const isDetails = this.layout === 'details'; + const label = this.label; + + let first: string | undefined; + + try { + const url = new URL(this.first ?? ''); + const filter = new URLSearchParams(this.filter ?? ''); + + url.searchParams.set('limit', String(this.limit)); + filter.forEach((value, key) => url.searchParams.set(key, value)); + first = url.toString(); + } catch { + first = undefined; + } + + return html` + + { + const page = evt.currentTarget as CollectionPage; + this.__totalItems = parseInt(page.data?.total_items ?? 0); + }} + > + + + `; + } } diff --git a/src/elements/internal/InternalAsyncListControl/index.ts b/src/elements/internal/InternalAsyncListControl/index.ts index ea9a6131..4c97f6c3 100644 --- a/src/elements/internal/InternalAsyncListControl/index.ts +++ b/src/elements/internal/InternalAsyncListControl/index.ts @@ -3,6 +3,8 @@ import '@vaadin/vaadin-checkbox'; import '@vaadin/vaadin-overlay'; import '@vaadin/vaadin-button'; +import '../../internal/InternalSummaryControl/index'; + import '../../public/CollectionPage/index'; import '../../public/SwipeActions/index'; import '../../public/FormDialog/index'; diff --git a/src/elements/internal/InternalDateControl/InternalDateControl.ts b/src/elements/internal/InternalDateControl/InternalDateControl.ts index 9e1117c3..d9d39c56 100644 --- a/src/elements/internal/InternalDateControl/InternalDateControl.ts +++ b/src/elements/internal/InternalDateControl/InternalDateControl.ts @@ -43,7 +43,7 @@ export class InternalDateControl extends InternalEditableControl { } else if (this.format === 'iso-long') { value = serializeDate(new Date(this._value as string)); } else { - value = this._value as string; + value = ((this._value as string | null) ?? '').substring(0, 10); } } diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts index 3f5b7470..68bd59bb 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts @@ -100,8 +100,9 @@ export class InternalEditableControl extends InternalControl { } set placeholder(newValue: string) { - this.requestUpdate('placeholder', this.__placeholder); + const oldValue = this.__placeholder; this.__placeholder = newValue; + this.requestUpdate('placeholder', oldValue); } /** @@ -114,8 +115,9 @@ export class InternalEditableControl extends InternalControl { } set helperText(newValue: string) { - this.requestUpdate('helperText', this.__helperText); + const oldValue = this.__helperText; this.__helperText = newValue; + this.requestUpdate('helperText', oldValue); } /** @@ -130,8 +132,9 @@ export class InternalEditableControl extends InternalControl { } set v8nPrefix(newValue: string) { - this.requestUpdate('v8nPrefix', this.__v8nPrefix); + const oldValue = this.__v8nPrefix; this.__v8nPrefix = newValue; + this.requestUpdate('v8nPrefix', oldValue); } /** @@ -146,8 +149,9 @@ export class InternalEditableControl extends InternalControl { } set property(newValue: string) { - this.requestUpdate('property', this.__property); + const oldValue = this.__property; this.__property = newValue; + this.requestUpdate('property', oldValue); } /** @@ -159,38 +163,44 @@ export class InternalEditableControl extends InternalControl { } set label(newValue: string) { - this.requestUpdate('label', this.__label); + const oldValue = this.__label; this.__label = newValue; + this.requestUpdate('label', oldValue); } /** Restores the default placeholder translation. */ resetPlaceholder(): void { - this.requestUpdate('placeholder', this.__placeholder); + const oldValue = this.__placeholder; this.__placeholder = null; + this.requestUpdate('placeholder', oldValue); } /** Restores the default helper text translation. */ resetHelperText(): void { - this.requestUpdate('helperText', this.__helperText); + const oldValue = this.__helperText; this.__helperText = null; + this.requestUpdate('helperText', oldValue); } /** Restores the default v8n prefix for errors. */ resetV8nPrefix(): void { - this.requestUpdate('v8nPrefix', this.__v8nPrefix); + const oldValue = this.__v8nPrefix; this.__v8nPrefix = null; + this.requestUpdate('v8nPrefix', oldValue); } /** Restores the default property name. */ resetProperty(): void { - this.requestUpdate('property', this.__property); + const oldValue = this.__property; this.__property = null; + this.requestUpdate('property', oldValue); } /** Restores the default label translation. */ resetLabel(): void { - this.requestUpdate('label', this.__label); + const oldValue = this.__label; this.__label = null; + this.requestUpdate('label', oldValue); } reportValidity(): void { diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts index f0dc5251..db114914 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.test.ts @@ -9,6 +9,7 @@ import { FormDialog } from '../../public/FormDialog/FormDialog'; import { Type } from '../../public/QueryBuilder/types'; import { stub } from 'sinon'; import { createRouter } from '../../../server'; +import { getTestData } from '../../../testgen/getTestData'; async function waitForIdle(element: Control) { await waitUntil( @@ -443,12 +444,15 @@ describe('InternalResourcePickerControl', () => { }); it('renders View link in standalone layout when value is set and getItemUrl is defined', async () => { + const getItemUrl = stub().returns('https://example.com/hapi/customers/0'); + const router = createRouter(); const control = await fixture(html` value.replace('demo.api', 'example.com')} + .getItemUrl=${getItemUrl} + @fetch=${(evt: FetchEvent) => router.handleEvent(evt)} > `); @@ -458,6 +462,9 @@ describe('InternalResourcePickerControl', () => { control.getValue = () => 'https://demo.api/hapi/customers/0'; await control.requestUpdate(); + await waitForIdle(control); + await control.requestUpdate(); + linkText = control.renderRoot.querySelector('[key="view"]'); expect(linkText).to.exist; expect(linkText).to.have.attribute('infer', ''); @@ -465,6 +472,11 @@ describe('InternalResourcePickerControl', () => { const viewLink = linkText?.closest('a'); expect(viewLink).to.exist; expect(viewLink).to.have.attribute('href', 'https://example.com/hapi/customers/0'); + + const customer = await getTestData('./hapi/customers/0', router); + getItemUrl.resetHistory(); + await control.requestUpdate(); + expect(getItemUrl).to.have.been.calledWith('https://demo.api/hapi/customers/0', customer); }); it('renders Copy ID button in standalone layout when value is set and showCopyIdButton is true', async () => { diff --git a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts index 8f7ff86c..a5519198 100644 --- a/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts +++ b/src/elements/internal/InternalResourcePickerControl/InternalResourcePickerControl.ts @@ -42,7 +42,7 @@ export class InternalResourcePickerControl extends InternalEditableControl { virtualHost = uniqueId('internal-resource-picker-control-'); - getItemUrl: ((href: string) => string) | null = null; + getItemUrl: ((href: string, data: unknown | null) => string) | null = null; formProps: Record = {}; @@ -185,18 +185,22 @@ export class InternalResourcePickerControl extends InternalEditableControl { } private __renderStandaloneLayout() { - const selectionUrl = typeof this._value === 'string' ? this.getItemUrl?.(this._value) : void 0; + const valueLoader = this.__valueLoader; const selectionId = typeof this._value === 'string' ? getResourceId(this._value) : void 0; + const selectionUrl = + typeof this._value === 'string' + ? this.getItemUrl?.(this._value, valueLoader?.data ?? null) + : void 0; return html`
- ${this.label} + ${this.label} ${selectionUrl ? html`
- ${this.__getItemRenderer(this.item)({ - html, - data: null, - href: (this._value as string | undefined) || '', - related: [], - parent: '', - props: {}, - spread: spread, - simplifyNsLoading: this.simplifyNsLoading, - disabled: this.disabled, - disabledControls: this.disabledControls, - readonly: this.readonly, - readonlyControls: this.readonlyControls, - hidden: this.hidden, - hiddenControls: this.hiddenControls, - templates: this.templates, - previous: null, - next: null, - group: this.nucleon?.group ?? '', - lang: this.lang, - ns: this.ns, - })} + this.requestUpdate()} + > + ${this.__getItemRenderer(this.item)({ + html, + data: valueLoader?.data ?? null, + href: (this._value as string | undefined) || '', + related: [], + parent: '', + props: {}, + spread: spread, + simplifyNsLoading: this.simplifyNsLoading, + disabled: this.disabled, + disabledControls: this.disabledControls, + readonly: this.readonly, + readonlyControls: this.readonlyControls, + hidden: this.hidden, + hiddenControls: this.hiddenControls, + templates: this.templates, + previous: null, + next: null, + group: this.nucleon?.group ?? '', + lang: this.lang, + ns: this.ns, + })} +
@@ -321,4 +333,9 @@ export class InternalResourcePickerControl extends InternalEditableControl { }) ); } + + private get __valueLoader() { + type Loader = NucleonElement; + return this.renderRoot.querySelector('#valueLoader'); + } } diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts index fccdfb39..fcb92fc7 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts @@ -25,6 +25,11 @@ describe('InternalSummaryControl', () => { expect(new Control()).to.have.property('layout', null); }); + it('has a reactive property "count" that defaults to null', () => { + expect(Control).to.have.deep.nested.property('properties.count', { type: Number }); + expect(new Control()).to.have.property('count', null); + }); + it('has a reactive property "open" that defaults to false', () => { expect(Control).to.have.deep.nested.property('properties.open', { type: Boolean }); expect(new Control()).to.have.property('open', false); @@ -48,6 +53,19 @@ describe('InternalSummaryControl', () => { expect(control.renderRoot).to.include.text('Foo bar'); }); + it('renders count in label if .count is set and layout is "details"', async () => { + const control = await fixture(html` + + `); + + expect(control.renderRoot).to.include.text('Test'); + expect(control.renderRoot).to.not.include.text('Test (123)'); + + control.count = 123; + await control.requestUpdate(); + expect(control.renderRoot).to.include.text('Test (123)'); + }); + it('renders helper text in default layout', async () => { const control = await fixture(html` diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts index c2ee5c58..d3ca55c4 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts @@ -9,6 +9,7 @@ export class InternalSummaryControl extends InternalEditableControl { return { ...super.properties, layout: {}, + count: { type: Number }, open: { type: Boolean }, }; } @@ -35,6 +36,8 @@ export class InternalSummaryControl extends InternalEditableControl { layout: null | 'section' | 'details' = null; + count: number | null = null; + open = false; renderLightDom(): void { @@ -50,7 +53,7 @@ export class InternalSummaryControl extends InternalEditableControl { @toggle=${(evt: Event) => { const details = evt.currentTarget as HTMLDetailsElement; this.open = details.open; - if (!evt.composed) this.dispatchEvent(new CustomEvent('toggle')); + if (!evt.composed && !evt.bubbles) this.dispatchEvent(new CustomEvent('toggle')); }} > @@ -63,7 +66,9 @@ export class InternalSummaryControl extends InternalEditableControl { class="font-medium uppercase text-s tracking-wider flex items-center justify-between gap-s" ?hidden=${!this.label} > - ${this.label} + + ${this.label}${typeof this.count === 'number' ? ` (${this.count})` : ''} + { expect(customElements.get('foxy-email-template-form')).to.equal(Form); }); + it('has a reactive property "defaultSubject"', () => { + expect(new Form()).to.have.property('defaultSubject', null); + expect(Form.properties).to.have.deep.property('defaultSubject', { + attribute: 'default-subject', + }); + }); + it('extends foxy-internal-form', () => { expect(new Form()).to.be.instanceOf(customElements.get('foxy-internal-form')); }); @@ -193,6 +200,15 @@ describe('EmailTemplateForm', () => { control?.setValue(true); expect(control?.getValue()).to.be.true; expect(form.form.subject).to.equal('general.subject.default_value'); + + control?.setValue(false); + expect(control?.getValue()).to.be.false; + expect(form.form.subject).to.equal(''); + + form.defaultSubject = 'Receipt ({{ order_id }})'; + control?.setValue(true); + expect(control?.getValue()).to.be.true; + expect(form.form.subject).to.equal('Receipt ({{ order_id }})'); }); it('renders a text control for Subject in General summary', async () => { diff --git a/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts b/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts index c529ed79..a1cdeda9 100644 --- a/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts +++ b/src/elements/public/EmailTemplateForm/EmailTemplateForm.ts @@ -1,4 +1,4 @@ -import type { TemplateResult } from 'lit-element'; +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; @@ -17,6 +17,16 @@ const Base = TranslatableMixin(InternalForm, NS); * @since 1.14.0 */ export class EmailTemplateForm extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultSubject: { attribute: 'default-subject' }, + }; + } + + /** Default email subject. Use this instead of i18next key when you need to use handlebars syntax. */ + defaultSubject: string | null = null; + private readonly __templateLanguageOptions = [ { rawLabel: 'Nunjucks', value: 'nunjucks' }, { rawLabel: 'Handlebars', value: 'handlebars' }, @@ -28,7 +38,11 @@ export class EmailTemplateForm extends Base { private readonly __toggleGetValue = () => !!this.form.subject; private readonly __toggleSetValue = (newValue: boolean) => { - this.edit({ subject: newValue ? this.t('general.subject.default_value') : '' }); + if (newValue) { + this.edit({ subject: this.defaultSubject ?? this.t('general.subject.default_value') }); + } else { + this.edit({ subject: '' }); + } }; get readonlySelector(): BooleanSelector { diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts index 478c86b5..6774bd47 100644 --- a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.test.ts @@ -872,6 +872,7 @@ describe('ExperimentalAddToCartBuilder', () => { ); expect(control).to.exist; + expect(control).to.have.attribute('layout', 'details'); expect(control).to.have.attribute( 'first', `foxy://${element.nucleon?.virtualHost}/form/items/0/custom_options` diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts index 08b7e163..08d1f190 100644 --- a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts @@ -351,6 +351,7 @@ export class InternalExperimentalAddToCartBuilderItemControl extends InternalCon { expect(customElements.get('foxy-customer-card')).to.exist; }); + it('imports and defines foxy-item-card', () => { + expect(customElements.get('foxy-item-card')).to.exist; + }); + + it('imports and defines foxy-nucleon', () => { + expect(customElements.get('foxy-nucleon')).to.exist; + }); + it('imports and defines itself as foxy-gift-card-code-form', () => { expect(customElements.get('foxy-gift-card-code-form')).to.equal(GiftCardCodeForm); }); @@ -60,6 +69,13 @@ describe('GiftCardCodeForm', () => { expect(new GiftCardCodeForm()).to.have.property('ns', 'gift-card-code-form'); }); + it('has a reactive property "getTransactionPageHref"', () => { + expect(new GiftCardCodeForm()).to.have.property('getTransactionPageHref', null); + expect(GiftCardCodeForm).to.have.deep.nested.property('properties.getTransactionPageHref', { + attribute: false, + }); + }); + it('has a reactive property "getCustomerHref"', () => { expect(GiftCardCodeForm).to.have.deep.nested.property('properties.getCustomerHref', { attribute: false, @@ -169,6 +185,7 @@ describe('GiftCardCodeForm', () => { ); expect(control).to.exist; + expect(control).to.have.attribute('format', 'iso-long'); }); it('renders a resource picker control for associated customer', async () => { @@ -225,10 +242,15 @@ describe('GiftCardCodeForm', () => { it('renders a resource picker control for associated cart item', async () => { const router = createRouter(); + const getTransactionPageHref = (href: string) => { + return `https://example.com/transactions/${getResourceId(href)}`; + }; + const element = await fixture( html` router.handleEvent(evt)} > @@ -243,6 +265,9 @@ describe('GiftCardCodeForm', () => { 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'); + expect( + control?.getItemUrl?.('https://demo.api/hapi/items/0', await getTestData('./hapi/items/0')) + ).to.equal('https://example.com/transactions/0'); }); 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 7589f6dc..e62bc77f 100644 --- a/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts +++ b/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts @@ -1,5 +1,5 @@ import type { PropertyDeclarations } from 'lit-element'; -import type { Data } from './types'; +import type { Data, TransactionPageHrefGetter } from './types'; import type { TemplateResult } from 'lit-html'; import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { NucleonV8N } from '../NucleonElement/types'; @@ -26,6 +26,7 @@ export class GiftCardCodeForm extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + getTransactionPageHref: { attribute: false }, getCustomerHref: { attribute: false }, }; } @@ -39,11 +40,27 @@ export class GiftCardCodeForm extends Base { ]; } + /** When set, the Cart Item section will display a link to transaction. */ + getTransactionPageHref: TransactionPageHrefGetter | null = null; + /** Returns a `fx:customer` Resource URL for a Customer ID. */ getCustomerHref: (id: number | string) => string = id => { return `https://api.foxycart.com/customers/${id}`; }; + private readonly __cartItemGetItemUrl = (_: string, data: Resource | null) => { + let itemUrl: string | undefined | null = null; + + try { + const transactionUrl = data?._links['fx:transaction'].href; + if (transactionUrl) itemUrl = this.getTransactionPageHref?.(transactionUrl); + } catch (err) { + console.log(err); + } + + return itemUrl ?? null; + }; + private readonly __customerGetValue = () => { const link = this.data?._links?.['fx:customer']?.href; const id = this.form.customer_id; @@ -108,7 +125,7 @@ export class GiftCardCodeForm extends Base { - + @@ -125,6 +142,7 @@ export class GiftCardCodeForm extends Base { href} > diff --git a/src/elements/public/GiftCardCodeForm/index.ts b/src/elements/public/GiftCardCodeForm/index.ts index e576b4de..1d8038ef 100644 --- a/src/elements/public/GiftCardCodeForm/index.ts +++ b/src/elements/public/GiftCardCodeForm/index.ts @@ -7,7 +7,9 @@ import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; import '../GiftCardCodeLogCard/index'; +import '../NucleonElement/index'; import '../CustomerCard/index'; +import '../ItemCard/index'; import { GiftCardCodeForm } from './GiftCardCodeForm'; diff --git a/src/elements/public/GiftCardCodeForm/types.ts b/src/elements/public/GiftCardCodeForm/types.ts index 61c2376c..4a7335f8 100644 --- a/src/elements/public/GiftCardCodeForm/types.ts +++ b/src/elements/public/GiftCardCodeForm/types.ts @@ -1,6 +1,8 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; +export type TransactionPageHrefGetter = (href: string) => string | null; + export type Data = Resource & { customer_id?: number | string; }; diff --git a/src/elements/public/GiftCardForm/GiftCardForm.test.ts b/src/elements/public/GiftCardForm/GiftCardForm.test.ts index 82e5d1e8..8766203e 100644 --- a/src/elements/public/GiftCardForm/GiftCardForm.test.ts +++ b/src/elements/public/GiftCardForm/GiftCardForm.test.ts @@ -110,6 +110,13 @@ describe('GiftCardForm', () => { expect(new GiftCardForm()).to.have.property('ns', 'gift-card-form'); }); + it('has a reactive property "getTransactionPageHref"', () => { + expect(new GiftCardForm()).to.have.property('getTransactionPageHref', null); + expect(GiftCardForm).to.have.deep.nested.property('properties.getTransactionPageHref', { + attribute: false, + }); + }); + it('has a reactive property "getCustomerHref"', () => { expect(GiftCardForm).to.have.deep.nested.property('properties.getCustomerHref', { attribute: false, @@ -121,6 +128,13 @@ describe('GiftCardForm', () => { ); }); + it('has a reactive property "codesFilter"', () => { + expect(new GiftCardForm()).to.have.property('codesFilter', null); + expect(GiftCardForm).to.have.deep.nested.property('properties.codesFilter', { + attribute: 'codes-filter', + }); + }); + it('extends foxy-internal-form', () => { expect(new GiftCardForm()).to.be.instanceOf(customElements.get('foxy-internal-form')); }); @@ -422,7 +436,11 @@ describe('GiftCardForm', () => { const writeTextMethod = stub(navigator.clipboard, 'writeText').resolves(); const element = await fixture(html` - + + `); const control = element.renderRoot.querySelector( @@ -435,11 +453,13 @@ describe('GiftCardForm', () => { 'https://demo.api/hapi/gift_card_codes?gift_card_id=0&order=date_created+desc' ); + expect(control).to.have.attribute('filter', 'code=abc123'); expect(control).to.have.attribute('limit', '5'); expect(control).to.have.attribute('item', 'foxy-gift-card-code-card'); expect(control).to.have.attribute('form', 'foxy-gift-card-code-form'); expect(control).to.have.attribute('alert'); expect(control).to.have.deep.property('formProps', { + '.getTransactionPageHref': element.getTransactionPageHref, '.getCustomerHref': element.getCustomerHref, }); diff --git a/src/elements/public/GiftCardForm/GiftCardForm.ts b/src/elements/public/GiftCardForm/GiftCardForm.ts index 85eb9def..df44e52b 100644 --- a/src/elements/public/GiftCardForm/GiftCardForm.ts +++ b/src/elements/public/GiftCardForm/GiftCardForm.ts @@ -1,4 +1,5 @@ import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { TransactionPageHrefGetter } from '../GiftCardCodeForm/types'; import type { NucleonElement } from '../NucleonElement/NucleonElement'; import type { SwipeAction } from '../../internal/InternalAsyncListControl/types'; import type { NucleonV8N } from '../NucleonElement/types'; @@ -30,7 +31,9 @@ export class GiftCardForm extends Base { static get properties(): PropertyDeclarations { return { ...super.properties, + getTransactionPageHref: { attribute: false }, getCustomerHref: { attribute: false }, + codesFilter: { attribute: 'codes-filter' }, }; } @@ -64,11 +67,17 @@ export class GiftCardForm extends Base { ]; } + /** When set, the Cart Item section in Gift Card Code form will display a link to transaction. */ + getTransactionPageHref: TransactionPageHrefGetter | null = null; + /** Returns a `fx:customer` Resource URL for a Customer ID. */ getCustomerHref: (id: number | string) => string = id => { return `https://api.foxycart.com/customers/${id}`; }; + /** When set, will apply as default filter in Codes section. */ + codesFilter: string | null = null; + private readonly __provisioningMaxBalanceValueGetter = () => { return this.form.provisioning_config?.initial_balance_max; }; @@ -290,15 +299,19 @@ export class GiftCardForm extends Base { diff --git a/src/elements/public/ItemCategoryForm/ItemCategoryForm.test.ts b/src/elements/public/ItemCategoryForm/ItemCategoryForm.test.ts index 0356201e..5cf5234c 100644 --- a/src/elements/public/ItemCategoryForm/ItemCategoryForm.test.ts +++ b/src/elements/public/ItemCategoryForm/ItemCategoryForm.test.ts @@ -680,7 +680,7 @@ describe('ItemCategoryForm', () => { expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ { label: 'option_per_order', value: 'per_order' }, - { label: 'option_per_shipment', value: 'per_shipment' }, + { label: 'option_per_item', value: 'per_item' }, ]); }); diff --git a/src/elements/public/ItemCategoryForm/ItemCategoryForm.ts b/src/elements/public/ItemCategoryForm/ItemCategoryForm.ts index aa42868f..552ee077 100644 --- a/src/elements/public/ItemCategoryForm/ItemCategoryForm.ts +++ b/src/elements/public/ItemCategoryForm/ItemCategoryForm.ts @@ -135,7 +135,7 @@ export class ItemCategoryForm extends TranslatableMixin(InternalForm, 'item-cate private static __shippingFlatRateTypeOptions = [ { label: 'option_per_order', value: 'per_order' }, - { label: 'option_per_shipment', value: 'per_shipment' }, + { label: 'option_per_item', value: 'per_item' }, ]; private static __defaultWeightUnitOptions = [ diff --git a/src/elements/public/PaymentsApi/PaymentsApi.ts b/src/elements/public/PaymentsApi/PaymentsApi.ts index 2f6de982..ccb9d15d 100644 --- a/src/elements/public/PaymentsApi/PaymentsApi.ts +++ b/src/elements/public/PaymentsApi/PaymentsApi.ts @@ -172,7 +172,7 @@ export class PaymentsApi extends LitElement { paymentGatewaysUrl, getPaymentMethodSetHostedPaymentGatewayUrl: createGetter( - paymentMethodSetHostedPaymentGatewaysUrl + paymentMethodSetHostedPaymentGatewayBaseUrl ), getHostedPaymentGatewayUrl: createGetter(hostedPaymentGatewayBaseUrl), diff --git a/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.test.ts b/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.test.ts index 793b21bc..29d42f0c 100644 --- a/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.test.ts +++ b/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.test.ts @@ -232,5 +232,44 @@ describe('Transaction', () => { expect(link).to.exist; expect(link).to.have.property('href', 'https://example.com/test'); }); + + it('renders View Receipt action if transaction has fx:receipt link', async () => { + const router = createRouter(); + const wrapper = await fixture(html` + router.handleEvent(evt)} + > + + + + `); + + await waitUntil(() => wrapper.in({ idle: 'snapshot' })); + const control = wrapper.firstElementChild as InternalTransactionActionsControl; + + unset(wrapper, 'data._links["fx:receipt"]'); + wrapper.data = { ...wrapper.data! }; + await wrapper.requestUpdate(); + await control.requestUpdate(); + + expect(control.renderRoot.querySelector('[infer="receipt"]')).to.not.exist; + + set(wrapper, 'data._links["fx:receipt"]', { href: 'https://example.com/receipt' }); + wrapper.data = { ...wrapper.data! }; + await wrapper.requestUpdate(); + await control.requestUpdate(); + + const action = control.renderRoot.querySelector('[infer="receipt"]'); + + expect(action).to.exist; + expect(action).to.have.property('localName', 'foxy-i18n'); + expect(action).to.have.property('key', 'caption'); + + const link = action?.closest('a'); + expect(link).to.exist; + expect(link).to.have.attribute('href', 'https://example.com/receipt'); + expect(link).to.have.attribute('target', '_blank'); + }); }); }); diff --git a/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.ts b/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.ts index aa27de1f..ec656f94 100644 --- a/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.ts +++ b/src/elements/public/Transaction/internal/InternalTransactionActionsControl/InternalTransactionActionsControl.ts @@ -12,6 +12,7 @@ export class InternalTransactionActionsControl extends InternalControl { ${this.nucleon?.data?._links['fx:refund'] ? this.__renderRefundAction() : ''} ${this.nucleon?.data?._links['fx:send_emails'] ? this.__renderSendEmailsAction() : ''} ${this.nucleon?.data?._links['fx:subscription'] ? this.__renderSubscriptionAction() : ''} + ${this.nucleon?.data?._links['fx:receipt'] ? this.__renderReceiptAction() : ''}
`; } @@ -76,4 +77,18 @@ export class InternalTransactionActionsControl extends InternalControl { `; } + + private __renderReceiptAction() { + const host = this.nucleon as Transaction | null; + + return html` + + + + `; + } } diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts index c9740625..d14950db 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.test.ts @@ -82,8 +82,9 @@ describe('UserInvitationForm', () => { expect(form.hiddenSelector.matches('undo', true)).to.be.true; }); - it('hides Delete button when status is not "rejected" or "expired" (in admin layout)', async () => { + it('hides Delete button when status is not "revoked" or "expired" (in admin layout)', async () => { const form = new Form(); + form.layout = 'admin'; expect(form.hiddenSelector.matches('delete', true)).to.be.true; const data = await getTestData('./hapi/user_invitations/0'); @@ -93,14 +94,19 @@ describe('UserInvitationForm', () => { data.status = 'rejected'; form.data = { ...data }; - expect(form.hiddenSelector.matches('delete', true)).to.be.false; + expect(form.hiddenSelector.matches('delete', true)).to.be.true; data.status = 'expired'; form.data = { ...data }; - expect(form.hiddenSelector.matches('delete', true)).to.be.true; + expect(form.hiddenSelector.matches('delete', true)).to.be.false; - form.layout = 'admin'; + data.status = 'revoked'; + form.data = { ...data }; expect(form.hiddenSelector.matches('delete', true)).to.be.false; + + data.status = 'accepted'; + form.data = { ...data }; + expect(form.hiddenSelector.matches('delete', true)).to.be.true; }); it('hides Leave button when status is not "accepted"', async () => { @@ -135,18 +141,19 @@ describe('UserInvitationForm', () => { expect(form.hiddenSelector.matches('revoke', true)).to.be.true; }); - it('hides Resend button when status is not "revoked" or "sent" or "expired" (in admin layout)', async () => { + it('hides Resend button when status is not "sent" (in admin layout)', async () => { const form = new Form(); expect(form.hiddenSelector.matches('resend', true)).to.be.true; const data = await getTestData('./hapi/user_invitations/0'); data.status = 'accepted'; + form.layout = 'admin'; form.data = { ...data }; expect(form.hiddenSelector.matches('resend', true)).to.be.true; data.status = 'revoked'; form.data = { ...data }; - expect(form.hiddenSelector.matches('resend', true)).to.be.false; + expect(form.hiddenSelector.matches('resend', true)).to.be.true; data.status = 'sent'; form.data = { ...data }; @@ -155,27 +162,21 @@ describe('UserInvitationForm', () => { 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 () => { + it('hides 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; }); diff --git a/src/elements/public/UserInvitationForm/UserInvitationForm.ts b/src/elements/public/UserInvitationForm/UserInvitationForm.ts index 298c7cc5..bfe862b0 100644 --- a/src/elements/public/UserInvitationForm/UserInvitationForm.ts +++ b/src/elements/public/UserInvitationForm/UserInvitationForm.ts @@ -87,12 +87,13 @@ export class UserInvitationForm extends Base { if ( (status !== 'rejected' || layout !== 'user') && - (status !== 'expired' || layout !== 'admin') + (status !== 'expired' || layout !== 'admin') && + (status !== 'revoked' || layout !== 'admin') ) { alwaysMatch.unshift('delete'); } - if (status !== 'sent' && status !== 'revoked' && (status !== 'expired' || layout !== 'admin')) { + if (status !== 'sent' || layout !== 'admin') { alwaysMatch.unshift('resend'); } diff --git a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts index 60d98cc5..a046dc03 100644 --- a/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts +++ b/src/elements/public/UserInvitationForm/internal/InternalUserInvitationFormAsyncAction/InternalUserInvitationFormAsyncAction.ts @@ -46,8 +46,14 @@ export class InternalUserInvitationFormAsyncAction extends InternalControl { const api = new NucleonElement.API(this); const response = await api.fetch(this.href ?? '', { method: 'POST' }); - this.__state = response.ok ? 'idle' : 'fail'; - if (response.ok) this.nucleon?.refresh(); + if (response.ok) { + // if we refresh right away, sometimes we get an old response from cache + await new Promise(resolve => setTimeout(resolve, 1000)); + this.nucleon?.refresh(); + this.__state = 'idle'; + } else { + this.__state = 'fail'; + } } catch { this.__state = 'fail'; } diff --git a/src/elements/public/WebhookForm/WebhookForm.test.ts b/src/elements/public/WebhookForm/WebhookForm.test.ts index ad62b4f2..8dcfce6e 100644 --- a/src/elements/public/WebhookForm/WebhookForm.test.ts +++ b/src/elements/public/WebhookForm/WebhookForm.test.ts @@ -5,7 +5,6 @@ import type { Data } from './types'; import { InternalAsyncListControl } from '../../internal/InternalAsyncListControl/InternalAsyncListControl'; import { InternalPasswordControl } from '../../internal/InternalPasswordControl/InternalPasswordControl'; import { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; -import { InternalSourceControl } from '../../internal/InternalSourceControl/InternalSourceControl'; import { expect, fixture, html } from '@open-wc/testing'; import { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; @@ -26,10 +25,6 @@ describe('WebhookForm', () => { expect(customElements.get('foxy-internal-select-control')).to.exist; }); - it('imports and defines foxy-internal-source-control element', () => { - expect(customElements.get('foxy-internal-source-control')).to.exist; - }); - it('imports and defines foxy-internal-text-control element', () => { expect(customElements.get('foxy-internal-text-control')).to.exist; }); @@ -164,20 +159,22 @@ describe('WebhookForm', () => { expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders webhook query as source control', async () => { + it('renders webhook query as text control inside of the General summary', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="query"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="query"]'); expect(control).to.exist; - expect(control).to.be.instanceOf(InternalSourceControl); + expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders webhook url as source control', async () => { + it('renders webhook url as text control inside of the General summary', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="url"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="url"]'); expect(control).to.exist; - expect(control).to.be.instanceOf(InternalSourceControl); + expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.have.attribute('layout', 'summary-item'); }); it('renders webhook encryption key as password control inside of the General summary', async () => { diff --git a/src/elements/public/WebhookForm/WebhookForm.ts b/src/elements/public/WebhookForm/WebhookForm.ts index 2c27337e..4685f3f4 100644 --- a/src/elements/public/WebhookForm/WebhookForm.ts +++ b/src/elements/public/WebhookForm/WebhookForm.ts @@ -123,10 +123,12 @@ export class WebhookForm extends TranslatableMixin(InternalForm, 'webhook-form') .generatorOptions=${this.__encryptionKeyGeneratorOptions} > - - - + + + + +