diff --git a/custom-elements.json b/custom-elements.json index 5169c221..fc5cd2d2 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -23212,8 +23212,7 @@ "type": "array" }, { - "name": "value", - "description": "Current value as hAPI filter string." + "name": "value" }, { "name": "mode", @@ -23328,8 +23327,7 @@ }, { "name": "value", - "attribute": "value", - "description": "Current value as hAPI filter string." + "attribute": "value" }, { "name": "templates", @@ -23422,6 +23420,21 @@ "path": "./src/elements/public/ReportForm/index.ts", "description": "Form element for creating or editing reports (`fx:report`).", "attributes": [ + { + "name": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, { "name": "mode", "type": "string", @@ -23454,16 +23467,6 @@ "name": "hiddencontrols", "default": "\"False\"" }, - { - "name": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, { "name": "lang", "description": "Optional ISO 639-1 code describing the language element content is written in.\nChanging the `lang` attribute will update the value of this property.", @@ -23504,6 +23507,65 @@ } ], "properties": [ + { + "name": "simplifyNsLoading", + "attribute": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "attribute": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "t", + "type": "Translator", + "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" + }, + { + "name": "generalErrorPrefix", + "description": "Validation errors with this prefix will show up at the top of the form.", + "type": "string", + "default": "\"error:\"" + }, + { + "name": "status", + "attribute": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, + { + "name": "headerTitleKey", + "description": "Getter that returns a i18n key for the optional form header title.", + "type": "string" + }, + { + "name": "headerTitleOptions", + "description": "I18next options to pass to the header title translation function.", + "type": "Record" + }, + { + "name": "headerSubtitleKey", + "description": "Getter that returns a i18n key for the optional form header subtitle. Note that subtitle is shown only when data is avaiable.", + "type": "string" + }, + { + "name": "headerSubtitleOptions", + "description": "I18next options to pass to the header subtitle translation function. Note that subtitle is shown only when data is avaiable.", + "type": "Record" + }, + { + "name": "headerSubtitleBadges", + "description": "Getter that returns a list of the optional badges to put into the subtitle. The badges are shown only if subtitle is visible.", + "type": "Badge[]" + }, + { + "name": "headerCopyIdValue", + "description": "ID that will be written to clipboard when Copy ID button in header is clicked.", + "type": "string | number" + }, { "name": "templates", "default": "{}" @@ -23559,23 +23621,6 @@ "name": "hiddenSelector", "type": "BooleanSelector" }, - { - "name": "simplifyNsLoading", - "attribute": "simplify-ns-loading", - "type": "boolean", - "default": "false" - }, - { - "name": "ns", - "attribute": "ns", - "type": "string", - "default": "\"defaultNS\"" - }, - { - "name": "t", - "type": "Translator", - "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" - }, { "name": "UpdateEvent", "description": "Instances of this event are dispatched on an element whenever it changes its\nstate (e.g. when going from `busy` to `idle` or on `form` data change).\nThis event isn't cancelable, and it does not bubble.", @@ -32053,6 +32098,10 @@ "name": "default-domain", "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." }, + { + "name": "current-user", + "description": "Currently logged in user resource URL. Used to display a warning when revoking access." + }, { "name": "layout", "description": "Admin layout will display user info, user layout (default) will display store info." @@ -32153,6 +32202,11 @@ "attribute": "default-domain", "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." }, + { + "name": "currentUser", + "attribute": "current-user", + "description": "Currently logged in user resource URL. Used to display a warning when revoking access." + }, { "name": "layout", "attribute": "layout", @@ -32357,6 +32411,9 @@ } ], "events": [ + { + "name": "selfrevoked" + }, { "name": "update", "description": "Instance of `NucleonElement.UpdateEvent`. Dispatched on an element whenever it changes its state." diff --git a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts new file mode 100644 index 00000000..d52a3add --- /dev/null +++ b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts @@ -0,0 +1,345 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit-html'; + +import { InternalNativeDateControl as Control } from './index'; +import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; +import { NucleonElement } from '../../public/NucleonElement/index'; +import { stub } from 'sinon'; + +class TestControl extends Control { + static get properties() { + return { + ...super.properties, + testCheckValidity: { attribute: false }, + testErrorMessage: { attribute: false }, + testValue: { attribute: false }, + }; + } + + testCheckValidity = () => true; + + testErrorMessage = ''; + + testValue = ''; + + nucleon = new NucleonElement(); + + protected get _checkValidity() { + return this.testCheckValidity; + } + + protected get _errorMessage() { + return this.testErrorMessage; + } + + protected get _value() { + return this.testValue; + } + + protected set _value(newValue: string) { + this.testValue = newValue; + super._value = newValue; + } +} + +customElements.define('test-internal-native-date-control', TestControl); + +describe('InternalNativeDateControl', () => { + it('imports and defines foxy-internal-editable-control', () => { + expect(customElements.get('foxy-internal-editable-control')).to.equal(InternalEditableControl); + }); + + it('imports and defines itself as foxy-internal-native-date-control', () => { + expect(customElements.get('foxy-internal-native-date-control')).to.equal(Control); + }); + + it('extends InternalEditableControl', () => { + expect(new Control()).to.be.instanceOf(InternalEditableControl); + }); + + it('defines a reactive property for "format" (String, default "date")', () => { + expect(Control).to.have.deep.nested.property('properties.format', {}); + expect(new Control()).to.have.property('format', 'date'); + }); + + it('renders a date input element', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input'); + expect(input).to.not.be.null; + expect(input).to.have.attribute('type', 'date'); + }); + + it('renders a datetime-local input when format is "datetime-local"', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input'); + expect(input).to.not.be.null; + expect(input).to.have.attribute('type', 'datetime-local'); + }); + + it('renders label', async () => { + const control = await fixture(html` + + `); + + expect(control.renderRoot).to.include.text('label'); + + control.label = 'Foo bar'; + await control.requestUpdate(); + + expect(control.renderRoot).to.not.include.text('label'); + expect(control.renderRoot).to.include.text('Foo bar'); + }); + + 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('renders error text after reportValidity() is called', async () => { + const control = await fixture(html` + + `); + + expect(control.renderRoot).to.not.include.text('Test error message'); + + control.testErrorMessage = 'Test error message'; + await control.requestUpdate(); + + // Error message should still be hidden until reportValidity() is called + const errorElement = control.renderRoot.querySelector('.text-error'); + expect(errorElement).to.have.attribute('hidden'); + + control.reportValidity(); + await control.requestUpdate(); + + expect(errorElement).to.not.have.attribute('hidden'); + expect(control.renderRoot).to.include.text('Test error message'); + }); + + it('shows error text after input blur', async () => { + const control = await fixture(html` + + `); + + control.testErrorMessage = 'Test error message'; + await control.requestUpdate(); + + const errorElement = control.renderRoot.querySelector('.text-error'); + expect(errorElement).to.have.attribute('hidden'); + + const input = control.renderRoot.querySelector('input')!; + input.dispatchEvent(new Event('blur')); + await control.requestUpdate(); + + expect(errorElement).to.not.have.attribute('hidden'); + expect(control.renderRoot).to.include.text('Test error message'); + }); + + it('hides error text when disabled', async () => { + const control = await fixture(html` + + `); + + control.testErrorMessage = 'Test error message'; + control.reportValidity(); + control.disabled = true; + await control.requestUpdate(); + + const errorElement = control.renderRoot.querySelector('.text-error'); + expect(errorElement).to.have.attribute('hidden'); + }); + + it('hides error text when readonly', async () => { + const control = await fixture(html` + + `); + + control.testErrorMessage = 'Test error message'; + control.reportValidity(); + control.readonly = true; + await control.requestUpdate(); + + const errorElement = control.renderRoot.querySelector('.text-error'); + expect(errorElement).to.have.attribute('hidden'); + }); + + it('sets "disabled" on input from "disabled" on itself', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + expect(input).to.have.property('disabled', false); + + control.disabled = true; + await control.requestUpdate(); + + expect(input).to.have.property('disabled', true); + + control.disabled = false; + await control.requestUpdate(); + + expect(input).to.have.property('disabled', false); + }); + + it('sets "readonly" on input from "readonly" on itself', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + expect(input).to.have.property('readOnly', false); + + control.readonly = true; + await control.requestUpdate(); + + expect(input).to.have.property('readOnly', true); + + control.readonly = false; + await control.requestUpdate(); + + expect(input).to.have.property('readOnly', false); + }); + + it('sets "placeholder" on input from "placeholder" on itself', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + expect(input).to.have.property('placeholder', 'placeholder'); + + control.placeholder = 'Test placeholder'; + await control.requestUpdate(); + + expect(input).to.have.property('placeholder', 'Test placeholder'); + }); + + it('sets "value" on input from "_value" on itself', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + expect(input).to.have.property('value', ''); + + control.testValue = '2023-01-15'; + await control.requestUpdate(); + + expect(input).to.have.property('value', '2023-01-15'); + }); + + it('writes to "_value" on input', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + expect(input).to.have.property('value', ''); + + input.value = '2023-01-15'; + input.dispatchEvent(new CustomEvent('input')); + + expect(control).to.have.property('testValue', '2023-01-15'); + }); + + it('submits the host nucleon form on Enter', async () => { + const control = await fixture(html` + + `); + + const input = control.renderRoot.querySelector('input')!; + const submitMethod = stub(control.nucleon, 'submit'); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(submitMethod).to.have.been.calledOnce; + + submitMethod.restore(); + }); + + it('renders a clear button', async () => { + const control = await fixture(html` + + `); + + control.testValue = '2023-01-15'; + await control.requestUpdate(); + + const button = control.renderRoot.querySelector('button'); + expect(button).to.not.be.null; + expect(button).to.have.attribute('aria-label', 'clear'); + }); + + it('clears the value and dispatches "clear" event when clear button is clicked', async () => { + const control = await fixture(html` + + `); + + control.testValue = '2023-01-15'; + await control.requestUpdate(); + + const button = control.renderRoot.querySelector('button')!; + const clearHandler = stub(); + control.addEventListener('clear', clearHandler); + + button.click(); + + expect(control).to.have.property('testValue', ''); + expect(clearHandler).to.have.been.calledOnce; + }); + + it('hides clear button when readonly', async () => { + const control = await fixture(html` + + `); + + control.testValue = '2023-01-15'; + control.readonly = true; + await control.requestUpdate(); + + const button = control.renderRoot.querySelector('button'); + expect(button).to.have.attribute('hidden'); + }); + + it('hides clear button when value is empty', async () => { + const control = await fixture(html` + + `); + + control.testValue = ''; + await control.requestUpdate(); + + const button = control.renderRoot.querySelector('button'); + expect(button).to.have.attribute('hidden'); + }); + + it('disables clear button when control is disabled', async () => { + const control = await fixture(html` + + `); + + control.testValue = '2023-01-15'; + control.disabled = true; + await control.requestUpdate(); + + const button = control.renderRoot.querySelector('button'); + expect(button).to.have.property('disabled', true); + }); +}); diff --git a/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts new file mode 100644 index 00000000..0b1bf4dd --- /dev/null +++ b/src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts @@ -0,0 +1,115 @@ +import type { CSSResultArray, PropertyDeclarations, TemplateResult } from 'lit-element'; + +import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; +import { html, css, svg } from 'lit-element'; +import { classMap } from '../../../utils/class-map'; +import { live } from 'lit-html/directives/live'; + +/** + * Internal control displaying a basic text box with date input. + * + * @since 1.51.0 + * @element foxy-internal-native-date-control + */ +export class InternalNativeDateControl extends InternalEditableControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + format: {}, + __isErrorVisible: { attribute: false }, + }; + } + + static get styles(): CSSResultArray { + return [ + super.styles, + css` + input::-webkit-contacts-auto-fill-button { + visibility: hidden; + display: none !important; + pointer-events: none; + position: absolute; + right: 0; + } + `, + ]; + } + + format: 'date' | 'datetime-local' = 'date'; + + private __isErrorVisible = false; + + reportValidity(): void { + this.__isErrorVisible = true; + super.reportValidity(); + } + + renderControl(): TemplateResult { + return html` +
+
+ + + evt.key === 'Enter' && this.nucleon?.submit()} + @blur=${() => (this.__isErrorVisible = true)} + @input=${(evt: Event) => { + evt.stopPropagation(); + this._value = (evt.target as HTMLInputElement).value; + }} + /> + + +
+ +
+

${this.helperText}

+

+ ${this._errorMessage} +

+
+
+ `; + } + + protected get _value(): string { + return (super._value as string | undefined) ?? ''; + } + + protected set _value(newValue: string) { + super._value = newValue as unknown | undefined; + } +} diff --git a/src/elements/internal/InternalNativeDateControl/index.ts b/src/elements/internal/InternalNativeDateControl/index.ts new file mode 100644 index 00000000..eec420d9 --- /dev/null +++ b/src/elements/internal/InternalNativeDateControl/index.ts @@ -0,0 +1,6 @@ +import '../InternalEditableControl/index'; +import { InternalNativeDateControl as Control } from './InternalNativeDateControl'; + +customElements.define('foxy-internal-native-date-control', Control); + +export { Control as InternalNativeDateControl }; diff --git a/src/elements/public/ReportForm/ReportForm.stories.ts b/src/elements/public/ReportForm/ReportForm.stories.ts index cd1a587f..dce6f9c4 100644 --- a/src/elements/public/ReportForm/ReportForm.stories.ts +++ b/src/elements/public/ReportForm/ReportForm.stories.ts @@ -11,9 +11,9 @@ const summary: Summary = { localName: 'foxy-report-form', translatable: true, configurable: { - inputs: ['name', 'range'], - buttons: ['delete', 'create'], - sections: ['timestamps'], + sections: ['timestamps', 'header'], + buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], + inputs: ['name', 'range', 'datetime-start', 'datetime-end', 'datetime-precise'], }, }; diff --git a/src/elements/public/ReportForm/ReportForm.test.ts b/src/elements/public/ReportForm/ReportForm.test.ts index 350b3f3b..61b076f6 100644 --- a/src/elements/public/ReportForm/ReportForm.test.ts +++ b/src/elements/public/ReportForm/ReportForm.test.ts @@ -1,1028 +1,306 @@ -import type { FetchEvent } from '../NucleonElement/FetchEvent'; - -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; - -import { - getCurrentMonth, - getCurrentQuarter, - getCurrentYear, - getLast30Days, - getLast365Days, - getPreviousMonth, - getPreviousQuarter, - getPreviousYear, - toAPIDateTime, -} from './utils'; - -import { ButtonElement } from '@vaadin/vaadin-button'; -import { CheckboxElement } from '@vaadin/vaadin-checkbox'; -import { Choice } from '../../private/Choice/Choice'; -import { ChoiceChangeEvent } from '../../private/Choice/ChoiceChangeEvent'; -import { Data } from './types'; -import { DatePickerElement } from '@vaadin/vaadin-date-picker'; -import { DateTimePicker } from '@vaadin/vaadin-date-time-picker'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { InternalSandbox } from '../../internal/InternalSandbox/InternalSandbox'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { ReportForm } from './index'; -import { SelectElement } from '@vaadin/vaadin-select'; -import { createRouter } from '../../../server/index'; -import { getByKey } from '../../../testgen/getByKey'; -import { getByName } from '../../../testgen/getByName'; -import { getByTestId } from '../../../testgen/getByTestId'; +import './index'; + +import { expect, fixture, html } from '@open-wc/testing'; +import { ReportForm as Form } from './ReportForm'; +import { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; +import { InternalNativeDateControl } from '../../internal/InternalNativeDateControl/InternalNativeDateControl'; +import { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; +import { getCurrentMonth, toAPIDateTime } from './utils'; import { getTestData } from '../../../testgen/getTestData'; import { stub } from 'sinon'; -import { unsafeHTML } from 'lit-html/directives/unsafe-html'; describe('ReportForm', () => { - const OriginalResizeObserver = window.ResizeObserver; - - // @ts-expect-error disabling ResizeObserver because it errors in test env - before(() => (window.ResizeObserver = undefined)); - after(() => (window.ResizeObserver = OriginalResizeObserver)); - - it('extends NucleonElement', () => { - expect(new ReportForm()).to.be.instanceOf(NucleonElement); + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); - it('registers as foxy-report-form', () => { - expect(customElements.get('foxy-report-form')).to.equal(ReportForm); + it('imports and defines foxy-internal-select-control', () => { + expect(customElements.get('foxy-internal-select-control')).to.exist; }); - it('has a default i18next namespace of "report-form"', () => { - expect(new ReportForm()).to.have.property('ns', 'report-form'); + it('imports and defines foxy-internal-native-date-control', () => { + expect(customElements.get('foxy-internal-native-date-control')).to.exist; }); - describe('name', () => { - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'name')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes name', async () => { - const element = await fixture( - html`` - ); - - expect(await getByTestId(element, 'name')).to.not.exist; - }); - - it('renders "name:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'name:before'); - - expect(slot).to.be.instanceOf(HTMLSlotElement); - }); - - it('replaces "name:before" slot with template "name:before" if available', async () => { - const type = 'name:before'; - const value = `

Value of the "${type}" template.

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

Value of the "${type}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, type); - const sandbox = (await getByTestId(element, type))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders a group label with i18n key "name"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.lang = 'es'; - element.ns = 'foo'; - - const control = (await getByTestId(element, 'name')) as HTMLElement; - const label = await getByKey(control, 'name'); - - expect(label).to.exist; - expect(label).to.have.attribute('lang', 'es'); - expect(label).to.have.attribute('ns', 'foo'); - }); - - it('renders a choice of report names', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.exist; - expect(choice).to.have.deep.property('items', ['complete', 'customers', 'customers_ltv']); - }); - - it('reflects the value of form.name', async () => { - const layout = html``; - const element = await fixture(layout); - const data = await getTestData('./hapi/reports/0'); - - element.data = data; - element.edit({ name: 'customers_ltv' }); - - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.have.property('value', 'customers_ltv'); - }); - - it('is enabled by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.not.have.attribute('disabled'); - }); - - it('is disabled when element is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.have.attribute('disabled'); - }); - - it('is disabled when disabledcontrols includes name', async () => { - const element = await fixture(html` - - `); - - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.have.attribute('disabled'); - }); - - it('is editable by default', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.not.have.attribute('readonly'); - }); - - it('is readonly when element is readonly', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.have.attribute('readonly'); - }); - - it('is readonly when readonlycontrols includes name', async () => { - const element = await fixture(html` - - `); - - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - expect(choice).to.have.attribute('readonly'); - }); - - it('writes to form.name on change', async () => { - const layout = html``; - const element = await fixture(layout); - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - - choice.value = 'customers_ltv'; - choice.dispatchEvent(new ChoiceChangeEvent('customers_ltv')); - - expect(element).to.have.nested.property('form.name', 'customers_ltv'); - }); - - ['customers', 'customers_ltv', 'complete'].forEach(type => { - it(`renders i18n label and explainer for "${type}" report name`, async () => { - const layout = html``; - const element = await fixture(layout); - - element.lang = 'es'; - element.ns = 'foo'; - - const control = (await getByTestId(element, 'name')) as HTMLElement; - const choice = (await getByTestId(control, 'name-choice')) as Choice; - const wrapper = choice.querySelector(`[slot="${type}-label"]`) as HTMLElement; - const label = await getByKey(wrapper, `name_${type}`); - const explainer = await getByKey(wrapper, `name_${type}_explainer`); - - expect(label).to.exist; - expect(label).to.have.attribute('lang', 'es'); - expect(label).to.have.attribute('ns', 'foo'); - - expect(explainer).to.exist; - expect(explainer).to.have.attribute('lang', 'es'); - expect(explainer).to.have.attribute('ns', 'foo'); - }); - }); + it('imports and defines foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; }); - describe('range', () => { - it('is visible by default', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'range')).to.exist; - }); - - it('is hidden when form is hidden', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'range')).to.not.exist; - }); - - it('is hidden when hiddencontrols includes range', async () => { - const element = await fixture( - html`` - ); - - expect(await getByTestId(element, 'range')).to.not.exist; - }); - - it('renders "range:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'range:before'); - - expect(slot).to.be.instanceOf(HTMLSlotElement); - }); - - it('replaces "range:before" slot with template "range:before" if available', async () => { - const type = 'range:before'; - const value = `

Value of the "${type}" template.

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

Value of the "${type}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, type); - const sandbox = (await getByTestId(element, type))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); - - it('renders a group label with i18n key "range"', async () => { - const layout = html``; - const element = await fixture(layout); - - element.lang = 'es'; - element.ns = 'foo'; - - const control = (await getByTestId(element, 'range')) as HTMLElement; - const label = await getByKey(control, 'range'); - - expect(label).to.exist; - expect(label).to.have.attribute('lang', 'es'); - expect(label).to.have.attribute('ns', 'foo'); - }); - - it('renders a range preset picker', async () => { - const options = [ - [ - { value: '0', label: 'preset_previous_quarter', ...getPreviousQuarter() }, - { value: '1', label: 'preset_previous_month', ...getPreviousMonth() }, - { value: '2', label: 'preset_previous_year', ...getPreviousYear() }, - ], - [ - { value: '3', label: 'preset_this_quarter', ...getCurrentQuarter() }, - { value: '4', label: 'preset_this_month', ...getCurrentMonth() }, - { value: '5', label: 'preset_this_year', ...getCurrentYear() }, - ], - [ - { value: '6', label: 'preset_last_365_days', ...getLast365Days() }, - { value: '7', label: 'preset_last_30_days', ...getLast30Days() }, - ], - ]; - - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const picker = (await getByTestId(control, 'range:preset')) as SelectElement; - - const dummyRoot = document.createElement('div'); - picker.renderer!(dummyRoot); - - expect(picker).to.have.property('value', 'custom'); - - for (const group of options) { - for (const { value, label, start, end } of group) { - const item = dummyRoot.querySelector(`vaadin-item[value="${value}"]`); - - expect(item).to.exist; - expect(item).to.have.text(label); - - picker.value = value; - picker.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.datetime_start', toAPIDateTime(start)); - expect(element).to.have.nested.property('form.datetime_end', toAPIDateTime(end)); - } - } - - const customItem = dummyRoot.querySelector(`vaadin-item[value="custom"]`); - - expect(customItem).to.exist; - expect(customItem).to.have.text('preset_custom'); - }); - - it('renders start date picker by default', async () => { - const element = await fixture(html``); - element.edit({ datetime_start: '2022-01-01T00:00:00' }); - - const control = (await getByTestId(element, 'range')) as HTMLElement; - const picker = (await getByTestId(control, 'range:start')) as SelectElement; - - expect(picker).to.be.instanceOf(DatePickerElement); - expect(picker).to.have.property('value', '2022-01-01'); - - picker.value = '2024-12-31'; - picker.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.datetime_start', '2024-12-31T00:00:00'); - }); - - it('renders end date picker by default', async () => { - const element = await fixture(html``); - element.edit({ datetime_end: '2022-01-01T23:59:59' }); - - const control = (await getByTestId(element, 'range')) as HTMLElement; - const picker = (await getByTestId(control, 'range:end')) as SelectElement; - - expect(picker).to.be.instanceOf(DatePickerElement); - expect(picker).to.have.property('value', '2022-01-01'); - - picker.value = '2024-12-31'; - picker.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.datetime_end', '2024-12-31T23:59:59'); - }); - - it('renders start datetime picker on demand', async () => { - const element = await fixture(html``); - element.edit({ datetime_start: '2022-01-01T00:00:00' }); - - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = true; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - const picker = (await getByTestId(control, 'range:start')) as SelectElement; - - expect(picker).to.be.instanceOf(DateTimePicker); - expect(picker).to.have.property('value', '2022-01-01T00:00:00'); - - picker.value = '2024-12-31T00:00:00'; - picker.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.datetime_start', '2024-12-31T00:00:00'); - }); - - it('renders end datetime picker on demand', async () => { - const element = await fixture(html``); - element.edit({ datetime_end: '2022-01-01T12:31:00' }); - - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = true; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - const picker = (await getByTestId(control, 'range:end')) as SelectElement; - - expect(picker).to.be.instanceOf(DateTimePicker); - expect(picker).to.have.property('value', '2022-01-01T12:31:00'); - - picker.value = '2024-12-31T12:31:00'; - picker.dispatchEvent(new CustomEvent('change')); - - expect(element).to.have.nested.property('form.datetime_end', '2024-12-31T12:31:00'); - }); - - [true, false].forEach(isChecked => { - const pickerType = isChecked ? 'datetime' : 'date'; - - it(`is enabled by default (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(toggle).to.not.have.attribute('disabled'); - expect(preset).to.not.have.attribute('disabled'); - expect(start).to.not.have.attribute('disabled'); - expect(end).to.not.have.attribute('disabled'); - }); - - it(`is disabled when the form is disabled (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - element.setAttribute('disabled', 'disabled'); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(toggle).to.have.attribute('disabled'); - expect(preset).to.have.attribute('disabled'); - expect(start).to.have.attribute('disabled'); - expect(end).to.have.attribute('disabled'); - }); - - it(`is disabled when disabledcontrols includes range (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - element.setAttribute('disabledcontrols', 'range'); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(toggle).to.have.attribute('disabled'); - expect(preset).to.have.attribute('disabled'); - expect(start).to.have.attribute('disabled'); - expect(end).to.have.attribute('disabled'); - }); - - it(`is disabled when the form is busy (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - const router = createRouter(); - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - element.addEventListener('fetch', (evt: any) => router.handleEvent(evt)); - element.href = 'https://demo.api/virtual/stall'; - await waitUntil(() => element.in('busy')); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(toggle).to.have.attribute('disabled'); - expect(preset).to.have.attribute('disabled'); - expect(start).to.have.attribute('disabled'); - expect(end).to.have.attribute('disabled'); - }); - - it(`is editable by default (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(preset).to.not.have.attribute('readonly'); - expect(start).to.not.have.attribute('readonly'); - expect(end).to.not.have.attribute('readonly'); - }); - - it(`is readonly when the form is readonly (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - element.setAttribute('readonly', 'readonly'); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(preset).to.have.attribute('readonly'); - expect(start).to.have.attribute('readonly'); - expect(end).to.have.attribute('readonly'); - }); - - it(`is readonly when readonlycontrols includes range (with ${pickerType} picker)`, async () => { - const element = await fixture(html``); - const control = (await getByTestId(element, 'range')) as HTMLElement; - const toggle = (await getByTestId(control, 'range:toggle')) as CheckboxElement; - - toggle.checked = isChecked; - toggle.dispatchEvent(new CustomEvent('change')); - await element.requestUpdate(); - - element.setAttribute('readonlycontrols', 'range'); - await element.requestUpdate(); - - const preset = (await getByTestId(control, 'range:preset')) as HTMLElement; - const start = (await getByTestId(control, 'range:start')) as HTMLElement; - const end = (await getByTestId(control, 'range:end')) as HTMLElement; - - expect(preset).to.have.attribute('readonly'); - expect(start).to.have.attribute('readonly'); - expect(end).to.have.attribute('readonly'); - }); - }); + it('imports and defines foxy-internal-form', () => { + expect(customElements.get('foxy-internal-form')).to.exist; }); - describe('timestamps', () => { - it('once form data is loaded, renders a property table with created and modified dates', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'timestamps'); - const items = [ - { name: 'date_modified', value: 'date' }, - { name: 'date_created', value: 'date' }, - ]; - - expect(control).to.have.deep.property('items', items); - }); - - it('once form data is loaded, renders "timestamps:before" slot', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'timestamps:before'); - - expect(slot).to.have.property('localName', 'slot'); - }); - - it('once form data is loaded, replaces "timestamps:before" slot with template "timestamps:before" if available', async () => { - const data = await getTestData('./hapi/reports/0'); - const name = 'timestamps:before'; - const value = `

Value of the "${name}" template.

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

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); - - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; - - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + it('imports and defines itself as foxy-report-form', () => { + expect(customElements.get('foxy-report-form')).to.equal(Form); }); - describe('create', () => { - it('if data is empty, renders create button', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.exist; - }); - - it('renders with i18n key "create" for caption', async () => { - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'create'); - const caption = control?.firstElementChild; - - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'create'); - expect(caption).to.have.attribute('ns', 'report-form'); - }); - - it('renders disabled if form is disabled', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is invalid', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if form is sending changes', async () => { - const layout = html``; - const element = await fixture(layout); - - element.edit({ - datetime_start: '2020-01-01T00:00:00', - datetime_end: '2022-12-31T23:59:59', - name: 'complete', - }); - - element.submit(); - - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); - - it('renders disabled if disabledcontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.have.attribute('disabled'); - }); + it('extends foxy-internal-form', () => { + expect(new Form()).to.be.instanceOf(customElements.get('foxy-internal-form')); + }); - it('submits valid form on click', async () => { - const element = await fixture(html``); - const submit = stub(element, 'submit'); + it('has a default i18n namespace "report-form"', () => { + expect(Form).to.have.property('defaultNS', 'report-form'); + expect(new Form()).to.have.property('ns', 'report-form'); + }); - element.edit({ - datetime_start: '2020-01-01T00:00:00', - datetime_end: '2022-12-31T23:59:59', - name: 'complete', - }); + it('renders a form header', async () => { + const form = new Form(); + const renderHeaderMethod = stub(form, 'renderHeader'); + form.data = await getTestData('./hapi/reports/0'); + form.render(); + expect(renderHeaderMethod).to.have.been.called; + }); - const control = await getByTestId(element, 'create'); - control!.dispatchEvent(new CustomEvent('click')); + it('renders a foxy-internal-select-control for name', async () => { + const element = await fixture
(html``); + const control = element.renderRoot.querySelector('[infer="name"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-select-control')); + }); - expect(submit).to.have.been.called; - }); + it('renders a foxy-internal-select-control for preset', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="preset"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-select-control')); + }); - it("doesn't render if form is hidden", async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); + it('renders a foxy-internal-native-date-control for datetime-start', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="datetime-start"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-native-date-control')); + }); - it('doesn\'t render if hiddencontrols includes "create"', async () => { - const layout = html``; - const element = await fixture(layout); - expect(await getByTestId(element, 'create')).to.not.exist; - }); + it('renders a foxy-internal-native-date-control for datetime-end', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="datetime-end"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-native-date-control')); + }); - it('renders with "create:before" slot by default', async () => { - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'create:before'); + it('renders a foxy-internal-switch-control for datetime-precise', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="datetime-precise"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-switch-control')); + }); - expect(slot).to.have.property('localName', 'slot'); - }); + it('produces name:v8n_required error when name is missing', () => { + const element = new Form(); + expect(element.errors).to.include('name:v8n_required'); + }); - it('replaces "create:before" slot with template "create:before" if available and rendered', async () => { - const name = 'create:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + it('produces datetime-start:v8n_required error when datetime_start is missing', () => { + const element = new Form(); + expect(element.errors).to.include('datetime-start:v8n_required'); + }); - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + it('produces datetime-end:v8n_required error when datetime_end is missing', () => { + const element = new Form(); + expect(element.errors).to.include('datetime-end:v8n_required'); + }); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + it('marks name as readonly when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.readonlySelector.matches('name', true)).to.be.true; + }); - it('renders with "create:after" slot by default', async () => { - const element = await fixture(html``); - const slot = await getByName(element, 'create:after'); - expect(slot).to.have.property('localName', 'slot'); - }); + it('marks preset as readonly when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.readonlySelector.matches('preset', true)).to.be.true; + }); - it('replaces "create:after" slot with template "create:after" if available and rendered', async () => { - const name = 'create:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - - - - `); + it('marks datetime-precise as readonly when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.readonlySelector.matches('datetime-precise', true)).to.be.true; + }); - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + it('marks datetime-start as readonly when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.readonlySelector.matches('datetime-start', true)).to.be.true; + }); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + it('marks datetime-end as readonly when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.readonlySelector.matches('datetime-end', true)).to.be.true; }); - describe('delete', () => { - it('renders delete button once resource is loaded', async () => { - const href = './hapi/reports/0'; - const data = await getTestData(href); - const layout = html``; - const element = await fixture(layout); + it('hides preset when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.hiddenSelector.matches('preset', true)).to.be.true; + }); - expect(await getByTestId(element, 'delete')).to.exist; - }); + it('hides datetime-precise when data is loaded', async () => { + const element = await fixture(html``); + element.data = await getTestData('./hapi/reports/0'); + expect(element.hiddenSelector.matches('datetime-precise', true)).to.be.true; + }); - it('renders with i18n key "delete" for caption', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'delete'); - const caption = control?.firstElementChild; + it('uses custom getValue for preset control to return matching preset or "custom"', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="preset"]')!; - expect(caption).to.have.property('localName', 'foxy-i18n'); - expect(caption).to.have.attribute('lang', 'es'); - expect(caption).to.have.attribute('key', 'delete'); - expect(caption).to.have.attribute('ns', 'report-form'); - }); + // When no dates are set, should return 'custom' + expect(control.getValue()).to.equal('custom'); - it('renders disabled if form is disabled', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); + // When dates match a preset, should return that preset's value + const { start, end } = getCurrentMonth(); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); + element.edit({ + datetime_start: toAPIDateTime(start), + datetime_end: toAPIDateTime(end), }); - it('renders disabled if form is sending changes', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - - element.edit({ - datetime_start: '2020-01-01T00:00:00', - datetime_end: '2022-12-31T23:59:59', - name: 'complete', - }); - - element.submit(); + expect(control.getValue()).to.equal('4'); // 'this_month' preset + }); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + it('uses custom setValue for preset control to set datetime_start and datetime_end', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="preset"]')!; - it('renders disabled if disabledcontrols includes "delete"', async () => { - const element = await fixture(html` - ('./hapi/reports/0')} - disabledcontrols="delete" - > - - `); + // Set to 'this_month' preset (value '4') + control.setValue('4'); - expect(await getByTestId(element, 'delete')).to.have.attribute('disabled'); - }); + const { start, end } = getCurrentMonth(); - it('shows deletion confirmation dialog on click', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const control = await getByTestId(element, 'delete'); - const confirm = await getByTestId(element, 'confirm'); - const showMethod = stub(confirm!, 'show'); + expect(element.form.datetime_start).to.equal(toAPIDateTime(start)); + expect(element.form.datetime_end).to.equal(toAPIDateTime(end)); + }); - control!.dispatchEvent(new CustomEvent('click')); + it('uses custom getValue for datetime-start control to return formatted date', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="datetime-start"]' + )!; - expect(showMethod).to.have.been.called; - }); + // When no value, returns empty string + expect(control.getValue()).to.equal(''); - it('deletes resource if deletion is confirmed', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + // When value is set, returns formatted date (without time when not in precise mode) + element.edit({ datetime_start: '2022-01-15T10:30:00' }); + expect(control.getValue()).to.equal('2022-01-15'); + }); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(false)); + it('uses custom getValue for datetime-start control to return datetime when in precise mode', async () => { + const element = await fixture(html``); + const preciseControl = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - expect(deleteMethod).to.have.been.called; - }); + // Enable precise mode + preciseControl.setValue(true); + element.edit({ datetime_start: '2022-01-15T10:30:00' }); - it('keeps resource if deletion is cancelled', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const confirm = await getByTestId(element, 'confirm'); - const deleteMethod = stub(element, 'delete'); + const control = element.renderRoot.querySelector( + '[infer="datetime-start"]' + )!; + expect(control.getValue()).to.equal('2022-01-15T10:30'); + }); - confirm!.dispatchEvent(new InternalConfirmDialog.HideEvent(true)); + it('uses custom setValue for datetime-start control to set datetime_start with default time', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="datetime-start"]' + )!; - expect(deleteMethod).not.to.have.been.called; - }); + control.setValue('2022-06-15'); + expect(element.form.datetime_start).to.equal('2022-06-15T00:00:00'); + }); - it("doesn't render if form is hidden", async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); + it('uses custom setValue for datetime-start control to preserve time in precise mode', async () => { + const element = await fixture(html``); + const preciseControl = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - expect(await getByTestId(element, 'delete')).to.not.exist; - }); + // Enable precise mode + preciseControl.setValue(true); - it('doesn\'t render if hiddencontrols includes "delete"', async () => { - const element = await fixture(html` - ('./hapi/reports/0')} - hiddencontrols="delete" - > - - `); + const control = element.renderRoot.querySelector( + '[infer="datetime-start"]' + )!; + control.setValue('2022-06-15T14:30'); + expect(element.form.datetime_start).to.equal('2022-06-15T14:30:00'); + }); - expect(await getByTestId(element, 'delete')).to.not.exist; - }); + it('uses custom getValue for datetime-end control to return formatted date', async () => { + const element = await fixture(html``); + const control = + element.renderRoot.querySelector('[infer="datetime-end"]')!; - it('renders with "delete:before" slot by default', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:before'); + // When no value, returns empty string + expect(control.getValue()).to.equal(''); - expect(slot).to.have.property('localName', 'slot'); - }); + // When value is set, returns formatted date (without time when not in precise mode) + element.edit({ datetime_end: '2022-01-15T23:59:59' }); + expect(control.getValue()).to.equal('2022-01-15'); + }); - it('replaces "delete:before" slot with template "delete:before" if available and rendered', async () => { - const href = './hapi/reports/0'; - const name = 'delete:before'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); + it('uses custom getValue for datetime-end control to return datetime when in precise mode', async () => { + const element = await fixture(html``); + const preciseControl = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + // Enable precise mode + preciseControl.setValue(true); + element.edit({ datetime_end: '2022-01-15T18:45:00' }); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + const control = + element.renderRoot.querySelector('[infer="datetime-end"]')!; + expect(control.getValue()).to.equal('2022-01-15T18:45'); + }); - it('renders with "delete:after" slot by default', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const slot = await getByName(element, 'delete:after'); + it('uses custom setValue for datetime-end control to set datetime_end with default end-of-day time', async () => { + const element = await fixture(html``); + const control = + element.renderRoot.querySelector('[infer="datetime-end"]')!; - expect(slot).to.have.property('localName', 'slot'); - }); + control.setValue('2022-06-15'); + expect(element.form.datetime_end).to.equal('2022-06-15T23:59:59'); + }); - it('replaces "delete:after" slot with template "delete:after" if available and rendered', async () => { - const href = './hapi/reports/0'; - const name = 'delete:after'; - const value = `

Value of the "${name}" template.

`; - const element = await fixture(html` - (href)}> - - - `); + it('uses custom setValue for datetime-end control to preserve time in precise mode', async () => { + const element = await fixture(html``); + const preciseControl = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - const slot = await getByName(element, name); - const sandbox = (await getByTestId(element, name))!.renderRoot; + // Enable precise mode + preciseControl.setValue(true); - expect(slot).to.not.exist; - expect(sandbox).to.contain.html(value); - }); + const control = + element.renderRoot.querySelector('[infer="datetime-end"]')!; + control.setValue('2022-06-15T18:45'); + expect(element.form.datetime_end).to.equal('2022-06-15T18:45:59'); }); - describe('spinner', () => { - it('renders foxy-spinner in "busy" state while loading data', async () => { - const router = createRouter(); - const layout = html` - router.handleEvent(evt)} - > - - `; - - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; - - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'busy'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'report-form spinner'); - }); + it('uses custom getValue for datetime-precise control to return current precise mode state', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - it('renders foxy-spinner in "error" state if loading data fails', async () => { - const href = './hapi/not-found'; - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); - const spinner = spinnerWrapper!.firstElementChild; + // Default is false + expect(control.getValue()).to.be.false; - await waitUntil(() => element.in('fail'), undefined, { timeout: 5000 }); + // After setting to true + control.setValue(true); + expect(control.getValue()).to.be.true; + }); - expect(spinnerWrapper).not.to.have.class('opacity-0'); - expect(spinner).to.have.attribute('state', 'error'); - expect(spinner).to.have.attribute('lang', 'es'); - expect(spinner).to.have.attribute('ns', 'report-form spinner'); - }); + it('uses custom setValue for datetime-precise control to toggle precise mode', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector( + '[infer="datetime-precise"]' + )!; - it('hides spinner once loaded', async () => { - const data = await getTestData('./hapi/reports/0'); - const layout = html``; - const element = await fixture(layout); - const spinnerWrapper = await getByTestId(element, 'spinner'); + control.setValue(true); + expect(control.getValue()).to.be.true; - expect(spinnerWrapper).to.have.class('opacity-0'); - }); + control.setValue(false); + expect(control.getValue()).to.be.false; }); }); diff --git a/src/elements/public/ReportForm/ReportForm.ts b/src/elements/public/ReportForm/ReportForm.ts index e77344d6..ded3cc26 100644 --- a/src/elements/public/ReportForm/ReportForm.ts +++ b/src/elements/public/ReportForm/ReportForm.ts @@ -1,7 +1,6 @@ -import { Choice, Group, Metadata } from '../../private/index'; -import { Data } from './types'; -import { PropertyDeclarations, TemplateResult, html } from 'lit-element'; -import { ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements'; +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { Data } from './types'; + import { getCurrentMonth, getCurrentQuarter, @@ -13,32 +12,16 @@ import { getPreviousYear, toAPIDateTime, toDatePickerValue, - toDateTimePickerValue, + toNativeDateTimePickerValue, } from './utils'; -import { ButtonElement } from '@vaadin/vaadin-button'; -import { CheckboxElement } from '@vaadin/vaadin-checkbox'; -import { ChoiceChangeEvent } from '../../private/Choice/ChoiceChangeEvent'; -import { ConfigurableMixin } from '../../../mixins/configurable'; -import { DateTimePicker } from '@vaadin/vaadin-date-time-picker'; -import { DialogHideEvent } from '../../private/Dialog/DialogHideEvent'; -import { InternalConfirmDialog } from '../../internal/InternalConfirmDialog/InternalConfirmDialog'; -import { NucleonElement } from '../NucleonElement/NucleonElement'; -import { NucleonV8N } from '../NucleonElement/types'; -import { ResponsiveMixin } from '../../../mixins/responsive'; -import { SelectElement } from '@vaadin/vaadin-select'; -import { ThemeableMixin } from '../../../mixins/themeable'; import { TranslatableMixin } from '../../../mixins/translatable'; -import { classMap } from '../../../utils/class-map'; -import { ifDefined } from 'lit-html/directives/if-defined'; -import { live } from 'lit-html/directives/live'; -import { render } from 'lit-html'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { NucleonV8N } from '../NucleonElement/types'; +import { html } from 'lit-element'; -const Base = ScopedElementsMixin( - ResponsiveMixin( - ThemeableMixin(ConfigurableMixin(TranslatableMixin(NucleonElement, 'report-form'))) - ) -); +const Base = TranslatableMixin(InternalForm, 'report-form'); /** * Form element for creating or editing reports (`fx:report`). @@ -47,25 +30,6 @@ const Base = ScopedElementsMixin( * @since 1.16.0 */ export class ReportForm extends Base { - static get scopedElements(): ScopedElementsMap { - return { - 'vaadin-date-time-picker': customElements.get('vaadin-date-time-picker'), - 'vaadin-date-picker': customElements.get('vaadin-date-picker'), - 'vaadin-checkbox': customElements.get('vaadin-checkbox'), - 'vaadin-select': customElements.get('vaadin-select'), - 'vaadin-button': customElements.get('vaadin-button'), - - 'foxy-internal-confirm-dialog': customElements.get('foxy-internal-confirm-dialog'), - 'foxy-internal-sandbox': customElements.get('foxy-internal-sandbox'), - 'foxy-spinner': customElements.get('foxy-spinner'), - 'foxy-i18n': customElements.get('foxy-i18n'), - - 'x-metadata': Metadata, - 'x-choice': Choice, - 'x-group': Group, - }; - } - static get properties(): PropertyDeclarations { return { ...super.properties, @@ -75,351 +39,158 @@ export class ReportForm extends Base { static get v8n(): NucleonV8N { return [ - ({ name: v }) => !!v || 'name_required', - ({ datetime_start: v }) => !!v || 'datetime_start_required', - ({ datetime_end: v }) => !!v || 'datetime_end_required', + ({ datetime_start: v }) => !!v || 'datetime-start:v8n_required', + ({ datetime_end: v }) => !!v || 'datetime-end:v8n_required', + ({ name: v }) => !!v || 'name:v8n_required', ]; } private __showRangeTime = false; - render(): TemplateResult { - const hidden = this.hiddenSelector; - - return html` -
-
- ${hidden.matches('name', true) ? '' : this.__renderName()} - ${hidden.matches('range', true) ? '' : this.__renderRange()} - ${hidden.matches('timestamps', true) || !this.data ? '' : this.__renderTimestamps()} - ${hidden.matches('create', true) || this.data ? '' : this.__renderCreate()} - ${hidden.matches('delete', true) || !this.data ? '' : this.__renderDelete()} -
- -
- - -
-
- `; - } - - private __renderName() { - const scope = 'name'; - const items = ['complete', 'customers', 'customers_ltv']; - const isDisabled = !this.in('idle') || this.disabledSelector.matches(scope); - const isReadonly = this.readonlySelector.matches(scope); - - return html` -
- ${this.renderTemplateOrSlot(`${scope}:before`)} - - - - - { - if (evt instanceof ChoiceChangeEvent) { - this.edit({ name: evt.detail as Data['name'] }); - } - }} - > - ${items.map(value => { - return html` -
- - - - - -
- `; - })} -
-
- - ${this.renderTemplateOrSlot(`${scope}:after`)} -
- `; - } - - private __renderRangePreset() { - const options = [ - [ - { value: '0', label: 'preset_previous_quarter', ...getPreviousQuarter() }, - { value: '1', label: 'preset_previous_month', ...getPreviousMonth() }, - { value: '2', label: 'preset_previous_year', ...getPreviousYear() }, - ], - [ - { value: '3', label: 'preset_this_quarter', ...getCurrentQuarter() }, - { value: '4', label: 'preset_this_month', ...getCurrentMonth() }, - { value: '5', label: 'preset_this_year', ...getCurrentYear() }, - ], - [ - { value: '6', label: 'preset_last_365_days', ...getLast365Days() }, - { value: '7', label: 'preset_last_30_days', ...getLast30Days() }, - ], - ]; - - const currentOption = options.flat(1).find(option => { - const { datetime_end: end, datetime_start: start } = this.form; - return ( - start && end && toAPIDateTime(option.start) === start && toAPIDateTime(option.end) === end - ); - }); - - const renderer = (root: Element) => { - const custom = html`${this.t('preset_custom')}`; - const separator = html`
`; - const predefined = options.map(group => { - const items = group.map(({ value, label }) => { - return html`${this.t(label)}`; - }); - - return html`${items}${separator}`; + private readonly __rawPresetOptions = [ + { value: '0', label: 'option_previous_quarter', ...getPreviousQuarter() }, + { value: '1', label: 'option_previous_month', ...getPreviousMonth() }, + { value: '2', label: 'option_previous_year', ...getPreviousYear() }, + { value: '3', label: 'option_this_quarter', ...getCurrentQuarter() }, + { value: '4', label: 'option_this_month', ...getCurrentMonth() }, + { value: '5', label: 'option_this_year', ...getCurrentYear() }, + { value: '6', label: 'option_last_365_days', ...getLast365Days() }, + { value: '7', label: 'option_last_30_days', ...getLast30Days() }, + ]; + + private readonly __presetOptions = JSON.stringify([ + ...this.__rawPresetOptions, + { value: 'custom', label: 'option_custom' }, + ]); + + private readonly __nameOptions = JSON.stringify([ + { value: 'complete', label: 'option_complete' }, + { value: 'customers', label: 'option_customers' }, + { value: 'customers_ltv', label: 'option_customers_ltv' }, + { value: 'transactions', label: 'option_transactions' }, + ]); + + private readonly __datetimePreciseGetValue = () => { + return this.__showRangeTime; + }; + + private readonly __datetimePreciseSetValue = (value: boolean) => { + this.__showRangeTime = value; + }; + + private readonly __datetimeStartGetValue = () => { + const value = this.form.datetime_start; + return value + ? this.__showRangeTime || this.data + ? toNativeDateTimePickerValue(value) + : toDatePickerValue(value) + : ''; + }; + + private readonly __datetimeStartSetValue = (value: string) => { + const time = this.__showRangeTime ? `${value.split('T')[1] ?? '00:00'}:00` : '00:00:00'; + this.edit({ datetime_start: `${value.split('T')[0]}T${time}` }); + }; + + private readonly __datetimeEndGetValue = () => { + const value = this.form.datetime_end; + return value + ? this.__showRangeTime || this.data + ? toNativeDateTimePickerValue(value) + : toDatePickerValue(value) + : ''; + }; + + private readonly __datetimeEndSetValue = (value: string) => { + const time = this.__showRangeTime ? `${value.split('T')[1] ?? '23:59'}:59` : '23:59:59'; + this.edit({ datetime_end: `${value.split('T')[0]}T${time}` }); + }; + + private readonly __presetGetValue = () => { + return ( + this.__rawPresetOptions.find(option => { + const { datetime_end: end, datetime_start: start } = this.form; + return ( + start && end && toAPIDateTime(option.start) === start && toAPIDateTime(option.end) === end + ); + })?.value ?? 'custom' + ); + }; + + private readonly __presetSetValue = (value: string) => { + const option = this.__rawPresetOptions.find(option => option.value === value); + + if (option) { + this.edit({ + datetime_start: toAPIDateTime(option.start), + datetime_end: toAPIDateTime(option.end), }); - - if (!root.firstElementChild) root.appendChild(document.createElement('vaadin-list-box')); - render(html`${predefined}${custom}`, root.firstElementChild as Element); - }; - - return html` -
- { - const select = evt.currentTarget as SelectElement; - const option = options.flat(1).find(option => option.value === select.value); - - if (option) { - this.edit({ - datetime_start: toAPIDateTime(option.start), - datetime_end: toAPIDateTime(option.end), - }); - } - }} - > - -
- `; + } + }; + + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + if (this.data) { + alwaysMatch.unshift('name', 'preset', 'datetime-precise', 'datetime-start', 'datetime-end'); + } + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - private __renderRangeDateTimePicker(type: 'start' | 'end') { - const field = type === 'end' ? 'datetime_end' : 'datetime_start'; - const error = this.errors.find(error => error.startsWith(`${field}_`)); - const value = this.form[field as keyof Data] as string; - - return html` - !error} - .value=${value ? toDateTimePickerValue(value) : ''} - @keydown=${(evt: KeyboardEvent) => evt.key === 'Enter' && this.submit()} - @change=${(evt: CustomEvent) => { - const picker = evt.currentTarget as DateTimePicker; - this.edit({ [field]: picker.value }); - }} - > - - `; - } - - private __renderRangeDatePicker(type: 'start' | 'end') { - const field = type === 'end' ? 'datetime_end' : 'datetime_start'; - const error = this.errors.find(error => error.startsWith(`${field}_`)); - const value = this.form[field as keyof Data] as string; - - return html` - !error} - .value=${value ? toDatePickerValue(value) : ''} - @keydown=${(evt: KeyboardEvent) => evt.key === 'Enter' && this.submit()} - @change=${(evt: CustomEvent) => { - const picker = evt.currentTarget as DateTimePicker; - const time = type === 'end' ? '23:59:59' : '00:00:00'; - - this.edit({ [field]: `${picker.value}T${time}` }); - }} - > - - `; - } - - private __renderRange() { - const renderer = this.__showRangeTime - ? this.__renderRangeDateTimePicker - : this.__renderRangeDatePicker; - - return html` -
- ${this.renderTemplateOrSlot('range:before')} - - - - -
-
- ${this.__renderRangePreset()} -
- - ${renderer.call(this, 'start')} ${renderer.call(this, 'end')} - - { - const checkbox = evt.currentTarget as CheckboxElement; - this.__showRangeTime = checkbox.checked; - }} - > - - -
-
- - ${this.renderTemplateOrSlot('range:after')} -
- `; + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + if (this.data) alwaysMatch.unshift('preset', 'datetime-precise'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } - private __renderTimestamps() { + renderBody(): TemplateResult { return html` -
- ${this.renderTemplateOrSlot('timestamps:before')} - - ({ - name: this.t(field), - value: this.data?.[field] - ? this.t('date', { value: new Date(this.data[field] as string) }) - : '', - }))} + ${this.renderHeader()} + + + - - - ${this.renderTemplateOrSlot('timestamps:after')} -
- `; - } - - private __renderCreate() { - const isCleanTemplateInvalid = this.in({ idle: { template: { clean: 'invalid' } } }); - const isDirtyTemplateInvalid = this.in({ idle: { template: { dirty: 'invalid' } } }); - const isCleanSnapshotInvalid = this.in({ idle: { snapshot: { clean: 'invalid' } } }); - const isDirtySnapshotInvalid = this.in({ idle: { snapshot: { dirty: 'invalid' } } }); - const isTemplateInvalid = isCleanTemplateInvalid || isDirtyTemplateInvalid; - const isSnaphotInvalid = isCleanSnapshotInvalid || isDirtySnapshotInvalid; - const isInvalid = isTemplateInvalid || isSnaphotInvalid; - const isIdle = this.in('idle'); - - return html` -
- ${this.renderTemplateOrSlot('create:before')} - - + + - - - - ${this.renderTemplateOrSlot('create:after')} -
- `; - } - - private __renderDelete() { - return html` -
- !evt.detail.cancelled && this.delete()} + + + + + - + - ${this.renderTemplateOrSlot('delete:before')} + + - { - const confirm = this.renderRoot.querySelector('#confirm') as InternalConfirmDialog; - confirm.show(evt.currentTarget as ButtonElement); - }} + - - + + - ${this.renderTemplateOrSlot('delete:after')} -
+ ${super.renderBody()} `; } } diff --git a/src/elements/public/ReportForm/index.ts b/src/elements/public/ReportForm/index.ts index 62e10827..211d5919 100644 --- a/src/elements/public/ReportForm/index.ts +++ b/src/elements/public/ReportForm/index.ts @@ -1,12 +1,8 @@ -import '@vaadin/vaadin-date-time-picker'; -import '@vaadin/vaadin-date-picker'; -import '@vaadin/vaadin-checkbox'; -import '@vaadin/vaadin-button'; -import '@vaadin/vaadin-select'; -import '../../internal/InternalConfirmDialog/index'; -import '../../internal/InternalSandbox/index'; -import '../Spinner/index'; -import '../I18n/index'; +import '../../internal/InternalNativeDateControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalForm/index'; import { ReportForm } from './ReportForm'; diff --git a/src/elements/public/ReportForm/utils.ts b/src/elements/public/ReportForm/utils.ts index 519c5da9..e6a58cf3 100644 --- a/src/elements/public/ReportForm/utils.ts +++ b/src/elements/public/ReportForm/utils.ts @@ -100,6 +100,10 @@ export function toDateTimePickerValue(apiValue: string): string { return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.exec(apiValue)?.[0] ?? ''; } +export function toNativeDateTimePickerValue(apiValue: string): string { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.exec(apiValue)?.[0] ?? ''; +} + export function toDatePickerValue(apiValue: string): string { return /^\d{4}-\d{2}-\d{2}/.exec(apiValue)?.[0] ?? ''; } diff --git a/src/elements/public/ReportsTable/ReportsTable.test.ts b/src/elements/public/ReportsTable/ReportsTable.test.ts index d4ba6ce2..722ab518 100644 --- a/src/elements/public/ReportsTable/ReportsTable.test.ts +++ b/src/elements/public/ReportsTable/ReportsTable.test.ts @@ -48,11 +48,13 @@ describe('ReportsTable', () => { expect(header).to.have.property('ns', 'foo'); }); - (['complete', 'customers', 'customers_ltv'] as const).forEach(name => { + (['complete', 'customers', 'customers_ltv', 'transactions'] as const).forEach(name => { it(`renders "Name" column cell for "${name}" report name`, async () => { type Report = Data['_embedded']['fx:reports'][number]; const data = { ...(await getTestData('./hapi/reports/0')), name }; + // TODO remove ts-expect-error when SDK types are updated + // @ts-expect-error SDK types do not include "transactions" yet const layout = ReportsTable.nameColumn.cell!({ lang: 'es', ns: 'foo', data, html }); const cell = await fixture(layout as TemplateResult); diff --git a/src/static/translations/report-form/en.json b/src/static/translations/report-form/en.json index 6371cbc8..54603635 100644 --- a/src/static/translations/report-form/en.json +++ b/src/static/translations/report-form/en.json @@ -1,39 +1,89 @@ { - "cancel": "Cancel", - "caption": "Create", - "date": "{{value, date}}", - "date_created": "Created on", - "date_modified": "Last updated on", - "datetime_end_required": "End date is required", - "datetime_start_required": "Start date is required", - "delete": "Delete", - "delete_prompt": "If you proceed, this report will be deleted forever. Are you sure?", - "end": "End", - "name": "Name", - "name_complete": "Complete", - "name_complete_explainer": "Transactions, coupon usage, subscription forecasts etc.", - "name_customers": "Customers", - "name_customers_explainer": "Exports customers to import somewhere else.", - "name_customers_ltv": "Lifetime value", - "name_customers_ltv_explainer": "Customers with lifetime value info.", - "preset": "Preset", - "preset_custom": "Custom", - "preset_last_30_days": "Last 30 days", - "preset_last_365_days": "Last 365 days", - "preset_previous_month": "Previous month", - "preset_previous_quarter": "Previous quarter", - "preset_previous_year": "Previous year", - "preset_this_month": "This month", - "preset_this_quarter": "This quarter", - "preset_this_year": "This year", - "range": "Range", - "select_date": "DD/MM/YYYY", - "select_time": "HH:MM:SS", - "start": "Start", - "use_precise_time": "Use precise time", + "header": { + "title_existing": "Report #{{ id }}", + "title_new": "New report", + "subtitle": "Status: {{ status }}", + "copy-id": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy ID", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "copy-json": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy source as JSON", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, + "name": { + "label": "Report type", + "placeholder": "Select", + "helper_text_none": "", + "helper_text_complete": "Transactions, coupon usage, subscription forecasts etc.", + "helper_text_customers": "Exports customers to import somewhere else.", + "helper_text_customers_ltv": "Customers with lifetime value info.", + "helper_text_transactions": "Exports transaction data for reporting and analysis.", + "option_complete": "Complete", + "option_customers": "Customers", + "option_customers_ltv": "Lifetime value", + "option_transactions": "Transactions", + "v8n_required": "Please select a report type." + }, + "preset": { + "label": "Range", + "helper_text": "", + "placeholder": "Select", + "option_custom": "Custom", + "option_last_30_days": "Last 30 days", + "option_last_365_days": "Last 365 days", + "option_previous_month": "Previous month", + "option_previous_quarter": "Previous quarter", + "option_previous_year": "Previous year", + "option_this_month": "This month", + "option_this_quarter": "This quarter", + "option_this_year": "This year" + }, + "datetime-precise": { + "label": "Use precise time", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "datetime-start": { + "label": "Start date", + "placeholder": "", + "helper_text": "", + "v8n_required": "Please select a start date." + }, + "datetime-end": { + "label": "End date", + "placeholder": "", + "helper_text": "", + "v8n_required": "Please select an end date." + }, + "timestamps": { + "date_created": "Created on", + "date_modified": "Last updated on", + "date": "{{value, date}}" + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "If you proceed, this report will be deleted forever. Are you sure?" + }, + "undo": { + "caption": "Undo" + }, + "submit": { + "caption": "Save changes" + }, + "create": { + "caption": "Create" + }, "spinner": { "refresh": "Refresh", "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/reports-table/en.json b/src/static/translations/reports-table/en.json index 0685bbbb..8fb478f4 100644 --- a/src/static/translations/reports-table/en.json +++ b/src/static/translations/reports-table/en.json @@ -9,6 +9,7 @@ "report_name_complete": "Complete", "report_name_customers": "Customers", "report_name_customers_ltv": "Lifetime value", + "report_name_transactions": "Transactions", "link-spinner": { "loading_busy": "Preparing", "loading_error": "Failed to create" diff --git a/src/utils/safe-date.ts b/src/utils/safe-date.ts index 36d1e42e..90888552 100644 --- a/src/utils/safe-date.ts +++ b/src/utils/safe-date.ts @@ -2,7 +2,14 @@ export function safeDate(year: number, month: number, day?: number): Date { // 0-99 map to 1900-1999 in JS Date, so we need to use // setFullYear to set the correct year. const date = new Date(); - date.setFullYear(year, month, day); + + // Passing day as undefined results in Invalid Date, at least in Safari. + if (day !== undefined) { + date.setFullYear(year, month, day); + } else { + date.setFullYear(year, month); + } + date.setHours(0, 0, 0, 0); return date; } diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index 9362f223..9082eedd 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -71,6 +71,10 @@ export const groups = [ name: 'foxy-internal-integer-control', files: './src/elements/internal/InternalIntegerControl/**/*.test.ts', }, + { + name: 'foxy-internal-native-date-control', + files: './src/elements/internal/InternalNativeDateControl/**/*.test.ts', + }, { name: 'foxy-internal-number-control', files: './src/elements/internal/InternalNumberControl/**/*.test.ts',