Merge branch 'main' into v14/feature/readonly-markdown-property-editor

This commit is contained in:
Niels Lyngsø
2024-08-30 21:09:01 +02:00
committed by GitHub
55 changed files with 1008 additions and 228 deletions

View File

@@ -11,11 +11,15 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen
@property({ attribute: false })
content?: UmbBlockDataType;
@property({ attribute: false })
settings?: UmbBlockDataType;
override render() {
return html`
<div class="uui-text">
<div class="uui-text ${this.settings?.blockAlignment ? 'align-' + this.settings?.blockAlignment : undefined}">
<h5 class="uui-text">My Custom View</h5>
<p>Headline: ${this.content?.headline}</p>
<p>Alignment: ${this.settings?.blockAlignment}</p>
</div>
`;
}
@@ -31,6 +35,13 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen
border-radius: 9px;
padding: 12px;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
`,
];
}

View File

@@ -7,6 +7,6 @@ export const manifests: Array<ManifestBlockEditorCustomView> = [
name: 'Block Editor Custom View Test',
element: () => import('./block-custom-view.js'),
forContentTypeAlias: 'headlineUmbracoDemoBlock',
forBlockEditor: 'block-grid',
forBlockEditor: 'block-list',
},
];

File diff suppressed because one or more lines are too long

View File

@@ -5068,6 +5068,12 @@ export type PostUserByIdChangePasswordData = {
export type PostUserByIdChangePasswordResponse = string;
export type PostCurrentUserChangePasswordData = {
requestBody?: ChangePasswordCurrentUserRequestModel;
};
export type PostCurrentUserChangePasswordResponse = string;
export type PostUserByIdResetPasswordData = {
id: string;
};

View File

@@ -111,8 +111,6 @@ export abstract class UmbBaseExtensionsInitializer<
manifests.forEach((manifest) => {
const existing = this._extensions.find((x) => x.alias === manifest.alias);
if (!existing) {
// Idea: could be abstracted into a createController method, so we can override it in a subclass.
// (This should be enough to be able to create a element extension controller instead.)
this._extensions.push(this._createController(manifest));
}
});

View File

@@ -406,6 +406,27 @@ export const data: Array<UmbMockDataTypeModel> = [
},
],
},
{
name: 'Dropdown Alignment Options',
id: 'dt-dropdown-align',
parent: null,
editorAlias: 'Umbraco.DropDown.Flexible',
editorUiAlias: 'Umb.PropertyEditorUi.Dropdown',
hasChildren: false,
isFolder: false,
isDeletable: true,
canIgnoreStartNodes: false,
values: [
{
alias: 'multiple',
value: false,
},
{
alias: 'items',
value: ['left', 'center', 'right'],
},
],
},
{
name: 'Slider',
id: 'dt-slider',
@@ -587,6 +608,7 @@ export const data: Array<UmbMockDataTypeModel> = [
{
label: 'Headline',
contentElementTypeKey: 'headline-umbraco-demo-block-id',
settingsElementTypeKey: 'headline-settings-demo-block-id',
backgroundColor: 'gold',
editorSize: 'medium',
icon: 'icon-edit',
@@ -613,7 +635,7 @@ export const data: Array<UmbMockDataTypeModel> = [
},
{
alias: 'useInlineEditingAsDefault',
value: true,
value: false,
},
{
alias: 'useLiveEditing',

View File

@@ -1507,6 +1507,60 @@ export const data: Array<UmbMockDocumentTypeModel> = [
},
],
},
{
allowedTemplates: [],
defaultTemplate: null,
id: 'headline-settings-demo-block-id',
alias: 'headlineSettingsUmbracoDemoBlock',
name: 'Headline',
description: null,
icon: 'icon-edit',
allowedAsRoot: true,
variesByCulture: false,
variesBySegment: false,
isElement: true,
hasChildren: false,
parent: { id: 'folder-umbraco-demo-blocks-id' },
isFolder: false,
allowedDocumentTypes: [],
compositions: [],
cleanup: {
preventCleanup: false,
keepAllVersionsNewerThanDays: null,
keepLatestVersionPerDayForDays: null,
},
properties: [
{
id: 'block-alignment-id',
container: { id: 'settings-group-key' },
alias: 'blockAlignment',
name: 'Block Alignment',
description: '',
dataType: { id: 'dt-dropdown-align' },
variesByCulture: false,
variesBySegment: false,
sortOrder: 0,
validation: {
mandatory: false,
mandatoryMessage: null,
regEx: null,
regExMessage: null,
},
appearance: {
labelOnTop: false,
},
},
],
containers: [
{
id: 'settings-group-key',
parent: null,
name: 'Settings',
type: 'Group',
sortOrder: 0,
},
],
},
{
allowedTemplates: [],
defaultTemplate: null,

View File

@@ -1,15 +1,40 @@
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { UmbInputNumberRangeElement } from '@umbraco-cms/backoffice/components';
@customElement('umb-block-grid-area-type-workspace-view')
export class UmbBlockGridAreaTypeWorkspaceViewSettingsElement extends UmbLitElement implements UmbWorkspaceViewElement {
// TODO: Add Localizations...
// TODO: Validation to prevent spaces and weird characters in alias:
// TODO: Add create button label field:
// TODO: Turn minAllowed and maxAllowed into one range property/input...
// TODO: Add validation permission field:
#dataset?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE;
@state()
_minValue?: number;
@state()
_maxValue?: number;
constructor() {
super();
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (context) => {
this.#dataset = context;
this.observe(await this.#dataset.propertyValueByAlias<number>('minAllowed'), (min) => {
this._minValue = min ?? 0;
});
this.observe(await this.#dataset.propertyValueByAlias<number>('maxAllowed'), (max) => {
this._maxValue = max ?? Infinity;
});
});
}
#onAllowedRangeChange = (e: UmbChangeEvent) => {
this.#dataset?.setPropertyValue('minAllowed', (e!.target! as UmbInputNumberRangeElement).minValue);
this.#dataset?.setPropertyValue('maxAllowed', (e!.target! as UmbInputNumberRangeElement).maxValue);
};
override render() {
return html`
<uui-box headline=${'Identification'}>
@@ -24,14 +49,14 @@ export class UmbBlockGridAreaTypeWorkspaceViewSettingsElement extends UmbLitElem
property-editor-ui-alias="Umb.PropertyEditorUi.TextBox"></umb-property>
</uui-box>
<uui-box headline=${'Validation'}>
<umb-property
label=${'minAllowed'}
alias="minAllowed"
property-editor-ui-alias="Umb.PropertyEditorUi.TextBox"></umb-property>
<umb-property
label=${'maxAllowed'}
alias="maxAllowed"
property-editor-ui-alias="Umb.PropertyEditorUi.TextBox"></umb-property>
<umb-property-layout label=${'rangeAllowed'}>
<umb-input-number-range
slot="editor"
.minValue=${this._minValue}
.maxValue=${this._maxValue}
@change=${this.#onAllowedRangeChange}>
</umb-input-number-range>
</umb-property-layout>
<umb-property
label=${'specifiedAllowance'}

View File

@@ -134,11 +134,23 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
});
#context = new UmbBlockGridEntriesContext(this);
#controlValidator?: UmbFormControlValidator;
#typeLimitValidator?: UmbFormControlValidatorConfig;
#rangeUnderflowValidator?: UmbFormControlValidatorConfig;
#rangeOverflowValidator?: UmbFormControlValidatorConfig;
@property({ attribute: false })
@property({ type: String, attribute: 'area-key', reflect: true })
public set areaKey(value: string | null | undefined) {
this._areaKey = value;
this.#context.setAreaKey(value ?? null);
this.#controlValidator?.destroy();
if (this.areaKey) {
// Only when there is a area key we should create a validator, otherwise it is the root entries element, which is taking part of the Property Editor Form Control. [NL]
// Currently there is no server validation for areas. So we can leave out the data path for it for now. [NL]
this.#controlValidator = new UmbFormControlValidator(this, this);
//new UmbBindServerValidationToFormControl(this, this, "$.values.[?(@.alias = 'my-input-alias')].value");
}
}
public get areaKey(): string | null | undefined {
return this._areaKey;
@@ -169,6 +181,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
constructor() {
super();
this.observe(
this.#context.layoutEntries,
(layoutEntries) => {
@@ -207,6 +220,14 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
null,
);
this.observe(
this.#context.hasTypeLimits,
(hasTypeLimits) => {
this.#setupBlockTypeLimitValidation(hasTypeLimits);
},
null,
);
this.#context.getManager().then((manager) => {
this.observe(
manager.layoutStylesheet,
@@ -223,8 +244,6 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
new UmbFormControlValidator(this, this /*, this.#dataPath*/);
}
#rangeUnderflowValidator?: UmbFormControlValidatorConfig;
#rangeOverflowValidator?: UmbFormControlValidatorConfig;
async #setupRangeValidation(rangeLimit: UmbNumberRangeValueType | undefined) {
if (this.#rangeUnderflowValidator) {
this.removeValidator(this.#rangeUnderflowValidator);
@@ -240,9 +259,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
(rangeLimit!.min ?? 0) - this._layoutEntries.length,
);
},
() => {
return this._layoutEntries.length < (rangeLimit?.min ?? 0);
},
() => this._layoutEntries.length < (rangeLimit?.min ?? 0),
);
}
@@ -260,8 +277,37 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
this._layoutEntries.length - (rangeLimit!.max ?? this._layoutEntries.length),
);
},
() => this._layoutEntries.length > (rangeLimit?.max ?? Infinity),
);
}
}
async #setupBlockTypeLimitValidation(hasTypeLimits: boolean | undefined) {
if (this.#typeLimitValidator) {
this.removeValidator(this.#typeLimitValidator);
this.#typeLimitValidator = undefined;
}
if (hasTypeLimits) {
this.#typeLimitValidator = this.addValidator(
'customError',
() => {
return (this._layoutEntries.length ?? 0) > (rangeLimit?.max ?? Infinity);
const invalids = this.#context.getInvalidBlockTypeLimits();
return invalids
.map((invalidRule) =>
this.localize.term(
invalidRule.amount < invalidRule.minRequirement
? 'blockEditor_areaValidationEntriesShort'
: 'blockEditor_areaValidationEntriesExceed',
invalidRule.name,
invalidRule.amount,
invalidRule.minRequirement,
invalidRule.maxRequirement,
),
)
.join(', ');
},
() => {
return !this.#context.checkBlockTypeLimitsValidity();
},
);
}
@@ -284,14 +330,14 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
</umb-block-grid-entry>`,
)}
</div>
<uui-form-validation-message .for=${this}></uui-form-validation-message>
${this._canCreate ? this.#renderCreateButton() : nothing}
${this._areaKey ? html` <uui-form-validation-message .for=${this}></uui-form-validation-message>` : nothing}
`;
}
#renderCreateButton() {
if (this._areaKey === null || this._layoutEntries.length === 0) {
return html`<uui-button-group>
return html`<uui-button-group id="createButton">
<uui-button
look="placeholder"
label=${this._singleBlockTypeName
@@ -343,19 +389,31 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
opacity: 0.2;
pointer-events: none;
}
> div {
display: flex;
flex-direction: column;
align-items: stretch;
}
uui-button-group {
#createButton {
padding-top: 1px;
grid-template-columns: 1fr auto;
display: grid;
}
// Only when we are n an area, we like to hide the button on drag
:host([area-key]) #createButton {
--umb-block-grid--is-dragging--variable: var(--umb-block-grid--is-dragging) none;
display: var(--umb-block-grid--is-dragging--variable, grid);
}
:host(:not([pristine]):invalid) #createButton {
--uui-button-contrast: var(--uui-color-danger);
--uui-button-contrast-hover: var(--uui-color-danger);
--uui-color-default-emphasis: var(--uui-color-danger);
--uui-button-border-color: var(--uui-color-danger);
--uui-button-border-color-hover: var(--uui-color-danger);
}
.umb-block-grid__layout-container[data-area-length='0'] {
--umb-block-grid--is-dragging--variable: var(--umb-block-grid--is-dragging) 1;

View File

@@ -13,6 +13,10 @@ import { UMB_BLOCK_GRID, type UmbBlockGridLayoutModel } from '@umbraco-cms/backo
import '../block-grid-block-inline/index.js';
import '../block-grid-block/index.js';
import '../block-scale-handler/index.js';
import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation';
import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block';
import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui';
import type { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/extension-api';
/**
* @element umb-block-grid-entry
*/
@@ -37,6 +41,16 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
this._blockViewProps.contentUdi = value;
this.setAttribute('data-element-udi', 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;
//
@@ -89,6 +103,14 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
@state()
_inlineCreateAboveWidth?: string;
// '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<UmbBlockGridLayoutModel> = {
contentUdi: undefined!,
@@ -178,6 +200,20 @@ export class UmbBlockGridEntryElement 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,
);
@@ -318,12 +354,23 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
return true;
};
#extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer<ManifestBlockEditorCustomView>) => {
if (ext.component) {
ext.component.classList.add('umb-block-grid__block--view');
}
return ext.component;
};
#renderInlineEditBlock() {
return html`<umb-block-grid-block-inline .label=${this._label}></umb-block-grid-block-inline>`;
return html`<umb-block-grid-block-inline
class="umb-block-grid__block--view"
.label=${this._label}></umb-block-grid-block-inline>`;
}
#renderRefBlock() {
return html`<umb-block-grid-block .label=${this._label}></umb-block-grid-block>`;
return html`<umb-block-grid-block
class="umb-block-grid__block--view"
.label=${this._label}></umb-block-grid-block>`;
}
#renderBlock() {
@@ -339,28 +386,47 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
: nothing}
<div class="umb-block-grid__block" part="umb-block-grid__block">
<umb-extension-slot
type="blockEditorCustomView"
default-element="umb-block-grid-block"
.props=${this._blockViewProps}
.filter=${this.#extensionSlotFilterMethod}
.renderMethod=${this.#extensionSlotRenderMethod}
.props=${this._blockViewProps}
default-element=${this._inlineEditingMode ? 'umb-block-grid-block-inline' : 'umb-block-grid-block'}
type="blockEditorCustomView"
single
>${this._inlineEditingMode ? this.#renderInlineEditBlock() : this.#renderRefBlock()}</umb-extension-slot
>
<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 content">!</uui-badge>`
: nothing}
</uui-button>`
: nothing}
${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>`
: nothing}
</uui-button>`
: nothing}
<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>
${!this._showContentEdit && this._contentInvalid
? html`<uui-badge attention color="danger" label="Invalid content">!</uui-badge>`
: nothing}
${this._canScale
? html` <umb-block-scale-handler
@mousedown=${(e: MouseEvent) => this.#context.scaleManager.onScaleMouseDown(e)}>
@@ -383,21 +449,31 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
}
static override styles = [
UUIBlinkKeyframes,
css`
:host {
position: relative;
display: block;
--umb-block-grid-entry-actions-opacity: 0;
}
:host([settings-invalid]),
:host([content-invalid]),
:host(:hover),
:host(:focus-within) {
--umb-block-grid-entry-actions-opacity: 1;
}
uui-action-bar {
position: absolute;
top: var(--uui-size-2);
right: var(--uui-size-2);
opacity: var(--umb-block-grid-entry-actions-opacity, 0);
transition: opacity 120ms;
}
uui-button-inline-create {
top: 0px;
position: absolute;
// Avoid showing inline-create in dragging-mode
--umb-block-grid__block--inline-create-button-display--condition: var(--umb-block-grid--dragging-mode) none;
display: var(--umb-block-grid__block--inline-create-button-display--condition);
}
@@ -412,35 +488,60 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
right: calc(1px - (var(--umb-block-grid--column-gap, 0px) * 0.5));
}
:host([drag-placeholder]) {
opacity: 0.2;
.umb-block-grid__block {
height: 100%;
}
:host(::after) {
:host::after {
content: '';
position: absolute;
z-index: 1;
pointer-events: none;
display: none;
inset: 0;
border: 1px solid transparent;
border-radius: 3px;
border-radius: var(--uui-border-radius);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.7),
inset 0 0 0 1px rgba(255, 255, 255, 0.7);
transition: border-color 240ms ease-in;
}
:host(:hover::after) {
// TODO: Look at the feature I out-commented here, what was that suppose to do [NL]:
//display: var(--umb-block-grid--block-ui-display, block);
:host(:hover):not(:drop)::after {
display: block;
border-color: var(--uui-color-interactive);
border-color: var(--uui-color-interactive-emphasis);
}
.umb-block-grid__block {
height: 100%;
:host([drag-placeholder])::after {
display: block;
border-width: 2px;
border-color: var(--uui-color-interactive-emphasis);
animation: ${UUIBlinkAnimationValue};
}
:host([drag-placeholder])::before {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
border-radius: var(--uui-border-radius);
background-color: var(--uui-color-interactive-emphasis);
opacity: 0.12;
}
:host([drag-placeholder]) .umb-block-grid__block {
transition: opacity 50ms 16ms;
opacity: 0;
}
:host([settings-invalid])::after,
:host([content-invalid])::after {
border-color: var(--uui-color-danger);
}
:host([settings-invalid])::before,
:host([content-invalid])::before {
background-color: var(--uui-color-danger);
}
uui-badge {
z-index: 2;
}
`,
];

View File

@@ -8,7 +8,13 @@ import {
import type { UmbBlockGridLayoutModel, UmbBlockGridTypeAreaType, UmbBlockGridTypeModel } from '../types.js';
import { UMB_BLOCK_GRID_MANAGER_CONTEXT } from './block-grid-manager.context-token.js';
import type { UmbBlockGridScalableContainerContext } from './block-grid-scale-manager/block-grid-scale-manager.controller.js';
import { UmbArrayState, UmbNumberState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import {
UmbArrayState,
UmbBooleanState,
UmbNumberState,
UmbObjectState,
UmbStringState,
} from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { pathFolderName } from '@umbraco-cms/backoffice/utils';
@@ -49,6 +55,9 @@ export class UmbBlockGridEntriesContext
public readonly amountOfAllowedBlockTypes = this.#allowedBlockTypes.asObservablePart((x) => x.length);
public readonly canCreate = this.#allowedBlockTypes.asObservablePart((x) => x.length > 0);
#hasTypeLimits = new UmbBooleanState(undefined);
public readonly hasTypeLimits = this.#hasTypeLimits.asObservable();
firstAllowedBlockTypeName() {
if (!this._manager) {
throw new Error('Manager not ready');
@@ -84,6 +93,10 @@ export class UmbBlockGridEntriesContext
this.#workspaceModal.setUniquePathValue('areaKey', areaKey ?? 'null');
this.#catalogueModal.setUniquePathValue('areaKey', areaKey ?? 'null');
this.#gotAreaKey();
// Idea: If we need to parse down a validation data path to target the specific layout object: [NL]
// If we have a areaKey, we want to inherit our layoutDataPath from nearest blockGridEntry context.
// If not, we want to set the layoutDataPath to a base one.
}
setLayoutColumns(columns: number | undefined) {
@@ -133,9 +146,30 @@ export class UmbBlockGridEntriesContext
blockGroups: this._manager?.getBlockGroups() ?? [],
openClipboard: routingInfo.view === 'clipboard',
originData: { index: index, areaKey: this.#areaKey, parentUnique: this.#parentUnique },
createBlockInWorkspace: true,
},
};
})
.onSubmit(async (value, data) => {
if (value?.create && data) {
const created = await this.create(
value.create.contentElementTypeKey,
// We can parse an empty object, cause the rest will be filled in by others.
{} as any,
data.originData as UmbBlockGridWorkspaceOriginData,
);
if (created) {
this.insert(
created.layout,
created.content,
created.settings,
data.originData as UmbBlockGridWorkspaceOriginData,
);
} else {
throw new Error('Failed to create block');
}
}
})
.observeRouteBuilder((routeBuilder) => {
// TODO: Does it make any sense that this is a state? Check usage and confirm. [NL]
this._catalogueRouteBuilderState.setValue(routeBuilder);
@@ -163,8 +197,8 @@ export class UmbBlockGridEntriesContext
protected _gotBlockManager() {
if (!this._manager) return;
this.#getAllowedBlockTypes();
this.#getRangeLimits();
this.#setupAllowedBlockTypes();
this.#setupRangeLimits();
this.observe(
this._manager.propertyAlias,
@@ -216,8 +250,6 @@ export class UmbBlockGridEntriesContext
'observeThisLayouts',
);
this.removeUmbControllerByAlias('observeAreaType');
const hostEl = this.getHostElement() as HTMLElement | undefined;
if (hostEl) {
hostEl.removeAttribute('data-area-alias');
@@ -229,8 +261,8 @@ export class UmbBlockGridEntriesContext
}
this.removeUmbControllerByAlias('observeAreaType');
this.#getAllowedBlockTypes();
this.#getRangeLimits();
this.#setupAllowedBlockTypes();
this.#setupRangeLimits();
} else {
if (!this.#parentEntry) return;
@@ -273,22 +305,44 @@ export class UmbBlockGridEntriesContext
hostEl.style.setProperty('--umb-block-grid--grid-columns', areaType?.columnSpan?.toString() ?? '');
hostEl.style.setProperty('--umb-block-grid--area-column-span', areaType?.columnSpan?.toString() ?? '');
hostEl.style.setProperty('--umb-block-grid--area-row-span', areaType?.rowSpan?.toString() ?? '');
this.#getAllowedBlockTypes();
this.#getRangeLimits();
this.#setupAllowedBlockTypes();
this.#setupRangeLimits();
},
'observeAreaType',
);
}
}
#getAllowedBlockTypes() {
#setupAllowedBlockTypes() {
if (!this._manager) return;
this.#allowedBlockTypes.setValue(this.#retrieveAllowedElementTypes());
this.#setupAllowedBlockTypesLimits();
}
#getRangeLimits() {
#setupRangeLimits() {
if (!this._manager) return;
const range = this.#retrieveRangeLimits();
this.#rangeLimits.setValue(range);
//const range = this.#retrieveRangeLimits();
if (this.#areaKey != null) {
this.removeUmbControllerByAlias('observeConfigurationRootLimits');
// Area entries:
if (!this.#areaType) return undefined;
// No need to observe as this method is called every time the area is changed.
this.#rangeLimits.setValue({
min: this.#areaType.minAllowed ?? 0,
max: this.#areaType.maxAllowed ?? Infinity,
});
} else if (this.#areaKey === null) {
if (!this._manager) return undefined;
this.observe(
this._manager.editorConfiguration,
(config) => {
const min = config?.getValueByAlias<UmbNumberRangeValueType>('validationLimit')?.min ?? 0;
const max = config?.getValueByAlias<UmbNumberRangeValueType>('validationLimit')?.max ?? Infinity;
this.#rangeLimits.setValue({ min, max });
},
'observeConfigurationRootLimits',
);
}
}
getPathForCreateBlock(index: number) {
@@ -385,24 +439,95 @@ export class UmbBlockGridEntriesContext
/**
* @internal
* @returns an NumberRange of the min and max allowed items in the current area. Or undefined if not ready jet.
*/
#retrieveRangeLimits(): UmbNumberRangeValueType | undefined {
if (this.#areaKey != null) {
#setupAllowedBlockTypesLimits() {
if (!this._manager) return;
if (this.#areaKey) {
// Area entries:
if (!this.#areaType) return undefined;
if (!this.#areaType) return;
return { min: this.#areaType.minAllowed ?? 0, max: this.#areaType.maxAllowed ?? Infinity };
if (this.#areaType.specifiedAllowance && this.#areaType.specifiedAllowance?.length > 0) {
this.#hasTypeLimits.setValue(true);
}
} else if (this.#areaKey === null) {
if (!this._manager) return undefined;
const config = this._manager.getEditorConfiguration();
const min = config?.getValueByAlias<UmbNumberRangeValueType>('validationLimit')?.min ?? 0;
const max = config?.getValueByAlias<UmbNumberRangeValueType>('validationLimit')?.max ?? Infinity;
return { min, max };
// RESET
}
}
return undefined;
#invalidBlockTypeLimits?: Array<{
groupKey?: string;
key?: string;
name: string;
amount: number;
minRequirement: number;
maxRequirement: number;
}>;
getInvalidBlockTypeLimits() {
return this.#invalidBlockTypeLimits ?? [];
}
/**
* @internal
* @returns {boolean} - True if the block type limits are valid, otherwise false.
*/
checkBlockTypeLimitsValidity(): boolean {
if (!this.#areaType || !this.#areaType.specifiedAllowance) return false;
const layoutEntries = this._layoutEntries.getValue();
this.#invalidBlockTypeLimits = [];
const hasInvalidRules = this.#areaType.specifiedAllowance.some((rule) => {
const minAllowed = rule.minAllowed || 0;
const maxAllowed = rule.maxAllowed || 0;
// For block groups:
if (rule.groupKey) {
const groupElementTypeKeys =
this._manager
?.getBlockTypes()
.filter((blockType) => blockType.groupKey === rule.groupKey && blockType.allowInAreas === true)
.map((x) => x.contentElementTypeKey) ?? [];
const groupAmount = layoutEntries.filter((entry) => {
const contentTypeKey = this._manager!.getContentTypeKeyOf(entry.contentUdi);
return contentTypeKey ? groupElementTypeKeys.indexOf(contentTypeKey) !== -1 : false;
}).length;
if (groupAmount < minAllowed || (maxAllowed > 0 && groupAmount > maxAllowed)) {
this.#invalidBlockTypeLimits!.push({
groupKey: rule.groupKey,
name: this._manager!.getBlockGroupName(rule.groupKey) ?? '?',
amount: groupAmount,
minRequirement: minAllowed,
maxRequirement: maxAllowed,
});
return true;
}
}
// For specific elementTypes:
else if (rule.elementTypeKey) {
const amount = layoutEntries.filter((entry) => {
const contentTypeKey = this._manager!.getContentOf(entry.contentUdi)?.contentTypeKey;
return contentTypeKey === rule.elementTypeKey;
}).length;
if (amount < minAllowed || (maxAllowed > 0 ? amount > maxAllowed : false)) {
this.#invalidBlockTypeLimits!.push({
key: rule.elementTypeKey,
name: this._manager!.getContentTypeNameOf(rule.elementTypeKey) ?? '?',
amount: amount,
minRequirement: minAllowed,
maxRequirement: maxAllowed,
});
return true;
}
}
// Lets fail cause the rule was bad.
console.error('Invalid block type limit rule.', rule);
return false;
});
return hasInvalidRules === false;
}
/**

View File

@@ -62,6 +62,9 @@ export class UmbBlockGridManagerContext<
getBlockGroups() {
return this.#blockGroups.value;
}
getBlockGroupName(unique: string) {
return this.#blockGroups.getValue().find((group) => group.key === unique)?.name;
}
constructor(host: UmbControllerHost) {
super(host);

View File

@@ -1,16 +1,25 @@
import { UmbBlockGridManagerContext } from '../../context/block-grid-manager.context.js';
import { UMB_BLOCK_GRID_PROPERTY_EDITOR_ALIAS } from './manifests.js';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, customElement, property, state, css, type PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import {
html,
customElement,
property,
state,
css,
type PropertyValueMap,
ref,
} from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import '../../components/block-grid-entries/index.js';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import { UmbBlockGridManagerContext } from '../../context/block-grid-manager.context.js';
import { UMB_BLOCK_GRID_PROPERTY_EDITOR_ALIAS } from './manifests.js';
import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation';
import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type';
import type { UmbBlockGridTypeModel, UmbBlockGridValueModel } from '@umbraco-cms/backoffice/block-grid';
import { UmbBlockElementDataValidationPathTranslator } from '@umbraco-cms/backoffice/block';
/**
* @element umb-property-editor-ui-block-grid
@@ -20,6 +29,9 @@ export class UmbPropertyEditorUIBlockGridElement
extends UmbFormControlMixin<UmbBlockGridValueModel, typeof UmbLitElement>(UmbLitElement)
implements UmbPropertyEditorUiElement
{
#validationContext = new UmbValidationContext(this).provide();
#contentDataPathTranslator?: UmbBlockElementDataValidationPathTranslator;
#settingsDataPathTranslator?: UmbBlockElementDataValidationPathTranslator;
#context = new UmbBlockGridManagerContext(this);
//
private _value: UmbBlockGridValueModel = {
@@ -31,11 +43,6 @@ export class UmbPropertyEditorUIBlockGridElement
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
if (!config) return;
/*const validationLimit = config.getValueByAlias<NumberRangeValueType>('validationLimit');
this.#limitMin = validationLimit?.min;
this.#limitMax = validationLimit?.max;*/
const blocks = config.getValueByAlias<Array<UmbBlockGridTypeModel>>('blocks') ?? [];
this.#context.setBlockTypes(blocks);
@@ -44,7 +51,7 @@ export class UmbPropertyEditorUIBlockGridElement
this.style.maxWidth = config.getValueByAlias<string>('maxPropertyWidth') ?? '';
//config.useLiveEditing, is covered by the EditorConfiguration of context.
//config.useLiveEditing, is covered by the EditorConfiguration of context. [NL]
this.#context.setEditorConfiguration(config);
}
@@ -70,6 +77,25 @@ export class UmbPropertyEditorUIBlockGridElement
constructor() {
super();
this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => {
this.observe(
context.dataPath,
(dataPath) => {
// Translate paths for content/settings:
this.#contentDataPathTranslator?.destroy();
this.#settingsDataPathTranslator?.destroy();
if (dataPath) {
// Set the data path for the local validation context:
this.#validationContext.setDataPath(dataPath);
this.#contentDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'contentData');
this.#settingsDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'settingsData');
}
},
'observeDataPath',
);
});
// TODO: Prevent initial notification from these observes
this.consumeContext(UMB_PROPERTY_CONTEXT, (propertyContext) => {
this.observe(
@@ -99,8 +125,21 @@ export class UmbPropertyEditorUIBlockGridElement
});
}
#currentEntriesElement?: Element;
#gotRootEntriesElement(element: Element | undefined): void {
if (this.#currentEntriesElement === element) return;
if (this.#currentEntriesElement) {
this.removeFormControlElement(this.#currentEntriesElement as any);
}
this.#currentEntriesElement = element;
if (element) {
this.addFormControlElement(element as any);
}
}
override render() {
return html` <umb-block-grid-entries
${ref(this.#gotRootEntriesElement)}
.areaKey=${null}
.layoutColumns=${this._layoutColumns}></umb-block-grid-entries>`;
}

View File

@@ -248,6 +248,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
default-element=${this._inlineEditingMode ? 'umb-inline-list-block' : 'umb-ref-list-block'}
.props=${this._blockViewProps}
.filter=${this.#extensionSlotFilterMethod}
single
>${this._inlineEditingMode ? this.#renderInlineBlock() : this.#renderRefBlock()}</umb-extension-slot
>
<uui-action-bar>

View File

@@ -139,21 +139,14 @@ export class UmbPropertyEditorUIBlockListElement
this.observe(
context.dataPath,
(dataPath) => {
// Translate paths for content elements:
// Translate paths for content/settings:
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.#contentDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'contentData');
this.#settingsDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'settingsData');
}
},
@@ -163,13 +156,13 @@ export class UmbPropertyEditorUIBlockListElement
this.addValidator(
'rangeUnderflow',
() => this.localize.term('validation_entriesShort'),
() => '#validation_entriesShort',
() => !!this._limitMin && this.#entriesContext.getLength() < this._limitMin,
);
this.addValidator(
'rangeOverflow',
() => this.localize.term('validation_entriesExceed'),
() => '#validation_entriesExceed',
() => !!this._limitMax && this.#entriesContext.getLength() > this._limitMax,
);

View File

@@ -151,6 +151,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
type="blockEditorCustomView"
default-element=${'umb-ref-rte-block'}
.props=${this._blockViewProps}
single
>${this.#renderRefBlock()}</umb-extension-slot
>
<uui-action-bar>

View File

@@ -4,7 +4,6 @@ import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel } from '../types.js';
import {
UMB_BLOCK_RTE_WORKSPACE_MODAL,
type UmbBlockRteWorkspaceOriginData,
type UmbBlockRteWorkspaceData,
} from '../workspace/block-rte-workspace.modal-token.js';
import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from './block-rte-manager.context-token.js';
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
@@ -41,9 +40,30 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
blockGroups: [],
openClipboard: routingInfo.view === 'clipboard',
originData: {},
createBlockInWorkspace: true,
},
};
})
.onSubmit(async (value, data) => {
if (value?.create && data) {
const created = await this.create(
value.create.contentElementTypeKey,
// We can parse an empty object, cause the rest will be filled in by others.
{} as any,
data.originData as UmbBlockRteWorkspaceOriginData,
);
if (created) {
this.insert(
created.layout,
created.content,
created.settings,
data.originData as UmbBlockRteWorkspaceOriginData,
);
} else {
throw new Error('Failed to create block');
}
}
})
.observeRouteBuilder((routeBuilder) => {
this._catalogueRouteBuilderState.setValue(routeBuilder);
});
@@ -114,10 +134,10 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
async create(
contentElementTypeKey: string,
partialLayoutEntry?: Omit<UmbBlockRteLayoutModel, 'contentUdi'>,
modalData?: UmbBlockRteWorkspaceData,
originData?: UmbBlockRteWorkspaceOriginData,
) {
await this._retrieveManager;
return this._manager?.create(contentElementTypeKey, partialLayoutEntry, modalData);
return this._manager?.create(contentElementTypeKey, partialLayoutEntry, originData);
}
// insert Block?
@@ -126,10 +146,10 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
layoutEntry: UmbBlockRteLayoutModel,
content: UmbBlockDataType,
settings: UmbBlockDataType | undefined,
modalData: UmbBlockRteWorkspaceData,
originData: UmbBlockRteWorkspaceOriginData,
) {
await this._retrieveManager;
return this._manager?.insert(layoutEntry, content, settings, modalData) ?? false;
return this._manager?.insert(layoutEntry, content, settings, originData) ?? false;
}
// create Block?

View File

@@ -1,5 +1,5 @@
import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel } from '../types.js';
import type { UmbBlockRteWorkspaceData } from '../index.js';
import type { UmbBlockRteWorkspaceOriginData } from '../index.js';
import type { UmbBlockDataType } from '../../block/types.js';
import type { Editor } from '@umbraco-cms/backoffice/external/tinymce';
import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
@@ -36,7 +36,7 @@ export class UmbBlockRteManagerContext<
partialLayoutEntry?: Omit<BlockLayoutType, 'contentUdi'>,
// This property is used by some implementations, but not used in this.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
originData?: UmbBlockRteWorkspaceData,
originData?: UmbBlockRteWorkspaceOriginData,
) {
const data = super.createBlockData(contentElementTypeKey, partialLayoutEntry);
@@ -57,13 +57,13 @@ export class UmbBlockRteManagerContext<
layoutEntry: BlockLayoutType,
content: UmbBlockDataType,
settings: UmbBlockDataType | undefined,
modalData: UmbBlockRteWorkspaceData,
originData: UmbBlockRteWorkspaceOriginData,
) {
if (!this.#editor) return false;
this._layouts.appendOne(layoutEntry);
this.insertBlockData(layoutEntry, content, settings, modalData);
this.insertBlockData(layoutEntry, content, settings, originData);
if (layoutEntry.displayInline) {
this.#editor.selection.setContent(

View File

@@ -21,7 +21,7 @@ export class UmbBlockTypeCardElement extends UmbLitElement {
(x) => x.unique,
);
@property({ type: String, attribute: false })
@property({ type: String })
href?: string;
@property({ type: String, attribute: false })

View File

@@ -234,11 +234,11 @@ export abstract class UmbBlockEntryContext<
}
#updateCreatePaths() {
const index = this.#index.value;
if (this._entries && index !== undefined) {
if (this._entries) {
this.observe(
observeMultiple([this._entries.catalogueRouteBuilder, this._entries.canCreate]),
([catalogueRouteBuilder, canCreate]) => {
observeMultiple([this.index, this._entries.catalogueRouteBuilder, this._entries.canCreate]),
([index, catalogueRouteBuilder, canCreate]) => {
if (index === undefined) return;
if (catalogueRouteBuilder && canCreate) {
this.#createBeforePath.setValue(this._entries!.getPathForCreateBlock(index));
this.#createAfterPath.setValue(this._entries!.getPathForCreateBlock(index + 1));

View File

@@ -148,6 +148,13 @@ export abstract class UmbBlockManagerContext<
getContentTypeNameOf(contentTypeKey: string) {
return this.#contentTypes.getValue().find((x) => x.unique === contentTypeKey)?.name;
}
getContentTypeKeyOf(contentTypeKey: string) {
return this.#contentTypes.getValue().find((x) => x.unique === contentTypeKey)?.unique;
}
getContentTypeHasProperties(contentTypeKey: string) {
const properties = this.#contentTypes.getValue().find((x) => x.unique === contentTypeKey)?.properties;
return properties ? properties.length > 0 : false;
}
blockTypeOf(contentTypeKey: string) {
return this.#blockTypes.asObservablePart((source) =>
source.find((x) => x.contentElementTypeKey === contentTypeKey),

View File

@@ -1,6 +1,10 @@
import { UMB_BLOCK_WORKSPACE_MODAL } from '../../workspace/index.js';
import type { UmbBlockTypeGroup, UmbBlockTypeWithGroupKey } from '@umbraco-cms/backoffice/block-type';
import type { UmbBlockCatalogueModalData, UmbBlockCatalogueModalValue } from '@umbraco-cms/backoffice/block';
import {
UMB_BLOCK_MANAGER_CONTEXT,
type UmbBlockCatalogueModalData,
type UmbBlockCatalogueModalValue,
} from '@umbraco-cms/backoffice/block';
import { css, html, customElement, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit';
import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
import { UMB_MODAL_CONTEXT, UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
@@ -14,8 +18,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
UmbBlockCatalogueModalData,
UmbBlockCatalogueModalValue
> {
//
private _search = '';
#search = '';
private _groupedBlocks: Array<{ name?: string; blocks: Array<UmbBlockTypeWithGroupKey> }> = [];
@@ -28,6 +31,9 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
@state()
private _filtered: Array<{ name?: string; blocks: Array<UmbBlockTypeWithGroupKey> }> = [];
@state()
_manager?: typeof UMB_BLOCK_MANAGER_CONTEXT.TYPE;
constructor() {
super();
@@ -49,6 +55,10 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
});
}
});
this.consumeContext(UMB_BLOCK_MANAGER_CONTEXT, (manager) => {
this._manager = manager;
});
}
override connectedCallback() {
@@ -71,10 +81,10 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
}
#updateFiltered() {
if (this._search.length === 0) {
if (this.#search.length === 0) {
this._filtered = this._groupedBlocks;
} else {
const search = this._search.toLowerCase();
const search = this.#search.toLowerCase();
this._filtered = this._groupedBlocks.map((group) => {
return { ...group, blocks: group.blocks.filter((block) => block.label?.toLocaleLowerCase().includes(search)) };
});
@@ -82,7 +92,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
}
#onSearch(e: UUIInputEvent) {
this._search = e.target.value as string;
this.#search = e.target.value as string;
this.#updateFiltered();
}
@@ -98,7 +108,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
override render() {
return html`
<umb-body-layout headline="${this.localize.term('blockEditor_addBlock')}">
${this.#renderViews()} ${this._openClipboard ? this.#renderClipboard() : this.#renderCreateEmpty()}
${this.#renderViews()}${this.#renderMain()}
<div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button>
<uui-button
@@ -111,6 +121,10 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
`;
}
#renderMain() {
return this._manager ? (this._openClipboard ? this.#renderClipboard() : this.#renderCreateEmpty()) : nothing;
}
#renderClipboard() {
return html`Clipboard`;
}
@@ -140,7 +154,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
.backgroundColor=${block.backgroundColor}
.contentElementTypeKey=${block.contentElementTypeKey}
@open=${() => this.#chooseBlock(block.contentElementTypeKey)}
?href=${this._workspacePath
.href=${this._workspacePath && this._manager!.getContentTypeHasProperties(block.contentElementTypeKey)
? `${this._workspacePath}create/${block.contentElementTypeKey}`
: undefined}>
</umb-block-type-card>

View File

@@ -1,6 +1,8 @@
import { UMB_BLOCK_WORKSPACE_CONTEXT } from './index.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { customElement, css, html, property } from '@umbraco-cms/backoffice/external/lit';
import { customElement, css, html, property, state, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
@customElement('umb-block-workspace-editor')
export class UmbBlockWorkspaceEditorElement extends UmbLitElement {
@@ -8,10 +10,29 @@ export class UmbBlockWorkspaceEditorElement extends UmbLitElement {
@property({ type: String, attribute: false })
workspaceAlias?: string;
constructor() {
super();
this.consumeContext(UMB_BLOCK_WORKSPACE_CONTEXT, (context) => {
this.observe(
observeMultiple([
context.isNew,
context.content.structure.ownerContentTypePart((contentType) => contentType?.name),
]),
([isNew, name]) => {
this._headline = this.localize.term(isNew ? 'general_add' : 'general_edit') + ' ' + name;
},
'observeOwnerContentElementTypeName',
);
});
}
@state()
_headline: string = '';
override render() {
return this.workspaceAlias
? html` <umb-workspace-editor alias=${this.workspaceAlias} headline=${'BLOCK EDITOR'}> </umb-workspace-editor> `
: '';
? html` <umb-workspace-editor alias=${this.workspaceAlias} headline=${this._headline}> </umb-workspace-editor> `
: nothing;
}
static override styles = [

View File

@@ -9,11 +9,12 @@ import {
import { UmbClassState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { ManifestWorkspace } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal';
import { UMB_MODAL_CONTEXT, type UmbModalContext } from '@umbraco-cms/backoffice/modal';
import { decodeFilePath } from '@umbraco-cms/backoffice/utils';
import {
UMB_BLOCK_ENTRIES_CONTEXT,
UMB_BLOCK_MANAGER_CONTEXT,
type UmbBlockWorkspaceOriginData,
type UmbBlockWorkspaceData,
} from '@umbraco-cms/backoffice/block';
import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property';
@@ -32,7 +33,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
#retrieveBlockManager;
#blockEntries?: typeof UMB_BLOCK_ENTRIES_CONTEXT.TYPE;
#retrieveBlockEntries;
#modalContext?: typeof UMB_MODAL_CONTEXT.TYPE;
#modalContext?: UmbModalContext<UmbBlockWorkspaceData>;
#retrieveModalContext;
#entityType: string;
@@ -68,7 +69,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
this.addValidationContext(this.settings.validation);
this.#retrieveModalContext = this.consumeContext(UMB_MODAL_CONTEXT, (context) => {
this.#modalContext = context;
this.#modalContext = context as any;
context.onSubmit().catch(this.#modalRejected);
}).asPromise();
@@ -180,7 +181,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
const blockCreated = await this.#blockEntries.create(
contentElementTypeId,
{},
this.#modalContext.data as UmbBlockWorkspaceData,
this.#modalContext.data.originData as UmbBlockWorkspaceOriginData,
);
if (!blockCreated) {
throw new Error('Block Entries could not create block');
@@ -200,7 +201,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
blockCreated.layout,
blockCreated.content,
blockCreated.settings,
this.#modalContext.data as UmbBlockWorkspaceData,
this.#modalContext.data.originData as UmbBlockWorkspaceOriginData,
);
if (!blockInserted) {
throw new Error('Block Entries could not insert block');
@@ -360,7 +361,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
layoutData,
contentData,
settingsData,
this.#modalContext.data as UmbBlockWorkspaceData,
this.#modalContext.data.originData as UmbBlockWorkspaceOriginData,
);
if (!blockInserted) {
throw new Error('Block Entries could not insert block');
@@ -368,7 +369,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
} else {
// Update data:
this.#blockManager.setOneLayout(layoutData, this.#modalContext.data as UmbBlockWorkspaceData);
this.#blockManager.setOneLayout(layoutData, this.#modalContext.data.originData as UmbBlockWorkspaceOriginData);
if (contentData) {
this.#blockManager.setOneContent(contentData);
}

View File

@@ -134,7 +134,7 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin(
override render() {
//TODO: Using native input=color element instead of uui-color-picker due to its huge size and bad adaptability as a pop up
return html`
<uui-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<umb-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<div id="item">
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-navigation"></uui-icon>`}
<div class="color-wrapper">
@@ -183,7 +183,7 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin(
`,
)}
</div>
</uui-form-validation-message>
</umb-form-validation-message>
`;
}

View File

@@ -79,7 +79,7 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlMixin(U
return html`
${this.disabled || this.readonly ? nothing : html`<uui-icon name="icon-navigation" class="handle"></uui-icon>`}
<uui-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<umb-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<uui-input
id="input"
label="Value"
@@ -90,7 +90,7 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlMixin(U
?readonly=${this.readonly}
required=${this.required}
required-message="Value is missing"></uui-input>
</uui-form-validation-message>
</umb-form-validation-message>
${when(
!this.readonly,

View File

@@ -15,16 +15,20 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
* @augments {UmbLitElement}
*/
// TODO: Refactor extension-slot and extension-with-api slot.
// TODO: Fire change event.
// TODO: Make property that reveals the amount of displayed/permitted extensions.
@customElement('umb-extension-slot')
export class UmbExtensionSlotElement extends UmbLitElement {
#attached = false;
#extensionsController?: UmbExtensionsElementInitializer;
#extensionsController?: UmbExtensionsElementInitializer | UmbExtensionElementInitializer;
@state()
private _permitted?: Array<UmbExtensionElementInitializer>;
@property({ type: Boolean })
single?: boolean;
/**
* The type or types of extensions to render.
* @type {string | string[]}
@@ -77,7 +81,6 @@ export class UmbExtensionSlotElement extends UmbLitElement {
return this.#props;
}
set props(newVal: Record<string, unknown> | undefined) {
// TODO, compare changes since last time. only reset the ones that changed. This might be better done by the controller is self:
this.#props = newVal;
if (this.#extensionsController) {
this.#extensionsController.properties = newVal;
@@ -88,7 +91,7 @@ export class UmbExtensionSlotElement extends UmbLitElement {
@property({ type: String, attribute: 'default-element' })
public defaultElement?: string;
@property()
@property({ attribute: false })
public renderMethod?: (
extension: UmbExtensionElementInitializer,
index: number,
@@ -128,15 +131,17 @@ export class UmbExtensionSlotElement extends UmbLitElement {
override render() {
return this._permitted
? this._permitted.length > 0
? repeat(
this._permitted,
(ext) => ext.alias,
(ext, i) => (this.renderMethod ? this.renderMethod(ext, i) : ext.component),
)
? this.single
? this.#renderExtension(this._permitted[0], 0)
: repeat(this._permitted, (ext) => ext.alias, this.#renderExtension)
: html`<slot></slot>`
: '';
}
#renderExtension = (ext: UmbExtensionElementInitializer, i: number) => {
return this.renderMethod ? this.renderMethod(ext, i) : ext.component;
};
static override styles = css`
:host {
display: contents;

View File

@@ -7,6 +7,8 @@ import type { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/ext
@customElement('umb-test-extension-slot-manifest-element')
class UmbTestExtensionSlotManifestElement extends HTMLElement {}
@customElement('umb-test-extension-slot-manifest-element-2')
class UmbTestExtensionSlotManifestElement2 extends HTMLElement {}
function sleep(timeMs: number) {
return new Promise((resolve) => {
@@ -44,9 +46,21 @@ describe('UmbExtensionSlotElement', () => {
expect(element).to.have.property('filter');
});
it('has a props property', () => {
expect(element).to.have.property('props');
});
it('has a defaultElement property', () => {
expect(element).to.have.property('defaultElement');
});
it('has a renderMethod property', () => {
expect(element).to.have.property('renderMethod');
});
it('has a single property', () => {
expect(element).to.have.property('single');
});
});
});
@@ -57,6 +71,17 @@ describe('UmbExtensionSlotElement', () => {
alias: 'unit-test-ext-slot-element-manifest',
name: 'unit-test-extension',
elementName: 'umb-test-extension-slot-manifest-element',
weight: 200, // first is the heaviest and is therefor rendered first.
meta: {
pathname: 'test/test',
},
});
umbExtensionsRegistry.register({
type: 'dashboard',
alias: 'unit-test-ext-slot-element-manifest-2',
name: 'unit-test-extension-2',
elementName: 'umb-test-extension-slot-manifest-element-2',
weight: 100,
meta: {
pathname: 'test/test',
},
@@ -65,6 +90,7 @@ describe('UmbExtensionSlotElement', () => {
afterEach(async () => {
umbExtensionsRegistry.unregister('unit-test-ext-slot-element-manifest');
umbExtensionsRegistry.unregister('unit-test-ext-slot-element-manifest-2');
});
it('renders a manifest element', async () => {
@@ -73,18 +99,30 @@ describe('UmbExtensionSlotElement', () => {
await sleep(20);
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(element.shadowRoot!.childElementCount).to.be.equal(2);
});
it('works with the filtering method', async () => {
element = await fixture(
html`<umb-extension-slot
type="dashboard"
.filter=${(x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-element-manifest'}></umb-extension-slot>`,
.filter=${(x: ManifestDashboard) =>
x.alias === 'unit-test-ext-slot-element-manifest-2'}></umb-extension-slot>`,
);
await sleep(20);
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement2);
expect(element.shadowRoot!.childElementCount).to.be.equal(1);
});
it('works with the single mode', async () => {
element = await fixture(html`<umb-extension-slot type="dashboard" single></umb-extension-slot>`);
await sleep(20);
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(element.shadowRoot!.childElementCount).to.be.equal(1);
});
it('use the render method', async () => {
@@ -102,6 +140,7 @@ describe('UmbExtensionSlotElement', () => {
expect(element.shadowRoot!.firstElementChild?.firstElementChild).to.be.instanceOf(
UmbTestExtensionSlotManifestElement,
);
expect(element.shadowRoot!.childElementCount).to.be.equal(1);
});
it('parses the props', async () => {
@@ -117,6 +156,7 @@ describe('UmbExtensionSlotElement', () => {
expect((element.shadowRoot!.firstElementChild as any).testProp).to.be.equal('fooBar');
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(element.shadowRoot!.childElementCount).to.be.equal(1);
});
});
});

View File

@@ -17,6 +17,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
* @augments {UmbLitElement}
*/
// TODO: Refactor extension-slot and extension-with-api slot.
// TODO: Fire change event.
// TODO: Make property that reveals the amount of displayed/permitted extensions.
@customElement('umb-extension-with-api-slot')
@@ -27,6 +28,9 @@ export class UmbExtensionWithApiSlotElement extends UmbLitElement {
@state()
private _permitted?: Array<UmbExtensionElementAndApiInitializer>;
@property({ type: Boolean })
single?: boolean;
/**
* The type or types of extensions to render.
* @type {string | string[]}
@@ -177,15 +181,17 @@ export class UmbExtensionWithApiSlotElement extends UmbLitElement {
override render() {
return this._permitted
? this._permitted.length > 0
? repeat(
this._permitted,
(ext) => ext.alias,
(ext, i) => (this.renderMethod ? this.renderMethod(ext, i) : ext.component),
)
? this.single
? this.#renderExtension(this._permitted[0], 0)
: repeat(this._permitted, (ext) => ext.alias, this.#renderExtension)
: html`<slot></slot>`
: '';
}
#renderExtension = (ext: UmbExtensionElementAndApiInitializer, i: number) => {
return this.renderMethod ? this.renderMethod(ext, i) : ext.component;
};
static override styles = css`
:host {
display: contents;

View File

@@ -195,7 +195,7 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
return html`
<uui-box class="uui-text">
<div class="container">
<uui-form-validation-message>
<umb-form-validation-message>
<uui-input
id="name-input"
name="name"
@@ -208,8 +208,8 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
${umbFocus()}>
<!-- TODO: validation for bad characters -->
</uui-input>
</uui-form-validation-message>
<uui-form-validation-message>
</umb-form-validation-message>
<umb-form-validation-message>
<uui-input-lock
id="alias-input"
name="alias"
@@ -222,7 +222,7 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
@input=${this.#onAliasChange}
@lock-change=${this.#onToggleAliasLock}>
</uui-input-lock>
</uui-form-validation-message>
</umb-form-validation-message>
<uui-textarea
id="description-input"
name="description"
@@ -231,13 +231,13 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
placeholder=${this.localize.term('placeholders_enterDescription')}
.value=${this._data?.description}></uui-textarea>
</div>
<uui-form-validation-message>
<umb-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>
</umb-form-validation-message>
<hr />
<div class="container">
<b><umb-localize key="validation_validation">Validation</umb-localize></b>

View File

@@ -81,9 +81,9 @@ export class UmbPropertyLayoutElement extends UmbLitElement {
<slot name="description"></slot>
</div>
<div id="editorColumn">
<uui-form-validation-message>
<umb-form-validation-message>
<slot name="editor"></slot>
</uui-form-validation-message>
</umb-form-validation-message>
</div>
`;
}

View File

@@ -332,7 +332,7 @@ export class UmbPropertyElement extends UmbLitElement {
if ('checkValidity' in this._element) {
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]
// We trust blindly that the dataPath will be present at this stage and not arrive later than this moment. [NL]
if (dataPath) {
this.#validationMessageBinder = new UmbBindServerValidationToFormControl(
this,

View File

@@ -16,9 +16,7 @@ class UmbSorterTestElement extends UmbLitElement {
itemSelector: '.item',
containerSelector: '#container',
disabledItemSelector: '.disabled',
onChange: ({ model }) => {
this.model = model;
},
// TODO: In theory missing model change callback? [NL]
});
getAllItems() {
@@ -151,6 +149,7 @@ describe('UmbSorterController', () => {
describe('enable', () => {
it('sets all allowed items to draggable', () => {
// [NL] I have experienced an issue with this test, it may need a little delay before testing for this. As the test relies on DOM.
const items = element.getSortableItems();
expect(items.length).to.equal(3);
items.forEach((item) => {

View File

@@ -20,6 +20,9 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
selection: [],
};
@state()
_hasSelection: boolean = false;
@state()
_createPath?: string;
@@ -34,6 +37,9 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
constructor() {
super();
this.#pickerContext.selection.setSelectable(true);
this.observe(this.#pickerContext.selection.hasSelection, (hasSelection) => {
this._hasSelection = hasSelection;
});
this.#observePickerSelection();
this.#observeSearch();
}
@@ -188,7 +194,8 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
label=${this.localize.term('general_choose')}
look="primary"
color="positive"
@click=${this._submitModal}></uui-button>
@click=${this._submitModal}
?disabled=${!this._hasSelection}></uui-button>
</div>
`;
}

View File

@@ -13,6 +13,7 @@ export class UmbSelectionManager<ValueType extends string | null = string | null
#selection = new UmbArrayState(<Array<ValueType>>[], (x) => x);
public readonly selection = this.#selection.asObservable();
public readonly hasSelection = this.#selection.asObservablePart((x) => x.length > 0);
#multiple = new UmbBooleanState(false);
public readonly multiple = this.#multiple.asObservable();

View File

@@ -126,4 +126,28 @@ This fact enables a property to observe if there is any Message Paths that start
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
### UmbBindServerValidationToFormControl
This binder takes a Form Control Element and a data-path.
The Data Path is a JSON Path defining where the data of this input is located in the model sent to the server.
```
this.#validationMessageBinder = new UmbBindServerValidationToFormControl(
this,
this.querySelector('#myInput"),
"$.values.[?(@.alias = 'my-input-alias')].value",
);
```
Once the binder is initialized you need to keep it updated with the value your form control represents. Notice we do not recommend using events from the form control to notify about the changes.
Instead observe the value in of your data model.
This example is just a dummy example of how that could look:
```
this.observe(
this.#value,
(value) => {
this.#validationMessageBinder.value = value;
},
);
```

View File

@@ -0,0 +1,97 @@
import { UmbValidationInvalidEvent, UmbValidationValidEvent } from '../events/index.js';
import type { UmbFormControlMixinInterface } from '../mixins/index.js';
import { css, customElement, html, property, repeat, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
/**
* @description - Component for displaying one or more validation messages from UMB/UUI Form Control within the given scope.
* Notice: Only supports components that is build on the UMB / UUI FormControlMixing.
* @slot - for button contents
* @slot message - for extras in the messages container
* @see FormControlMixin
*/
@customElement('umb-form-validation-message')
export class UmbFormValidationMessageElement extends UmbLitElement {
/**
* Set the element containing Form Controls of interest.
* @type {string}
* @default
*/
@property({ reflect: false, attribute: true })
public get for(): HTMLElement | string | null {
return this._for;
}
public set for(value: HTMLElement | string | null) {
let element = null;
if (typeof value === 'string') {
const scope = this.getRootNode();
element = (scope as DocumentFragment)?.getElementById(value);
} else if (value instanceof HTMLElement) {
element = value;
}
const newScope = element ?? this;
const oldScope = this._for;
if (oldScope === newScope) {
return;
}
if (oldScope !== null) {
oldScope.removeEventListener(UmbValidationInvalidEvent.TYPE, this.#onControlInvalid as EventListener);
oldScope.removeEventListener(UmbValidationValidEvent.TYPE, this.#onControlValid as EventListener);
}
this._for = newScope;
this._for.addEventListener(UmbValidationInvalidEvent.TYPE, this.#onControlInvalid as EventListener);
this._for.addEventListener(UmbValidationValidEvent.TYPE, this.#onControlValid as EventListener);
}
private _for: HTMLElement | null = null;
constructor() {
super();
if (this.for === null) {
this.for = this;
}
}
private _messages = new Map<UmbFormControlMixinInterface<unknown>, string>();
#onControlInvalid = async (e: UmbValidationInvalidEvent) => {
const ctrl = (e as any).composedPath()[0];
if (ctrl.pristine === false) {
// Currently we only show message from components who does have the pristine property. (we only want to show messages from fields that are NOT pristine aka. that are dirty or in a from that has been submitted)
// Notice we use the localization controller here, this is different frm the UUI component which uses the same name.
this._messages.set(ctrl, this.localize.string(ctrl.validationMessage));
} else {
this._messages.delete(ctrl);
}
this.requestUpdate();
};
#onControlValid = (e: UmbValidationValidEvent) => {
const ctrl = (e as any).composedPath()[0];
this._messages.delete(ctrl);
this.requestUpdate();
};
override render() {
return html`
<slot></slot>
<div id="messages">
${repeat(this._messages, (item) => html`<div>${unsafeHTML(item[1])}</div>`)}
<slot name="message"></slot>
</div>
`;
}
static override styles = [
css`
#messages {
color: var(--uui-color-danger-standalone);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-form-validation-message': UmbFormValidationMessageElement;
}
}

View File

@@ -1,9 +1,10 @@
export * from './components/form-validation-message.element.js';
export * from './const.js';
export * from './context/index.js';
export * from './controllers/index.js';
export * from './directives/bind-to-validation.lit-directive.js';
export * from './events/index.js';
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

@@ -80,6 +80,7 @@ export declare abstract class UmbFormControlMixinElement<ValueType>
) => UmbFormControlValidatorConfig;
removeValidator: (obj: UmbFormControlValidatorConfig) => void;
protected addFormControlElement(element: UmbNativeFormControlElement): void;
protected removeFormControlElement(element: UmbNativeFormControlElement): void;
//static formAssociated: boolean;
protected getFormElement(): HTMLElement | undefined | null;
@@ -96,10 +97,11 @@ export declare abstract class UmbFormControlMixinElement<ValueType>
}
/**
* @mixin
* The mixin allows a custom element to participate in HTML forms.
* @param {object} superClass - superclass to be extended.
* @param defaultValue
* @mixin
* @param {object} defaultValue - Default value for the form control.
* @returns {Function} - The mixin class.
*/
export function UmbFormControlMixin<
ValueType = FormData | FormDataEntryValue,
@@ -172,7 +174,7 @@ export function UmbFormControlMixin<
* Get internal form element.
* This has to be implemented to provide a FormControl Element of choice for the given context. The element is used as anchor for validation-messages.
* @function getFormElement
* @returns {HTMLElement | undefined | null}
* @returns {HTMLElement | undefined | null} - Returns the form element or undefined if not found.
*/
protected getFormElement(): HTMLElement | undefined | null {
return this.#formCtrlElements.find((el) => el.validity.valid === false);
@@ -181,7 +183,7 @@ export function UmbFormControlMixin<
/**
* Focus first element that is invalid.
* @function focusFirstInvalidElement
* @returns {HTMLElement | undefined}
* @returns {HTMLElement | undefined} - Returns the first invalid element or undefined if no invalid elements are found.
*/
focusFirstInvalidElement() {
const firstInvalid = this.#formCtrlElements.find((el) => el.validity.valid === false);
@@ -219,6 +221,7 @@ export function UmbFormControlMixin<
* @param {FlagTypes} flagKey the type of validation.
* @param {method} getMessageMethod method to retrieve relevant message. Is executed every time the validator is re-executed.
* @param {method} checkMethod method to determine if this validator should invalidate this form control. Return true if this should prevent submission.
* @returns {UmbFormControlValidatorConfig} - The added validator configuration.
*/
addValidator(
flagKey: FlagTypes,
@@ -249,19 +252,18 @@ export function UmbFormControlMixin<
}
}
#runValidatorsCallback = () => this._runValidators;
/**
* @function addFormControlElement
* @description Important notice if adding a native form control then ensure that its value and thereby validity is updated when value is changed from the outside.
* @param element {UmbNativeFormControlElement} - element to validate and include as part of this form association.
* @param {UmbNativeFormControlElement} element - element to validate and include as part of this form control association.
* @returns {void}
*/
protected addFormControlElement(element: UmbNativeFormControlElement) {
this.#formCtrlElements.push(element);
element.addEventListener(UmbValidationInvalidEvent.TYPE, () => {
this._runValidators();
});
element.addEventListener(UmbValidationValidEvent.TYPE, () => {
this._runValidators();
});
element.addEventListener(UmbValidationInvalidEvent.TYPE, this.#runValidatorsCallback);
element.addEventListener(UmbValidationValidEvent.TYPE, this.#runValidatorsCallback);
// If we are in validationMode/'touched'/not-pristine then we need to validate this newly added control. [NL]
if (this._pristine === false) {
element.checkValidity();
@@ -270,12 +272,29 @@ export function UmbFormControlMixin<
}
}
/**
* @function removeFormControlElement
* @param {UmbNativeFormControlElement} element - element to remove as part of this form controls associated controls.
* @returns {void}
*/
protected removeFormControlElement(element: UmbNativeFormControlElement) {
const index = this.#formCtrlElements.indexOf(element);
if (index !== -1) {
this.#formCtrlElements.splice(index, 1);
element.removeEventListener(UmbValidationInvalidEvent.TYPE, this.#runValidatorsCallback);
element.removeEventListener(UmbValidationValidEvent.TYPE, this.#runValidatorsCallback);
if (this._pristine === false) {
this._runValidators();
}
}
}
private _customValidityObject?: UmbFormControlValidatorConfig;
/**
* @function setCustomValidity
* @description Set custom validity state, set to empty string to remove the custom message.
* @param message {string} - The message to be shown
* @param {string} message - The message to be shown
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity|HTMLObjectElement:setCustomValidity}
*/
protected setCustomValidity(message: string | null) {

View File

@@ -105,6 +105,9 @@ export class UmbDataTypeWorkspaceViewInfoReferenceElement extends UmbLitElement
static override styles = [
UmbTextStyles,
css`
:host {
display: contents;
}
uui-table-cell {
color: var(--uui-color-text-alt);
}

View File

@@ -97,16 +97,14 @@ export class UmbDocumentWorkspaceViewInfoHistoryElement extends UmbLitElement {
override render() {
return html`<uui-box>
<div id="rollback" slot="header">
<h2><umb-localize key="general_history">History</umb-localize></h2>
<uui-button
label=${this.localize.term('actions_rollback')}
look="secondary"
slot="actions"
@click=${this.#onRollbackModalOpen}>
<uui-icon name="icon-undo"></uui-icon> ${this.localize.term('actions_rollback')}
</uui-button>
</div>
<umb-localize slot="headline" key="general_history">History</umb-localize>
<uui-button
slot="header-actions"
label=${this.localize.term('actions_rollback')}
look="secondary"
@click=${this.#onRollbackModalOpen}>
<uui-icon name="icon-undo"></uui-icon> ${this.localize.term('actions_rollback')}
</uui-button>
${this._items ? this.#renderHistory() : html`<uui-loader-circle></uui-loader-circle> `}
${this.#renderPagination()}
</uui-box> `;
@@ -167,18 +165,6 @@ export class UmbDocumentWorkspaceViewInfoHistoryElement extends UmbLitElement {
font-size: 2rem;
}
#rollback {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
#rollback h2 {
font-size: var(--uui-type-h5-size);
margin: 0;
}
uui-tag uui-icon {
margin-right: var(--uui-size-space-1);
}

View File

@@ -175,6 +175,9 @@ export class UmbDocumentWorkspaceViewInfoReferenceElement extends UmbLitElement
static override styles = [
UmbTextStyles,
css`
:host {
display: contents;
}
uui-table-cell:not(.link-cell) {
color: var(--uui-color-text-alt);
}

View File

@@ -196,6 +196,10 @@ export class UmbMediaWorkspaceViewInfoReferenceElement extends UmbLitElement {
static override styles = [
UmbTextStyles,
css`
:host {
display: contents;
}
uui-table-cell {
color: var(--uui-color-text-alt);
}

View File

@@ -1,8 +1,9 @@
import { UmbChangeUserPasswordRepository } from '../../repository/index.js';
import { UmbChangeUserPasswordRepository } from '@umbraco-cms/backoffice/user';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbEntityActionArgs } from '@umbraco-cms/backoffice/entity-action';
import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action';
import { UMB_MODAL_MANAGER_CONTEXT, UMB_CHANGE_PASSWORD_MODAL } from '@umbraco-cms/backoffice/modal';
import { UMB_CURRENT_USER_CONTEXT, UmbCurrentUserRepository } from '@umbraco-cms/backoffice/current-user';
export class UmbChangeUserPasswordEntityAction extends UmbEntityActionBase<never> {
constructor(host: UmbControllerHost, args: UmbEntityActionArgs<never>) {
@@ -23,8 +24,16 @@ export class UmbChangeUserPasswordEntityAction extends UmbEntityActionBase<never
const data = await modalContext.onSubmit();
const repository = new UmbChangeUserPasswordRepository(this);
await repository.changePassword(this.args.unique, data.newPassword);
const currentUserContext = await this.getContext(UMB_CURRENT_USER_CONTEXT);
const isCurrentUser = await currentUserContext.isUserCurrentUser(this.args.unique);
if (isCurrentUser) {
const repository = new UmbCurrentUserRepository(this);
await repository.changePassword(data.newPassword, data.oldPassword);
} else {
const repository = new UmbChangeUserPasswordRepository(this);
await repository.changePassword(this.args.unique, data.newPassword);
}
}
}

View File

@@ -0,0 +1,24 @@
import type { ManifestTypes, UmbBackofficeManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_USER_ENTITY_TYPE } from '@umbraco-cms/backoffice/user';
export const manifests: Array<ManifestTypes | UmbBackofficeManifestKind> = [
{
type: 'entityAction',
kind: 'default',
alias: 'Umb.EntityAction.User.ChangePassword',
name: 'Change User Password Entity Action',
weight: 600,
api: () => import('./change-user-password.action.js'),
forEntityTypes: [UMB_USER_ENTITY_TYPE],
meta: {
icon: 'icon-key',
label: '#user_changePassword',
},
},
{
type: 'modal',
alias: 'Umb.Modal.ChangePassword',
name: 'Change Password Modal',
js: () => import('./change-password-modal.element.js'),
},
];

View File

@@ -2,6 +2,8 @@ export * from './action/index.js';
export * from './components/index.js';
export * from './history/current-user-history.store.js';
export * from './utils/index.js';
export * from './repository/index.js';
export * from './current-user.context.js';
export * from './current-user.context.token.js';
export type * from './types.js';

View File

@@ -1,9 +1,9 @@
import { UMB_CURRENT_USER_CONTEXT } from '../current-user.context.token.js';
import { UmbCurrentUserRepository } from '../repository/index.js';
import { UmbActionBase } from '@umbraco-cms/backoffice/action';
import type { UmbCurrentUserAction, UmbCurrentUserActionArgs } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UMB_CHANGE_PASSWORD_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
export class UmbChangePasswordCurrentUserAction<ArgsMetaType = never>
extends UmbActionBase<UmbCurrentUserActionArgs<ArgsMetaType>>
implements UmbCurrentUserAction<ArgsMetaType>
@@ -32,13 +32,17 @@ export class UmbChangePasswordCurrentUserAction<ArgsMetaType = never>
if (!this.#unique) return;
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
modalManager.open(this, UMB_CHANGE_PASSWORD_MODAL, {
const modalContext = modalManager.open(this, UMB_CHANGE_PASSWORD_MODAL, {
data: {
user: {
unique: this.#unique,
},
},
});
const data = await modalContext.onSubmit();
const repository = new UmbCurrentUserRepository(this);
await repository.changePassword(data.newPassword, data.oldPassword);
}
}

View File

@@ -2,6 +2,8 @@ import { UmbCurrentUserServerDataSource } from './current-user.server.data-sourc
import { UMB_CURRENT_USER_STORE_CONTEXT } from './current-user.store.token.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
import type { UmbNotificationContext } from '@umbraco-cms/backoffice/notification';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
/**
* A repository for the current user
@@ -12,6 +14,7 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
#currentUserSource = new UmbCurrentUserServerDataSource(this._host);
#currentUserStore?: typeof UMB_CURRENT_USER_STORE_CONTEXT.TYPE;
#init: Promise<unknown>;
protected notificationContext?: UmbNotificationContext;
constructor(host: UmbControllerHost) {
super(host);
@@ -20,6 +23,10 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
this.consumeContext(UMB_CURRENT_USER_STORE_CONTEXT, (instance) => {
this.#currentUserStore = instance;
}).asPromise(),
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
this.notificationContext = instance;
}).asPromise(),
]);
}
@@ -108,6 +115,27 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase {
return {};
}
/**
* Change password for current user
* @param userId
* @param newPassword
* @param oldPassword
* @param isCurrentUser
* @returns
*/
async changePassword(newPassword: string, oldPassword: string) {
if (!newPassword) throw new Error('New password is missing');
if (!oldPassword) throw new Error('Old password is missing');
const { data, error } = await this.#currentUserSource.changePassword(newPassword, oldPassword);
if (!error) {
const notification = { data: { message: `Password changed` } };
this.notificationContext?.peek('positive', notification);
}
return { data, error };
}
}
export default UmbCurrentUserRepository;

View File

@@ -115,4 +115,24 @@ export class UmbCurrentUserServerDataSource {
return {};
}
/**
* Change the password for current user
* @param id
* @param newPassword
* @param oldPassword
* @param isCurrentUser
* @returns
*/
async changePassword(newPassword: string, oldPassword: string) {
return tryExecuteAndNotify(
this.#host,
UserService.postCurrentUserByIdChangePassword({
requestBody: {
newPassword,
oldPassword
},
}),
);
}
}

View File

@@ -1,4 +1,4 @@
export { UMB_CURRENT_USER_REPOSITORY_ALIAS } from './constants.js';
export { UMB_CURRENT_USER_STORE_CONTEXT } from './current-user.store.token.js';
export { UmbCurrentUserRepository } from './current-user.repository.js';
export { UmbCurrentUserStore } from './current-user.store.js';
export { UmbCurrentUserStore } from './current-user.store.js';

View File

@@ -3,7 +3,7 @@ import { manifests as userManifests } from './user/manifests.js';
import { manifests as userSectionManifests } from './user-section/manifests.js';
import { manifests as currentUserManifests } from './current-user/manifests.js';
import { manifests as userPermissionManifests } from './user-permission/manifests.js';
import { manifests as modalManifests } from './modals/manifests.js';
import { manifests as changePasswordManifests } from './change-password/manifests.js';
// We need to load any components that are not loaded by the user management bundle to register them in the browser.
import './user-group/components/index.js';
@@ -16,5 +16,5 @@ export const manifests = [
...userSectionManifests,
...currentUserManifests,
...userPermissionManifests,
...modalManifests,
...changePasswordManifests,
];

View File

@@ -1,12 +0,0 @@
import type { ManifestModal, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
const modals: Array<ManifestModal> = [
{
type: 'modal',
alias: 'Umb.Modal.ChangePassword',
name: 'Change Password Modal',
js: () => import('./change-password/change-password-modal.element.js'),
},
];
export const manifests: Array<ManifestTypes> = [...modals];

View File

@@ -55,19 +55,6 @@ const entityActions: Array<ManifestTypes> = [
},
],
},
{
type: 'entityAction',
kind: 'default',
alias: 'Umb.EntityAction.User.ChangePassword',
name: 'Change User Password Entity Action',
weight: 600,
api: () => import('./change-password/change-user-password.action.js'),
forEntityTypes: [UMB_USER_ENTITY_TYPE],
meta: {
icon: 'icon-key',
label: '#user_changePassword',
},
},
{
type: 'entityAction',
kind: 'default',

View File

@@ -33,7 +33,7 @@ export class UmbChangeUserPasswordServerDataSource {
UserService.postUserByIdChangePassword({
id,
requestBody: {
newPassword,
newPassword
},
}),
);