From 7f2d515763ea4810ac4d99ecb2080ff9a8f90d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 22 Aug 2025 13:46:01 +0200 Subject: [PATCH] Workspace view navigation context (#19255) * todos * navigation context * replace raw manifests with view context * Array State has method * rename to hint and much more * Notes for later * correcting one word * more notes * update JS Docs * update tests for getHasOne * fix context api usage * update code for v.16 * correct test * export UMB_WORKSPACE_VIEW_CONTEXT * minor corrections * rename to _hintMap * refactor part 1 * update version number in comment * clear method for array states * declare hint import map * mega refactor * final corrections for working POC * clean up path logic * implement scaffold * propagation and inheritance from view to workspace * separate types from classes * refactor to view context * rename editor navigation context to editor context * propagate removals * clean up notes * Hints for Content Tabs * use const path * handle gone parent * added comments on something to be looked at * hints context types * contentTypeMergedContainers * lint fixes * public contentTypeMergedContainers * refactor property structure helper class * a few notes for Presets * set variant ID instead of parsing it to the constructor * do not inject root to the path * adjust structure manager logic * UmbPropertyTypeContainerMergedModel type update * correct mergedContainersOfParentIdAndType * fix lint errors * fix missing import * Update src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../examples/workspace-view-hint/README.md | 3 + .../hint-workspace-view.ts | 84 ++++++ .../examples/workspace-view-hint/index.ts | 22 ++ src/Umbraco.Web.UI.Client/package.json | 2 + .../observable-api/states/array-state.test.ts | 7 + .../libs/observable-api/states/array-state.ts | 21 +- .../block-grid-manager.context-token.ts | 2 +- .../block-grid-entries.context-token.ts | 2 +- .../block-grid-entry.context-token.ts | 2 +- .../block-list-entries.context-token.ts | 2 +- .../block-list-manager.context-token.ts | 2 +- .../property-editor-ui-block-list.element.ts | 3 + .../block-rte-entries.context-token.ts | 2 +- ...-element-property-dataset.context-token.ts | 1 + ...nt-type-property-structure-helper.class.ts | 102 +------- .../content-type-structure-manager.class.ts | 167 +++++++++++- .../packages/content/content-type/types.ts | 11 + .../content-detail-workspace-base.ts | 17 ++ .../content-validation-to-hints.manager.ts | 112 ++++++++ .../content-workspace.context-token.ts | 3 +- .../views/edit/content-editor.element.ts | 89 +++++-- .../core/hint/context/hint.context-token.ts | 4 + .../core/hint/context/hints.context.ts | 15 ++ .../core/hint/context/hints.controller.ts | 241 ++++++++++++++++++ .../src/packages/core/hint/context/index.ts | 3 + .../src/packages/core/hint/index.ts | 2 + .../src/packages/core/hint/types.ts | 19 ++ ...u-tree-structure-workspace-context-base.ts | 4 +- .../validation/context/validation.context.ts | 1 - .../src/packages/core/view/context/index.ts | 2 + .../core/view/context/view.context-token.ts | 4 + .../core/view/context/view.context.ts | 79 ++++++ .../src/packages/core/view/index.ts | 1 + .../src/packages/core/vite.config.ts | 2 + .../components/workspace-editor/index.ts | 3 + .../workspace-editor.context-token.ts | 4 + .../workspace-editor.context.ts | 76 ++++++ .../workspace-editor.element.ts | 165 ++++++++---- .../workspace-view.context-token.ts | 9 + .../workspace-view.context.ts | 13 + .../workspace-split-view.context.ts | 35 ++- .../workspace-split-view.element.ts | 15 +- .../core/workspace/controllers/index.ts | 2 +- src/Umbraco.Web.UI.Client/tsconfig.json | 2 + 44 files changed, 1165 insertions(+), 192 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/examples/workspace-view-hint/README.md create mode 100644 src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts create mode 100644 src/Umbraco.Web.UI.Client/examples/workspace-view-hint/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/hint/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/view/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/README.md b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/README.md new file mode 100644 index 0000000000..37d60ccfba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/README.md @@ -0,0 +1,3 @@ +# Workspace View Badge Example + +This example demonstrates the essence of the Workspace View Navigation Context. And how to append a Status that will be displayed as a badge on the Workspace Views Tab Navigation. diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts new file mode 100644 index 0000000000..24a4d2ce03 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts @@ -0,0 +1,84 @@ +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 { UMB_WORKSPACE_VIEW_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/document'; + +@customElement('example-hint-workspace-view') +export class ExampleHintWorkspaceView extends UmbElementMixin(LitElement) { + // + + async onClick() { + /* + const context = await this.getContext(UMB_WORKSPACE_VIEW_NAVIGATION_CONTEXT); + if (!context) { + throw new Error('Could not find the context'); + } + const view = await context.getViewContext('example.workspaceView.hint'); + */ + + /* + const view = await this.getContext(UMB_WORKSPACE_VIEW_CONTEXT); + if (!view) { + throw new Error('Could not find the view'); + } + + if (view.hints.has('exampleHintFromToggleAction')) { + view.hints.removeOne('exampleHintFromToggleAction'); + } else { + view.hints.addOne({ + unique: 'exampleHintFromToggleAction', + text: 'Hi', + color: 'invalid', + weight: 100, + }); + } + */ + + const workspace = await this.getContext(UMB_DOCUMENT_WORKSPACE_CONTEXT); + if (!workspace) { + throw new Error('Could not find the workspace'); + } + + if (workspace.hints.has('exampleHintFromToggleAction')) { + workspace.hints.removeOne('exampleHintFromToggleAction'); + } else { + workspace.hints.addOne({ + unique: 'exampleHintFromToggleAction', + path: ['Umb.WorkspaceView.Document.Edit', 'root'], + text: 'Hi', + color: 'invalid', + weight: 100, + }); + } + } + + override render() { + return html` + +

See the hint on this views tab

+

This is toggle on/off via this button:

+ Toggle hint +
+ `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + padding: var(--uui-size-layout-1); + } + `, + ]; +} + +export { ExampleHintWorkspaceView as element }; + +declare global { + interface HTMLElementTagNameMap { + 'example-hint-workspace-view': ExampleHintWorkspaceView; + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/index.ts b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/index.ts new file mode 100644 index 0000000000..48f5d7a981 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/index.ts @@ -0,0 +1,22 @@ +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceView', + name: 'Example Badge Workspace View', + alias: 'example.workspaceView.hint', + element: () => import('./hint-workspace-view.js'), + weight: 900, + meta: { + label: 'View with badge', + pathname: 'badge', + icon: 'icon-lab', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: 'Umb.Workspace.Document', + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index f6eddc8453..d50f63f784 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -52,6 +52,7 @@ "./extension-registry": "./dist-cms/packages/core/extension-registry/index.js", "./health-check": "./dist-cms/packages/health-check/index.js", "./help": "./dist-cms/packages/help/index.js", + "./hint": "./dist-cms/packages/core/hint/index.js", "./http-client": "./dist-cms/packages/core/http-client/index.js", "./icon": "./dist-cms/packages/core/icon-registry/index.js", "./id": "./dist-cms/packages/core/id/index.js", @@ -117,6 +118,7 @@ "./utils": "./dist-cms/packages/core/utils/index.js", "./validation": "./dist-cms/packages/core/validation/index.js", "./variant": "./dist-cms/packages/core/variant/index.js", + "./view": "./dist-cms/packages/core/view/index.js", "./webhook": "./dist-cms/packages/webhook/index.js", "./workspace": "./dist-cms/packages/core/workspace/index.js", "./external/backend-api": "./dist-cms/packages/core/backend-api/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts index e2a4cdbe58..d9d5d1ad4f 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts @@ -44,6 +44,13 @@ describe('ArrayState', () => { }); }); + it('getHasOne method, return true when key exists', () => { + expect(subject.getHasOne('2')).to.be.true; + }); + it('getHasOne method, return false when key does not exists', () => { + expect(subject.getHasOne('1337')).to.be.false; + }); + it('filter method, removes anything that is not true of the given predicate method', (done) => { const expectedData = [initialData[0], initialData[2]]; diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts index a057b8bb15..26cf9261ab 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts @@ -48,10 +48,10 @@ export class UmbArrayState extends UmbDeepState { * @param {T} data - The next data for this state to hold. * @description - Set the data of this state, if sortBy has been defined for this state the data will be sorted before set. If data is different than current this will trigger observations to update. * @example Example change the data of a state - * const myState = new UmbArrayState('Good morning'); - * // myState.value is equal 'Good morning'. - * myState.setValue('Goodnight') - * // myState.value is equal 'Goodnight'. + * const myState = new UmbArrayState(['Good morning']); + * // myState.value is equal ['Good morning']. + * myState.setValue(['Goodnight']) + * // myState.value is equal ['Goodnight']. */ override setValue(value: T[]) { if (value && this.#sortMethod) { @@ -61,6 +61,19 @@ export class UmbArrayState extends UmbDeepState { } } + /** + * @function clear + * @description - Set the data of this state to an empty array. + * @example Example clearing the data of a state + * const myState = new UmbArrayState(['Good morning']); + * // myState.value is equal ['Good morning']. + * myState.clear() + * // myState.value is equal []. + */ + clear() { + super.setValue([]); + } + /** * @function getHasOne * @param {U} unique - the unique value to compare with. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/block-grid-manager/block-grid-manager.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/block-grid-manager/block-grid-manager.context-token.ts index a75bae703e..fc68fde793 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/block-grid-manager/block-grid-manager.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/block-grid-manager/block-grid-manager.context-token.ts @@ -1,7 +1,7 @@ import type { UmbBlockGridManagerContext } from './block-grid-manager.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.17) [NL] export const UMB_BLOCK_GRID_MANAGER_CONTEXT = new UmbContextToken< UmbBlockGridManagerContext, UmbBlockGridManagerContext diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.context-token.ts index 6b05007c46..13754ae34b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.context-token.ts @@ -1,5 +1,5 @@ import type { UmbBlockGridEntriesContext } from './block-grid-entries.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.17) [NL] export const UMB_BLOCK_GRID_ENTRIES_CONTEXT = new UmbContextToken('UmbBlockEntriesContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.context-token.ts index b5213a3c32..3c0bc679bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.context-token.ts @@ -1,5 +1,5 @@ import type { UmbBlockGridEntryContext } from './block-grid-entry.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.17) [NL] export const UMB_BLOCK_GRID_ENTRY_CONTEXT = new UmbContextToken('UmbBlockEntryContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context-token.ts index b477dc4685..f81b388cdf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context-token.ts @@ -1,5 +1,5 @@ import type { UmbBlockListEntriesContext } from './block-list-entries.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.17) [NL] export const UMB_BLOCK_LIST_ENTRIES_CONTEXT = new UmbContextToken('UmbBlockEntriesContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-manager.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-manager.context-token.ts index bde5ce5b25..43f4621c9f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-manager.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-manager.context-token.ts @@ -1,7 +1,7 @@ import type { UmbBlockListManagerContext } from './block-list-manager.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.16) export const UMB_BLOCK_LIST_MANAGER_CONTEXT = new UmbContextToken< UmbBlockListManagerContext, UmbBlockListManagerContext diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts index baeba6c44f..943d79c51d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts @@ -216,6 +216,7 @@ export class UmbPropertyEditorUIBlockListElement this.#gotPropertyContext(context); }); + // TODO: Why is this logic not part of the Block Grid and RTE Editors? [NL] // Observe Blocks and clean up validation messages for content/settings that are not in the block list anymore: this.observe( this.#managerContext.layouts, @@ -224,6 +225,7 @@ export class UmbPropertyEditorUIBlockListElement const contentKeys = layouts.map((x) => x.contentKey); this.#validationContext.messages.getMessagesOfPathAndDescendant('$.contentData').forEach((message) => { // get the KEY from this string: $.contentData[?(@.key == 'KEY')] + // TODO: Investigate if this is missing a part to just get the [] part of the path. Cause couldn't there be a sub path inside of this. [NL] const key = extractJsonQueryProps(message.path).key; if (key && contentKeys.indexOf(key) === -1) { validationMessagesToRemove.push(message.key); @@ -232,6 +234,7 @@ export class UmbPropertyEditorUIBlockListElement const settingsKeys = layouts.map((x) => x.settingsKey).filter((x) => x !== undefined) as string[]; this.#validationContext.messages.getMessagesOfPathAndDescendant('$.settingsData').forEach((message) => { + // TODO: Investigate if this is missing a part to just get the [] part of the path. Cause couldn't there be a sub path inside of this. [NL] // get the key from this string: $.settingsData[?(@.key == 'KEY')] const key = extractJsonQueryProps(message.path).key; if (key && settingsKeys.indexOf(key) === -1) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context-token.ts index bda7bbbaa5..296c2c89ae 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context-token.ts @@ -1,5 +1,5 @@ import type { UmbBlockRteEntriesContext } from './block-rte-entries.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -// TODO: Make discriminator method for this: +// TODO: Make discriminator method for this: (Aim to do this for v.17) [NL] export const UMB_BLOCK_RTE_ENTRIES_CONTEXT = new UmbContextToken('UmbBlockEntriesContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-property-dataset.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-property-dataset.context-token.ts index db5f957cb2..e7e3169c38 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-property-dataset.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-property-dataset.context-token.ts @@ -1,6 +1,7 @@ import type { UmbBlockElementPropertyDatasetContext } from './block-element-property-dataset.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +// TODO: Use a discriminator (Aim to do this for v.17) [NL] export const UMB_BLOCK_ELEMENT_PROPERTY_DATASET_CONTEXT = new UmbContextToken( 'UmbPropertyDatasetContext', ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts index 120b814814..10237041bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts @@ -1,13 +1,8 @@ -import type { - UmbContentTypeModel, - UmbPropertyContainerTypes, - UmbPropertyTypeContainerModel, - UmbPropertyTypeModel, -} from '../types.js'; +import type { UmbContentTypeModel, UmbPropertyTypeModel } from '../types.js'; import type { UmbContentTypeStructureManager } from './content-type-structure-manager.class.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbArrayState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; type UmbPropertyTypeUnique = UmbPropertyTypeModel['unique']; @@ -68,100 +63,19 @@ export class UmbContentTypePropertyStructureHelper; #observeContainers() { - if (!this.#structure || this.#containerId === undefined) return; - - if (this.#containerId === null) { - this.observe( - this.#structure.propertyStructuresOf(null), - (properties) => { - this.#propertyStructure.setValue(properties); - }, - 'observePropertyStructures', - ); - this.removeUmbControllerByAlias('_observeContainers'); - } else { - this.observe( - this.#structure.containerById(this.#containerId), - (container) => { - if (container) { - this._containerName = container.name ?? ''; - this._containerType = container.type; - if (container.parent) { - // We have a parent for our main container, so lets observe that one as well: [NL] - this.observe( - this.#structure!.containerById(container.parent.id), - (parent) => { - if (parent) { - this._parentName = parent.name ?? ''; - this._parentType = parent.type; - this.#observeSimilarContainers(); - } else { - this.removeUmbControllerByAlias('_observeContainers'); - this._parentName = undefined; - this._parentType = undefined; - } - }, - '_observeMainParentContainer', - ); - } else { - this.removeUmbControllerByAlias('_observeMainParentContainer'); - this._parentName = null; //In this way we want to look for one without a parent. [NL] - this._parentType = undefined; - this.#observeSimilarContainers(); - } - } else { - this.removeUmbControllerByAlias('_observeContainers'); - this._containerName = undefined; - this._containerType = undefined; - this.#propertyStructure.setValue([]); - } - }, - '_observeMainContainer', - ); - } - } - - #observeSimilarContainers() { - if (this._containerName === undefined || !this._containerType || this._parentName === undefined) return; this.observe( - this.#structure!.containersByNameAndTypeAndParent( - this._containerName, - this._containerType, - this._parentName, - this._parentType, - ), - (groupContainers) => { - if (this.#containers) { - // We want to remove properties of groups that does not exist anymore: [NL] - const goneGroupContainers = this.#containers.filter((x) => !groupContainers.some((y) => y.id === x.id)); - const _propertyStructure = this.#propertyStructure - .getValue() - .filter((x) => !goneGroupContainers.some((y) => y.id === x.container?.id)); - this.#propertyStructure.setValue(_propertyStructure); - } - + this.#containerId ? this.#structure?.mergedContainersOfId(this.#containerId) : undefined, + (container) => { this.observe( - mergeObservables( - groupContainers.map((group) => this.#structure!.propertyStructuresOf(group.id)), - (sources) => { - return sources.flatMap((x) => x); - }, - ), + container ? this.#structure?.propertyStructuresOfGroupIds(container.ids ?? []) : undefined, (properties) => { - this.#propertyStructure.setValue(properties); + this.#propertyStructure.setValue(properties ?? []); }, - 'observePropertyStructures', + 'observeProperties', ); - this.#containers = groupContainers; }, - '_observeContainers', + 'observeContainer', ); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts index d7708d7d87..192a36204a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts @@ -1,6 +1,7 @@ import type { UmbContentTypeModel, UmbPropertyContainerTypes, + UmbPropertyTypeContainerMergedModel, UmbPropertyTypeContainerModel, UmbPropertyTypeModel, } from '../types.js'; @@ -12,7 +13,7 @@ import { } from '@umbraco-cms/backoffice/repository'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { UmbControllerHost, UmbController } from '@umbraco-cms/backoffice/controller-api'; -import type { MappingFunction } from '@umbraco-cms/backoffice/observable-api'; +import type { MappingFunction, Observable } from '@umbraco-cms/backoffice/observable-api'; import { UmbArrayState, partialUpdateFrozenArray, @@ -28,6 +29,7 @@ import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-ap import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbError } from '@umbraco-cms/backoffice/resources'; +import { encodeFolderName } from '@umbraco-cms/backoffice/router'; type UmbPropertyTypeUnique = UmbPropertyTypeModel['unique']; @@ -733,6 +735,20 @@ export class UmbContentTypeStructureManager< }); } + propertyStructuresOfGroupIds(groupIds: Array) { + return this.#contentTypes.asObservablePart((docTypes) => { + const props: UmbPropertyTypeModel[] = []; + docTypes.forEach((docType) => { + docType.properties?.forEach((property) => { + if (property.container?.id && groupIds.includes(property.container.id)) { + props.push(property); + } + }); + }); + return props; + }); + } + rootContainers(containerType: UmbPropertyContainerTypes) { return createObservablePart(this.#contentTypeContainers, (data) => { return data.filter((x) => x.parent === null && x.type === containerType); @@ -773,8 +789,8 @@ export class UmbContentTypeStructureManager< ); } - isOwnerContainer(containerId: string) { - return this.getOwnerContentType()?.containers?.filter((x) => x.id === containerId); + isOwnerContainer(containerId: string): boolean | undefined { + return this.getOwnerContentType()?.containers?.some((x) => x.id === containerId); } containersOfParentId(parentId: string, containerType: UmbPropertyContainerTypes) { @@ -866,4 +882,149 @@ export class UmbContentTypeStructureManager< this.#contentTypes.destroy(); super.destroy(); } + + #mergedContainers: UmbPropertyTypeContainerMergedModel[] = []; + public readonly contentTypeMergedContainers = createObservablePart( + this.#contentTypeContainers, + (containers: UmbPropertyTypeContainerModel[]): UmbPropertyTypeContainerMergedModel[] => { + // Lookup map for containers + const containerByIdCache = new Map(); + for (const c of containers) { + containerByIdCache.set(c.id, c); + } + + // Cache to avoid recomputing parent chains + const chainCache = new Map>(); + + // Map to merge duplicates + const mergedMap = new Map(); + + for (const container of containers) { + const path = getContainerChainKey(container, containerByIdCache, chainCache); + const key = path?.join('|') ?? null; + if (!mergedMap.has(key)) { + // Store the first occurrence + mergedMap.set(key, { + key: key, + ids: [container.id], + ownerId: this.isOwnerContainer(container.id) ? container.id : undefined, + parentIds: new Set([container.parent?.id ?? null]), + path: path, + type: container.type, + name: container.name, + sortOrder: container.sortOrder, // Heavily assuming the first is the owner content type container, this could maybe turn out not always to be the case? + }); + } else { + // existing already then just add the id: + const existing = mergedMap.get(key)!; + existing.ids.push(container.id); + existing.parentIds.add(container.parent?.id ?? null); + existing.ownerId ??= this.isOwnerContainer(container.id) ? container.id : undefined; + } + } + + return (this.#mergedContainers = Array.from(mergedMap.values())); + }, + ); + + public mergedContainersOfId(id: string): Observable { + return createObservablePart(this.contentTypeMergedContainers, (mergedContainers) => { + return mergedContainers.find((x) => x.ids.includes(id)); + }); + } + + /** + * + * Find merged containers that match the provided container ids. + * Notice if you can provide one or more ids matching the same container and it will still only return return the matching container once. + * @param containerIds - An array of container ids to find merged containers for. + * @returns {Observable} - An observable that emits the merged containers that match the provided container ids. + */ + /* + public mergedContainersOfIds(searchIds: Array): Observable> { + return createObservablePart(this.contentTypeMergedContainers, (mergedContainers) => { + return mergedContainers.filter((x) => searchIds.some((id) => x.ids.includes(id))); + }); + } + */ + + /** + * + * Find merged containers that match the provided container ids. + * Notice if you can provide one or more ids matching the same container and it will still only return return the matching container once. + * @param containerIds - An array of container ids to find merged containers for. + * @returns {UmbPropertyTypeContainerMergedModel | undefined} - The merged containers that match the provided container ids. + */ + getMergedContainerById(id: string): UmbPropertyTypeContainerMergedModel | undefined { + return this.#mergedContainers.find((x) => x.ids.includes(id)); + } + + /** + * + * Find merged child containers that are children of the provided parent container ids. + * Notice this will find matching containers and include their child containers in this. + * @param containerIds - An array of container ids to find merged child containers for. + * @param type - The type of the containers to find. + * @returns {Observable} - An observable that emits the merged child containers that match the provided container ids. + */ + public mergedContainersOfParentIdAndType( + searchId: string | null, + type: UmbPropertyContainerTypes, + ): Observable> { + return createObservablePart(this.contentTypeMergedContainers, (mergedContainers) => { + // First find the path for the parentId, and then find matching children: + const parentIds = searchId ? (mergedContainers.find((x) => x.ids.includes(searchId))?.ids ?? []) : [null]; + return mergedContainers.filter((x) => x.type === type && parentIds.some((id) => x.parentIds.has(id))); + }); + } + + /** + * + * Find merged child containers that are children of one of the provided parent container ids. + * Notice if you can provide one or more ids matching the same parent and it will still only return return the matching child container once. + * @param containerIds - An array of container ids to find merged child containers for. + * @param type - The type of the containers to find. + * @returns {Observable} - An observable that emits the merged child containers that match the provided container ids. + */ + /* + public mergedContainersOfParentIds( + searchIds: Array, + type: UmbPropertyContainerTypes, + ): Observable> { + return createObservablePart(this.contentTypeMergedContainers, (mergedContainers) => { + return mergedContainers.filter((x) => x.type === type && searchIds.some((id) => x.parentIds.has(id))); + }); + } + */ +} + +// Get a unique key for a container including all parent type/name pairs +/** + * + * @param container + * @param containerById + * @param chainCache + */ +function getContainerChainKey( + container: UmbPropertyTypeContainerModel, + containerById: Map, + chainCache: Map>, +): Array { + if (chainCache.has(container.id)) { + return chainCache.get(container.id)!; + } + + // Notice this is made compatible with the path for the URL of the tab, making the match simpler in the other end. [NL] + let path = [`${container.type.toLowerCase()}/${encodeFolderName(container.name)}`]; + if (container.parent && containerById.has(container.parent.id)) { + const parent = containerById.get(container.parent.id)!; + path = [...getContainerChainKey(parent, containerById, chainCache), ...path]; + } else if (!container.parent && container.type === 'Group') { + // Append root to the containers with no parent. + //path.unshift(`root`); + // No that is not part of the responsibility of this one. [NL] + } + + chainCache.set(container.id, [...path]); + return path; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts index 4364ef2e5b..5d43b761bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts @@ -13,6 +13,17 @@ export interface UmbPropertyTypeContainerModel { type: UmbPropertyContainerTypes; sortOrder: number; } + +export interface UmbPropertyTypeContainerMergedModel { + key: string; + ids: Array; + ownerId?: string; + parentIds: Set; + path: Array; + name: string; + type: UmbPropertyContainerTypes; + sortOrder: number; +} /** * * @deprecated diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index 86750af6ff..e0d12dcd46 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -6,6 +6,7 @@ import type { UmbContentPropertyDatasetContext } from '../property-dataset-conte import type { UmbContentValidationRepository } from '../repository/content-validation-repository.interface.js'; import type { UmbContentWorkspaceContext } from './content-workspace-context.interface.js'; import { UmbContentDetailValidationPathTranslator } from './content-detail-validation-path-translator.js'; +import { UmbContentValidationToHintsManager } from './content-validation-to-hints.manager.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDetailRepository, UmbDetailRepositoryConstructor } from '@umbraco-cms/backoffice/repository'; import { @@ -54,6 +55,7 @@ import { type UmbPropertyTypePresetModelTypeModel, } from '@umbraco-cms/backoffice/property'; import { UmbSegmentCollectionRepository, type UmbSegmentCollectionItemModel } from '@umbraco-cms/backoffice/segment'; +import { UmbHintContext, type UmbVariantHint } from '@umbraco-cms/backoffice/hint'; export interface UmbContentDetailWorkspaceContextArgs< DetailModelType extends UmbContentDetailModel, @@ -138,6 +140,9 @@ export abstract class UmbContentDetailWorkspaceContextBase< /* Split View */ readonly splitView = new UmbWorkspaceSplitViewManager(); + /* Hints */ + readonly hints = new UmbHintContext(this); + /* Variant Options */ // TODO: Optimize this so it uses either a App Language Context? [NL] #languageRepository = new UmbLanguageCollectionRepository(this); @@ -205,6 +210,13 @@ export abstract class UmbContentDetailWorkspaceContextBase< x ? x.variesByCulture || x.variesBySegment : undefined, ); + new UmbContentValidationToHintsManager( + this, + this.structure, + this.validationContext, + this.hints, + ); + this.variantOptions = mergeObservables( [this.variesByCulture, this.variesBySegment, this.variants, this.languages, this._segments], ([variesByCulture, variesBySegment, variants, languages, segments]) => { @@ -366,12 +378,17 @@ export abstract class UmbContentDetailWorkspaceContextBase< // Load the content type structure, usually this comes from the data, but in this case we are making the data, and we need this to be able to complete the data. [NL] await this.structure.loadType((data as any)[this.#contentTypePropertyName].unique); + /** + * TODO: Should we also set Preset Values when loading Content, because maybe content contains uncreated Cultures or Segments. + */ + // Set culture and segment for all values: const cultures = this.#languages.getValue().map((x) => x.unique); if (this.structure.variesBySegment) { console.warn('Segments are not yet implemented for preset'); } + // TODO: Add Segments for Presets: const segments: Array | undefined = this.structure.variesBySegment ? [] : undefined; const repo = new UmbDataTypeDetailRepository(this); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts new file mode 100644 index 0000000000..8dcd90539b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-validation-to-hints.manager.ts @@ -0,0 +1,112 @@ +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { + UmbContentTypeModel, + UmbContentTypeStructureManager, + UmbPropertyTypeContainerMergedModel, +} from '@umbraco-cms/backoffice/content-type'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbHintController, UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import { extractJsonQueryProps, type UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +/* + * @internal + * @module UmbContentValidationToHintsManager + * @description + * This manager observes the content type structure and validation messages, converting them into hints. + * It is designed to be used in a content workspace to provide real-time feedback on content validation. + */ +export class UmbContentValidationToHintsManager< + ContentTypeDetailModelType extends UmbContentTypeModel = UmbContentTypeModel, +> extends UmbControllerBase { + /*workspace.hints.addOne({ + unique: 'exampleHintFromToggleAction', + path: ['Umb.WorkspaceView.Document.Edit', 'root'], + text: 'Hi', + color: 'invalid', + weight: 100, + }); + + TODO: + * Maintaine structural awareness of all Properties. + * Observe validation messages for all Properties, and turn them into Hints as fitting. + */ + + #hintedMsgs: Set = new Set(); + + #containers: Array = []; + + constructor( + host: UmbControllerHost, + structure: UmbContentTypeStructureManager, + validation: UmbValidationContext, + hints: UmbHintController, + ) { + super(host); + + this.observe(structure.contentTypeMergedContainers, (merged) => { + this.#containers = merged; + }); + + this.observe(validation.messages.messagesOfPathAndDescendant('$.values'), (messages) => { + messages.forEach((message) => { + if (this.#hintedMsgs.has(message.key)) return; + + // Get the value between [ and ] of message.path: + const query = getValueBetweenBrackets(message.path); + if (!query) return; + const queryProps = extractJsonQueryProps(query); + + const alias = queryProps.alias; + const variantId = UmbVariantId.CreateFromPartial(queryProps); + + structure.getPropertyStructureByAlias(alias).then((property) => { + if (!property) return; + + let path: Array = []; + if (property.container) { + const container = this.#containers.find((c) => c.ids.includes(property.container!.id)); + if (container) { + path = container.path; + } else { + throw new Error( + `Could not find the declared container of id "${property.container.id}" for property with alias: "${property.alias}"`, + ); + } + } + + hints.addOne({ + unique: message.key, + path: ['Umb.WorkspaceView.Document.Edit', ...path], + text: '!', + /*label: message.body,*/ + color: 'invalid', + weight: 1000, + variantId, + }); + this.#hintedMsgs.add(message.key); + }); + }); + this.#hintedMsgs.forEach((key) => { + if (!messages.some((msg) => msg.key === key)) { + this.#hintedMsgs.delete(key); + hints.removeOne(key); + } + }); + }); + } +} + +/** + * + * @param path {string} The path string to extract the value from. + */ +function getValueBetweenBrackets(path: string): string | null { + const start = path.indexOf('['); + if (start === -1) return null; + + const end = path.indexOf(']', start + 1); + if (end === -1) return null; + + return path.substring(start + 1, end); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-workspace.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-workspace.context-token.ts index cf2c295263..cd80d53266 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-workspace.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-workspace.context-token.ts @@ -7,5 +7,6 @@ export const UMB_CONTENT_WORKSPACE_CONTEXT = new UmbContextToken< >( 'UmbWorkspaceContext', undefined, - (context): context is UmbContentWorkspaceContext => (context as any).IS_CONTENT_WORKSPACE_CONTEXT, + (context): context is UmbContentWorkspaceContext => + (context as UmbContentWorkspaceContext).IS_CONTENT_WORKSPACE_CONTEXT, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index bac7610e64..dce11b6601 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -1,5 +1,5 @@ import type { UmbContentWorkspaceViewEditTabElement } from './content-editor-tab.element.js'; -import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbContentTypeModel, @@ -15,6 +15,8 @@ import { encodeFolderName } from '@umbraco-cms/backoffice/router'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; import './content-editor-tab.element.js'; +import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import { UMB_VIEW_CONTEXT, UmbViewContext } from '@umbraco-cms/backoffice/view'; @customElement('umb-content-workspace-view-edit') export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements UmbWorkspaceViewElement { @@ -23,6 +25,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements @state() private _hasRootProperties = false; */ + #viewContext?: UmbViewContext; @state() private _hasRootGroups = false; @@ -39,6 +42,11 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements @state() private _activePath = ''; + @state() + private _hintMap: Map = new Map(); + + #tabViewContexts: Array = []; + #structureManager?: UmbContentTypeStructureManager; private _tabsStructureHelper = new UmbContentTypeContainerStructureHelper(this); @@ -46,13 +54,20 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements constructor() { super(); + this.consumeContext(UMB_VIEW_CONTEXT, (context) => { + this.#viewContext = context; + this.#tabViewContexts.forEach((view) => { + view.inheritFrom(this.#viewContext); + }); + }); + this._tabsStructureHelper.setIsRoot(true); this._tabsStructureHelper.setContainerChildType('Tab'); this.observe( this._tabsStructureHelper.mergedContainers, (tabs) => { this._tabs = tabs; - this._createRoutes(); + this.#createRoutes(); }, null, ); @@ -73,13 +88,13 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements await this.#structureManager.hasRootContainers('Group'), (hasRootGroups) => { this._hasRootGroups = hasRootGroups; - this._createRoutes(); + this.#createRoutes(); }, '_observeGroups', ); } - private _createRoutes() { + #createRoutes() { if (!this._tabs || !this.#structureManager) return; const routes: UmbRoute[] = []; @@ -91,18 +106,21 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements (component as UmbContentWorkspaceViewEditTabElement).containerId = null; }, }); + this.#createViewContext('root'); } if (this._tabs.length > 0) { this._tabs?.forEach((tab) => { const tabName = tab.name ?? ''; + const path = `tab/${encodeFolderName(tabName)}`; routes.push({ - path: `tab/${encodeFolderName(tabName)}`, + path, component: () => import('./content-editor-tab.element.js'), setup: (component) => { (component as UmbContentWorkspaceViewEditTabElement).containerId = tab.id; }, }); + this.#createViewContext(path); }); } @@ -122,35 +140,45 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements this._routes = routes; } + #createViewContext(viewAlias: string) { + if (!this.#tabViewContexts.find((context) => context.viewAlias === viewAlias)) { + const view = new UmbViewContext(this, viewAlias); + this.#tabViewContexts.push(view); + + view.inheritFrom(this.#viewContext); + + this.observe( + view.firstHintOfVariant, + (hint) => { + if (hint) { + this._hintMap.set(viewAlias, hint); + } else { + this._hintMap.delete(viewAlias); + } + this.requestUpdate('_hintMap'); + }, + 'umbObserveState_' + viewAlias, + ); + } + } + override render() { if (!this._routes || !this._tabs) return; return html` ${this._routerPath && (this._tabs.length > 1 || (this._tabs.length === 1 && this._hasRootGroups)) ? html` - ${this._hasRootGroups && this._tabs.length > 0 - ? html` - - ` - : ''} + ${this._hasRootGroups && this._tabs.length > 0 ? this.#renderTab('root', '#general_generic') : nothing} ${repeat( this._tabs, (tab) => tab.name, (tab, index) => { - const path = this._routerPath + '/tab/' + encodeFolderName(tab.name || ''); - return html``; + const path = 'tab/' + encodeFolderName(tab.name || ''); + return this.#renderTab(path, tab.name, index); }, )} ` - : ''} + : nothing} ${hint && !active + ? html`${hint.text}` + : nothing}`; + } + static override styles = [ UmbTextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts new file mode 100644 index 0000000000..5649e78018 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbHintController } from './hints.controller.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_HINT_CONTEXT = new UmbContextToken('UmbHintContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts new file mode 100644 index 0000000000..87025e2717 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts @@ -0,0 +1,15 @@ +import type { UmbHint, UmbIncomingHintBase } from '../types.js'; +import { UMB_HINT_CONTEXT } from './hint.context-token.js'; +import { UmbHintController } from './hints.controller.js'; +import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbHintContext< + HintType extends UmbHint = UmbHint, + IncomingHintType extends UmbIncomingHintBase = UmbPartialSome, +> extends UmbHintController { + constructor(host: UmbControllerHost) { + super(host); + this.provideContext(UMB_HINT_CONTEXT, this as unknown as UmbHintContext); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts new file mode 100644 index 0000000000..d655c10ae3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts @@ -0,0 +1,241 @@ +import type { UmbPartialSome } from '../../utils/type/index.js'; +import type { UmbHint, UmbIncomingHintBase } from '../types.js'; +import { UMB_HINT_CONTEXT } from './hint.context-token.js'; +import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbArrayState, UmbObjectState, type Observable } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; + +export class UmbHintController< + HintType extends UmbHint = UmbHint, + IncomingHintType extends UmbIncomingHintBase = UmbPartialSome, +> extends UmbControllerBase { + // + #viewAlias?: string; + getViewAlias(): string | undefined { + return this.#viewAlias; + } + #scaffold = new UmbObjectState>({}); + readonly scaffold = this.#scaffold.asObservable(); + #inUnprovidingState?: boolean; + + #parent?: UmbHintController; + #parentHints?: Array; + + readonly #hints = new UmbArrayState([], (x) => x.unique); + public readonly hints = this.#hints.asObservable(); + public readonly firstHint = this.#hints.asObservablePart((x) => x[0]); + // Consider using weight to determine the visibility distance. [NL] + //public readonly hasHints = this._hints.asObservablePart((x) => x.length > 0); + + updateScaffold(updates: Partial) { + this.#scaffold.update(updates); + } + getScaffold(): Partial { + return this.#scaffold.getValue(); + } + + constructor(host: UmbControllerHost, args?: { viewAlias?: string; scaffold?: Partial }) { + super(host); + + this.#viewAlias = args?.viewAlias; + if (args?.scaffold) { + this.#scaffold.setValue(args?.scaffold); + } + + this.#hints.sortBy((a, b) => (b.weight || 0) - (a.weight || 0)); + } + + #providerCtrl?: UmbContextProviderController; + #currentProvideHost?: UmbClassInterface; + /** + * 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 {UmbClassInterface} controllerHost - The controller host to provide this validation context to. + */ + provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_HINT_CONTEXT, this as any); + } + + unprovide(): void { + if (this.#providerCtrl) { + // We need to set this in Unprovide state, so this context can be provided again later. + this.#inUnprovidingState = true; + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + this.#inUnprovidingState = false; + this.#currentProvideHost = undefined; + } + } + + asObservablePart(fn: (hints: HintType[]) => R): Observable { + return this.#hints.asObservablePart(fn); + } + + descendingHints(viewAlias?: string): Observable | undefined> { + if (viewAlias) { + return this.#hints.asObservablePart((hints) => { + return hints.filter((hint) => hint.path[0] === viewAlias); + }); + } else { + return this.hints; + } + } + + inherit(): void { + this.consumeContext(UMB_HINT_CONTEXT, (parent) => { + this.inheritFrom(parent); + }).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] + } + + inheritFrom(parent: UmbHintController | undefined): void { + if (this.#parent === parent) return; + this.#parent = parent; + this.observe(this.#parent?.scaffold, (scaffold) => { + if (scaffold) { + this.#scaffold.update(scaffold as any); + } + }); + this.observe(parent?.descendingHints(this.#viewAlias), this.#receiveHints, 'observeParentHints'); + this.observe(this.hints, this.#propagateHints, 'observeLocalMessages'); + } + + #receiveHints = (hints: UmbHint[] | undefined) => { + if (!hints) { + // Parent properly lost, so lets assume the parent hints are empty: [NL] + hints = []; + } + this.initiateChange(); + if (this.#parentHints) { + // Remove the local messages that does not exist in the parent anymore: + const toRemove = this.#parentHints.filter((hint) => !hints.find((m) => m.unique === hint.unique)); + this.remove(toRemove.map((hint) => hint.unique)); + } + this.#parentHints = hints; + + hints.forEach((hint) => { + // Remove first entry of hint.path, if it matches viewAlias. + if (this.#viewAlias && hint.path[0] === this.#viewAlias) { + hint = { ...hint, path: hint.path.slice(1) }; + } + this.#hints.appendOne(hint as HintType); + }); + + this.finishChange(); + }; + + #propagateHints = (hints: Array) => { + if (!this.#parent) return; + + this.#parent!.initiateChange(); + + const viewAlias = this.getViewAlias(); + + hints.forEach((hint) => { + let newPath = hint.path; + // If the hint path does not already contain the parent view alias as the first entry, we add it. (This will usually happen, but some Hint Contexts does not have a view alias as they) + if (viewAlias && newPath[0] !== viewAlias) { + newPath = [viewAlias, ...hint.path]; + } + this.#parent!.addOne({ ...hint, path: newPath }); + }); + + // Remove hints that are not in the local hints anymore: + const toRemove = this.#parentHints?.filter((hint) => !hints.find((m) => m.unique === hint.unique)); + if (toRemove) { + this.#parent!.remove(toRemove.map((hint) => hint.unique)); + } + + this.#parent!.finishChange(); + }; + + initiateChange() { + this.#hints.mute(); + } + finishChange() { + this.#hints.unmute(); + } + + /** + * Add a new hint + * @param {HintType} hint - The hint to add + * @returns {HintType['unique']} Unique value of the hint + */ + addOne(hint: IncomingHintType): string | symbol { + const newHint = { ...this.#scaffold.getValue(), ...hint } as unknown as HintType; + newHint.unique ??= Symbol(); + newHint.weight ??= 0; + newHint.text ??= '!'; + newHint.path ??= []; + this.#hints.appendOne(newHint); + return hint.unique!; + } + + /** + * Add multiple rules + * @param {HintType[]} hints - Array of hints to add + */ + add(hints: IncomingHintType[]) { + this.#hints.mute(); + hints.forEach((hint) => this.addOne(hint)); + this.#hints.unmute(); + } + + /** + * Remove a hint + * @param {HintType['unique']} unique Unique value of the hint to remove + */ + removeOne(unique: HintType['unique']) { + this.#hints.removeOne(unique); + } + + /** + * Remove multiple hints + * @param {HintType['unique'][]} uniques Array of unique values to remove + */ + remove(uniques: HintType['unique'][]) { + this.#hints.remove(uniques); + } + + /** + * Check if a hint exists + * @param {HintType['unique']} unique Unique value of the hint to check + * @returns {boolean} True if the hint exists, false otherwise + */ + has(unique: HintType['unique']): boolean { + return this.#hints.getHasOne(unique); + } + + /** + * Get all hints + * @returns {HintType[]} Array of hints + */ + getAll(): HintType[] { + return this.#hints.getValue(); + } + + /** + * Clear all hints + */ + clear(): void { + this.#hints.setValue([]); + } + + override destroy(): void { + super.destroy(); + if (this.#inUnprovidingState === true) { + return; + } + this.unprovide(); + this.#parentHints = undefined; + this.#parent = undefined; + + this.#hints.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts new file mode 100644 index 0000000000..9523595c70 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts @@ -0,0 +1,3 @@ +export * from './hint.context-token.js'; +export * from './hints.context.js'; +export * from './hints.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/index.ts new file mode 100644 index 0000000000..66e7bbbc85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/index.ts @@ -0,0 +1,2 @@ +export * from './context/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/types.ts new file mode 100644 index 0000000000..6eef3a7529 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/types.ts @@ -0,0 +1,19 @@ +import type { UUIInterfaceColor } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +export interface UmbIncomingHintBase { + unique?: string | symbol; + text: string; + weight?: number; + color?: UUIInterfaceColor; +} + +export interface UmbHint extends UmbIncomingHintBase { + unique: string | symbol; + path: Array; + weight: number; +} + +export interface UmbVariantHint extends UmbHint { + variantId?: UmbVariantId; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts index 5ebd0dc2ae..21df3dcb27 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts @@ -103,7 +103,7 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex } #setParentData(structureItems: Array) { - /* If the item is not new, the current item is the last item in the array. + /* If the item is not new, the current item is the last item in the array. We filter out the current item unique to handle any case where it could show up */ const parent = structureItems.filter((item) => item.unique !== this.#workspaceContext?.getUnique()).pop(); @@ -133,7 +133,7 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex return entity; }) - /* If the item is not new, the current item is the last item in the array. + /* If the item is not new, the current item is the last item in the array. We filter out the current item unique to handle any case where it could show up */ .filter((item) => item.unique !== this.#workspaceContext?.getUnique()); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts index 6deed36696..8bd9647dd8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation.context.ts @@ -9,7 +9,6 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; */ export class UmbValidationContext extends UmbValidationController { constructor(host: UmbControllerHost) { - // This is overridden to avoid setting a controllerAlias, this might make sense, but currently i want to leave it out. [NL] super(host); this.provideContext(UMB_VALIDATION_CONTEXT, this); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts new file mode 100644 index 0000000000..3fff04337f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts @@ -0,0 +1,2 @@ +export * from './view.context.js'; +export * from './view.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context-token.ts new file mode 100644 index 0000000000..efbb4b8c53 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbViewContext } from './view.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_VIEW_CONTEXT = new UmbContextToken('UmbViewContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts new file mode 100644 index 0000000000..fdd124adca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts @@ -0,0 +1,79 @@ +import { UMB_VIEW_CONTEXT } from './view.context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import { UmbClassState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UmbHintController, type UmbVariantHint } from '@umbraco-cms/backoffice/hint'; + +/** + * + * TODO: + * Include Shortcuts + * + * Browser Title? + * + */ +export class UmbViewContext extends UmbControllerBase { + // + #providerCtrl: any; + #currentProvideHost?: UmbClassInterface; + + public readonly viewAlias: string; + #variantId = new UmbClassState(undefined); + protected readonly variantId = this.#variantId.asObservable(); + + public hints; + + readonly firstHintOfVariant; + + constructor(host: UmbControllerHost, viewAlias: string) { + super(host); + this.viewAlias = viewAlias; + this.hints = new UmbHintController(this, { + viewAlias: viewAlias, + }); + this.firstHintOfVariant = mergeObservables([this.variantId, this.hints.hints], ([variantId, hints]) => { + if (variantId) { + return hints.find((hint) => + hint.variantId ? hint.variantId.equal(variantId!) || hint.variantId.isInvariant() : true, + ); + } else { + return hints[0]; + } + }); + } + + setVariantId(variantId: UmbVariantId | undefined): void { + this.#variantId.setValue(variantId); + this.hints.updateScaffold({ variantId: variantId }); + } + + provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); + this.hints.provideAt(controllerHost); + } + + unprovide(): void { + if (this.#providerCtrl) { + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + } + this.hints.unprovide(); + } + + inheritFrom(context?: UmbViewContext): void { + this.observe( + context?.variantId, + (variantId) => { + this.setVariantId(variantId); + }, + 'observeParentVariantId', + ); + this.hints.inheritFrom(context?.hints); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/index.ts new file mode 100644 index 0000000000..00c55032bc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/index.ts @@ -0,0 +1 @@ +export * from './context/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 45570f2893..6df35d3cd1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ 'event/index': './event/index.ts', 'extension-registry/index': './extension-registry/index.ts', 'http-client/index': './http-client/index.ts', + 'hint/index': './hint/index.ts', 'icon-registry/index': './icon-registry/index.ts', 'id/index': './id/index.ts', 'lit-element/index': './lit-element/index.ts', @@ -62,6 +63,7 @@ export default defineConfig({ 'utils/index': './utils/index.ts', 'validation/index': './validation/index.ts', 'variant/index': './variant/index.ts', + 'view/index': './view/index.ts', 'workspace/index': './workspace/index.ts', manifests: 'manifests.ts', }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/index.ts index c372e7e902..8c72e0ff13 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/index.ts @@ -1 +1,4 @@ export * from './workspace-editor.element.js'; +export * from './workspace-editor.context-token.js'; +export * from './workspace-editor.context.js'; +export * from './workspace-view.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context-token.ts new file mode 100644 index 0000000000..fb6638a93e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbWorkspaceEditorContext } from './workspace-editor.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_WORKSPACE_EDITOR_CONTEXT = new UmbContextToken('UmbWorkspaceViewContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts new file mode 100644 index 0000000000..dd916b5a2e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts @@ -0,0 +1,76 @@ +import { UmbWorkspaceViewContext } from './workspace-view.context.js'; +import { UMB_WORKSPACE_EDITOR_CONTEXT } from './workspace-editor.context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbBasicState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UmbHintController, type UmbVariantHint } from '@umbraco-cms/backoffice/hint'; + +export class UmbWorkspaceEditorContext extends UmbContextBase { + // + #init: Promise; + /** + * State holding the permitted Workspace Views as a Workspace View Context + */ + #views = new UmbBasicState(>[]); + public readonly views = this.#views.asObservable(); + + #variantId?: UmbVariantId; + #hints = new UmbHintController(this, {}); + + constructor(host: UmbControllerHost) { + super(host, UMB_WORKSPACE_EDITOR_CONTEXT); + + this.#hints.inherit(); + + this.#init = new UmbExtensionsManifestInitializer( + this, + umbExtensionsRegistry, + 'workspaceView', + null, + (workspaceViews) => { + const oldViews = this.#views.getValue(); + + // remove ones that are no longer contained in the workspaceViews (And thereby make the new array): + const viewsToKeep = oldViews.filter( + (view) => !workspaceViews.some((x) => x.manifest.alias === view.manifest.alias), + ); + + const diff = viewsToKeep.length !== workspaceViews.length; + + if (diff) { + const newViews = [...viewsToKeep]; + + // Add ones that are new: + workspaceViews + .filter((view) => !viewsToKeep.some((x) => x.manifest.alias === view.manifest.alias)) + .forEach((view) => { + const context = new UmbWorkspaceViewContext(this, view.manifest); + context.setVariantId(this.#variantId); + context.hints.inheritFrom(this.#hints); + newViews.push(context); + }); + + this.#views.setValue(newViews); + } + }, + 'initViewApis', + {}, + ).asPromise(); + } + + setVariantId(variantId: UmbVariantId | undefined): void { + this.#variantId = variantId; + this.#hints.updateScaffold({ variantId }); + this.#views.getValue().forEach((view) => { + view.hints.updateScaffold({ variantId }); + }); + } + + async getViewContext(alias: string): Promise { + await this.#init; + return this.#views.getValue().find((view) => view.manifest.alias === alias); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index f55bcd8c89..499278f5d1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -1,26 +1,32 @@ -import type { ManifestWorkspaceView } from '../../extensions/types.js'; import { UMB_WORKSPACE_VIEW_PATH_PATTERN } from '../../paths.js'; +import { UmbWorkspaceEditorContext } from './workspace-editor.context.js'; +import type { UmbWorkspaceViewContext } from './workspace-view.context.js'; import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { createExtensionElement, UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { createExtensionElement } from '@umbraco-cms/backoffice/extension-api'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbRoute, UmbRouterSlotInitEvent, UmbRouterSlotChangeEvent } from '@umbraco-cms/backoffice/router'; +import type { UmbObserverController } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; /** * @element umb-workspace-editor * @description - * @slot icon - Slot for icon * @slot header - Slot for workspace header - * @slot name - Slot for name - * @slot footer - Slot for workspace footer + * @slot action-menu - Slot for workspace header + * @slot footer-info - Slot for workspace footer * @slot actions - Slot for workspace footer actions * @slot - slot for main content - * @class UmbWorkspaceEditor + * @class UmbWorkspaceEditorElement * @augments {UmbLitElement} */ @customElement('umb-workspace-editor') export class UmbWorkspaceEditorElement extends UmbLitElement { + // + #navigationContext = new UmbWorkspaceEditorContext(this); + #workspaceViewHintObservers: Array = []; + @property() public headline = ''; @@ -36,8 +42,25 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { @property({ type: Boolean }) public loading = false; + @property({ attribute: false }) + public get variantId(): UmbVariantId | undefined { + return this._variantId; + } + public set variantId(value: UmbVariantId | undefined) { + if (value && this._variantId?.equal(value)) { + return; + } + this._variantId = value; + this.#navigationContext.setVariantId(value); + this.#observeWorkspaceViewHints(); + } + private _variantId?: UmbVariantId | undefined; + @state() - private _workspaceViews: Array = []; + private _workspaceViews: Array = []; + + @state() + private _hintMap: Map = new Map(); @state() private _routes?: UmbRoute[]; @@ -50,24 +73,49 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { constructor() { super(); - - new UmbExtensionsManifestInitializer(this, umbExtensionsRegistry, 'workspaceView', null, (workspaceViews) => { - this._workspaceViews = workspaceViews.map((view) => view.manifest); - this._createRoutes(); - }); + this.observe( + this.#navigationContext.views, + (views) => { + this._workspaceViews = views; + this.#observeWorkspaceViewHints(); + this.#createRoutes(); + }, + null, + ); } - private _createRoutes() { + #observeWorkspaceViewHints() { + this.#workspaceViewHintObservers.forEach((observer) => observer.destroy()); + this._hintMap = new Map(); + this.#workspaceViewHintObservers = this._workspaceViews.map((view, index) => + this.observe( + view.firstHintOfVariant, + (hint) => { + if (hint) { + this._hintMap.set(view.manifest.alias, hint); + } else { + this._hintMap.delete(view.manifest.alias); + } + this.requestUpdate('_hintMap'); + }, + 'umbObserveState_' + index, + ), + ); + } + + #createRoutes() { let newRoutes: UmbRoute[] = []; if (this._workspaceViews.length > 0) { - newRoutes = this._workspaceViews.map((manifest) => { + newRoutes = this._workspaceViews.map((context) => { + const manifest = context.manifest; return { path: UMB_WORKSPACE_VIEW_PATH_PATTERN.generateLocal({ viewPathname: manifest.meta.pathname }), component: () => createExtensionElement(manifest), - setup: (component) => { + setup: (component?: any) => { if (component) { - (component as any).manifest = manifest; + context.provideAt(component); + component.manifest = manifest; } }, }; @@ -88,24 +136,26 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { } override render() { - return html` - - ${this.#renderBackButton()} - - - ${this.#renderViews()} ${this.#renderRoutes()} - - ${when( - !this.enforceNoFooter, - () => html` - - - - - `, - )} - - `; + return this._routes + ? html` + + ${this.#renderBackButton()} + + + ${this.#renderViews()} ${this.#renderRoutes()} + + ${when( + !this.enforceNoFooter, + () => html` + + + + + `, + )} + + ` + : nothing; } #renderViews() { @@ -115,20 +165,31 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { ${repeat( this._workspaceViews, - (view) => view.alias, - (view, index) => + (view) => view.manifest.alias, + (view, index) => { + const manifest = view.manifest; + const displayName = manifest.meta.label ? this.localize.string(manifest.meta.label) : manifest.name; + const hint = this._hintMap.get(manifest.alias); + const active = + 'view/' + manifest.meta.pathname === this._activePath || (index === 0 && this._activePath === ''); // Notice how we use index 0 to determine which workspace that is active with empty path. [NL] - html` + return html` - - ${view.meta.label ? this.localize.string(view.meta.label) : view.name} + href="${this._routerPath}/view/${manifest.meta.pathname}" + .label=${displayName} + ?active=${active} + data-mark="workspace:view-link:${manifest.alias}"> +
+ ${hint && !active + ? html`${hint.text}` + : nothing} +
+ ${displayName}
- `, + `; + }, )}
` @@ -196,6 +257,18 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { border-right: 1px solid var(--uui-color-border); } + div[slot='icon'] { + position: relative; + } + + uui-badge { + position: absolute; + font-size: var(--uui-type-small-size); + top: -0.5em; + right: auto; + left: calc(50% + 0.8em); + } + umb-extension-slot[slot='actions'] { display: flex; gap: var(--uui-size-space-2); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context-token.ts new file mode 100644 index 0000000000..fa8d595d80 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context-token.ts @@ -0,0 +1,9 @@ +import type { UmbWorkspaceViewContext } from './workspace-view.context.js'; +import type { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_WORKSPACE_VIEW_CONTEXT = new UmbContextToken( + 'UmbViewContext', + undefined, + (context): context is UmbWorkspaceViewContext => (context as UmbWorkspaceViewContext).IS_WORKSPACE_VIEW_CONTEXT, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts new file mode 100644 index 0000000000..a268bde27f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts @@ -0,0 +1,13 @@ +import type { ManifestWorkspaceView } from '../../types.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +export class UmbWorkspaceViewContext extends UmbViewContext { + public readonly IS_WORKSPACE_VIEW_CONTEXT = true as const; + + public manifest: ManifestWorkspaceView; + + constructor(host: UmbControllerHost, manifest: ManifestWorkspaceView) { + super(host, manifest.alias); + this.manifest = manifest; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.context.ts index 6ff5ac73e4..eb40274c91 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.context.ts @@ -2,7 +2,7 @@ import { UMB_VARIANT_WORKSPACE_CONTEXT } from '../../contexts/index.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import { UmbBooleanState, UmbNumberState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbBooleanState, UmbClassState, UmbNumberState } from '@umbraco-cms/backoffice/observable-api'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property'; import { UMB_MARK_ATTRIBUTE_NAME } from '@umbraco-cms/backoffice/const'; @@ -24,21 +24,25 @@ export class UmbWorkspaceSplitViewContext extends UmbContextBase { #isNew = new UmbBooleanState(undefined); isNew = this.#isNew.asObservable(); - //#variantId = new UmbClassState(undefined); - //variantId = this.#variantId.asObservable(); + #variantId = new UmbClassState(undefined); + variantId = this.#variantId.asObservable(); constructor(host: UmbControllerHost) { super(host, UMB_WORKSPACE_SPLIT_VIEW_CONTEXT); this.consumeContext(UMB_VARIANT_WORKSPACE_CONTEXT, (context) => { this.#workspaceContext = context; - this._observeVariant(); + this.#observeVariant(); this.#observeIsNew(); }); - this.observe(this.index, () => { - this._observeVariant(); - }); + this.observe( + this.index, + () => { + this.#observeVariant(); + }, + null, + ); } #observeIsNew() { @@ -51,7 +55,7 @@ export class UmbWorkspaceSplitViewContext extends UmbContextBase { ); } - private _observeVariant() { + #observeVariant() { if (!this.#workspaceContext) return; const index = this.#index.getValue(); @@ -67,6 +71,7 @@ export class UmbWorkspaceSplitViewContext extends UmbContextBase { this.#datasetContext?.destroy(); const variantId = UmbVariantId.Create(activeVariantInfo); + this.#variantId.setValue(variantId); const validationContext = this.#workspaceContext?.getVariantValidationContext(variantId); if (validationContext) { @@ -109,11 +114,17 @@ export class UmbWorkspaceSplitViewContext extends UmbContextBase { * concept this class could have methods to set and get the culture and segment of the active variant? just by using the index. */ - /* - public destroy(): void { - + public override destroy(): void { + this.#isNew.destroy(); + this.#variantId.destroy(); + this.#index.destroy(); + this.#variantVariantValidationContext?.unprovide(); + this.#datasetContext?.destroy(); + this.#workspaceContext = undefined; + this.#variantVariantValidationContext = undefined; + this.#datasetContext = undefined; + super.destroy(); } - */ } export const UMB_WORKSPACE_SPLIT_VIEW_CONTEXT = new UmbContextToken( diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.element.ts index 25b0c70f8d..86cf1f63bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view.element.ts @@ -14,6 +14,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; // import local components import './workspace-split-view-variant-selector.element.js'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; /** * @@ -43,6 +44,9 @@ export class UmbWorkspaceSplitViewElement extends UmbLitElement { @state() private _isNew = false; + @state() + private _variantId?: UmbVariantId; + splitViewContext = new UmbWorkspaceSplitViewContext(this); #onVariantSelectorSlotChanged(e: Event) { @@ -57,7 +61,15 @@ export class UmbWorkspaceSplitViewElement extends UmbLitElement { (isNew) => { this._isNew = isNew ?? false; }, - 'umbObserveIsNew', + null, + ); + + this.observe( + this.splitViewContext.variantId, + (variantId) => { + this._variantId = variantId; + }, + null, ); } @@ -66,6 +78,7 @@ export class UmbWorkspaceSplitViewElement extends UmbLitElement { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/index.ts index fce8ba5e87..3efe051619 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/index.ts @@ -1,3 +1,3 @@ export * from './workspace-is-new-redirect.controller.js'; -export * from './workspace-split-view-manager.controller.js'; export * from './workspace-route-manager.controller.js'; +export * from './workspace-split-view-manager.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 88e278fb2d..c2ec31a43d 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -81,6 +81,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/extension-registry": ["./src/packages/core/extension-registry/index.ts"], "@umbraco-cms/backoffice/health-check": ["./src/packages/health-check/index.ts"], "@umbraco-cms/backoffice/help": ["./src/packages/help/index.ts"], + "@umbraco-cms/backoffice/hint": ["./src/packages/core/hint/index.ts"], "@umbraco-cms/backoffice/http-client": ["./src/packages/core/http-client/index.ts"], "@umbraco-cms/backoffice/icon": ["./src/packages/core/icon-registry/index.ts"], "@umbraco-cms/backoffice/id": ["./src/packages/core/id/index.ts"], @@ -146,6 +147,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/utils": ["./src/packages/core/utils/index.ts"], "@umbraco-cms/backoffice/validation": ["./src/packages/core/validation/index.ts"], "@umbraco-cms/backoffice/variant": ["./src/packages/core/variant/index.ts"], + "@umbraco-cms/backoffice/view": ["./src/packages/core/view/index.ts"], "@umbraco-cms/backoffice/webhook": ["./src/packages/webhook/index.ts"], "@umbraco-cms/backoffice/workspace": ["./src/packages/core/workspace/index.ts"], "@umbraco-cms/backoffice/external/backend-api": ["./src/packages/core/backend-api/index.ts"],