diff --git a/src/Umbraco.Web.UI.Client/.vscode/settings.json b/src/Umbraco.Web.UI.Client/.vscode/settings.json index 1f9ce773f9..f4ca096129 100644 --- a/src/Umbraco.Web.UI.Client/.vscode/settings.json +++ b/src/Umbraco.Web.UI.Client/.vscode/settings.json @@ -22,6 +22,7 @@ "umbraco", "Uncategorized", "uninitialize", + "unprovide", "variantable" ], "exportall.config.folderListener": [], diff --git a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/README.md b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/README.md index 4a1b15255a..557c5709cc 100644 --- a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/README.md +++ b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/README.md @@ -3,3 +3,8 @@ This example demonstrates the essence of the Property Dataset. This dashboard implements such, to display a few selected Property Editors and bind the data back to the Dashboard. + + +## SVG code of Icons + +Make sure to use currentColor for fill or stroke color, as that will make the icon adapt to the font color of where its begin used. diff --git a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts index 6f483bd387..20cc50236d 100644 --- a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; +import { type UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; @customElement('example-dataset-dashboard') export class ExampleDatasetDashboard extends UmbElementMixin(LitElement) { diff --git a/src/Umbraco.Web.UI.Client/examples/icons/README.md b/src/Umbraco.Web.UI.Client/examples/icons/README.md new file mode 100644 index 0000000000..42c0147752 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/README.md @@ -0,0 +1,7 @@ +# Icons Example + +This example demonstrates how to registerer your own icons. + +Currently they have to be made as JavaScript files that exports an SVG string. + +Declared as part of a Icon Dictionary in a JavaScript file. diff --git a/src/Umbraco.Web.UI.Client/examples/icons/files/icon-heart.ts b/src/Umbraco.Web.UI.Client/examples/icons/files/icon-heart.ts new file mode 100644 index 0000000000..d8dd6c2be4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/files/icon-heart.ts @@ -0,0 +1,14 @@ +export default ` + + + +`; diff --git a/src/Umbraco.Web.UI.Client/examples/icons/files/icon-wand.ts b/src/Umbraco.Web.UI.Client/examples/icons/files/icon-wand.ts new file mode 100644 index 0000000000..c9425fbae4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/files/icon-wand.ts @@ -0,0 +1,19 @@ +export default ` + + + + + + + + +`; diff --git a/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts new file mode 100644 index 0000000000..5307bde407 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts @@ -0,0 +1,36 @@ +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; + +// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name +@customElement('example-icons-dashboard') +// eslint-disable-next-line local-rules/umb-class-prefix +export class ExampleIconsDashboard extends UmbElementMixin(LitElement) { + override render() { + return html` + +

Custom icons:

+ + +
+ `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + padding: var(--uui-size-layout-1); + } + `, + ]; +} + +export default ExampleIconsDashboard; + +declare global { + interface HTMLElementTagNameMap { + 'example-icons-dashboard': ExampleIconsDashboard; + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/icons/icons-dictionary.ts b/src/Umbraco.Web.UI.Client/examples/icons/icons-dictionary.ts new file mode 100644 index 0000000000..b91cd341ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/icons-dictionary.ts @@ -0,0 +1,10 @@ +export default [ + { + name: 'my-icon-wand', + path: () => import('./files/icon-wand.js'), + }, + { + name: 'my-icon-heart', + path: () => import('./files/icon-heart.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/icons/index.ts b/src/Umbraco.Web.UI.Client/examples/icons/index.ts new file mode 100644 index 0000000000..b53fa46a80 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/icons/index.ts @@ -0,0 +1,21 @@ +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'icons', + name: 'Example Dataset Dashboard', + alias: 'example.dashboard.dataset', + js: () => import('./icons-dictionary.js'), + }, + { + type: 'dashboard', + name: 'Example Icons Dashboard', + alias: 'example.dashboard.icons', + element: () => import('./icons-dashboard.js'), + weight: 900, + meta: { + label: 'Icons example', + pathname: 'icons-example', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/sorter-group.ts b/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/sorter-group.ts index a26b40c1c2..38a4c681f9 100644 --- a/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/sorter-group.ts +++ b/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/sorter-group.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement, repeat, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { UmbSorterConfig, UmbSorterController } from '@umbraco-cms/backoffice/sorter'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; import './sorter-item.js'; import ExampleSorterItem from './sorter-item.js'; diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index d7c58bb739..c316265395 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "@umbraco-cms/backoffice", "version": "14.2.0", - "hasInstallScript": true, "license": "MIT", "workspaces": [ "./src/packages/block", @@ -18,15 +17,24 @@ "./src/packages/documents", "./src/packages/health-check", "./src/packages/language", + "./src/packages/log-viewer", + "./src/packages/markdown-editor", "./src/packages/media", "./src/packages/members", "./src/packages/models-builder", "./src/packages/multi-url-picker", + "./src/packages/packages", + "./src/packages/performance-profiling", "./src/packages/property-editors", + "./src/packages/publish-cache", + "./src/packages/relations", "./src/packages/search", + "./src/packages/static-file", "./src/packages/tags", + "./src/packages/telemetry", "./src/packages/templating", "./src/packages/tiny-mce", + "./src/packages/ufm", "./src/packages/umbraco-news", "./src/packages/user", "./src/packages/webhook" @@ -7905,6 +7913,14 @@ "resolved": "src/packages/language", "link": true }, + "node_modules/@umbraco-backoffice/log-viewer": { + "resolved": "src/packages/log-viewer", + "link": true + }, + "node_modules/@umbraco-backoffice/markdown": { + "resolved": "src/packages/markdown-editor", + "link": true + }, "node_modules/@umbraco-backoffice/media": { "resolved": "src/packages/media", "link": true @@ -7921,18 +7937,42 @@ "resolved": "src/packages/multi-url-picker", "link": true }, + "node_modules/@umbraco-backoffice/package": { + "resolved": "src/packages/packages", + "link": true + }, + "node_modules/@umbraco-backoffice/performance-profiling": { + "resolved": "src/packages/performance-profiling", + "link": true + }, "node_modules/@umbraco-backoffice/property-editors": { "resolved": "src/packages/property-editors", "link": true }, + "node_modules/@umbraco-backoffice/publish-cache": { + "resolved": "src/packages/publish-cache", + "link": true + }, + "node_modules/@umbraco-backoffice/relation": { + "resolved": "src/packages/relations", + "link": true + }, "node_modules/@umbraco-backoffice/search": { "resolved": "src/packages/search", "link": true }, + "node_modules/@umbraco-backoffice/static-file": { + "resolved": "src/packages/static-file", + "link": true + }, "node_modules/@umbraco-backoffice/tag": { "resolved": "src/packages/tags", "link": true }, + "node_modules/@umbraco-backoffice/telemetry": { + "resolved": "src/packages/telemetry", + "link": true + }, "node_modules/@umbraco-backoffice/templating": { "resolved": "src/packages/templating", "link": true @@ -7941,6 +7981,10 @@ "resolved": "src/packages/tiny-mce", "link": true }, + "node_modules/@umbraco-backoffice/ufm": { + "resolved": "src/packages/ufm", + "link": true + }, "node_modules/@umbraco-backoffice/umbraco-news": { "resolved": "src/packages/umbraco-news", "link": true @@ -23145,6 +23189,12 @@ "src/packages/language": { "name": "@umbraco-backoffice/language" }, + "src/packages/log-viewer": { + "name": "@umbraco-backoffice/log-viewer" + }, + "src/packages/markdown-editor": { + "name": "@umbraco-backoffice/markdown" + }, "src/packages/media": { "name": "@umbraco-backoffice/media" }, @@ -23157,21 +23207,42 @@ "src/packages/multi-url-picker": { "name": "@umbraco-backoffice/multi-url-picker" }, + "src/packages/packages": { + "name": "@umbraco-backoffice/package" + }, + "src/packages/performance-profiling": { + "name": "@umbraco-backoffice/performance-profiling" + }, "src/packages/property-editors": { "name": "@umbraco-backoffice/property-editors" }, + "src/packages/publish-cache": { + "name": "@umbraco-backoffice/publish-cache" + }, + "src/packages/relations": { + "name": "@umbraco-backoffice/relation" + }, "src/packages/search": { "name": "@umbraco-backoffice/search" }, + "src/packages/static-file": { + "name": "@umbraco-backoffice/static-file" + }, "src/packages/tags": { "name": "@umbraco-backoffice/tag" }, + "src/packages/telemetry": { + "name": "@umbraco-backoffice/telemetry" + }, "src/packages/templating": { "name": "@umbraco-backoffice/templating" }, "src/packages/tiny-mce": { "name": "@umbraco-backoffice/tiny-mce" }, + "src/packages/ufm": { + "name": "@umbraco-backoffice/ufm" + }, "src/packages/umbraco-news": { "name": "@umbraco-backoffice/umbraco-news" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index c7d3a1c546..1d98a6c6bf 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -58,7 +58,7 @@ "./models": "./dist-cms/packages/core/models/index.js", "./multi-url-picker": "./dist-cms/packages/multi-url-picker/index.js", "./notification": "./dist-cms/packages/core/notification/index.js", - "./object-type": "./dist-cms/packages/object-type/index.js", + "./object-type": "./dist-cms/packages/core/object-type/index.js", "./package": "./dist-cms/packages/packages/package/index.js", "./partial-view": "./dist-cms/packages/templating/partial-views/index.js", "./picker-input": "./dist-cms/packages/core/picker-input/index.js", @@ -137,15 +137,24 @@ "./src/packages/documents", "./src/packages/health-check", "./src/packages/language", + "./src/packages/log-viewer", + "./src/packages/markdown-editor", "./src/packages/media", "./src/packages/members", "./src/packages/models-builder", "./src/packages/multi-url-picker", + "./src/packages/packages", + "./src/packages/performance-profiling", "./src/packages/property-editors", + "./src/packages/publish-cache", + "./src/packages/relations", "./src/packages/search", + "./src/packages/static-file", "./src/packages/tags", + "./src/packages/telemetry", "./src/packages/templating", "./src/packages/tiny-mce", + "./src/packages/ufm", "./src/packages/umbraco-news", "./src/packages/user", "./src/packages/webhook" @@ -164,7 +173,6 @@ "check:paths": "node ./devops/build/check-path-length.js dist-cms 120", "check:circular": "madge --circular --warning --extensions ts ./src", "compile": "tsc", - "postinstall": "npm run generate:tsconfig", "dev": "vite", "dev:server": "VITE_UMBRACO_USE_MSW=off vite", "dev:mock": "VITE_UMBRACO_USE_MSW=on vite", diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts index b5b6c47879..d59e98126c 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts @@ -26,12 +26,15 @@ const CORE_PACKAGES = [ import('../../packages/models-builder/umbraco-package.js'), import('../../packages/multi-url-picker/umbraco-package.js'), import('../../packages/packages/umbraco-package.js'), + import('../../packages/performance-profiling/umbraco-package.js'), import('../../packages/property-editors/umbraco-package.js'), + import('../../packages/publish-cache/umbraco-package.js'), import('../../packages/relations/umbraco-package.js'), import('../../packages/search/umbraco-package.js'), import('../../packages/settings/umbraco-package.js'), import('../../packages/static-file/umbraco-package.js'), import('../../packages/tags/umbraco-package.js'), + import('../../packages/telemetry/umbraco-package.js'), import('../../packages/templating/umbraco-package.js'), import('../../packages/tiny-mce/umbraco-package.js'), import('../../packages/ufm/umbraco-package.js'), diff --git a/src/Umbraco.Web.UI.Client/src/external/lit/index.ts b/src/Umbraco.Web.UI.Client/src/external/lit/index.ts index e7c9bc62c3..a1f3492898 100644 --- a/src/Umbraco.Web.UI.Client/src/external/lit/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/lit/index.ts @@ -1,6 +1,6 @@ export * from 'lit'; export * from 'lit/decorators.js'; -export { directive, AsyncDirective } from 'lit/async-directive.js'; +export { directive, AsyncDirective, type PartInfo } from 'lit/async-directive.js'; export * from 'lit/directives/class-map.js'; export * from 'lit/directives/if-defined.js'; export * from 'lit/directives/map.js'; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts index 2f45cdca03..72fc1695ab 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts @@ -702,6 +702,10 @@ export const data: Array = [ alias: 'blockGroups', value: [{ key: 'demo-block-group-id', name: 'Demo Blocks' }], }, + { + alias: 'layoutStylesheet', + value: '/wwwroot/css/umbraco-blockgridlayout.css' + }, { alias: 'blocks', value: [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index 36f1be78de..8ecfe7f894 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -1,3 +1,5 @@ +import { UmbBlockGridEntriesContext } from '../../context/block-grid-entries.context.js'; +import type { UmbBlockGridEntryElement } from '../block-grid-entry/index.js'; import { getAccumulatedValueOfIndex, getInterpolatedIndexOfPositionInWeightMap, @@ -14,13 +16,12 @@ import { type UmbFormControlValidatorConfig, } from '@umbraco-cms/backoffice/validation'; import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; -import { UmbBlockGridEntriesContext } from '../../context/block-grid-entries.context.js'; -import type { UmbBlockGridEntryElement } from '../block-grid-entry/index.js'; import type { UmbBlockGridLayoutModel } from '@umbraco-cms/backoffice/block-grid'; /** * Notice this utility method is not really shareable with others as it also takes areas into account. [NL] * @param args + * @returns { null | true } */ function resolvePlacementAsGrid(args: resolvePlacementArgs) { // If this has areas, we do not want to move, unless we are at the edge diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts index f14c1594ac..948c21b35a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts @@ -89,6 +89,20 @@ export class UmbBlockGridEntriesContext return this.#layoutColumns.getValue(); } + getMinAllowed() { + if (this.#areaKey) { + return this.#areaType?.minAllowed ?? 0; + } + return this._manager?.getMinAllowed() ?? 0; + } + + getMaxAllowed() { + if (this.#areaKey) { + return this.#areaType?.maxAllowed ?? Infinity; + } + return this._manager?.getMaxAllowed() ?? Infinity; + } + getLayoutContainerElement() { return this.getHostElement().shadowRoot?.querySelector('.umb-block-grid__layout-container') as | HTMLElement @@ -131,7 +145,7 @@ export class UmbBlockGridEntriesContext data: { entityType: 'block', preset: {}, - originData: { areaKey: this.#areaKey, parentUnique: this.#parentUnique }, + originData: { areaKey: this.#areaKey, parentUnique: this.#parentUnique, baseDataPath: this._dataPath }, }, modal: { size: 'medium' }, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts index 15ae596750..dfbf764f72 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts @@ -1,12 +1,13 @@ import type { UmbBlockGridLayoutModel, UmbBlockGridTypeModel } from '../types.js'; import type { UmbBlockGridWorkspaceData } from '../index.js'; import { UmbArrayState, appendToFrozenArray, pushAtToUniqueArray } from '@umbraco-cms/backoffice/observable-api'; -import { removeInitialSlashFromPath, transformServerPathToClientPath } from '@umbraco-cms/backoffice/utils'; +import { removeLastSlashFromPath, transformServerPathToClientPath } from '@umbraco-cms/backoffice/utils'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; import { type UmbBlockDataType, UmbBlockManagerContext } from '@umbraco-cms/backoffice/block'; import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type'; +import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; export const UMB_BLOCK_GRID_DEFAULT_LAYOUT_STYLESHEET = '/umbraco/backoffice/css/umbraco-blockgridlayout.css'; @@ -29,7 +30,7 @@ export class UmbBlockGridManagerContext< if (layoutStylesheet) { // Cause we await initAppUrl in setting the _editorConfiguration, we can trust the appUrl begin here. - return this.#appUrl! + removeInitialSlashFromPath(transformServerPathToClientPath(layoutStylesheet)); + return removeLastSlashFromPath(this.#appUrl!) + transformServerPathToClientPath(layoutStylesheet); } return undefined; }); @@ -38,6 +39,16 @@ export class UmbBlockGridManagerContext< return parseInt(value && value !== '' ? value : '12'); }); + getMinAllowed() { + return this._editorConfiguration.getValue()?.getValueByAlias('validationLimit')?.min ?? 0; + } + + getMaxAllowed() { + return ( + this._editorConfiguration.getValue()?.getValueByAlias('validationLimit')?.max ?? Infinity + ); + } + override setEditorConfiguration(configs: UmbPropertyEditorConfigCollection) { this.#initAppUrl.then(() => { // we await initAppUrl, So the appUrl begin here is available when retrieving the layoutStylesheet. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts index bfa02c4f6b..56ed2f9a2b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts @@ -127,10 +127,7 @@ export class UmbPropertyEditorUIBlockGridAreasConfigElement .key=${area.key}>`, )} - ` + ` : ''; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts index fba0224034..4e29db4375 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts @@ -17,7 +17,12 @@ export const UMB_BLOCK_GRID_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index 4d8764fec6..ef2dd27628 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -10,6 +10,8 @@ import '../inline-list-block/index.js'; import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; import { UmbBlockListEntryContext } from '../../context/block-list-entry.context.js'; import { UMB_BLOCK_LIST, type UmbBlockListLayoutModel } from '../../types.js'; +import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; /** * @element umb-block-list-entry @@ -33,6 +35,16 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper if (!value) return; this._contentUdi = value; this.#context.setContentUdi(value); + + new UmbObserveValidationStateController( + this, + `$.contentData[${UmbDataPathBlockElementDataQuery({ udi: value })}]`, + (hasMessages) => { + this._contentInvalid = hasMessages; + this._blockViewProps.contentInvalid = hasMessages; + }, + 'observeMessagesForContent', + ); } private _contentUdi?: string | undefined; @@ -61,6 +73,14 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @state() _inlineEditingMode?: boolean; + // 'content-invalid' attribute is used for styling purpose. + @property({ type: Boolean, attribute: 'content-invalid', reflect: true }) + _contentInvalid?: boolean; + + // 'settings-invalid' attribute is used for styling purpose. + @property({ type: Boolean, attribute: 'settings-invalid', reflect: true }) + _settingsInvalid?: boolean; + @state() _blockViewProps: UmbBlockEditorCustomViewProperties = { contentUdi: undefined!, @@ -141,6 +161,20 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.settings, (settings) => { this.#updateBlockViewProps({ settings }); + + this.removeUmbControllerByAlias('observeMessagesForSettings'); + if (settings) { + // Observe settings validation state: + new UmbObserveValidationStateController( + this, + `$.settingsData[${UmbDataPathBlockElementDataQuery(settings)}]`, + (hasMessages) => { + this._settingsInvalid = hasMessages; + this._blockViewProps.settingsInvalid = hasMessages; + }, + 'observeMessagesForSettings', + ); + } }, null, ); @@ -218,16 +252,30 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper > ${this._showContentEdit && this._workspaceEditContentPath - ? html` + ? html` + ${this._contentInvalid + ? html`!` + : ''} ` : ''} ${this._hasSettings && this._workspaceEditSettingsPath - ? html` + ? html` + ${this._settingsInvalid + ? html`!` + : ''} ` : ''} - this.#context.requestDelete()}> + this.#context.requestDelete()}> @@ -243,15 +291,41 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper :host { position: relative; display: block; + --umb-block-list-entry-actions-opacity: 0; } + + :host([settings-invalid]), + :host([content-invalid]), + :host(:hover), + :host(:focus-within) { + --umb-block-list-entry-actions-opacity: 1; + } + uui-action-bar { position: absolute; top: var(--uui-size-2); right: var(--uui-size-2); + opacity: var(--umb-block-list-entry-actions-opacity, 0); + transition: opacity 120ms; } :host([drag-placeholder]) { opacity: 0.2; + --umb-block-list-entry-actions-opacity: 0; + } + + :host([settings-invalid])::after, + :host([content-invalid])::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: 1px solid var(--uui-color-danger); + border-radius: var(--uui-border-radius); + } + + uui-badge { + z-index: 2; } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts index 2c167d839a..4e82a91a05 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts @@ -17,7 +17,7 @@ export class UmbRefListBlockElement extends UmbLitElement { @state() _content?: UmbBlockDataType; - @state() + @property() _workspaceEditPath?: string; constructor() { @@ -44,6 +44,7 @@ export class UmbRefListBlockElement extends UmbLitElement { } override render() { + // TODO: apply `slot="name"` to the `umb-ufm-render` element, when UUI supports it. [NL] return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts index 8f74c76255..b2108eb02f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts @@ -46,7 +46,7 @@ export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< .addUniquePaths(['propertyAlias', 'variantId']) .addAdditionalPath('block') .onSetup(() => { - return { data: { entityType: 'block', preset: {} }, modal: { size: 'medium' } }; + return { data: { entityType: 'block', preset: {}, baseDataPath: this._dataPath }, modal: { size: 'medium' } }; }) .observeRouteBuilder((routeBuilder) => { const newPath = routeBuilder({}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts index 2a96c2e0c0..45ffa9c275 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts @@ -1,9 +1,9 @@ +import { UmbBlockListManagerContext } from '../../context/block-list-manager.context.js'; import { UmbBlockListEntriesContext } from '../../context/block-list-entries.context.js'; import type { UmbBlockListLayoutModel, UmbBlockListValueModel } from '../../types.js'; import type { UmbBlockListEntryElement } from '../../components/block-list-entry/index.js'; -import { UmbBlockListManagerContext } from '../../context/block-list-manager.context.js'; import { UMB_BLOCK_LIST_PROPERTY_EDITOR_ALIAS } from './manifests.js'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/lit-element'; import { html, customElement, property, state, repeat, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbPropertyEditorUiElement, UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/extension-registry'; @@ -15,9 +15,14 @@ import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import type { UmbSorterConfig } from '@umbraco-cms/backoffice/sorter'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import type { UmbBlockLayoutBaseModel } from '@umbraco-cms/backoffice/block'; +import { + UmbBlockElementDataValidationPathTranslator, + type UmbBlockLayoutBaseModel, +} from '@umbraco-cms/backoffice/block'; import '../../components/block-list-entry/index.js'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; const SORTER_CONFIG: UmbSorterConfig = { getUniqueOfElement: (element) => { @@ -35,7 +40,10 @@ const SORTER_CONFIG: UmbSorterConfig(UmbLitElement) + implements UmbPropertyEditorUiElement +{ // #sorter = new UmbSorterController(this, { ...SORTER_CONFIG, @@ -44,6 +52,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement }, }); + #validationContext = new UmbValidationContext(this).provide(); + #contentDataPathTranslator?: UmbBlockElementDataValidationPathTranslator; + #settingsDataPathTranslator?: UmbBlockElementDataValidationPathTranslator; + //#catalogueModal: UmbModalRouteRegistrationController; private _value: UmbBlockListValueModel = { @@ -53,7 +65,7 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement }; @property({ attribute: false }) - public set value(value: UmbBlockListValueModel | undefined) { + public override set value(value: UmbBlockListValueModel | undefined) { const buildUpValue: Partial = value ? { ...value } : {}; buildUpValue.layout ??= {}; buildUpValue.contentData ??= []; @@ -64,7 +76,7 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement this.#managerContext.setContents(buildUpValue.contentData); this.#managerContext.setSettings(buildUpValue.settingsData); } - public get value(): UmbBlockListValueModel { + public override get value(): UmbBlockListValueModel | undefined { return this._value; } @@ -121,6 +133,44 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement constructor() { super(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.observe( + context.dataPath, + (dataPath) => { + // Translate paths for content elements: + this.#contentDataPathTranslator?.destroy(); + if (dataPath) { + // Set the data path for the local validation context: + this.#validationContext.setDataPath(dataPath); + + this.#contentDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'contentData'); + } + + // Translate paths for settings elements: + this.#settingsDataPathTranslator?.destroy(); + if (dataPath) { + // Set the data path for the local validation context: + this.#validationContext.setDataPath(dataPath); + + this.#settingsDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'settingsData'); + } + }, + 'observeDataPath', + ); + }); + + this.addValidator( + 'rangeUnderflow', + () => this.localize.term('validation_entriesShort'), + () => !!this._limitMin && this.#entriesContext.getLength() < this._limitMin, + ); + + this.addValidator( + 'rangeOverflow', + () => this.localize.term('validation_entriesExceed'), + () => !!this._limitMax && this.#entriesContext.getLength() > this._limitMax, + ); + this.observe(this.#entriesContext.layoutEntries, (layouts) => { this._layouts = layouts; // Update sorter. @@ -155,6 +205,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement this.dispatchEvent(new UmbPropertyValueChangeEvent()); }; + protected override getFormElement() { + return undefined; + } + override render() { let createPath: string | undefined; if (this._blocks?.length === 1) { @@ -171,7 +225,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement html` - + `, )} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts index e2334ca5a0..89888c14d2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts @@ -15,7 +15,7 @@ export const UMB_BLOCK_LIST_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts index 3e4f13eb02..4571835198 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts @@ -47,7 +47,7 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext< .addUniquePaths(['propertyAlias', 'variantId']) .addAdditionalPath('block') .onSetup(() => { - return { data: { entityType: 'block', preset: {} }, modal: { size: 'medium' } }; + return { data: { entityType: 'block', preset: {}, baseDataPath: this._dataPath }, modal: { size: 'medium' } }; }) .observeRouteBuilder((routeBuilder) => { const newPath = routeBuilder({}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts index 84ca328671..89dbaa9bd7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts @@ -11,7 +11,7 @@ export const UMB_BLOCK_RTE_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/block-type-card/block-type-card.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/block-type-card/block-type-card.element.ts index 6be3216d4b..9edc38b487 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/block-type-card/block-type-card.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/block-type-card/block-type-card.element.ts @@ -6,13 +6,13 @@ import { html, customElement, property, state, ifDefined } from '@umbraco-cms/ba import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; -import { removeInitialSlashFromPath, transformServerPathToClientPath } from '@umbraco-cms/backoffice/utils'; +import { removeLastSlashFromPath, transformServerPathToClientPath } from '@umbraco-cms/backoffice/utils'; @customElement('umb-block-type-card') export class UmbBlockTypeCardElement extends UmbLitElement { // #init: Promise; - #appUrl?: string; + #appUrl: string = ''; #itemManager = new UmbRepositoryItemsManager( this, @@ -28,7 +28,7 @@ export class UmbBlockTypeCardElement extends UmbLitElement { value = transformServerPathToClientPath(value); if (value) { this.#init.then(() => { - this._iconFile = this.#appUrl + removeInitialSlashFromPath(value); + this._iconFile = removeLastSlashFromPath(this.#appUrl) + value; }); } else { this._iconFile = undefined; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/types.ts index dccd64dad7..4bb332af04 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/types.ts @@ -1,4 +1,5 @@ import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/extension-registry'; +export type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/extension-registry'; export interface UmbBlockTypeGroup { name?: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts index 4f8b41066f..cf84248e6d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts @@ -27,12 +27,18 @@ export abstract class UmbBlockEntriesContext< protected _workspacePath = new UmbStringState(undefined); workspacePath = this._workspacePath.asObservable(); + protected _dataPath?: string; + public abstract readonly canCreate: Observable; protected _layoutEntries = new UmbArrayState([], (x) => x.contentUdi); readonly layoutEntries = this._layoutEntries.asObservable(); readonly layoutEntriesLength = this._layoutEntries.asObservablePart((x) => x.length); + getLength() { + return this._layoutEntries.getValue().length; + } + constructor(host: UmbControllerHost, blockManagerContextToken: BlockManagerContextTokenType) { super(host, UMB_BLOCK_ENTRIES_CONTEXT.toString()); @@ -48,6 +54,10 @@ export abstract class UmbBlockEntriesContext< return this._manager!; } + setDataPath(path: string) { + this._dataPath = path; + } + protected abstract _gotBlockManager(): void; // Public methods: diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts index 828e8f118c..0e7cb3f0dc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts @@ -1,4 +1,5 @@ export * from './context/index.js'; export * from './modals/index.js'; export * from './types.js'; +export * from './validation/index.js'; export * from './workspace/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts new file mode 100644 index 0000000000..096eec3967 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts @@ -0,0 +1,28 @@ +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + GetPropertyNameFromPath, + UmbDataPathPropertyValueQuery, + UmbValidationPathTranslatorBase, +} from '@umbraco-cms/backoffice/validation'; + +export class UmbBlockElementDataValidationPathTranslator extends UmbValidationPathTranslatorBase { + constructor(host: UmbControllerHost) { + super(host); + } + + translate(path: string) { + if (!this._context) return; + if (path.indexOf('$.') !== 0) { + // We do not handle this path. + return false; + } + + const rest = path.substring(2); + const key = GetPropertyNameFromPath(rest); + + const specificValue = { alias: key }; + // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: + //return '$.values' + UmbVariantValuesValidationPathTranslator(specificValue) + path.substring(path.indexOf(']')); + return '$.values[' + UmbDataPathPropertyValueQuery(specificValue) + '.value'; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts new file mode 100644 index 0000000000..4ba0cf60f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts @@ -0,0 +1,23 @@ +import { UmbDataPathBlockElementDataQuery } from './data-path-element-data-query.function.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbAbstractArrayValidationPathTranslator } from '@umbraco-cms/backoffice/validation'; + +export class UmbBlockElementDataValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + #propertyName: string; + + constructor(host: UmbControllerHost, propertyName: 'contentData' | 'settingsData') { + super(host, '$.' + propertyName + '[', UmbDataPathBlockElementDataQuery); + this.#propertyName = propertyName; + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + const entry = data[this.#propertyName][index]; + if (!entry || !entry.udi) { + console.log('block did not have UDI', this.#propertyName, index, data); + return false; + } + return entry; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/data-path-element-data-query.function.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/data-path-element-data-query.function.ts new file mode 100644 index 0000000000..c65d92fb82 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/data-path-element-data-query.function.ts @@ -0,0 +1,15 @@ +import type { UmbBlockDataType } from '../types.js'; + +/** + * Validation Data Path Query generator for Block Element Data. + * write a JSON-Path filter similar to `?(@.udi = 'my-udi://1234')` + * @param udi {string} - The udi of the block Element data. + * @param data {{udi: string}} - A data object with the udi property. + * @returns + */ +export function UmbDataPathBlockElementDataQuery(data: Pick): string { + // write a array of strings for each property, where alias must be present and culture and segment are optional + //const filters: Array = [`@.udi = '${udi}'`]; + //return `?(${filters.join(' && ')})`; + return `?(@.udi = '${data.udi}')`; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/index.ts new file mode 100644 index 0000000000..331352a0d8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/validation/index.ts @@ -0,0 +1,2 @@ +export * from './block-data-validation-path-translator.controller.js'; +export * from './data-path-element-data-query.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index 54bcd7aeff..be65432e2b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -4,8 +4,9 @@ import type { UmbContentTypeModel } from '@umbraco-cms/backoffice/content-type'; import { UmbContentTypeStructureManager } from '@umbraco-cms/backoffice/content-type'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { type UmbClassInterface, UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbDocumentTypeDetailRepository } from '@umbraco-cms/backoffice/document-type'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export class UmbBlockElementManager extends UmbControllerBase { // @@ -24,11 +25,17 @@ export class UmbBlockElementManager extends UmbControllerBase { new UmbDocumentTypeDetailRepository(this), ); - constructor(host: UmbControllerHost) { - // TODO: Get Workspace Alias via Manifest. + readonly validation = new UmbValidationContext(this); + + constructor(host: UmbControllerHost, dataPathPropertyName: string) { super(host); this.observe(this.contentTypeId, (id) => this.structure.loadType(id)); + this.observe(this.unique, (udi) => { + if (udi) { + this.validation.setDataPath('$.' + dataPathPropertyName + `[?(@.udi = '${udi}')]`); + } + }); } reset() { @@ -99,6 +106,13 @@ export class UmbBlockElementManager extends UmbControllerBase { return new UmbBlockElementPropertyDatasetContext(host, this); } + public setup(host: UmbClassInterface) { + this.createPropertyDatasetContext(host); + + // Provide Validation Context for this view: + this.validation.provideAt(host); + } + public override destroy(): void { this.#data.destroy(); this.structure.destroy(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts index 0464b2fe88..0f1fb6eff3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts @@ -47,11 +47,11 @@ export class UmbBlockWorkspaceContext x?.contentUdi); readonly contentUdi = this.#layout.asObservablePart((x) => x?.contentUdi); - readonly content = new UmbBlockElementManager(this); + readonly content = new UmbBlockElementManager(this, 'contentData'); - readonly settings = new UmbBlockElementManager(this); + readonly settings = new UmbBlockElementManager(this, 'settingsData'); - // TODO: Get the name of the contentElementType.. + // TODO: Get the name from the content element type. Or even better get the Label, but that has to be re-actively updated. #label = new UmbStringState(undefined); readonly name = this.#label.asObservable(); @@ -60,6 +60,9 @@ export class UmbBlockWorkspaceContext { this.#modalContext = context; context.onSubmit().catch(this.#modalRejected); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.modal-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.modal-token.ts index aaebad4028..701f468ca8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.modal-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.modal-token.ts @@ -3,6 +3,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export interface UmbBlockWorkspaceData extends UmbWorkspaceModalData { originData: OriginDataType; + baseDataPath: string; } export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken( @@ -12,7 +13,7 @@ export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts index 5487628534..295ee4073e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts @@ -32,6 +32,9 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement { @state() _propertyStructure: Array = []; + @state() + _dataPaths?: Array; + constructor() { super(); @@ -48,26 +51,36 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement { this.#propertyStructureHelper.propertyStructure, (propertyStructure) => { this._propertyStructure = propertyStructure; + this.#generatePropertyDataPath(); }, 'observePropertyStructure', ); } + #generatePropertyDataPath() { + if (!this._propertyStructure) return; + this._dataPaths = this._propertyStructure.map((property) => `$.${property.alias}`); + } + override render() { return repeat( this._propertyStructure, (property) => property.alias, - (property) => html` `, + (property, index) => + html` `, ); } static override styles = [ UmbTextStyles, css` - umb-property-type-based-property { + .property { border-bottom: 1px solid var(--uui-color-divider); } - umb-property-type-based-property:last-child { + .property:last-child { border-bottom: 0; } `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts index 1677f4ba5b..acc21154e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts @@ -69,8 +69,8 @@ export class UmbBlockWorkspaceViewEditElement extends UmbLitElement implements U const dataManager = this.#blockWorkspace[this.#managerName]; this.#tabsStructureHelper.setStructureManager(dataManager.structure); - // Create Data Set: - dataManager.createPropertyDatasetContext(this); + // Create Data Set & setup Validation Context: + dataManager.setup(this); this.observe( this.#blockWorkspace![this.#managerName!].structure.hasRootContainers('Group'), diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/modals/manifest-viewer/manifest-viewer-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/modals/manifest-viewer/manifest-viewer-modal.element.ts index 2e5e8e476e..931b876ce3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/modals/manifest-viewer/manifest-viewer-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/modals/manifest-viewer/manifest-viewer-modal.element.ts @@ -1,29 +1,37 @@ import type { UmbManifestViewerModalData, UmbManifestViewerModalValue } from './manifest-viewer-modal.token.js'; -import { css, html, customElement, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -// JSON parser for the manifest viewer modal -// Enabling us to view JS code, but it is not optimal, but currently better than nothing [NL] -// Ideally we should have a JS code stringify that can print the manifest as JS. [NL] -function JsonParser(key: string, value: any) { - if (typeof value === 'function' && value !== null && value.toString) { - return Function.prototype.toString.call(value); - } - return value; -} - @customElement('umb-manifest-viewer-modal') export class UmbManifestViewerModalElement extends UmbModalBaseElement< UmbManifestViewerModalData, UmbManifestViewerModalValue > { + // Code adapted from https://stackoverflow.com/a/57668208/12787 + // Licensed under the permissions of the CC BY-SA 4.0 DEED + #stringify(obj: any): string { + let output = '{'; + for (const key in obj) { + let value = obj[key]; + if (typeof value === 'function') { + value = value.toString(); + } else if (value instanceof Array) { + value = JSON.stringify(value); + } else if (typeof value === 'object') { + value = this.#stringify(value); + } else { + value = `"${value}"`; + } + output += `\n ${key}: ${value},`; + } + return output + '\n}'; + } + override render() { return html` - + ${this.data - ? html`${JSON.stringify(this.data, JsonParser, 2)}` + ? html`${this.#stringify(this.data)}` : nothing}
@@ -32,7 +40,14 @@ export class UmbManifestViewerModalElement extends UmbModalBaseElement< `; } - static override styles = [css``]; + static override styles = [ + css` + umb-code-block { + border: none; + height: 100%; + } + `, + ]; } export default UmbManifestViewerModalElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/manifests.ts index 5e6142daa1..e31e338113 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/code-editor/property-editor/manifests.ts @@ -8,8 +8,8 @@ export const manifest: ManifestPropertyEditorUi = { meta: { label: 'Code Editor', propertyEditorSchemaAlias: 'Umbraco.Plain.String', - icon: 'icon-code', - group: 'common', + icon: 'icon-brackets', + group: 'richContent', settings: { properties: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts index cc2bb9308e..36e616ae61 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts @@ -1,19 +1,24 @@ -import { css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { type PropertyValueMap, css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; import { generateAlias } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-input-with-alias') -export class UmbInputWithAliasElement extends UmbFormControlMixin(UmbLitElement) { +export class UmbInputWithAliasElement extends UmbFormControlMixin( + UmbLitElement, +) { @property({ type: String }) label: string = ''; @property({ type: String, reflect: false }) alias?: string; + @property({ type: Boolean, reflect: true }) + required: boolean = false; + @property({ type: Boolean, reflect: true, attribute: 'alias-readonly' }) aliasReadonly = false; @@ -23,7 +28,15 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin | Map): void { + super.firstUpdated(_changedProperties); + + this.addValidator( + 'valueMissing', + () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => this.required && !this.value, + ); + this.shadowRoot?.querySelectorAll('uui-input').forEach((x) => this.addFormControlElement(x)); } @@ -64,6 +77,13 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin + @input=${this.#onNameChange} + ?required=${this.required}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts index ebb1da1e6f..203cfbcbbc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts @@ -204,7 +204,7 @@ export class UmbTableElement extends LitElement { if (this.config.hideIcon && !this.config.allowSelection) return; return html` - + ${when( this.config.allowSelection, () => @@ -236,7 +236,7 @@ export class UmbTableElement extends LitElement { if (this.config.hideIcon && !this.config.allowSelection) return; return html` - + ${when(!this.config.hideIcon, () => html``)} ${when( this.config.allowSelection, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts index f6511f43cc..268bea7841 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts @@ -112,12 +112,14 @@ export class UmbContentTypeStructureManager< if (!contentType || !contentType.unique) throw new Error('Could not find the Content Type to save'); const { error, data } = await this.#repository.save(contentType); - if (error || !data) return { error, data }; + if (error || !data) { + throw error?.message ?? 'Repository did not return data after save.'; + } // Update state with latest version: this.#contentTypes.updateOne(contentType.unique, data); - return { error, data }; + return data; } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts index 18173790f5..1399820201 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts @@ -7,7 +7,7 @@ import type { } from '@umbraco-cms/backoffice/content-type'; import { UmbContentTypePropertyStructureHelper } from '@umbraco-cms/backoffice/content-type'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbDataPathPropertyValueFilter } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathPropertyValueQuery } from '@umbraco-cms/backoffice/validation'; import { UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; @@ -58,7 +58,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement if (!this.#variantId || !this._propertyStructure) return; this._dataPaths = this._propertyStructure.map( (property) => - `$.values[${UmbDataPathPropertyValueFilter({ + `$.values[${UmbDataPathPropertyValueQuery({ alias: property.alias, culture: property.variesByCulture ? this.#variantId!.culture : null, segment: property.variesBySegment ? this.#variantId!.segment : null, @@ -74,7 +74,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement (property, index) => html` `, ) : ''; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts index d9b7a73de0..35ed551b97 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts @@ -46,6 +46,8 @@ export interface UmbBlockEditorCustomViewProperties< layout?: LayoutType; content?: UmbBlockDataType; settings?: UmbBlockDataType; + contentInvalid?: boolean; + settingsInvalid?: boolean; } export interface UmbBlockEditorCustomViewElement< diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts index 8043f4d9b6..39f40cde5b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts @@ -3,4 +3,6 @@ import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/ export interface UmbPropertyEditorUiElement extends HTMLElement { value?: unknown; config?: UmbPropertyEditorConfigCollection; + mandatory?: boolean; + mandatoryMessage?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-registry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-registry.context.ts index 2dfbcca3bf..d479851c79 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-registry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-registry.context.ts @@ -30,12 +30,12 @@ export class UmbIconRegistryContext extends UmbContextBase(manifest.js); if (!js || !js.default || !Array.isArray(js.default)) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/destroy.lit-directive.ts b/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/destroy.lit-directive.ts new file mode 100644 index 0000000000..3860d3d87a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/destroy.lit-directive.ts @@ -0,0 +1,38 @@ +import { AsyncDirective, directive, nothing, type ElementPart } from '@umbraco-cms/backoffice/external/lit'; + +/** + * The `focus` directive sets focus on the given element once its connected to the DOM. + */ +class UmbDestroyDirective extends AsyncDirective { + #el?: HTMLElement & { destroy: () => void }; + + override render() { + return nothing; + } + + override update(part: ElementPart) { + this.#el = part.element as any; + return nothing; + } + + override disconnected() { + if (this.#el) { + this.#el.destroy(); + } + this.#el = undefined; + } + + //override reconnected() {} +} + +/** + * @description + * A Lit directive, which destroys the element once its disconnected from the DOM. + * @example: + * ```js + * html``; + * ``` + */ +export const umbDestroyOnDisconnect = directive(UmbDestroyDirective); + +//export type { UmbDestroyDirective }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/index.ts index 0ccb1bc7eb..f6877c9c11 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/lit-element/directives/index.ts @@ -1 +1,2 @@ export * from './focus.lit-directive.js'; +export * from './destroy.lit-directive.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/property-editor-ui-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/property-editor-ui-picker-modal.token.ts index 7a96e4c8ce..185f5ff781 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/property-editor-ui-picker-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/property-editor-ui-picker-modal.token.ts @@ -1,6 +1,7 @@ import { UmbModalToken } from './modal-token.js'; export interface UmbPropertyEditorUIPickerModalData { + /** @deprecated This property will be removed in Umbraco 15. */ submitLabel?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/workspace-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/workspace-modal.token.ts index f5b7edf84c..27b52e61bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/workspace-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/workspace-modal.token.ts @@ -2,9 +2,9 @@ import { UmbModalToken } from './modal-token.js'; export interface UmbWorkspaceModalData { entityType: string; preset: Partial; + baseDataPath?: string; } -// TODO: It would be good with a WorkspaceValueBaseType, to avoid the hardcoded type for unique here: export type UmbWorkspaceModalValue = | { unique: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/object-type/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/object-type/index.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/object-type/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/object-type/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/object-type/input-object-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/object-type/input-object-type.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/object-type/input-object-type.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/object-type/input-object-type.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/object-type/object-type.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/core/object-type/object-type.repository.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/object-type/object-type.repository.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/object-type/object-type.repository.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/property-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/property-type-workspace.context.ts index 880e05d8c2..a5d949cb39 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/property-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/property-type-workspace.context.ts @@ -16,6 +16,7 @@ import type { ManifestWorkspace } from '@umbraco-cms/backoffice/extension-regist import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type'; import { UmbId } from '@umbraco-cms/backoffice/id'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export class UmbPropertyTypeWorkspaceContext extends UmbSubmittableWorkspaceContextBase @@ -39,6 +40,9 @@ export class UmbPropertyTypeWorkspaceContext { this.#context = instance; - this.observe(instance.data, (data) => { - this._data = data; - }); + this.observe( + instance.data, + (data) => { + this._data = data; + }, + 'observeData', + ); }); this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, (instance) => { @@ -176,27 +181,34 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i return html`
- - - - - - + + + + + + + + +
- + + +
Validation @@ -426,14 +442,11 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i uui-input { width: 100%; } - #alias-lock { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; + uui-input:focus-within { + z-index: 1; } - #alias-lock uui-icon { - margin-bottom: 2px; + uui-input-lock:focus-within { + z-index: 1; } .container { display: flex; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts index 76a0dbaa15..69db32034b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts @@ -44,22 +44,28 @@ export class UmbPropertyContext extends UmbContextBase(undefined); public readonly validation = this.#validation.asObservable(); - private _editor = new UmbBasicState(undefined); - public readonly editor = this._editor.asObservable(); + public readonly validationMandatory = this.#validation.asObservablePart((x) => x?.mandatory); + public readonly validationMandatoryMessage = this.#validation.asObservablePart((x) => x?.mandatoryMessage); + + #dataPath = new UmbStringState(undefined); + public readonly dataPath = this.#dataPath.asObservable(); + + #editor = new UmbBasicState(undefined); + public readonly editor = this.#editor.asObservable(); setEditor(editor: UmbPropertyEditorUiElement | undefined) { - this._editor.setValue(editor ?? undefined); + this.#editor.setValue(editor ?? undefined); } getEditor() { - return this._editor.getValue(); + return this.#editor.getValue(); } // property variant ID: #variantId = new UmbClassState(undefined); public readonly variantId = this.#variantId.asObservable(); - private _variantDifference = new UmbStringState(undefined); - public readonly variantDifference = this._variantDifference.asObservable(); + #variantDifference = new UmbStringState(undefined); + public readonly variantDifference = this.#variantDifference.asObservable(); #datasetContext?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE; @@ -72,17 +78,29 @@ export class UmbPropertyContext extends UmbContextBase { - this._observeProperty(); - }); + this.observe( + this.alias, + () => { + this._observeProperty(); + }, + null, + ); - this.observe(this.configValues, (configValues) => { - this.#config.setValue(configValues ? new UmbPropertyEditorConfigCollection(configValues) : undefined); - }); + this.observe( + this.configValues, + (configValues) => { + this.#config.setValue(configValues ? new UmbPropertyEditorConfigCollection(configValues) : undefined); + }, + null, + ); - this.observe(this.variantId, () => { - this._generateVariantDifferenceString(); - }); + this.observe( + this.variantId, + () => { + this._generateVariantDifferenceString(); + }, + null, + ); } private async _observeProperty(): Promise { @@ -109,9 +127,20 @@ export class UmbPropertyContext extends UmbContextBase extends UmbContextBase { this._invalid = invalid; }); } public get dataPath(): string | undefined { - return this.#dataPath; + return this.#propertyContext.getDataPath(); } - #dataPath?: string; @state() private _variantDifference?: string; @@ -172,7 +171,7 @@ export class UmbPropertyElement extends UmbLitElement { #propertyContext = new UmbPropertyContext(this); #controlValidator?: UmbFormControlValidator; - #validationMessageBinder?: UmbBindValidationMessageToFormControl; + #validationMessageBinder?: UmbBindServerValidationToFormControl; #valueObserver?: UmbObserverController; #configObserver?: UmbObserverController; @@ -220,9 +219,12 @@ export class UmbPropertyElement extends UmbLitElement { ); this.observe( - this.#propertyContext.validation, - (validation) => { - this._mandatory = validation?.mandatory; + this.#propertyContext.validationMandatory, + (mandatory) => { + this._mandatory = mandatory; + if (this._element) { + this._element.mandatory = mandatory; + } }, null, ); @@ -281,6 +283,8 @@ export class UmbPropertyElement extends UmbLitElement { if (this._element) { this._element.addEventListener('change', this._onPropertyEditorChange as any as EventListener); this._element.addEventListener('property-value-change', this._onPropertyEditorChange as any as EventListener); + // No need to observe mandatory, as we already do so and set it on the _element if present: [NL] + this._element.mandatory = this._mandatory; // No need for a controller alias, as the clean is handled via the observer prop: this.#valueObserver = this.observe( @@ -303,15 +307,25 @@ export class UmbPropertyElement extends UmbLitElement { }, null, ); + this.#configObserver = this.observe( + this.#propertyContext.validationMandatoryMessage, + (mandatoryMessage) => { + if (mandatoryMessage) { + this._element!.mandatoryMessage = mandatoryMessage ?? undefined; + } + }, + null, + ); if ('checkValidity' in this._element) { - this.#controlValidator = new UmbFormControlValidator(this, this._element as any, this.#dataPath); + const dataPath = this.dataPath; + this.#controlValidator = new UmbFormControlValidator(this, this._element as any, dataPath); // We trust blindly that the dataPath is available at this stage. [NL] - if (this.#dataPath) { - this.#validationMessageBinder = new UmbBindValidationMessageToFormControl( + if (dataPath) { + this.#validationMessageBinder = new UmbBindServerValidationToFormControl( this, this._element as any, - this.#dataPath, + dataPath, ); this.#validationMessageBinder.value = this.#propertyContext.getValue(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts index 82505e62be..6a16aa67e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts @@ -12,6 +12,7 @@ export * from './path/path-decode.function.js'; export * from './path/path-encode.function.js'; export * from './path/path-folder-name.function.js'; export * from './path/remove-initial-slash-from-path.function.js'; +export * from './path/remove-last-slash-from-path.function.js'; export * from './path/stored-path.function.js'; export * from './path/transform-server-path-to-client-path.function.js'; export * from './path/umbraco-path.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-initial-slash-from-path.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-initial-slash-from-path.function.ts index 64ab8b51c8..f2b26ac1c6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-initial-slash-from-path.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-initial-slash-from-path.function.ts @@ -1,5 +1,5 @@ /** - * + * Removes the initial slash from a path, if the first character is a slash. * @param path */ export function removeInitialSlashFromPath(path: string) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-last-slash-from-path.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-last-slash-from-path.function.ts new file mode 100644 index 0000000000..5973746c29 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/remove-last-slash-from-path.function.ts @@ -0,0 +1,7 @@ +/** + * Remove the last slash from a path, if the last character is a slash. + * @param path + */ +export function removeLastSlashFromPath(path: string) { + return path.endsWith('/') ? path.slice(undefined, -1) : path; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/README.md b/src/Umbraco.Web.UI.Client/src/packages/core/validation/README.md new file mode 100644 index 0000000000..90bdcc7220 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/README.md @@ -0,0 +1,129 @@ +# Backoffice Validation System + +The validation system works around a system of Validation Messages, provided via Validation Contexts and connected to the application via Validators. + +The system both supports handling front-end validation, server-validation and other things can as well be hooked into it. + +## Validation Context + +The core of the system is a Validation Context, this holds the messages and more. + +## Validation Messages + +A Validation message consist of a type, path and body. This typically looks like this: + +``` +{ + type: "client", + path: "$.values[?(@.alias = 'my-property-alias')].value", + message: "Must contain at least 3 words" +} +``` + +Because each validation issue is presented in the Validation Context as a Message, its existence will be available for anyone to observe. +One benefit of this is that Elements that are removed from screen can still have their validation messages preventing the submission of a dataset. +As well Tabs and other navigation can use this to be highlighted, so the user can be guide to the location. + +### Path aka. Data Path + +The Path also named Data Path, A Path pointing to the related data in the model. +A massage uses this to point to the location in the model that the message is concerned. + +The following models headline can be target with this path: + +Data: +``` +{ + settings: { + title: 'too short' + } +} +``` + +JsonPath: +``` +"$.settings.title" +``` + +The following example shows how we use JsonPath Queries to target entries of an array: + +Data: +``` +{ + values: [ + { + alias: 'my-alias', + value: 'my-value' + } + ] +} +``` + +JsonPath: +``` +"$.values.[?(@.alias = 'my-alias')].value" +``` + +Paths are based on JSONPath, using JSON Path Queries when looking up data of an Array. Using Queries enables Paths to not point to specific index, but what makes a entry unique. + +Messages are set via Validators, which is the glue between a the context and a validation source. + +## Validators + +Messages can be set by Validators, a Validator gets assigned to the Validation Context. Making the Context aware about the Validators. + +When the validation context is asked to Validate, it can then call the `validate` method on all the Validators. + +The Validate method can be async, meaning it can request the server or other way figure out its state before resolving. + +We provide a few built in Validators which handles most cases. + +### Form Control Validator + +This Validator binds a Form Control Element with the Validation Context. When the Form Control becomes Invalid, its Validation Message is appended to the Validation Context. + +Notice this one also comes as a Lit Directive called `umbBindToValidation`. + +Also notice this does not bind server validation to the Form Control, see `UmbBindServerValidationToFormControl` + +### Server Model Validator + +This Validator can asks a end-point for validation of the model. + +The Server Model Validator stores the data that was send to the server on the Validation Context. This is then later used by Validation Path Translators to convert index based paths to Json Path Queries. + +This is needed to allow the user to make changes to the data, without loosing the accuracy of the messages coming from server validation. + +## Validation Path Translator + +Validation Path Translator translate Message Paths into a format that is independent of the actual current data. But compatible with mutations of that data model. +This enables the user to retrieve validation messages from the server, and then the user can insert more items and still have the validation appearing in the right spots. +This would not be possible with index based paths, which is why we translate those into JSON Path Queries. + +Such conversation could be from this path: +``` +"$.values.[5].value" +``` + +To this path: +``` +"$.values.[?(@.alias = 'my-alias')].value" +``` + +Once this path is converted to use Json Path Queries, the Data can be changed. The concerned entry might get another index. Without that affecting the accuracy of the path. + +### Late registered Path Translators + +Translators can be registered late. This means that a Property Editor that has a complex value structure, can register a Path Translator for its part of the data. Such Translator will appear late because the Property might not be rendered in the users current view, but first when the user navigates there. +This is completely fine, as messages can be partly translated and then enhanced by late coming Path Translators. + +This fact enables a property to observe if there is any Message Paths that start with the same path as the Data Path for the Property. In this was a property can know that it contains a Validation Message without the Message Path begin completely translated. + + + +## Binders + +Validators represent a component of the Validation to be considered, but it does not represent other messages of its path. +To display messages from a given data-path, a Binder is needed. We bring a few to make this happen: + +UmbBindServerValidationToFormControl diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/const.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/const.ts new file mode 100644 index 0000000000..0160e54bf4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/const.ts @@ -0,0 +1 @@ +export const UMB_VALIDATION_EMPTY_LOCALIZATION_KEY = '#validation_invalidEmpty'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/index.ts index 96fbced51a..99311cb3cf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/index.ts @@ -1,4 +1,2 @@ export * from './validation.context.js'; export * from './validation.context-token.js'; -export * from './server-model-validation.context.js'; -export * from './server-model-validation.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context-token.ts deleted file mode 100644 index 1f8a1932dc..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context-token.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { UmbServerModelValidationContext } from './index.js'; -import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; - -export const UMB_SERVER_MODEL_VALIDATION_CONTEXT = new UmbContextToken( - 'UmbServerModelValidationContext', -); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts deleted file mode 100644 index f969b3d4eb..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { UmbValidationMessageTranslator } from '../translators/validation-message-translator.interface.js'; -import type { UmbValidator } from '../interfaces/validator.interface.js'; -import { UMB_VALIDATION_CONTEXT } from './validation.context-token.js'; -import { UMB_SERVER_MODEL_VALIDATION_CONTEXT } from './server-model-validation.context-token.js'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; - -type ServerFeedbackEntry = { path: string; messages: Array }; - -export class UmbServerModelValidationContext - extends UmbContextBase - implements UmbValidator -{ - #validatePromise?: Promise; - #validatePromiseResolve?: () => void; - - #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; - #isValid = true; - - #data: any; - getData(): any { - return this.#data; - } - #translators: Array = []; - - // Hold server feedback... - #serverFeedback: Array = []; - - constructor(host: UmbControllerHost) { - super(host, UMB_SERVER_MODEL_VALIDATION_CONTEXT); - this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { - if (this.#context) { - this.#context.removeValidator(this); - } - this.#context = context; - context.addValidator(this); - - // Run translators? - }); - } - - async askServerForValidation(data: unknown, requestPromise: Promise>): Promise { - this.#context?.messages.removeMessagesByType('server'); - - this.#serverFeedback = []; - this.#isValid = false; - //this.#validatePromiseReject?.(); - this.#validatePromise = new Promise((resolve) => { - this.#validatePromiseResolve = resolve; - }); - - // Store this state of the data for translator look ups: - this.#data = data; - // Ask the server for validation... - const { error } = await requestPromise; - - this.#isValid = error ? false : true; - - if (!this.#isValid) { - // We are missing some typing here, but we will just go wild with 'as any': [NL] - const readErrorBody = (error as any).body; - // Check if there are validation errors, since the error might be a generic ApiError - if (readErrorBody?.errors) { - Object.keys(readErrorBody.errors).forEach((path) => { - this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] }); - }); - } - } - - this.#validatePromiseResolve?.(); - this.#validatePromiseResolve = undefined; - - // Translate feedback: - this.#serverFeedback = this.#serverFeedback.flatMap(this.#executeTranslatorsOnFeedback); - } - - #executeTranslatorsOnFeedback = (feedback: ServerFeedbackEntry) => { - return this.#translators.flatMap((translator) => { - let newPath: string | undefined; - if ((newPath = translator.translate(feedback.path))) { - // TODO: I might need to restructure this part for adjusting existing feedback with a part-translation. - // Detect if some part is unhandled? - // If so only make a partial translation on the feedback, add a message for the handled part. - // then return [ of the partial translated feedback, and the partial handled part. ]; - - // TODO:Check if there was any temporary messages base on this path, like if it was partial-translated at one point.. - - this.#context?.messages.addMessages('server', newPath, feedback.messages); - // by not returning anything this feedback gets removed from server feedback.. - return []; - } - return feedback; - }); - }; - - addTranslator(translator: UmbValidationMessageTranslator): void { - if (this.#translators.indexOf(translator) === -1) { - this.#translators.push(translator); - } - // execute translators here? - } - - removeTranslator(translator: UmbValidationMessageTranslator): void { - const index = this.#translators.indexOf(translator); - if (index !== -1) { - this.#translators.splice(index, 1); - } - } - - get isValid(): boolean { - return this.#isValid; - } - async validate(): Promise { - if (this.#validatePromise) { - await this.#validatePromise; - } - return this.#isValid ? Promise.resolve() : Promise.reject(); - } - - reset(): void {} - - focusFirstInvalidElement(): void {} - - override hostConnected(): void { - super.hostConnected(); - if (this.#context) { - this.#context.addValidator(this); - } - } - override hostDisconnected(): void { - super.hostDisconnected(); - if (this.#context) { - this.#context.removeValidator(this); - this.#context = undefined; - } - } - - override destroy(): void { - // TODO: make sure we destroy things properly: - this.#translators = []; - super.destroy(); - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts index 252f716e92..6104c31dc8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts @@ -1,3 +1,4 @@ +import type { UmbValidationMessageTranslator } from '../translators/validation-message-path-translator.interface.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; @@ -7,35 +8,23 @@ export interface UmbValidationMessage { type: UmbValidationMessageType; key: string; path: string; - message: string; + body: string; } export class UmbValidationMessagesManager { #messages = new UmbArrayState([], (x) => x.key); + messages = this.#messages.asObservable(); /*constructor() { - this.#messages.asObservable().subscribe((x) => console.log('messages:', x)); + this.#messages.asObservable().subscribe((x) => console.log('all messages:', x)); }*/ - /* - serializeMessages(fromPath: string, toPath: string): void { - this.#messages.setValue( - this.#messages.getValue().map((x) => { - if (x.path.indexOf(fromPath) === 0) { - x.path = toPath + x.path.substring(fromPath.length); - } - return x; - }), - ); - } - */ - getHasAnyMessages(): boolean { return this.#messages.getValue().length !== 0; } - /* messagesOfPathAndDescendant(path: string): Observable> { + path = path.toLowerCase(); // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. using a more performant way than Regex: return this.#messages.asObservablePart((msgs) => msgs.filter( @@ -45,16 +34,17 @@ export class UmbValidationMessagesManager { ), ); } - */ messagesOfTypeAndPath(type: UmbValidationMessageType, path: string): Observable> { + path = path.toLowerCase(); // Find messages that matches the given type and path. return this.#messages.asObservablePart((msgs) => msgs.filter((x) => x.type === type && x.path === path)); } hasMessagesOfPathAndDescendant(path: string): Observable { + path = path.toLowerCase(); return this.#messages.asObservablePart((msgs) => - // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. Using a more performant way than Regex: + // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. Using a more performant way than Regex: [NL] msgs.some( (x) => x.path.indexOf(path) === 0 && @@ -63,6 +53,7 @@ export class UmbValidationMessagesManager { ); } getHasMessagesOfPathAndDescendant(path: string): boolean { + path = path.toLowerCase(); return this.#messages .getValue() .some( @@ -72,33 +63,78 @@ export class UmbValidationMessagesManager { ); } - addMessage(type: UmbValidationMessageType, path: string, message: string): void { - this.#messages.appendOne({ type, key: UmbId.new(), path, message }); + addMessage(type: UmbValidationMessageType, path: string, body: string, key: string = UmbId.new()): void { + path = this.#translatePath(path.toLowerCase()) ?? path.toLowerCase(); + // check if there is an existing message with the same path and type, and append the new messages: [NL] + if (this.#messages.getValue().find((x) => x.type === type && x.path === path && x.body === body)) { + return; + } + this.#messages.appendOne({ type, key, path, body: body }); } - addMessages(type: UmbValidationMessageType, path: string, messages: Array): void { - this.#messages.append(messages.map((message) => ({ type, key: UmbId.new(), path, message }))); + addMessages(type: UmbValidationMessageType, path: string, bodies: Array): void { + path = this.#translatePath(path.toLowerCase()) ?? path.toLowerCase(); + // filter out existing messages with the same path and type, and append the new messages: [NL] + const existingMessages = this.#messages.getValue(); + const newBodies = bodies.filter( + (message) => existingMessages.find((x) => x.type === type && x.path === path && x.body === message) === undefined, + ); + this.#messages.append(newBodies.map((body) => ({ type, key: UmbId.new(), path, body }))); } - /* - removeMessage(message: UmbValidationDataPath): void { - this.#messages.removeOne(message.key); - }*/ removeMessageByKey(key: string): void { this.#messages.removeOne(key); } removeMessagesByTypeAndPath(type: UmbValidationMessageType, path: string): void { + path = path.toLowerCase(); this.#messages.filter((x) => !(x.type === type && x.path === path)); } removeMessagesByType(type: UmbValidationMessageType): void { this.#messages.filter((x) => x.type !== type); } - reset(): void { + #translatePath(path: string): string | undefined { + for (const translator of this.#translators) { + const newPath = translator.translate(path); + // If not undefined or false, then it was a valid translation: [NL] + if (newPath) { + // Lets try to translate it again, this will recursively translate the path until no more translations are possible (and then fallback to '?? newpath') [NL] + return this.#translatePath(newPath) ?? newPath; + } + } + return; + } + + #translators: Array = []; + addTranslator(translator: UmbValidationMessageTranslator): void { + if (this.#translators.indexOf(translator) === -1) { + this.#translators.push(translator); + } + // execute translators on all messages: + // Notice we are calling getValue() in each iteration to avoid the need to re-translate the same messages over and over again. [NL] + for (const msg of this.#messages.getValue()) { + const newPath = this.#translatePath(msg.path); + // If newPath is not false or undefined, a translation of it has occurred, meaning we ant to update it: [NL] + if (newPath) { + // update the specific message, with its new path: [NL] + this.#messages.updateOne(msg.key, { path: newPath }); + } + } + } + + removeTranslator(translator: UmbValidationMessageTranslator): void { + const index = this.#translators.indexOf(translator); + if (index !== -1) { + this.#translators.splice(index, 1); + } + } + + clear(): void { this.#messages.setValue([]); } destroy(): void { + this.#translators = []; this.#messages.destroy(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts index 69ac384df9..627e7212d4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts @@ -1,24 +1,201 @@ import type { UmbValidator } from '../interfaces/validator.interface.js'; -import { UmbValidationMessagesManager } from './validation-messages.manager.js'; +import type { UmbValidationMessageTranslator } from '../translators/index.js'; +import { GetValueByJsonPath } from '../utils/json-path.function.js'; +import { type UmbValidationMessage, UmbValidationMessagesManager } from './validation-messages.manager.js'; import { UMB_VALIDATION_CONTEXT } from './validation.context-token.js'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import { type UmbClassInterface, UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; + +/** + * Helper method to replace the start of a string with another string. + * @param path {string} + * @param startFrom {string} + * @param startTo {string} + * @returns {string} + */ +function ReplaceStartOfString(path: string, startFrom: string, startTo: string): string { + if (path.startsWith(startFrom + '.')) { + return startTo + path.slice(startFrom.length); + } + return path; +} + +/** + * Validation Context is the core of Validation. + * It hosts Validators that has to validate for the context to be valid. + * It can also be used as a Validator as part of a parent Validation Context. + */ +export class UmbValidationContext extends UmbControllerBase implements UmbValidator { + // The current provider controller, that is providing this context: + #providerCtrl?: UmbContextProviderController; + + // Local version of the data send to the server, only use-case is for translation. + #translationData = new UmbObjectState(undefined); + translationDataOf(path: string): any { + return this.#translationData.asObservablePart((data) => GetValueByJsonPath(data, path)); + } + setTranslationData(data: any): void { + this.#translationData.setValue(data); + } + getTranslationData(): any { + return this.#translationData.getValue(); + } -export class UmbValidationContext extends UmbContextBase implements UmbValidator { #validators: Array = []; #validationMode: boolean = false; #isValid: boolean = false; + #parent?: UmbValidationContext; + #parentMessages?: Array; + #localMessages?: Array; + #baseDataPath?: string; + public readonly messages = new UmbValidationMessagesManager(); constructor(host: UmbControllerHost) { - super(host, UMB_VALIDATION_CONTEXT); + // This is overridden to avoid setting a controllerAlias, this might make sense, but currently i want to leave it out. [NL] + super(host); } + /** + * Add a path translator to this validation context. + * @param translator + */ + async addTranslator(translator: UmbValidationMessageTranslator) { + this.messages.addTranslator(translator); + } + + /** + * Remove a path translator from this validation context. + * @param translator + */ + async removeTranslator(translator: UmbValidationMessageTranslator) { + this.messages.removeTranslator(translator); + } + + /** + * Provides the validation context to the current host, if not already provided to a different host. + * @returns instance {UmbValidationContext} - Returns it self. + */ + provide(): UmbValidationContext { + if (this.#providerCtrl) return this; + this.provideContext(UMB_VALIDATION_CONTEXT, this); + return this; + } + /** + * Provide this validation context to a specific controller host. + * This can be used to Host a validation context in a Workspace, but provide it on a certain scope, like a specific Workspace View. + * @param controllerHost {UmbClassInterface} + */ + provideAt(controllerHost: UmbClassInterface): void { + this.#providerCtrl?.destroy(); + this.#providerCtrl = controllerHost.provideContext(UMB_VALIDATION_CONTEXT, this); + } + + /** + * Define a specific data path for this validation context. + * This will turn this validation context into a sub-context of the parent validation context. + * This means that a two-way binding for messages will be established between the parent and the sub-context. + * And it will inherit the Translation Data from its parent. + * + * messages and data will be localizes accordingly to the given data path. + * @param dataPath {string} - The data path to bind this validation context to. + * @example + * ```ts + * const validationContext = new UmbValidationContext(host); + * validationContext.setDataPath("$.values[?(@.alias='my-property')].value"); + * ``` + * + * A message with the path: '$.values[?(@.alias='my-property')].value.innerProperty', will for above example become '$.innerProperty' for the local Validation Context. + */ + setDataPath(dataPath: string): void { + if (this.#baseDataPath) { + if (this.#baseDataPath === dataPath) return; + console.log(this.#baseDataPath, dataPath); + // Just fire an error, as I haven't made the right clean up jet. Or haven't thought about what should happen if it changes while already setup. + // cause maybe all the messages should be removed as we are not interested in the old once any more. But then on the other side, some might be relevant as this is the same entity that changed its paths? + throw new Error('Data path is already set, we do not support changing the context data-path as of now.'); + } + if (!dataPath) return; + this.#baseDataPath = dataPath; + + this.consumeContext(UMB_VALIDATION_CONTEXT, (parent) => { + if (this.#parent) { + this.#parent.removeValidator(this); + } + this.#parent = parent; + parent.addValidator(this); + + this.messages.clear(); + + this.observe(parent.translationDataOf(dataPath), (data) => { + this.setTranslationData(data); + }); + + this.observe( + parent.messages.messagesOfPathAndDescendant(dataPath), + (msgs) => { + //this.messages.appendMessages(msgs); + if (this.#parentMessages) { + // Remove the local messages that does not exist in the parent anymore: + const toRemove = this.#parentMessages.filter((msg) => !msgs.find((m) => m.key === msg.key)); + toRemove.forEach((msg) => { + this.messages.removeMessageByKey(msg.key); + }); + } + this.#parentMessages = msgs; + msgs.forEach((msg) => { + const path = ReplaceStartOfString(msg.path, this.#baseDataPath!, '$'); + // Notice, the local message uses the same key. [NL] + this.messages.addMessage(msg.type, path, msg.body, msg.key); + }); + }, + 'observeParentMessages', + ); + + this.observe( + this.messages.messages, + (msgs) => { + if (!this.#parent) return; + //this.messages.appendMessages(msgs); + if (this.#localMessages) { + // Remove the parent messages that does not exist locally anymore: + const toRemove = this.#localMessages.filter((msg) => !msgs.find((m) => m.key === msg.key)); + toRemove.forEach((msg) => { + this.#parent!.messages.removeMessageByKey(msg.key); + }); + } + this.#localMessages = msgs; + msgs.forEach((msg) => { + // replace this.#baseDataPath (if it starts with it) with $ in the path, so it becomes relative to the parent context + const path = ReplaceStartOfString(msg.path, '$', this.#baseDataPath!); + // Notice, the parent message uses the same key. [NL] + this.#parent!.messages.addMessage(msg.type, path, msg.body, msg.key); + }); + }, + 'observeLocalMessages', + ); + }).skipHost(); + // Notice skipHost ^^, this is because we do not want it to consume it self, as this would be a match for this consumption, instead we will look at the parent and above. [NL] + } + + /** + * Get if this context is valid. + * Notice this does not verify the validity. + * @returns {boolean} + */ get isValid(): boolean { return this.#isValid; } + /** + * Add a validator to this context. + * This validator will have to be valid for the context to be valid. + * If the context is in validation mode, the validator will be validated immediately. + * @param validator { UmbValidator } - The validator to add to this context. + */ addValidator(validator: UmbValidator): void { if (this.#validators.includes(validator)) return; this.#validators.push(validator); @@ -27,6 +204,11 @@ export class UmbValidationContext extends UmbContextBase i this.validate(); } } + + /** + * Remove a validator from this context. + * @param validator {UmbValidator} - The validator to remove from this context. + */ removeValidator(validator: UmbValidator): void { const index = this.#validators.indexOf(validator); if (index !== -1) { @@ -39,27 +221,9 @@ export class UmbValidationContext extends UmbContextBase i } } - /*#onValidatorChange = (e: Event) => { - const target = e.target as unknown as UmbValidator | undefined; - if (!target) { - console.error('Validator did not exist.'); - return; - } - const dataPath = target.dataPath; - if (!dataPath) { - console.error('Validator did not exist or did not provide a data-path.'); - return; - } - - if (target.isValid) { - this.messages.removeMessagesByTypeAndPath('client', dataPath); - } else { - this.messages.addMessages('client', dataPath, target.getMessages()); - } - };*/ - /** - * + * Validate this context, all the validators of this context will be validated. + * Notice its a recursive check meaning sub validation contexts also validates their validators. * @returns succeed {Promise} - Returns a promise that resolves to true if the validator succeeded, this depends on the validators and wether forceSucceed is set. */ async validate(): Promise { @@ -71,6 +235,11 @@ export class UmbValidationContext extends UmbContextBase i () => Promise.resolve(false), ); + if (!this.messages) { + // This Context has been destroyed while is was validating, so we should not continue. + return; + } + // If we have any messages then we are not valid, otherwise lets check the validation results: [NL] // This enables us to keep client validations though UI is not present anymore — because the client validations got defined as messages. [NL] const isValid = this.messages.getHasAnyMessages() ? false : resultsStatus; @@ -86,6 +255,9 @@ export class UmbValidationContext extends UmbContextBase i return Promise.resolve(); } + /** + * Focus the first invalid element that this context can find. + */ focusFirstInvalidElement(): void { const firstInvalid = this.#validators.find((v) => !v.isValid); if (firstInvalid) { @@ -93,6 +265,9 @@ export class UmbValidationContext extends UmbContextBase i } } + /** + * Reset the validation state of this context. + */ reset(): void { this.#validationMode = false; this.#validators.forEach((v) => v.reset()); @@ -108,7 +283,14 @@ export class UmbValidationContext extends UmbContextBase i } override destroy(): void { + this.#providerCtrl = undefined; + if (this.#parent) { + this.#parent.removeValidator(this); + } + this.#parent = undefined; this.#destroyValidators(); + this.messages?.destroy(); + (this.messages as any) = undefined; super.destroy(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts similarity index 87% rename from src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts index 2bfc4e49c2..9b2857a841 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts @@ -1,14 +1,18 @@ import type { UmbValidationMessage } from '../context/validation-messages.manager.js'; import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; import type { UmbFormControlMixinInterface } from '../mixins/form-control.mixin.js'; -import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; +import { defaultMemoization } from '@umbraco-cms/backoffice/observable-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; const ctrlSymbol = Symbol(); const observeSymbol = Symbol(); -export class UmbBindValidationMessageToFormControl extends UmbControllerBase { +/** + * Binds server validation to a form control. + * This controller will add a custom error to the form control if the validation context has any messages for the specified data path. + */ +export class UmbBindServerValidationToFormControl extends UmbControllerBase { #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; #control: UmbFormControlMixinInterface; @@ -24,7 +28,7 @@ export class UmbBindValidationMessageToFormControl extends UmbControllerBase { this.#value = value; } else { // If not valid lets see if we should remove server validation [NL] - if (!jsonStringComparison(this.#value, value)) { + if (!defaultMemoization(this.#value, value)) { this.#value = value; // Only remove server validations from validation context [NL] this.#messages.forEach((message) => { @@ -62,7 +66,7 @@ export class UmbBindValidationMessageToFormControl extends UmbControllerBase { if (!this.#controlValidator) { this.#controlValidator = this.#control.addValidator( 'customError', - () => this.#messages.map((x) => x.message).join(', '), + () => this.#messages.map((x) => x.body).join(', '), () => !this.#isValid, ); //this.#control.addEventListener('change', this.#onControlChange); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/form-control-validator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/form-control-validator.controller.ts index 244025e09d..743b94bc41 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/form-control-validator.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/form-control-validator.controller.ts @@ -6,6 +6,10 @@ import { UmbValidationValidEvent } from '../events/validation-valid.event.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +/** + * Bind a Form Controls validation state to the validation context. + * This validator will validate the form control and add messages to the validation context if the form control is invalid. + */ export class UmbFormControlValidator extends UmbControllerBase implements UmbValidator { // The path to the data that this validator is validating. readonly #dataPath?: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/index.ts index e261d92414..66e51504c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/index.ts @@ -1,3 +1,5 @@ -export * from './bind-validation-message-to-form-control.controller.js'; -export * from './observe-validation-state.controller.js'; +export * from './bind-server-validation-to-form-control.controller.js'; export * from './form-control-validator.controller.js'; +export * from './observe-validation-state.controller.js'; +export * from './server-model-validator.context-token.js'; +export * from './server-model-validator.context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/observe-validation-state.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/observe-validation-state.controller.ts index 3c4ee10667..798f63d6c0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/observe-validation-state.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/observe-validation-state.controller.ts @@ -2,12 +2,16 @@ import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -const CtrlSymbol = Symbol(); const ObserveSymbol = Symbol(); export class UmbObserveValidationStateController extends UmbControllerBase { - constructor(host: UmbControllerHost, dataPath: string | undefined, callback: (messages: boolean) => void) { - super(host, CtrlSymbol); + constructor( + host: UmbControllerHost, + dataPath: string | undefined, + callback: (messages: boolean) => void, + controllerAlias?: string, + ) { + super(host, controllerAlias ?? 'observeValidationState_' + dataPath); if (dataPath) { this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { this.observe(context.messages.hasMessagesOfPathAndDescendant(dataPath), callback, ObserveSymbol); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context-token.ts new file mode 100644 index 0000000000..67f3cdbffa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbServerModelValidatorContext } from './server-model-validator.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_SERVER_MODEL_VALIDATOR_CONTEXT = new UmbContextToken( + 'UmbServerModelValidationContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context.ts new file mode 100644 index 0000000000..bee1c2369c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/server-model-validator.context.ts @@ -0,0 +1,137 @@ +import type { UmbValidator } from '../interfaces/validator.interface.js'; +import { UmbDataPathPropertyValueQuery } from '../utils/index.js'; +import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY } from '../const.js'; +import { UMB_SERVER_MODEL_VALIDATOR_CONTEXT } from './server-model-validator.context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; + +/** This should ideally be generated by the server, but we currently don't generate error-model-types. */ +interface ValidateErrorResponseBodyModel { + detail: string; + errors: Record>; + missingProperties: Array; + operationStatus: string; + status: number; + title: string; + type: string; +} + +export class UmbServerModelValidatorContext + extends UmbContextBase + implements UmbValidator +{ + #validatePromise?: Promise; + #validatePromiseResolve?: () => void; + + #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; + #isValid = true; + + #data: any; + getData(): any { + return this.#data; + } + + constructor(host: UmbControllerHost) { + super(host, UMB_SERVER_MODEL_VALIDATOR_CONTEXT); + this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { + if (this.#context) { + this.#context.removeValidator(this); + } + this.#context = context; + context.addValidator(this); + + // Run translators? + }).asPromise(); + } + + async askServerForValidation(data: unknown, requestPromise: Promise>): Promise { + this.#context?.messages.removeMessagesByType('server'); + + this.#isValid = false; + //this.#validatePromiseReject?.(); + this.#validatePromise = new Promise((resolve) => { + this.#validatePromiseResolve = resolve; + }); + + // Store this state of the data for translator look ups: + this.#data = data; + // Ask the server for validation... + const { error } = await requestPromise; + + this.#isValid = error ? false : true; + if (this.#isValid) { + // Send data to context for translation: + this.#context?.setTranslationData(undefined); + } else { + if (!this.#context) { + throw new Error('No context available for translation.'); + } + // Send data to context for translation: + this.#context.setTranslationData(data); + + // We are missing some typing here, but we will just go wild with 'as any': [NL] + const errorBody = (error as any).body as ValidateErrorResponseBodyModel; + // Check if there are validation errors, since the error might be a generic ApiError + if (errorBody?.errors) { + Object.keys(errorBody.errors).forEach((path) => { + //serverFeedback.push({ path, messages: errorBody.errors[path] }); + this.#context!.messages.addMessages('server', path, errorBody.errors[path]); + }); + } + // Check if there are missing properties: + if (errorBody?.missingProperties) { + // Retrieve the variants of he send data, as those are the once we will declare as missing properties: + // Temporary fix for missing properties, as we currently get one for each variant, but we do not know which variant it is for: [NL] + const uniqueMissingProperties = [...new Set(errorBody.missingProperties)]; + uniqueMissingProperties.forEach((alias) => { + this.#data.variants.forEach((variant: any) => { + const path = `$.values[${UmbDataPathPropertyValueQuery({ + alias: alias, + culture: variant.culture, + segment: variant.segment, + })}].value`; + this.#context!.messages.addMessages('server', path, [UMB_VALIDATION_EMPTY_LOCALIZATION_KEY]); + }); + }); + } + } + + this.#validatePromiseResolve?.(); + this.#validatePromiseResolve = undefined; + } + + get isValid(): boolean { + return this.#isValid; + } + async validate(): Promise { + if (this.#validatePromise) { + await this.#validatePromise; + } + return this.#isValid ? Promise.resolve() : Promise.reject(); + } + + reset(): void {} + + focusFirstInvalidElement(): void {} + + override hostConnected(): void { + super.hostConnected(); + if (this.#context) { + this.#context.addValidator(this); + } + } + override hostDisconnected(): void { + super.hostDisconnected(); + if (this.#context) { + this.#context.removeValidator(this); + this.#context = undefined; + } + } + + override destroy(): void { + // TODO: make sure we destroy things properly: + super.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts new file mode 100644 index 0000000000..a23e79e1bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts @@ -0,0 +1,84 @@ +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { AsyncDirective, directive, nothing, type ElementPart } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbFormControlMixinInterface } from '@umbraco-cms/backoffice/validation'; +import { UmbBindServerValidationToFormControl, UmbFormControlValidator } from '@umbraco-cms/backoffice/validation'; + +/** + * The `bind to validation` directive connects the Form Control Element to closets Validation Context. + */ +class UmbBindToValidationDirective extends AsyncDirective { + #host?: UmbControllerHost; + #dataPath?: string; + #el?: UmbFormControlMixinInterface; + #validator?: UmbFormControlValidator; + #msgBinder?: UmbBindServerValidationToFormControl; + + // For Directives their arguments have to be defined on the Render method, despite them, not being used by the render method. In this case they are used by the update method. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override render(host: UmbControllerHost, dataPath?: string, value?: unknown) { + return nothing; + } + + override update(part: ElementPart, args: Parameters) { + if (!part.element) return nothing; + if (this.#el !== part.element || this.#host !== args[0] || this.#dataPath !== args[1]) { + this.#host = args[0]; + this.#dataPath = args[1]; + this.#el = part.element as UmbFormControlMixinInterface; + + if (!this.#msgBinder && this.#dataPath) { + this.#msgBinder = new UmbBindServerValidationToFormControl(this.#host, this.#el as any, this.#dataPath); + } + + this.#validator = new UmbFormControlValidator(this.#host, this.#el, this.#dataPath); + } + + // If we have a msgBinder, then lets update the value no matter the other conditions. + if (this.#msgBinder) { + this.#msgBinder.value = args[2]; + } + + return nothing; + } + + override disconnected() { + if (this.#validator) { + this.#validator?.destroy(); + this.#validator = undefined; + } + if (this.#msgBinder) { + this.#msgBinder.destroy(); + this.#msgBinder = undefined; + } + this.#el = undefined; + } + + /*override reconnected() { + }*/ +} + +/** + * @description + * A Lit directive, which binds the validation state of the element to the Validation Context. + * + * The minimal binding can be established by just providing a host element: + * @example: + * ```js + * html``; + * ``` + * But can be extended with a dataPath, which is the path to the data holding the value. (if no server validation in this context, then this can be fictive and is then just used internal for remembering the validation state despite the element begin removed from the DOM.) + * @example: + * ```js + * html``; + * ``` + * + * Additional the value can be provided, which is then used to remove a server validation state, if the value is changed. + * @example: + * ```js + * html``; + * ``` + * + */ +export const umbBindToValidation = directive(UmbBindToValidationDirective); + +//export type { UmbFocusDirective }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/index.ts index 9e9b667fa8..ecb51f74ac 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/index.ts @@ -1,3 +1,4 @@ +export * from './const.js'; export * from './context/index.js'; export * from './controllers/index.js'; export * from './events/index.js'; @@ -5,3 +6,4 @@ export * from './interfaces/index.js'; export * from './mixins/index.js'; export * from './translators/index.js'; export * from './utils/index.js'; +export * from './directives/bind-to-validation.lit-directive.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts index b390b64463..4a634d197c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts @@ -13,8 +13,8 @@ type UmbNativeFormControlElement = Pick< * https://developer.mozilla.org/en-US/docs/Web/API/ValidityState * */ type FlagTypes = - | 'badInput' | 'customError' + | 'badInput' | 'patternMismatch' | 'rangeOverflow' | 'rangeUnderflow' @@ -23,14 +23,27 @@ type FlagTypes = | 'tooShort' | 'typeMismatch' | 'valueMissing' - | 'badInput' | 'valid'; +const WeightedErrorFlagTypes = [ + 'customError', + 'valueMissing', + 'badInput', + 'typeMismatch', + 'patternMismatch', + 'rangeOverflow', + 'rangeUnderflow', + 'stepMismatch', + 'tooLong', + 'tooShort', +]; + // Acceptable as an internal interface/type, BUT if exposed externally this should be turned into a public interface in a separate file. export interface UmbFormControlValidatorConfig { flagKey: FlagTypes; getMessageMethod: () => string; checkMethod: () => boolean; + weight: number; } export interface UmbFormControlMixinInterface extends HTMLElement { @@ -216,8 +229,11 @@ export function UmbFormControlMixin< flagKey: flagKey, getMessageMethod: getMessageMethod, checkMethod: checkMethod, + weight: WeightedErrorFlagTypes.indexOf(flagKey), } satisfies UmbFormControlValidatorConfig; this.#validators.push(validator); + // Sort validators based on the WeightedErrorFlagTypes order. [NL] + this.#validators.sort((a, b) => (a.weight > b.weight ? 1 : b.weight > a.weight ? -1 : 0)); return validator; } @@ -287,29 +303,38 @@ export function UmbFormControlMixin< */ protected _runValidators() { this.#validity = {}; - const messages: Set = new Set(); + //const messages: Set = new Set(); + let message: string | undefined = undefined; let innerFormControlEl: UmbNativeFormControlElement | undefined = undefined; - // Loop through inner native form controls to adapt their validityState. [NL] - this.#formCtrlElements.forEach((formCtrlEl) => { - let key: keyof ValidityState; - for (key in formCtrlEl.validity) { - if (key !== 'valid' && formCtrlEl.validity[key]) { - this.#validity[key] = true; - messages.add(formCtrlEl.validationMessage); - innerFormControlEl ??= formCtrlEl; - } - } - }); - // Loop through custom validators, currently its intentional to have them overwritten native validity. but might need to be reconsidered (This current way enables to overwrite with custom messages) [NL] - this.#validators.forEach((validator) => { + this.#validators.some((validator) => { if (validator.checkMethod()) { this.#validity[validator.flagKey] = true; - messages.add(validator.getMessageMethod()); + //messages.add(validator.getMessageMethod()); + message = validator.getMessageMethod(); + return true; } + return false; }); + if (!message) { + // Loop through inner native form controls to adapt their validityState. [NL] + this.#formCtrlElements.some((formCtrlEl) => { + let key: keyof ValidityState; + for (key in formCtrlEl.validity) { + if (key !== 'valid' && formCtrlEl.validity[key]) { + this.#validity[key] = true; + //messages.add(formCtrlEl.validationMessage); + message = formCtrlEl.validationMessage; + innerFormControlEl ??= formCtrlEl; + return true; + } + } + return false; + }); + } + const hasError = Object.values(this.#validity).includes(true); // https://developer.mozilla.org/en-US/docs/Web/API/ValidityState#valid @@ -319,7 +344,8 @@ export function UmbFormControlMixin< this._internals.setValidity( this.#validity, // Turn messages into an array and join them with a comma. [NL]: - [...messages].join(', '), + //[...messages].join(', '), + message, innerFormControlEl ?? this.getFormElement() ?? undefined, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts new file mode 100644 index 0000000000..121310af55 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts @@ -0,0 +1,42 @@ +import { UmbValidationPathTranslatorBase } from './validation-path-translator-base.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export abstract class UmbAbstractArrayValidationPathTranslator extends UmbValidationPathTranslatorBase { + #initialPathToMatch: string; + #queryMethod: (data: unknown) => string; + + constructor(host: UmbControllerHost, initialPathToMatch: string, queryMethod: (data: any) => string) { + super(host); + + this.#initialPathToMatch = initialPathToMatch; + this.#queryMethod = queryMethod; + } + translate(path: string) { + if (!this._context) return; + if (path.indexOf(this.#initialPathToMatch) !== 0) { + // We do not handle this path. + return false; + } + const pathEnd = path.indexOf(']'); + if (pathEnd === -1) { + // We do not handle this path. + return false; + } + // retrieve the number from the message values index: [NL] + const index = parseInt(path.substring(this.#initialPathToMatch.length, pathEnd)); + + if (isNaN(index)) { + // index is not a number, this means its not a path we want to translate. [NL] + return false; + } + + // Get the data from the validation request, the context holds that for us: [NL] + const data = this.getDataFromIndex(index); + + if (!data) return false; + // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: + return this.#initialPathToMatch + this.#queryMethod(data) + path.substring(path.indexOf(']')); + } + + abstract getDataFromIndex(index: number): unknown | undefined; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/index.ts index 43b09bb49f..9192c2b141 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/index.ts @@ -1,2 +1,5 @@ -export type * from './validation-message-translator.interface.js'; -export * from './variant-values-validation-message-translator.controller.js'; +export * from './validation-path-translator-base.controller.js'; +export * from './abstract-array-path-translator.controller.js'; +export * from './variant-values-validation-path-translator.controller.js'; +export * from './variants-validation-path-translator.controller.js'; +export type * from './validation-message-path-translator.interface.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-path-translator.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-path-translator.interface.ts new file mode 100644 index 0000000000..9d7509e327 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-path-translator.interface.ts @@ -0,0 +1,8 @@ +export interface UmbValidationMessageTranslator { + /** + * + * @param path - The path to translate + * @returns {false | undefined | string} - Returns false if the path is not handled by this translator, undefined if the path is invalid, or the translated path as a string. + */ + translate(path: string): false | undefined | string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-translator.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-translator.interface.ts deleted file mode 100644 index 80b66fa608..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-message-translator.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface UmbValidationMessageTranslator { - translate(message: string): undefined | string; -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-path-translator-base.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-path-translator-base.controller.ts new file mode 100644 index 0000000000..27dda12276 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/validation-path-translator-base.controller.ts @@ -0,0 +1,30 @@ +import { UMB_VALIDATION_CONTEXT } from '../index.js'; +import type { UmbValidationMessageTranslator } from './validation-message-path-translator.interface.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +export abstract class UmbValidationPathTranslatorBase + extends UmbControllerBase + implements UmbValidationMessageTranslator +{ + // + protected _context?: typeof UMB_VALIDATION_CONTEXT.TYPE; + + constructor(host: UmbControllerHost) { + super(host); + + this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { + this._context?.removeTranslator(this); + this._context = context; + context.addTranslator(this); + }); + } + + override hostDisconnected(): void { + this._context?.removeTranslator(this); + this._context = undefined; + super.hostDisconnected(); + } + + abstract translate(path: string): ReturnType; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts deleted file mode 100644 index 7b10823bb4..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { UmbServerModelValidationContext } from '../context/server-model-validation.context.js'; -import { UmbDataPathPropertyValueFilter } from '../utils/data-path-property-value-filter.function.js'; -import type { UmbValidationMessageTranslator } from './validation-message-translator.interface.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; - -export class UmbVariantValuesValidationMessageTranslator - extends UmbControllerBase - implements UmbValidationMessageTranslator -{ - // - #context: UmbServerModelValidationContext; - - constructor(host: UmbControllerHost, context: UmbServerModelValidationContext) { - super(host); - context.addTranslator(this); - this.#context = context; - } - - translate(path: string) { - if (path.indexOf('$.values[') !== 0) { - // No translation anyway. - return; - } - const pathEnd = path.indexOf(']'); - if (pathEnd === -1) { - // No translation anyway. - return; - } - // retrieve the number from the message values index: [NL] - const index = parseInt(path.substring(9, pathEnd)); - - if (isNaN(index)) { - // No translation anyway. - return; - } - // Get the data from the validation request, the context holds that for us: [NL] - const data = this.#context.getData(); - - const specificValue = data.values[index]; - // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: - return '$.values[' + UmbDataPathPropertyValueFilter(specificValue) + path.substring(path.indexOf(']')); - } - - override destroy(): void { - super.destroy(); - this.#context.removeTranslator(this); - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts new file mode 100644 index 0000000000..f3e1020607 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts @@ -0,0 +1,15 @@ +import { UmbDataPathPropertyValueQuery } from '../utils/data-path-property-value-query.function.js'; +import { UmbAbstractArrayValidationPathTranslator } from './abstract-array-path-translator.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbVariantValuesValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + constructor(host: UmbControllerHost) { + super(host, '$.values[', UmbDataPathPropertyValueQuery); + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + return data.values[index]; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts new file mode 100644 index 0000000000..545e2a5488 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts @@ -0,0 +1,15 @@ +import { UmbDataPathVariantQuery } from '../utils/data-path-variant-query.function.js'; +import { UmbAbstractArrayValidationPathTranslator } from './abstract-array-path-translator.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbVariantsValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + constructor(host: UmbControllerHost) { + super(host, '$.variants[', UmbDataPathVariantQuery); + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + return data.variants[index]; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-filter.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-query.function.ts similarity index 66% rename from src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-filter.function.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-query.function.ts index 408cf91b06..d184f6e205 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-filter.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-property-value-query.function.ts @@ -2,22 +2,22 @@ import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; import type { UmbVariantPropertyValueModel } from '@umbraco-cms/backoffice/variant'; /** - * Validation Data Path filter for Property Value. + * Validation Data Path Query generator for Property Value. * write a JSON-Path filter similar to `?(@.alias = 'myAlias' && @.culture == 'en-us' && @.segment == 'mySegment')` * where culture and segment are optional * @param value * @returns */ -export function UmbDataPathPropertyValueFilter( +export function UmbDataPathPropertyValueQuery( value: UmbPartialSome, 'culture' | 'segment'>, ): string { // write a array of strings for each property, where alias must be present and culture and segment are optional const filters: Array = [`@.alias = '${value.alias}'`]; - if (value.culture) { - filters.push(`@.culture == '${value.culture}'`); + if (value.culture !== undefined) { + filters.push(`@.culture = ${value.culture ? `'${value.culture}'` : 'null'}`); } - if (value.segment) { - filters.push(`@.segment == '${value.segment}'`); + if (value.segment !== undefined) { + filters.push(`@.segment = ${value.segment ? `'${value.segment}'` : 'null'}`); } return `?(${filters.join(' && ')})`; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-variant-query.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-variant-query.function.ts new file mode 100644 index 0000000000..25666269cd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/data-path-variant-query.function.ts @@ -0,0 +1,20 @@ +import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; +import type { UmbVariantPropertyValueModel } from '@umbraco-cms/backoffice/variant'; + +/** + * Validation Data Path query generator for Variant. + * write a JSON-Path filter similar to `?(@.culture == 'en-us' && @.segment == 'mySegment')` + * where segment are optional. + * @param value + * @returns + */ +export function UmbDataPathVariantQuery( + value: UmbPartialSome, 'segment'>, +): string { + // write a array of strings for each property, where culture must be present and segment is optional + const filters: Array = [`@.culture = ${value.culture ? `'${value.culture}'` : 'null'}`]; + if (value.segment !== undefined) { + filters.push(`@.segment = ${value.segment ? `'${value.segment}'` : 'null'}`); + } + return `?(${filters.join(' && ')})`; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/index.ts index 1fb8cf117c..52e7d2c3c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/index.ts @@ -1 +1,3 @@ -export * from './data-path-property-value-filter.function.js'; +export * from './data-path-property-value-query.function.js'; +export * from './data-path-variant-query.function.js'; +export * from './json-path.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.function.ts new file mode 100644 index 0000000000..98606e2ee3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.function.ts @@ -0,0 +1,112 @@ +/** + * + * @param data + * @param path + */ +export function GetValueByJsonPath(data: any, path: string): any { + // strip $ from the path: + const strippedPath = path.startsWith('$.') ? path.slice(2) : path; + // get value from the path: + return GetNextPropertyValueFromPath(data, strippedPath); +} + +/** + * + * @param path + */ +export function GetPropertyNameFromPath(path: string): string { + // find next '.' or '[' in the path, using regex: + const match = path.match(/\.|\[/); + // If no match is found, we assume its a single key so lets return the value of the key: + if (match === null || match.index === undefined) return path; + + // split the path at the first match: + return path.slice(0, match.index); +} + +/** + * + * @param data + * @param path + */ +function GetNextPropertyValueFromPath(data: any, path: string): any { + if (!data) return undefined; + // find next '.' or '[' in the path, using regex: + const match = path.match(/\.|\[/); + // If no match is found, we assume its a single key so lets return the value of the key: + if (match === null || match.index === undefined) return data[path]; + + // split the path at the first match: + const key = path.slice(0, match.index); + const rest = path.slice(match.index + 1); + + if (!key) return undefined; + // get the value of the key from the data: + const value = data[key]; + // if there is no rest of the path, return the value: + if (rest === undefined) return value; + // if the value is an array, get the value at the index: + if (Array.isArray(value)) { + // get the value until the next ']', the value can be anything in between the brackets: + const lookupEnd = rest.match(/\]/); + if (!lookupEnd) return undefined; + // get everything before the match: + const entryPointer = rest.slice(0, lookupEnd.index); + + // check if the entryPointer is a JSON Path Filter ( starting with ?( and ending with ) ): + if (entryPointer.startsWith('?(') && entryPointer.endsWith(')')) { + // get the filter from the entryPointer: + console.log('query', entryPointer); + // get the filter as a function: + const jsFilter = JsFilterFromJsonPathFilter(entryPointer); + // find the index of the value that matches the filter: + const index = value.findIndex(jsFilter[0]); + // if the index is -1, return undefined: + if (index === -1) return undefined; + // get the value at the index: + const data = value[index]; + // Check for safety: + if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) { + return data; + } + // continue with the rest of the path: + return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data; + } else { + // get the value at the index: + const indexAsNumber = parseInt(entryPointer); + if (isNaN(indexAsNumber)) return undefined; + const data = value[indexAsNumber]; + // Check for safety: + if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) { + return data; + } + // continue with the rest of the path: + return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data; + } + } else { + // continue with the rest of the path: + return GetNextPropertyValueFromPath(value, rest); + } +} + +/** + * + * @param filter + */ +function JsFilterFromJsonPathFilter(filter: string): any { + // strip ?( and ) from the filter + const jsFilter = filter.slice(2, -1); + // split the filter into parts by splitting at ' && ' + const parts = jsFilter.split(' && '); + // map each part to a function that returns true if the part is true + return parts.map((part) => { + // split the part into key and value + const [path, equal] = part.split(' = '); + // remove @. + const key = path.slice(2); + // remove quotes: + const value = equal.slice(1, -1); + // return a function that returns true if the key is equal to the value + return (item: any) => item[key] === value; + }); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.test.ts new file mode 100644 index 0000000000..3673b28ecd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/utils/json-path.test.ts @@ -0,0 +1,37 @@ +import { expect } from '@open-wc/testing'; +import { GetValueByJsonPath } from './json-path.function.js'; + +describe('UmbJsonPathFunctions', () => { + it('retrieve property value', () => { + const result = GetValueByJsonPath({ value: 'test' }, '$.value'); + + expect(result).to.eq('test'); + }); + + it('value of first entry in an array', () => { + const result = GetValueByJsonPath({ values: ['test'] }, '$.values[0]'); + + expect(result).to.eq('test'); + }); + + it('value property of first entry in an array', () => { + const result = GetValueByJsonPath({ values: [{ value: 'test' }] }, '$.values[0].value'); + + expect(result).to.eq('test'); + }); + + it('value property of first entry in an array', () => { + const result = GetValueByJsonPath( + { values: [{ value: { deepData: [{ value: 'inner' }] } }] }, + '$.values[0].value.deepData[0].value', + ); + + expect(result).to.eq('inner'); + }); + + it('query of first entry in an array', () => { + const result = GetValueByJsonPath({ values: [{ id: '123', value: 'test' }] }, "$.values[?(@.id = '123')].value"); + + expect(result).to.eq('test'); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts index ce012c8c61..dfac1d8762 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts @@ -81,15 +81,19 @@ export class UmbVariantId { // TODO: needs localization option: // TODO: Consider if this should be handled else where, it does not seem like the responsibility of this class, since it contains wordings: - public toDifferencesString(variantId: UmbVariantId): string { + public toDifferencesString( + variantId: UmbVariantId, + invariantMessage: string = 'Invariant', + unsegmentedMessage: string = 'Unsegmented', + ): string { let r = ''; if (variantId.culture !== this.culture) { - r = 'Invariant'; + r = invariantMessage; } if (variantId.segment !== this.segment) { - r = (r !== '' ? ' ' : '') + 'Unsegmented'; + r = (r !== '' ? ' ' : '') + unsegmentedMessage; } return r; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 3b9b7511b2..c04299b8e1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ 'modal/index': './modal/index.ts', 'models/index': './models/index.ts', 'notification/index': './notification/index.ts', + 'object-type/index': './object-type/index.ts', 'picker-input/index': './picker-input/index.ts', 'property-action/index': './property-action/index.ts', 'property-editor/index': './property-editor/index.ts', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 15639d6ed7..79a7b63072 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -19,7 +19,6 @@ import type { UmbRoute, UmbRouterSlotInitEvent, UmbRouterSlotChangeEvent } from * @class UmbWorkspaceEditor * @augments {UmbLitElement} */ -// TODO: This element has a bug in the tabs. After the url changes - for example a new entity/file is chosen in the tree and loaded to the workspace the links in the tabs still point to the previous url and therefore views do not change correctly @customElement('umb-workspace-editor') export class UmbWorkspaceEditorElement extends UmbLitElement { @property() diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index c4bfbf0cab..7a9bce0fc7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -12,6 +12,7 @@ import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_PROPERTY_DATASET_CONTEXT, isNameablePropertyDatasetContext } from '@umbraco-cms/backoffice/property'; import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbDataPathVariantQuery, umbBindToValidation } from '@umbraco-cms/backoffice/validation'; type UmbDocumentVariantOption = { culture: string | null; @@ -45,6 +46,9 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { @state() private _name?: string; + @state() + private _variantId?: UmbVariantId; + @state() private _variantDisplayName = ''; @@ -132,11 +136,11 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { const workspaceContext = this.#splitViewContext.getWorkspaceContext(); if (!workspaceContext) return; - const variantId = this.#datasetContext.getVariantId(); + this._variantId = this.#datasetContext.getVariantId(); // Find the variant option matching this, to get the language name... - const culture = variantId.culture; - const segment = variantId.segment; + const culture = this._variantId.culture; + const segment = this._variantId.segment; this.observe( workspaceContext.variantOptions, @@ -209,12 +213,15 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { } override render() { - return html` + return this._variantId + ? html` ${ @@ -287,7 +294,8 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { : nothing }
- `; + ` + : nothing; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts index b98584e961..ccc13c9526 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts @@ -7,7 +7,7 @@ import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; -import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import type { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export abstract class UmbSubmittableWorkspaceContextBase extends UmbContextBase> @@ -18,7 +18,16 @@ export abstract class UmbSubmittableWorkspaceContextBase // TODO: We could make a base type for workspace modal data, and use this here: As well as a base for the result, to make sure we always include the unique (instead of the object type) public readonly modalContext?: UmbModalContext<{ preset: object }>; - public readonly validation = new UmbValidationContext(this); + //public readonly validation = new UmbValidationContext(this); + #validationContexts: Array = []; + + /** + * Appends a validation context to the workspace. + * @param context + */ + addValidationContext(context: UmbValidationContext) { + this.#validationContexts.push(context); + } #submitPromise: Promise | undefined; #submitResolve: (() => void) | undefined; @@ -42,14 +51,15 @@ export abstract class UmbSubmittableWorkspaceContextBase constructor(host: UmbControllerHost, workspaceAlias: string) { super(host, UMB_WORKSPACE_CONTEXT.toString()); this.workspaceAlias = workspaceAlias; - // TODO: Consider if we can turn this consumption to submitComplete, just as a getContext. [NL] + // TODO: Consider if we can move this consumption to #resolveSubmit, just as a getContext, but it depends if others use the modalContext prop.. [NL] this.consumeContext(UMB_MODAL_CONTEXT, (context) => { (this.modalContext as UmbModalContext) = context; }); } protected resetState() { - this.validation.reset(); + //this.validation.reset(); + this.#validationContexts.forEach((context) => context.reset()); this.#isNew.setValue(undefined); } @@ -61,6 +71,15 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#isNew.setValue(isNew); } + /** + * If a Workspace has multiple validation contexts, then this method can be overwritten to return the correct one. + * @returns Promise that resolves to void when the validation is complete. + */ + async validate(): Promise> { + //return this.validation.validate(); + return Promise.all(this.#validationContexts.map((context) => context.validate())); + } + async requestSubmit(): Promise { return this.validateAndSubmit( () => this.submit(), @@ -76,7 +95,7 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#submitResolve = resolve; this.#submitReject = reject; }); - this.validation.validate().then( + this.validate().then( async () => { onValid().then(this.#completeSubmit, this.#rejectSubmit); }, @@ -90,6 +109,8 @@ export abstract class UmbSubmittableWorkspaceContextBase #rejectSubmit = () => { if (this.#submitPromise) { + // TODO: Capture the validation contexts messages on open, and then reset to them in this case. [NL] + this.#submitReject?.(); this.#submitPromise = undefined; this.#submitResolve = undefined; @@ -115,7 +136,8 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#resolveSubmit(); // Calling reset on the validation context here. [NL] - this.validation.reset(); + // TODO: Capture the validation messages on open, and then reset to that. + //this.validation.reset(); }; //abstract getIsDirty(): Promise; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts index 62700d299f..f30d2a64d4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts @@ -1,10 +1,10 @@ import { UMB_DATA_TYPE_PICKER_FLOW_MODAL } from '../../modals/index.js'; import { UMB_DATATYPE_WORKSPACE_MODAL } from '../../workspace/index.js'; -import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { css, html, customElement, property, state, type PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; // Note: Does only support picking a single data type. But this could be developed later into this same component. To follow other picker input components. /** @@ -15,7 +15,7 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; * @fires focus - when the input gains focus */ @customElement('umb-data-type-flow-input') -export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, '') { +export class UmbInputDataTypeElement extends UmbFormControlMixin(UmbLitElement, '') { protected override getFormElement() { return undefined; } @@ -52,9 +52,7 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, new UmbModalRouteRegistrationController(this, UMB_DATA_TYPE_PICKER_FLOW_MODAL) .onSetup(() => { return { - data: { - submitLabel: 'Submit', - }, + data: {}, value: { selection: this._ids ?? [] }, }; }) @@ -68,6 +66,20 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, }); } + protected override firstUpdated(_changedProperties: PropertyValueMap | Map): void { + super.firstUpdated(_changedProperties); + + this.addValidator( + 'valueMissing', + () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => this.hasAttribute('required') && !this.value, + ); + } + + override focus() { + this.shadowRoot?.querySelector('umb-ref-data-type')?.focus(); + } + override render() { return this._ids && this._ids.length > 0 ? html` @@ -89,6 +101,9 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, label="Select Property Editor" look="placeholder" color="default" + @focus=${() => { + this.pristine = false; + }} .href=${this._createRoute}> `; } @@ -100,6 +115,11 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, --uui-button-padding-top-factor: 4; --uui-button-padding-bottom-factor: 4; } + :host(:invalid:not([pristine])) #empty-state-button { + --uui-button-border-color: var(--uui-color-danger); + --uui-button-border-width: 2px; + --uui-button-contrast: var(--uui-color-danger); + } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts index 46a9136d23..2fb4bb1ffc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts @@ -3,7 +3,7 @@ import { html, customElement, state, ifDefined, repeat } from '@umbraco-cms/back import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { PropertyEditorSettingsProperty } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbDataPathPropertyValueFilter } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathPropertyValueQuery } from '@umbraco-cms/backoffice/validation'; /** * @element umb-property-editor-config @@ -46,7 +46,7 @@ export class UmbPropertyEditorConfigElement extends UmbLitElement { (property) => property.alias, (property) => html` { - this._dataTypes = dataTypes; + this._dataTypes = dataTypes.sort((a, b) => a.name.localeCompare(b.name)); }); } - private _handleClick(dataType: UmbDataTypeItemModel) { + #handleClick(dataType: UmbDataTypeItemModel) { if (dataType.unique) { this.value = { dataTypeId: dataType.unique }; this.modalContext?.submit(); } } - private _handleCreate() { + #handleCreate() { this.value = { createNewWithPropertyEditorUiAlias: this._propertyEditorUiAlias }; this.modalContext?.submit(); } - private _close() { + #close() { this.modalContext?.reject(); } override render() { return html` - - ${this._renderDataTypes()} ${this._renderCreate()} + + ${this.#renderDataTypes()} ${this.#renderCreate()}
- +
`; } - private _renderDataTypes() { - return this._dataTypes && this._dataTypes.length > 0 - ? html`
    - ${repeat( - this._dataTypes!, - (dataType) => dataType.unique, - (dataType) => - dataType.unique - ? html`
  • - -
    - - ${dataType.name} -
    -
    -
  • ` - : '', - )} -
` - : ''; - } - private _renderCreate() { + #renderDataTypes() { + if (!this._dataTypes?.length) return; return html` - +
    + ${repeat( + this._dataTypes, + (dataType) => dataType.unique, + (dataType) => html` +
  • + this.#handleClick(dataType)}> +
    + + ${dataType.name} +
    +
    +
  • + `, + )} +
+ `; + } + + #renderCreate() { + return html` +
- Create new + Create new
`; @@ -174,12 +175,6 @@ export class UmbDataTypePickerFlowDataTypePickerModalElement extends UmbModalBas margin: auto; } - #category-name { - text-align: center; - display: block; - text-transform: capitalize; - font-size: 1.2rem; - } #create-button { max-width: 100px; --uui-button-padding-left-factor: 0; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts index a898f756b9..cf2af5dd45 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts @@ -6,23 +6,20 @@ import type { UmbDataTypePickerFlowModalData, UmbDataTypePickerFlowModalValue, } from './data-type-picker-flow-modal.token.js'; -import { css, html, repeat, customElement, state, when, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UmbPaginationManager, debounce, fromCamelCase } from '@umbraco-cms/backoffice/utils'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type'; +import { UMB_PROPERTY_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/property-type'; import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbDataTypeItemModel } from '@umbraco-cms/backoffice/data-type'; import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; -import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; -import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type'; -import { UMB_PROPERTY_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/property-type'; -import { UmbPaginationManager, debounce } from '@umbraco-cms/backoffice/utils'; -interface GroupedItems { - [key: string]: Array; -} @customElement('umb-data-type-picker-flow-modal') export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< UmbDataTypePickerFlowModalData, @@ -32,17 +29,13 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< public override set data(value: UmbDataTypePickerFlowModalData) { super.data = value; - this._submitLabel = this.data?.submitLabel ?? this._submitLabel; } @state() - private _groupedDataTypes?: GroupedItems; + private _groupedDataTypes?: Array<{ key: string; items: Array }> = []; @state() - private _groupedPropertyEditorUIs: GroupedItems = {}; - - @state() - private _submitLabel = 'Select'; + private _groupedPropertyEditorUIs: Array<{ key: string; items: Array }> = []; @state() private _currentPage = 1; @@ -52,13 +45,18 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< pagination = new UmbPaginationManager(); - private _createDataTypeModal!: UmbModalRouteRegistrationController; - #collectionRepository; - #dataTypes: Array = []; - #propertyEditorUIs: Array = []; + + #createDataTypeModal!: UmbModalRouteRegistrationController; + #currentFilterQuery = ''; + #dataTypes: Array = []; + + #groupLookup: Record = {}; + + #propertyEditorUIs: Array = []; + constructor() { super(); @@ -66,10 +64,10 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< this.#init(); } - private _createDataType(propertyEditorUiAlias: string) { + #createDataType(propertyEditorUiAlias: string) { // TODO: Could be nice with a more pretty way to prepend to the URL: // Open create modal: - this._createDataTypeModal.open( + this.#createDataTypeModal.open( { uiAlias: propertyEditorUiAlias }, `create/parent/${UMB_DATA_TYPE_ENTITY_TYPE}/null`, ); @@ -82,10 +80,13 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< this.#initPromise = Promise.all([ this.observe(umbExtensionsRegistry.byType('propertyEditorUi'), (propertyEditorUIs) => { // Only include Property Editor UIs which has Property Editor Schema Alias - this.#propertyEditorUIs = propertyEditorUIs.filter( - (propertyEditorUi) => !!propertyEditorUi.meta.propertyEditorSchemaAlias, - ); - this._performFiltering(); + this.#propertyEditorUIs = propertyEditorUIs + .filter((propertyEditorUi) => !!propertyEditorUi.meta.propertyEditorSchemaAlias) + .sort((a, b) => a.meta.label.localeCompare(b.meta.label)); + + this.#groupLookup = Object.fromEntries(propertyEditorUIs.map((ui) => [ui.alias, ui.meta.group])); + + this.#performFiltering(); }).asPromise(), ]); @@ -101,10 +102,10 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< }) .onSubmit((submitData) => { if (submitData?.dataTypeId) { - this._select(submitData.dataTypeId); + this.#select(submitData.dataTypeId); this._submitModal(); } else if (submitData?.createNewWithPropertyEditorUiAlias) { - this._createDataType(submitData.createNewWithPropertyEditorUiAlias); + this.#createDataType(submitData.createNewWithPropertyEditorUiAlias); } }) .observeRouteBuilder((routeBuilder) => { @@ -112,7 +113,7 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< this.requestUpdate('_dataTypePickerModalRouteBuilder'); }); - this._createDataTypeModal = new UmbModalRouteRegistrationController(this, UMB_DATATYPE_WORKSPACE_MODAL) + this.#createDataTypeModal = new UmbModalRouteRegistrationController(this, UMB_DATATYPE_WORKSPACE_MODAL) .addAdditionalPath(':uiAlias') .onSetup(async (params) => { const contentContextConsumer = this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, () => { @@ -137,7 +138,7 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< }; }) .onSubmit((value) => { - this._select(value?.unique); + this.#select(value?.unique); this._submitModal(); }); } @@ -160,14 +161,14 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< } } - private _handleDataTypeClick(dataType: UmbDataTypeItemModel) { + #handleDataTypeClick(dataType: UmbDataTypeItemModel) { if (dataType.unique) { - this._select(dataType.unique); + this.#select(dataType.unique); this._submitModal(); } } - private _select(unique: string | undefined) { + #select(unique: string | undefined) { this.value = { selection: unique ? [unique] : [] }; } @@ -188,98 +189,100 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< async #handleFiltering() { await this.#getDataTypes(); - this._performFiltering(); + this.#performFiltering(); } - private _performFiltering() { + #performFiltering() { if (this.#currentFilterQuery) { - const filteredDataTypes = this.#dataTypes.filter((dataType) => - dataType.name?.toLowerCase().includes(this.#currentFilterQuery), + const filteredDataTypes = this.#dataTypes + .filter((dataType) => dataType.name?.toLowerCase().includes(this.#currentFilterQuery)) + .sort((a, b) => a.name.localeCompare(b.name)); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const grouped = Object.groupBy(filteredDataTypes, (dataType: UmbDataTypeItemModel) => + fromCamelCase(this.#groupLookup[dataType.propertyEditorUiAlias] ?? 'Uncategorized'), ); - /* TODO: data type items doesn't have a group property. We will need a reference to the Property Editor UI to get the group. - this is a temp solution to group them as uncategorized. The same result as with the lodash groupBy. - */ - this._groupedDataTypes = { - undefined: filteredDataTypes, - }; + this._groupedDataTypes = Object.keys(grouped) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ key, items: grouped[key] })); } else { - this._groupedDataTypes = undefined; + this._groupedDataTypes = []; } const filteredUIs = !this.#currentFilterQuery ? this.#propertyEditorUIs - : this.#propertyEditorUIs.filter((propertyEditorUI) => { - return ( + : this.#propertyEditorUIs.filter( + (propertyEditorUI) => propertyEditorUI.name.toLowerCase().includes(this.#currentFilterQuery) || - propertyEditorUI.alias.toLowerCase().includes(this.#currentFilterQuery) - ); - }); + propertyEditorUI.alias.toLowerCase().includes(this.#currentFilterQuery), + ); - // TODO: groupBy is not known by TS yet // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - this._groupedPropertyEditorUIs = Object.groupBy( - filteredUIs, - (propertyEditorUI: ManifestPropertyEditorUi) => propertyEditorUI.meta.group, + const grouped = Object.groupBy(filteredUIs, (propertyEditorUi: ManifestPropertyEditorUi) => + fromCamelCase(propertyEditorUi.meta.group ?? 'Uncategorized'), ); + + this._groupedPropertyEditorUIs = Object.keys(grouped) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ key, items: grouped[key] })); } override render() { return html` - - ${this._renderFilter()} ${this._renderGrid()} + + ${this.#renderFilter()} ${this.#renderGrid()}
- +
`; } - private _renderGrid() { - return this.#currentFilterQuery ? this._renderFilteredList() : this._renderUIs(); + #renderGrid() { + return this.#currentFilterQuery ? this.#renderFilteredList() : this.#renderUIs(); } - private _renderFilter() { + #renderFilter() { return html` `; } - private _renderFilteredList() { + #renderFilteredList() { if (!this._groupedDataTypes) return nothing; - const dataTypesEntries = Object.entries(this._groupedDataTypes); - if (!this._groupedPropertyEditorUIs) return nothing; - const editorUIEntries = Object.entries(this._groupedPropertyEditorUIs); - - if (dataTypesEntries.length === 0 && editorUIEntries.length === 0) { - return html`Nothing matches your search, try another search term.`; + if (this._groupedDataTypes.length === 0 && this._groupedPropertyEditorUIs.length === 0) { + return html`

Nothing matches your search, try another search term.

`; } return html` ${when( - dataTypesEntries.length > 0, - () => - html`
- Available configurations -
- ${this._renderDataTypes()}${this.#renderLoadMore()}`, + this._groupedDataTypes.length > 0, + () => html` +
+ Available configurations +
+ ${this.#renderDataTypes()} ${this.#renderLoadMore()} + `, )} ${when( - editorUIEntries.length > 0, - () => - html`
- Create a new configuration -
- ${this._renderUIs(true)}`, + this._groupedPropertyEditorUIs.length > 0, + () => html` +
+ Create a new configuration +
+ ${this.#renderUIs(true)} + `, )} `; } @@ -289,83 +292,93 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< return html``; } - private _renderDataTypes() { + #renderDataTypes() { if (!this._groupedDataTypes) return nothing; - const entries = Object.entries(this._groupedDataTypes); - // TODO: Fix so we can have Data Types grouped. (or choose not to group them) - return entries.map( - ([key, value]) => - html`
${key === 'undefined' ? 'Uncategorized' : key}
- ${this._renderGroupDataTypes(value)}`, + return this._groupedDataTypes.map( + (group) => html` +
${group.key}
+ ${this.#renderGroupDataTypes(group.items)} + `, ); } - private _renderUIs(createAsNewOnPick?: boolean) { + #renderUIs(createAsNewOnPick?: boolean) { if (!this._groupedPropertyEditorUIs) return nothing; - const entries = Object.entries(this._groupedPropertyEditorUIs); - - return entries.map( - ([key, value]) => - html`
${key === 'undefined' ? 'Uncategorized' : key}
- ${this._renderGroupUIs(value, createAsNewOnPick)}`, + return this._groupedPropertyEditorUIs.map( + (group) => html` +
${group.key}
+ ${this.#renderGroupUIs(group.items, createAsNewOnPick)} + `, ); } - private _renderGroupUIs(uis: Array, createAsNewOnPick?: boolean) { - return html`
    - ${this._dataTypePickerModalRouteBuilder - ? repeat( - uis, - (propertyEditorUI) => propertyEditorUI.alias, - (propertyEditorUI) => { - return html`
  • ${this._renderDataTypeButton(propertyEditorUI, createAsNewOnPick)}
  • `; - }, - ) - : ''} -
`; + #renderGroupUIs(uis: Array, createAsNewOnPick?: boolean) { + return html` +
    + ${this._dataTypePickerModalRouteBuilder + ? repeat( + uis, + (propertyEditorUI) => propertyEditorUI.alias, + (propertyEditorUI) => { + return html`
  • ${this.#renderDataTypeButton(propertyEditorUI, createAsNewOnPick)}
  • `; + }, + ) + : ''} +
+ `; } - private _renderDataTypeButton(propertyEditorUI: ManifestPropertyEditorUi, createAsNewOnPick?: boolean) { + #renderDataTypeButton(propertyEditorUI: ManifestPropertyEditorUi, createAsNewOnPick?: boolean) { if (createAsNewOnPick) { - return html` this._createDataType(propertyEditorUI.alias)}> - ${this._renderItemContent(propertyEditorUI)} - `; + return html` + this.#createDataType(propertyEditorUI.alias)}> + ${this.#renderItemContent(propertyEditorUI)} + + `; } else { - return html` - ${this._renderItemContent(propertyEditorUI)} - `; + return html` + + ${this.#renderItemContent(propertyEditorUI)} + + `; } } - private _renderItemContent(propertyEditorUI: ManifestPropertyEditorUi) { - return html`
- - ${propertyEditorUI.meta.label || propertyEditorUI.name} -
`; + + #renderItemContent(propertyEditorUI: ManifestPropertyEditorUi) { + return html` +
+ + ${propertyEditorUI.meta.label || propertyEditorUI.name} +
+ `; } - private _renderGroupDataTypes(dataTypes: Array) { - return html`
    - ${repeat( - dataTypes, - (dataType) => dataType.unique, - (dataType) => - html`
  • - -
    - - ${dataType.name} -
    -
    -
  • `, - )} -
`; + #renderGroupDataTypes(dataTypes: Array) { + return html` +
    + ${repeat( + dataTypes, + (dataType) => dataType.unique, + (dataType) => html` +
  • + this.#handleDataTypeClick(dataType)}> +
    + + ${dataType.name} +
    +
    +
  • + `, + )} +
+ `; } static override styles = [ @@ -430,6 +443,7 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< height: 100%; width: 100%; } + #item-grid .item .icon { font-size: 2em; margin: auto; @@ -440,7 +454,6 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< } .choice-type-headline { - text-transform: capitalize; border-bottom: 1px solid var(--uui-color-divider); } `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.token.ts index d6acec2fda..55a1643d90 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.token.ts @@ -1,6 +1,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export interface UmbDataTypePickerFlowModalData { + /** @deprecated This property will be removed in Umbraco 15. */ submitLabel?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/property-editor-ui-picker/property-editor-ui-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/property-editor-ui-picker/property-editor-ui-picker-modal.element.ts index a8ea26c931..3fb5bd97c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/property-editor-ui-picker/property-editor-ui-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/property-editor-ui-picker/property-editor-ui-picker-modal.element.ts @@ -1,141 +1,131 @@ -import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; +import { css, customElement, html, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import { fromCamelCase } from '@umbraco-cms/backoffice/utils'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbPropertyEditorUIPickerModalData, UmbPropertyEditorUIPickerModalValue, } from '@umbraco-cms/backoffice/modal'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/extension-registry'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; +import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; -interface GroupedPropertyEditorUIs { - [key: string]: Array; -} @customElement('umb-property-editor-ui-picker-modal') export class UmbPropertyEditorUIPickerModalElement extends UmbModalBaseElement< UmbPropertyEditorUIPickerModalData, UmbPropertyEditorUIPickerModalValue > { @state() - private _groupedPropertyEditorUIs: GroupedPropertyEditorUIs = {}; + private _groupedPropertyEditorUIs: Array<{ key: string; items: Array }> = []; @state() private _propertyEditorUIs: Array = []; - @state() - private _submitLabel = 'Select'; - override connectedCallback(): void { super.connectedCallback(); - // TODO: We never parse on a submit label, so this seem weird as we don't enable this of other places. - //this._submitLabel = this.data?.submitLabel ?? this._submitLabel; - this.#usePropertyEditorUIs(); } #usePropertyEditorUIs() { this.observe(umbExtensionsRegistry.byType('propertyEditorUi'), (propertyEditorUIs) => { // Only include Property Editor UIs which has Property Editor Schema Alias - this._propertyEditorUIs = propertyEditorUIs.filter( - (propertyEditorUi) => !!propertyEditorUi.meta.propertyEditorSchemaAlias, - ); + this._propertyEditorUIs = propertyEditorUIs + .filter((propertyEditorUi) => !!propertyEditorUi.meta.propertyEditorSchemaAlias) + .sort((a, b) => a.meta.label.localeCompare(b.meta.label)); - // TODO: groupBy is not known by TS yet - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - this._groupedPropertyEditorUIs = Object.groupBy( - this._propertyEditorUIs, - (propertyEditorUi: ManifestPropertyEditorUi) => propertyEditorUi.meta.group, - ); + this.#groupPropertyEditorUIs(this._propertyEditorUIs); }); } #handleClick(propertyEditorUi: ManifestPropertyEditorUi) { - this.#select(propertyEditorUi.alias); - } - - #select(alias: string) { - this.value = { selection: [alias] }; + this.value = { selection: [propertyEditorUi.alias] }; + this._submitModal(); } #handleFilterInput(event: UUIInputEvent) { - let query = (event.target.value as string) || ''; - query = query.toLowerCase(); + const query = ((event.target.value as string) || '').toLowerCase(); const result = !query ? this._propertyEditorUIs - : this._propertyEditorUIs.filter((propertyEditorUI) => { - return ( - propertyEditorUI.name.toLowerCase().includes(query) || propertyEditorUI.alias.toLowerCase().includes(query) - ); - }); + : this._propertyEditorUIs.filter( + (propertyEditorUI) => + propertyEditorUI.name.toLowerCase().includes(query) || propertyEditorUI.alias.toLowerCase().includes(query), + ); - // TODO: groupBy is not known by TS yet + this.#groupPropertyEditorUIs(result); + } + + #groupPropertyEditorUIs(items: Array) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - this._groupedPropertyEditorUIs = Object.groupBy( - result, - (propertyEditorUI: ManifestPropertyEditorUi) => propertyEditorUI.meta.group, + const grouped = Object.groupBy(items, (propertyEditorUi: ManifestPropertyEditorUi) => + fromCamelCase(propertyEditorUi.meta.group), ); + + this._groupedPropertyEditorUIs = Object.keys(grouped) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ key, items: grouped[key] })); } override render() { return html` - ${this._renderFilter()} ${this._renderGrid()} + ${this.#renderFilter()} ${this.#renderGrid()}
- - +
`; } - private _renderFilter() { - return html` - - `; + #renderFilter() { + return html` + + + + `; } - private _renderGrid() { - return html` ${Object.entries(this._groupedPropertyEditorUIs).map( - ([key, value]) => - html`

${key}

- ${this._renderGroupItems(value)}`, - )}`; - } - - private _renderGroupItems(groupItems: Array) { - return html`
    + #renderGrid() { + return html` ${repeat( - groupItems, - (propertyEditorUI) => propertyEditorUI.alias, - (propertyEditorUI) => - html`
  • - -
  • `, + this._groupedPropertyEditorUIs, + (group) => group.key, + (group) => html` +

    ${group.key}

    + ${this.#renderGroupItems(group.items)} + `, )} -
`; + `; + } + + #renderGroupItems(groupItems: Array) { + return html` +
    + ${repeat( + groupItems, + (propertyEditorUI) => propertyEditorUI.alias, + (propertyEditorUI) => html` +
  • + +
  • + `, + )} +
+ `; } static override styles = [ - UmbTextStyles, css` #filter { width: 100%; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace-editor.element.ts index cdea583b3c..413eca4ee8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace-editor.element.ts @@ -4,6 +4,7 @@ import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; import type { ManifestWorkspace } from '@umbraco-cms/backoffice/extension-registry'; +import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; /** * @element umb-data-type-workspace-editor * @description - Element for displaying the Data Type Workspace edit route. @@ -54,8 +55,10 @@ export class UmbDataTypeWorkspaceEditorElement extends UmbLitElement { `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts index 49aa80140f..46bb5ec6a3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts @@ -28,6 +28,7 @@ import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; type EntityType = UmbDataTypeDetailModel; @@ -99,6 +100,8 @@ export class UmbDataTypeWorkspaceContext constructor(host: UmbControllerHost) { super(host, 'Umb.Workspace.DataType'); + this.addValidationContext(new UmbValidationContext(this).provide()); + this.#observePropertyEditorSchemaAlias(); this.#observePropertyEditorUIAlias(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts index b107d79176..b6f5ba92a0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts @@ -4,6 +4,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UMB_MODAL_MANAGER_CONTEXT, UMB_PROPERTY_EDITOR_UI_PICKER_MODAL } from '@umbraco-cms/backoffice/modal'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry'; +import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; @customElement('umb-data-type-details-workspace-view') export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement implements UmbWorkspaceViewElement { @@ -71,11 +72,9 @@ export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement im override render() { return html` - - ${this._propertyEditorUiAlias && this._propertyEditorSchemaAlias - ? this.#renderPropertyEditorReference() - : this.#renderChooseButton()} - + ${this._propertyEditorUiAlias && this._propertyEditorSchemaAlias + ? this.#renderPropertyEditorReference() + : this.#renderChooseButton()} ${this.#renderSettings()} `; @@ -90,37 +89,44 @@ export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement im `; } + // Notice, we have implemented a property-layout for each states of the property editor ui picker, in this way the validation message gets removed once the choose-button is gone. (As we are missing ability to detect if elements got removed from DOM)[NL] #renderChooseButton() { return html` - + + + `; } #renderPropertyEditorReference() { if (!this._propertyEditorUiAlias || !this._propertyEditorSchemaAlias) return nothing; return html` - - ${this._propertyEditorUiIcon - ? html`` - : nothing} - - - - + + + ${this._propertyEditorUiIcon + ? html`` + : nothing} + + + + + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/document-type-export.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/document-type-export.action.ts new file mode 100644 index 0000000000..c28abdf792 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/document-type-export.action.ts @@ -0,0 +1,18 @@ +import { UmbExportDocumentTypeRepository } from './repository/index.js'; +import { blobDownload } from '@umbraco-cms/backoffice/utils'; +import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; + +export class UmbExportDocumentTypeEntityAction extends UmbEntityActionBase { + #repository = new UmbExportDocumentTypeRepository(this); + + override async execute() { + if (!this.args.unique) throw new Error('Unique is not available'); + + const { data } = await this.#repository.requestExport(this.args.unique); + if (!data) return; + + blobDownload(data, `${this.args.unique}.udt`, 'text/xml'); + } +} + +export default UmbExportDocumentTypeEntityAction; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/manifests.ts new file mode 100644 index 0000000000..621cd14233 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../../entity.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +const entityActions: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Umb.EntityAction.DocumentType.Export', + name: 'Export Document Type Entity Action', + forEntityTypes: [UMB_DOCUMENT_TYPE_ENTITY_TYPE], + api: () => import('./document-type-export.action.js'), + meta: { + icon: 'icon-download-alt', + label: '#actions_export', + }, + }, +]; + +export const manifests: Array = [...entityActions, ...repositoryManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/constants.ts new file mode 100644 index 0000000000..69e9b5543b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_EXPORT_DOCUMENT_TYPE_REPOSITORY_ALIAS = 'Umb.Repository.DocumentType.Export'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.repository.ts new file mode 100644 index 0000000000..21ba0be694 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.repository.ts @@ -0,0 +1,21 @@ +import { UmbExportDocumentTypeServerDataSource } from './document-type-export.server.data-source.js'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +export class UmbExportDocumentTypeRepository extends UmbRepositoryBase { + #exportSource = new UmbExportDocumentTypeServerDataSource(this); + + async requestExport(unique: string) { + const { data, error } = await this.#exportSource.export(unique); + + if (!error) { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const notification = { data: { message: `Exported` } }; + notificationContext.peek('positive', notification); + } + + return { data, error }; + } +} + +export { UmbExportDocumentTypeRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.server.data-source.ts new file mode 100644 index 0000000000..b8aba51188 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/document-type-export.server.data-source.ts @@ -0,0 +1,33 @@ +import { DocumentTypeService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; + +/** + * Export Document Server Data Source + * @export + * @class UmbExportDocumentTypeServerDataSource + */ +export class UmbExportDocumentTypeServerDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbExportDocumentTypeServerDataSource. + * @param {UmbControllerHost} host + * @memberof UmbExportDocumentTypeServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Export an item for the given id to the destination unique + * @param {unique} unique + * @returns {*} + * @memberof UmbExportDocumentTypeServerDataSource + */ + async export(unique: string) { + if (!unique) throw new Error('Unique is missing'); + + return tryExecuteAndNotify(this.#host, DocumentTypeService.getDocumentTypeByIdExport({ id: unique })); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/index.ts new file mode 100644 index 0000000000..0aca422f31 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbExportDocumentTypeRepository } from './document-type-export.repository.js'; +export { UMB_EXPORT_DOCUMENT_TYPE_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/manifests.ts new file mode 100644 index 0000000000..4c4475f974 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/export/repository/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_EXPORT_DOCUMENT_TYPE_REPOSITORY_ALIAS } from './constants.js'; +import type { ManifestRepository, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +const exportRepository: ManifestRepository = { + type: 'repository', + alias: UMB_EXPORT_DOCUMENT_TYPE_REPOSITORY_ALIAS, + name: 'Export Document Type Repository', + api: () => import('./document-type-export.repository.js'), +}; + +export const manifests: Array = [exportRepository]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/document-type-import.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/document-type-import.action.ts new file mode 100644 index 0000000000..09cc224c30 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/document-type-import.action.ts @@ -0,0 +1,24 @@ +import { UMB_DOCUMENT_TYPE_IMPORT_MODAL } from './modal/document-type-import-modal.token.js'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UmbEntityActionBase, UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; + +export class UmbImportDocumentTypeEntityAction extends UmbEntityActionBase { + override async execute() { + const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + const modalContext = modalManager.open(this, UMB_DOCUMENT_TYPE_IMPORT_MODAL, { + data: { unique: this.args.unique }, + }); + await modalContext.onSubmit().catch(() => {}); + + const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + const event = new UmbRequestReloadChildrenOfEntityEvent({ + unique: this.args.unique, + entityType: this.args.entityType, + }); + + actionEventContext.dispatchEvent(event); + } +} + +export default UmbImportDocumentTypeEntityAction; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/manifests.ts new file mode 100644 index 0000000000..0a34f26496 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/manifests.ts @@ -0,0 +1,21 @@ +import { UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as modalManifests } from './modal/manifests.js'; +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +const entityActions: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Umb.EntityAction.DocumentType.Import', + name: 'Export Document Type Entity Action', + forEntityTypes: [UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE], + api: () => import('./document-type-import.action.js'), + meta: { + icon: 'icon-page-up', + label: '#actions_import', + }, + }, +]; + +export const manifests: Array = [...entityActions, ...repositoryManifests, ...modalManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts new file mode 100644 index 0000000000..9bfef4ee36 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts @@ -0,0 +1,173 @@ +import { UmbDocumentTypeImportRepository } from '../repository/document-type-import.repository.js'; +import type { + UmbDocumentTypeImportModalData, + UmbDocumentTypeImportModalValue, +} from './document-type-import-modal.token.js'; +import { css, html, customElement, query, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import type { UmbDropzoneElement } from '@umbraco-cms/backoffice/media'; + +interface UmbDocumentTypePreview { + unique: string; + name: string; + alias: string; + icon: string; +} + +@customElement('umb-document-type-import-modal') +export class UmbDocumentTypeImportModalLayout extends UmbModalBaseElement< + UmbDocumentTypeImportModalData, + UmbDocumentTypeImportModalValue +> { + #documentTypeImportRepository = new UmbDocumentTypeImportRepository(this); + #temporaryUnique?: string; + #fileReader; + + @state() + private _fileContent: Array = []; + + @query('#dropzone') + private dropzone?: UmbDropzoneElement; + + constructor() { + super(); + this.#fileReader = new FileReader(); + this.#fileReader.onload = (e) => { + if (typeof e.target?.result === 'string') { + const fileContent = e.target.result; + this.#documentTypePreviewBuilder(fileContent); + } else { + this.#requestReset(); + } + }; + } + + #onFileDropped() { + const data = this.dropzone?.getFiles()[0]; + if (!data) return; + + this.#temporaryUnique = data.temporaryUnique; + this.#fileReader.readAsText(data.file); + } + + async #onFileImport() { + if (!this.#temporaryUnique) return; + const { error } = await this.#documentTypeImportRepository.requestImport(this.#temporaryUnique); + if (error) return; + this._submitModal(); + } + + #documentTypePreviewBuilder(htmlString: string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/xml'); + const elements = doc.childNodes; + + const documentTypes: Array = []; + + elements.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'DocumentType') { + documentTypes.push(node as Element); + } + }); + + this._fileContent = this.#documentTypePreviewItemBuilder(documentTypes); + } + + #documentTypePreviewItemBuilder(elements: Array) { + const documentTypes: Array = []; + elements.forEach((documentType) => { + const info = documentType.getElementsByTagName('Info')[0]; + const unique = info.getElementsByTagName('Key')[0].textContent ?? ''; + const name = info.getElementsByTagName('Name')[0].textContent ?? ''; + const alias = info.getElementsByTagName('Alias')[0].textContent ?? ''; + const icon = info.getElementsByTagName('Icon')[0].textContent ?? ''; + + documentTypes.push({ unique, name, alias, icon }); + }); + return documentTypes; + } + + #requestReset() { + this._fileContent = []; + this.#temporaryUnique = undefined; + } + + async #onBrowse() { + this.dropzone?.browse(); + } + + override render() { + return html` + ${this.#renderUploadZone()} + + + `; + } + + #renderUploadZone() { + return html` + ${when( + this._fileContent.length, + () => + html` + + + `, + () => + /**TODO Add localizations */ + html`
+ Drag and drop your file here + + +
`, + )} + `; + } + + static override styles = [ + UmbTextStyles, + css` + #wrapper { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + position: relative; + gap: var(--uui-size-space-3); + border: 2px dashed var(--uui-color-divider-standalone); + background-color: var(--uui-color-surface-alt); + padding: var(--uui-size-space-6); + } + + #import { + margin-top: var(--uui-size-space-6); + } + `, + ]; +} + +export default UmbDocumentTypeImportModalLayout; + +declare global { + interface HTMLElementTagNameMap { + 'umb-document-type-import-modal': UmbDocumentTypeImportModalLayout; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.token.ts new file mode 100644 index 0000000000..1ec359be4c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.token.ts @@ -0,0 +1,17 @@ +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; + +export interface UmbDocumentTypeImportModalData { + unique: string | null; +} + +export interface UmbDocumentTypeImportModalValue {} + +export const UMB_DOCUMENT_TYPE_IMPORT_MODAL = new UmbModalToken< + UmbDocumentTypeImportModalData, + UmbDocumentTypeImportModalValue +>('Umb.Modal.DocumentType.Import', { + modal: { + type: 'sidebar', + size: 'small', + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/index.ts new file mode 100644 index 0000000000..7bada0a24e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/index.ts @@ -0,0 +1 @@ +export * from './document-type-import-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/manifests.ts new file mode 100644 index 0000000000..e4a851b196 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/manifests.ts @@ -0,0 +1,10 @@ +import type { ManifestModal } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'modal', + alias: 'Umb.Modal.DocumentType.Import', + name: 'Document Type Import Modal', + element: () => import('./document-type-import-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/constants.ts new file mode 100644 index 0000000000..0da908eb12 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_DOCUMENT_TYPE_IMPORT_REPOSITORY_ALIAS = 'Umb.Repository.DocumentType.Import'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.repository.ts new file mode 100644 index 0000000000..ea520402b1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.repository.ts @@ -0,0 +1,21 @@ +import { UmbDocumentTypeImportServerDataSource } from './document-type-import.server.data-source.js'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +export class UmbDocumentTypeImportRepository extends UmbRepositoryBase { + #importSource = new UmbDocumentTypeImportServerDataSource(this); + + async requestImport(temporaryUnique: string) { + const { data, error } = await this.#importSource.import(temporaryUnique); + + if (!error) { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const notification = { data: { message: `Imported` } }; + notificationContext.peek('positive', notification); + } + + return { data, error }; + } +} + +export { UmbDocumentTypeImportRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.server.data-source.ts new file mode 100644 index 0000000000..252e5ca91d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/document-type-import.server.data-source.ts @@ -0,0 +1,37 @@ +import { DocumentTypeService, type PostDocumentTypeImportData } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; + +/** + * Document Type Import Server Data Source + * @Import + * @class UmbDocumentTypeImportServerDataSource + */ +export class UmbDocumentTypeImportServerDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbDocumentTypeImportServerDataSource. + * @param {UmbControllerHost} host + * @memberof UmbDocumentTypeImportServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Import an item for the given id to the destination unique + * @param {temporaryUnique} temporaryUnique + * @returns {*} + * @memberof UmbDocumentTypeImportServerDataSource + */ + async import(temporaryUnique: string) { + if (!temporaryUnique) throw new Error('Unique is missing'); + + const requestBody: PostDocumentTypeImportData = { + requestBody: { file: { id: temporaryUnique } }, + }; + + return tryExecuteAndNotify(this.#host, DocumentTypeService.postDocumentTypeImport(requestBody)); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/index.ts new file mode 100644 index 0000000000..51a3401f57 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbDocumentTypeImportRepository } from './document-type-import.repository.js'; +export { UMB_DOCUMENT_TYPE_IMPORT_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/manifests.ts new file mode 100644 index 0000000000..b5c47f7170 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/repository/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_DOCUMENT_TYPE_IMPORT_REPOSITORY_ALIAS } from './constants.js'; +import type { ManifestRepository, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +const importRepository: ManifestRepository = { + type: 'repository', + alias: UMB_DOCUMENT_TYPE_IMPORT_REPOSITORY_ALIAS, + name: 'Import Document Type Repository', + api: () => import('./document-type-import.repository.js'), +}; + +export const manifests: Array = [importRepository]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/manifests.ts index 566d23d078..b23d2c7660 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/manifests.ts @@ -6,6 +6,8 @@ import { import { manifests as createManifests } from './create/manifests.js'; import { manifests as moveManifests } from './move-to/manifests.js'; import { manifests as duplicateManifests } from './duplicate/manifests.js'; +import { manifests as exportManifests } from './export/manifests.js'; +import { manifests as importManifests } from './import/manifests.js'; import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; const entityActions: Array = [ @@ -27,4 +29,6 @@ export const manifests: Array = [ ...createManifests, ...moveManifests, ...duplicateManifests, + ...exportManifests, + ...importManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts index c41cabacbe..18f4a74ced 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts @@ -4,6 +4,7 @@ import { umbFocus, UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; import { UMB_MODAL_MANAGER_CONTEXT, UMB_ICON_PICKER_MODAL } from '@umbraco-cms/backoffice/modal'; import type { UUITextareaElement } from '@umbraco-cms/backoffice/external/uui'; +import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; @customElement('umb-document-type-workspace-editor') export class UmbDocumentTypeWorkspaceEditorElement extends UmbLitElement { @@ -86,10 +87,12 @@ export class UmbDocumentTypeWorkspaceEditorElement extends UmbLitElement { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace.context.ts index d332df01e3..65ab3f5c5f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type-workspace.context.ts @@ -30,6 +30,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import type { UmbRoutableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import type { UmbPathPatternTypeAsEncodedParamsType } from '@umbraco-cms/backoffice/router'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; type EntityType = UmbDocumentTypeDetailModel; export class UmbDocumentTypeWorkspaceContext @@ -79,6 +80,8 @@ export class UmbDocumentTypeWorkspaceContext constructor(host: UmbControllerHost) { super(host, 'Umb.Workspace.DocumentType'); + this.addValidationContext(new UmbValidationContext(this).provide()); + // General for content types: //this.data = this.structure.ownerContentType; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts index 0bcb58633f..cfff881f56 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts @@ -11,7 +11,7 @@ export const manifests: Array = [ label: 'Document Picker', propertyEditorSchemaAlias: 'Umbraco.ContentPicker', icon: 'icon-document', - group: 'common', + group: 'pickers', settings: { properties: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index df3d705077..719e6d6719 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -52,7 +52,15 @@ import { UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; -import { UmbServerModelValidationContext } from '@umbraco-cms/backoffice/validation'; +import { + UMB_VALIDATION_CONTEXT, + UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + UmbDataPathVariantQuery, + UmbServerModelValidatorContext, + UmbValidationContext, + UmbVariantValuesValidationPathTranslator, + UmbVariantsValidationPathTranslator, +} from '@umbraco-cms/backoffice/validation'; import { UmbDocumentBlueprintDetailRepository } from '@umbraco-cms/backoffice/document-blueprint'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; import type { UmbContentWorkspaceContext } from '@umbraco-cms/backoffice/content'; @@ -87,7 +95,7 @@ export class UmbDocumentWorkspaceContext #languages = new UmbArrayState([], (x) => x.unique); public readonly languages = this.#languages.asObservable(); - #serverValidation = new UmbServerModelValidationContext(this); + #serverValidation = new UmbServerModelValidatorContext(this); #validationRepository?: UmbDocumentValidationRepository; #blueprintRepository = new UmbDocumentBlueprintDetailRepository(this); @@ -159,6 +167,11 @@ export class UmbDocumentWorkspaceContext constructor(host: UmbControllerHost) { super(host, UMB_DOCUMENT_WORKSPACE_ALIAS); + this.addValidationContext(new UmbValidationContext(this).provide()); + + new UmbVariantValuesValidationPathTranslator(this); + new UmbVariantsValidationPathTranslator(this); + this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique)); this.observe(this.varies, (varies) => (this.#varies = varies)); @@ -572,6 +585,7 @@ export class UmbDocumentWorkspaceContext async #performSaveOrCreate(saveData: UmbDocumentDetailModel): Promise { if (this.getIsNew()) { + // Create: const parent = this.#parent.getValue(); if (!parent) throw new Error('Parent is not set'); @@ -592,6 +606,7 @@ export class UmbDocumentWorkspaceContext }); eventContext.dispatchEvent(event); } else { + // Save: const { data, error } = await this.repository.save(saveData); if (!data || error) { console.error('Error saving document', error); @@ -603,8 +618,8 @@ export class UmbDocumentWorkspaceContext const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); const event = new UmbRequestReloadStructureForEntityEvent({ - unique: this.getUnique()!, entityType: this.getEntityType(), + unique: this.getUnique()!, }); eventContext.dispatchEvent(event); @@ -623,6 +638,7 @@ export class UmbDocumentWorkspaceContext culture = selected[0]; const variantId = UmbVariantId.FromString(culture); const saveData = this.#buildSaveData([variantId]); + await this.#runMandatoryValidationForSaveData(saveData); await this.#performSaveOrCreate(saveData); } @@ -666,6 +682,7 @@ export class UmbDocumentWorkspaceContext } const saveData = this.#buildSaveData(variantIds); + await this.#runMandatoryValidationForSaveData(saveData); // Create the validation repository if it does not exist. (we first create this here when we need it) [NL] this.#validationRepository ??= new UmbDocumentValidationRepository(this); @@ -703,6 +720,23 @@ export class UmbDocumentWorkspaceContext ); } + async #runMandatoryValidationForSaveData(saveData: UmbDocumentDetailModel) { + // Check that the data is valid before we save it. + // Check variants have a name: + const variantsWithoutAName = saveData.variants.filter((x) => !x.name); + if (variantsWithoutAName.length > 0) { + const validationContext = await this.getContext(UMB_VALIDATION_CONTEXT); + variantsWithoutAName.forEach((variant) => { + validationContext.messages.addMessage( + 'client', + `$.variants[${UmbDataPathVariantQuery(variant)}].name`, + UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + ); + }); + throw new Error('All variants must have a name'); + } + } + async #performSaveAndPublish(variantIds: Array, saveData: UmbDocumentDetailModel): Promise { const unique = this.getUnique(); if (!unique) throw new Error('Unique is missing'); @@ -753,6 +787,7 @@ export class UmbDocumentWorkspaceContext } const saveData = this.#buildSaveData(variantIds); + await this.#runMandatoryValidationForSaveData(saveData); return await this.#performSaveOrCreate(saveData); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/package.json b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/package.json new file mode 100644 index 0000000000..b9eeb6c15c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/log-viewer", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/vite.config.ts new file mode 100644 index 0000000000..f7e77cd976 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/log-viewer'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/package.json b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/package.json new file mode 100644 index 0000000000..d0b01652c2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/markdown", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts index 3e7ac5cd37..ee9dc738f5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts @@ -10,7 +10,7 @@ const manifest: ManifestPropertyEditorUi = { label: 'Markdown Editor', propertyEditorSchemaAlias: 'Umbraco.MarkdownEditor', icon: 'icon-code', - group: 'pickers', + group: 'richContent', settings: { properties: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/vite.config.ts new file mode 100644 index 0000000000..1d1c040c00 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/markdown-editor'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-cropper/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-cropper/manifests.ts index 2a2b5f688e..dfd31736c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-cropper/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-cropper/manifests.ts @@ -9,7 +9,7 @@ const manifest: ManifestPropertyEditorUi = { meta: { label: 'Image Cropper', icon: 'icon-crop', - group: 'pickers', + group: 'media', propertyEditorSchemaAlias: 'Umbraco.ImageCropper', }, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/manifests.ts index 8db76a1bcc..669c8977f6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/manifests.ts @@ -10,7 +10,7 @@ const manifest: ManifestPropertyEditorUi = { label: 'Media Picker', propertyEditorSchemaAlias: 'Umbraco.MediaPicker3', icon: 'icon-picture', - group: 'pickers', + group: 'media', }, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/manifests.ts index f7da784d05..311f8d61b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/manifests.ts @@ -10,7 +10,7 @@ const manifest: ManifestPropertyEditorUi = { label: 'Upload Field', propertyEditorSchemaAlias: 'Umbraco.UploadField', icon: 'icon-download-alt', - group: 'common', + group: 'media', }, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/index.ts index 9ea0d68960..d8c7e845f9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/index.ts @@ -2,6 +2,7 @@ import './components/index.js'; export * from './entity.js'; export * from './repository/index.js'; +export * from './collection/index.js'; export * from './components/index.js'; export type { UmbMemberGroupDetailModel } from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts index e7d0046b8e..e0c3ec8ed1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts @@ -1,9 +1,13 @@ import { UmbMemberCollectionRepository } from '../../collection/index.js'; +import { UmbMemberSearchProvider } from '../../search/member.search-provider.js'; import type { UmbMemberDetailModel } from '../../types.js'; +import type { UmbMemberItemModel } from '../../repository/index.js'; import type { UmbMemberPickerModalValue, UmbMemberPickerModalData } from './member-picker-modal.token.js'; -import { html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; -import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { debounce, UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-member-picker-modal') export class UmbMemberPickerModalElement extends UmbModalBaseElement< @@ -13,8 +17,18 @@ export class UmbMemberPickerModalElement extends UmbModalBaseElement< @state() private _members: Array = []; + @state() + private _searchQuery: string = ''; + + @state() + private _searchResult: Array = []; + + @state() + private _searching = false; + #collectionRepository = new UmbMemberCollectionRepository(this); #selectionManager = new UmbSelectionManager(this); + #searchProvider = new UmbMemberSearchProvider(this); override connectedCallback(): void { super.connectedCallback(); @@ -23,6 +37,18 @@ export class UmbMemberPickerModalElement extends UmbModalBaseElement< this.#selectionManager.setSelection(this.value?.selection ?? []); } + constructor() { + super(); + this.observe( + this.#selectionManager.selection, + (selection) => { + this.updateValue({ selection }); + this.requestUpdate(); + }, + 'umbSelectionObserver', + ); + } + override async firstUpdated() { const { data } = await this.#collectionRepository.requestCollection({}); this._members = data?.items ?? []; @@ -36,43 +62,143 @@ export class UmbMemberPickerModalElement extends UmbModalBaseElement< } } - #submit() { - this.value = { selection: this.#selectionManager.getSelection() }; - this.modalContext?.submit(); + #onSearchInput(event: UUIInputEvent) { + const value = event.target.value as string; + this._searchQuery = value; + + if (!this._searchQuery) { + this._searchResult = []; + this._searching = false; + return; + } + + this._searching = true; + this.#debouncedSearch(); } - #close() { - this.modalContext?.reject(); + #debouncedSearch = debounce(this.#search, 300); + + async #search() { + if (!this._searchQuery) return; + const { data } = await this.#searchProvider.search({ query: this._searchQuery }); + this._searchResult = data?.items ?? []; + this._searching = false; + } + + #onSearchClear() { + this._searchQuery = ''; + this._searchResult = []; } override render() { - return html` - - ${repeat( - this.#filteredMembers, - (item) => item.unique, - (item) => html` - this.#selectionManager.select(item.unique)} - @deselected=${() => this.#selectionManager.deselect(item.unique)} - ?selected=${this.#selectionManager.isSelected(item.unique)}> - - + return html` + + ${this.#renderSearch()} ${this.#renderItems()} +
+ this.modalContext?.reject()}> + this.modalContext?.submit()}> +
+
+ `; + } + + #renderItems() { + if (this._searchQuery) return nothing; + return html` + ${repeat( + this.#filteredMembers, + (item) => item.unique, + (item) => this.#renderMemberItem(item), + )} + `; + } + + #renderSearch() { + return html` + +
+ ${this._searching + ? html`` + : html``} +
+ ${when( + this._searchQuery, + () => html` +
+ + + +
`, )} -
-
- - -
-
`; + +
+ ${this.#renderSearchResult()} + `; } + + #renderSearchResult() { + if (this._searchQuery && this._searching === false && this._searchResult.length === 0) { + return this.#renderEmptySearchResult(); + } + + return html` + ${repeat( + this._searchResult, + (item) => item.unique, + (item) => this.#renderMemberItem(item), + )} + `; + } + + #renderEmptySearchResult() { + return html`No result for "${this._searchQuery}".`; + } + + #renderMemberItem(item: UmbMemberItemModel | UmbMemberDetailModel) { + return html` + this.#selectionManager.select(item.unique)} + @deselected=${() => this.#selectionManager.deselect(item.unique)} + ?selected=${this.#selectionManager.isSelected(item.unique)}> + + + `; + } + + static override styles = [ + UmbTextStyles, + css` + #search-input { + width: 100%; + } + + #search-divider { + width: 100%; + height: 1px; + background-color: var(--uui-color-divider); + margin-top: var(--uui-size-space-5); + margin-bottom: var(--uui-size-space-3); + } + + #search-indicator { + margin-left: 7px; + margin-top: 4px; + } + `, + ]; } export default UmbMemberPickerModalElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/index.ts index 3a751ec32f..e5bebd5e5d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/index.ts @@ -3,3 +3,4 @@ import './components/index.js'; export * from './entity.js'; export * from './components/index.js'; export * from './repository/index.js'; +export * from './collection/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-builder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-builder/manifests.ts index d2d5da69af..40c3db214d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-builder/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-builder/manifests.ts @@ -1,9 +1,4 @@ -import type { - ManifestTypes, - ManifestWorkspace, - ManifestWorkspaceActions, - ManifestWorkspaceView, -} from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestTypes, ManifestWorkspace } from '@umbraco-cms/backoffice/extension-registry'; const workspace: ManifestWorkspace = { type: 'workspace', @@ -15,7 +10,4 @@ const workspace: ManifestWorkspace = { }, }; -const workspaceViews: Array = []; -const workspaceActions: Array = []; - -export const manifests: Array = [workspace, ...workspaceViews, ...workspaceActions]; +export const manifests: Array = [workspace]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-repo/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-repo/manifests.ts index ca3f976d04..9ee6b2ffe5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-repo/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-repo/manifests.ts @@ -1,9 +1,4 @@ -import type { - ManifestTypes, - ManifestWorkspace, - ManifestWorkspaceActions, - ManifestWorkspaceView, -} from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestTypes, ManifestWorkspace } from '@umbraco-cms/backoffice/extension-registry'; const workspace: ManifestWorkspace = { type: 'workspace', @@ -15,7 +10,4 @@ const workspace: ManifestWorkspace = { }, }; -const workspaceViews: Array = []; -const workspaceActions: Array = []; - -export const manifests: Array = [workspace, ...workspaceViews, ...workspaceActions]; +export const manifests: Array = [workspace]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package.json b/src/Umbraco.Web.UI.Client/src/packages/packages/package.json new file mode 100644 index 0000000000..4f95a8575b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/package", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/constants.ts new file mode 100644 index 0000000000..a73f790346 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/constants.ts @@ -0,0 +1,2 @@ +export const UMB_PACKAGE_REPOSITORY_ALIAS = 'Umb.Repository.Package'; +export const UMB_PACKAGE_STORE_ALIAS = 'Umb.Store.Package'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/manifests.ts index 4825a19fc6..72b80c8342 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package/repository/manifests.ts @@ -1,7 +1,6 @@ +import { UMB_PACKAGE_REPOSITORY_ALIAS, UMB_PACKAGE_STORE_ALIAS } from './constants.js'; import type { ManifestStore, ManifestRepository, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; -export const UMB_PACKAGE_REPOSITORY_ALIAS = 'Umb.Repository.Package'; - const repository: ManifestRepository = { type: 'repository', alias: UMB_PACKAGE_REPOSITORY_ALIAS, @@ -9,8 +8,6 @@ const repository: ManifestRepository = { api: () => import('./package.repository.js'), }; -export const UMB_PACKAGE_STORE_ALIAS = 'Umb.Store.Package'; - const store: ManifestStore = { type: 'store', alias: UMB_PACKAGE_STORE_ALIAS, diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/vite.config.ts new file mode 100644 index 0000000000..859f550e82 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/packages'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + entry: { + 'package/index': 'package/index.ts', + manifests: 'manifests.ts', + 'umbraco-package': 'umbraco-package.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/performance-profiling/dashboard-performance-profiling.element.ts b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/dashboard-performance-profiling.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/performance-profiling/dashboard-performance-profiling.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/performance-profiling/dashboard-performance-profiling.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/performance-profiling/dashboard-performance-profiling.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/dashboard-performance-profiling.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/performance-profiling/dashboard-performance-profiling.stories.ts rename to src/Umbraco.Web.UI.Client/src/packages/performance-profiling/dashboard-performance-profiling.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/manifests.ts new file mode 100644 index 0000000000..756137ea28 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/manifests.ts @@ -0,0 +1,19 @@ +export const manifests = [ + { + type: 'dashboard', + alias: 'Umb.Dashboard.Profiling', + name: 'Profiling', + element: () => import('./dashboard-performance-profiling.element.js'), + weight: 101, + meta: { + label: '#dashboardTabs_settingsProfiler', + pathname: 'profiling', + }, + conditions: [ + { + alias: 'Umb.Condition.SectionAlias', + match: 'Umb.Section.Settings', + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/package.json b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/package.json new file mode 100644 index 0000000000..f7aa90a5f6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/performance-profiling", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/umbraco-package.ts new file mode 100644 index 0000000000..1ee024fcbc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.PerformanceProfiling'; +export const extensions = [ + { + name: 'Performance Profiling Bundle', + alias: 'Umb.Bundle.PerformanceProfiling', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/vite.config.ts new file mode 100644 index 0000000000..22ca20f837 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/performance-profiling/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/performance-profiling'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + entry: { + manifests: 'manifests.ts', + 'umbraco-package': 'umbraco-package.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/manifests.ts index 6635738b2d..e0be2a8a08 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/manifests.ts @@ -11,7 +11,7 @@ export const manifests: Array = [ label: 'Dropdown', propertyEditorSchemaAlias: 'Umbraco.DropDown.Flexible', icon: 'icon-list', - group: 'pickers', + group: 'lists', }, }, schemaManifest, diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/label/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/label/manifests.ts index 34341e6edb..942843cf4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/label/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/label/manifests.ts @@ -10,7 +10,7 @@ export const manifests: Array = [ meta: { label: 'Label', icon: 'icon-readonly', - group: 'pickers', + group: 'common', propertyEditorSchemaAlias: 'Umbraco.Label', }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts index 2e3397c100..4cac623483 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts @@ -7,7 +7,7 @@ import { type UmbPropertyEditorConfigCollection, } from '@umbraco-cms/backoffice/property-editor'; import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; type UuiInputTypeType = typeof UUIInputElement.prototype.type; @@ -25,6 +25,15 @@ export class UmbPropertyEditorUITextBoxElement @property({ type: Boolean, reflect: true }) readonly = false; + /** + * Sets the input to mandatory, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + #defaultType: UuiInputTypeType = 'text'; @state() @@ -50,6 +59,10 @@ export class UmbPropertyEditorUITextBoxElement this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!); } + override focus() { + return this.shadowRoot?.querySelector('uui-input')?.focus(); + } + #onInput(e: InputEvent) { const newValue = (e.target as HTMLInputElement).value; if (newValue === this.value) return; @@ -65,6 +78,8 @@ export class UmbPropertyEditorUITextBoxElement inputMode=${ifDefined(this._inputMode)} maxlength=${ifDefined(this._maxChars)} @input=${this.#onInput} + ?required=${this.mandatory} + .requiredMessage=${this.mandatoryMessage} ?readonly=${this.readonly}>`; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.stories.ts rename to src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.test.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.test.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.test.ts rename to src/Umbraco.Web.UI.Client/src/packages/publish-cache/dashboard-published-status.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/publish-cache/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/manifests.ts new file mode 100644 index 0000000000..de1be9b57e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/manifests.ts @@ -0,0 +1,19 @@ +export const manifests = [ + { + type: 'dashboard', + alias: 'Umb.Dashboard.PublishedStatus', + name: 'Published Status Dashboard', + element: () => import('./dashboard-published-status.element.js'), + weight: 300, + meta: { + label: '#dashboardTabs_settingsPublishedStatus', + pathname: 'published-status', + }, + conditions: [ + { + alias: 'Umb.Condition.SectionAlias', + match: 'Umb.Section.Settings', + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/publish-cache/package.json b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/package.json new file mode 100644 index 0000000000..0a7fb90a2a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/publish-cache", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/publish-cache/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/umbraco-package.ts new file mode 100644 index 0000000000..cdb1d3ecdf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.PublishCache'; +export const extensions = [ + { + name: 'Publish Cache Bundle', + alias: 'Umb.Bundle.PublishCache', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/publish-cache/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/vite.config.ts new file mode 100644 index 0000000000..f9a7968319 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/publish-cache/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/publish-cache'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + entry: { + manifests: 'manifests.ts', + 'umbraco-package': 'umbraco-package.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/index.ts deleted file mode 100644 index 2a421f612c..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import './relation-types/index.js'; -import './relations/index.js'; - -export * from './manifests.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/package.json b/src/Umbraco.Web.UI.Client/src/packages/relations/package.json new file mode 100644 index 0000000000..ed717f3881 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/relation", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/constants.ts new file mode 100644 index 0000000000..8eb94e7ee4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/constants.ts @@ -0,0 +1 @@ +export const UMB_RELATION_TYPE_COLLECTION_ALIAS = 'Umb.Collection.RelationType'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/index.ts index 64a5d76908..6dcf6dd58e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/index.ts @@ -1,2 +1,2 @@ export { UmbRelationTypeCollectionRepository } from './repository/index.js'; -export { UMB_RELATION_TYPE_COLLECTION_ALIAS } from './manifests.js'; +export { UMB_RELATION_TYPE_COLLECTION_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/manifests.ts index fa2f027bca..92464432af 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/manifests.ts @@ -1,10 +1,9 @@ +import { UMB_RELATION_TYPE_COLLECTION_ALIAS } from './constants.js'; import { UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; import { manifests as collectionRepositoryManifests } from './repository/manifests.js'; import { manifests as collectionViewManifests } from './views/manifests.js'; import type { ManifestCollection, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; -export const UMB_RELATION_TYPE_COLLECTION_ALIAS = 'Umb.Collection.RelationType'; - const collectionManifest: ManifestCollection = { type: 'collection', kind: 'default', diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/constants.ts new file mode 100644 index 0000000000..86bdb48b1c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS = 'Umb.Repository.RelationType.Collection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/index.ts index 4e128f9d5d..142b390843 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/index.ts @@ -1,2 +1,2 @@ -export { UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS } from './manifests.js'; +export { UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; export { UmbRelationTypeCollectionRepository } from './relation-type-collection.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/manifests.ts index 9de27e01d1..9c879ceacf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/collection/repository/manifests.ts @@ -1,7 +1,6 @@ +import { UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; import type { ManifestRepository, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; -export const UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS = 'Umb.Repository.RelationType.Collection'; - const repository: ManifestRepository = { type: 'repository', alias: UMB_RELATION_TYPE_COLLECTION_REPOSITORY_ALIAS, diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/constants.ts new file mode 100644 index 0000000000..a1025f4c1c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/constants.ts @@ -0,0 +1,2 @@ +export const UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS = 'Umb.Repository.RelationType.Detail'; +export const UMB_RELATION_TYPE_DETAIL_STORE_ALIAS = 'Umb.Store.RelationType.Detail'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/index.ts index e5e155c2c7..19e0e34ac8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/index.ts @@ -1,2 +1,2 @@ export { UmbRelationTypeDetailRepository } from './relation-type-detail.repository.js'; -export { UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS } from './manifests.js'; +export { UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/manifests.ts index cf88507bae..9dc550e148 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relation-types/repository/detail/manifests.ts @@ -1,7 +1,6 @@ +import { UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS, UMB_RELATION_TYPE_DETAIL_STORE_ALIAS } from './constants.js'; import type { ManifestRepository, ManifestStore, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; -export const UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS = 'Umb.Repository.RelationType.Detail'; - const repository: ManifestRepository = { type: 'repository', alias: UMB_RELATION_TYPE_DETAIL_REPOSITORY_ALIAS, @@ -9,8 +8,6 @@ const repository: ManifestRepository = { api: () => import('./relation-type-detail.repository.js'), }; -export const UMB_RELATION_TYPE_DETAIL_STORE_ALIAS = 'Umb.Store.RelationType.Detail'; - const store: ManifestStore = { type: 'store', alias: UMB_RELATION_TYPE_DETAIL_STORE_ALIAS, diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/vite.config.ts new file mode 100644 index 0000000000..b1b16816e8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/relations'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + entry: { + 'relation-types/index': 'relation-types/index.ts', + 'relations/index': 'relations/index.ts', + manifests: 'manifests.ts', + 'umbraco-package': 'umbraco-package.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/manifests.ts deleted file mode 100644 index 0d4c10f4e0..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/manifests.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ManifestDashboard, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; - -const sectionAlias = 'Umb.Section.Settings'; - -const dashboards: Array = [ - { - type: 'dashboard', - alias: 'Umb.Dashboard.PublishedStatus', - name: 'Published Status Dashboard', - element: () => import('./published-status/dashboard-published-status.element.js'), - weight: 300, - meta: { - label: '#dashboardTabs_settingsPublishedStatus', - pathname: 'published-status', - }, - conditions: [ - { - alias: 'Umb.Condition.SectionAlias', - match: sectionAlias, - }, - ], - }, - { - type: 'dashboard', - alias: 'Umb.Dashboard.Profiling', - name: 'Profiling', - element: () => import('./performance-profiling/dashboard-performance-profiling.element.js'), - weight: 101, - meta: { - label: '#dashboardTabs_settingsProfiler', - pathname: 'profiling', - }, - conditions: [ - { - alias: 'Umb.Condition.SectionAlias', - match: sectionAlias, - }, - ], - }, - { - type: 'dashboard', - alias: 'Umb.Dashboard.Telemetry', - name: 'Telemetry', - element: () => import('./telemetry/dashboard-telemetry.element.js'), - weight: 100, - meta: { - label: 'Telemetry Data', - pathname: 'telemetry', - }, - conditions: [ - { - alias: 'Umb.Condition.SectionAlias', - match: sectionAlias, - }, - ], - }, -]; - -export const manifests: Array = [...dashboards]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/manifests.ts deleted file mode 100644 index 72ba9e5dbd..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/manifests.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { manifests as dashboardManifests } from './dashboards/manifests.js'; -import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; - -export const manifests: Array = [...dashboardManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/umbraco-package.ts index 87a8be3eec..4f594994c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/umbraco-package.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/settings/umbraco-package.ts @@ -1,9 +1,2 @@ export const name = 'Umbraco.Core.Settings'; -export const extensions = [ - { - name: 'Settings Bundle', - alias: 'Umb.Bundle.Settings', - type: 'bundle', - js: () => import('./manifests.js'), - }, -]; +export const extensions = []; diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/modals/static-file-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/modals/static-file-picker-modal.token.ts index a1e5c2383d..9dec85120a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/modals/static-file-picker-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/modals/static-file-picker-modal.token.ts @@ -1,4 +1,4 @@ -import { UMB_STATIC_FILE_TREE_ALIAS } from '../tree/manifests.js'; +import { UMB_STATIC_FILE_TREE_ALIAS } from '../tree/index.js'; import type { UmbStaticFileItemModel } from '../repository/item/types.js'; import { type UmbTreePickerModalValue, diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/package.json b/src/Umbraco.Web.UI.Client/src/packages/static-file/package.json new file mode 100644 index 0000000000..9b84eeb204 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/static-file", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/constants.ts new file mode 100644 index 0000000000..ed91eeeaa5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/constants.ts @@ -0,0 +1,4 @@ +export const UMB_STATIC_FILE_TREE_REPOSITORY_ALIAS = 'Umb.Repository.StaticFile.Tree'; +export const UMB_STATIC_FILE_TREE_STORE_ALIAS = 'Umb.Store.StaticFile.Tree'; +export const UMB_STATIC_FILE_TREE_ALIAS = 'Umb.Tree.StaticFile'; +export const UMB_STATIC_FILE_TREE_ITEM_ALIAS = 'Umb.TreeItem.StaticFile'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/index.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/index.ts index ef66c13c81..1e974c5fe4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/index.ts @@ -3,4 +3,4 @@ export { UMB_STATIC_FILE_TREE_REPOSITORY_ALIAS, UMB_STATIC_FILE_TREE_STORE_ALIAS, UMB_STATIC_FILE_TREE_ALIAS, -} from './manifests.js'; +} from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/manifests.ts index a9f81fd83b..72e84e09e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/manifests.ts @@ -3,6 +3,12 @@ import { UMB_STATIC_FILE_FOLDER_ENTITY_TYPE, UMB_STATIC_FILE_ROOT_ENTITY_TYPE, } from '../entity.js'; +import { + UMB_STATIC_FILE_TREE_ALIAS, + UMB_STATIC_FILE_TREE_ITEM_ALIAS, + UMB_STATIC_FILE_TREE_REPOSITORY_ALIAS, + UMB_STATIC_FILE_TREE_STORE_ALIAS, +} from './constants.js'; import type { ManifestRepository, ManifestTree, @@ -11,11 +17,6 @@ import type { ManifestTypes, } from '@umbraco-cms/backoffice/extension-registry'; -export const UMB_STATIC_FILE_TREE_REPOSITORY_ALIAS = 'Umb.Repository.StaticFile.Tree'; -export const UMB_STATIC_FILE_TREE_STORE_ALIAS = 'Umb.Store.StaticFile.Tree'; -export const UMB_STATIC_FILE_TREE_ALIAS = 'Umb.Tree.StaticFile'; -export const UMB_STATIC_FILE_TREE_ITEM_ALIAS = 'Umb.TreeItem.StaticFile'; - const treeRepository: ManifestRepository = { type: 'repository', alias: UMB_STATIC_FILE_TREE_REPOSITORY_ALIAS, diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/vite.config.ts new file mode 100644 index 0000000000..fde0eb382a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/static-file'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.stories.ts rename to src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.test.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.test.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/telemetry/dashboard-telemetry.test.ts rename to src/Umbraco.Web.UI.Client/src/packages/telemetry/dashboard-telemetry.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/telemetry/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/manifests.ts new file mode 100644 index 0000000000..de18e665e7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/telemetry/manifests.ts @@ -0,0 +1,19 @@ +export const manifests = [ + { + type: 'dashboard', + alias: 'Umb.Dashboard.Telemetry', + name: 'Telemetry', + element: () => import('./dashboard-telemetry.element.js'), + weight: 100, + meta: { + label: 'Telemetry Data', + pathname: 'telemetry', + }, + conditions: [ + { + alias: 'Umb.Condition.SectionAlias', + match: 'Umb.Section.Settings', + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/telemetry/package.json b/src/Umbraco.Web.UI.Client/src/packages/telemetry/package.json new file mode 100644 index 0000000000..80800d336f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/telemetry/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/telemetry", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/telemetry/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/umbraco-package.ts new file mode 100644 index 0000000000..4094ad7c24 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/telemetry/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.Telemetry'; +export const extensions = [ + { + name: 'Telemetry Bundle', + alias: 'Umb.Bundle.Telemetry', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/telemetry/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/telemetry/vite.config.ts new file mode 100644 index 0000000000..15b98a42d8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/telemetry/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/telemetry'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + entry: { + manifests: 'manifests.ts', + 'umbraco-package': 'umbraco-package.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/property-editors/tiny-mce/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/property-editors/tiny-mce/manifests.ts index aaa3f6880e..c4c40d5be4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/property-editors/tiny-mce/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/property-editors/tiny-mce/manifests.ts @@ -13,7 +13,7 @@ const manifest: ManifestPropertyEditorUi = { label: 'Rich Text Editor', propertyEditorSchemaAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, icon: 'icon-browser-window', - group: 'richText', + group: 'richContent', settings: { properties: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/package.json b/src/Umbraco.Web.UI.Client/src/packages/ufm/package.json new file mode 100644 index 0000000000..9868c4a84c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/ufm", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/vite.config.ts new file mode 100644 index 0000000000..4a15f72e73 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/ufm'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 0c726f16e7..715ff5c48d 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -84,7 +84,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/models": ["./src/packages/core/models/index.ts"], "@umbraco-cms/backoffice/multi-url-picker": ["./src/packages/multi-url-picker/index.ts"], "@umbraco-cms/backoffice/notification": ["./src/packages/core/notification/index.ts"], - "@umbraco-cms/backoffice/object-type": ["./src/packages/object-type/index.ts"], + "@umbraco-cms/backoffice/object-type": ["./src/packages/core/object-type/index.ts"], "@umbraco-cms/backoffice/package": ["./src/packages/packages/package/index.ts"], "@umbraco-cms/backoffice/partial-view": ["./src/packages/templating/partial-views/index.ts"], "@umbraco-cms/backoffice/picker-input": ["./src/packages/core/picker-input/index.ts"],