Merge branch 'main' into dependabot/npm_and_yarn/typescript-eslint-8.0.1

This commit is contained in:
Niels Lyngsø
2024-08-19 11:36:34 +02:00
194 changed files with 3124 additions and 911 deletions

View File

@@ -22,6 +22,7 @@
"umbraco",
"Uncategorized",
"uninitialize",
"unprovide",
"variantable"
],
"exportall.config.folderListener": [],

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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.

View File

@@ -0,0 +1,14 @@
export default `<!-- @license lucide-static v0.424.0 - ISC -->
<svg
class="lucide lucide-heart"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
`;

View File

@@ -0,0 +1,19 @@
export default `<svg
class="lucide lucide-wand-sparkles"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72" />
<path d="m14 7 3 3" />
<path d="M5 6v4" />
<path d="M19 14v4" />
<path d="M10 2v2" />
<path d="M7 8H3" />
<path d="M21 16h-4" />
<path d="M11 3H9" />
</svg>`;

View File

@@ -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`
<uui-box class="uui-text">
<h1 class="uui-h2" style="margin-top: var(--uui-size-layout-1);">Custom icons:</h1>
<uui-icon name="my-icon-wand"></uui-icon>
<uui-icon name="my-icon-heart"></uui-icon>
</uui-box>
`;
}
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;
}
}

View File

@@ -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'),
},
];

View File

@@ -0,0 +1,21 @@
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<ManifestTypes> = [
{
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',
},
},
];

View File

@@ -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';

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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'),

View File

@@ -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';

View File

@@ -702,6 +702,10 @@ export const data: Array<UmbMockDataTypeModel> = [
alias: 'blockGroups',
value: [{ key: 'demo-block-group-id', name: 'Demo Blocks' }],
},
{
alias: 'layoutStylesheet',
value: '/wwwroot/css/umbraco-blockgridlayout.css'
},
{
alias: 'blocks',
value: [

View File

@@ -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<UmbBlockGridLayoutModel, UmbBlockGridEntryElement>) {
// If this has areas, we do not want to move, unless we are at the edge

View File

@@ -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' },
};

View File

@@ -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<UmbNumberRangeValueType>('validationLimit')?.min ?? 0;
}
getMaxAllowed() {
return (
this._editorConfiguration.getValue()?.getValueByAlias<UmbNumberRangeValueType>('validationLimit')?.max ?? Infinity
);
}
override setEditorConfiguration(configs: UmbPropertyEditorConfigCollection) {
this.#initAppUrl.then(() => {
// we await initAppUrl, So the appUrl begin here is available when retrieving the layoutStylesheet.

View File

@@ -127,10 +127,7 @@ export class UmbPropertyEditorUIBlockGridAreasConfigElement
.key=${area.key}></umb-block-area-config-entry>`,
)}
</div>
<uui-button
look="placeholder"
label=${'Add area'}
href=${this._workspacePath + 'create'}></uui-button>`
<uui-button look="placeholder" label=${'Add area'} href=${this._workspacePath + 'create'}></uui-button>`
: '';
}
}

View File

@@ -17,7 +17,12 @@ export const UMB_BLOCK_GRID_WORKSPACE_MODAL = new UmbModalToken<UmbBlockGridWork
type: 'sidebar',
size: 'medium',
},
data: { entityType: 'block', preset: {}, originData: { index: -1, parentUnique: null } },
data: {
entityType: 'block',
preset: {},
originData: { index: -1, parentUnique: null },
baseDataPath: undefined as unknown as string,
},
},
// Recast the type, so the entityType data prop is not required:
) as UmbModalToken<Omit<UmbWorkspaceModalData, 'entityType'>, UmbWorkspaceModalValue>;

View File

@@ -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<UmbBlockListLayoutModel> = {
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
>
<uui-action-bar>
${this._showContentEdit && this._workspaceEditContentPath
? html`<uui-button label="edit" compact href=${this._workspaceEditContentPath}>
? html`<uui-button
label="edit"
look="secondary"
color=${this._contentInvalid ? 'danger' : ''}
href=${this._workspaceEditContentPath}>
<uui-icon name="icon-edit"></uui-icon>
${this._contentInvalid
? html`<uui-badge attention color="danger" label="Invalid settings">!</uui-badge>`
: ''}
</uui-button>`
: ''}
${this._hasSettings && this._workspaceEditSettingsPath
? html`<uui-button label="Edit settings" compact href=${this._workspaceEditSettingsPath}>
? html`<uui-button
label="Edit settings"
look="secondary"
color=${this._settingsInvalid ? 'danger' : ''}
href=${this._workspaceEditSettingsPath}>
<uui-icon name="icon-settings"></uui-icon>
${this._settingsInvalid
? html`<uui-badge attention color="danger" label="Invalid settings">!</uui-badge>`
: ''}
</uui-button>`
: ''}
<uui-button label="delete" compact @click=${() => this.#context.requestDelete()}>
<uui-button label="delete" look="secondary" @click=${() => this.#context.requestDelete()}>
<uui-icon name="icon-remove"></uui-icon>
</uui-button>
</uui-action-bar>
@@ -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;
}
`,
];

View File

@@ -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`
<uui-ref-node standalone href=${this._workspaceEditPath ?? '#'}>
<umb-ufm-render inline .markdown=${this.label} .value=${this._content}></umb-ufm-render>

View File

@@ -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({});

View File

@@ -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<UmbBlockListLayoutModel, UmbBlockListEntryElement> = {
getUniqueOfElement: (element) => {
@@ -35,7 +40,10 @@ const SORTER_CONFIG: UmbSorterConfig<UmbBlockListLayoutModel, UmbBlockListEntryE
* @element umb-property-editor-ui-block-list
*/
@customElement('umb-property-editor-ui-block-list')
export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implements UmbPropertyEditorUiElement {
export class UmbPropertyEditorUIBlockListElement
extends UmbFormControlMixin<UmbBlockListValueModel | undefined, typeof UmbLitElement, undefined>(UmbLitElement)
implements UmbPropertyEditorUiElement
{
//
#sorter = new UmbSorterController<UmbBlockListLayoutModel, UmbBlockListEntryElement>(this, {
...SORTER_CONFIG,
@@ -44,6 +52,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement
},
});
#validationContext = new UmbValidationContext(this).provide();
#contentDataPathTranslator?: UmbBlockElementDataValidationPathTranslator;
#settingsDataPathTranslator?: UmbBlockElementDataValidationPathTranslator;
//#catalogueModal: UmbModalRouteRegistrationController<typeof UMB_BLOCK_CATALOGUE_MODAL.DATA, undefined>;
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<UmbBlockListValueModel> = 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`<uui-button-inline-create
label=${this._createButtonLabel}
href=${this._catalogueRouteBuilder?.({ view: 'create', index: index }) ?? ''}></uui-button-inline-create>
<umb-block-list-entry .contentUdi=${layoutEntry.contentUdi} .layout=${layoutEntry}>
<umb-block-list-entry
.contentUdi=${layoutEntry.contentUdi}
.layout=${layoutEntry}
${umbDestroyOnDisconnect()}>
</umb-block-list-entry> `,
)}
<uui-button-group>

View File

@@ -15,7 +15,7 @@ export const UMB_BLOCK_LIST_WORKSPACE_MODAL = new UmbModalToken<UmbBlockListWork
type: 'sidebar',
size: 'medium',
},
data: { entityType: 'block', preset: {}, originData: { index: -1 } },
data: { entityType: 'block', preset: {}, originData: { index: -1 }, baseDataPath: undefined as unknown as string },
},
// Recast the type, so the entityType data prop is not required:
) as UmbModalToken<Omit<UmbWorkspaceModalData, 'entityType'>, UmbWorkspaceModalValue>;

View File

@@ -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({});

View File

@@ -11,7 +11,7 @@ export const UMB_BLOCK_RTE_WORKSPACE_MODAL = new UmbModalToken<UmbBlockRteWorksp
type: 'sidebar',
size: 'medium',
},
data: { entityType: 'block', preset: {}, originData: {} },
data: { entityType: 'block', preset: {}, originData: {}, baseDataPath: undefined as unknown as string },
},
// Recast the type, so the entityType data prop is not required:
) as UmbModalToken<Omit<UmbWorkspaceModalData, 'entityType'>, UmbWorkspaceModalValue>;

View File

@@ -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<void>;
#appUrl?: string;
#appUrl: string = '';
#itemManager = new UmbRepositoryItemsManager<UmbDocumentTypeItemModel>(
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;

View File

@@ -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;

View File

@@ -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<boolean>;
protected _layoutEntries = new UmbArrayState<BlockLayoutType>([], (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:

View File

@@ -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';

View File

@@ -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';
}
}

View File

@@ -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;
}
}

View File

@@ -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<UmbBlockDataType, 'udi'>): string {
// write a array of strings for each property, where alias must be present and culture and segment are optional
//const filters: Array<string> = [`@.udi = '${udi}'`];
//return `?(${filters.join(' && ')})`;
return `?(@.udi = '${data.udi}')`;
}

View File

@@ -0,0 +1,2 @@
export * from './block-data-validation-path-translator.controller.js';
export * from './data-path-element-data-query.function.js';

View File

@@ -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();

View File

@@ -47,11 +47,11 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
readonly unique = this.#layout.asObservablePart((x) => 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<string | undefined>(undefined);
readonly name = this.#label.asObservable();
@@ -60,6 +60,9 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
const manifest = workspaceArgs.manifest;
this.#entityType = manifest.meta?.entityType;
this.addValidationContext(this.content.validation);
this.addValidationContext(this.settings.validation);
this.#retrieveModalContext = this.consumeContext(UMB_MODAL_CONTEXT, (context) => {
this.#modalContext = context;
context.onSubmit().catch(this.#modalRejected);

View File

@@ -3,6 +3,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export interface UmbBlockWorkspaceData<OriginDataType = unknown> extends UmbWorkspaceModalData {
originData: OriginDataType;
baseDataPath: string;
}
export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken<UmbBlockWorkspaceData, UmbWorkspaceModalValue>(
@@ -12,7 +13,7 @@ export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken<UmbBlockWorkspaceData
type: 'sidebar',
size: 'large',
},
data: { entityType: 'block', preset: {}, originData: {} },
data: { entityType: 'block', preset: {}, originData: {}, baseDataPath: undefined as unknown as string },
},
// Recast the type, so the entityType data prop is not required:
) as UmbModalToken<Omit<UmbWorkspaceModalData, 'entityType'>, UmbWorkspaceModalValue>;

View File

@@ -32,6 +32,9 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
@state()
_propertyStructure: Array<UmbPropertyTypeModel> = [];
@state()
_dataPaths?: Array<string>;
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`<umb-property-type-based-property .property=${property}></umb-property-type-based-property> `,
(property, index) =>
html`<umb-property-type-based-property
class="property"
data-path=${this._dataPaths![index]}
.property=${property}></umb-property-type-based-property> `,
);
}
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;
}
`,

View File

@@ -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'),

View File

@@ -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`
<umb-body-layout headline="${this.localize.term('general_manifest')}" main-no-padding>
<umb-body-layout headline=${this.localize.term('general_manifest')} main-no-padding>
${this.data
? html`<umb-code-block language="json" copy style="height:100%; border: none;"
>${JSON.stringify(this.data, JsonParser, 2)}</umb-code-block
>`
? html`<umb-code-block language="JSON" copy>${this.#stringify(this.data)}</umb-code-block>`
: nothing}
<div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button>
@@ -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;

View File

@@ -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: [
{

View File

@@ -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<string, typeof UmbLitElement>(UmbLitElement) {
export class UmbInputWithAliasElement extends UmbFormControlMixin<string, typeof UmbLitElement, undefined>(
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<string, typeof
@state()
private _aliasLocked = true;
override firstUpdated() {
protected override firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.firstUpdated(_changedProperties);
this.addValidator(
'valueMissing',
() => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY,
() => this.required && !this.value,
);
this.shadowRoot?.querySelectorAll<UUIInputElement>('uui-input').forEach((x) => this.addFormControlElement(x));
}
@@ -64,6 +77,13 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin<string, typeof
this.dispatchEvent(new UmbChangeEvent());
}
}
#onAliasBlur() {
// If the alias is empty, then try to generate one [NL]
if (!this.alias && this._aliasLocked === false) {
this.alias = generateAlias(this.value ?? '');
this.dispatchEvent(new UmbChangeEvent());
}
}
#onToggleAliasLock(event: CustomEvent) {
this._aliasLocked = !this._aliasLocked;
@@ -82,7 +102,8 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin<string, typeof
placeholder=${nameLabel}
label=${nameLabel}
.value=${this.value}
@input=${this.#onNameChange}>
@input=${this.#onNameChange}
?required=${this.required}>
<uui-input-lock
auto-width
name="alias"
@@ -92,7 +113,9 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin<string, typeof
.value=${this.alias}
?locked=${this._aliasLocked && !this.aliasReadonly}
?readonly=${this.aliasReadonly}
?required=${this.required}
@input=${this.#onAliasChange}
@blur=${this.#onAliasBlur}
@lock-change=${this.#onToggleAliasLock}>
</uui-input-lock>
</uui-input>

View File

@@ -204,7 +204,7 @@ export class UmbTableElement extends LitElement {
if (this.config.hideIcon && !this.config.allowSelection) return;
return html`
<uui-table-head-cell style="--uui-table-cell-padding: 0">
<uui-table-head-cell style="--uui-table-cell-padding: 0; text-align: center;">
${when(
this.config.allowSelection,
() =>
@@ -236,7 +236,7 @@ export class UmbTableElement extends LitElement {
if (this.config.hideIcon && !this.config.allowSelection) return;
return html`
<uui-table-cell>
<uui-table-cell style="text-align: center;">
${when(!this.config.hideIcon, () => html`<umb-icon name="${ifDefined(item.icon ?? undefined)}"></umb-icon>`)}
${when(
this.config.allowSelection,

View File

@@ -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;
}
/**

View File

@@ -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`<umb-property-type-based-property
class="property"
.dataPath=${this._dataPaths![index]}
data-path=${this._dataPaths![index]}
.property=${property}></umb-property-type-based-property> `,
)
: '';

View File

@@ -46,6 +46,8 @@ export interface UmbBlockEditorCustomViewProperties<
layout?: LayoutType;
content?: UmbBlockDataType;
settings?: UmbBlockDataType;
contentInvalid?: boolean;
settingsInvalid?: boolean;
}
export interface UmbBlockEditorCustomViewElement<

View File

@@ -3,4 +3,6 @@ import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/
export interface UmbPropertyEditorUiElement extends HTMLElement {
value?: unknown;
config?: UmbPropertyEditorConfigCollection;
mandatory?: boolean;
mandatoryMessage?: string;
}

View File

@@ -30,12 +30,12 @@ export class UmbIconRegistryContext extends UmbContextBase<UmbIconRegistryContex
if (this.#manifestMap.has(manifest.alias)) return;
this.#manifestMap.set(manifest.alias, manifest);
// TODO: Should we unInit a entry point if is removed?
this.instantiateEntryPoint(manifest);
this.instantiateIcons(manifest);
});
});
}
async instantiateEntryPoint(manifest: ManifestIcons) {
async instantiateIcons(manifest: ManifestIcons) {
if (manifest.js) {
const js = await loadManifestPlainJs<{ default?: any }>(manifest.js);
if (!js || !js.default || !Array.isArray(js.default)) {

View File

@@ -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`<input ${umbDestroyOnDisconnect()}>`;
* ```
*/
export const umbDestroyOnDisconnect = directive(UmbDestroyDirective);
//export type { UmbDestroyDirective };

View File

@@ -1 +1,2 @@
export * from './focus.lit-directive.js';
export * from './destroy.lit-directive.js';

View File

@@ -1,6 +1,7 @@
import { UmbModalToken } from './modal-token.js';
export interface UmbPropertyEditorUIPickerModalData {
/** @deprecated This property will be removed in Umbraco 15. */
submitLabel?: string;
}

View File

@@ -2,9 +2,9 @@ import { UmbModalToken } from './modal-token.js';
export interface UmbWorkspaceModalData<DataModelType = unknown> {
entityType: string;
preset: Partial<DataModelType>;
baseDataPath?: string;
}
// TODO: It would be good with a WorkspaceValueBaseType, to avoid the hardcoded type for unique here:
export type UmbWorkspaceModalValue =
| {
unique: string;

View File

@@ -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<PropertyTypeData extends UmbPropertyTypeModel = UmbPropertyTypeModel>
extends UmbSubmittableWorkspaceContextBase<PropertyTypeData>
@@ -39,6 +40,9 @@ export class UmbPropertyTypeWorkspaceContext<PropertyTypeData extends UmbPropert
constructor(host: UmbControllerHost, args: { manifest: ManifestWorkspace }) {
super(host, args.manifest.alias);
this.addValidationContext(new UmbValidationContext(this).provide());
const manifest = args.manifest;
this.#entityType = manifest.meta?.entityType;

View File

@@ -7,6 +7,7 @@ import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/cont
import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry';
import type { UUIBooleanInputEvent, UUIInputEvent, UUISelectEvent } from '@umbraco-cms/backoffice/external/uui';
import { umbBindToValidation } from '@umbraco-cms/backoffice/validation';
@customElement('umb-property-type-workspace-view-settings')
export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement implements UmbWorkspaceViewElement {
@@ -54,9 +55,13 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
this.consumeContext(UMB_PROPERTY_TYPE_WORKSPACE_CONTEXT, (instance) => {
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`
<uui-box class="uui-text">
<div class="container">
<!-- TODO: Align styling across this and the property of document type workspace editor, or consider if this can go away for a different UX flow -->
<uui-input
id="name-input"
name="name"
label=${this.localize.term('placeholders_entername')}
placeholder=${this.localize.term('placeholders_entername')}
.value=${this._data?.name}
@input=${this.#onNameChange}
${umbFocus()}>
<!-- TODO: validation for bad characters -->
</uui-input>
<uui-input-lock
id="alias-input"
name="alias"
label=${this.localize.term('placeholders_enterAlias')}
placeholder=${this.localize.term('placeholders_enterAlias')}
.value=${this._data?.alias}
?locked=${this._aliasLocked}
@input=${this.#onAliasChange}
@lock-change=${this.#onToggleAliasLock}>
</uui-input-lock>
<uui-form-validation-message>
<uui-input
id="name-input"
name="name"
label=${this.localize.term('placeholders_entername')}
placeholder=${this.localize.term('placeholders_entername')}
.value=${this._data?.name}
@input=${this.#onNameChange}
required
${umbBindToValidation(this, '$.name')}
${umbFocus()}>
<!-- TODO: validation for bad characters -->
</uui-input>
</uui-form-validation-message>
<uui-form-validation-message>
<uui-input-lock
id="alias-input"
name="alias"
label=${this.localize.term('placeholders_enterAlias')}
placeholder=${this.localize.term('placeholders_enterAlias')}
.value=${this._data?.alias}
?locked=${this._aliasLocked}
required
${umbBindToValidation(this, '$.alias')}
@input=${this.#onAliasChange}
@lock-change=${this.#onToggleAliasLock}>
</uui-input-lock>
</uui-form-validation-message>
<uui-textarea
id="description-input"
name="description"
@@ -205,9 +217,13 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
placeholder=${this.localize.term('placeholders_enterDescription')}
.value=${this._data?.description}></uui-textarea>
</div>
<umb-data-type-flow-input
.value=${this._data?.dataType?.unique ?? ''}
@change=${this.#onDataTypeIdChange}></umb-data-type-flow-input>
<uui-form-validation-message>
<umb-data-type-flow-input
.value=${this._data?.dataType?.unique ?? ''}
@change=${this.#onDataTypeIdChange}
required
${umbBindToValidation(this, '$.dataType.unique')}></umb-data-type-flow-input>
</uui-form-validation-message>
<hr />
<div class="container">
<b><umb-localize key="validation_validation">Validation</umb-localize></b>
@@ -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;

View File

@@ -44,22 +44,28 @@ export class UmbPropertyContext<ValueType = any> extends UmbContextBase<UmbPrope
#validation = new UmbObjectState<UmbPropertyTypeValidationModel | undefined>(undefined);
public readonly validation = this.#validation.asObservable();
private _editor = new UmbBasicState<UmbPropertyEditorUiElement | undefined>(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<UmbPropertyEditorUiElement | undefined>(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<UmbVariantId | undefined>(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<ValueType = any> extends UmbContextBase<UmbPrope
this._observeProperty();
});
this.observe(this.alias, () => {
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<void> {
@@ -109,9 +127,20 @@ export class UmbPropertyContext<ValueType = any> extends UmbContextBase<UmbPrope
private _generateVariantDifferenceString() {
if (!this.#datasetContext) return;
const contextVariantId = this.#datasetContext.getVariantId?.() ?? undefined;
this._variantDifference.setValue(
contextVariantId ? this.#variantId.getValue()?.toDifferencesString(contextVariantId) : '',
);
const propertyVariantId = this.#variantId.getValue();
let shareMessage;
if (contextVariantId && propertyVariantId) {
if (contextVariantId.segment !== propertyVariantId.segment) {
// TODO: Translate this, ideally the actual culture is mentioned in the message:
shareMessage = 'Shared across culture';
}
if (contextVariantId.culture !== propertyVariantId.culture) {
// TODO: Translate this:
shareMessage = 'Shared';
}
}
this.#variantDifference.setValue(shareMessage);
}
public setAlias(alias: string | undefined): void {
@@ -181,6 +210,13 @@ export class UmbPropertyContext<ValueType = any> extends UmbContextBase<UmbPrope
return this.#validation.getValue();
}
public setDataPath(dataPath: string | undefined): void {
this.#dataPath.setValue(dataPath);
}
public getDataPath(): string | undefined {
return this.#dataPath.getValue();
}
public resetValue(): void {
this.setValue(undefined); // TODO: We should get the value from the server aka. the value from the persisted data. (Most workspaces holds this data, via dataset) [NL]
}

View File

@@ -5,7 +5,7 @@ import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registr
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import {
UmbBindValidationMessageToFormControl,
UmbBindServerValidationToFormControl,
UmbFormControlValidator,
UmbObserveValidationStateController,
} from '@umbraco-cms/backoffice/validation';
@@ -133,17 +133,16 @@ export class UmbPropertyElement extends UmbLitElement {
* @attr
* @default
*/
@property({ type: String, attribute: false })
@property({ type: String, attribute: 'data-path' })
public set dataPath(dataPath: string | undefined) {
this.#dataPath = dataPath;
this.#propertyContext.setDataPath(dataPath);
new UmbObserveValidationStateController(this, dataPath, (invalid) => {
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<unknown>;
#configObserver?: UmbObserverController<UmbPropertyEditorConfigCollection | undefined>;
@@ -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();
}

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -0,0 +1 @@
export const UMB_VALIDATION_EMPTY_LOCALIZATION_KEY = '#validation_invalidEmpty';

View File

@@ -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';

View File

@@ -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>(
'UmbServerModelValidationContext',
);

View File

@@ -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<string> };
export class UmbServerModelValidationContext
extends UmbContextBase<UmbServerModelValidationContext>
implements UmbValidator
{
#validatePromise?: Promise<void>;
#validatePromiseResolve?: () => void;
#context?: typeof UMB_VALIDATION_CONTEXT.TYPE;
#isValid = true;
#data: any;
getData(): any {
return this.#data;
}
#translators: Array<UmbValidationMessageTranslator> = [];
// Hold server feedback...
#serverFeedback: Array<ServerFeedbackEntry> = [];
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<UmbDataSourceResponse<string>>): Promise<void> {
this.#context?.messages.removeMessagesByType('server');
this.#serverFeedback = [];
this.#isValid = false;
//this.#validatePromiseReject?.();
this.#validatePromise = new Promise<void>((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<void> {
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();
}
}

View File

@@ -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<UmbValidationMessage>([], (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<Array<UmbValidationMessage>> {
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<Array<UmbValidationMessage>> {
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<boolean> {
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<string>): void {
this.#messages.append(messages.map((message) => ({ type, key: UmbId.new(), path, message })));
addMessages(type: UmbValidationMessageType, path: string, bodies: Array<string>): 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<UmbValidationMessageTranslator> = [];
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();
}
}

View File

@@ -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<UmbValidationContext, UmbValidationContext, UmbValidationContext>;
// Local version of the data send to the server, only use-case is for translation.
#translationData = new UmbObjectState<any>(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<UmbValidationContext> implements UmbValidator {
#validators: Array<UmbValidator> = [];
#validationMode: boolean = false;
#isValid: boolean = false;
#parent?: UmbValidationContext;
#parentMessages?: Array<UmbValidationMessage>;
#localMessages?: Array<UmbValidationMessage>;
#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<UmbValidationContext> 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<UmbValidationContext> 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<boolean>} - Returns a promise that resolves to true if the validator succeeded, this depends on the validators and wether forceSucceed is set.
*/
async validate(): Promise<void> {
@@ -71,6 +235,11 @@ export class UmbValidationContext extends UmbContextBase<UmbValidationContext> 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<UmbValidationContext> 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<UmbValidationContext> 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<UmbValidationContext> 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();
}
}

View File

@@ -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<unknown>;
@@ -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);

View File

@@ -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;

View File

@@ -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';

View File

@@ -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);

View File

@@ -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<UmbServerModelValidatorContext>(
'UmbServerModelValidationContext',
);

View File

@@ -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<string, Array<string>>;
missingProperties: Array<string>;
operationStatus: string;
status: number;
title: string;
type: string;
}
export class UmbServerModelValidatorContext
extends UmbContextBase<UmbServerModelValidatorContext>
implements UmbValidator
{
#validatePromise?: Promise<void>;
#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<UmbDataSourceResponse<string>>): Promise<void> {
this.#context?.messages.removeMessagesByType('server');
this.#isValid = false;
//this.#validatePromiseReject?.();
this.#validatePromise = new Promise<void>((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<void> {
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();
}
}

View File

@@ -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<unknown>;
#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<typeof this.render>) {
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<unknown>;
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`<input ${umbBindToValidation(this)}>`;
* ```
* 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`<input ${umbBindToValidation(this, '$.headline')}>`;
* ```
*
* Additional the value can be provided, which is then used to remove a server validation state, if the value is changed.
* @example:
* ```js
* html`<input ${umbBindToValidation(this, '$.headline', this.headlineValue)}>`;
* ```
*
*/
export const umbBindToValidation = directive(UmbBindToValidationDirective);
//export type { UmbFocusDirective };

View File

@@ -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';

View File

@@ -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<ValueType> 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<string> = new Set();
//const messages: Set<string> = 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,
);

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -1,3 +0,0 @@
export interface UmbValidationMessageTranslator {
translate(message: string): undefined | string;
}

View File

@@ -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<UmbValidationMessageTranslator['translate']>;
}

View File

@@ -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);
}
}

View File

@@ -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];
}
}

View File

@@ -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];
}
}

View File

@@ -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<Omit<UmbVariantPropertyValueModel, 'value'>, '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<string> = [`@.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(' && ')})`;
}

View File

@@ -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<Pick<UmbVariantPropertyValueModel, 'culture' | 'segment'>, 'segment'>,
): string {
// write a array of strings for each property, where culture must be present and segment is optional
const filters: Array<string> = [`@.culture = ${value.culture ? `'${value.culture}'` : 'null'}`];
if (value.segment !== undefined) {
filters.push(`@.segment = ${value.segment ? `'${value.segment}'` : 'null'}`);
}
return `?(${filters.join(' && ')})`;
}

View File

@@ -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';

View File

@@ -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;
});
}

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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',

View File

@@ -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()

View File

@@ -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`
<uui-input
id="name-input"
label=${this.localize.term('placeholders_entername')}
.value=${this._name ?? ''}
@input=${this.#handleInput}
required
${umbBindToValidation(this, `$.variants[${UmbDataPathVariantQuery(this._variantId)}].name`, this._name ?? '')}
${umbFocus()}
>
${
@@ -287,7 +294,8 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement {
: nothing
}
</div>
`;
`
: nothing;
}
static override styles = [

View File

@@ -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<WorkspaceDataModelType>
extends UmbContextBase<UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>>
@@ -18,7 +18,16 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
// 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<UmbValidationContext> = [];
/**
* Appends a validation context to the workspace.
* @param context
*/
addValidationContext(context: UmbValidationContext) {
this.#validationContexts.push(context);
}
#submitPromise: Promise<void> | undefined;
#submitResolve: (() => void) | undefined;
@@ -42,14 +51,15 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
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<WorkspaceDataModelType>
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<Array<void>> {
//return this.validation.validate();
return Promise.all(this.#validationContexts.map((context) => context.validate()));
}
async requestSubmit(): Promise<void> {
return this.validateAndSubmit(
() => this.submit(),
@@ -76,7 +95,7 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
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<WorkspaceDataModelType>
#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<WorkspaceDataModelType>
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<boolean>;

View File

@@ -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<any> | Map<PropertyKey, unknown>): 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}></uui-button>
`;
}
@@ -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);
}
`,
];
}

View File

@@ -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`<umb-property
.dataPath="$.values[${UmbDataPathPropertyValueFilter(property)}].value"
data-path="$.values[${UmbDataPathPropertyValueQuery(property)}].value"
label=${property.label}
description=${ifDefined(property.description)}
alias=${property.alias}

View File

@@ -3,9 +3,9 @@ import type {
UmbDataTypePickerFlowDataTypePickerModalData,
UmbDataTypePickerFlowDataTypePickerModalValue,
} from './data-type-picker-flow-data-type-picker-modal.token.js';
import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, customElement, html, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbDataTypeItemModel } from '@umbraco-cms/backoffice/data-type';
@customElement('umb-data-type-picker-flow-data-type-picker-modal')
@@ -25,10 +25,10 @@ export class UmbDataTypePickerFlowDataTypePickerModalElement extends UmbModalBas
this._propertyEditorUiAlias = this.data.propertyEditorUiAlias;
this._observeDataTypesOf(this._propertyEditorUiAlias);
this.#observeDataTypesOf(this._propertyEditorUiAlias);
}
private async _observeDataTypesOf(propertyEditorUiAlias: string) {
async #observeDataTypesOf(propertyEditorUiAlias: string) {
if (!this.data) return;
const dataTypeCollectionRepository = new UmbDataTypeCollectionRepository(this);
@@ -40,64 +40,65 @@ export class UmbDataTypePickerFlowDataTypePickerModalElement extends UmbModalBas
});
this.observe(collection.asObservable(), (dataTypes) => {
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`
<umb-body-layout headline="Select a configuration">
<uui-box> ${this._renderDataTypes()} ${this._renderCreate()}</uui-box>
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectEditorConfiguration')}>
<uui-box>${this.#renderDataTypes()} ${this.#renderCreate()}</uui-box>
<div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._close}></uui-button>
<uui-button label=${this.localize.term('general_close')} @click=${this.#close}></uui-button>
</div>
</umb-body-layout>
`;
}
private _renderDataTypes() {
return this._dataTypes && this._dataTypes.length > 0
? html`<ul id="item-grid">
${repeat(
this._dataTypes!,
(dataType) => dataType.unique,
(dataType) =>
dataType.unique
? html` <li class="item">
<uui-button label="dataType.name" type="button" @click="${() => this._handleClick(dataType)}">
<div class="item-content">
<umb-icon name=${dataType.icon ?? 'icon-circle-dotted'} class="icon"></umb-icon>
${dataType.name}
</div>
</uui-button>
</li>`
: '',
)}
</ul>`
: '';
}
private _renderCreate() {
#renderDataTypes() {
if (!this._dataTypes?.length) return;
return html`
<uui-button id="create-button" type="button" look="placeholder" @click="${this._handleCreate}">
<ul id="item-grid">
${repeat(
this._dataTypes,
(dataType) => dataType.unique,
(dataType) => html`
<li class="item">
<uui-button label=${dataType.name} @click=${() => this.#handleClick(dataType)}>
<div class="item-content">
<umb-icon name=${dataType.icon ?? 'icon-circle-dotted'} class="icon"></umb-icon>
${dataType.name}
</div>
</uui-button>
</li>
`,
)}
</ul>
`;
}
#renderCreate() {
return html`
<uui-button id="create-button" look="placeholder" @click=${this.#handleCreate}>
<div class="content">
<uui-icon name="icon-add" class="icon"></uui-icon>
Create new
<umb-localize key="contentTypeEditor_availableEditors">Create new</umb-localize>
</div>
</uui-button>
`;
@@ -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;

Some files were not shown because too many files have changed in this diff Show More