From 6bda63616d42b338228554f1d710f7ad80970962 Mon Sep 17 00:00:00 2001 From: Tomi Virkki Date: Fri, 15 Nov 2024 15:59:25 +0200 Subject: [PATCH 1/2] refactor: extract crud logic into reusable mixins --- packages/crud/src/vaadin-crud-grid-mixin.d.ts | 29 + packages/crud/src/vaadin-crud-grid-mixin.js | 266 +++++ packages/crud/src/vaadin-crud-grid.d.ts | 22 +- packages/crud/src/vaadin-crud-grid.js | 252 +--- packages/crud/src/vaadin-crud-helpers.js | 4 + packages/crud/src/vaadin-crud-mixin.d.ts | 269 +++++ packages/crud/src/vaadin-crud-mixin.js | 1046 +++++++++++++++++ packages/crud/src/vaadin-crud.d.ts | 265 +---- packages/crud/src/vaadin-crud.js | 1036 +--------------- 9 files changed, 1635 insertions(+), 1554 deletions(-) create mode 100644 packages/crud/src/vaadin-crud-grid-mixin.d.ts create mode 100644 packages/crud/src/vaadin-crud-grid-mixin.js create mode 100644 packages/crud/src/vaadin-crud-mixin.d.ts create mode 100644 packages/crud/src/vaadin-crud-mixin.js diff --git a/packages/crud/src/vaadin-crud-grid-mixin.d.ts b/packages/crud/src/vaadin-crud-grid-mixin.d.ts new file mode 100644 index 0000000000..e0b3f0f0c4 --- /dev/null +++ b/packages/crud/src/vaadin-crud-grid-mixin.d.ts @@ -0,0 +1,29 @@ +import type { Constructor } from '@open-wc/dedupe-mixin'; +import type { IncludedMixinClass } from './vaadin-crud-include-mixin.js'; + +/** + * A mixin providing common crud grid functionality. + */ +export declare function CrudGridMixin>( + base: T, +): Constructor & Constructor & T; + +export declare class CrudGridMixinClass { + /** + * Disable filtering in the generated columns. + * @attr {boolean} no-filter + */ + noFilter: boolean | null | undefined; + + /** + * Disable sorting in the generated columns. + * @attr {boolean} no-sort + */ + noSort: boolean | null | undefined; + + /** + * Do not add headers to columns. + * @attr {boolean} no-head + */ + noHead: boolean | null | undefined; +} diff --git a/packages/crud/src/vaadin-crud-grid-mixin.js b/packages/crud/src/vaadin-crud-grid-mixin.js new file mode 100644 index 0000000000..a9ce76ecd1 --- /dev/null +++ b/packages/crud/src/vaadin-crud-grid-mixin.js @@ -0,0 +1,266 @@ +/** + * @license + * Copyright (c) 2000 - 2024 Vaadin Ltd. + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See https://vaadin.com/commercial-license-and-service-terms for the full + * license. + */ +import { capitalize, getProperty } from './vaadin-crud-helpers.js'; +import { IncludedMixin } from './vaadin-crud-include-mixin.js'; + +/** + * A mixin providing common crud-grid functionality. + * + * @polymerMixin + * @mixes IncludedMixin + */ +export const CrudGridMixin = (superClass) => + class extends IncludedMixin(superClass) { + static get properties() { + return { + /** + * Disable filtering in the generated columns. + * @attr {boolean} no-filter + */ + noFilter: Boolean, + + /** + * Disable sorting in the generated columns. + * @attr {boolean} no-sort + */ + noSort: Boolean, + + /** + * Do not add headers to columns. + * @attr {boolean} no-head + */ + noHead: Boolean, + + /** @private */ + __hideEditColumn: Boolean, + }; + } + + static get observers() { + return ['__onItemsChange(items)', '__onHideEditColumnChange(hideEditColumn)']; + } + + /** @private */ + __onItemsChange(items) { + if ((!this.dataProvider || this.dataProvider === this._arrayDataProvider) && !this.include && items && items[0]) { + this._configure(items[0]); + } + } + + /** @private */ + __onHideEditColumnChange() { + if (this.firstChild) { + this.__toggleEditColumn(); + } + } + + /** @private */ + __toggleEditColumn() { + let editColumn = this.querySelector('vaadin-crud-edit-column'); + if (this.hideEditColumn) { + if (editColumn) { + this.removeChild(editColumn); + } + } else if (!editColumn) { + editColumn = document.createElement('vaadin-crud-edit-column'); + editColumn.frozenToEnd = true; + this.appendChild(editColumn); + } + } + + /** @private */ + __dataProviderWrapper(params, callback) { + this.__dataProvider(params, (items, size) => { + if (this.innerHTML === '' && !this.include && items[0]) { + this._configure(items[0]); + } + callback(items, size); + }); + } + + /** + * @override + * @private + */ + _dataProviderChanged(dataProvider, oldDataProvider) { + if (this._arrayDataProvider === dataProvider) { + super._dataProviderChanged(dataProvider, oldDataProvider); + } else if (this.__dataProviderWrapper !== dataProvider) { + this.innerHTML = ''; + this.__dataProvider = dataProvider; + this.dataProvider = this.__dataProviderWrapper; + super._dataProviderChanged(this.__dataProviderWrapper, oldDataProvider); + } + } + + /** + * Auto-generate grid columns based on the JSON structure of the object provided. + * + * Method will be executed when items or dataProvider is assigned. + * @private + */ + _configure(item) { + this.innerHTML = ''; + this.__createColumns(this, item, undefined, this.__getPropertyDepth(item)); + this.__toggleEditColumn(); + } + + /** + * Return the deepest property depth of the object + * @private + */ + __getPropertyDepth(object) { + if (!object || typeof object !== 'object') { + return 0; + } + + return Object.keys(object).reduce((deepest, prop) => { + if (this.exclude && this.exclude.test(prop)) { + return deepest; + } + return Math.max(deepest, 1 + this.__getPropertyDepth(object[prop])); + }, 0); + } + + /** + * Parse the camelCase column names into sentence case headers. + * @param {string} path + * @return {string} + * @protected + */ + _generateHeader(path) { + return path + .substr(path.lastIndexOf('.') + 1) + .replace(/([A-Z])/gu, '-$1') + .toLowerCase() + .replace(/-/gu, ' ') + .replace(/^./u, (match) => match.toUpperCase()); + } + + /** @private */ + __createColumn(parent, path) { + let col; + if (!this.noFilter && !this.noSort && !parent.__sortColumnGroup) { + // This crud-grid has both a sorter and a filter, but neither has yet been + // created => col should become the sorter group column + col = this.__createGroup(parent); + col.__sortColumnGroup = true; + // Create the filter column under this sorter group column + this.__createColumn(col, path); + } else { + // In all other cases, col should be a regular column with a renderer + col = document.createElement('vaadin-grid-column'); + parent.appendChild(col); + col.renderer = (root, _column, model) => { + root.textContent = path ? getProperty(path, model.item) : model.item; + }; + } + + if (!this.noHead && path) { + // Create a header renderer for the column (or column group) + col.headerRenderer = (root) => { + if (root.firstElementChild) { + return; + } + + const label = this._generateHeader(path); + + if (col.__sortColumnGroup || (this.noFilter && !this.noSort)) { + // The column is either the sorter group column or the root level + // sort column (in case a filter isn't used at all) => add the sort indicator + const sorter = document.createElement('vaadin-grid-sorter'); + sorter.setAttribute('path', path); + // TODO: Localize aria labels + sorter.setAttribute('aria-label', `Sort by ${label}`); + sorter.textContent = label; + root.appendChild(sorter); + } else if (!this.noFilter) { + // Filtering is enabled in this crud-grid, create the filter element + const filter = document.createElement('vaadin-grid-filter'); + filter.setAttribute('path', path); + // TODO: Localize aria labels + filter.setAttribute('aria-label', `Filter by ${label}`); + filter.style.display = 'flex'; + + const textField = window.document.createElement('vaadin-text-field'); + textField.setAttribute('theme', 'small'); + textField.setAttribute('focus-target', true); + textField.style.width = '100%'; + if (this.noSort) { + textField.placeholder = label; + } + textField.addEventListener('value-changed', (event) => { + filter.value = event.detail.value; + }); + + filter.appendChild(textField); + root.appendChild(filter); + } else if (this.noSort && this.noFilter) { + // Neither sorter nor filter are enabled, just add the label + root.textContent = label; + } + }; + } + } + + /** + * Creates the column structure for the (sub)object. + * + * @param {HTMLElement} parent May be the crud-grid or a column group. + * @param {Object} object The object to create the sub-columns for. + * @param {string} path The property path from the root item to the object. + * @param {number} depth The depth of the object in the object hierarchy. + * @private + */ + __createColumns(parent, object, path, depth) { + if (object && typeof object === 'object') { + // Iterate over the object properties + Object.keys(object).forEach((prop) => { + if (!this.include && this.exclude && this.exclude.test(prop)) { + return; + } + // Sub-object of the current object + const subObject = object[prop]; + // Full path to the sub-object + const subObjectPath = path ? `${path}.${prop}` : prop; + + // The column element for the sub-object + let subObjectColumn = parent; + if (!this.noHead && depth > 1) { + const isSubObject = subObject && typeof subObject === 'object'; + // If the sub-object is an actual object, create a column group with the property + // name as the header text, otherwise create a group without a header + subObjectColumn = this.__createGroup(parent, isSubObject ? prop : undefined); + } + + // Run recursively for the sub-object level + this.__createColumns(subObjectColumn, subObject, subObjectPath, depth - 1); + }); + } else if (depth > 1) { + // The object has been fully traversed, but empty wrapping column + // groups are still needed to complete the full object depth + this.__createColumns(this.__createGroup(parent), undefined, path, depth - 1); + } else { + // The column group depth is complete, create the actual leaf column + this.__createColumn(parent, path); + } + } + + /** @private */ + __createGroup(parent, header) { + const grp = document.createElement('vaadin-grid-column-group'); + if (header) { + grp.header = capitalize(header); + } + parent.appendChild(grp); + return grp; + } + }; diff --git a/packages/crud/src/vaadin-crud-grid.d.ts b/packages/crud/src/vaadin-crud-grid.d.ts index f6b690d2f7..760754a3dd 100644 --- a/packages/crud/src/vaadin-crud-grid.d.ts +++ b/packages/crud/src/vaadin-crud-grid.d.ts @@ -9,30 +9,12 @@ * license. */ import { Grid } from '@vaadin/grid/src/vaadin-grid.js'; -import { IncludedMixin } from './vaadin-crud-include-mixin.js'; +import { CrudGridMixin } from './vaadin-crud-grid-mixin.js'; /** * An element used internally by ``. Not intended to be used separately. */ -declare class CrudGrid extends IncludedMixin(Grid) { - /** - * Disable filtering in the generated columns. - * @attr {boolean} no-filter - */ - noFilter: boolean | null | undefined; - - /** - * Disable sorting in the generated columns. - * @attr {boolean} no-sort - */ - noSort: boolean | null | undefined; - - /** - * Do not add headers to columns. - * @attr {boolean} no-head - */ - noHead: boolean | null | undefined; -} +declare class CrudGrid extends CrudGridMixin(Grid) {} declare global { interface HTMLElementTagNameMap { diff --git a/packages/crud/src/vaadin-crud-grid.js b/packages/crud/src/vaadin-crud-grid.js index 17139bdc37..40860cbeb3 100644 --- a/packages/crud/src/vaadin-crud-grid.js +++ b/packages/crud/src/vaadin-crud-grid.js @@ -15,265 +15,19 @@ import '@vaadin/grid/src/vaadin-grid-sorter.js'; import './vaadin-crud-edit-column.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { Grid } from '@vaadin/grid/src/vaadin-grid.js'; -import { capitalize, getProperty } from './vaadin-crud-helpers.js'; -import { IncludedMixin } from './vaadin-crud-include-mixin.js'; +import { CrudGridMixin } from './vaadin-crud-grid-mixin.js'; /** * An element used internally by ``. Not intended to be used separately. * * @extends Grid - * @mixes IncludedMixin + * @mixes CrudGridMixin * @private */ -class CrudGrid extends IncludedMixin(Grid) { +class CrudGrid extends CrudGridMixin(Grid) { static get is() { return 'vaadin-crud-grid'; } - - static get properties() { - return { - /** - * Disable filtering in the generated columns. - * @attr {boolean} no-filter - */ - noFilter: Boolean, - - /** - * Disable sorting in the generated columns. - * @attr {boolean} no-sort - */ - noSort: Boolean, - - /** - * Do not add headers to columns. - * @attr {boolean} no-head - */ - noHead: Boolean, - - /** @private */ - __hideEditColumn: Boolean, - }; - } - - static get observers() { - return ['__onItemsChange(items)', '__onHideEditColumnChange(hideEditColumn)']; - } - - /** @private */ - __onItemsChange(items) { - if ((!this.dataProvider || this.dataProvider === this._arrayDataProvider) && !this.include && items && items[0]) { - this._configure(items[0]); - } - } - - /** @private */ - __onHideEditColumnChange() { - if (this.firstChild) { - this.__toggleEditColumn(); - } - } - - /** @private */ - __toggleEditColumn() { - let editColumn = this.querySelector('vaadin-crud-edit-column'); - if (this.hideEditColumn) { - if (editColumn) { - this.removeChild(editColumn); - } - } else if (!editColumn) { - editColumn = document.createElement('vaadin-crud-edit-column'); - editColumn.frozenToEnd = true; - this.appendChild(editColumn); - } - } - - /** @private */ - __dataProviderWrapper(params, callback) { - this.__dataProvider(params, (items, size) => { - if (this.innerHTML === '' && !this.include && items[0]) { - this._configure(items[0]); - } - callback(items, size); - }); - } - - /** - * @override - * @private - */ - _dataProviderChanged(dataProvider, oldDataProvider) { - if (this._arrayDataProvider === dataProvider) { - super._dataProviderChanged(dataProvider, oldDataProvider); - } else if (this.__dataProviderWrapper !== dataProvider) { - this.innerHTML = ''; - this.__dataProvider = dataProvider; - this.dataProvider = this.__dataProviderWrapper; - super._dataProviderChanged(this.__dataProviderWrapper, oldDataProvider); - } - } - - /** - * Auto-generate grid columns based on the JSON structure of the object provided. - * - * Method will be executed when items or dataProvider is assigned. - * @private - */ - _configure(item) { - this.innerHTML = ''; - this.__createColumns(this, item, undefined, this.__getPropertyDepth(item)); - this.__toggleEditColumn(); - } - - /** - * Return the deepest property depth of the object - * @private - */ - __getPropertyDepth(object) { - if (!object || typeof object !== 'object') { - return 0; - } - - return Object.keys(object).reduce((deepest, prop) => { - if (this.exclude && this.exclude.test(prop)) { - return deepest; - } - return Math.max(deepest, 1 + this.__getPropertyDepth(object[prop])); - }, 0); - } - - /** - * Parse the camelCase column names into sentence case headers. - * @param {string} path - * @return {string} - * @protected - */ - _generateHeader(path) { - return path - .substr(path.lastIndexOf('.') + 1) - .replace(/([A-Z])/gu, '-$1') - .toLowerCase() - .replace(/-/gu, ' ') - .replace(/^./u, (match) => match.toUpperCase()); - } - - /** @private */ - __createColumn(parent, path) { - let col; - if (!this.noFilter && !this.noSort && !parent.__sortColumnGroup) { - // This crud-grid has both a sorter and a filter, but neither has yet been - // created => col should become the sorter group column - col = this.__createGroup(parent); - col.__sortColumnGroup = true; - // Create the filter column under this sorter group column - this.__createColumn(col, path); - } else { - // In all other cases, col should be a regular column with a renderer - col = document.createElement('vaadin-grid-column'); - parent.appendChild(col); - col.renderer = (root, _column, model) => { - root.textContent = path ? getProperty(path, model.item) : model.item; - }; - } - - if (!this.noHead && path) { - // Create a header renderer for the column (or column group) - col.headerRenderer = (root) => { - if (root.firstElementChild) { - return; - } - - const label = this._generateHeader(path); - - if (col.__sortColumnGroup || (this.noFilter && !this.noSort)) { - // The column is either the sorter group column or the root level - // sort column (in case a filter isn't used at all) => add the sort indicator - const sorter = document.createElement('vaadin-grid-sorter'); - sorter.setAttribute('path', path); - // TODO: Localize aria labels - sorter.setAttribute('aria-label', `Sort by ${label}`); - sorter.textContent = label; - root.appendChild(sorter); - } else if (!this.noFilter) { - // Filtering is enabled in this crud-grid, create the filter element - const filter = document.createElement('vaadin-grid-filter'); - filter.setAttribute('path', path); - // TODO: Localize aria labels - filter.setAttribute('aria-label', `Filter by ${label}`); - filter.style.display = 'flex'; - - const textField = window.document.createElement('vaadin-text-field'); - textField.setAttribute('theme', 'small'); - textField.setAttribute('focus-target', true); - textField.style.width = '100%'; - if (this.noSort) { - textField.placeholder = label; - } - textField.addEventListener('value-changed', (event) => { - filter.value = event.detail.value; - }); - - filter.appendChild(textField); - root.appendChild(filter); - } else if (this.noSort && this.noFilter) { - // Neither sorter nor filter are enabled, just add the label - root.textContent = label; - } - }; - } - } - - /** - * Creates the column structure for the (sub)object. - * - * @param {HTMLElement} parent May be the crud-grid or a column group. - * @param {Object} object The object to create the sub-columns for. - * @param {string} path The property path from the root item to the object. - * @param {number} depth The depth of the object in the object hierarchy. - * @private - */ - __createColumns(parent, object, path, depth) { - if (object && typeof object === 'object') { - // Iterate over the object properties - Object.keys(object).forEach((prop) => { - if (!this.include && this.exclude && this.exclude.test(prop)) { - return; - } - // Sub-object of the current object - const subObject = object[prop]; - // Full path to the sub-object - const subObjectPath = path ? `${path}.${prop}` : prop; - - // The column element for the sub-object - let subObjectColumn = parent; - if (!this.noHead && depth > 1) { - const isSubObject = subObject && typeof subObject === 'object'; - // If the sub-object is an actual object, create a column group with the property - // name as the header text, otherwise create a group without a header - subObjectColumn = this.__createGroup(parent, isSubObject ? prop : undefined); - } - - // Run recursively for the sub-object level - this.__createColumns(subObjectColumn, subObject, subObjectPath, depth - 1); - }); - } else if (depth > 1) { - // The object has been fully traversed, but empty wrapping column - // groups are still needed to complete the full object depth - this.__createColumns(this.__createGroup(parent), undefined, path, depth - 1); - } else { - // The column group depth is complete, create the actual leaf column - this.__createColumn(parent, path); - } - } - - /** @private */ - __createGroup(parent, header) { - const grp = document.createElement('vaadin-grid-column-group'); - if (header) { - grp.header = capitalize(header); - } - parent.appendChild(grp); - return grp; - } } defineCustomElement(CrudGrid); diff --git a/packages/crud/src/vaadin-crud-helpers.js b/packages/crud/src/vaadin-crud-helpers.js index 080bd12c63..068090e558 100644 --- a/packages/crud/src/vaadin-crud-helpers.js +++ b/packages/crud/src/vaadin-crud-helpers.js @@ -62,3 +62,7 @@ export function setProperty(path, value, obj) { set(obj, path, value); } } + +export function isValidEditorPosition(editorPosition) { + return ['bottom', 'aside'].includes(editorPosition); +} diff --git a/packages/crud/src/vaadin-crud-mixin.d.ts b/packages/crud/src/vaadin-crud-mixin.d.ts new file mode 100644 index 0000000000..f6e7a2391f --- /dev/null +++ b/packages/crud/src/vaadin-crud-mixin.d.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright (c) 2000 - 2024 Vaadin Ltd. + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See https://vaadin.com/commercial-license-and-service-terms for the full + * license. + */ +import type { Constructor } from '@open-wc/dedupe-mixin'; +import type { GridFilterDefinition, GridSorterDefinition } from '@vaadin/grid/src/vaadin-grid.js'; + +export type CrudDataProviderCallback = (items: T[], size?: number) => void; + +export type CrudDataProviderParams = { + page: number; + pageSize: number; + filters: GridFilterDefinition[]; + sortOrders: GridSorterDefinition[]; +}; + +export type CrudDataProvider = (params: CrudDataProviderParams, callback: CrudDataProviderCallback) => void; + +export type CrudEditorPosition = '' | 'aside' | 'bottom'; + +export interface CrudI18n { + newItem: string; + editItem: string; + saveItem: string; + cancel: string; + deleteItem: string; + editLabel: string; + confirm: { + delete: { + title: string; + content: string; + button: { + confirm: string; + dismiss: string; + }; + }; + cancel: { + title: string; + content: string; + button: { + confirm: string; + dismiss: string; + }; + }; + }; +} + +/** + * Fired when the `editorOpened` property changes. + */ +export type CrudEditorOpenedChangedEvent = CustomEvent<{ value: boolean }>; + +/** + * Fired when the `editedItem` property changes. + */ +export type CrudEditedItemChangedEvent = CustomEvent<{ value: T }>; + +/** + * Fired when the `items` property changes. + */ +export type CrudItemsChangedEvent = CustomEvent<{ value: T[] }>; + +/** + * Fired when the `size` property changes. + */ +export type CrudSizeChangedEvent = CustomEvent<{ value: number }>; + +/** + * Fired when user wants to create a new item. + */ +export type CrudNewEvent = CustomEvent<{ item: null }>; + +/** + * Fired when user wants to edit an existing item. + */ +export type CrudEditEvent = CustomEvent<{ item: T; index: number }>; + +/** + * Fired when user wants to delete item. + */ +export type CrudDeleteEvent = CustomEvent<{ item: T }>; + +/** + * Fired when user discards edition. + */ +export type CrudCancelEvent = CustomEvent<{ item: T }>; + +/** + * Fired when user wants to save a new or an existing item. + */ +export type CrudSaveEvent = CustomEvent<{ item: T; new: boolean }>; + +export type CrudCustomEventMap = { + 'editor-opened-changed': CrudEditorOpenedChangedEvent; + + 'edited-item-changed': CrudEditedItemChangedEvent; + + 'items-changed': CrudItemsChangedEvent; + + 'size-changed': CrudSizeChangedEvent; + + new: CrudNewEvent; + + cancel: CrudCancelEvent; + + delete: CrudDeleteEvent; + + edit: CrudEditEvent; + + save: CrudSaveEvent; +}; + +export type CrudEventMap = CrudCustomEventMap & HTMLElementEventMap; + +/** + * A mixin providing common crud functionality. + */ +export declare function CrudMixin = Constructor>( + base: T, +): Constructor> & T; + +export declare class CrudMixinClass { + /** + * An array containing the items which will be stamped to the column template instances. + */ + items: Item[] | null | undefined; + + /** + * The item being edited in the dialog. + */ + editedItem: Item | null | undefined; + + /** + * Sets how editor will be presented on desktop screen. + * + * Accepted values are: + * - `` (default) - form will open as overlay + * - `bottom` - form will open below the grid + * - `aside` - form will open on the grid side (_right_, if lft and _left_ if rtl) + * @attr {bottom|aside} editor-position + */ + editorPosition: CrudEditorPosition; + + /** + * Enables user to click on row to edit it. + * Note: When enabled, auto-generated grid won't show the edit column. + * @attr {boolean} edit-on-click + */ + editOnClick: boolean; + + /** + * Function that provides items lazily. Receives arguments `params`, `callback` + * + * `params.page` Requested page index + * `params.pageSize` Current page size + * `params.filters` Currently applied filters + * `params.sortOrders` Currently applied sorting orders + * + * `callback(items, size)` Callback function with arguments: + * - `items` Current page of items + * - `size` Total number of items + */ + dataProvider: CrudDataProvider | null | undefined; + + /** + * Disable filtering when grid is autoconfigured. + * @attr {boolean} no-filter + */ + noFilter: boolean | null | undefined; + + /** + * Disable sorting when grid is autoconfigured. + * @attr {boolean} no-sort + */ + noSort: boolean | null | undefined; + + /** + * Remove grid headers when it is autoconfigured. + * @attr {boolean} no-head + */ + noHead: boolean | null | undefined; + + /** + * A comma-separated list of fields to include in the generated grid and the generated editor. + * + * It can be used to explicitly define the field order. + * + * When it is defined [`exclude`](#/elements/vaadin-crud#property-exclude) is ignored. + * + * Default is undefined meaning that all properties in the object should be mapped to fields. + */ + include: string | null | undefined; + + /** + * A comma-separated list of fields to be excluded from the generated grid and the generated editor. + * + * When [`include`](#/elements/vaadin-crud#property-include) is defined, this parameter is ignored. + * + * Default is to exclude all private fields (those properties starting with underscore) + */ + exclude: string | null | undefined; + + /** + * Reflects the opened status of the editor. + */ + editorOpened: boolean | null | undefined; + + /** + * Number of items in the data set which is reported by the grid. + * Typically it reflects the number of filtered items displayed in the grid. + */ + readonly size: number | null | undefined; + + /** + * Controls visibility state of toolbar. + * When set to false toolbar is hidden and shown when set to true. + * @attr {boolean} no-toolbar + */ + noToolbar: boolean; + + /** + * The object used to localize this component. + * For changing the default localization, change the entire + * _i18n_ object or just the property you want to modify. + * + * The object has the following JSON structure and default values: + * + * ``` + * { + * newItem: 'New item', + * editItem: 'Edit item', + * saveItem: 'Save', + * cancel: 'Cancel', + * deleteItem: 'Delete...', + * editLabel: 'Edit', + * confirm: { + * delete: { + * title: 'Confirm delete', + * content: 'Are you sure you want to delete the selected item? This action cannot be undone.', + * button: { + * confirm: 'Delete', + * dismiss: 'Cancel' + * } + * }, + * cancel: { + * title: 'Unsaved changes', + * content: 'There are unsaved modifications to the item.', + * button: { + * confirm: 'Discard', + * dismiss: 'Continue editing' + * } + * } + * } + * } + * ``` + */ + i18n: CrudI18n; + + /** + * A reference to all fields inside the [`_form`](#/elements/vaadin-crud#property-_form) element + */ + protected readonly _fields: HTMLElement[]; +} diff --git a/packages/crud/src/vaadin-crud-mixin.js b/packages/crud/src/vaadin-crud-mixin.js new file mode 100644 index 0000000000..9bfc71af3c --- /dev/null +++ b/packages/crud/src/vaadin-crud-mixin.js @@ -0,0 +1,1046 @@ +/** + * @license + * Copyright (c) 2000 - 2024 Vaadin Ltd. + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See https://vaadin.com/commercial-license-and-service-terms for the full + * license. + */ + +import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; +import { FocusRestorationController } from '@vaadin/a11y-base/src/focus-restoration-controller.js'; +import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js'; +import { SlotController } from '@vaadin/component-base/src/slot-controller.js'; +import { ButtonSlotController, FormSlotController, GridSlotController } from './vaadin-crud-controllers.js'; +import { getProperty, isValidEditorPosition, setProperty } from './vaadin-crud-helpers.js'; + +/** + * A mixin providing common crud functionality. + * + * @polymerMixin + */ +export const CrudMixin = (superClass) => + class extends superClass { + static get properties() { + return { + /** + * A reference to the grid used for displaying the item list + * @private + */ + _grid: { + type: Object, + observer: '__gridChanged', + }, + + /** + * A reference to the editor component which will be teleported to the dialog + * @private + */ + _form: { + type: Object, + observer: '__formChanged', + }, + + /** + * A reference to the save button which will be teleported to the dialog + * @private + */ + _saveButton: { + type: Object, + }, + + /** + * A reference to the delete button which will be teleported to the dialog + * @private + */ + _deleteButton: { + type: Object, + }, + + /** + * A reference to the cancel button which will be teleported to the dialog + * @private + */ + _cancelButton: { + type: Object, + }, + + /** + * A reference to the default editor header element created by the CRUD + * @private + */ + _defaultHeader: { + type: Object, + }, + + /** + * A reference to the "New item" button + * @private + */ + _newButton: { + type: Object, + }, + + /** + * An array containing the items which will be stamped to the column template instances. + * @type {Array | undefined} + */ + items: { + type: Array, + notify: true, + observer: '__itemsChanged', + }, + + /** + * The item being edited in the dialog. + * @type {unknown} + */ + editedItem: { + type: Object, + observer: '__editedItemChanged', + notify: true, + }, + + /** + * Sets how editor will be presented on desktop screen. + * + * Accepted values are: + * - `` (default) - form will open as overlay + * - `bottom` - form will open below the grid + * - `aside` - form will open on the grid side (_right_, if lft and _left_ if rtl) + * @attr {bottom|aside} editor-position + * @type {!CrudEditorPosition} + */ + editorPosition: { + type: String, + value: '', + reflectToAttribute: true, + observer: '__editorPositionChanged', + }, + + /** + * Enables user to click on row to edit it. + * Note: When enabled, auto-generated grid won't show the edit column. + * @attr {boolean} edit-on-click + * @type {boolean} + */ + editOnClick: { + type: Boolean, + value: false, + }, + + /** + * Function that provides items lazily. Receives arguments `params`, `callback` + * + * `params.page` Requested page index + * `params.pageSize` Current page size + * `params.filters` Currently applied filters + * `params.sortOrders` Currently applied sorting orders + * + * `callback(items, size)` Callback function with arguments: + * - `items` Current page of items + * - `size` Total number of items + * @type {CrudDataProvider | undefined} + */ + dataProvider: { + type: Function, + observer: '__dataProviderChanged', + }, + + /** + * Disable filtering when grid is autoconfigured. + * @attr {boolean} no-filter + */ + noFilter: Boolean, + + /** + * Disable sorting when grid is autoconfigured. + * @attr {boolean} no-sort + */ + noSort: Boolean, + + /** + * Remove grid headers when it is autoconfigured. + * @attr {boolean} no-head + */ + noHead: Boolean, + + /** + * A comma-separated list of fields to include in the generated grid and the generated editor. + * + * It can be used to explicitly define the field order. + * + * When it is defined [`exclude`](#/elements/vaadin-crud#property-exclude) is ignored. + * + * Default is undefined meaning that all properties in the object should be mapped to fields. + */ + include: String, + + /** + * A comma-separated list of fields to be excluded from the generated grid and the generated editor. + * + * When [`include`](#/elements/vaadin-crud#property-include) is defined, this parameter is ignored. + * + * Default is to exclude all private fields (those properties starting with underscore) + */ + exclude: String, + + /** + * Reflects the opened status of the editor. + */ + editorOpened: { + type: Boolean, + reflectToAttribute: true, + notify: true, + observer: '__editorOpenedChanged', + }, + + /** + * Number of items in the data set which is reported by the grid. + * Typically it reflects the number of filtered items displayed in the grid. + * + * Note: As with ``, this property updates automatically only + * if `items` is used for data. + */ + size: { + type: Number, + readOnly: true, + notify: true, + }, + + /** + * Controls visibility state of toolbar. + * When set to false toolbar is hidden and shown when set to true. + * @attr {boolean} no-toolbar + */ + noToolbar: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + + /** + * The object used to localize this component. + * For changing the default localization, change the entire + * _i18n_ object or just the property you want to modify. + * + * The object has the following JSON structure and default values: + * + * ``` + * { + * newItem: 'New item', + * editItem: 'Edit item', + * saveItem: 'Save', + * cancel: 'Cancel', + * deleteItem: 'Delete...', + * editLabel: 'Edit', + * confirm: { + * delete: { + * title: 'Confirm delete', + * content: 'Are you sure you want to delete the selected item? This action cannot be undone.', + * button: { + * confirm: 'Delete', + * dismiss: 'Cancel' + * } + * }, + * cancel: { + * title: 'Unsaved changes', + * content: 'There are unsaved modifications to the item.', + * button: { + * confirm: 'Discard', + * dismiss: 'Continue editing' + * } + * } + * } + * } + * ``` + * + * @type {!CrudI18n} + * @default {English/US} + */ + i18n: { + type: Object, + value() { + return { + newItem: 'New item', + editItem: 'Edit item', + saveItem: 'Save', + cancel: 'Cancel', + deleteItem: 'Delete...', + editLabel: 'Edit', + confirm: { + delete: { + title: 'Delete item', + content: 'Are you sure you want to delete this item? This action cannot be undone.', + button: { + confirm: 'Delete', + dismiss: 'Cancel', + }, + }, + cancel: { + title: 'Discard changes', + content: 'There are unsaved changes to this item.', + button: { + confirm: 'Discard', + dismiss: 'Cancel', + }, + }, + }, + }; + }, + }, + + /** @private */ + __dialogAriaLabel: String, + + /** @private */ + __isDirty: Boolean, + + /** @private */ + __isNew: Boolean, + + /** + * @type {boolean} + * @protected + */ + _fullscreen: { + type: Boolean, + observer: '__fullscreenChanged', + }, + + /** + * @type {string} + * @protected + */ + _fullscreenMediaQuery: { + value: '(max-width: 600px), (max-height: 600px)', + }, + }; + } + + static get observers() { + return [ + '__headerPropsChanged(_defaultHeader, __isNew, i18n.newItem, i18n.editItem)', + '__formPropsChanged(_form, _theme, include, exclude)', + '__gridPropsChanged(_grid, _theme, include, exclude, noFilter, noHead, noSort, items)', + '__i18nChanged(i18n, _grid)', + '__editOnClickChanged(editOnClick, _grid)', + '__saveButtonPropsChanged(_saveButton, i18n.saveItem, __isDirty)', + '__cancelButtonPropsChanged(_cancelButton, i18n.cancel)', + '__deleteButtonPropsChanged(_deleteButton, i18n.deleteItem, __isNew)', + '__newButtonPropsChanged(_newButton, i18n.newItem)', + ]; + } + + constructor() { + super(); + + this.__cancel = this.__cancel.bind(this); + this.__delete = this.__delete.bind(this); + this.__save = this.__save.bind(this); + this.__new = this.__new.bind(this); + this.__onFormChange = this.__onFormChange.bind(this); + this.__onGridEdit = this.__onGridEdit.bind(this); + this.__onGridSizeChanged = this.__onGridSizeChanged.bind(this); + this.__onGridActiveItemChanged = this.__onGridActiveItemChanged.bind(this); + + this.__focusRestorationController = new FocusRestorationController(); + } + + /** @protected */ + get _headerNode() { + return this._headerController && this._headerController.node; + } + + /** + * A reference to all fields inside the [`_form`](#/elements/vaadin-crud#property-_form) element + * @return {!Array} + * @protected + */ + get _fields() { + if (!this.__fields || !this.__fields.length) { + this.__fields = Array.from(this._form.querySelectorAll('*')).filter((e) => e.validate || e.checkValidity); + } + return this.__fields; + } + + /** @protected */ + ready() { + super.ready(); + + this.$.dialog.$.overlay.addEventListener('vaadin-overlay-outside-click', this.__cancel); + this.$.dialog.$.overlay.addEventListener('vaadin-overlay-escape-press', this.__cancel); + + this._headerController = new SlotController(this, 'header', 'h3', { + initializer: (node) => { + this._defaultHeader = node; + }, + }); + this.addController(this._headerController); + + this._gridController = new GridSlotController(this); + this.addController(this._gridController); + + this.addController(new FormSlotController(this)); + + // Init controllers in `ready()` (not constructor) so that Flow can set `_noDefaultButtons` + this._newButtonController = new ButtonSlotController(this, 'new', 'primary', this._noDefaultButtons); + this._saveButtonController = new ButtonSlotController(this, 'save', 'primary', this._noDefaultButtons); + this._cancelButtonController = new ButtonSlotController(this, 'cancel', 'tertiary', this._noDefaultButtons); + this._deleteButtonController = new ButtonSlotController(this, 'delete', 'tertiary error', this._noDefaultButtons); + + this.addController(this._newButtonController); + + // NOTE: order in which buttons are added should match the order of slots in template + this.addController(this._saveButtonController); + this.addController(this._cancelButtonController); + this.addController(this._deleteButtonController); + + this.addController( + new MediaQueryController(this._fullscreenMediaQuery, (matches) => { + this._fullscreen = matches; + }), + ); + + this.addController(this.__focusRestorationController); + } + + /** + * @param {boolean} isDirty + * @private + */ + __isSaveBtnDisabled(isDirty) { + // Used instead of isDirty property binding in order to enable overriding of the behavior + // by overriding the method (i.e. from Flow component) + return !isDirty; + } + + /** + * @param {HTMLElement | undefined} headerNode + * @param {boolean} isNew + * @param {string} i18nNewItem + * @param {string} i18nEditItem + * @private + */ + __headerPropsChanged(headerNode, isNew, i18nNewItem, i18nEditItem) { + if (headerNode) { + headerNode.textContent = isNew ? i18nNewItem : i18nEditItem; + } + } + + /** + * @param {CrudI18n} i18n + * @param {CrudGrid | Grid} grid + * @private + */ + __i18nChanged(i18n, grid) { + if (!grid) { + return; + } + + afterNextRender(grid, () => { + Array.from(grid.querySelectorAll('vaadin-crud-edit-column')).forEach((column) => { + column.ariaLabel = i18n.editLabel; + }); + }); + } + + /** @private */ + __editorPositionChanged(editorPosition) { + if (isValidEditorPosition(editorPosition)) { + return; + } + this.editorPosition = ''; + } + + /** @private */ + __editorOpenedChanged(opened, oldOpened) { + if (!opened && oldOpened) { + this.__closeEditor(); + } else { + this.__formChanged(this._form); + } + + if (opened) { + this.__ensureChildren(); + + // When using bottom / aside editor position, + // auto-focus the editor element on open. + if (this._form.parentElement === this) { + this.$.editor.setAttribute('tabindex', '0'); + this.$.editor.focus(); + } else { + this.$.editor.removeAttribute('tabindex'); + } + } + + this.__toggleToolbar(); + + // Make sure to reset scroll position + this.$.scroller.scrollTop = 0; + } + + /** @private */ + __fullscreenChanged(fullscreen, oldFullscreen) { + if (fullscreen || oldFullscreen) { + this.__toggleToolbar(); + + this.__ensureChildren(); + + this.toggleAttribute('fullscreen', fullscreen); + } + } + + /** @private */ + __toggleToolbar() { + // Hide toolbar to give more room for the editor when it's positioned below the grid + if (this.editorPosition === 'bottom' && !this._fullscreen) { + this.$.toolbar.style.display = this.editorOpened ? 'none' : ''; + } + } + + /** @private */ + __moveChildNodes(target) { + const nodes = [this._headerNode, this._form, this._saveButton, this._cancelButton, this._deleteButton]; + if (!nodes.every((node) => node instanceof HTMLElement)) { + return; + } + + // Teleport header node, form, and the buttons to corresponding slots. + // NOTE: order in which buttons are moved matches the order of slots. + nodes.forEach((node) => { + target.appendChild(node); + }); + + // Wait to set label until slotted element has been moved. + setTimeout(() => { + this.__dialogAriaLabel = this._headerNode.textContent.trim(); + }); + } + + /** @private */ + __shouldOpenDialog(fullscreen, editorPosition) { + return editorPosition === '' || fullscreen; + } + + /** @private */ + __ensureChildren() { + if (this.__shouldOpenDialog(this._fullscreen, this.editorPosition)) { + // Move form to dialog + this.__moveChildNodes(this.$.dialog.$.overlay); + } else { + // Move form to crud + this.__moveChildNodes(this); + } + } + + /** @private */ + __computeDialogOpened(opened, fullscreen, editorPosition) { + // Only open dialog when editorPosition is "" or fullscreen is set + return this.__shouldOpenDialog(fullscreen, editorPosition) ? opened : false; + } + + /** @private */ + __computeEditorHidden(opened, fullscreen, editorPosition) { + // Only show editor when editorPosition is "bottom" or "aside" + if (['aside', 'bottom'].includes(editorPosition) && !fullscreen) { + return !opened; + } + + return true; + } + + /** @private */ + __onDialogOpened(event) { + this.editorOpened = event.detail.value; + } + + /** @private */ + __onGridEdit(event) { + event.stopPropagation(); + this.__confirmBeforeChangingEditedItem(event.detail.item); + } + + /** @private */ + __onFormChange() { + this.__isDirty = true; + } + + /** @private */ + __onGridSizeChanged() { + this._setSize(this._grid.size); + } + + /** + * @param {CrudGrid | Grid} grid + * @param {CrudGrid | Grid | undefined} oldGrid + * @private + */ + __gridChanged(grid, oldGrid) { + if (oldGrid) { + oldGrid.removeEventListener('edit', this.__onGridEdit); + oldGrid.removeEventListener('size-changed', this.__onGridSizeChanged); + } + if (this.dataProvider) { + this.__dataProviderChanged(this.dataProvider); + } + if (this.editedItem) { + this.__editedItemChanged(this.editedItem); + } + grid.addEventListener('edit', this.__onGridEdit); + grid.addEventListener('size-changed', this.__onGridSizeChanged); + this.__onGridSizeChanged(); + } + + /** + * @param {HTMLElement | undefined | null} form + * @param {HTMLElement | undefined | null} oldForm + * @private + */ + __formChanged(form, oldForm) { + if (oldForm && oldForm.parentElement) { + oldForm.parentElement.removeChild(oldForm); + oldForm.removeEventListener('change', this.__onFormChange); + oldForm.removeEventListener('input', this.__onFormChange); + } + if (!form) { + return; + } + if (this.items) { + this.__itemsChanged(this.items); + } + if (this.editedItem) { + this.__editedItemChanged(this.editedItem); + } + form.addEventListener('change', this.__onFormChange); + form.addEventListener('input', this.__onFormChange); + } + + /** + * @param {HTMLElement | undefined} form + * @param {string} theme + * @param {string | string[] | undefined} include + * @param {string | RegExp} exclude + * @private + */ + __formPropsChanged(form, theme, include, exclude) { + if (form) { + form.include = include; + form.exclude = exclude; + + if (theme) { + form.setAttribute('theme', theme); + } else { + form.removeAttribute('theme'); + } + } + } + + /** + * @param {HTMLElement | undefined} grid + * @param {string} theme + * @param {string | string[] | undefined} include + * @param {string | RegExp} exclude + * @param {boolean} noFilter + * @param {boolean} noHead + * @param {boolean} noSort + * @param {Array | undefined} items + * @private + */ + // eslint-disable-next-line @typescript-eslint/max-params + __gridPropsChanged(grid, theme, include, exclude, noFilter, noHead, noSort, items) { + if (!grid) { + return; + } + + if (grid === this._gridController.defaultNode) { + grid.noFilter = noFilter; + grid.noHead = noHead; + grid.noSort = noSort; + grid.include = include; + grid.exclude = exclude; + + if (theme) { + grid.setAttribute('theme', theme); + } else { + grid.removeAttribute('theme'); + } + } + + grid.items = items; + } + + /** + * @param {HTMLElement | undefined} saveButton + * @param {string} i18nLabel + * @param {boolean} isDirty + * @private + */ + __saveButtonPropsChanged(saveButton, i18nLabel, isDirty) { + if (saveButton) { + saveButton.toggleAttribute('disabled', this.__isSaveBtnDisabled(isDirty)); + + if (saveButton === this._saveButtonController.defaultNode) { + saveButton.textContent = i18nLabel; + } + } + } + + /** + * @param {HTMLElement | undefined} deleteButton + * @param {string} i18nLabel + * @param {boolean} isNew + * @private + */ + __deleteButtonPropsChanged(deleteButton, i18nLabel, isNew) { + if (deleteButton) { + deleteButton.toggleAttribute('hidden', isNew); + + if (deleteButton === this._deleteButtonController.defaultNode) { + deleteButton.textContent = i18nLabel; + } + } + } + + /** + * @param {HTMLElement | undefined} cancelButton + * @param {string} i18nLabel + * @private + */ + __cancelButtonPropsChanged(cancelButton, i18nLabel) { + if (cancelButton && cancelButton === this._cancelButtonController.defaultNode) { + cancelButton.textContent = i18nLabel; + } + } + + /** + * @param {HTMLElement | undefined} newButton + * @param {string} i18nNewItem + * @private + */ + __newButtonPropsChanged(newButton, i18nNewItem) { + if (newButton && newButton === this._newButtonController.defaultNode) { + newButton.textContent = i18nNewItem; + } + } + + /** @private */ + __dataProviderChanged(dataProvider) { + if (this._grid) { + this._grid.dataProvider = this.__createDataProviderProxy(dataProvider); + } + } + + /** @private */ + __editOnClickChanged(editOnClick, grid) { + if (!grid) { + return; + } + + grid.hideEditColumn = editOnClick; + + if (editOnClick) { + grid.addEventListener('active-item-changed', this.__onGridActiveItemChanged); + } else { + grid.removeEventListener('active-item-changed', this.__onGridActiveItemChanged); + } + } + + /** @private */ + __onGridActiveItemChanged(event) { + const item = event.detail.value; + if (this.editorOpened && this.__isDirty) { + this.__confirmBeforeChangingEditedItem(item); + return; + } + if (item) { + this.__edit(item); + } else if (!this.__keepOpened) { + this.__closeEditor(); + } + } + + /** @private */ + __confirmBeforeChangingEditedItem(item, keepOpened) { + if ( + this.editorOpened && // Editor opened + this.__isDirty && // Form change has been made + this.editedItem !== item // Item is different + ) { + this.$.confirmCancel.opened = true; + this.addEventListener( + 'cancel', + (event) => { + event.preventDefault(); // Prevent closing the editor + if (item || keepOpened) { + this.__edit(item); + this.__clearItemAndKeepEditorOpened(item, keepOpened); + } else { + this.__closeEditor(); + } + }, + { once: true }, + ); + } else { + this.__edit(item); + this.__clearItemAndKeepEditorOpened(item, keepOpened); + } + } + + /** @private */ + __clearItemAndKeepEditorOpened(item, keepOpened) { + if (!item) { + setTimeout(() => { + this.__keepOpened = keepOpened; + this.editedItem = this._grid.activeItem = undefined; + }); + } + } + + /** @private */ + __createDataProviderProxy(dataProvider) { + return (params, callback) => { + const callbackProxy = (chunk, size) => { + if (chunk && chunk[0]) { + this.__model = chunk[0]; + } + + callback(chunk, size); + }; + + dataProvider(params, callbackProxy); + }; + } + + /** @private */ + __itemsChanged(items) { + if (this.items && this.items[0]) { + this.__model = items[0]; + } + } + + /** @private */ + __editedItemChanged(item) { + if (!this._form) { + return; + } + if (item) { + if (!this._fields.length && this._form._configure) { + if (this.__model) { + this._form._configure(this.__model); + } else { + console.warn( + ' Unable to autoconfigure form because the data structure is unknown. ' + + 'Either specify `include` or ensure at least one item is available beforehand.', + ); + } + } + this._form.item = item; + this._fields.forEach((e) => { + const path = e.path || e.getAttribute('path'); + if (path) { + e.value = getProperty(path, item); + } + }); + + this.__isNew = !!(this.__isNew || (this.items && this.items.indexOf(item) < 0)); + this.editorOpened = true; + } + } + + /** @private */ + __validate() { + return this._fields.every((e) => (e.validate || e.checkValidity).call(e)); + } + + /** @private */ + __setHighlightedItem(item) { + if (this._grid === this._gridController.defaultNode) { + this._grid.selectedItems = item ? [item] : []; + } + } + + /** @private */ + __closeEditor() { + this.editorOpened = false; + this.__isDirty = false; + this.__setHighlightedItem(null); + + // Delay changing the item in order not to modify editor while closing + setTimeout(() => this.__clearItemAndKeepEditorOpened(null, false)); + } + + /** @private */ + __new() { + this.__confirmBeforeChangingEditedItem(null, true); + } + + /** @private */ + __edit(item) { + if (this.editedItem === item) { + return; + } + this.__setHighlightedItem(item); + this.__openEditor(item); + } + + /** @private */ + __fireEvent(type, item) { + const event = new CustomEvent(type, { detail: { item }, cancelable: true }); + this.dispatchEvent(event); + return event.defaultPrevented === false; + } + + /** @private */ + __openEditor(item) { + this.__focusRestorationController.saveFocus(); + + this.__isDirty = false; + this.__isNew = !item; + const result = this.__fireEvent(this.__isNew ? 'new' : 'edit', item); + if (result) { + this.editedItem = item || {}; + } else { + this.editorOpened = true; + } + } + + /** @private */ + __restoreFocusOnDelete() { + if (this._grid._flatSize === 1) { + this._newButton.focus(); + } else { + this._grid._focusFirstVisibleRow(); + } + } + + /** @private */ + __restoreFocusOnSaveOrCancel() { + const focusNode = this.__focusRestorationController.focusNode; + const row = this._grid._getRowContainingNode(focusNode); + if (!row) { + this.__focusRestorationController.restoreFocus(); + return; + } + + if (this._grid._isItemAssignedToRow(this.editedItem, row) && this._grid._isInViewport(row)) { + this.__focusRestorationController.restoreFocus(); + } else { + this._grid._focusFirstVisibleRow(); + } + } + + /** @private */ + __save() { + if (!this.__validate()) { + return; + } + + const item = { ...this.editedItem }; + this._fields.forEach((e) => { + const path = e.path || e.getAttribute('path'); + if (path) { + setProperty(path, e.value, item); + } + }); + const result = this.__fireEvent('save', item); + if (result) { + if (this.__isNew && !this.dataProvider) { + if (!this.items) { + this.items = [item]; + } else { + this.items.push(item); + } + } else { + if (!this.editedItem) { + this.editedItem = {}; + } + Object.assign(this.editedItem, item); + } + + this.__restoreFocusOnSaveOrCancel(); + this._grid.clearCache(); + this.__closeEditor(); + } + } + + /** @private */ + __cancel() { + if (this.__isDirty) { + this.$.confirmCancel.opened = true; + } else { + this.__confirmCancel(); + } + } + + /** @private */ + __confirmCancel() { + const result = this.__fireEvent('cancel', this.editedItem); + if (result) { + this.__restoreFocusOnSaveOrCancel(); + this.__closeEditor(); + } + } + + /** @private */ + __delete() { + this.$.confirmDelete.opened = true; + } + + /** @private */ + __confirmDelete() { + const result = this.__fireEvent('delete', this.editedItem); + if (result) { + if (this.items && this.items.indexOf(this.editedItem) >= 0) { + this.items.splice(this.items.indexOf(this.editedItem), 1); + } + + this.__restoreFocusOnDelete(); + this._grid.clearCache(); + this.__closeEditor(); + } + } + + /** + * Fired when user wants to edit an existing item. If the default is prevented, then + * a new item is not assigned to the form, giving that responsibility to the app, though + * dialog is always opened. + * + * @event edit + * @param {Object} detail.item the item to edit + */ + + /** + * Fired when user wants to create a new item. + * + * @event new + */ + + /** + * Fired when user wants to delete item. If the default is prevented, then + * no action is performed, items array is not modified nor dialog closed + * + * @event delete + * @param {Object} detail.item the item to delete + */ + + /** + * Fired when user discards edition. If the default is prevented, then + * no action is performed, user is responsible to close dialog and reset + * item and grid. + * + * @event cancel + * @param {Object} detail.item the item to delete + */ + + /** + * Fired when user wants to save a new or an existing item. If the default is prevented, then + * no action is performed, items array is not modified nor dialog closed + * + * @event save + * @param {Object} detail.item the item to save + * @param {Object} detail.new whether the item is a new one + */ + }; diff --git a/packages/crud/src/vaadin-crud.d.ts b/packages/crud/src/vaadin-crud.d.ts index b963ba3258..99e2535f72 100644 --- a/packages/crud/src/vaadin-crud.d.ts +++ b/packages/crud/src/vaadin-crud.d.ts @@ -8,117 +8,13 @@ * See https://vaadin.com/commercial-license-and-service-terms for the full * license. */ -import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; -import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import type { GridDefaultItem, GridFilterDefinition, GridSorterDefinition } from '@vaadin/grid/src/vaadin-grid.js'; -import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js'; +import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js'; +import type { GridDefaultItem } from '@vaadin/grid/src/vaadin-grid.js'; +import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import type { CrudEventMap, CrudMixinClass } from './vaadin-crud-mixin.js'; -export type CrudDataProviderCallback = (items: T[], size?: number) => void; - -export type CrudDataProviderParams = { - page: number; - pageSize: number; - filters: GridFilterDefinition[]; - sortOrders: GridSorterDefinition[]; -}; - -export type CrudDataProvider = (params: CrudDataProviderParams, callback: CrudDataProviderCallback) => void; - -export type CrudEditorPosition = '' | 'aside' | 'bottom'; - -export interface CrudI18n { - newItem: string; - editItem: string; - saveItem: string; - cancel: string; - deleteItem: string; - editLabel: string; - confirm: { - delete: { - title: string; - content: string; - button: { - confirm: string; - dismiss: string; - }; - }; - cancel: { - title: string; - content: string; - button: { - confirm: string; - dismiss: string; - }; - }; - }; -} - -/** - * Fired when the `editorOpened` property changes. - */ -export type CrudEditorOpenedChangedEvent = CustomEvent<{ value: boolean }>; - -/** - * Fired when the `editedItem` property changes. - */ -export type CrudEditedItemChangedEvent = CustomEvent<{ value: T }>; - -/** - * Fired when the `items` property changes. - */ -export type CrudItemsChangedEvent = CustomEvent<{ value: T[] }>; - -/** - * Fired when the `size` property changes. - */ -export type CrudSizeChangedEvent = CustomEvent<{ value: number }>; - -/** - * Fired when user wants to create a new item. - */ -export type CrudNewEvent = CustomEvent<{ item: null }>; - -/** - * Fired when user wants to edit an existing item. - */ -export type CrudEditEvent = CustomEvent<{ item: T; index: number }>; - -/** - * Fired when user wants to delete item. - */ -export type CrudDeleteEvent = CustomEvent<{ item: T }>; - -/** - * Fired when user discards edition. - */ -export type CrudCancelEvent = CustomEvent<{ item: T }>; - -/** - * Fired when user wants to save a new or an existing item. - */ -export type CrudSaveEvent = CustomEvent<{ item: T; new: boolean }>; - -export type CrudCustomEventMap = { - 'editor-opened-changed': CrudEditorOpenedChangedEvent; - - 'edited-item-changed': CrudEditedItemChangedEvent; - - 'items-changed': CrudItemsChangedEvent; - - 'size-changed': CrudSizeChangedEvent; - - new: CrudNewEvent; - - cancel: CrudCancelEvent; - - delete: CrudDeleteEvent; - - edit: CrudEditEvent; - - save: CrudSaveEvent; -}; - -export type CrudEventMap = CrudCustomEventMap & HTMLElementEventMap; +export * from './vaadin-crud-mixin.js'; /** * `` is a Web Component for [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) operations. @@ -262,148 +158,7 @@ export type CrudEventMap = CrudCustomEventMap & HTMLElementEventMap; * @fires {CustomEvent} save - Fired when user wants to save a new or an existing item. * @fires {CustomEvent} cancel - Fired when user discards edition. */ -declare class Crud extends ControllerMixin(ElementMixin(ThemableMixin(HTMLElement))) { - /** - * An array containing the items which will be stamped to the column template instances. - */ - items: Item[] | null | undefined; - - /** - * The item being edited in the dialog. - */ - editedItem: Item | null | undefined; - - /** - * Sets how editor will be presented on desktop screen. - * - * Accepted values are: - * - `` (default) - form will open as overlay - * - `bottom` - form will open below the grid - * - `aside` - form will open on the grid side (_right_, if lft and _left_ if rtl) - * @attr {bottom|aside} editor-position - */ - editorPosition: CrudEditorPosition; - - /** - * Enables user to click on row to edit it. - * Note: When enabled, auto-generated grid won't show the edit column. - * @attr {boolean} edit-on-click - */ - editOnClick: boolean; - - /** - * Function that provides items lazily. Receives arguments `params`, `callback` - * - * `params.page` Requested page index - * `params.pageSize` Current page size - * `params.filters` Currently applied filters - * `params.sortOrders` Currently applied sorting orders - * - * `callback(items, size)` Callback function with arguments: - * - `items` Current page of items - * - `size` Total number of items - */ - dataProvider: CrudDataProvider | null | undefined; - - /** - * Disable filtering when grid is autoconfigured. - * @attr {boolean} no-filter - */ - noFilter: boolean | null | undefined; - - /** - * Disable sorting when grid is autoconfigured. - * @attr {boolean} no-sort - */ - noSort: boolean | null | undefined; - - /** - * Remove grid headers when it is autoconfigured. - * @attr {boolean} no-head - */ - noHead: boolean | null | undefined; - - /** - * A comma-separated list of fields to include in the generated grid and the generated editor. - * - * It can be used to explicitly define the field order. - * - * When it is defined [`exclude`](#/elements/vaadin-crud#property-exclude) is ignored. - * - * Default is undefined meaning that all properties in the object should be mapped to fields. - */ - include: string | null | undefined; - - /** - * A comma-separated list of fields to be excluded from the generated grid and the generated editor. - * - * When [`include`](#/elements/vaadin-crud#property-include) is defined, this parameter is ignored. - * - * Default is to exclude all private fields (those properties starting with underscore) - */ - exclude: string | null | undefined; - - /** - * Reflects the opened status of the editor. - */ - editorOpened: boolean | null | undefined; - - /** - * Number of items in the data set which is reported by the grid. - * Typically it reflects the number of filtered items displayed in the grid. - */ - readonly size: number | null | undefined; - - /** - * Controls visibility state of toolbar. - * When set to false toolbar is hidden and shown when set to true. - * @attr {boolean} no-toolbar - */ - noToolbar: boolean; - - /** - * The object used to localize this component. - * For changing the default localization, change the entire - * _i18n_ object or just the property you want to modify. - * - * The object has the following JSON structure and default values: - * - * ``` - * { - * newItem: 'New item', - * editItem: 'Edit item', - * saveItem: 'Save', - * cancel: 'Cancel', - * deleteItem: 'Delete...', - * editLabel: 'Edit', - * confirm: { - * delete: { - * title: 'Confirm delete', - * content: 'Are you sure you want to delete the selected item? This action cannot be undone.', - * button: { - * confirm: 'Delete', - * dismiss: 'Cancel' - * } - * }, - * cancel: { - * title: 'Unsaved changes', - * content: 'There are unsaved modifications to the item.', - * button: { - * confirm: 'Discard', - * dismiss: 'Continue editing' - * } - * } - * } - * } - * ``` - */ - i18n: CrudI18n; - - /** - * A reference to all fields inside the [`_form`](#/elements/vaadin-crud#property-_form) element - */ - protected readonly _fields: HTMLElement[]; - +declare class Crud extends HTMLElement { addEventListener>( type: K, listener: (this: Crud, ev: CrudEventMap[K]) => void, @@ -417,6 +172,12 @@ declare class Crud extends ControllerMixin(ElementMixin( ): void; } +interface Crud + extends ElementMixinClass, + ThemableMixinClass, + ControllerMixinClass, + CrudMixinClass {} + declare global { interface HTMLElementTagNameMap { 'vaadin-crud': Crud; diff --git a/packages/crud/src/vaadin-crud.js b/packages/crud/src/vaadin-crud.js index 0f89532c40..524aa88014 100644 --- a/packages/crud/src/vaadin-crud.js +++ b/packages/crud/src/vaadin-crud.js @@ -13,17 +13,12 @@ import '@vaadin/confirm-dialog/src/vaadin-confirm-dialog.js'; import './vaadin-crud-dialog.js'; import './vaadin-crud-grid.js'; import './vaadin-crud-form.js'; -import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; -import { FocusRestorationController } from '@vaadin/a11y-base/src/focus-restoration-controller.js'; import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js'; -import { SlotController } from '@vaadin/component-base/src/slot-controller.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; -import { ButtonSlotController, FormSlotController, GridSlotController } from './vaadin-crud-controllers.js'; -import { getProperty, setProperty } from './vaadin-crud-helpers.js'; +import { CrudMixin } from './vaadin-crud-mixin.js'; import { crudStyles } from './vaadin-crud-styles.js'; registerStyles('vaadin-crud', crudStyles, { moduleId: 'vaadin-crud-styles' }); @@ -175,8 +170,9 @@ registerStyles('vaadin-crud', crudStyles, { moduleId: 'vaadin-crud-styles' }); * @mixes ControllerMixin * @mixes ElementMixin * @mixes ThemableMixin + * @mixes CrudMixin */ -class Crud extends ControllerMixin(ElementMixin(ThemableMixin(PolymerElement))) { +class Crud extends ControllerMixin(ElementMixin(ThemableMixin(CrudMixin(PolymerElement)))) { static get template() { return html`
@@ -255,1032 +251,6 @@ class Crud extends ControllerMixin(ElementMixin(ThemableMixin(PolymerElement))) static get cvdlName() { return 'vaadin-crud'; } - - static get properties() { - return { - /** - * A reference to the grid used for displaying the item list - * @private - */ - _grid: { - type: Object, - observer: '__gridChanged', - }, - - /** - * A reference to the editor component which will be teleported to the dialog - * @private - */ - _form: { - type: Object, - observer: '__formChanged', - }, - - /** - * A reference to the save button which will be teleported to the dialog - * @private - */ - _saveButton: { - type: Object, - }, - - /** - * A reference to the delete button which will be teleported to the dialog - * @private - */ - _deleteButton: { - type: Object, - }, - - /** - * A reference to the cancel button which will be teleported to the dialog - * @private - */ - _cancelButton: { - type: Object, - }, - - /** - * A reference to the default editor header element created by the CRUD - * @private - */ - _defaultHeader: { - type: Object, - }, - - /** - * A reference to the "New item" button - * @private - */ - _newButton: { - type: Object, - }, - - /** - * An array containing the items which will be stamped to the column template instances. - * @type {Array | undefined} - */ - items: { - type: Array, - notify: true, - observer: '__itemsChanged', - }, - - /** - * The item being edited in the dialog. - * @type {unknown} - */ - editedItem: { - type: Object, - observer: '__editedItemChanged', - notify: true, - }, - - /** - * Sets how editor will be presented on desktop screen. - * - * Accepted values are: - * - `` (default) - form will open as overlay - * - `bottom` - form will open below the grid - * - `aside` - form will open on the grid side (_right_, if lft and _left_ if rtl) - * @attr {bottom|aside} editor-position - * @type {!CrudEditorPosition} - */ - editorPosition: { - type: String, - value: '', - reflectToAttribute: true, - observer: '__editorPositionChanged', - }, - - /** - * Enables user to click on row to edit it. - * Note: When enabled, auto-generated grid won't show the edit column. - * @attr {boolean} edit-on-click - * @type {boolean} - */ - editOnClick: { - type: Boolean, - value: false, - }, - - /** - * Function that provides items lazily. Receives arguments `params`, `callback` - * - * `params.page` Requested page index - * `params.pageSize` Current page size - * `params.filters` Currently applied filters - * `params.sortOrders` Currently applied sorting orders - * - * `callback(items, size)` Callback function with arguments: - * - `items` Current page of items - * - `size` Total number of items - * @type {CrudDataProvider | undefined} - */ - dataProvider: { - type: Function, - observer: '__dataProviderChanged', - }, - - /** - * Disable filtering when grid is autoconfigured. - * @attr {boolean} no-filter - */ - noFilter: Boolean, - - /** - * Disable sorting when grid is autoconfigured. - * @attr {boolean} no-sort - */ - noSort: Boolean, - - /** - * Remove grid headers when it is autoconfigured. - * @attr {boolean} no-head - */ - noHead: Boolean, - - /** - * A comma-separated list of fields to include in the generated grid and the generated editor. - * - * It can be used to explicitly define the field order. - * - * When it is defined [`exclude`](#/elements/vaadin-crud#property-exclude) is ignored. - * - * Default is undefined meaning that all properties in the object should be mapped to fields. - */ - include: String, - - /** - * A comma-separated list of fields to be excluded from the generated grid and the generated editor. - * - * When [`include`](#/elements/vaadin-crud#property-include) is defined, this parameter is ignored. - * - * Default is to exclude all private fields (those properties starting with underscore) - */ - exclude: String, - - /** - * Reflects the opened status of the editor. - */ - editorOpened: { - type: Boolean, - reflectToAttribute: true, - notify: true, - observer: '__editorOpenedChanged', - }, - - /** - * Number of items in the data set which is reported by the grid. - * Typically it reflects the number of filtered items displayed in the grid. - * - * Note: As with ``, this property updates automatically only - * if `items` is used for data. - */ - size: { - type: Number, - readOnly: true, - notify: true, - }, - - /** - * Controls visibility state of toolbar. - * When set to false toolbar is hidden and shown when set to true. - * @attr {boolean} no-toolbar - */ - noToolbar: { - type: Boolean, - value: false, - reflectToAttribute: true, - }, - - /** - * The object used to localize this component. - * For changing the default localization, change the entire - * _i18n_ object or just the property you want to modify. - * - * The object has the following JSON structure and default values: - * - * ``` - * { - * newItem: 'New item', - * editItem: 'Edit item', - * saveItem: 'Save', - * cancel: 'Cancel', - * deleteItem: 'Delete...', - * editLabel: 'Edit', - * confirm: { - * delete: { - * title: 'Confirm delete', - * content: 'Are you sure you want to delete the selected item? This action cannot be undone.', - * button: { - * confirm: 'Delete', - * dismiss: 'Cancel' - * } - * }, - * cancel: { - * title: 'Unsaved changes', - * content: 'There are unsaved modifications to the item.', - * button: { - * confirm: 'Discard', - * dismiss: 'Continue editing' - * } - * } - * } - * } - * ``` - * - * @type {!CrudI18n} - * @default {English/US} - */ - i18n: { - type: Object, - value() { - return { - newItem: 'New item', - editItem: 'Edit item', - saveItem: 'Save', - cancel: 'Cancel', - deleteItem: 'Delete...', - editLabel: 'Edit', - confirm: { - delete: { - title: 'Delete item', - content: 'Are you sure you want to delete this item? This action cannot be undone.', - button: { - confirm: 'Delete', - dismiss: 'Cancel', - }, - }, - cancel: { - title: 'Discard changes', - content: 'There are unsaved changes to this item.', - button: { - confirm: 'Discard', - dismiss: 'Cancel', - }, - }, - }, - }; - }, - }, - - /** @private */ - __dialogAriaLabel: String, - - /** @private */ - __isDirty: Boolean, - - /** @private */ - __isNew: Boolean, - - /** - * @type {boolean} - * @protected - */ - _fullscreen: { - type: Boolean, - observer: '__fullscreenChanged', - }, - - /** - * @type {string} - * @protected - */ - _fullscreenMediaQuery: { - value: '(max-width: 600px), (max-height: 600px)', - }, - }; - } - - static get observers() { - return [ - '__headerPropsChanged(_defaultHeader, __isNew, i18n.newItem, i18n.editItem)', - '__formPropsChanged(_form, _theme, include, exclude)', - '__gridPropsChanged(_grid, _theme, include, exclude, noFilter, noHead, noSort, items)', - '__i18nChanged(i18n, _grid)', - '__editOnClickChanged(editOnClick, _grid)', - '__saveButtonPropsChanged(_saveButton, i18n.saveItem, __isDirty)', - '__cancelButtonPropsChanged(_cancelButton, i18n.cancel)', - '__deleteButtonPropsChanged(_deleteButton, i18n.deleteItem, __isNew)', - '__newButtonPropsChanged(_newButton, i18n.newItem)', - ]; - } - - /** @private */ - static _isValidEditorPosition(editorPosition) { - return ['bottom', 'aside'].includes(editorPosition); - } - - constructor() { - super(); - - this.__cancel = this.__cancel.bind(this); - this.__delete = this.__delete.bind(this); - this.__save = this.__save.bind(this); - this.__new = this.__new.bind(this); - this.__onFormChange = this.__onFormChange.bind(this); - this.__onGridEdit = this.__onGridEdit.bind(this); - this.__onGridSizeChanged = this.__onGridSizeChanged.bind(this); - this.__onGridActiveItemChanged = this.__onGridActiveItemChanged.bind(this); - - this.__focusRestorationController = new FocusRestorationController(); - } - - /** @protected */ - get _headerNode() { - return this._headerController && this._headerController.node; - } - - /** - * A reference to all fields inside the [`_form`](#/elements/vaadin-crud#property-_form) element - * @return {!Array} - * @protected - */ - get _fields() { - if (!this.__fields || !this.__fields.length) { - this.__fields = Array.from(this._form.querySelectorAll('*')).filter((e) => e.validate || e.checkValidity); - } - return this.__fields; - } - - /** @protected */ - ready() { - super.ready(); - - this.$.dialog.$.overlay.addEventListener('vaadin-overlay-outside-click', this.__cancel); - this.$.dialog.$.overlay.addEventListener('vaadin-overlay-escape-press', this.__cancel); - - this._headerController = new SlotController(this, 'header', 'h3', { - initializer: (node) => { - this._defaultHeader = node; - }, - }); - this.addController(this._headerController); - - this._gridController = new GridSlotController(this); - this.addController(this._gridController); - - this.addController(new FormSlotController(this)); - - // Init controllers in `ready()` (not constructor) so that Flow can set `_noDefaultButtons` - this._newButtonController = new ButtonSlotController(this, 'new', 'primary', this._noDefaultButtons); - this._saveButtonController = new ButtonSlotController(this, 'save', 'primary', this._noDefaultButtons); - this._cancelButtonController = new ButtonSlotController(this, 'cancel', 'tertiary', this._noDefaultButtons); - this._deleteButtonController = new ButtonSlotController(this, 'delete', 'tertiary error', this._noDefaultButtons); - - this.addController(this._newButtonController); - - // NOTE: order in which buttons are added should match the order of slots in template - this.addController(this._saveButtonController); - this.addController(this._cancelButtonController); - this.addController(this._deleteButtonController); - - this.addController( - new MediaQueryController(this._fullscreenMediaQuery, (matches) => { - this._fullscreen = matches; - }), - ); - - this.addController(this.__focusRestorationController); - } - - /** - * @param {boolean} isDirty - * @private - */ - __isSaveBtnDisabled(isDirty) { - // Used instead of isDirty property binding in order to enable overriding of the behavior - // by overriding the method (i.e. from Flow component) - return !isDirty; - } - - /** - * @param {HTMLElement | undefined} headerNode - * @param {boolean} isNew - * @param {string} i18nNewItem - * @param {string} i18nEditItem - * @private - */ - __headerPropsChanged(headerNode, isNew, i18nNewItem, i18nEditItem) { - if (headerNode) { - headerNode.textContent = isNew ? i18nNewItem : i18nEditItem; - } - } - - /** - * @param {CrudI18n} i18n - * @param {CrudGrid | Grid} grid - * @private - */ - __i18nChanged(i18n, grid) { - if (!grid) { - return; - } - - afterNextRender(grid, () => { - Array.from(grid.querySelectorAll('vaadin-crud-edit-column')).forEach((column) => { - column.ariaLabel = i18n.editLabel; - }); - }); - } - - /** @private */ - __editorPositionChanged(editorPosition) { - if (Crud._isValidEditorPosition(editorPosition)) { - return; - } - this.editorPosition = ''; - } - - /** @private */ - __editorOpenedChanged(opened, oldOpened) { - if (!opened && oldOpened) { - this.__closeEditor(); - } else { - this.__formChanged(this._form); - } - - if (opened) { - this.__ensureChildren(); - - // When using bottom / aside editor position, - // auto-focus the editor element on open. - if (this._form.parentElement === this) { - this.$.editor.setAttribute('tabindex', '0'); - this.$.editor.focus(); - } else { - this.$.editor.removeAttribute('tabindex'); - } - } - - this.__toggleToolbar(); - - // Make sure to reset scroll position - this.$.scroller.scrollTop = 0; - } - - /** @private */ - __fullscreenChanged(fullscreen, oldFullscreen) { - if (fullscreen || oldFullscreen) { - this.__toggleToolbar(); - - this.__ensureChildren(); - - this.toggleAttribute('fullscreen', fullscreen); - } - } - - /** @private */ - __toggleToolbar() { - // Hide toolbar to give more room for the editor when it's positioned below the grid - if (this.editorPosition === 'bottom' && !this._fullscreen) { - this.$.toolbar.style.display = this.editorOpened ? 'none' : ''; - } - } - - /** @private */ - __moveChildNodes(target) { - const nodes = [this._headerNode, this._form, this._saveButton, this._cancelButton, this._deleteButton]; - if (!nodes.every((node) => node instanceof HTMLElement)) { - return; - } - - // Teleport header node, form, and the buttons to corresponding slots. - // NOTE: order in which buttons are moved matches the order of slots. - nodes.forEach((node) => { - target.appendChild(node); - }); - - // Wait to set label until slotted element has been moved. - setTimeout(() => { - this.__dialogAriaLabel = this._headerNode.textContent.trim(); - }); - } - - /** @private */ - __shouldOpenDialog(fullscreen, editorPosition) { - return editorPosition === '' || fullscreen; - } - - /** @private */ - __ensureChildren() { - if (this.__shouldOpenDialog(this._fullscreen, this.editorPosition)) { - // Move form to dialog - this.__moveChildNodes(this.$.dialog.$.overlay); - } else { - // Move form to crud - this.__moveChildNodes(this); - } - } - - /** @private */ - __computeDialogOpened(opened, fullscreen, editorPosition) { - // Only open dialog when editorPosition is "" or fullscreen is set - return this.__shouldOpenDialog(fullscreen, editorPosition) ? opened : false; - } - - /** @private */ - __computeEditorHidden(opened, fullscreen, editorPosition) { - // Only show editor when editorPosition is "bottom" or "aside" - if (['aside', 'bottom'].includes(editorPosition) && !fullscreen) { - return !opened; - } - - return true; - } - - /** @private */ - __onDialogOpened(event) { - this.editorOpened = event.detail.value; - } - - /** @private */ - __onGridEdit(event) { - event.stopPropagation(); - this.__confirmBeforeChangingEditedItem(event.detail.item); - } - - /** @private */ - __onFormChange() { - this.__isDirty = true; - } - - /** @private */ - __onGridSizeChanged() { - this._setSize(this._grid.size); - } - - /** - * @param {CrudGrid | Grid} grid - * @param {CrudGrid | Grid | undefined} oldGrid - * @private - */ - __gridChanged(grid, oldGrid) { - if (oldGrid) { - oldGrid.removeEventListener('edit', this.__onGridEdit); - oldGrid.removeEventListener('size-changed', this.__onGridSizeChanged); - } - if (this.dataProvider) { - this.__dataProviderChanged(this.dataProvider); - } - if (this.editedItem) { - this.__editedItemChanged(this.editedItem); - } - grid.addEventListener('edit', this.__onGridEdit); - grid.addEventListener('size-changed', this.__onGridSizeChanged); - this.__onGridSizeChanged(); - } - - /** - * @param {HTMLElement | undefined | null} form - * @param {HTMLElement | undefined | null} oldForm - * @private - */ - __formChanged(form, oldForm) { - if (oldForm && oldForm.parentElement) { - oldForm.parentElement.removeChild(oldForm); - oldForm.removeEventListener('change', this.__onFormChange); - oldForm.removeEventListener('input', this.__onFormChange); - } - if (!form) { - return; - } - if (this.items) { - this.__itemsChanged(this.items); - } - if (this.editedItem) { - this.__editedItemChanged(this.editedItem); - } - form.addEventListener('change', this.__onFormChange); - form.addEventListener('input', this.__onFormChange); - } - - /** - * @param {HTMLElement | undefined} form - * @param {string} theme - * @param {string | string[] | undefined} include - * @param {string | RegExp} exclude - * @private - */ - __formPropsChanged(form, theme, include, exclude) { - if (form) { - form.include = include; - form.exclude = exclude; - - if (theme) { - form.setAttribute('theme', theme); - } else { - form.removeAttribute('theme'); - } - } - } - - /** - * @param {HTMLElement | undefined} grid - * @param {string} theme - * @param {string | string[] | undefined} include - * @param {string | RegExp} exclude - * @param {boolean} noFilter - * @param {boolean} noHead - * @param {boolean} noSort - * @param {Array | undefined} items - * @private - */ - // eslint-disable-next-line @typescript-eslint/max-params - __gridPropsChanged(grid, theme, include, exclude, noFilter, noHead, noSort, items) { - if (!grid) { - return; - } - - if (grid === this._gridController.defaultNode) { - grid.noFilter = noFilter; - grid.noHead = noHead; - grid.noSort = noSort; - grid.include = include; - grid.exclude = exclude; - - if (theme) { - grid.setAttribute('theme', theme); - } else { - grid.removeAttribute('theme'); - } - } - - grid.items = items; - } - - /** - * @param {HTMLElement | undefined} saveButton - * @param {string} i18nLabel - * @param {boolean} isDirty - * @private - */ - __saveButtonPropsChanged(saveButton, i18nLabel, isDirty) { - if (saveButton) { - saveButton.toggleAttribute('disabled', this.__isSaveBtnDisabled(isDirty)); - - if (saveButton === this._saveButtonController.defaultNode) { - saveButton.textContent = i18nLabel; - } - } - } - - /** - * @param {HTMLElement | undefined} deleteButton - * @param {string} i18nLabel - * @param {boolean} isNew - * @private - */ - __deleteButtonPropsChanged(deleteButton, i18nLabel, isNew) { - if (deleteButton) { - deleteButton.toggleAttribute('hidden', isNew); - - if (deleteButton === this._deleteButtonController.defaultNode) { - deleteButton.textContent = i18nLabel; - } - } - } - - /** - * @param {HTMLElement | undefined} cancelButton - * @param {string} i18nLabel - * @private - */ - __cancelButtonPropsChanged(cancelButton, i18nLabel) { - if (cancelButton && cancelButton === this._cancelButtonController.defaultNode) { - cancelButton.textContent = i18nLabel; - } - } - - /** - * @param {HTMLElement | undefined} newButton - * @param {string} i18nNewItem - * @private - */ - __newButtonPropsChanged(newButton, i18nNewItem) { - if (newButton && newButton === this._newButtonController.defaultNode) { - newButton.textContent = i18nNewItem; - } - } - - /** @private */ - __dataProviderChanged(dataProvider) { - if (this._grid) { - this._grid.dataProvider = this.__createDataProviderProxy(dataProvider); - } - } - - /** @private */ - __editOnClickChanged(editOnClick, grid) { - if (!grid) { - return; - } - - grid.hideEditColumn = editOnClick; - - if (editOnClick) { - grid.addEventListener('active-item-changed', this.__onGridActiveItemChanged); - } else { - grid.removeEventListener('active-item-changed', this.__onGridActiveItemChanged); - } - } - - /** @private */ - __onGridActiveItemChanged(event) { - const item = event.detail.value; - if (this.editorOpened && this.__isDirty) { - this.__confirmBeforeChangingEditedItem(item); - return; - } - if (item) { - this.__edit(item); - } else if (!this.__keepOpened) { - this.__closeEditor(); - } - } - - /** @private */ - __confirmBeforeChangingEditedItem(item, keepOpened) { - if ( - this.editorOpened && // Editor opened - this.__isDirty && // Form change has been made - this.editedItem !== item // Item is different - ) { - this.$.confirmCancel.opened = true; - this.addEventListener( - 'cancel', - (event) => { - event.preventDefault(); // Prevent closing the editor - if (item || keepOpened) { - this.__edit(item); - this.__clearItemAndKeepEditorOpened(item, keepOpened); - } else { - this.__closeEditor(); - } - }, - { once: true }, - ); - } else { - this.__edit(item); - this.__clearItemAndKeepEditorOpened(item, keepOpened); - } - } - - /** @private */ - __clearItemAndKeepEditorOpened(item, keepOpened) { - if (!item) { - setTimeout(() => { - this.__keepOpened = keepOpened; - this.editedItem = this._grid.activeItem = undefined; - }); - } - } - - /** @private */ - __createDataProviderProxy(dataProvider) { - return (params, callback) => { - const callbackProxy = (chunk, size) => { - if (chunk && chunk[0]) { - this.__model = chunk[0]; - } - - callback(chunk, size); - }; - - dataProvider(params, callbackProxy); - }; - } - - /** @private */ - __itemsChanged(items) { - if (this.items && this.items[0]) { - this.__model = items[0]; - } - } - - /** @private */ - __editedItemChanged(item) { - if (!this._form) { - return; - } - if (item) { - if (!this._fields.length && this._form._configure) { - if (this.__model) { - this._form._configure(this.__model); - } else { - console.warn( - ' Unable to autoconfigure form because the data structure is unknown. ' + - 'Either specify `include` or ensure at least one item is available beforehand.', - ); - } - } - this._form.item = item; - this._fields.forEach((e) => { - const path = e.path || e.getAttribute('path'); - if (path) { - e.value = getProperty(path, item); - } - }); - - this.__isNew = !!(this.__isNew || (this.items && this.items.indexOf(item) < 0)); - this.editorOpened = true; - } - } - - /** @private */ - __validate() { - return this._fields.every((e) => (e.validate || e.checkValidity).call(e)); - } - - /** @private */ - __setHighlightedItem(item) { - if (this._grid === this._gridController.defaultNode) { - this._grid.selectedItems = item ? [item] : []; - } - } - - /** @private */ - __closeEditor() { - this.editorOpened = false; - this.__isDirty = false; - this.__setHighlightedItem(null); - - // Delay changing the item in order not to modify editor while closing - setTimeout(() => this.__clearItemAndKeepEditorOpened(null, false)); - } - - /** @private */ - __new() { - this.__confirmBeforeChangingEditedItem(null, true); - } - - /** @private */ - __edit(item) { - if (this.editedItem === item) { - return; - } - this.__setHighlightedItem(item); - this.__openEditor(item); - } - - /** @private */ - __fireEvent(type, item) { - const event = new CustomEvent(type, { detail: { item }, cancelable: true }); - this.dispatchEvent(event); - return event.defaultPrevented === false; - } - - /** @private */ - __openEditor(item) { - this.__focusRestorationController.saveFocus(); - - this.__isDirty = false; - this.__isNew = !item; - const result = this.__fireEvent(this.__isNew ? 'new' : 'edit', item); - if (result) { - this.editedItem = item || {}; - } else { - this.editorOpened = true; - } - } - - /** @private */ - __restoreFocusOnDelete() { - if (this._grid._flatSize === 1) { - this._newButton.focus(); - } else { - this._grid._focusFirstVisibleRow(); - } - } - - /** @private */ - __restoreFocusOnSaveOrCancel() { - const focusNode = this.__focusRestorationController.focusNode; - const row = this._grid._getRowContainingNode(focusNode); - if (!row) { - this.__focusRestorationController.restoreFocus(); - return; - } - - if (this._grid._isItemAssignedToRow(this.editedItem, row) && this._grid._isInViewport(row)) { - this.__focusRestorationController.restoreFocus(); - } else { - this._grid._focusFirstVisibleRow(); - } - } - - /** @private */ - __save() { - if (!this.__validate()) { - return; - } - - const item = { ...this.editedItem }; - this._fields.forEach((e) => { - const path = e.path || e.getAttribute('path'); - if (path) { - setProperty(path, e.value, item); - } - }); - const result = this.__fireEvent('save', item); - if (result) { - if (this.__isNew && !this.dataProvider) { - if (!this.items) { - this.items = [item]; - } else { - this.items.push(item); - } - } else { - if (!this.editedItem) { - this.editedItem = {}; - } - Object.assign(this.editedItem, item); - } - - this.__restoreFocusOnSaveOrCancel(); - this._grid.clearCache(); - this.__closeEditor(); - } - } - - /** @private */ - __cancel() { - if (this.__isDirty) { - this.$.confirmCancel.opened = true; - } else { - this.__confirmCancel(); - } - } - - /** @private */ - __confirmCancel() { - const result = this.__fireEvent('cancel', this.editedItem); - if (result) { - this.__restoreFocusOnSaveOrCancel(); - this.__closeEditor(); - } - } - - /** @private */ - __delete() { - this.$.confirmDelete.opened = true; - } - - /** @private */ - __confirmDelete() { - const result = this.__fireEvent('delete', this.editedItem); - if (result) { - if (this.items && this.items.indexOf(this.editedItem) >= 0) { - this.items.splice(this.items.indexOf(this.editedItem), 1); - } - - this.__restoreFocusOnDelete(); - this._grid.clearCache(); - this.__closeEditor(); - } - } - - /** - * Fired when user wants to edit an existing item. If the default is prevented, then - * a new item is not assigned to the form, giving that responsibility to the app, though - * dialog is always opened. - * - * @event edit - * @param {Object} detail.item the item to edit - */ - - /** - * Fired when user wants to create a new item. - * - * @event new - */ - - /** - * Fired when user wants to delete item. If the default is prevented, then - * no action is performed, items array is not modified nor dialog closed - * - * @event delete - * @param {Object} detail.item the item to delete - */ - - /** - * Fired when user discards edition. If the default is prevented, then - * no action is performed, user is responsible to close dialog and reset - * item and grid. - * - * @event cancel - * @param {Object} detail.item the item to delete - */ - - /** - * Fired when user wants to save a new or an existing item. If the default is prevented, then - * no action is performed, items array is not modified nor dialog closed - * - * @event save - * @param {Object} detail.item the item to save - * @param {Object} detail.new whether the item is a new one - */ } defineCustomElement(Crud); From 630393c747244c0186734f4d50157e00b5ca5f67 Mon Sep 17 00:00:00 2001 From: Tomi Virkki Date: Fri, 29 Nov 2024 13:53:42 +0200 Subject: [PATCH 2/2] address review comments --- packages/crud/src/vaadin-crud-grid-mixin.d.ts | 10 ++++++++++ packages/crud/src/vaadin-crud-mixin.js | 1 - packages/crud/src/vaadin-crud.js | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/crud/src/vaadin-crud-grid-mixin.d.ts b/packages/crud/src/vaadin-crud-grid-mixin.d.ts index e0b3f0f0c4..21a53853f7 100644 --- a/packages/crud/src/vaadin-crud-grid-mixin.d.ts +++ b/packages/crud/src/vaadin-crud-grid-mixin.d.ts @@ -1,3 +1,13 @@ +/** + * @license + * Copyright (c) 2000 - 2024 Vaadin Ltd. + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See https://vaadin.com/commercial-license-and-service-terms for the full + * license. + */ import type { Constructor } from '@open-wc/dedupe-mixin'; import type { IncludedMixinClass } from './vaadin-crud-include-mixin.js'; diff --git a/packages/crud/src/vaadin-crud-mixin.js b/packages/crud/src/vaadin-crud-mixin.js index 9bfc71af3c..f1a811e651 100644 --- a/packages/crud/src/vaadin-crud-mixin.js +++ b/packages/crud/src/vaadin-crud-mixin.js @@ -8,7 +8,6 @@ * See https://vaadin.com/commercial-license-and-service-terms for the full * license. */ - import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; import { FocusRestorationController } from '@vaadin/a11y-base/src/focus-restoration-controller.js'; import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js'; diff --git a/packages/crud/src/vaadin-crud.js b/packages/crud/src/vaadin-crud.js index 524aa88014..de625ff0f2 100644 --- a/packages/crud/src/vaadin-crud.js +++ b/packages/crud/src/vaadin-crud.js @@ -172,7 +172,7 @@ registerStyles('vaadin-crud', crudStyles, { moduleId: 'vaadin-crud-styles' }); * @mixes ThemableMixin * @mixes CrudMixin */ -class Crud extends ControllerMixin(ElementMixin(ThemableMixin(CrudMixin(PolymerElement)))) { +class Crud extends CrudMixin(ControllerMixin(ElementMixin(ThemableMixin(PolymerElement)))) { static get template() { return html`