diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 24a725065648..d42c1dab93fb 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -4279,6 +4279,10 @@ "resolved": "src/packages/block", "link": true }, + "node_modules/@umbraco-backoffice/clipboard": { + "resolved": "src/packages/clipboard", + "link": true + }, "node_modules/@umbraco-backoffice/code-editor": { "resolved": "src/packages/code-editor", "link": true @@ -17498,6 +17502,9 @@ "src/packages/block": { "name": "@umbraco-backoffice/block" }, + "src/packages/clipboard": { + "name": "@umbraco-backoffice/clipboard" + }, "src/packages/code-editor": { "name": "@umbraco-backoffice/code-editor" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 5185fef37bd5..ed634f045696 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -25,6 +25,7 @@ "./block-rte": "./dist-cms/packages/block/block-rte/index.js", "./block-type": "./dist-cms/packages/block/block-type/index.js", "./block": "./dist-cms/packages/block/block/index.js", + "./clipboard": "./dist-cms/packages/clipboard/index.js", "./code-editor": "./dist-cms/packages/code-editor/index.js", "./collection": "./dist-cms/packages/core/collection/index.js", "./components": "./dist-cms/packages/core/components/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts index ab39e5545ef2..b918aa3860c9 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts @@ -13,6 +13,7 @@ import './components/index.js'; const CORE_PACKAGES = [ import('../../packages/block/umbraco-package.js'), + import('../../packages/clipboard/umbraco-package.js'), import('../../packages/code-editor/umbraco-package.js'), import('../../packages/data-type/umbraco-package.js'), import('../../packages/dictionary/umbraco-package.js'), diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts index 0d53be4b2666..037f2e7f5b2b 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts @@ -163,6 +163,11 @@ export class UmbExtensionRegistry< return !this.#exclusions.includes(ext.alias); }; + /** + * Register an extension. + * @param {(ManifestTypes | ManifestKind)} manifest - The extension to register. + * @memberof UmbExtensionRegistry + */ register(manifest: ManifestTypes | ManifestKind): void { const isValid = this.#validateExtension(manifest); if (!isValid) { @@ -185,19 +190,39 @@ export class UmbExtensionRegistry< ]); } + /** + * Get all registered extensions. + * @returns {Array} - All registered extensions. + * @memberof UmbExtensionRegistry + */ getAllExtensions(): Array { return this._extensions.getValue(); } + /** + * Register many extensions. + * @param {(Array>)} manifests - The extensions to register. + * @memberof UmbExtensionRegistry + */ registerMany(manifests: Array>): void { // we have to register extensions individually, so we ensure a manifest is valid before continuing to the next one manifests.forEach((manifest) => this.register(manifest)); } + /** + * Unregister many extensions with the given aliases. + * @param {Array} aliases - The aliases of the extensions to unregister. + * @memberof UmbExtensionRegistry + */ unregisterMany(aliases: Array): void { aliases.forEach((alias) => this.unregister(alias)); } + /** + * Unregister an extension with the given alias. + * @param {string} alias - The alias of the extension to unregister. + * @memberof UmbExtensionRegistry + */ unregister(alias: string): void { const newKindsValues = this._kinds.getValue().filter((kind) => kind.alias !== alias); const newExtensionsValues = this._extensions.getValue().filter((extension) => extension.alias !== alias); @@ -206,6 +231,12 @@ export class UmbExtensionRegistry< this._extensions.setValue(newExtensionsValues); } + /** + * Check if an extension with the given alias is registered. + * @param {string} alias - The alias of the extension to check. + * @returns {boolean} - true if an extension with the given alias is registered. + * @memberof UmbExtensionRegistry + */ isRegistered(alias: string): boolean { if (this._extensions.getValue().find((ext) => ext.alias === alias)) { return true; @@ -218,6 +249,15 @@ export class UmbExtensionRegistry< return false; } + /** + * Clears all extensions and kinds from the registry. + * @memberof UmbExtensionRegistry + */ + clear(): void { + this._extensions.setValue([]); + this._kinds.setValue([]); + } + #validateExtension(manifest: ManifestTypes | ManifestKind): boolean { if (!manifest.type) { console.error(`Extension is missing type`, manifest); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.test.ts new file mode 100644 index 000000000000..8f34c4aa0f55 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.test.ts @@ -0,0 +1,85 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import { UmbBlockGridToBlockClipboardCopyPropertyValueTranslator } from './block-grid-to-block-copy-translator.js'; +import type { UmbBlockGridValueModel } from '../../../types.js'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockListToBlockClipboardCopyPropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let copyTranslator: UmbBlockGridToBlockClipboardCopyPropertyValueTranslator; + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + columnSpan: 12, + rowSpan: 1, + areas: [], + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [ + { + contentKey: 'contentKey', + culture: null, + segment: null, + }, + ], + }; + + const blockClipboardEntryValue: UmbBlockClipboardEntryValueModel = { + contentData: blockGridPropertyValue.contentData, + layout: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + settingsData: blockGridPropertyValue.settingsData, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + copyTranslator = new UmbBlockGridToBlockClipboardCopyPropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(copyTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('returns the block clipboard entry value', async () => { + const result = await copyTranslator.translate(blockGridPropertyValue); + expect(result).to.deep.equal(blockClipboardEntryValue); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.ts new file mode 100644 index 000000000000..39da64181f38 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/copy/block-grid-to-block-copy-translator.ts @@ -0,0 +1,56 @@ +import type { UmbBlockGridValueModel } from '../../../types.js'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbGridBlockClipboardEntryValueModel } from '../../types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardCopyPropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; + +export class UmbBlockGridToBlockClipboardCopyPropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + /** + * Translates a Block Grid property value to a Block clipboard entry value. + * @param {UmbBlockGridValueModel} propertyValue - The Block Grid property value. + * @returns {Promise} - The Block clipboard entry value. + * @memberof UmbBlockGridToBlockClipboardCopyPropertyValueTranslator + */ + async translate(propertyValue: UmbBlockGridValueModel): Promise { + if (!propertyValue) { + throw new Error('Property value is missing.'); + } + + return this.#constructBlockValue(propertyValue); + } + + #constructGridBlockValue(propertyValue: UmbBlockGridValueModel): UmbGridBlockClipboardEntryValueModel { + const valueClone = structuredClone(propertyValue); + + const gridBlockValue: UmbGridBlockClipboardEntryValueModel = { + contentData: valueClone.contentData, + layout: valueClone.layout?.[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS] ?? undefined, + settingsData: valueClone.settingsData, + }; + + return gridBlockValue; + } + + #constructBlockValue(propertyValue: UmbBlockGridValueModel): UmbBlockClipboardEntryValueModel { + const gridBlockValue = this.#constructGridBlockValue(propertyValue); + + const layout: UmbBlockClipboardEntryValueModel['layout'] = gridBlockValue.layout?.map((gridLayout) => { + return { + contentKey: gridLayout.contentKey, + settingsKey: gridLayout.settingsKey, + }; + }); + + return { + contentData: gridBlockValue.contentData, + layout: layout, + settingsData: gridBlockValue.settingsData, + }; + } +} + +export { UmbBlockGridToBlockClipboardCopyPropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/manifests.ts new file mode 100644 index 000000000000..952b053f4ee1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/manifests.ts @@ -0,0 +1,22 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS } from '../../property-editors/constants.js'; +import { UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE } from '@umbraco-cms/backoffice/block'; + +export const manifests: Array = [ + { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Umb.ClipboardCopyPropertyValueTranslator.BlockGridToBlock', + name: 'Block Grid to Block Clipboard Copy Property Value Translator', + api: () => import('./copy/block-grid-to-block-copy-translator.js'), + fromPropertyEditorUi: UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + }, + { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Umb.ClipboardPastePropertyValueTranslator.BlockToBlockGrid', + name: 'Block To Block Grid Clipboard Paste Property Value Translator', + weight: 900, + api: () => import('./paste/block-to-block-grid-paste-translator.js'), + fromClipboardEntryValueType: UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + toPropertyEditorUi: UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.test.ts new file mode 100644 index 000000000000..61445c9ab81e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.test.ts @@ -0,0 +1,113 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbBlockGridValueModel } from '../../../types.js'; +import { UmbBlockToBlockGridClipboardPastePropertyValueTranslator } from './block-to-block-grid-paste-translator.js'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockToBlockGridClipboardPastePropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let copyTranslator: UmbBlockToBlockGridClipboardPastePropertyValueTranslator; + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + columnSpan: 12, + rowSpan: 1, + areas: [], + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [], + }; + + const blockClipboardEntryValue: UmbBlockClipboardEntryValueModel = { + contentData: blockGridPropertyValue.contentData, + layout: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + settingsData: blockGridPropertyValue.settingsData, + }; + + const config1: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }> = [ + { + alias: 'blocks', + value: [ + { + contentElementTypeKey: 'contentTypeKey', + }, + ], + }, + ]; + + const config2: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }> = [ + { + alias: 'blocks', + value: [ + { + contentElementTypeKey: 'contentTypeKey2', + }, + ], + }, + ]; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + copyTranslator = new UmbBlockToBlockGridClipboardPastePropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(copyTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('returns the block grid property value', async () => { + const result = await copyTranslator.translate(blockClipboardEntryValue); + expect(result).to.deep.equal(blockGridPropertyValue); + }); + }); + + describe('isCompatibleValue', () => { + it('returns true if the value is compatible', async () => { + const result = await copyTranslator.isCompatibleValue(blockClipboardEntryValue, config1); + expect(result).to.be.true; + }); + + it('returns false if the value is not compatible', async () => { + const result = await copyTranslator.isCompatibleValue(blockClipboardEntryValue, config2); + expect(result).to.be.false; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.ts new file mode 100644 index 000000000000..1f7cc20ba85b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/block/paste/block-to-block-grid-paste-translator.ts @@ -0,0 +1,64 @@ +import type { UmbBlockGridLayoutModel, UmbBlockGridValueModel } from '../../../types.js'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../constants.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardPastePropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; +import type { UmbBlockClipboardEntryValueModel, UmbBlockLayoutBaseModel } from '@umbraco-cms/backoffice/block'; + +export class UmbBlockToBlockGridClipboardPastePropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + /** + * Translates a block clipboard entry value to a Block Grid property value. + * @param {UmbBlockClipboardEntryValueModel} value The block clipboard entry value. + * @returns {Promise} The translated Block Grid property value. + * @memberof UmbBlockToBlockGridClipboardPastePropertyValueTranslator + */ + async translate(value: UmbBlockClipboardEntryValueModel): Promise { + if (!value) { + throw new Error('Values is missing.'); + } + + const valueClone = structuredClone(value); + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: valueClone.contentData, + settingsData: valueClone.settingsData, + expose: [], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: valueClone.layout?.map((baseLayout: UmbBlockLayoutBaseModel) => { + const gridLayout: UmbBlockGridLayoutModel = { + ...baseLayout, + columnSpan: 12, + rowSpan: 1, + areas: [], + }; + + return gridLayout; + }), + }, + }; + + return blockGridPropertyValue; + } + + /** + * Determines if a block clipboard entry value is compatible with the Block Grid property editor. + * @param {UmbBlockClipboardEntryValueModel} value The block clipboard entry value. + * @param {*} config The Block Grid property editor configuration. + * @returns {Promise} A promise that resolves with a boolean indicating if the value is compatible. + * @memberof UmbBlockToBlockGridClipboardPastePropertyValueTranslator + */ + async isCompatibleValue( + value: UmbBlockClipboardEntryValueModel, + // TODO: Replace any with the correct type. + config: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }>, + ): Promise { + const allowedBlockContentTypes = + config.find((c) => c.alias === 'blocks')?.value.map((b) => b.contentElementTypeKey) ?? []; + const blockContentTypes = value.contentData.map((c) => c.contentTypeKey); + return blockContentTypes?.every((b) => allowedBlockContentTypes.includes(b)) ?? false; + } +} + +export { UmbBlockToBlockGridClipboardPastePropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/constants.ts new file mode 100644 index 000000000000..425d7ff722c1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/constants.ts @@ -0,0 +1 @@ +export * from './grid-block/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/constants.ts new file mode 100644 index 000000000000..b8cec92cea34 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/constants.ts @@ -0,0 +1 @@ +export const UMB_GRID_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE = 'gridBlock'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.test.ts new file mode 100644 index 000000000000..6129436f8242 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.test.ts @@ -0,0 +1,79 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbBlockGridValueModel, UmbGridBlockClipboardEntryValueModel } from '../../../types.js'; +import { UmbBlockGridToGridBlockClipboardCopyPropertyValueTranslator } from './block-grid-to-grid-block-copy-translator.js'; +import { UmbBlockGridManagerContext } from '../../../context/block-grid-manager.context.js'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + constructor() { + super(); + new UmbBlockGridManagerContext(this); + } +} + +describe('UmbBlockListToBlockClipboardCopyPropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let copyTranslator: UmbBlockGridToGridBlockClipboardCopyPropertyValueTranslator; + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + columnSpan: 12, + rowSpan: 1, + areas: [], + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [], + }; + + const gridBlockClipboardEntryValue: UmbGridBlockClipboardEntryValueModel = { + contentData: blockGridPropertyValue.contentData, + layout: blockGridPropertyValue.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS], + settingsData: blockGridPropertyValue.settingsData, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + copyTranslator = new UmbBlockGridToGridBlockClipboardCopyPropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(copyTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('returns the grid block clipboard entry value', async () => { + const result = await copyTranslator.translate(blockGridPropertyValue); + expect(result).to.deep.equal(gridBlockClipboardEntryValue); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.ts new file mode 100644 index 000000000000..a6483bd6b5f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/copy/block-grid-to-grid-block-copy-translator.ts @@ -0,0 +1,66 @@ +import type { UmbBlockGridValueModel } from '../../../types.js'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbGridBlockClipboardEntryValueModel } from '../../types.js'; +import { forEachBlockLayoutEntryOf } from '../../../utils/index.js'; +import { UMB_BLOCK_GRID_MANAGER_CONTEXT } from '../../../context/constants.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardCopyPropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; + +export class UmbBlockGridToGridBlockClipboardCopyPropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + #blockGridManager?: typeof UMB_BLOCK_GRID_MANAGER_CONTEXT.TYPE; + + async translate(propertyValue: UmbBlockGridValueModel) { + if (!propertyValue) { + throw new Error('Property value is missing.'); + } + + this.#blockGridManager = await this.getContext(UMB_BLOCK_GRID_MANAGER_CONTEXT); + + return this.#constructGridBlockValue(propertyValue); + } + + #constructGridBlockValue(propertyValue: UmbBlockGridValueModel): UmbGridBlockClipboardEntryValueModel { + const valueClone = structuredClone(propertyValue); + + const layout = valueClone.layout?.[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS] ?? undefined; + const contentData = valueClone.contentData; + const settingsData = valueClone.settingsData; + + if (!layout?.length) { + throw new Error('No layouts found.'); + } + + layout.forEach((layout) => { + // Find sub Blocks and append their data: + forEachBlockLayoutEntryOf(layout, async (entry) => { + const content = this.#blockGridManager!.getContentOf(entry.contentKey); + + if (!content) { + throw new Error('No content found'); + } + + contentData.push(structuredClone(content)); + + if (entry.settingsKey) { + const settings = this.#blockGridManager!.getSettingsOf(entry.settingsKey); + if (settings) { + settingsData.push(structuredClone(settings)); + } + } + }); + }); + + const gridBlockValue: UmbGridBlockClipboardEntryValueModel = { + contentData, + layout, + settingsData, + }; + + return gridBlockValue; + } +} + +export { UmbBlockGridToGridBlockClipboardCopyPropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/manifests.ts new file mode 100644 index 000000000000..b7e868f1e21c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/manifests.ts @@ -0,0 +1,22 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS } from '../../property-editors/constants.js'; +import { UMB_GRID_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE } from './constants.js'; + +export const manifests: Array = [ + { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Umb.ClipboardCopyPropertyValueTranslator.BlockGridToGridBlock', + name: 'Block Grid To Grid Block Clipboard Copy Property Value Translator', + api: () => import('./copy/block-grid-to-grid-block-copy-translator.js'), + fromPropertyEditorUi: UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: UMB_GRID_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + }, + { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Umb.ClipboardPastePropertyValueTranslator.GridBlockToBlockGrid', + name: 'Grid Block To Block Grid Clipboard Paste Property Value Translator', + api: () => import('./paste/grid-block-to-block-grid-paste-translator.js'), + fromClipboardEntryValueType: UMB_GRID_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + weight: 1000, + toPropertyEditorUi: UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.test.ts new file mode 100644 index 000000000000..105cfa9e18a7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.test.ts @@ -0,0 +1,73 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbBlockGridValueModel, UmbGridBlockClipboardEntryValueModel } from '../../../types'; +import { UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator } from './grid-block-to-block-grid-paste-translator.js'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let copyTranslator: UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator; + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + columnSpan: 12, + rowSpan: 1, + areas: [], + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [], + }; + + const gridBlockClipboardEntryValue: UmbGridBlockClipboardEntryValueModel = { + contentData: blockGridPropertyValue.contentData, + layout: blockGridPropertyValue.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS], + settingsData: blockGridPropertyValue.settingsData, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + copyTranslator = new UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(copyTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('returns the block grid property value', async () => { + const result = await copyTranslator.translate(gridBlockClipboardEntryValue); + expect(result).to.deep.equal(blockGridPropertyValue); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.ts new file mode 100644 index 000000000000..150d71484334 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/grid-block/paste/grid-block-to-block-grid-paste-translator.ts @@ -0,0 +1,55 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbBlockGridValueModel } from '../../../types.js'; +import type { UmbGridBlockClipboardEntryValueModel } from '../../types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardPastePropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; + +export class UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + /** + * Translates a grid block clipboard entry value to a block grid property value. + * @param {UmbGridBlockClipboardEntryValueModel} value - The grid block clipboard entry value. + * @returns {Promise} {Promise} + * @memberof UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator + */ + async translate(value: UmbGridBlockClipboardEntryValueModel): Promise { + if (!value) { + throw new Error('Value is missing.'); + } + + const valueClone = structuredClone(value); + + const blockGridPropertyValue: UmbBlockGridValueModel = { + contentData: valueClone.contentData, + settingsData: valueClone.settingsData, + expose: [], + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: valueClone.layout, + }, + }; + + return blockGridPropertyValue; + } + + /** + * Checks if the clipboard entry value is compatible with the config. + * @param {UmbGridBlockClipboardEntryValueModel} value - The grid block clipboard entry value. + * @param {*} config - The Property Editor config. + * @returns {Promise} {Promise} + * @memberof UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator + */ + async isCompatibleValue( + value: UmbGridBlockClipboardEntryValueModel, + // TODO: Replace any with the correct type. + config: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }>, + ): Promise { + const allowedBlockContentTypes = + config.find((c) => c.alias === 'blocks')?.value.map((b) => b.contentElementTypeKey) ?? []; + const blockContentTypes = value.contentData.map((c) => c.contentTypeKey); + return blockContentTypes?.every((b) => allowedBlockContentTypes.includes(b)) ?? false; + } +} + +export { UmbGridBlockToBlockGridClipboardPastePropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/manifests.ts new file mode 100644 index 000000000000..6546d9da1158 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/manifests.ts @@ -0,0 +1,48 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS } from '../property-editors/constants.js'; +import { manifests as blockManifests } from './block/manifests.js'; +import { manifests as gridBlockManifests } from './grid-block/manifests.js'; +import { + UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS, + UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/property'; + +const forPropertyEditorUis = [UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS]; + +export const manifests: Array = [ + { + type: 'propertyContext', + kind: 'clipboard', + alias: 'Umb.PropertyContext.BlockGrid.Clipboard', + name: 'Block Grid Clipboard Property Context', + forPropertyEditorUis, + }, + { + type: 'propertyAction', + kind: 'copyToClipboard', + alias: 'Umb.PropertyAction.BlockGrid.Clipboard.Copy', + name: 'Block Grid Copy To Clipboard Property Action', + forPropertyEditorUis, + conditions: [ + { + alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, + }, + { + alias: UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS, + }, + ], + }, + { + type: 'propertyAction', + kind: 'pasteFromClipboard', + alias: 'Umb.PropertyAction.BlockGrid.Clipboard.Paste', + name: 'Block Grid Paste From Clipboard Property Action', + forPropertyEditorUis, + conditions: [ + { + alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, + }, + ], + }, + ...blockManifests, + ...gridBlockManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/types.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/types.ts new file mode 100644 index 000000000000..68d38e54f04e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/clipboard/types.ts @@ -0,0 +1,8 @@ +import type { UmbBlockGridLayoutModel } from '../types.js'; +import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; + +export interface UmbGridBlockClipboardEntryValueModel { + contentData: Array; + settingsData: Array; + layout: UmbBlockGridLayoutModel[] | undefined; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index b44d1f90c54d..8fe49c37ef78 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -404,7 +404,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen look="placeholder" href=${this.#context.getPathForClipboard(-1) ?? ''} ?disabled=${this._isReadOnly}> - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 654ef8ff0edc..f6523fc64e8b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -499,7 +499,8 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper #renderActionBar() { return html` - ${this.#renderEditAction()} ${this.#renderEditSettingsAction()} ${this.#renderDeleteAction()} `; } @@ -543,6 +544,12 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper `; } + #renderCopyToClipboardAction() { + return html` this.#context.copyToClipboard()}> + + `; + } + #renderDeleteAction() { if (this._isReadOnly) return nothing; return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/constants.ts index b2a076d54ec8..1905a7d399c7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/constants.ts @@ -1,6 +1,7 @@ export * from './components/constants.js'; export * from './context/constants.js'; export * from './property-editors/constants.js'; +export * from './clipboard/constants.js'; export const UMB_BLOCK_GRID_TYPE = 'block-grid-type'; export const UMB_BLOCK_GRID = 'block-grid'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts index 91442a0e4bb6..69f08d7abd34 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts @@ -2,10 +2,18 @@ import type { UmbBlockDataModel } from '../../block/index.js'; import { UMB_BLOCK_CATALOGUE_MODAL, UmbBlockEntriesContext } from '../../block/index.js'; import { UMB_BLOCK_GRID_ENTRY_CONTEXT, + UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS, + UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, UMB_BLOCK_GRID_WORKSPACE_MODAL, type UmbBlockGridWorkspaceOriginData, } from '../index.js'; -import type { UmbBlockGridLayoutModel, UmbBlockGridTypeAreaType, UmbBlockGridTypeModel } from '../types.js'; +import type { + UmbBlockGridLayoutModel, + UmbBlockGridTypeAreaType, + UmbBlockGridTypeModel, + UmbBlockGridValueModel, +} from '../types.js'; +import { forEachBlockLayoutEntryOf } from '../utils/index.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 { @@ -18,6 +26,11 @@ import { import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbModalRouteRegistrationController, UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router'; import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; +import { + UMB_CLIPBOARD_PROPERTY_CONTEXT, + UmbClipboardPastePropertyValueTranslatorValueResolver, +} from '@umbraco-cms/backoffice/clipboard'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; interface UmbBlockGridAreaTypeInvalidRuleType { groupKey?: string; @@ -145,15 +158,50 @@ export class UmbBlockGridEntriesContext new UmbModalRouteRegistrationController(this, UMB_BLOCK_CATALOGUE_MODAL) .addAdditionalPath('_catalogue/:view/:index') - .onSetup((routingInfo) => { + .onSetup(async (routingInfo) => { if (!this._manager) return false; // Idea: Maybe on setup should be async, so it can retrieve the values when needed? [NL] const index = routingInfo.index ? parseInt(routingInfo.index) : -1; + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + const pasteTranslatorManifests = clipboardContext.getPasteTranslatorManifests( + UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + ); + + // TODO: consider moving some of this logic to the clipboard property context + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + const config = propertyContext.getConfig(); + const valueResolver = new UmbClipboardPastePropertyValueTranslatorValueResolver(this); + return { data: { blocks: this.#allowedBlockTypes.getValue(), blockGroups: this._manager.getBlockGroups() ?? [], openClipboard: routingInfo.view === 'clipboard', + clipboardFilter: async (clipboardEntryDetail) => { + const hasSupportedPasteTranslator = clipboardContext.hasSupportedPasteTranslator( + pasteTranslatorManifests, + clipboardEntryDetail.values, + ); + + if (!hasSupportedPasteTranslator) { + return false; + } + + const pasteTranslator = await valueResolver.getPasteTranslator( + clipboardEntryDetail.values, + UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + ); + + if (pasteTranslator.isCompatibleValue) { + const value = await valueResolver.resolve( + clipboardEntryDetail.values, + UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + ); + return pasteTranslator.isCompatibleValue(value, config); + } + + return true; + }, originData: { index: index, areaKey: this.#areaKey, @@ -172,7 +220,7 @@ export class UmbBlockGridEntriesContext data.originData as UmbBlockGridWorkspaceOriginData, ); if (created) { - this.insert( + await this.insert( created.layout, created.content, created.settings, @@ -181,6 +229,15 @@ export class UmbBlockGridEntriesContext } else { throw new Error('Failed to create block'); } + } else if (value?.clipboard && value.clipboard.selection?.length && data) { + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + + const propertyValues = await clipboardContext.readMultiple( + value.clipboard.selection, + UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + ); + + this._insertFromPropertyValues(propertyValues, data.originData as UmbBlockGridWorkspaceOriginData); } }) .observeRouteBuilder((routeBuilder) => { @@ -384,16 +441,62 @@ export class UmbBlockGridEntriesContext originData: UmbBlockGridWorkspaceOriginData, ) { await this._retrieveManager; - // TODO: Insert layout entry at the right spot. return this._manager?.insert(layoutEntry, content, settings, originData) ?? false; } // create Block? override async delete(contentKey: string) { // TODO: Loop through children and delete them as well? + // Find layout entry: + const layout = this._layoutEntries.getValue().find((x) => x.contentKey === contentKey); + if (!layout) { + throw new Error(`Cannot delete block, missing layout for ${contentKey}`); + } + // The following loop will only delete the referenced data of sub Layout Entries, as the Layout entry is part of the main Layout Entry they will go away when that is removed. [NL] + forEachBlockLayoutEntryOf(layout, async (entry) => { + if (entry.settingsKey) { + this._manager!.removeOneSettings(entry.settingsKey); + } + this._manager!.removeOneContent(contentKey); + this._manager!.removeExposesOf(contentKey); + }); + await super.delete(contentKey); } + protected async _insertFromPropertyValue(value: UmbBlockGridValueModel, originData: UmbBlockGridWorkspaceOriginData) { + const layoutEntries = value.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]; + + if (!layoutEntries) { + throw new Error('No layout entries found'); + } + + await Promise.all( + layoutEntries.map(async (layoutEntry) => { + await this._insertBlockFromPropertyValue(layoutEntry, value, originData); + if (originData.index !== -1) { + originData = { ...originData, index: originData.index + 1 }; + } + }), + ); + + return originData; + } + + protected override async _insertBlockFromPropertyValue( + layoutEntry: UmbBlockGridLayoutModel, + value: UmbBlockGridValueModel, + originData: UmbBlockGridWorkspaceOriginData, + ) { + await super._insertBlockFromPropertyValue(layoutEntry, value, originData); + + // Handle inserting of the inner blocks.. + await forEachBlockLayoutEntryOf(layoutEntry, async (entry, parentUnique, areaKey) => { + const localOriginData = { index: -1, parentUnique, areaKey }; + await this._insertBlockFromPropertyValue(entry, value, localOriginData); + }); + } + /** * @internal * @returns {Array} an Array of ElementTypeKeys that are allowed in the current area. Or undefined if not ready jet. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entry.context.ts index a458b7f49cc4..ce4343b7228f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entry.context.ts @@ -1,4 +1,6 @@ import { closestColumnSpanOption } from '../utils/index.js'; +import type { UmbBlockGridValueModel } from '../types.js'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS, UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS } from '../constants.js'; import { UMB_BLOCK_GRID_MANAGER_CONTEXT } from './block-grid-manager.context-token.js'; import { UMB_BLOCK_GRID_ENTRIES_CONTEXT } from './block-grid-entries.context-token.js'; import { @@ -16,6 +18,8 @@ import { import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbBlockEntryContext } from '@umbraco-cms/backoffice/block'; import type { UmbBlockGridTypeModel, UmbBlockGridLayoutModel } from '@umbraco-cms/backoffice/block-grid'; +import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UMB_CLIPBOARD_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/clipboard'; export class UmbBlockGridEntryContext extends UmbBlockEntryContext< @@ -264,4 +268,48 @@ export class UmbBlockGridEntryContext } return columnSpan; } + + async copyToClipboard() { + if (!this._manager) return; + + const propertyDatasetContext = await this.getContext(UMB_PROPERTY_DATASET_CONTEXT); + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + + const workspaceName = propertyDatasetContext?.getName(); + const propertyLabel = propertyContext?.getLabel(); + const blockLabel = this.getLabel(); + + const entryName = workspaceName + ? `${workspaceName} - ${propertyLabel} - ${blockLabel}` + : `${propertyLabel} - ${blockLabel}`; + + const layout = this.getLayout(); + if (!layout) { + throw new Error('No layout found'); + } + const content = this.getContent(); + const settings = this.getSettings(); + const expose = this.getExpose(); + + const contentData = content ? [structuredClone(content)] : []; + const settingsData = settings ? [structuredClone(settings)] : []; + const exposes = expose ? [structuredClone(expose)] : []; + + const propertyValue: UmbBlockGridValueModel = { + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: layout ? [structuredClone(layout)] : undefined, + }, + contentData, + settingsData, + expose: exposes, + }; + + clipboardContext.write({ + icon: this.getContentElementTypeIcon(), + name: entryName, + propertyValue, + propertyEditorUiAlias: UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS, + }); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts index ea147e17bb27..20b42b6b18fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-manager.context.ts @@ -181,7 +181,6 @@ export class UmbBlockGridManagerContext< return undefined; } - // TODO: Remove dependency on modalData object here. [NL] Maybe change it into requiring the originData object instead. insert( layoutEntry: BlockLayoutType, content: UmbBlockDataModel, diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/manifests.ts index 6c7e62c0a6b2..209e3eef670e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/manifests.ts @@ -1,9 +1,13 @@ +import { manifests as clipboardManifests } from './clipboard/manifests.js'; import { manifests as componentManifests } from './components/manifests.js'; import { manifests as propertyEditorManifests } from './property-editors/manifests.js'; +import { manifests as propertyValueClonerManifests } from './property-value-cloner/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; export const manifests: Array = [ - ...workspaceManifests, - ...propertyEditorManifests, + ...clipboardManifests, ...componentManifests, + ...propertyEditorManifests, + ...propertyValueClonerManifests, + ...workspaceManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/manifests.ts new file mode 100644 index 000000000000..d0fcae1a39f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; + +export const manifests = [ + { + type: 'propertyValueCloner', + name: 'Block Grid Value Cloner', + alias: 'Umb.PropertyValueCloner.BlockGrid', + api: () => import('./property-value-cloner-block-grid.cloner.js'), + forEditorAlias: UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.test.ts new file mode 100644 index 000000000000..274a52570450 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.test.ts @@ -0,0 +1,204 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbPropertyValueCloneController } from '@umbraco-cms/backoffice/property'; +import { manifests } from './manifests'; +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants'; +import type { UmbBlockGridLayoutModel, UmbBlockGridValueModel } from '../types'; +import type { UmbBlockDataModel, UmbBlockExposeModel } from '@umbraco-cms/backoffice/block'; + +@customElement('umb-test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockGridPropertyValueCloner', () => { + describe('Cloner', () => { + beforeEach(async () => { + umbExtensionsRegistry.registerMany(manifests); + }); + afterEach(async () => { + umbExtensionsRegistry.unregisterMany(manifests.map((m) => m.alias)); + }); + + it('clones value', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS, + alias: 'test', + culture: null, + segment: null, + value: { + layout: { + [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + contentKey: 'content-1', + settingsKey: 'settings-1', + areas: [ + { + alias: 'area-1', + items: [ + { + contentKey: 'content-2', + settingsKey: 'settings-2', + areas: [ + { + alias: 'area-2', + items: [ + { + contentKey: 'content-3', + settingsKey: 'settings-3', + areas: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + contentData: [ + { + key: 'content-1', + contentTypeKey: 'fictive-content-type-1', + culture: null, + segment: null, + }, + { + key: 'content-2', + contentTypeKey: 'fictive-content-type-2', + culture: null, + segment: null, + }, + { + key: 'content-3', + contentTypeKey: 'fictive-content-type-3', + culture: null, + segment: null, + }, + ], + settingsData: [ + { + key: 'settings-1', + contentTypeKey: 'fictive-content-type-1', + culture: null, + segment: null, + }, + { + key: 'settings-2', + contentTypeKey: 'fictive-content-type-2', + culture: null, + segment: null, + }, + { + key: 'settings-3', + contentTypeKey: 'fictive-content-type-3', + culture: null, + segment: null, + }, + ], + expose: [ + { + contentKey: 'content-1', + culture: null, + segment: null, + }, + { + contentKey: 'content-2', + culture: null, + segment: null, + }, + { + contentKey: 'content-3', + culture: null, + segment: null, + }, + ], + }, + }; + + const result = (await ctrl.clone(value)) as { value: UmbBlockGridValueModel | undefined }; + + const newContentKey = result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey; + const newSettingsKey = result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].settingsKey; + + if (newContentKey === undefined) { + throw new Error('newContentKey is undefined'); + } + + expect(result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey).to.not.be.equal( + 'content-1', + ); + expect(result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].settingsKey).to.not.be.equal( + 'settings-1', + ); + expect(result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey).to.be.equal( + newContentKey, + ); + + expect(result.value?.contentData[0].key).to.not.be.equal('content-1'); + expect(result.value?.contentData[0].key).to.be.equal(newContentKey); + expect(result.value?.settingsData[0].key).to.not.be.equal('settings-1'); + expect(result.value?.settingsData[0].key).to.be.equal(newSettingsKey); + expect(result.value?.expose[0].contentKey).to.not.be.equal('content-1'); + expect(result.value?.expose[0].contentKey).to.be.equal(newContentKey); + + testLayoutEntryNewKeyIsReflected( + 'fictive-content-type-1', + result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0], + result.value?.contentData, + result.value?.settingsData, + result.value?.expose, + ); + + // Test for inner layout entry: + + expect(result.value?.contentData[1].key).to.not.be.equal('content-2'); + expect(result.value?.settingsData[1].key).to.not.be.equal('settings-2'); + + testLayoutEntryNewKeyIsReflected( + 'fictive-content-type-2', + result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].areas[0]?.items[0], + result.value?.contentData, + result.value?.settingsData, + result.value?.expose, + ); + + // Test for inner inner layout entry: + + expect(result.value?.contentData[2].key).to.not.be.equal('content-3'); + expect(result.value?.settingsData[2].key).to.not.be.equal('settings-3'); + + testLayoutEntryNewKeyIsReflected( + 'fictive-content-type-3', + result.value?.layout[UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].areas[0]?.items[0].areas[0]?.items[0], + result.value?.contentData, + result.value?.settingsData, + result.value?.expose, + ); + }); + }); +}); + +function testLayoutEntryNewKeyIsReflected( + contentTypeKey: string, + layoutEntry?: UmbBlockGridLayoutModel, + contentData?: Array, + settingsData?: Array, + expose?: Array, +) { + if (!layoutEntry || !contentData || !settingsData || !expose) { + throw new Error('some arguments was undefined'); + } + // Test layout entry + const newContentKey = layoutEntry.contentKey; + const newSettingsKey = layoutEntry.settingsKey; + + expect(contentData.some((x) => x.key === newContentKey && x.contentTypeKey === contentTypeKey)).to.be.true; + expect(settingsData.some((x) => x.key === newSettingsKey && x.contentTypeKey === contentTypeKey)).to.be.true; + expect(expose.some((x) => x.contentKey === newContentKey)).to.be.true; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.ts new file mode 100644 index 000000000000..cc996d332c60 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-value-cloner/property-value-cloner-block-grid.cloner.ts @@ -0,0 +1,35 @@ +import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; +import type { UmbBlockGridLayoutModel, UmbBlockGridValueModel } from '../types.js'; +import { UmbBlockPropertyValueCloner, type UmbBlockPropertyValueClonerArgs } from '@umbraco-cms/backoffice/block'; + +export class UmbBlockGridPropertyValueCloner extends UmbBlockPropertyValueCloner< + UmbBlockGridValueModel, + UmbBlockGridLayoutModel +> { + constructor(args: UmbBlockPropertyValueClonerArgs) { + super(UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS, args); + } + + _cloneLayout( + layouts: Array | undefined, + ): Promise | undefined> | undefined { + return layouts ? Promise.all(layouts.map(this.#cloneLayoutEntry)) : undefined; + } + + #cloneLayoutEntry = async (layout: UmbBlockGridLayoutModel): Promise => { + // Clone the specific layout entry: + const entryClone = await this._cloneBlock(layout); + // And then clone the items of its areas: + entryClone.areas = await Promise.all( + entryClone.areas.map(async (area) => { + return { + ...area, + items: await Promise.all(area.items.map(this.#cloneLayoutEntry)), + }; + }), + ); + return entryClone; + }; +} + +export { UmbBlockGridPropertyValueCloner as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/types.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/types.ts index b22f5a7b074c..de0f73b2a739 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/types.ts @@ -1,6 +1,8 @@ import type { UmbBlockLayoutBaseModel, UmbBlockValueType } from '@umbraco-cms/backoffice/block'; import type { UmbBlockTypeWithGroupKey } from '@umbraco-cms/backoffice/block-type'; +export type * from './clipboard/types.js'; + // Configuration models: export interface UmbBlockGridTypeModel extends UmbBlockTypeWithGroupKey { columnSpanOptions: Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/utils/index.ts index 3b3970ac625a..66c655f7b8fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/utils/index.ts @@ -1,10 +1,13 @@ +import type { UmbBlockGridLayoutModel } from '../types.js'; + /** * - * @param target - * @param map - * @param max + * @param {number} target - The target number + * @param {Array} map - The map to search in + * @param {number} max - The max value to return if no match is found + * @returns {number | undefined} - The closest number in the map to the target */ -export function closestColumnSpanOption(target: number, map: Array, max: number) { +export function closestColumnSpanOption(target: number, map: Array, max: number): number | undefined { if (map.length > 0) { const result = map.reduce((a, b) => { if (a > max) { @@ -25,3 +28,28 @@ export function closestColumnSpanOption(target: number, map: Array, max: } return; } + +/** + * + * @param {UmbBlockGridLayoutModel} entry - The entry to iterate over + * @param {(entry:UmbBlockGridLayoutModel) => void } callback - The callback to call for each entry + */ +export async function forEachBlockLayoutEntryOf( + entry: UmbBlockGridLayoutModel, + callback: (entry: UmbBlockGridLayoutModel, parentUnique: string, areaKey: string) => PromiseLike, +): Promise { + if (entry.areas) { + const parentUnique = entry.contentKey; + await Promise.all( + entry.areas.map(async (area) => { + const areaKey = area.key; + await Promise.all( + area.items.map(async (item) => { + await callback(item, parentUnique, areaKey); + await forEachBlockLayoutEntryOf(item, callback); + }), + ); + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.test.ts new file mode 100644 index 000000000000..bc71ec139f9c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.test.ts @@ -0,0 +1,82 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbBlockListValueModel } from '../../../types.js'; +import { UmbBlockListToBlockClipboardCopyPropertyValueTranslator } from './block-list-to-block-copy-translator'; +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockListToBlockClipboardCopyPropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let copyTranslator: UmbBlockListToBlockClipboardCopyPropertyValueTranslator; + + const blockListPropertyValue: UmbBlockListValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [ + { + contentKey: 'contentKey', + culture: null, + segment: null, + }, + ], + }; + + const blockClipboardEntryValue: UmbBlockClipboardEntryValueModel = { + contentData: blockListPropertyValue.contentData, + layout: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + settingsData: blockListPropertyValue.settingsData, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + copyTranslator = new UmbBlockListToBlockClipboardCopyPropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(copyTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('returns the block clipboard entry value', async () => { + const result = await copyTranslator.translate(blockListPropertyValue); + expect(result).to.deep.equal(blockClipboardEntryValue); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.ts new file mode 100644 index 000000000000..a055c6c48fa7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/block-list-to-block-copy-translator.ts @@ -0,0 +1,37 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/block-list-editor/constants.js'; +import type { UmbBlockListLayoutModel, UmbBlockListValueModel } from '../../../types.js'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardCopyPropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; + +export class UmbBlockListToBlockClipboardCopyPropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + async translate(propertyValue: UmbBlockListValueModel): Promise { + if (!propertyValue) { + throw new Error('Property value is missing.'); + } + + const valueClone = structuredClone(propertyValue); + + const contentData = valueClone.contentData; + const layout = valueClone.layout?.[UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS] ?? undefined; + const settingsData = valueClone.settingsData; + + layout?.forEach((layoutItem: UmbBlockListLayoutModel) => { + // @ts-expect-error - We are removing the $type property from the layout item + delete layoutItem.$type; + }); + + const blockValue: UmbBlockClipboardEntryValueModel = { + contentData: contentData ?? [], + layout: layout, + settingsData: settingsData ?? [], + }; + + return blockValue; + } +} + +export { UmbBlockListToBlockClipboardCopyPropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/manifests.ts new file mode 100644 index 000000000000..4709ac60a1c3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/copy/manifests.ts @@ -0,0 +1,13 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS } from '../../../property-editors/constants.js'; +import { UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE } from '@umbraco-cms/backoffice/block'; + +export const manifests: Array = [ + { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Umb.ClipboardCopyPropertyValueTranslator.BlockListToBlock', + name: 'Block List To Block Clipboard Copy Property Value Translator', + api: () => import('./block-list-to-block-copy-translator.js'), + fromPropertyEditorUi: UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.test.ts new file mode 100644 index 000000000000..a54f811c9c27 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.test.ts @@ -0,0 +1,110 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbBlockListValueModel } from '../../../types.js'; +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/constants.js'; +import { UmbBlockToBlockListClipboardPastePropertyValueTranslator } from './block-to-block-list-paste-translator'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockToBlockListClipboardPastePropertyValueTranslator', () => { + let hostElement: UmbTestControllerHostElement; + let pasteTranslator: UmbBlockToBlockListClipboardPastePropertyValueTranslator; + + const blockListPropertyValue: UmbBlockListValueModel = { + contentData: [ + { + key: 'contentKey', + contentTypeKey: 'contentTypeKey', + values: [ + { + culture: null, + segment: null, + alias: 'headline', + editorAlias: 'Umbraco.TextBox', + value: 'Headline value', + }, + ], + }, + ], + layout: { + [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + }, + settingsData: [], + expose: [], + }; + + const blockClipboardEntryValue: UmbBlockClipboardEntryValueModel = { + contentData: blockListPropertyValue.contentData, + layout: [ + { + contentKey: 'contentKey', + settingsKey: null, + }, + ], + settingsData: blockListPropertyValue.settingsData, + }; + + const config: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }> = [ + { + alias: 'blocks', + value: [ + { + contentElementTypeKey: 'contentTypeKey', + }, + ], + }, + ]; + + const config2: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }> = [ + { + alias: 'blocks', + value: [ + { + contentElementTypeKey: 'contentTypeKey2', + }, + ], + }, + ]; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + pasteTranslator = new UmbBlockToBlockListClipboardPastePropertyValueTranslator(hostElement); + document.body.innerHTML = ''; + document.body.appendChild(hostElement); + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a translate method', () => { + expect(pasteTranslator).to.have.property('translate').that.is.a('function'); + }); + }); + }); + + describe('translate', () => { + it('return the block list property value', async () => { + const result = await pasteTranslator.translate(blockClipboardEntryValue); + expect(result).to.deep.equal(blockListPropertyValue); + }); + }); + + describe('isCompatibleValue', () => { + it('should return true if the content types are allowed', async () => { + const result = await pasteTranslator.isCompatibleValue(blockClipboardEntryValue, config); + expect(result).to.be.true; + }); + + it('should return false if the content types are not allowed', async () => { + const result = await pasteTranslator.isCompatibleValue(blockClipboardEntryValue, config2); + expect(result).to.be.false; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.ts new file mode 100644 index 000000000000..9493c4ef6145 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/block-to-block-list-paste-translator.ts @@ -0,0 +1,55 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../../../property-editors/block-list-editor/constants.js'; +import type { UmbBlockListValueModel } from '../../../types.js'; +import type { UmbBlockClipboardEntryValueModel } from '@umbraco-cms/backoffice/block'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardPastePropertyValueTranslator } from '@umbraco-cms/backoffice/clipboard'; + +export class UmbBlockToBlockListClipboardPastePropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + /** + * Translates a block clipboard entry value to a block list property value. + * @param {UmbBlockClipboardEntryValueModel} value - The block clipboard entry value. + * @returns {Promise} - The block list property value. + * @memberof UmbBlockToBlockListClipboardPastePropertyValueTranslator + */ + async translate(value: UmbBlockClipboardEntryValueModel): Promise { + if (!value) { + throw new Error('Value is missing.'); + } + + const valueClone = structuredClone(value); + + const blockListPropertyValue: UmbBlockListValueModel = { + contentData: valueClone.contentData, + settingsData: valueClone.settingsData, + expose: [], + layout: { + [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: valueClone.layout ?? undefined, + }, + }; + + return blockListPropertyValue; + } + + /** + * Checks if the clipboard entry value is compatible with the config. + * @param {UmbBlockClipboardEntryValueModel} value - The block clipboard entry value. + * @param {*} config - The Property Editor config. + * @returns {Promise} - Whether the clipboard entry value is compatible with the config. + * @memberof UmbBlockToBlockListClipboardPastePropertyValueTranslator + */ + async isCompatibleValue( + value: UmbBlockClipboardEntryValueModel, + // TODO: Replace any with the correct type. + config: Array<{ alias: string; value: [{ contentElementTypeKey: string }] }>, + ): Promise { + const allowedBlockContentTypes = + config.find((c) => c.alias === 'blocks')?.value.map((b) => b.contentElementTypeKey) ?? []; + const blockContentTypes = value.contentData.map((c) => c.contentTypeKey); + return blockContentTypes?.every((b) => allowedBlockContentTypes.includes(b)) ?? false; + } +} + +export { UmbBlockToBlockListClipboardPastePropertyValueTranslator as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/manifests.ts new file mode 100644 index 000000000000..d91954c48e40 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/block/paste/manifests.ts @@ -0,0 +1,13 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS } from '../../../property-editors/constants.js'; +import { UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE } from '@umbraco-cms/backoffice/block'; + +export const manifests: Array = [ + { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Umb.ClipboardPastePropertyValueTranslator.BlockToBlockList', + name: 'Block To Block List Clipboard Paste Property Value Translator', + api: () => import('./block-to-block-list-paste-translator.js'), + fromClipboardEntryValueType: UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE, + toPropertyEditorUi: UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/manifests.ts new file mode 100644 index 000000000000..d8d73eb55c27 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/clipboard/manifests.ts @@ -0,0 +1,48 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS } from '../property-editors/constants.js'; +import { manifests as blockCopyManifests } from './block/copy/manifests.js'; +import { manifests as blockPasteManifests } from './block/paste/manifests.js'; +import { + UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS, + UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/property'; + +const forPropertyEditorUis = [UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS]; + +export const manifests: Array = [ + { + type: 'propertyContext', + kind: 'clipboard', + alias: 'Umb.PropertyContext.BlockList.Clipboard', + name: 'Block List Clipboard Property Context', + forPropertyEditorUis, + }, + { + type: 'propertyAction', + kind: 'copyToClipboard', + alias: 'Umb.PropertyAction.BlockList.Clipboard.Copy', + name: 'Block List Copy To Clipboard Property Action', + forPropertyEditorUis, + conditions: [ + { + alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, + }, + { + alias: UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS, + }, + ], + }, + { + type: 'propertyAction', + kind: 'pasteFromClipboard', + alias: 'Umb.PropertyAction.BlockList.Clipboard.Paste', + name: 'Block List Paste From Clipboard Property Action', + forPropertyEditorUis, + conditions: [ + { + alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, + }, + ], + }, + ...blockCopyManifests, + ...blockPasteManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index 3765bda03430..fc0048e73c87 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -1,6 +1,10 @@ import { UmbBlockListEntryContext } from '../../context/block-list-entry.context.js'; -import type { UmbBlockListLayoutModel } from '../../types.js'; -import { UMB_BLOCK_LIST } from '../../constants.js'; +import type { UmbBlockListLayoutModel, UmbBlockListValueModel } from '../../types.js'; +import { + UMB_BLOCK_LIST, + UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS, + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, +} from '../../constants.js'; import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/lit-element'; import { html, css, customElement, property, state, nothing } from '@umbraco-cms/backoffice/external/lit'; import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; @@ -15,6 +19,8 @@ import type { } from '@umbraco-cms/backoffice/block-custom-view'; import type { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/extension-api'; import { UUIBlinkAnimationValue } from '@umbraco-cms/backoffice/external/uui'; +import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UMB_CLIPBOARD_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/clipboard'; /** * @element umb-block-list-entry @@ -266,6 +272,41 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.expose(); }; + async #copyToClipboard() { + const propertyDatasetContext = await this.getContext(UMB_PROPERTY_DATASET_CONTEXT); + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + + const workspaceName = propertyDatasetContext?.getName(); + const propertyLabel = propertyContext?.getLabel(); + const blockLabel = this._label; + + const entryName = workspaceName + ? `${workspaceName} - ${propertyLabel} - ${blockLabel}` + : `${propertyLabel} - ${blockLabel}`; + + const content = this.#context.getContent(); + const layout = this.#context.getLayout(); + const settings = this.#context.getSettings(); + const expose = this.#context.getExpose(); + + const propertyValue: UmbBlockListValueModel = { + contentData: content ? [structuredClone(content)] : [], + layout: { + [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: layout ? [structuredClone(layout)] : undefined, + }, + settingsData: settings ? [structuredClone(settings)] : [], + expose: expose ? [structuredClone(expose)] : [], + }; + + clipboardContext.write({ + icon: this._icon, + name: entryName, + propertyValue, + propertyEditorUiAlias: UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + }); + } + #extensionSlotFilterMethod = (manifest: ManifestBlockEditorCustomView) => { if ( manifest.forContentTypeAlias && @@ -328,7 +369,8 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper >${this._inlineEditingMode ? this.#renderInlineBlock() : this.#renderRefBlock()} - ${this.#renderEditContentAction()} ${this.#renderEditSettingsAction()} ${this.#renderDeleteAction()} + ${this.#renderEditContentAction()} ${this.#renderEditSettingsAction()} + ${this.#renderCopyToClipboardAction()} ${this.#renderDeleteAction()} ${!this._showContentEdit && this._contentInvalid ? html`!` @@ -384,6 +426,12 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper `; } + #renderCopyToClipboardAction() { + return html` this.#copyToClipboard()}> + + `; + } + override render() { return this.#renderBlock(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts index 1e5604a48c2d..9cc4369ecd5d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts @@ -1,12 +1,21 @@ import type { UmbBlockDataModel } from '../../block/index.js'; import { UMB_BLOCK_CATALOGUE_MODAL, UmbBlockEntriesContext } from '../../block/index.js'; import type { UmbBlockListWorkspaceOriginData } from '../index.js'; -import { UMB_BLOCK_LIST_WORKSPACE_MODAL } from '../index.js'; -import type { UmbBlockListLayoutModel, UmbBlockListTypeModel } from '../types.js'; +import { + UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS, + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + UMB_BLOCK_LIST_WORKSPACE_MODAL, +} from '../index.js'; +import type { UmbBlockListLayoutModel, UmbBlockListTypeModel, UmbBlockListValueModel } from '../types.js'; import { UMB_BLOCK_LIST_MANAGER_CONTEXT } from './block-list-manager.context-token.js'; import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { + UMB_CLIPBOARD_PROPERTY_CONTEXT, + UmbClipboardPastePropertyValueTranslatorValueResolver, +} from '@umbraco-cms/backoffice/clipboard'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< typeof UMB_BLOCK_LIST_MANAGER_CONTEXT, @@ -29,11 +38,47 @@ export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< await this._retrieveManager; if (!this._manager) return false; const index = routingInfo.index ? parseInt(routingInfo.index) : -1; + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + + const pasteTranslatorManifests = clipboardContext.getPasteTranslatorManifests( + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + ); + + // TODO: consider moving some of this logic to the clipboard property context + const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); + const config = propertyContext.getConfig(); + const valueResolver = new UmbClipboardPastePropertyValueTranslatorValueResolver(this); + return { data: { blocks: this._manager?.getBlockTypes() ?? [], blockGroups: [], openClipboard: routingInfo.view === 'clipboard', + clipboardFilter: async (clipboardEntryDetail) => { + const hasSupportedPasteTranslator = clipboardContext.hasSupportedPasteTranslator( + pasteTranslatorManifests, + clipboardEntryDetail.values, + ); + + if (!hasSupportedPasteTranslator) { + return false; + } + + const pasteTranslator = await valueResolver.getPasteTranslator( + clipboardEntryDetail.values, + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + ); + + if (pasteTranslator.isCompatibleValue) { + const value = await valueResolver.resolve( + clipboardEntryDetail.values, + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + ); + return pasteTranslator.isCompatibleValue(value, config); + } + + return true; + }, originData: { index: index }, createBlockInWorkspace: this._manager.getInlineEditingMode() === false, }, @@ -56,6 +101,15 @@ export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< } else { throw new Error('Failed to create block'); } + } else if (value?.clipboard && value.clipboard.selection?.length && data) { + const clipboardContext = await this.getContext(UMB_CLIPBOARD_PROPERTY_CONTEXT); + + const propertyValues = await clipboardContext.readMultiple( + value.clipboard.selection, + UMB_BLOCK_LIST_PROPERTY_EDITOR_UI_ALIAS, + ); + + this._insertFromPropertyValues(propertyValues, data.originData as UmbBlockListWorkspaceOriginData); } }) .observeRouteBuilder((routeBuilder) => { @@ -126,12 +180,26 @@ export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< originData: UmbBlockListWorkspaceOriginData, ) { await this._retrieveManager; + return this._manager?.insert(layoutEntry, content, settings, originData) ?? false; } - // create Block? - override async delete(contentKey: string) { - // TODO: Loop through children and delete them as well? - await super.delete(contentKey); + protected async _insertFromPropertyValue(value: UmbBlockListValueModel, originData: UmbBlockListWorkspaceOriginData) { + const layoutEntries = value.layout[UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]; + + if (!layoutEntries) { + throw new Error('No layout entries found'); + } + + await Promise.all( + layoutEntries.map(async (layoutEntry) => { + this._insertBlockFromPropertyValue(layoutEntry, value, originData); + if (originData.index !== -1) { + originData = { ...originData, index: originData.index + 1 }; + } + }), + ); + + return originData; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entry.context.ts index 4d9d4099c6a4..1930897f52ac 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entry.context.ts @@ -27,17 +27,13 @@ export class UmbBlockListEntryContext extends UmbBlockEntryContext< } _gotManager() { - if (this._manager) { - this.observe( - this._manager.inlineEditingMode, - (inlineEditingMode) => { - this.#inlineEditingMode.setValue(inlineEditingMode); - }, - 'observeInlineEditingMode', - ); - } else { - this.removeUmbControllerByAlias('observeInlineEditingMode'); - } + this.observe( + this._manager?.inlineEditingMode, + (inlineEditingMode) => { + this.#inlineEditingMode.setValue(inlineEditingMode); + }, + 'observeInlineEditingMode', + ); } _gotEntries() {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/manifests.ts index 81f582ffda1d..578d2561a319 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/manifests.ts @@ -1,4 +1,12 @@ +import { manifests as clipboardManifests } from './clipboard/manifests.js'; import { manifests as propertyEditorManifests } from './property-editors/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; +import { manifests as propertValueClonerManifests } from './property-value-cloner/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; -export const manifests: Array = [...workspaceManifests, ...propertyEditorManifests]; +export const manifests: Array = [ + ...clipboardManifests, + ...propertValueClonerManifests, + ...propertyEditorManifests, + ...workspaceManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/manifests.ts index 3f862e0f145b..38122753754f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/manifests.ts @@ -46,7 +46,6 @@ export const manifests: Array = [ }, }, }, - blockListSchemaManifest, { type: 'propertyValueResolver', alias: 'Umb.PropertyValueResolver.BlockList', @@ -56,4 +55,5 @@ export const manifests: Array = [ editorAlias: UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS, }, }, + blockListSchemaManifest, ]; 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 727501c99888..559e8230af4e 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 @@ -359,7 +359,7 @@ export class UmbPropertyEditorUIBlockListElement look="placeholder" href=${this._catalogueRouteBuilder?.({ view: 'clipboard', index: -1 }) ?? ''} ?disabled=${this.readonly}> - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/manifests.ts index 97cd909b6fd2..6604a9d6d3ea 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/manifests.ts @@ -1,4 +1,4 @@ import { manifest as blockListTypeConfiguration } from './block-list-type-configuration/manifests.js'; -import { manifests as blockGridEditorManifests } from './block-list-editor/manifests.js'; +import { manifests as blockListEditorManifests } from './block-list-editor/manifests.js'; -export const manifests: Array = [blockListTypeConfiguration, ...blockGridEditorManifests]; +export const manifests: Array = [blockListTypeConfiguration, ...blockListEditorManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/manifests.ts new file mode 100644 index 000000000000..59678851721d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; + +export const manifests = [ + { + type: 'propertyValueCloner', + name: 'Block List Value Cloner', + alias: 'Umb.PropertyValueCloner.BlockList', + api: () => import('./property-value-cloner-block-list.cloner.js'), + forEditorAlias: UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/property-value-cloner-block-list.cloner.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/property-value-cloner-block-list.cloner.ts new file mode 100644 index 000000000000..dd120bf26fd6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-value-cloner/property-value-cloner-block-list.cloner.ts @@ -0,0 +1,13 @@ +import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; +import { + UmbFlatLayoutBlockPropertyValueCloner, + type UmbBlockPropertyValueClonerArgs, +} from '@umbraco-cms/backoffice/block'; + +export class UmbBlockListPropertyValueCloner extends UmbFlatLayoutBlockPropertyValueCloner { + constructor(args: UmbBlockPropertyValueClonerArgs) { + super(UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS, args); + } +} + +export { UmbBlockListPropertyValueCloner as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/constants.ts index 4aaa6a7406ce..5c9fd8a2b8c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/constants.ts @@ -1,2 +1,3 @@ export const UMB_BLOCK_RTE_TYPE = 'block-rte-type'; export const UMB_BLOCK_RTE = 'block-rte'; +export const UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS = 'Umbraco.RichText'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts index 3f29d6538842..492ab213fa65 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts @@ -1,6 +1,6 @@ import type { UmbBlockDataModel } from '../../block/index.js'; import { UMB_BLOCK_CATALOGUE_MODAL, UmbBlockEntriesContext } from '../../block/index.js'; -import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel } from '../types.js'; +import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel, UmbBlockRteValueModel } from '../types.js'; import { UMB_BLOCK_RTE_WORKSPACE_MODAL, type UmbBlockRteWorkspaceOriginData, @@ -9,6 +9,7 @@ import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from './block-rte-manager.context-token import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '@umbraco-cms/backoffice/rte'; export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext< typeof UMB_BLOCK_RTE_MANAGER_CONTEXT, @@ -127,8 +128,24 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext< // create Block? override async delete(contentKey: string) { - // TODO: Loop through children and delete them as well? await super.delete(contentKey); this._manager?.deleteLayoutElement(contentKey); } + + protected async _insertFromPropertyValue(value: UmbBlockRteValueModel, originData: UmbBlockRteWorkspaceOriginData) { + const layoutEntries = value.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; + + if (!layoutEntries) { + throw new Error('No layout entries found'); + } + + await Promise.all( + layoutEntries.map(async (layoutEntry) => { + this._insertBlockFromPropertyValue(layoutEntry, value, originData); + // TODO: Missing some way to insert a Block HTML Element into the RTE at the current cursor point. (hopefully the responsibilit can be avoided here, but there is some connection missing at this point) [NL] + }), + ); + + return originData; + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts index 4fefc64e27ec..c56cfeebd5f1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts @@ -14,6 +14,9 @@ export class UmbBlockRteManagerContext< removeOneLayout(contentKey: string) { this._layouts.removeOne(contentKey); } + removeManyLayouts(contentKeys: Array) { + this._layouts.remove(contentKeys); + } create( contentElementTypeKey: string, diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/manifests.ts index db401f3ea267..be7b6f90d320 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/manifests.ts @@ -1,3 +1,4 @@ import { manifests as workspaceManifests } from './workspace/manifests.js'; +import { manifests as propertValueClonerManifests } from './property-value-cloner/manifests.js'; -export const manifests: Array = workspaceManifests; +export const manifests: Array = [...workspaceManifests, ...propertValueClonerManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/manifests.ts new file mode 100644 index 000000000000..333355db6785 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; + +export const manifests = [ + { + type: 'propertyValueCloner', + name: 'RTE Block Value Cloner', + alias: 'Umb.PropertyValueCloner.BlockRte', + api: () => import('./property-value-cloner-block-rte.cloner.js'), + forEditorAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.test.ts new file mode 100644 index 000000000000..cfc393562d9a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.test.ts @@ -0,0 +1,99 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbPropertyValueCloneController } from '@umbraco-cms/backoffice/property'; +import { manifests } from './manifests'; +import { + UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, + type UmbPropertyEditorUiValueType, +} from '@umbraco-cms/backoffice/rte'; + +@customElement('umb-test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbBlockRtePropertyValueCloner', () => { + describe('Cloner', () => { + beforeEach(async () => { + umbExtensionsRegistry.registerMany(manifests); + }); + afterEach(async () => { + umbExtensionsRegistry.unregisterMany(manifests.map((m) => m.alias)); + }); + + it('clones value', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, + alias: 'test', + culture: null, + segment: null, + value: { + markup: + '

the upper markup

the middle markup

the lower markup

', + blocks: { + layout: { + [UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]: [ + { + contentKey: 'content-1', + settingsKey: 'settings-1', + }, + ], + }, + contentData: [ + { + key: 'content-1', + contentTypeKey: 'fictive-content-type', + culture: null, + segment: null, + }, + ], + settingsData: [ + { + key: 'settings-1', + contentTypeKey: 'fictive-content-type', + culture: null, + segment: null, + }, + ], + expose: [ + { + contentKey: 'content-1', + culture: null, + segment: null, + }, + ], + }, + }, + }; + + const result = (await ctrl.clone(value)) as { value: UmbPropertyEditorUiValueType | undefined }; + + const newContentKey = result.value?.blocks.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey; + const newSettingsKey = result.value?.blocks.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].settingsKey; + + if (newContentKey === undefined) { + throw new Error('newContentKey is undefined'); + } + + expect(result.value?.markup.indexOf(newContentKey) !== 0).to.be.true; + + expect(result.value?.blocks.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey).to.not.be.equal( + 'content-1', + ); + expect(result.value?.blocks.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].settingsKey).to.not.be.equal( + 'settings-1', + ); + expect(result.value?.blocks.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]?.[0].contentKey).to.be.equal( + newContentKey, + ); + + expect(result.value?.blocks.contentData[0].key).to.not.be.equal('content-1'); + expect(result.value?.blocks.contentData[0].key).to.be.equal(newContentKey); + expect(result.value?.blocks.settingsData[0].key).to.not.be.equal('settings-1'); + expect(result.value?.blocks.settingsData[0].key).to.be.equal(newSettingsKey); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.ts new file mode 100644 index 000000000000..ea0ea1a18bd7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/property-value-cloner/property-value-cloner-block-rte.cloner.ts @@ -0,0 +1,44 @@ +import { + UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, + type UmbPropertyEditorUiValueType, +} from '@umbraco-cms/backoffice/rte'; +import type { UmbPropertyValueCloner } from '@umbraco-cms/backoffice/property'; +import { UmbFlatLayoutBlockPropertyValueCloner } from '@umbraco-cms/backoffice/block'; + +export class UmbBlockRTEPropertyValueCloner implements UmbPropertyValueCloner { + #markup?: string; + #markupDoc?: Document; + + async cloneValue(value: UmbPropertyEditorUiValueType) { + if (value) { + this.#markup = value.markup; + + const parser = new DOMParser(); + this.#markupDoc = parser.parseFromString(this.#markup, 'text/html'); + + const cloner = new UmbFlatLayoutBlockPropertyValueCloner(UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, { + contentIdUpdatedCallback: this.#replaceContentKeyInMarkup, + }); + const result = {} as UmbPropertyEditorUiValueType; + result.blocks = await cloner.cloneValue(value.blocks); + result.markup = this.#markup; + return result; + } + return value; + } + + #replaceContentKeyInMarkup = (contentKey: string, newContentKey: string) => { + if (!this.#markupDoc) throw new Error('Markup document is not initialized'); + const elements = this.#markupDoc.querySelectorAll( + `umb-rte-block[data-content-key='${contentKey}'], umb-rte-block-inline[data-content-key='${contentKey}']`, + ); + elements.forEach((element) => { + element.setAttribute('data-content-key', newContentKey); + }); + this.#markup = this.#markupDoc.body.innerHTML ?? undefined; + }; + + destroy(): void {} +} + +export { UmbBlockRTEPropertyValueCloner as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/constants.ts new file mode 100644 index 000000000000..442f12a0104e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/constants.ts @@ -0,0 +1 @@ +export const UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE = 'block'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/types.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/types.ts index e7f519fe5537..7161bbd537c3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/clipboard/types.ts @@ -1,11 +1,7 @@ -//import type { UmbClipboardEntry } from '@umbraco-cms/backoffice/clipboard'; +import type { UmbBlockDataModel, UmbBlockLayoutBaseModel } from '../types.js'; -/** - * A Clipboard entry for Blocks. - */ -//export type UmbBlockClipboardEntry = UmbClipboardEntry<'block', UmbBlockClipboardEntryMeta, any>; - -/*interface UmbBlockClipboardEntryMeta { - contentTypeAliases: Array; +export interface UmbBlockClipboardEntryValueModel { + contentData: Array; + settingsData: Array; + layout: Array | undefined; } -*/ diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts index f1a0e198aef1..99e7a726df00 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entries.context.ts @@ -1,5 +1,5 @@ import type { UmbBlockWorkspaceOriginData } from '../workspace/block-workspace.modal-token.js'; -import type { UmbBlockDataModel, UmbBlockLayoutBaseModel } from '../types.js'; +import type { UmbBlockDataModel, UmbBlockLayoutBaseModel, UmbBlockValueType } from '../types.js'; import type { UmbBlockDataObjectModel, UmbBlockManagerContext } from './block-manager.context.js'; import { UMB_BLOCK_ENTRIES_CONTEXT } from './block-entries.context-token.js'; import { type Observable, UmbArrayState, UmbBasicState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; @@ -112,4 +112,32 @@ export abstract class UmbBlockEntriesContext< } this._manager!.removeExposesOf(contentKey); } + + // insert/paste from property value methods: + + protected async _insertFromPropertyValues(values: Array, originData: BlockOriginData) { + await Promise.all( + values.map(async (value) => { + originData = await this._insertFromPropertyValue(value, originData); + }), + ); + } + + protected abstract _insertFromPropertyValue( + values: UmbBlockValueType, + originData: BlockOriginData, + ): Promise; + + protected async _insertBlockFromPropertyValue( + layoutEntry: BlockLayoutType, + value: UmbBlockValueType, + originData: BlockOriginData, + ) { + const content = value.contentData.find((x) => x.key === layoutEntry.contentKey); + if (!content) { + throw new Error('No content found for layout entry'); + } + const settings = value.settingsData.find((x) => x.key === layoutEntry.settingsKey); + await this.insert(layoutEntry, content, settings, originData); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts index 686d52f3a09f..1ed5d6463768 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts @@ -1,5 +1,5 @@ import type { UmbBlockManagerContext, UmbBlockWorkspaceOriginData } from '../index.js'; -import type { UmbBlockLayoutBaseModel, UmbBlockDataModel, UmbBlockDataType } from '../types.js'; +import type { UmbBlockLayoutBaseModel, UmbBlockDataModel, UmbBlockDataType, UmbBlockExposeModel } from '../types.js'; import type { UmbBlockEntriesContext } from './block-entries.context.js'; import type { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; @@ -84,6 +84,30 @@ export abstract class UmbBlockEntryContext< public readonly contentElementTypeAlias = this.#contentElementType.asObservablePart((x) => x?.alias); public readonly contentElementTypeIcon = this.#contentElementType.asObservablePart((x) => x?.icon); + /** + * Get the name of the content element type. + * @returns {string | undefined} - the name of the content element type. + */ + public getContentElementTypeName(): string | undefined { + return this.#contentElementType.getValue()?.name; + } + + /** + * Get the alias of the content element type. + * @returns {string | undefined} - the alias of the content element type. + */ + public getContentElementTypeAlias(): string | undefined { + return this.#contentElementType.getValue()?.alias; + } + + /** + * Get the icon of the content element type. + * @returns {string | undefined} - the icon of the content element type. + */ + public getContentElementTypeIcon(): string | undefined { + return this.#contentElementType.getValue()?.icon; + } + _blockType = new UmbObjectState(undefined); public readonly blockType = this._blockType.asObservable(); public readonly contentElementTypeKey = this._blockType.asObservablePart((x) => x?.contentElementTypeKey); @@ -97,8 +121,19 @@ export abstract class UmbBlockEntryContext< public readonly settingsKey = this._layout.asObservablePart((x) => (x ? (x.settingsKey ?? null) : undefined)); public readonly unique = this._layout.asObservablePart((x) => x?.contentKey); + /** + * Get the layout of the block. + * @returns {BlockLayoutType | undefined} - the layout of the block. + */ + public getLayout(): BlockLayoutType | undefined { + return this._layout.getValue(); + } + #label = new UmbStringState(''); public readonly label = this.#label.asObservable(); + public getLabel() { + return this.#label.getValue(); + } #labelRender = new UmbUfmVirtualRenderController(this); @@ -219,6 +254,14 @@ export abstract class UmbBlockEntryContext< return this.#contentValuesObservable; } + /** + * Get the content of the block. + * @returns {UmbBlockDataModel | undefined} - the content of the block. + */ + public getContent(): UmbBlockDataModel | undefined { + return this.#content.getValue(); + } + #settings = new UmbObjectState(undefined); //public readonly settings = this.#settings.asObservable(); protected readonly _settingsValueArray = this.#settings.asObservablePart((x) => x?.values); @@ -246,6 +289,14 @@ export abstract class UmbBlockEntryContext< return this.#settingsValuesObservable; } + /** + * Get the settings of the block. + * @returns {UmbBlockDataModel | undefined} - the settings of the block. + */ + public getSettings(): UmbBlockDataModel | undefined { + return this.#settings.getValue(); + } + abstract readonly showContentEdit: Observable; constructor( @@ -668,5 +719,12 @@ export abstract class UmbBlockEntryContext< this._manager?.setOneExpose(this.#contentKey, variantId); } - //copy + /** + * Get the expose of the block. + * @returns {UmbBlockExposeModel | undefined} - the expose of the block. + */ + public getExpose(): UmbBlockExposeModel | undefined { + const exposes = this._manager?.getExposes(); + return exposes?.find((x) => x.contentKey === this.#contentKey); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts index c08b9d67269b..a679c1cd815c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts @@ -98,25 +98,70 @@ export abstract class UmbBlockManagerContext< return this.#blockTypes.value; } + /** + * Set all layouts. + * @param {Array} layouts - All layouts. + */ setLayouts(layouts: Array) { this._layouts.setValue(layouts); } - getLayouts() { + + /** + * Get all layouts. + * @returns {Array} - All layouts. + */ + getLayouts(): Array { return this._layouts.getValue(); } + + /** + * Set all contents. + * @param {Array} contents - All contents. + */ setContents(contents: Array) { this.#contents.setValue(contents); } - getContents() { + + /** + * Get all contents. + * @returns {Array} - All contents. + */ + getContents(): Array { return this.#contents.value; } + + /** + * Set all settings. + * @param {Array} settings - All settings. + */ setSettings(settings: Array) { this.#settings.setValue(settings); } + + /** + * Get all settings. + * @returns {Array} - All settings. + */ + getSettings(): Array { + return this.#settings.value; + } + + /** + * Set all exposes. + * @param {Array} exposes - All exposes. + */ setExposes(exposes: Array) { this.#exposes.setValue(exposes); } + /** + * Get all exposes. + * @returns {Array} - All exposes. + */ + getExposes(): Array { + return this.#exposes.value; + } + constructor(host: UmbControllerHost) { super(host, UMB_BLOCK_MANAGER_CONTEXT); @@ -212,6 +257,9 @@ export abstract class UmbBlockManagerContext< getContentOf(contentKey: string) { return this.#contents.value.find((x) => x.key === contentKey); } + getSettingsOf(settingsKey: string) { + return this.#settings.value.find((x) => x.key === settingsKey); + } // originData param is used by some implementations. [NL] should be here, do not remove it. // eslint-disable-next-line @typescript-eslint/no-unused-vars setOneLayout(layoutData: BlockLayoutType, _originData?: BlockOriginDataType) { @@ -234,6 +282,14 @@ export abstract class UmbBlockManagerContext< removeOneSettings(settingsKey: string) { this.#settings.removeOne(settingsKey); } + + removeManyContent(contentKeys: Array) { + this.#contents.remove(contentKeys); + } + removeManySettings(settingsKeys: Array) { + this.#settings.remove(settingsKeys); + } + removeExposesOf(contentKey: string) { this.#exposes.filter((x) => x.contentKey !== contentKey); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts index fd6ca0502c2a..497575eb46c6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/index.ts @@ -1,6 +1,8 @@ +export * from './clipboard/index.js'; export * from './components/index.js'; export * from './context/index.js'; export * from './modals/index.js'; +export * from './property-value-cloner/index.js'; export * from './property-value-resolver/index.js'; export * from './validation/index.js'; export * from './workspace/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts index 92de7b54d6f0..d3b9dad65fa3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts @@ -12,6 +12,7 @@ import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/rou // TODO: This is across packages, how should we go about getting just a single element from another package? like here we just need the umb-block-type-card element import '@umbraco-cms/backoffice/block-type'; +import type { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; @customElement('umb-block-catalogue-modal') export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< @@ -105,6 +106,16 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< this.modalContext?.submit(); } + async #onClipboardPickerSelectionChange(event: UmbSelectionChangeEvent) { + const target = event.target as any; + const selection = target?.selection || []; + this.value = { + clipboard: { + selection, + }, + }; + } + override render() { return html` @@ -126,7 +137,11 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< } #renderClipboard() { - return html`Clipboard`; + return html``; } #renderCreateEmpty() { @@ -181,7 +196,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< ?active=${this._openClipboard} @click=${() => (this._openClipboard = true)}> Clipboard - + `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.token.ts index 743d5223795b..236be55faa93 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.token.ts @@ -1,12 +1,14 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; import type { UmbBlockWorkspaceData } from '@umbraco-cms/backoffice/block'; import type { UmbBlockTypeGroup, UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type'; +import type { UmbClipboardEntryDetailModel } from '@umbraco-cms/backoffice/clipboard'; export interface UmbBlockCatalogueModalData { blocks: Array; blockGroups?: Array; createBlockInWorkspace?: boolean; openClipboard?: boolean; + clipboardFilter?: (clipboardDetailEntryModel: UmbClipboardEntryDetailModel) => Promise; originData: UmbBlockWorkspaceData['originData']; } @@ -15,6 +17,9 @@ export type UmbBlockCatalogueModalValue = create?: { contentElementTypeKey: string; }; + clipboard?: { + selection: Array; + }; } | undefined; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/block-property-value-cloner.api.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/block-property-value-cloner.api.ts new file mode 100644 index 000000000000..f4345636fa19 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/block-property-value-cloner.api.ts @@ -0,0 +1,97 @@ +import type { UmbBlockLayoutBaseModel, UmbBlockValueType } from '../types.js'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { UmbPropertyValueCloner } from '@umbraco-cms/backoffice/property'; + +export type UmbBlockPropertyValueClonerArgs = { + contentIdUpdatedCallback?: (oldContentKey: string, newContentKey: string) => void; +}; + +export abstract class UmbBlockPropertyValueCloner< + ValueType extends UmbBlockValueType, + LayoutEntryType extends UmbBlockLayoutBaseModel = UmbBlockLayoutBaseModel, +> implements UmbPropertyValueCloner +{ + #contentIdUpdatedCallback?: UmbBlockPropertyValueClonerArgs['contentIdUpdatedCallback']; + + #propertyEditorAlias: string; + #contentData?: ValueType['contentData']; + #settingsData?: ValueType['settingsData']; + #expose?: ValueType['expose']; + + constructor(propertyEditorAlias: string, args?: UmbBlockPropertyValueClonerArgs) { + this.#propertyEditorAlias = propertyEditorAlias; + this.#contentIdUpdatedCallback = args?.contentIdUpdatedCallback; + } + + async cloneValue(value: ValueType) { + if (value) { + this.#contentData = value.contentData; + this.#settingsData = value.settingsData; + this.#expose = value.expose; + + const result = { + ...value, + layout: { + [this.#propertyEditorAlias]: await this._cloneLayout( + value.layout[this.#propertyEditorAlias] as unknown as Array, + ), + }, + contentData: this.#contentData, + settingsData: this.#settingsData, + expose: this.#expose, + }; + + return result; + } + return value; + } + + protected abstract _cloneLayout( + layouts: Array | undefined, + ): Promise | undefined> | undefined; + + protected async _cloneBlock(layoutEntry: LayoutEntryType): Promise { + const clonedLayoutEntry = { ...layoutEntry }; + + const contentKey = layoutEntry.contentKey; + const settingsKey = layoutEntry.settingsKey; + + // Generate new contentKey and settingsKey: + const newContentKey = UmbId.new(); + clonedLayoutEntry.contentKey = newContentKey; + + // Replace contentKeys in contentData + this.#contentData = this.#contentData?.map((contentData) => { + if (contentData.key === contentKey) { + return { ...contentData, key: newContentKey }; + } + return contentData; + }); + + // Replace contentKey in expose: + this.#expose = this.#expose?.map((expose) => { + if (expose.contentKey === contentKey) { + return { ...expose, contentKey: newContentKey }; + } + return expose; + }); + + this.#contentIdUpdatedCallback?.(contentKey, newContentKey); + + if (settingsKey) { + const newSettingsKey = UmbId.new(); + clonedLayoutEntry.settingsKey = newSettingsKey; + // Replace settingsKeys in settingsData + this.#settingsData = this.#settingsData?.map((settingsData) => { + if (settingsData.key === settingsKey) { + return { ...settingsData, key: newSettingsKey }; + } + return settingsData; + }); + } + + return clonedLayoutEntry; + } + + destroy(): void {} +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.api.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.api.ts new file mode 100644 index 000000000000..f0112b2ed9e7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.api.ts @@ -0,0 +1,12 @@ +import type { UmbBlockLayoutBaseModel, UmbBlockValueType } from '../types.js'; +import { UmbBlockPropertyValueCloner } from './block-property-value-cloner.api.js'; +export class UmbFlatLayoutBlockPropertyValueCloner< + ValueType extends UmbBlockValueType = UmbBlockValueType, +> extends UmbBlockPropertyValueCloner { + // + _cloneLayout( + layouts: Array | undefined, + ): Promise | undefined> | undefined { + return layouts ? Promise.all(layouts.map((layout) => this._cloneBlock(layout))) : undefined; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.test.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.test.ts new file mode 100644 index 000000000000..d92490db88b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/flat-layout-block-property-value-cloner.test.ts @@ -0,0 +1,94 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbFlatLayoutBlockPropertyValueCloner } from './flat-layout-block-property-value-cloner.api'; +import { UmbPropertyValueCloneController, type ManifestPropertyValueCloner } from '@umbraco-cms/backoffice/property'; +import type { UmbBlockValueType } from '../types'; + +@customElement('umb-test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +class TestUmbFlatLayoutBlockPropertyValueCloner extends UmbFlatLayoutBlockPropertyValueCloner { + constructor() { + super('TestEditor'); + } +} + +describe('FlatLayoutBlockPropertyValueCloner', () => { + describe('Cloner', () => { + beforeEach(async () => { + const manifestCloner: ManifestPropertyValueCloner = { + type: 'propertyValueCloner', + name: 'test-cloner-1', + alias: 'Umb.Test.Cloner.1', + api: TestUmbFlatLayoutBlockPropertyValueCloner, + forEditorAlias: 'block-test-editor', + }; + + umbExtensionsRegistry.register(manifestCloner); + }); + afterEach(async () => { + umbExtensionsRegistry.unregister('Umb.Test.Cloner.1'); + }); + + it('clones value', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'block-test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + layout: { + TestEditor: [ + { + contentKey: 'content-1', + settingsKey: 'settings-1', + }, + ], + }, + contentData: [ + { + key: 'content-1', + contentTypeKey: 'fictive-content-type', + culture: null, + segment: null, + }, + ], + settingsData: [ + { + key: 'settings-1', + contentTypeKey: 'fictive-content-type', + culture: null, + segment: null, + }, + ], + expose: [ + { + contentKey: 'content-1', + culture: null, + segment: null, + }, + ], + }, + }; + + const result = (await ctrl.clone(value)) as { value: UmbBlockValueType | undefined }; + + const newContentKey = result.value?.layout.TestEditor?.[0].contentKey; + const newSettingsKey = result.value?.layout.TestEditor?.[0].settingsKey; + + expect(result.value?.layout.TestEditor?.[0].contentKey).to.not.be.equal('content-1'); + expect(result.value?.layout.TestEditor?.[0].settingsKey).to.not.be.equal('settings-1'); + expect(result.value?.layout.TestEditor?.[0].contentKey).to.be.equal(newContentKey); + + expect(result.value?.contentData[0].key).to.not.be.equal('content-1'); + expect(result.value?.contentData[0].key).to.be.equal(newContentKey); + expect(result.value?.settingsData[0].key).to.not.be.equal('settings-1'); + expect(result.value?.settingsData[0].key).to.be.equal(newSettingsKey); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/index.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/index.ts new file mode 100644 index 000000000000..bbcfd96630f3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/property-value-cloner/index.ts @@ -0,0 +1,2 @@ +export * from './block-property-value-cloner.api.js'; +export * from './flat-layout-block-property-value-cloner.api.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/block/manifests.ts index a72748c4f91c..418d1b710a3f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/manifests.ts @@ -4,10 +4,11 @@ import { manifests as blockListManifests } from './block-list/manifests.js'; import { manifests as blockRteManifests } from './block-rte/manifests.js'; import { manifests as blockTypeManifests } from './block-type/manifests.js'; import { manifest as modalManifest } from './modals/manifest-viewer/manifest.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; // TODO: Remove test custom view, or transfer to test or similar? //import { manifest } from './custom-view/manifest.js'; -export const manifests: Array = [ +export const manifests: Array = [ //manifest, ...blockManifests, ...blockTypeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/constants.ts new file mode 100644 index 000000000000..9701e39eb5a4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/constants.ts @@ -0,0 +1,4 @@ +export * from './detail/constants.js'; +export * from './entity.js'; +export * from './item/constants.js'; +export * from './picker-modal/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.test.ts new file mode 100644 index 000000000000..f9e843cc7ce0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.test.ts @@ -0,0 +1,168 @@ +import { expect } from '@open-wc/testing'; +import { UmbClipboardEntryDetailLocalStorageDataSource } from './clipboard-entry-detail.local-storage.data-source.js'; +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../entity.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardEntryDetailLocalStorageDataSource', () => { + let hostElement: UmbTestControllerHostElement; + let dataSource: UmbClipboardEntryDetailLocalStorageDataSource; + const clipboardEntry: UmbClipboardEntryDetailModel = { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test' }], + icon: 'icon', + meta: {}, + name: 'Test', + unique: '123', + createDate: null, + updateDate: null, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + dataSource = new UmbClipboardEntryDetailLocalStorageDataSource(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a create method', () => { + expect(dataSource).to.have.property('create').that.is.a('function'); + }); + + it('has a read method', () => { + expect(dataSource).to.have.property('read').that.is.a('function'); + }); + + it('has a update method', () => { + expect(dataSource).to.have.property('update').that.is.a('function'); + }); + + it('has a delete method', () => { + expect(dataSource).to.have.property('delete').that.is.a('function'); + }); + }); + }); + + describe('Create', () => { + it('creates a new entry', async () => { + const response = await dataSource.create(clipboardEntry); + const compareEntry = { + ...clipboardEntry, + createDate: response.data?.createDate, + updateDate: response.data?.updateDate, + }; + expect(response.data).to.deep.equal(compareEntry); + }); + + it('returns an error if entry is missing', async () => { + // @ts-expect-error - Testing error case + const response = await dataSource.create(); + expect(response.error).to.be.an.instanceOf(Error); + }); + + it('has a createDate of today', async () => { + const today = new Date().toISOString().split('T')[0]; + const response = await dataSource.create(clipboardEntry); + expect(response.data?.createDate).to.be.a('string'); + expect(response.data?.createDate).to.include(today); + }); + }); + + describe('Read', () => { + it('reads an entry', async () => { + await dataSource.create(clipboardEntry); + const response = await dataSource.read(clipboardEntry.unique); + const compareEntry = { + ...clipboardEntry, + createDate: response.data?.createDate, + updateDate: response.data?.updateDate, + }; + expect(response.data).to.deep.equal(compareEntry); + }); + + it('returns an error if unique is missing', async () => { + // @ts-expect-error - Testing error case + const response = await dataSource.read(); + expect(response.error).to.be.an.instanceOf(Error); + }); + + it('returns an error if entry is not found', async () => { + const response = await dataSource.read('123'); + expect(response.error).to.be.an.instanceOf(Error); + }); + }); + + describe('Update', () => { + it('updates an entry', async () => { + await dataSource.create(clipboardEntry); + const updatedEntry = { ...clipboardEntry, values: [{ type: 'test', value: 'updated' }] }; + const response = await dataSource.update(updatedEntry); + expect(response.data?.values[0].value).to.equal('updated'); + }); + + it('returns an error if entry is missing', async () => { + // @ts-expect-error - Testing error case + const response = await dataSource.update(); + expect(response.error).to.be.an.instanceOf(Error); + }); + + it('returns an error if entry is not found', async () => { + const response = await dataSource.update(clipboardEntry); + expect(response.error).to.be.an.instanceOf(Error); + }); + + it('has an updateDate of today', async () => { + await dataSource.create(clipboardEntry); + const today = new Date().toISOString().split('T')[0]; + const updatedEntry = { ...clipboardEntry, data: ['updated'] }; + const response = await dataSource.update(updatedEntry); + expect(response.data?.updateDate).to.be.a('string'); + expect(response.data?.updateDate).to.include(today); + }); + }); + + describe('Delete', () => { + it('deletes an entry', async () => { + await dataSource.create(clipboardEntry); + await dataSource.delete(clipboardEntry.unique); + const response = await dataSource.read(clipboardEntry.unique); + expect(response.data).to.be.undefined; + }); + + it('returns an error if unique is missing', async () => { + // @ts-expect-error - Testing error case + const response = await dataSource.delete(); + expect(response.error).to.be.an.instanceOf(Error); + }); + + it('returns an error if entry is not found', async () => { + const response = await dataSource.delete('not-existing'); + expect(response.error).to.be.an.instanceOf(Error); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.ts new file mode 100644 index 000000000000..43f21e714a67 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.local-storage.data-source.ts @@ -0,0 +1,282 @@ +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UmbClipboardLocalStorageManager } from '../../clipboard-local-storage.manager.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../entity.js'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { + UmbDataSourceErrorResponse, + UmbDataSourceResponse, + UmbDetailDataSource, +} from '@umbraco-cms/backoffice/repository'; +import { ApiError } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +// TODO: these are temp solutions to comply to the ApiError interface +const localstorageFakeUrl = 'localstorage'; + +/** + * Manage clipboard entries in local storage + * @export + * @class UmbClipboardEntryDetailLocalStorageDataSource + * @implements {UmbDetailDataSource} + */ +export class UmbClipboardEntryDetailLocalStorageDataSource + extends UmbControllerBase + implements UmbDetailDataSource +{ + #localStorageManager = new UmbClipboardLocalStorageManager(this); + + /** + * Scaffold a new clipboard entry + * @param {Partial} [preset] + * @returns {*} + * @memberof UmbClipboardEntryDetailLocalStorageDataSource + */ + async createScaffold(preset: Partial = {}) { + const data: UmbClipboardEntryDetailModel = { + values: [], + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + icon: null, + meta: {}, + name: null, + unique: UmbId.new(), + createDate: null, + updateDate: null, + ...preset, + }; + + return { data }; + } + + /** + * Create a new clipboard entry in local storage + * @param {UmbClipboardEntryDetailModel} model + * @returns {*} {Promise>} + * @memberof UmbClipboardEntryDetailLocalStorageDataSource + */ + async create(model: UmbClipboardEntryDetailModel): Promise> { + if (!model) { + return { + error: new ApiError( + { + method: 'POST', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 400, + statusText: 'Bad Request', + url: localstorageFakeUrl, + body: {}, + }, + 'Clipboard entry is missing', + ), + }; + } + + // check if entry already exists + const entry = await this.#localStorageManager.getEntry(model.unique); + + if (entry) { + return { + error: new ApiError( + { + method: 'POST', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 400, + statusText: 'Bad Request', + url: localstorageFakeUrl, + body: {}, + }, + 'Clipboard entry already exists', + ), + }; + } + + const now = new Date().toISOString(); + const newEntry: UmbClipboardEntryDetailModel = structuredClone(model); + newEntry.createDate = now; + newEntry.updateDate = now; + + const entriesResult = await this.#localStorageManager.getEntries(); + const updatedEntries = [...entriesResult.entries, newEntry]; + + await this.#localStorageManager.setEntries(updatedEntries); + + return { data: newEntry }; + } + + /** + * Read a clipboard entry from local storage + * @param {string} unique + * @returns {*} {Promise>} + * @memberof UmbClipboardEntryDetailLocalStorageDataSource + */ + async read(unique: string): Promise> { + if (!unique) { + return { + error: new ApiError( + { + method: 'GET', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 400, + statusText: 'Bad Request', + url: localstorageFakeUrl, + body: {}, + }, + 'Unique is missing', + ), + }; + } + + // check if entry exists + const entry = await this.#localStorageManager.getEntry(unique); + + if (!entry) { + return { + error: new ApiError( + { + method: 'GET', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 404, + statusText: 'Not Found', + url: localstorageFakeUrl, + body: {}, + }, + 'Entry not found', + ), + }; + } + + return { data: entry }; + } + + /** + * Update a clipboard entry in local storage + * @param {UmbClipboardEntryDetailModel} model + * @returns {*} {Promise>} + * @memberof UmbClipboardEntryDetailLocalStorageDataSource + */ + async update(model: UmbClipboardEntryDetailModel): Promise> { + if (!model) { + return { + error: new ApiError( + { + method: 'PUT', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 400, + statusText: 'Bad Request', + url: localstorageFakeUrl, + body: {}, + }, + 'Clipboard entry is missing', + ), + }; + } + + // check if entry exists so it can be updated + const entry = await this.#localStorageManager.getEntry(model.unique); + if (!entry) { + return { + error: new ApiError( + { + method: 'GET', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 404, + statusText: 'Not Found', + url: localstorageFakeUrl, + body: {}, + }, + 'Entry not found', + ), + }; + } + + const entriesResult = await this.#localStorageManager.getEntries(); + + const updatedEntries = entriesResult.entries.map((storedEntry) => { + if (storedEntry.unique === model.unique) { + const updatedEntry: UmbClipboardEntryDetailModel = structuredClone(model); + updatedEntry.updateDate = new Date().toISOString(); + return updatedEntry; + } + + return storedEntry; + }); + + await this.#localStorageManager.setEntries(updatedEntries); + + const updatedEntry = updatedEntries.find((x) => x.unique === model.unique); + + return { data: updatedEntry }; + } + + /** + * Delete a clipboard entry from local storage + * @param {string} unique + * @returns {*} {Promise} + * @memberof UmbClipboardEntryDetailLocalStorageDataSource + */ + async delete(unique: string): Promise { + if (!unique) { + return { + error: new ApiError( + { + method: 'DELETE', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 400, + statusText: 'Bad Request', + url: localstorageFakeUrl, + body: {}, + }, + 'Unique is missing', + ), + }; + } + + // check if entry exist so it can be deleted + const entry = await this.#localStorageManager.getEntry(unique); + + if (!entry) { + return { + error: new ApiError( + { + method: 'GET', + url: localstorageFakeUrl, + }, + { + ok: false, + status: 404, + statusText: 'Not Found', + url: localstorageFakeUrl, + body: {}, + }, + 'Entry not found', + ), + }; + } + + const entriesResult = await this.#localStorageManager.getEntries(); + const updatedEntriesArray = entriesResult.entries.filter((x) => x.unique !== unique); + + await this.#localStorageManager.setEntries(updatedEntriesArray); + return {}; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.test.ts new file mode 100644 index 000000000000..be152e75937b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.test.ts @@ -0,0 +1,123 @@ +import { expect } from '@open-wc/testing'; +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../entity.js'; +import UmbClipboardEntryDetailRepository from './clipboard-entry-detail.repository.js'; +import UmbClipboardEntryDetailStore from './clipboard-entry-detail.store.js'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbCurrentUserStore(this); + new UmbClipboardEntryDetailStore(this); + new UmbNotificationContext(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardEntryDetailRepository', () => { + let hostElement: UmbTestControllerHostElement; + let repository: UmbClipboardEntryDetailRepository; + const detailData: UmbClipboardEntryDetailModel = { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test' }], + icon: 'icon', + meta: {}, + name: 'Test', + unique: '123', + createDate: null, + updateDate: null, + }; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + repository = new UmbClipboardEntryDetailRepository(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a create method', () => { + expect(repository).to.have.property('create').that.is.a('function'); + }); + + it('has a requestByUnique method', () => { + expect(repository).to.have.property('requestByUnique').that.is.a('function'); + }); + + it('has a save method', () => { + expect(repository).to.have.property('save').that.is.a('function'); + }); + + it('has a delete method', () => { + expect(repository).to.have.property('delete').that.is.a('function'); + }); + }); + }); + + describe('Create', () => { + it('creates a new entry', async () => { + const response = await repository.create(detailData); + expect(response.data).to.deep.equal({ + ...detailData, + // TODO: this is not testing anything. We can't use the response data to check the createDate and updateDate + createDate: response.data?.createDate, + updateDate: response.data?.updateDate, + }); + }); + }); + + describe('requestByUnique', () => { + it('requests an entry', async () => { + await repository.create(detailData); + const response = await repository.requestByUnique(detailData.unique); + expect(response.data).to.deep.equal({ + ...detailData, + // TODO: this is not testing anything. We can't use the response data to check the createDate and updateDate + createDate: response.data?.createDate, + updateDate: response.data?.updateDate, + }); + }); + }); + + describe('Update', () => { + it('updates an entry', async () => { + await repository.create(detailData); + const updatedEntry = { ...detailData, value: 'updated' }; + const response = await repository.save(updatedEntry); + expect(response.data).to.deep.equal({ + ...updatedEntry, + // TODO: this is not testing anything. We can't use the response data to check the createDate and updateDate + createDate: response.data?.createDate, + updateDate: response.data?.updateDate, + }); + }); + }); + + describe('Delete', () => { + it('deletes an entry', async () => { + // create an entry + await repository.create(detailData); + + // delete it + await repository.delete(detailData.unique); + const response = await repository.requestByUnique(detailData.unique); + expect(response.data).to.be.undefined; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.ts new file mode 100644 index 000000000000..6df7e07f6a54 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.repository.ts @@ -0,0 +1,17 @@ +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UmbClipboardEntryDetailLocalStorageDataSource } from './clipboard-entry-detail.local-storage.data-source.js'; +import { UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT } from './clipboard-entry-detail.store.context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +export class UmbClipboardEntryDetailRepository extends UmbDetailRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbClipboardEntryDetailLocalStorageDataSource, UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT); + } + + override async create(model: UmbClipboardEntryDetailModel) { + return super.create(model, null); + } +} + +export default UmbClipboardEntryDetailRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.context-token.ts new file mode 100644 index 000000000000..a969a17850fa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbClipboardEntryDetailStore } from './clipboard-entry-detail.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT = new UmbContextToken( + 'UmbClipboardEntryDetailStore', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.ts new file mode 100644 index 000000000000..3924297dc9fd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/clipboard-entry-detail.store.ts @@ -0,0 +1,22 @@ +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT } from './clipboard-entry-detail.store.context-token.js'; +import { UmbDetailStoreBase } from '@umbraco-cms/backoffice/store'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * @class UmbClipboardEntryDetailStore + * @augments {UmbStoreBase} + * @description - Data Store for Clipboard Details + */ +export class UmbClipboardEntryDetailStore extends UmbDetailStoreBase { + /** + * Creates an instance of UmbClipboardEntryDetailStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbClipboardEntryDetailStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT.toString()); + } +} + +export default UmbClipboardEntryDetailStore; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/constants.ts new file mode 100644 index 000000000000..1400ce5af199 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/constants.ts @@ -0,0 +1,3 @@ +export const UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS = 'Umb.Repository.ClipboardEntry.Detail'; +export const UMB_CLIPBOARD_ENTRY_DETAIL_STORE_ALIAS = 'Umb.Store.ClipboardEntry.Detail'; +export { UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT } from './clipboard-entry-detail.store.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/index.ts new file mode 100644 index 000000000000..4fe570f71b11 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/index.ts @@ -0,0 +1,3 @@ +export * from './clipboard-entry-detail.repository.js'; +export * from './clipboard-entry-detail.store.js'; +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/manifests.ts new file mode 100644 index 000000000000..f6a2cabd2a49 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/detail/manifests.ts @@ -0,0 +1,29 @@ +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../entity.js'; +import { UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS } from '../item/index.js'; +import { UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS, UMB_CLIPBOARD_ENTRY_DETAIL_STORE_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS, + name: 'Clipboard Detail Repository', + api: () => import('./clipboard-entry-detail.repository.js'), + }, + { + type: 'store', + alias: UMB_CLIPBOARD_ENTRY_DETAIL_STORE_ALIAS, + name: 'Clipboard Detail Store', + api: () => import('./clipboard-entry-detail.store.js'), + }, + { + type: 'entityAction', + kind: 'delete', + alias: 'Umb.EntityAction.ClipboardEntry.Delete', + name: 'Delete Dictionary Entry Entity Action', + forEntityTypes: [UMB_CLIPBOARD_ENTRY_ENTITY_TYPE], + meta: { + itemRepositoryAlias: UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS, + detailRepositoryAlias: UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/entity.ts new file mode 100644 index 000000000000..c10cd907aa62 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/entity.ts @@ -0,0 +1,3 @@ +export const UMB_CLIPBOARD_ENTRY_ENTITY_TYPE = 'clipboard-entry'; + +export type UmbClipboardEntryEntityType = typeof UMB_CLIPBOARD_ENTRY_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/global-element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/global-element.ts new file mode 100644 index 000000000000..b9d68f3acf22 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/global-element.ts @@ -0,0 +1 @@ +import './picker/clipboard-entry-picker.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/index.ts new file mode 100644 index 000000000000..6c9dd1a7152a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/index.ts @@ -0,0 +1,9 @@ +import './global-element.js'; + +export { UMB_CLIPBOARD_ENTRY_PICKER_MODAL } from './picker-modal/index.js'; +export { UmbClipboardEntryDetailRepository, UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS } from './detail/index.js'; +export { UmbClipboardEntryDetailStore } from './detail/index.js'; +export { UmbClipboardEntryItemRepository, UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS } from './item/index.js'; +export * from './entity.js'; + +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.local-storage.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.local-storage.data-source.ts new file mode 100644 index 000000000000..0e65deffef66 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.local-storage.data-source.ts @@ -0,0 +1,40 @@ +import { UmbClipboardLocalStorageManager } from '../../clipboard-local-storage.manager.js'; +import type { UmbClipboardEntryItemModel } from './types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbItemDataSource } from '@umbraco-cms/backoffice/repository'; + +/** + * A local storage data source for Clipboard Entry items + * @class UmbClipboardEntryItemServerDataSource + * @implements {UmbItemServerDataSourceBase} + */ +export class UmbClipboardEntryItemLocalStorageDataSource + extends UmbControllerBase + implements UmbItemDataSource +{ + #localStorageManager = new UmbClipboardLocalStorageManager(this); + + /** + * Gets items from local storage + * @param {Array} unique + * @memberof UmbClipboardEntryItemLocalStorageDataSource + */ + async getItems(unique: Array) { + const { entries } = await this.#localStorageManager.getEntries(); + const items = entries + .filter((entry) => unique.includes(entry.unique)) + .map((entry) => { + const item: UmbClipboardEntryItemModel = { + entityType: entry.entityType, + unique: entry.unique, + name: entry.name, + icon: entry.icon, + meta: entry.meta, + createDate: entry.createDate, + updateDate: entry.updateDate, + }; + return item; + }); + return { data: items }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.repository.ts new file mode 100644 index 000000000000..cc8cc2b14839 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.repository.ts @@ -0,0 +1,13 @@ +import { UmbClipboardEntryItemLocalStorageDataSource } from './clipboard-entry-item.local-storage.data-source.js'; +import { UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT } from './clipboard-entry-item.store.context-token.js'; +import type { UmbClipboardEntryItemModel } from './types.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +export class UmbClipboardEntryItemRepository extends UmbItemRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbClipboardEntryItemLocalStorageDataSource, UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT); + } +} + +export { UmbClipboardEntryItemRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.context-token.ts new file mode 100644 index 000000000000..03ab40ee456e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbClipboardEntryItemStore } from './clipboard-entry-item.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT = new UmbContextToken( + 'UmbClipboardEntryItemStore', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.ts new file mode 100644 index 000000000000..cadbe2d72b22 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/clipboard-entry-item.store.ts @@ -0,0 +1,23 @@ +import { UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT } from './clipboard-entry-item.store.context-token.js'; +import type { UmbClipboardEntryItemModel } from './types.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemStoreBase } from '@umbraco-cms/backoffice/store'; + +/** + * @class UmbClipboardEntryItemStore + * @augments {UmbStoreBase} + * @description - Data Store for Clipboard Entry items + */ + +export class UmbClipboardEntryItemStore extends UmbItemStoreBase { + /** + * Creates an instance of UmbClipboardEntryItemStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbClipboardEntryItemStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT.toString()); + } +} + +export { UmbClipboardEntryItemStore as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/constants.ts new file mode 100644 index 000000000000..6e03384a3f87 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/constants.ts @@ -0,0 +1,3 @@ +export const UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS = 'Umb.Repository.ClipboardEntryItem'; +export const UMB_CLIPBOARD_ENTRY_ITEM_STORE_ALIAS = 'Umb.Store.ClipboardEntryItem'; +export { UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT } from './clipboard-entry-item.store.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/index.ts new file mode 100644 index 000000000000..ad607b216761 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/index.ts @@ -0,0 +1,4 @@ +export * from './clipboard-entry-item.repository.js'; +export * from './constants.js'; + +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/manifests.ts new file mode 100644 index 000000000000..775114e3ee85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/manifests.ts @@ -0,0 +1,16 @@ +import { UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS, UMB_CLIPBOARD_ENTRY_ITEM_STORE_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS, + name: 'Clipboard Entry Item Repository', + api: () => import('./clipboard-entry-item.repository.js'), + }, + { + type: 'itemStore', + alias: UMB_CLIPBOARD_ENTRY_ITEM_STORE_ALIAS, + name: 'Clipboard Entry Item Store', + api: () => import('./clipboard-entry-item.store.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/types.ts new file mode 100644 index 000000000000..467e92a00fb6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/item/types.ts @@ -0,0 +1,32 @@ +import type { UmbClipboardEntryEntityType } from '../entity.js'; + +export interface UmbClipboardEntryItemModel { + entityType: UmbClipboardEntryEntityType; + + /** + * A unique identifier, ensures that this clipboard entry will be replaced if it gets copied later. + */ + unique: string; + /** + * The name of this clipboard entry. + */ + name: string | null; + /** + * The icon of the clipboard entry. + */ + icon: string | null; + /** + * The aliases of the content-types of these entries. + */ + meta: MetaType; + + /** + * The date the clipboard entry was created. + */ + createDate: string | null; + + /** + * The date the clipboard entry was last updated. + */ + updateDate: string | null; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/manifests.ts new file mode 100644 index 000000000000..6f9f126e419e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/manifests.ts @@ -0,0 +1,5 @@ +import { manifests as detailManifests } from './detail/manifests.js'; +import { manifests as itemManifests } from './item/manifests.js'; +import { manifests as pickerModalManifests } from './picker-modal/manifests.js'; + +export const manifests: Array = [...detailManifests, ...itemManifests, ...pickerModalManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts new file mode 100644 index 000000000000..b3e273cf94e5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.element.ts @@ -0,0 +1,50 @@ +import type { + UmbClipboardEntryPickerModalValue, + UmbClipboardEntryPickerModalData, +} from './clipboard-entry-picker-modal.token.js'; +import type { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; +import { html, customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; + +@customElement('umb-clipboard-entry-picker-modal') +export class UmbClipboardEntryPickerModalElement extends UmbModalBaseElement< + UmbClipboardEntryPickerModalData, + UmbClipboardEntryPickerModalValue +> { + #onSelectionChange(event: UmbSelectionChangeEvent) { + // TODO: make interface for picker element + const target = event.target as any; + this.updateValue({ selection: target.selection }); + } + + #submit() { + this.modalContext?.submit(); + } + + #close() { + this.modalContext?.reject(); + } + + override render() { + return html` + + + +
+ + +
+
`; + } +} + +export { UmbClipboardEntryPickerModalElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-clipboard-entry-picker-modal': UmbClipboardEntryPickerModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.token.ts new file mode 100644 index 000000000000..1421b7cb6da9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/clipboard-entry-picker-modal.token.ts @@ -0,0 +1,20 @@ +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS } from './constants.js'; +import { UmbModalToken, type UmbPickerModalData, type UmbPickerModalValue } from '@umbraco-cms/backoffice/modal'; + +export interface UmbClipboardEntryPickerModalData extends UmbPickerModalData { + asyncFilter?: (item: UmbClipboardEntryDetailModel) => Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbClipboardEntryPickerModalValue extends UmbPickerModalValue {} + +export const UMB_CLIPBOARD_ENTRY_PICKER_MODAL = new UmbModalToken< + UmbClipboardEntryPickerModalData, + UmbClipboardEntryPickerModalValue +>(UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS, { + modal: { + type: 'sidebar', + size: 'small', + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/constants.ts new file mode 100644 index 000000000000..a7cd5367fc38 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/constants.ts @@ -0,0 +1 @@ +export const UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS = 'Umb.Modal.ClipboardEntryPicker'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/index.ts new file mode 100644 index 000000000000..abd04e3c99c7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/index.ts @@ -0,0 +1 @@ +export { UMB_CLIPBOARD_ENTRY_PICKER_MODAL } from './clipboard-entry-picker-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/manifests.ts new file mode 100644 index 000000000000..24796dfd1174 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker-modal/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'modal', + alias: UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS, + name: 'Clipboard Item Picker Modal', + js: () => import('./clipboard-entry-picker-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts new file mode 100644 index 000000000000..0f0185994ae7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/picker/clipboard-entry-picker.element.ts @@ -0,0 +1,177 @@ +import { UmbClipboardCollectionRepository } from '../../collection/index.js'; +import type { UmbClipboardEntryDetailModel } from '../types.js'; +import { html, customElement, state, repeat, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbEntityContext, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { + UmbRequestReloadChildrenOfEntityEvent, + UmbRequestReloadStructureForEntityEvent, +} from '@umbraco-cms/backoffice/entity-action'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +// TODO: make this into an extension point (Picker) with two kinds of pickers: tree-item-picker and collection-item-picker; +@customElement('umb-clipboard-entry-picker') +export class UmbClipboardEntryPickerElement extends UmbLitElement { + @property({ type: Array }) + selection: Array = []; + + @property({ type: Object }) + config?: any; + + @state() + private _items: Array = []; + + #collectionRepository = new UmbClipboardCollectionRepository(this); + #selectionManager = new UmbSelectionManager(this); + #entityContext = new UmbEntityContext(this); + #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + + constructor() { + super(); + this.#entityContext.setEntityType('clipboard-entry'); + this.#entityContext.setUnique(null); + } + + override connectedCallback(): void { + super.connectedCallback(); + this.#selectionManager.setSelectable(true); + this.#selectionManager.setMultiple(this.config?.multiple ?? false); + this.#selectionManager.setSelection(this.selection ?? []); + + this.observe(this.#selectionManager.selection, (selection) => { + this.selection = selection; + }); + + this.#listenToEntityEvents(); + } + + override async firstUpdated() { + this.#requestItems(); + } + + async #requestItems() { + const { data } = await this.#collectionRepository.requestCollection({ + types: this.config?.entryTypes ?? [], + }); + + const entries = data?.items ?? []; + const sortedEntries = entries.sort((a, b) => new Date(b.updateDate!).getTime() - new Date(a.updateDate!).getTime()); + + if (this.config?.filter) { + this._items = sortedEntries.filter(this.config.filter); + return; + } + + if (this.config?.asyncFilter) { + const promises = Promise.all(sortedEntries.map(this.config.asyncFilter)); + const results = await promises; + this._items = sortedEntries.filter((_, index) => results[index]); + return; + } + + this._items = sortedEntries; + } + + async #listenToEntityEvents() { + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (context) => { + this.#actionEventContext = context; + + context?.removeEventListener( + UmbRequestReloadStructureForEntityEvent.TYPE, + this.#onReloadStructureRequest as unknown as EventListener, + ); + + context?.removeEventListener( + UmbRequestReloadChildrenOfEntityEvent.TYPE, + this.#onReloadChildrenRequest as unknown as EventListener, + ); + + context?.addEventListener( + UmbRequestReloadStructureForEntityEvent.TYPE, + this.#onReloadStructureRequest as unknown as EventListener, + ); + + context?.addEventListener( + UmbRequestReloadChildrenOfEntityEvent.TYPE, + this.#onReloadChildrenRequest as unknown as EventListener, + ); + }); + } + + #onReloadStructureRequest = (event: UmbRequestReloadStructureForEntityEvent) => { + const hasItem = this._items.some((item) => item.unique === event.getUnique()); + if (hasItem) { + this.#requestItems(); + } + }; + + #onReloadChildrenRequest = async (event: UmbRequestReloadChildrenOfEntityEvent) => { + // check if the collection is in the same context as the entity from the event + const unique = this.#entityContext.getUnique(); + const entityType = this.#entityContext.getEntityType(); + + if (unique === event.getUnique() && entityType === event.getEntityType()) { + this.#requestItems(); + } + }; + + override render() { + return html`${this._items.length > 0 + ? repeat( + this._items, + (item) => item.unique, + (item) => this.#renderItem(item), + ) + : html`There are no items in the clipboard`}`; + } + + #renderItem(item: UmbClipboardEntryDetailModel) { + return html` + this.#selectionManager.select(item.unique)} + @deselected=${() => this.#selectionManager.deselect(item.unique)} + ?selected=${this.selection.includes(item.unique)}> + ${this.#renderItemIcon(item)} ${this.#renderItemActions(item)} + + `; + } + + #renderItemIcon(item: UmbClipboardEntryDetailModel) { + const iconName = item.icon ?? 'icon-clipboard-entry'; + return html``; + } + + #renderItemActions(item: UmbClipboardEntryDetailModel) { + return html` + + + `; + } + + override destroy(): void { + this.#actionEventContext?.removeEventListener( + UmbRequestReloadStructureForEntityEvent.TYPE, + this.#onReloadStructureRequest as unknown as EventListener, + ); + + this.#actionEventContext?.removeEventListener( + UmbRequestReloadChildrenOfEntityEvent.TYPE, + this.#onReloadChildrenRequest as unknown as EventListener, + ); + + super.destroy(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-clipboard-entry-picker': UmbClipboardEntryPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/types.ts new file mode 100644 index 000000000000..622f237317e9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-entry/types.ts @@ -0,0 +1,32 @@ +import type { UmbClipboardEntryItemModel } from './item/types.js'; + +export interface UmbClipboardEntryValueModel { + type: string; + value: ValueType; +} + +export type UmbClipboardEntryValuesType = Array; + +/** + * A Clipboard entry is a composed set of data representing one entry in the clipboard. + * The entry has enough knowledge for the context of the clipboard to filter away unsupported entries. + */ +export interface UmbClipboardEntryDetailModel extends UmbClipboardEntryItemModel { + /** + * The values of the clipboard entry. + */ + values: UmbClipboardEntryValuesType; +} + +/** + * @deprecated + * @see UmbClipboardEntryDetailModel + */ +export interface UmbClipboardEntry { + type: Type; + unique: string; + name: string; + icons: Array; + meta: MetaType; + data: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.test.ts new file mode 100644 index 000000000000..77af57e0c30d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.test.ts @@ -0,0 +1,125 @@ +import { expect } from '@open-wc/testing'; +import { UmbClipboardLocalStorageManager } from './clipboard-local-storage.manager.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from './clipboard-entry/entity.js'; +import type { UmbClipboardEntryDetailModel } from './clipboard-entry/index.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; + +interface UmbTestClipboardEntryDetailModel extends UmbClipboardEntryDetailModel {} + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardLocalStorageManager', () => { + let hostElement: UmbTestControllerHostElement; + let manager: UmbClipboardLocalStorageManager; + const clipboardEntries: Array = [ + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test1' }], + icon: 'icon1', + meta: {}, + name: 'Test1', + unique: '1', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test2' }], + icon: 'icon2', + meta: {}, + name: 'Test2', + unique: '2', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test3' }], + icon: 'icon3', + meta: {}, + name: 'Test3', + unique: '3', + createDate: null, + updateDate: null, + }, + ]; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + manager = new UmbClipboardLocalStorageManager(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + await manager.setEntries(clipboardEntries); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a getEntries method', () => { + expect(manager).to.have.property('getEntries').that.is.a('function'); + }); + + it('has a getEntry method', () => { + expect(manager).to.have.property('getEntry').that.is.a('function'); + }); + + it('has a setEntries method', () => { + expect(manager).to.have.property('setEntries').that.is.a('function'); + }); + }); + }); + + describe('getEntries', () => { + it('returns all entries from local storage', async () => { + const { entries, total } = await manager.getEntries(); + expect(entries).to.deep.equal(clipboardEntries); + expect(total).to.equal(clipboardEntries.length); + }); + }); + + describe('getEntry', () => { + it('returns a single entry from local storage', async () => { + const entry = await manager.getEntry('2'); + expect(entry).to.deep.equal(clipboardEntries[1]); + }); + }); + + describe('setEntries', () => { + it('sets entries in local storage', async () => { + const newEntry: UmbClipboardEntryDetailModel = { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test4' }], + icon: 'icon4', + meta: {}, + name: 'Test4', + unique: '4', + createDate: null, + updateDate: null, + }; + await manager.setEntries([...clipboardEntries, newEntry]); + const { entries, total } = await manager.getEntries(); + expect(entries).to.deep.equal([...clipboardEntries, newEntry]); + expect(total).to.equal(clipboardEntries.length + 1); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.ts new file mode 100644 index 000000000000..434d3bbb0a29 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-local-storage.manager.ts @@ -0,0 +1,109 @@ +import type { UmbClipboardEntryDetailModel } from './clipboard-entry/index.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; + +const UMB_CLIPBOARD_LOCAL_STORAGE_KEY_PREFIX = 'umb:clipboard'; + +interface UmbClipboardLocalStorageFilterModel { + types?: Array; + skip?: number; + take?: number; +} + +// keep internal +export class UmbClipboardLocalStorageManager extends UmbControllerBase { + #currentUserUnique?: string; + #fingerprint?: string; + + constructor(host: UmbControllerHost) { + super(host); + + // TODO: look into encrypting the data + if (!window.isSecureContext && window.crypto) { + throw new Error('Clipboard local storage manager can only be used in a secure context'); + } + } + + // Gets all entries from local storage + async getEntries(): Promise<{ + entries: Array; + total: number; + }> { + const localStorageKey = await this.#requestLocalStorageKey(); + const localStorageItem = localStorage.getItem(localStorageKey); + const entries = localStorageItem ? JSON.parse(localStorageItem) : []; + const total = entries.length; + return { entries, total }; + } + + // Gets a single entry from local storage + async getEntry(unique: string): Promise { + const { entries } = await this.getEntries(); + return entries.find((x) => x.unique === unique); + } + + // Sets all entries in local storage + async setEntries(entries: Array) { + const currentUserUnique = await this.#requestCurrentUserUnique(); + + if (!currentUserUnique) { + throw new Error('Could not get current user unique'); + } + + const localStorageKey = await this.#requestLocalStorageKey(); + + localStorage.setItem(localStorageKey, JSON.stringify(entries)); + } + + // gets a filtered list of entries + async filter(filter: UmbClipboardLocalStorageFilterModel) { + const { entries } = await this.getEntries(); + const filteredEntries = this.#filterEntries(entries, filter); + const total = filteredEntries.length; + const skip = filter.skip || 0; + const take = filter.take || total; + const pagedEntries = filteredEntries.slice(skip, skip + take); + return { entries: pagedEntries, total }; + } + + #filterEntries(entries: Array, filter: UmbClipboardLocalStorageFilterModel) { + return entries.filter((entry) => { + if (filter.types?.length) { + const valueTypes = entry.values.map((x) => x.type); + return filter.types.some((type) => valueTypes.includes(type)); + } + return true; + }); + } + + async #requestLocalStorageKey() { + if (this.#fingerprint) { + return this.#fingerprint; + } + + const currentUserUnique = await this.#requestCurrentUserUnique(); + const fingerPrint = await this.#fingerPrint(`${UMB_CLIPBOARD_LOCAL_STORAGE_KEY_PREFIX}:${currentUserUnique}`); + this.#fingerprint = fingerPrint; + return this.#fingerprint; + } + + async #requestCurrentUserUnique() { + if (this.#currentUserUnique) { + return this.#currentUserUnique; + } + + const context = await this.getContext(UMB_CURRENT_USER_CONTEXT); + this.#currentUserUnique = context.getUnique(); + return this.#currentUserUnique; + } + + async #fingerPrint(text: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await window.crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/constants.ts new file mode 100644 index 000000000000..b49d8e6d3bef --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/constants.ts @@ -0,0 +1 @@ +export const UMB_CLIPBOARD_ROOT_WORKSPACE_ALIAS = 'Umb.Workspace.ClipboardRoot'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/entity.ts new file mode 100644 index 000000000000..da91bde44d95 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/entity.ts @@ -0,0 +1,3 @@ +export const UMB_CLIPBOARD_ROOT_ENTITY_TYPE = 'clipboard-root'; + +export type UmbClipboardRootEntityType = typeof UMB_CLIPBOARD_ROOT_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/index.ts new file mode 100644 index 000000000000..568ab94097bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/manifests.ts new file mode 100644 index 000000000000..a858821eb54b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/clipboard-root/manifests.ts @@ -0,0 +1,35 @@ +import { UMB_CLIPBOARD_COLLECTION_ALIAS } from '../collection/index.js'; +import { UMB_CLIPBOARD_ROOT_WORKSPACE_ALIAS } from './constants.js'; +import { UMB_CLIPBOARD_ROOT_ENTITY_TYPE } from './entity.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspace', + kind: 'default', + alias: UMB_CLIPBOARD_ROOT_WORKSPACE_ALIAS, + name: 'Clipboard Root Workspace', + meta: { + entityType: UMB_CLIPBOARD_ROOT_ENTITY_TYPE, + headline: 'Clipboard', + }, + }, + { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.ClipboardRoot.Collection', + name: 'Clipboard Root Collection Workspace View', + meta: { + label: 'Entries', + pathname: 'entries', + icon: 'icon-layers', + collectionAlias: UMB_CLIPBOARD_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_CLIPBOARD_ROOT_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/constants.ts new file mode 100644 index 000000000000..9267dd54a5d4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/constants.ts @@ -0,0 +1,3 @@ +export const UMB_CLIPBOARD_COLLECTION_ALIAS = 'Umb.Collection.Clipboard'; +export * from './repository/constants.js'; +export * from './views/table/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/index.ts new file mode 100644 index 000000000000..4a656c11ffef --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/index.ts @@ -0,0 +1,2 @@ +export { UmbClipboardCollectionRepository } from './repository/index.js'; +export { UMB_CLIPBOARD_COLLECTION_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/manifests.ts new file mode 100644 index 000000000000..e3f3acccf360 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/manifests.ts @@ -0,0 +1,18 @@ +import { UMB_CLIPBOARD_COLLECTION_ALIAS } from './constants.js'; +import { UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; +import { manifests as collectionRepositoryManifests } from './repository/manifests.js'; +import { manifests as collectionViewManifests } from './views/manifests.js'; + +export const manifests: Array = [ + { + type: 'collection', + kind: 'default', + alias: UMB_CLIPBOARD_COLLECTION_ALIAS, + name: 'Clipboard Collection', + meta: { + repositoryAlias: UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS, + }, + }, + ...collectionRepositoryManifests, + ...collectionViewManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.test.ts new file mode 100644 index 000000000000..8361aef86aaa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.test.ts @@ -0,0 +1,101 @@ +import { expect } from '@open-wc/testing'; +import { + UmbClipboardEntryDetailRepository, + UmbClipboardEntryDetailStore, + type UmbClipboardEntryDetailModel, +} from '../../clipboard-entry/index.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../../clipboard-entry/entity.js'; +import { UmbClipboardCollectionLocalStorageDataSource } from './clipboard-collection.local-storage.data-source.js'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbClipboardEntryDetailStore(this); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardLocalStorageDataSource', () => { + let hostElement: UmbTestControllerHostElement; + let detailRepository: UmbClipboardEntryDetailRepository; + let dataSource: UmbClipboardCollectionLocalStorageDataSource; + const clipboardEntries: Array = [ + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test1' }], + icon: 'icon1', + meta: {}, + name: 'Test1', + unique: '1', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test2' }], + icon: 'icon2', + meta: {}, + name: 'Test2', + unique: '2', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test3' }], + icon: 'icon3', + meta: {}, + name: 'Test3', + unique: '3', + createDate: null, + updateDate: null, + }, + ]; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + detailRepository = new UmbClipboardEntryDetailRepository(hostElement); + dataSource = new UmbClipboardCollectionLocalStorageDataSource(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + await detailRepository.create(clipboardEntries[0]); + await detailRepository.create(clipboardEntries[1]); + await detailRepository.create(clipboardEntries[2]); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a getCollection method', () => { + expect(dataSource).to.have.property('getCollection').that.is.a('function'); + }); + }); + }); + + describe('getCollection', () => { + it('should return all clipboard entries', async () => { + const result = await dataSource.getCollection({}); + expect(result.data.items).to.have.lengthOf(clipboardEntries.length); + expect(result.data.total).to.equal(clipboardEntries.length); + expect(result.data.items[0]).to.have.property('unique', '1'); + expect(result.data.items[1]).to.have.property('unique', '2'); + expect(result.data.items[2]).to.have.property('unique', '3'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.ts new file mode 100644 index 000000000000..b0750d97332e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.local-storage.data-source.ts @@ -0,0 +1,17 @@ +import type { UmbClipboardCollectionFilterModel } from '../types.js'; +import { UmbClipboardLocalStorageManager } from '../../clipboard-local-storage.manager.js'; +import type { UmbClipboardEntryDetailModel } from '../../clipboard-entry/index.js'; +import type { UmbCollectionDataSource } from '@umbraco-cms/backoffice/collection'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +export class UmbClipboardCollectionLocalStorageDataSource + extends UmbControllerBase + implements UmbCollectionDataSource +{ + #localStorageManager = new UmbClipboardLocalStorageManager(this); + + async getCollection(filter: UmbClipboardCollectionFilterModel) { + const { entries, total } = await this.#localStorageManager.filter(filter); + return { data: { items: entries, total } }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.test.ts new file mode 100644 index 000000000000..c182268db6bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.test.ts @@ -0,0 +1,107 @@ +import { expect } from '@open-wc/testing'; +import { + UmbClipboardEntryDetailRepository, + UmbClipboardEntryDetailStore, + type UmbClipboardEntryDetailModel, +} from '../../clipboard-entry/index.js'; +import { UMB_CLIPBOARD_ENTRY_ENTITY_TYPE } from '../../clipboard-entry/entity.js'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbClipboardCollectionRepository } from './clipboard-collection.repository.js'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbClipboardEntryDetailStore(this); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardLocalStorageDataSource', () => { + let hostElement: UmbTestControllerHostElement; + let detailRepository: UmbClipboardEntryDetailRepository; + let collectionRepository: UmbClipboardCollectionRepository; + const clipboardEntries: Array = [ + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test1' }], + icon: 'icon1', + meta: {}, + name: 'Test1', + unique: '1', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test2' }], + icon: 'icon2', + meta: {}, + name: 'Test2', + unique: '2', + createDate: null, + updateDate: null, + }, + { + entityType: UMB_CLIPBOARD_ENTRY_ENTITY_TYPE, + values: [{ type: 'test', value: 'test3' }], + icon: 'icon3', + meta: {}, + name: 'Test3', + unique: '3', + createDate: null, + updateDate: null, + }, + ]; + + describe('Public API', () => { + describe('methods', () => { + beforeEach(() => { + hostElement = new UmbTestControllerHostElement(); + collectionRepository = new UmbClipboardCollectionRepository(hostElement); + }); + + it('has a requestCollection method', () => { + expect(collectionRepository).to.have.property('requestCollection').that.is.a('function'); + }); + }); + }); + + describe('requestCollection', () => { + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + detailRepository = new UmbClipboardEntryDetailRepository(hostElement); + collectionRepository = new UmbClipboardCollectionRepository(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + await detailRepository.create(clipboardEntries[0]); + await detailRepository.create(clipboardEntries[1]); + await detailRepository.create(clipboardEntries[2]); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + it('should return all clipboard entries', async () => { + const result = await collectionRepository.requestCollection({}); + + expect(result.data.items).to.have.lengthOf(clipboardEntries.length); + expect(result.data.total).to.equal(clipboardEntries.length); + expect(result.data.items[0].unique).to.equal('1'); + expect(result.data.items[1].unique).to.equal('2'); + expect(result.data.items[2].unique).to.equal('3'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.ts new file mode 100644 index 000000000000..7498b9196856 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/clipboard-collection.repository.ts @@ -0,0 +1,14 @@ +import type { UmbClipboardCollectionFilterModel } from '../types.js'; +import { UmbClipboardCollectionLocalStorageDataSource } from './clipboard-collection.local-storage.data-source.js'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; + +export class UmbClipboardCollectionRepository extends UmbRepositoryBase implements UmbCollectionRepository { + #collectionSource = new UmbClipboardCollectionLocalStorageDataSource(this); + + async requestCollection(filter: UmbClipboardCollectionFilterModel) { + return this.#collectionSource.getCollection(filter); + } +} + +export { UmbClipboardCollectionRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/constants.ts new file mode 100644 index 000000000000..9b2e015469d0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS = 'Umb.Repository.ClipboardCollection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/index.ts new file mode 100644 index 000000000000..186250b99885 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './clipboard-collection.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/manifests.ts new file mode 100644 index 000000000000..6d32c762d726 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS, + name: 'Clipboard Collection Repository', + api: () => import('./clipboard-collection.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/types.ts new file mode 100644 index 000000000000..2f98e8e880a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/repository/types.ts @@ -0,0 +1,8 @@ +import type { UmbClipboardEntryDetailModel } from '../../clipboard-entry/index.js'; +import type { UmbClipboardCollectionFilterModel } from '../types.js'; +import type { UmbCollectionDataSource } from '@umbraco-cms/backoffice/collection'; + +export type UmbClipboardCollectionDataSource = UmbCollectionDataSource< + UmbClipboardEntryDetailModel, + UmbClipboardCollectionFilterModel +>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/types.ts new file mode 100644 index 000000000000..9c4f34e84d14 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/types.ts @@ -0,0 +1,5 @@ +export interface UmbClipboardCollectionFilterModel { + types?: Array; + skip?: number; + take?: number; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/manifests.ts new file mode 100644 index 000000000000..28a6feb6d99b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_CLIPBOARD_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_CLIPBOARD_TABLE_COLLECTION_VIEW_ALIAS } from './table/index.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + alias: UMB_CLIPBOARD_TABLE_COLLECTION_VIEW_ALIAS, + name: 'Clipboard Table Collection View', + js: () => import('./table/clipboard-table-collection-view.element.js'), + meta: { + label: 'Table', + icon: 'icon-list', + pathName: 'table', + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_CLIPBOARD_COLLECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/clipboard-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/clipboard-table-collection-view.element.ts new file mode 100644 index 000000000000..a598c936543a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/clipboard-table-collection-view.element.ts @@ -0,0 +1,81 @@ +import type { UmbClipboardEntryDetailModel } from '../../../clipboard-entry/index.js'; +import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; +import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; +import type { UmbTableColumn, UmbTableConfig, UmbTableItem } from '@umbraco-cms/backoffice/components'; +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'; + +@customElement('umb-clipboard-table-collection-view') +export class UmbClipboardTableCollectionViewElement extends UmbLitElement { + @state() + private _tableConfig: UmbTableConfig = { + allowSelection: false, + }; + + @state() + private _tableColumns: Array = [ + { + name: 'Name', + alias: 'name', + }, + ]; + + @state() + private _tableItems: Array = []; + + #collectionContext?: UmbDefaultCollectionContext; + + constructor() { + super(); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { + this.#collectionContext = instance; + this.#observeCollectionItems(); + }); + } + + #observeCollectionItems() { + if (!this.#collectionContext) return; + this.observe(this.#collectionContext.items, (items) => this.#createTableItems(items), 'umbCollectionItemsObserver'); + } + + #createTableItems(items: Array) { + this._tableItems = items.map((item) => { + return { + id: item.unique, + icon: item.icon ?? 'icon-clipboard-entry', + data: [ + { + columnAlias: 'name', + value: html`
${item.name}
`, + }, + ], + }; + }); + } + + override render() { + return html` + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbClipboardTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-clipboard-table-collection-view': UmbClipboardTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/constants.ts new file mode 100644 index 000000000000..807873b6f945 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/constants.ts @@ -0,0 +1 @@ +export const UMB_CLIPBOARD_TABLE_COLLECTION_VIEW_ALIAS = 'Umb.CollectionView.Clipboard.Table'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/collection/views/table/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/constants.ts new file mode 100644 index 000000000000..adfc175c42de --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/constants.ts @@ -0,0 +1,4 @@ +export * from './clipboard-entry/constants.js'; +export * from './clipboard-root/constants.js'; +export * from './collection/constants.js'; +export * from './property/context/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context-token.ts new file mode 100644 index 000000000000..feba0cfcdf62 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbClipboardContext } from './clipboard.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_CLIPBOARD_CONTEXT = new UmbContextToken('UmbClipboardContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.test.ts new file mode 100644 index 000000000000..b16ae5ad4c69 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.test.ts @@ -0,0 +1,69 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbClipboardContext } from './clipboard.context.js'; +import { UmbClipboardEntryDetailStore, type UmbClipboardEntryDetailModel } from '../clipboard-entry/index.js'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbClipboardEntryDetailStore(this); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardContext', () => { + let hostElement: UmbTestControllerHostElement; + let clipboardContext: UmbClipboardContext; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + clipboardContext = new UmbClipboardContext(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('write', () => { + it('should write an entry to the clipboard', async () => { + const preset: Partial = { + values: [{ type: 'test', value: 'test1' }], + icon: 'icon1', + meta: {}, + name: 'Test1', + }; + + const entry = await clipboardContext.write(preset); + expect(entry?.name).to.equal('Test1'); + }); + }); + + describe('read', () => { + it('should read an entry from the clipboard', async () => { + const preset: Partial = { + values: [{ type: 'test', value: 'test1' }], + icon: 'icon1', + meta: {}, + name: 'Test1', + }; + + const entry = await clipboardContext.write(preset); + const read = await clipboardContext.read(entry!.unique); + expect(read?.name).to.equal('Test1'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.ts new file mode 100644 index 000000000000..dc7b1de71919 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/clipboard.context.ts @@ -0,0 +1,47 @@ +import { UmbClipboardEntryDetailRepository, type UmbClipboardEntryDetailModel } from '../clipboard-entry/index.js'; +import { UMB_CLIPBOARD_CONTEXT } from './clipboard.context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * Clipboard context for managing clipboard entries + * @export + * @class UmbClipboardContext + * @augments {UmbContextBase} + */ +export class UmbClipboardContext extends UmbContextBase { + #clipboardDetailRepository = new UmbClipboardEntryDetailRepository(this); + + constructor(host: UmbControllerHost) { + super(host, UMB_CLIPBOARD_CONTEXT); + } + + /** + * Write to the clipboard + * @param {Partial} entryPreset - The preset for the clipboard entry + * @returns {Promise} + * @memberof UmbClipboardContext + */ + async write(entryPreset: Partial): Promise { + if (!entryPreset) throw new Error('Entry preset is required'); + + const { data: scaffoldData } = await this.#clipboardDetailRepository.createScaffold(entryPreset); + if (!scaffoldData) return; + + const { data } = await this.#clipboardDetailRepository.create(scaffoldData); + return data; + } + + /** + * Read from the clipboard + * @param {string} unique - The unique id of the clipboard entry + * @returns {Promise} - Returns the clipboard entry + * @memberof UmbClipboardContext + */ + async read(unique: string): Promise { + const { data } = await this.#clipboardDetailRepository.requestByUnique(unique); + return data; + } +} + +export { UmbClipboardContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/index.ts new file mode 100644 index 000000000000..958e561bb9ec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/index.ts @@ -0,0 +1,2 @@ +export * from './clipboard.context-token.js'; +export * from './clipboard.context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/manifests.ts new file mode 100644 index 000000000000..3e335529311b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/context/manifests.ts @@ -0,0 +1,8 @@ +export const manifests: Array = [ + { + type: 'globalContext', + alias: 'Umb.GlobalContext.Clipboard', + name: 'Clipboard Context', + api: () => import('./clipboard.context.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/index.ts new file mode 100644 index 000000000000..99a9efc44d31 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/index.ts @@ -0,0 +1,8 @@ +export * from './clipboard-entry/index.js'; +export * from './clipboard-root/index.js'; +export * from './collection/index.js'; +export * from './constants.js'; +export * from './context/index.js'; +export * from './property/index.js'; + +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/manifests.ts new file mode 100644 index 000000000000..41e0ea413c73 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/manifests.ts @@ -0,0 +1,14 @@ +import { manifests as collectionManifests } from './collection/manifests.js'; +import { manifests as contextManifests } from './context/manifests.js'; +import { manifests as entryManifests } from './clipboard-entry/manifests.js'; +import { manifests as propertyManifests } from './property/manifests.js'; +import { manifests as rootManifests } from './clipboard-root/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + ...collectionManifests, + ...contextManifests, + ...entryManifests, + ...propertyManifests, + ...rootManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/package.json b/src/Umbraco.Web.UI.Client/src/packages/clipboard/package.json new file mode 100644 index 000000000000..6a0b8eff27d5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/clipboard", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/copy-to-clipboard.property-action.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/copy-to-clipboard.property-action.ts new file mode 100644 index 000000000000..3395563069df --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/copy-to-clipboard.property-action.ts @@ -0,0 +1,73 @@ +import { UMB_CLIPBOARD_PROPERTY_CONTEXT } from '../../context/constants.js'; +import type { MetaPropertyActionCopyToClipboardKind } from './types.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UmbPropertyActionBase, type UmbPropertyActionArgs } from '@umbraco-cms/backoffice/property-action'; + +export class UmbCopyToClipboardPropertyAction extends UmbPropertyActionBase { + #propertyDatasetContext?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE; + #propertyContext?: typeof UMB_PROPERTY_CONTEXT.TYPE; + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + #clipboardContext?: typeof UMB_CLIPBOARD_PROPERTY_CONTEXT.TYPE; + #init?: Promise; + + constructor(host: UmbControllerHost, args: UmbPropertyActionArgs) { + super(host, args); + + this.#init = Promise.all([ + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.#propertyDatasetContext = context; + }).asPromise(), + + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.#propertyContext = context; + }).asPromise(), + + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => { + this.#notificationContext = context; + }).asPromise(), + + this.consumeContext(UMB_CLIPBOARD_PROPERTY_CONTEXT, (context) => { + this.#clipboardContext = context; + }).asPromise(), + ]); + } + + override async execute() { + await this.#init; + + if (!this.#propertyDatasetContext) throw new Error('Property dataset context is not available'); + if (!this.#propertyContext) throw new Error('Property context is not available'); + if (!this.#notificationContext) throw new Error('Notification context is not available'); + if (!this.#clipboardContext) throw new Error('Clipboard context is not available'); + + const propertyEditorUiAlias = this.#propertyContext.getEditorManifest()?.alias; + + if (!propertyEditorUiAlias) { + throw new Error('Property editor alias is not available'); + } + + const workspaceName = this.#propertyDatasetContext.getName(); + const propertyLabel = this.#propertyContext.getLabel()!; + const entryName = workspaceName ? `${workspaceName} - ${propertyLabel}` : propertyLabel; + + const propertyValue = this.#propertyContext.getValue(); + + if (!propertyValue) { + // TODO: Add correct message + localization + this.#notificationContext!.peek('danger', { data: { message: 'The property does not have a value to copy' } }); + return; + } + + const propertyEditorUiIcon = this.#propertyContext.getEditorManifest()?.meta.icon; + + this.#clipboardContext.write({ + name: entryName, + icon: propertyEditorUiIcon, + propertyValue, + propertyEditorUiAlias, + }); + } +} +export { UmbCopyToClipboardPropertyAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/manifests.ts new file mode 100644 index 000000000000..210b77bd61af --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/manifests.ts @@ -0,0 +1,22 @@ +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; +import { UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST } from '@umbraco-cms/backoffice/property-action'; + +export const manifests: Array = [ + { + type: 'kind', + alias: 'Umb.Kind.PropertyAction.CopyToClipboard', + matchKind: 'copyToClipboard', + matchType: 'propertyAction', + manifest: { + ...UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST.manifest, + type: 'propertyAction', + kind: 'copyToClipboard', + api: () => import('./copy-to-clipboard.property-action.js'), + weight: 1200, + meta: { + icon: 'icon-clipboard-copy', + label: 'Copy', + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/types.ts new file mode 100644 index 000000000000..12b3ef971cbb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/copy/types.ts @@ -0,0 +1,17 @@ +import type { ManifestPropertyAction, MetaPropertyAction } from '@umbraco-cms/backoffice/property-action'; + +export interface ManifestPropertyActionCopyToClipboardKind + extends ManifestPropertyAction { + type: 'propertyAction'; + kind: 'copyToClipboard'; +} + +export interface MetaPropertyActionCopyToClipboardKind extends MetaPropertyAction { + clipboardCopyResolverAlias?: string; +} + +declare global { + interface UmbExtensionManifestMap { + umbManifestPropertyActionCopyToClipboardKind: ManifestPropertyActionCopyToClipboardKind; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/manifests.ts new file mode 100644 index 000000000000..82301dae9ddf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/manifests.ts @@ -0,0 +1,5 @@ +import { manifests as copyManifests } from './copy/manifests.js'; +import { manifests as pasteManifests } from './paste/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [...copyManifests, ...pasteManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/manifests.ts new file mode 100644 index 000000000000..3c0ac1d98032 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/manifests.ts @@ -0,0 +1,22 @@ +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; +import { UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST } from '@umbraco-cms/backoffice/property-action'; + +export const manifests: Array = [ + { + type: 'kind', + alias: 'Umb.Kind.PropertyAction.pasteFromClipboard', + matchKind: 'pasteFromClipboard', + matchType: 'propertyAction', + manifest: { + ...UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST.manifest, + type: 'propertyAction', + kind: 'pasteFromClipboard', + api: () => import('./paste-from-clipboard.property-action.js'), + weight: 1190, + meta: { + icon: 'icon-clipboard-paste', + label: 'Replace', + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/paste-from-clipboard.property-action.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/paste-from-clipboard.property-action.ts new file mode 100644 index 000000000000..869b3ad05b76 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/paste-from-clipboard.property-action.ts @@ -0,0 +1,79 @@ +import { UmbClipboardEntryItemRepository } from '../../../clipboard-entry/index.js'; +import { UMB_CLIPBOARD_PROPERTY_CONTEXT } from '../../context/clipboard.property-context-token.js'; +import type { MetaPropertyActionPasteFromClipboardKind } from './types.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UmbPropertyActionBase, type UmbPropertyActionArgs } from '@umbraco-cms/backoffice/property-action'; + +export class UmbPasteFromClipboardPropertyAction extends UmbPropertyActionBase { + #init: Promise; + #propertyContext?: typeof UMB_PROPERTY_CONTEXT.TYPE; + #clipboardContext?: typeof UMB_CLIPBOARD_PROPERTY_CONTEXT.TYPE; + + constructor(host: UmbControllerHost, args: UmbPropertyActionArgs) { + super(host, args); + + this.#init = Promise.all([ + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.#propertyContext = context; + }).asPromise(), + + this.consumeContext(UMB_CLIPBOARD_PROPERTY_CONTEXT, (context) => { + this.#clipboardContext = context; + }).asPromise(), + ]); + } + + override async execute() { + await this.#init; + if (!this.#clipboardContext) throw new Error('Clipboard context not found'); + if (!this.#propertyContext) throw new Error('Property context not found'); + + const propertyEditorManifest = this.#propertyContext.getEditorManifest(); + + if (!propertyEditorManifest) { + throw new Error('Property editor manifest not found'); + } + + const result = await this.#clipboardContext.pick({ + propertyEditorUiAlias: propertyEditorManifest.alias, + multiple: false, + }); + + const selectedUnique = result.selection[0]; + const propertyValue = result.propertyValues[0]; + + if (!selectedUnique) { + throw new Error('No clipboard entry selected'); + } + + if (!propertyValue) { + throw new Error('No property value found'); + } + + const hasCurrentPropertyValue = this.#propertyContext.getValue(); + + if (hasCurrentPropertyValue) { + const clipboardEntryItemRepository = new UmbClipboardEntryItemRepository(this); + const { data } = await clipboardEntryItemRepository.requestItems([selectedUnique]); + + if (!data || data.length === 0) { + throw new Error('Clipboard entry not found'); + } + + const item = data[0]; + + // Todo: localize + await umbConfirmModal(this, { + headline: 'Paste from clipboard', + content: `The property already contains a value. Paste from the property action will overwrite the current value. + Do you want to replace the current value with ${item.name}?`, + confirmLabel: 'Paste', + }); + } + + this.#propertyContext?.setValue(propertyValue); + } +} +export { UmbPasteFromClipboardPropertyAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/types.ts new file mode 100644 index 000000000000..2fbb2310b507 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/paste/types.ts @@ -0,0 +1,16 @@ +import type { ManifestPropertyAction, MetaPropertyAction } from '@umbraco-cms/backoffice/property-action'; + +export interface ManifestPropertyActionPasteFromClipboardKind + extends ManifestPropertyAction { + type: 'propertyAction'; + kind: 'pasteFromClipboard'; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MetaPropertyActionPasteFromClipboardKind extends MetaPropertyAction {} + +declare global { + interface UmbExtensionManifestMap { + umbManifestPropertyActionPasteFromClipboardKind: ManifestPropertyActionPasteFromClipboardKind; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/types.ts new file mode 100644 index 000000000000..389a6d423d03 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/actions/types.ts @@ -0,0 +1,2 @@ +export type * from './copy/types.js'; +export type * from './paste/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context-token.ts new file mode 100644 index 000000000000..d83f7a11573f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context-token.ts @@ -0,0 +1,6 @@ +import type { UmbClipboardPropertyContext } from './clipboard.property-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_CLIPBOARD_PROPERTY_CONTEXT = new UmbContextToken( + 'UmbClipboardPropertyContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.test.ts new file mode 100644 index 000000000000..f9292598a970 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.test.ts @@ -0,0 +1,177 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { + UmbClipboardCopyPropertyValueTranslator, + UmbClipboardPastePropertyValueTranslator, +} from '../value-translator/types.js'; +import { UmbClipboardEntryDetailStore, type UmbClipboardEntryDetailModel } from '../../clipboard-entry/index.js'; +import { UmbClipboardPropertyContext } from './clipboard.property-context.js'; +import { UmbClipboardContext } from '../../context/clipboard.context.js'; + +const TEST_PROPERTY_EDITOR_UI_ALIAS = 'testPropertyEditorUiAlias'; +const TEST_CLIPBOARD_ENTRY_VALUE_TYPE = 'testClipboardEntryValueType'; + +class UmbTestClipboardCopyPropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + async translate(propertyValue: string): Promise { + const cleanedValue = propertyValue.replaceAll(' property value', ''); + return cleanedValue + ' clipboard value'; + } +} + +const copyTranslatorManifest = { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Test.ClipboardCopyPropertyValueTranslator1', + name: 'Test Clipboard Copy Property Value Translator 1', + api: UmbTestClipboardCopyPropertyValueTranslator, + fromPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE, +}; + +class UmbTestClipboardPastePropertyValueTranslator + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + async translate(clipboardEntryValue: string): Promise { + const cleanedValue = clipboardEntryValue.replaceAll(' clipboard value', ''); + return cleanedValue + ' property value'; + } +} + +const pasteTranslatorManifest = { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Test.ClipboardPastePropertyValueTranslator1', + name: 'Test Clipboard Paste Property Value Translator 1', + api: UmbTestClipboardPastePropertyValueTranslator, + weight: 1, + fromClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE, + toPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, +}; + +const propertyEditorManifest = { + type: 'propertyEditorUi', + alias: TEST_PROPERTY_EDITOR_UI_ALIAS, + name: 'Test Property Editor UI', + meta: { + label: 'Test Property Editor', + icon: 'document', + group: 'Common', + propertyEditorSchemaAlias: 'Umbraco.TextBox', + }, +}; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbClipboardEntryDetailStore(this); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + new UmbClipboardContext(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbClipboardPropertyContext', () => { + let hostElement: UmbTestControllerHostElement; + let clipboardContext: UmbClipboardPropertyContext; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + clipboardContext = new UmbClipboardPropertyContext(hostElement); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + }); + + describe('clipboard for property values', () => { + describe('write', () => { + let clipboardEntry: UmbClipboardEntryDetailModel | undefined; + + beforeEach(async () => { + umbExtensionsRegistry.registerMany([pasteTranslatorManifest, copyTranslatorManifest, propertyEditorManifest]); + + clipboardEntry = await clipboardContext.write({ + name: 'Test1', + icon: 'icon1', + propertyValue: 'test1', + propertyEditorUiAlias: TEST_PROPERTY_EDITOR_UI_ALIAS, + }); + }); + + afterEach(() => { + umbExtensionsRegistry.clear(); + }); + + it('should read an entry from the clipboard for a property', async () => { + expect(clipboardEntry?.name).to.equal('Test1'); + expect(clipboardEntry?.values[0].type).to.equal(TEST_CLIPBOARD_ENTRY_VALUE_TYPE); + expect(clipboardEntry?.values[0].value).to.equal('test1 clipboard value'); + }); + + it('should read an entry from the clipboard for a property', async () => { + const propertyValue = await clipboardContext.read( + clipboardEntry!.unique, + TEST_PROPERTY_EDITOR_UI_ALIAS, + ); + expect(propertyValue).to.equal('test1 property value'); + }); + }); + }); + + describe('getPasteTranslatorManifests', () => { + beforeEach(async () => { + umbExtensionsRegistry.registerMany([pasteTranslatorManifest]); + }); + + afterEach(() => { + umbExtensionsRegistry.clear(); + }); + + it('should return the paste property value translator manifests', () => { + const manifests = clipboardContext.getPasteTranslatorManifests(TEST_PROPERTY_EDITOR_UI_ALIAS); + expect(manifests).to.have.lengthOf(1); + expect(manifests[0].alias).to.equal(pasteTranslatorManifest.alias); + }); + }); + + describe('hasSupportedPasteTranslator', () => { + beforeEach(async () => { + umbExtensionsRegistry.registerMany([pasteTranslatorManifest]); + }); + + afterEach(() => { + umbExtensionsRegistry.clear(); + }); + + it('should return true if a supported paste property value translator is available', () => { + const manifests = clipboardContext.getPasteTranslatorManifests(TEST_PROPERTY_EDITOR_UI_ALIAS); + const values = [{ type: TEST_CLIPBOARD_ENTRY_VALUE_TYPE, value: 'test clipboard value' }]; + const hasSupported = clipboardContext.hasSupportedPasteTranslator(manifests, values); + expect(hasSupported).to.be.true; + }); + + it('should return false if no supported paste property value translator is available', () => { + const manifests = clipboardContext.getPasteTranslatorManifests(TEST_PROPERTY_EDITOR_UI_ALIAS); + const values = [{ type: 'unsupported', value: 'test clipboard value' }]; + const hasSupported = clipboardContext.hasSupportedPasteTranslator(manifests, values); + expect(hasSupported).to.be.false; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.ts new file mode 100644 index 000000000000..48ce7b74ffc0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/clipboard.property-context.ts @@ -0,0 +1,312 @@ +import { UMB_CLIPBOARD_CONTEXT } from '../../context/index.js'; +import { + UMB_CLIPBOARD_ENTRY_PICKER_MODAL, + type UmbClipboardEntryDetailModel, + type UmbClipboardEntryValuesType, +} from '../../clipboard-entry/index.js'; +import type { ManifestClipboardPastePropertyValueTranslator } from '../value-translator/types.js'; +import { + UmbClipboardCopyPropertyValueTranslatorValueResolver, + UmbClipboardPastePropertyValueTranslatorValueResolver, +} from '../value-translator/index.js'; +import { UMB_CLIPBOARD_PROPERTY_CONTEXT } from './clipboard.property-context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { UMB_PROPERTY_CONTEXT, UmbPropertyValueCloneController } from '@umbraco-cms/backoffice/property'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestPropertyEditorUi, UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UMB_CONTEXT_REQUEST_EVENT_TYPE, type UmbContextRequestEvent } from '@umbraco-cms/backoffice/context-api'; + +/** + * Clipboard context for managing clipboard entries for property values + * @export + * @class UmbClipboardPropertyContext + * @augments {UmbContextBase} + */ +export class UmbClipboardPropertyContext extends UmbContextBase { + #init?: Promise; + + #modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; + #propertyContext?: typeof UMB_PROPERTY_CONTEXT.TYPE; + #hostElement?: Element; + #propertyEditorElement?: UmbPropertyEditorUiElement; + + constructor(host: UmbControllerHost) { + super(host, UMB_CLIPBOARD_PROPERTY_CONTEXT); + + this.#hostElement = host.getHostElement(); + + this.#init = Promise.all([ + this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (context) => { + this.#modalManagerContext = context; + }).asPromise(), + + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.#propertyContext = context; + this.#propertyEditorElement = context.getEditor(); + }).asPromise(), + ]); + + this.#hostElement.addEventListener( + UMB_CONTEXT_REQUEST_EVENT_TYPE, + this.#proxyContextRequest.bind(this) as EventListener, + ); + } + + /** + * Read a clipboard entry for a property. The entry will be translated to the property editor value + * @param {string} unique - The unique id of the clipboard entry + * @param {string} propertyEditorUiAlias - The alias of the property editor to match + * @returns { Promise } - Returns the resolved property value + */ + async read(unique: string, propertyEditorUiAlias: string): Promise { + if (!unique) throw new Error('The Clipboard Entry unique is required'); + if (!propertyEditorUiAlias) throw new Error('Property Editor UI alias is required'); + const manifest = await this.#findPropertyEditorUiManifest(propertyEditorUiAlias); + return this.#resolvePropertyValue(unique, manifest); + } + + /** + * Read multiple clipboard entries for a property. The entries will be translated to the property editor values + * @param {Array} uniques - The unique ids of the clipboard entries + * @param {string} propertyEditorUiAlias - The alias of the property editor to match + * @returns { Promise> } - Returns an array of resolved property values + */ + async readMultiple( + uniques: Array, + propertyEditorUiAlias: string, + ): Promise> { + if (!uniques || !uniques.length) { + throw new Error('Clipboard entry uniques are required'); + } + + const promises = Promise.allSettled(uniques.map((unique) => this.read(unique, propertyEditorUiAlias))); + + const readResult = await promises; + // TODO:show message if some entries are not fulfilled + const fulfilledResult = readResult.filter((result) => result.status === 'fulfilled' && result.value) as Array< + PromiseFulfilledResult + >; + // Map the values and remove undefined. + const propertyValues = fulfilledResult.map((result) => result.value).filter((x) => x); + + if (!propertyValues.length) { + throw new Error('Failed to read clipboard entries'); + } + + return propertyValues; + } + + /** + * Write a clipboard entry for a property. The property value will be translated to the clipboard entry values + * @param args - Arguments for writing a clipboard entry + * @param {string} args.name - The name of the clipboard entry + * @param {string} args.icon - The icon of the clipboard entry + * @param {any} args.propertyValue - The property value to write + * @param {string} args.propertyEditorUiAlias - The alias of the property editor to match + * @returns { Promise } + */ + async write(args: { + name: string; + icon?: string; + propertyValue: any; + propertyEditorUiAlias: string; + }): Promise { + const clipboardContext = await this.getContext(UMB_CLIPBOARD_CONTEXT); + + const copyValueResolver = new UmbClipboardCopyPropertyValueTranslatorValueResolver(this); + const values = await copyValueResolver.resolve(args.propertyValue, args.propertyEditorUiAlias); + + const entryPreset: Partial = { + name: args.name, + values, + icon: args.icon, + }; + + return await clipboardContext.write(entryPreset); + } + + /** + * Pick a clipboard entry for a property. The entry will be translated to the property editor value + * @param args - Arguments for picking a clipboard entry + * @param {boolean} args.multiple - Allow multiple clipboard entries to be picked + * @param {string} args.propertyEditorUiAlias - The alias of the property editor to match + * @returns { Promise<{ selection: Array; propertyValues: Array }> } + */ + async pick(args: { + multiple: boolean; + propertyEditorUiAlias: string; + }): Promise<{ selection: Array; propertyValues: Array }> { + await this.#init; + + const pasteTranslatorManifests = this.getPasteTranslatorManifests(args.propertyEditorUiAlias); + const propertyEditorUiManifest = await this.#findPropertyEditorUiManifest(args.propertyEditorUiAlias); + const config = this.#propertyContext?.getConfig(); + + const valueResolver = new UmbClipboardPastePropertyValueTranslatorValueResolver(this); + + const modal = this.#modalManagerContext?.open(this, UMB_CLIPBOARD_ENTRY_PICKER_MODAL, { + data: { + asyncFilter: async (clipboardEntryDetail) => { + const hasSupportedPasteTranslator = this.hasSupportedPasteTranslator( + pasteTranslatorManifests, + clipboardEntryDetail.values, + ); + + if (!hasSupportedPasteTranslator) { + return false; + } + + const pasteTranslator = await valueResolver.getPasteTranslator( + clipboardEntryDetail.values, + propertyEditorUiManifest.alias, + ); + + if (pasteTranslator.isCompatibleValue) { + const value = await valueResolver.resolve(clipboardEntryDetail.values, propertyEditorUiManifest.alias); + return pasteTranslator.isCompatibleValue(value, config); + } + + return true; + }, + }, + }); + + const result = await modal?.onSubmit(); + const selection = result?.selection || []; + + if (!selection.length) { + throw new Error('No clipboard entry selected'); + } + + let propertyValues: Array = []; + + if (args.multiple) { + throw new Error('Multiple clipboard entries not supported'); + } else { + const selected = selection[0]; + + if (!selected) { + throw new Error('No clipboard entry selected'); + } + + const propertyValue = await this.#resolvePropertyValue(selected, propertyEditorUiManifest); + propertyValues = [propertyValue]; + } + + return { + selection, + propertyValues, + }; + } + + async #findPropertyEditorUiManifest(alias: string): Promise { + const manifest = umbExtensionsRegistry.getByAlias(alias); + + if (!manifest) { + throw new Error(`Could not find property editor with alias: ${alias}`); + } + + if (manifest.type !== 'propertyEditorUi') { + throw new Error(`Alias ${alias} is not a property editor ui`); + } + + return manifest; + } + + async #resolvePropertyValue( + clipboardEntryUnique: string, + propertyEditorUiManifest: ManifestPropertyEditorUi, + ): Promise { + if (!clipboardEntryUnique) { + throw new Error('Unique id is required'); + } + + if (!propertyEditorUiManifest.alias) { + throw new Error('Property Editor UI alias is required'); + } + + if (!propertyEditorUiManifest.meta.propertyEditorSchemaAlias) { + throw new Error('Property Editor UI Schema alias is required'); + } + + const clipboardContext = await this.getContext(UMB_CLIPBOARD_CONTEXT); + const entry = await clipboardContext.read(clipboardEntryUnique); + + if (!entry) { + throw new Error(`Could not find clipboard entry with unique id: ${clipboardEntryUnique}`); + } + + const valueResolver = new UmbClipboardPastePropertyValueTranslatorValueResolver(this); + const propertyValue = await valueResolver.resolve(entry.values, propertyEditorUiManifest.alias); + + const cloner = new UmbPropertyValueCloneController(this); + const clonedValue = await cloner.clone({ + editorAlias: propertyEditorUiManifest.meta.propertyEditorSchemaAlias, + alias: propertyEditorUiManifest.alias, + value: propertyValue, + }); + + return clonedValue.value; + } + + /** + * Get all clipboard paste translators for a property editor ui + * @param {string} propertyEditorUiAlias - The alias of the property editor to match + * @returns {Array} - Returns an array of clipboard paste translators + */ + getPasteTranslatorManifests(propertyEditorUiAlias: string) { + return umbExtensionsRegistry.getByTypeAndFilter( + 'clipboardPastePropertyValueTranslator', + (manifest) => manifest.toPropertyEditorUi === propertyEditorUiAlias, + ); + } + + /** + * Check if the clipboard entry values has supported paste translator + * @param {Array} manifests - The paste translator manifests + * @param {UmbClipboardEntryValuesType} clipboardEntryValues - The clipboard entry values + * @returns {boolean} - Returns true if the clipboard entry values has supported paste translator + */ + hasSupportedPasteTranslator( + manifests: Array, + clipboardEntryValues: UmbClipboardEntryValuesType, + ): boolean { + const entryValueTypes = clipboardEntryValues.map((x) => x.type); + + const supportedManifests = manifests.filter((manifest) => { + const canTranslateValue = entryValueTypes.includes(manifest.fromClipboardEntryValueType); + return canTranslateValue; + }); + + return supportedManifests.length > 0; + } + + #proxyContextRequest(event: UmbContextRequestEvent) { + const path = event.composedPath(); + + // Ignore events from the property editor element so we don't end up in a loop when proxying the requests. + if (path.includes(this.#propertyEditorElement as EventTarget)) { + return; + } + + // Proxy all context requests to the property editor element so the clipboard actions, translators and filters + // can consume contexts from property editor root element. + if (this.#propertyEditorElement) { + event.stopImmediatePropagation(); + this.#propertyEditorElement.dispatchEvent(event.clone()); + } + } + + override destroy(): void { + super.destroy(); + this.#hostElement?.removeEventListener( + UMB_CONTEXT_REQUEST_EVENT_TYPE, + this.#proxyContextRequest.bind(this) as EventListener, + ); + } +} + +export { UmbClipboardPropertyContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/constants.ts new file mode 100644 index 000000000000..1458bf18155e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/constants.ts @@ -0,0 +1 @@ +export * from './clipboard.property-context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/manifests.ts new file mode 100644 index 000000000000..8e72f153e201 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/manifests.ts @@ -0,0 +1,16 @@ +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'kind', + alias: 'Umb.Kind.PropertyContext.Clipboard', + matchKind: 'clipboard', + matchType: 'propertyContext', + manifest: { + type: 'propertyContext', + kind: 'clipboard', + api: () => import('./clipboard.property-context.js'), + weight: 1200, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/types.ts new file mode 100644 index 000000000000..70bd908a0318 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/context/types.ts @@ -0,0 +1,16 @@ +import type { ManifestPropertyContext, MetaPropertyContext } from '@umbraco-cms/backoffice/property'; + +export interface ManifestPropertyContextClipboardKind + extends ManifestPropertyContext { + type: 'propertyContext'; + kind: 'clipboard'; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MetaPropertyContextClipboardKind extends MetaPropertyContext {} + +declare global { + interface UmbExtensionManifestMap { + umbManifestPropertyContextClipboardKind: ManifestPropertyContextClipboardKind; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/index.ts new file mode 100644 index 000000000000..eb5c3fe3aa54 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/index.ts @@ -0,0 +1 @@ +export * from './value-translator/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/manifests.ts new file mode 100644 index 000000000000..eded6764dc0c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/manifests.ts @@ -0,0 +1,8 @@ +import { manifests as actionManifests } from './actions/manifests.js'; +import { manifests as contextManifests } from './context/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + ...actionManifests, + ...contextManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/types.ts new file mode 100644 index 000000000000..85865ab52eb6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/types.ts @@ -0,0 +1,2 @@ +export type * from './actions/types.js'; +export type * from './value-translator/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.test.ts new file mode 100644 index 000000000000..1df33343237b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.test.ts @@ -0,0 +1,102 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbClipboardCopyPropertyValueTranslatorValueResolver } from './clipboard-copy-translator-value-resolver.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardCopyPropertyValueTranslator } from './types.js'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbClipboardEntryValueModel } from '../../../clipboard-entry/index.js'; + +const TEST_PROPERTY_EDITOR_UI_ALIAS = 'testPropertyEditorUiAlias'; +const TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1 = 'testClipboardEntryValueType1'; +const TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2 = 'testClipboardEntryValueType2'; + +type TestValueType = String; + +class UmbTestClipboardCopyPropertyValueTranslator1 + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + async translate(value: TestValueType): Promise { + return value + '1'; + } +} + +class UmbTestClipboardCopyPropertyValueTranslator2 + extends UmbControllerBase + implements UmbClipboardCopyPropertyValueTranslator +{ + async translate(value: TestValueType): Promise { + return value + '2'; + } +} + +const copyTranslatorManifest1 = { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Test.ClipboardCopyPropertyValueTranslator1', + name: 'Test Clipboard Copy Property Value Translator 1', + api: UmbTestClipboardCopyPropertyValueTranslator1, + fromPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1, +}; + +const copyTranslatorManifest2 = { + type: 'clipboardCopyPropertyValueTranslator', + alias: 'Test.ClipboardCopyPropertyValueTranslator2', + name: 'Test Clipboard Copy Property Value Translator 2', + api: UmbTestClipboardCopyPropertyValueTranslator2, + fromPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, + toClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2, +}; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbClipboardCopyPropertyValueTranslatorValueResolver', () => { + let hostElement: UmbTestControllerHostElement; + let resolver: UmbClipboardCopyPropertyValueTranslatorValueResolver; + + const propertyValue = 'testValue'; + + beforeEach(async () => { + umbExtensionsRegistry.registerMany([copyTranslatorManifest1, copyTranslatorManifest2]); + hostElement = new UmbTestControllerHostElement(); + resolver = new UmbClipboardCopyPropertyValueTranslatorValueResolver(hostElement); + document.body.appendChild(hostElement); + }); + + afterEach(() => { + umbExtensionsRegistry.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a resolve method', () => { + expect(resolver).to.have.property('resolve').that.is.a('function'); + }); + }); + }); + + describe('resolve', async () => { + let clipboardEntryValue: Array>; + + beforeEach(async () => { + clipboardEntryValue = await resolver.resolve(propertyValue, TEST_PROPERTY_EDITOR_UI_ALIAS); + }); + + it('returns an array of values', async () => { + expect(clipboardEntryValue).length(2); + }); + + it('includes entries with the types of the registered translators', () => { + expect(clipboardEntryValue[0].type).to.equal(TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1); + expect(clipboardEntryValue[1].type).to.equal(TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2); + }); + + it('includes entries with the values by the registered translators', () => { + expect(clipboardEntryValue[0].value).to.equal(propertyValue + '1'); + expect(clipboardEntryValue[1].value).to.equal(propertyValue + '2'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.ts new file mode 100644 index 000000000000..14cb3d0f3e20 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator-value-resolver.ts @@ -0,0 +1,44 @@ +import type { UmbClipboardEntryValuesType } from '../../../clipboard-entry/types.js'; +import type { UmbClipboardCopyPropertyValueTranslator } from './types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { createExtensionApi } from '@umbraco-cms/backoffice/extension-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbClipboardCopyPropertyValueTranslatorValueResolver extends UmbControllerBase { + async resolve(propertyValue: any, propertyEditorUiAlias: string): Promise { + if (!propertyValue) { + throw new Error('Property value is required.'); + } + + if (!propertyEditorUiAlias) { + throw new Error('Property editor UI alias is required.'); + } + + const manifests = umbExtensionsRegistry.getByTypeAndFilter( + 'clipboardCopyPropertyValueTranslator', + (x) => x.fromPropertyEditorUi === propertyEditorUiAlias, + ); + + if (!manifests.length) { + throw new Error('No clipboard copy translators found.'); + } + + // Create translators + const apiPromises = manifests.map((manifest) => createExtensionApi(this, manifest)); + const apis = await Promise.all(apiPromises); + + // Translate values + const valuePromises = apis.map(async (api: UmbClipboardCopyPropertyValueTranslator | undefined) => + api?.translate(propertyValue), + ); + const translatedValues = await Promise.all(valuePromises); + + // Map to clipboard entry value models with entry type and value + const entryValues = translatedValues.map((value: any, index: number) => { + const valueType = manifests[index].toClipboardEntryValueType; + return { type: valueType, value }; + }); + + return entryValues; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator.extension.ts new file mode 100644 index 000000000000..cd376df4e2cb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/clipboard-copy-translator.extension.ts @@ -0,0 +1,15 @@ +import type { UmbClipboardCopyPropertyValueTranslator } from './types.js'; +import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface ManifestClipboardCopyPropertyValueTranslator + extends ManifestApi> { + type: 'clipboardCopyPropertyValueTranslator'; + fromPropertyEditorUi: string; + toClipboardEntryValueType: string; +} + +declare global { + interface UmbExtensionManifestMap { + umbClipboardCopyPropertyValueTranslator: ManifestClipboardCopyPropertyValueTranslator; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/index.ts new file mode 100644 index 000000000000..135deb7b088c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/index.ts @@ -0,0 +1 @@ +export * from './clipboard-copy-translator-value-resolver.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/types.ts new file mode 100644 index 000000000000..f5a9e60ee5c3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/copy/types.ts @@ -0,0 +1,8 @@ +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export type * from './clipboard-copy-translator.extension.js'; + +export interface UmbClipboardCopyPropertyValueTranslator + extends UmbApi { + translate: (propertyValue: PropertyValueModelType) => Promise; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/index.ts new file mode 100644 index 000000000000..d037c76d436b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/index.ts @@ -0,0 +1,2 @@ +export * from './copy/index.js'; +export * from './paste/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.test.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.test.ts new file mode 100644 index 000000000000..ebeb0ae3bc67 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.test.ts @@ -0,0 +1,103 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from 'lit/decorators.js'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbClipboardPastePropertyValueTranslatorValueResolver } from './clipboard-paste-translator-value-resolver.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbClipboardPastePropertyValueTranslator } from './types.js'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { type UmbClipboardEntryValuesType } from '../../../clipboard-entry/index.js'; + +const TEST_PROPERTY_EDITOR_UI_ALIAS = 'testPropertyEditorUiAlias'; +const TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1 = 'testClipboardEntryValueType1'; +const TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2 = 'testClipboardEntryValueType2'; + +type TestValueType = string; + +class UmbTestClipboardPastePropertyValueTranslator1 + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + async translate(value: TestValueType): Promise { + return value + '1'; + } +} + +class UmbTestClipboardPastePropertyValueTranslator2 + extends UmbControllerBase + implements UmbClipboardPastePropertyValueTranslator +{ + async translate(value: TestValueType): Promise { + return value + '2'; + } +} + +const pasteTranslatorManifest1 = { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Test.ClipboardPastePropertyValueTranslator1', + name: 'Test Clipboard Paste Property Value Translator 1', + api: UmbTestClipboardPastePropertyValueTranslator1, + weight: 1, + fromClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1, + toPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, +}; + +const pasteTranslatorManifest2 = { + type: 'clipboardPastePropertyValueTranslator', + alias: 'Test.ClipboardPastePropertyValueTranslator2', + name: 'Test Clipboard Paste Property Value Translator 2', + api: UmbTestClipboardPastePropertyValueTranslator2, + weight: 2, + fromClipboardEntryValueType: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2, + toPropertyEditorUi: TEST_PROPERTY_EDITOR_UI_ALIAS, +}; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbClipboardCopyPropertyValueTranslatorValueResolver', () => { + let hostElement: UmbTestControllerHostElement; + let resolver: UmbClipboardPastePropertyValueTranslatorValueResolver; + + const clipboardEntryValues: UmbClipboardEntryValuesType = [ + { + type: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_1, + value: 'testValue', + }, + { + type: TEST_CLIPBOARD_ENTRY_VALUE_TYPE_2, + value: 'testValue', + }, + ]; + + beforeEach(async () => { + umbExtensionsRegistry.registerMany([pasteTranslatorManifest1, pasteTranslatorManifest2]); + hostElement = new UmbTestControllerHostElement(); + resolver = new UmbClipboardPastePropertyValueTranslatorValueResolver(hostElement); + document.body.appendChild(hostElement); + }); + + afterEach(() => { + umbExtensionsRegistry.clear(); + document.body.innerHTML = ''; + }); + + describe('Public API', () => { + describe('methods', () => { + it('has a resolve method', () => { + expect(resolver).to.have.property('resolve').that.is.a('function'); + }); + }); + }); + + describe('resolve', async () => { + let propertyValue: string | undefined; + + beforeEach(async () => { + propertyValue = await resolver.resolve(clipboardEntryValues, TEST_PROPERTY_EDITOR_UI_ALIAS); + }); + + it('should return the property value translated by the paste translator with the highest weight', async () => { + await expect(propertyValue).to.equal('testValue2'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.ts new file mode 100644 index 000000000000..5797083f148e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator-value-resolver.ts @@ -0,0 +1,99 @@ +import type { UmbClipboardEntryValuesType } from '../../../clipboard-entry/types.js'; +import type { UmbClipboardPastePropertyValueTranslator } from './types.js'; +import type { ManifestClipboardPastePropertyValueTranslator } from './clipboard-paste-translator.extension.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { createExtensionApi, type ManifestBase } from '@umbraco-cms/backoffice/extension-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbClipboardPastePropertyValueTranslatorValueResolver< + PropertyValueType = unknown, +> extends UmbControllerBase { + #apiCache = new Map(); + + async resolve( + clipboardEntryValues: UmbClipboardEntryValuesType, + propertyEditorUiAlias: string, + ): Promise { + if (!clipboardEntryValues.length) { + throw new Error('Clipboard entry values are required.'); + } + + if (!propertyEditorUiAlias) { + throw new Error('Property editor UI alias is required.'); + } + + const manifest = this.#getManifestWithBestFit(clipboardEntryValues, propertyEditorUiAlias); + const pasteTranslator = await this.getPasteTranslator(clipboardEntryValues, propertyEditorUiAlias); + + const valueToTranslate = clipboardEntryValues.find((x) => x.type === manifest.fromClipboardEntryValueType); + + if (!valueToTranslate) { + throw new Error(`Value to translate is missing`); + } + + return pasteTranslator.translate(valueToTranslate.value); + } + + /** + * Get the paste translator for the given clipboard entry values and property editor ui alias + * @param {UmbClipboardEntryValuesType} clipboardEntryValues + * @param {string} propertyEditorUiAlias + * @returns {Promise} - The paste translator + * @memberof UmbClipboardPastePropertyValueTranslatorValueResolver + */ + async getPasteTranslator( + clipboardEntryValues: UmbClipboardEntryValuesType, + propertyEditorUiAlias: string, + ): Promise { + const manifest = this.#getManifestWithBestFit(clipboardEntryValues, propertyEditorUiAlias); + + // Check the cache before creating a new instance + if (this.#apiCache.has(manifest.alias)) { + return this.#apiCache.get(manifest.alias)!; + } + + const pasteTranslator = await createExtensionApi(this, manifest); + + if (!pasteTranslator) { + throw new Error('Failed to create paste translator.'); + } + + if (!pasteTranslator.translate) { + throw new Error('Paste translator does not have a translate method.'); + } + + // Cache the api instance for future use + this.#apiCache.set(manifest.alias, pasteTranslator); + + return pasteTranslator; + } + + #getManifestWithBestFit( + clipboardEntryValues: UmbClipboardEntryValuesType, + propertyEditorUiAlias: string, + ): ManifestClipboardPastePropertyValueTranslator { + const supportedManifests = this.#getSupportedManifests(clipboardEntryValues, propertyEditorUiAlias); + + if (!supportedManifests.length) { + throw new Error('No paste translator found for the given property editor ui and entry value type.'); + } + + // Pick the manifest with the highest priority + return supportedManifests.sort((a: ManifestBase, b: ManifestBase): number => (b.weight || 0) - (a.weight || 0))[0]; + } + + #getSupportedManifests(clipboardEntryValues: UmbClipboardEntryValuesType, propertyEditorUiAlias: string) { + const entryValueTypes = clipboardEntryValues.map((x) => x.type); + + const supportedManifests = umbExtensionsRegistry.getByTypeAndFilter( + 'clipboardPastePropertyValueTranslator', + (manifest) => { + const canTranslateValue = entryValueTypes.includes(manifest.fromClipboardEntryValueType); + const supportsPropertyEditorUi = manifest.toPropertyEditorUi === propertyEditorUiAlias; + return canTranslateValue && supportsPropertyEditorUi; + }, + ); + + return supportedManifests; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator.extension.ts new file mode 100644 index 000000000000..a92a081f1842 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/clipboard-paste-translator.extension.ts @@ -0,0 +1,15 @@ +import type { UmbClipboardPastePropertyValueTranslator } from './types.js'; +import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface ManifestClipboardPastePropertyValueTranslator + extends ManifestApi { + type: 'clipboardPastePropertyValueTranslator'; + fromClipboardEntryValueType: string; + toPropertyEditorUi: string; +} + +declare global { + interface UmbExtensionManifestMap { + umbClipboardPastePropertyValueTranslator: ManifestClipboardPastePropertyValueTranslator; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/index.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/index.ts new file mode 100644 index 000000000000..d2ad99b3aeb5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/index.ts @@ -0,0 +1 @@ +export * from './clipboard-paste-translator-value-resolver.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/types.ts new file mode 100644 index 000000000000..e4cc3081edaf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/paste/types.ts @@ -0,0 +1,12 @@ +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export type * from './clipboard-paste-translator.extension.js'; + +export interface UmbClipboardPastePropertyValueTranslator< + ClipboardEntryValueType = any, + PropertyValueModelType = any, + ConfigType = any, +> extends UmbApi { + translate: (value: ClipboardEntryValueType) => Promise; + isCompatibleValue?: (value: ClipboardEntryValueType, config: ConfigType) => Promise; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/types.ts new file mode 100644 index 000000000000..389a6d423d03 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/property/value-translator/types.ts @@ -0,0 +1,2 @@ +export type * from './copy/types.js'; +export type * from './paste/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/types.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/types.ts new file mode 100644 index 000000000000..4c8a1966a9a4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/types.ts @@ -0,0 +1,3 @@ +export type * from './clipboard-entry/types.js'; +export type * from './collection/types.js'; +export type * from './property/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/umbraco-package.ts new file mode 100644 index 000000000000..f292056dbfb6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Clipboard'; +export const extensions = [ + { + name: 'Clipboard Bundle', + alias: 'Umb.Bundle.Clipboard', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/clipboard/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/clipboard/vite.config.ts new file mode 100644 index 000000000000..cb703c59f940 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/clipboard/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/clipboard'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/index.ts deleted file mode 100644 index c33fc88b0ee1..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -//export type * from './types.js'; -export const UMB_SOMETHING_TO_EXPORT = 'foobar'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/manifests.ts deleted file mode 100644 index a3c4e6ad4404..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/manifests.ts +++ /dev/null @@ -1 +0,0 @@ -export const manifests = []; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/types.ts deleted file mode 100644 index 1d0530b150d3..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/clipboard/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * A Clipboard entry is a composed set of data representing one entry in the clipboard. - * The entry has enough knowledge for the context of the clipboard to filter away unsupported entries. - */ -export interface UmbClipboardEntry { - /** - * The type of clipboard entry, this determines the data type of the entry. Making the entry as general as possible. - * Example a entry from a Block Editor, gets a generic type called 'block'. Making it able to copy/paste between different Block Editors. - */ - type: Type; - /** - * A unique identifier, ensures that this clipboard entry will be replaced if it gets copied later. - */ - unique: string; - /** - * The name of this clipboard entry. - */ - name: string; - /** - * The icons of the copied pieces for this clipboard entry. - */ - icons: Array; - /** - * The aliases of the content-types of these entries. - */ - meta: MetaType; - /** - * The data of the copied pieces for this clipboard entry. - */ - data: Array; -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts index 074324688be7..4663f0f2373e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts @@ -28,6 +28,9 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { @state() private _firstActionApi?: UmbEntityAction; + @state() + _dropdownIsOpen = false; + #sectionSidebarContext?: UmbSectionSidebarContext; // TODO: provide the entity context on a higher level, like the root element of this entity, tree-item/workspace/... [NL] @@ -75,11 +78,16 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { #openContextMenu() { if (!this.entityType) throw new Error('Entity type is not defined'); if (this.unique === undefined) throw new Error('Unique is not defined'); - this.#sectionSidebarContext?.toggleContextMenu(this, { - entityType: this.entityType, - unique: this.unique, - headline: this.label, - }); + + if (this.#sectionSidebarContext) { + this.#sectionSidebarContext.toggleContextMenu(this, { + entityType: this.entityType, + unique: this.unique, + headline: this.label, + }); + } else { + this._dropdownIsOpen = !this._dropdownIsOpen; + } } async #onFirstActionClick(event: PointerEvent) { @@ -88,6 +96,14 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { await this._firstActionApi?.execute(); } + #onActionExecuted() { + this._dropdownIsOpen = false; + } + + #onDropdownClick(event: Event) { + event.stopPropagation(); + } + override render() { if (this._numberOfActions === 0) return nothing; return html`${this.#renderMore()} ${this.#renderFirstAction()} `; @@ -95,9 +111,22 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { #renderMore() { if (this._numberOfActions === 1) return nothing; - return html` - - `; + + if (this.#sectionSidebarContext) { + return html` + + `; + } + + return html` + + + + + `; } #renderFirstAction() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.test.ts new file mode 100644 index 000000000000..d2ee6658955f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.test.ts @@ -0,0 +1,165 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { + ManifestPropertyValueResolver, + UmbPropertyValueData, + UmbPropertyValueResolver, +} from '@umbraco-cms/backoffice/property'; +import { UmbVariantId, type UmbVariantDataModel } from '@umbraco-cms/backoffice/variant'; +import { UmbMergeContentVariantDataController } from './merge-content-variant-data.controller.js'; +import type { UmbContentLikeDetailModel } from '../types.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +@customElement('umb-test-controller-host') +export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +type TestPropertyValueNestedType = { + nestedValue: UmbPropertyValueData; +}; + +export class TestPropertyValueResolver + implements + UmbPropertyValueResolver< + UmbPropertyValueData, + UmbPropertyValueData, + UmbVariantDataModel + > +{ + async processValues( + property: UmbPropertyValueData, + valuesCallback: (values: Array) => Promise | undefined>, + ) { + if (property.value) { + const processedValues = await valuesCallback([property.value.nestedValue]); + return { + ...property, + value: { + nestedValue: processedValues?.[0] ?? property.value.nestedValue, + } as TestPropertyValueNestedType, + } as UmbPropertyValueData; + } + return property; + } + + destroy(): void {} +} + +describe('UmbMergeContentVariantDataController', () => { + describe('Simple resolver', () => { + beforeEach(async () => { + const manifest: ManifestPropertyValueResolver = { + type: 'propertyValueResolver', + name: 'test-resolver-1', + alias: 'Umb.Test.Resolver.1', + api: TestPropertyValueResolver, + forEditorAlias: 'test-editor', + }; + + umbExtensionsRegistry.register(manifest); + }); + + afterEach(async () => { + umbExtensionsRegistry.unregister('Umb.Test.Resolver.1'); + }); + + it('transfers inner values of select variants', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbMergeContentVariantDataController(ctrlHost); + + const persistedData: UmbContentLikeDetailModel = { + values: [ + { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + nestedValue: { + editorAlias: 'some-editor', + alias: 'some', + culture: null, + segment: null, + value: 'saved-nested-value-invariant', + }, + }, + }, + ], + }; + + const runtimeData: UmbContentLikeDetailModel = { + values: [ + { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + nestedValue: { + editorAlias: 'some-editor', + alias: 'some', + value: 'updated-nested-value-invariant', + }, + }, + }, + ], + }; + + const result = await ctrl.process(persistedData, runtimeData, [], [UmbVariantId.CreateInvariant()]); + + expect((result.values[0].value as TestPropertyValueNestedType).nestedValue.value).to.be.equal( + 'updated-nested-value-invariant', + ); + }); + + it('does not transfers inner values of a not selected variant', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbMergeContentVariantDataController(ctrlHost); + + const persistedData: UmbContentLikeDetailModel = { + values: [ + { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + nestedValue: { + editorAlias: 'some-editor', + alias: 'some', + culture: null, + segment: null, + value: 'saved-nested-value-invariant', + }, + }, + }, + ], + }; + + const runtimeData: UmbContentLikeDetailModel = { + values: [ + { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + nestedValue: { + editorAlias: 'some-editor', + alias: 'some', + value: 'updated-nested-value-invariant', + }, + }, + }, + ], + }; + + const variants = [new UmbVariantId('da')]; + const result = await ctrl.process(persistedData, runtimeData, variants, variants); + + expect((result.values[0].value as TestPropertyValueNestedType).nestedValue.value).to.be.equal( + 'saved-nested-value-invariant', + ); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.ts index dbf4bb00630d..4b296d6f24c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content/controller/merge-content-variant-data.controller.ts @@ -122,7 +122,8 @@ export class UmbMergeContentVariantDataController extends UmbControllerBase { // Find the resolver for this editor alias: const manifest = umbExtensionsRegistry.getByTypeAndFilter( 'propertyValueResolver', - (x) => x.meta.editorAlias === editorAlias, + // TODO: Remove depcrated filter in v.17 [NL] + (x) => x.forEditorAlias === editorAlias || x.meta?.editorAlias === editorAlias, )[0]; if (!manifest) { @@ -150,13 +151,14 @@ export class UmbMergeContentVariantDataController extends UmbControllerBase { } let valuesIndex = 0; - newValue = await api.processValues(newValue, async (values) => { - // got some values (content and/or settings): - // but how to get the persisted and the draft of this..... - const persistedValues = persistedValuesHolder[valuesIndex++]; + newValue = + (await api.processValues(newValue, async (values) => { + // got some values (content and/or settings): + // but how to get the persisted and the draft of this..... + const persistedValues = persistedValuesHolder[valuesIndex++]; - return await this.#processValues(persistedValues, values, variantsToStore); - }); + return await this.#processValues(persistedValues, values, variantsToStore); + })) ?? newValue; } if (api.processVariants) { @@ -171,18 +173,19 @@ export class UmbMergeContentVariantDataController extends UmbControllerBase { } let valuesIndex = 0; - newValue = await api.processVariants(newValue, async (values) => { - // got some values (content and/or settings): - // but how to get the persisted and the draft of this..... - const persistedVariants = persistedVariantsHolder[valuesIndex++]; - - return await this.#processVariants( - persistedVariants, - values, - variantsToStore, - api.compareVariants ?? defaultCompareVariantMethod, - ); - }); + newValue = + (await api.processVariants(newValue, async (values) => { + // got some values (content and/or settings): + // but how to get the persisted and the draft of this..... + const persistedVariants = persistedVariantsHolder[valuesIndex++]; + + return await this.#processVariants( + persistedVariants, + values, + variantsToStore, + api.compareVariants ?? defaultCompareVariantMethod, + ); + })) ?? newValue; } /* diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index 6f35e953ccf7..781b916fa20b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -379,6 +379,22 @@ "file": "user.svg", "legacy": true }, + { + "name": "icon-clipboard", + "file": "clipboard.svg" + }, + { + "name": "icon-clipboard-copy", + "file": "clipboard-copy.svg" + }, + { + "name": "icon-clipboard-entry", + "file": "clipboard.svg" + }, + { + "name": "icon-clipboard-paste", + "file": "clipboard-paste.svg" + }, { "name": "icon-cloud-drive", "file": "hard-drive.svg" diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index cab131a37ea4..052c8c053aac 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -367,6 +367,22 @@ name: "icon-client", legacy: true, path: () => import("./icons/icon-client.js"), },{ +name: "icon-clipboard", + +path: () => import("./icons/icon-clipboard.js"), +},{ +name: "icon-clipboard-copy", + +path: () => import("./icons/icon-clipboard-copy.js"), +},{ +name: "icon-clipboard-entry", + +path: () => import("./icons/icon-clipboard-entry.js"), +},{ +name: "icon-clipboard-paste", + +path: () => import("./icons/icon-clipboard-paste.js"), +},{ name: "icon-cloud-drive", path: () => import("./icons/icon-cloud-drive.js"), diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-copy.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-copy.ts new file mode 100644 index 000000000000..c32a59159a1e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-copy.ts @@ -0,0 +1,18 @@ +export default ` + + + + + + + +`; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-entry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-entry.ts new file mode 100644 index 000000000000..029242b2a9df --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-entry.ts @@ -0,0 +1,15 @@ +export default ` + + + + +`; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-paste.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-paste.ts new file mode 100644 index 000000000000..ea570d432f5c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard-paste.ts @@ -0,0 +1,16 @@ +export default ` + + + + + +`; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard.ts new file mode 100644 index 000000000000..029242b2a9df --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-clipboard.ts @@ -0,0 +1,15 @@ +export default ` + + + + +`; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts index 807e23de7053..25e388e0630e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts @@ -1,5 +1,4 @@ import { manifests as authManifests } from './auth/manifests.js'; -import { manifests as clipboardManifests } from './clipboard/manifests.js'; import { manifests as collectionManifests } from './collection/manifests.js'; import { manifests as contentManifests } from './content/manifests.js'; import { manifests as contentTypeManifests } from './content-type/manifests.js'; @@ -28,7 +27,6 @@ import type { UmbExtensionManifestKind } from './extension-registry/index.js'; export const manifests: Array = [ ...authManifests, - ...clipboardManifests, ...collectionManifests, ...contentManifests, ...contentTypeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts index aac6cc02f859..24c7a48086d7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts @@ -1,4 +1,4 @@ -import { html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, property, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbConfirmModalData, UmbConfirmModalValue, UmbModalContext } from '@umbraco-cms/backoffice/modal'; import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; @@ -41,7 +41,14 @@ export class UmbConfirmModalElement extends UmbLitElement { `; } - static override styles = [UmbTextStyles]; + static override styles = [ + UmbTextStyles, + css` + uui-dialog-layout { + max-inline-size: 60ch; + } + `, + ]; } export default UmbConfirmModalElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/clear/property-action-clear.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/clear/property-action-clear.controller.ts index 405b62e20ba2..b4ce1068eb8f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/clear/property-action-clear.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/clear/property-action-clear.controller.ts @@ -1,4 +1,4 @@ -import { UmbPropertyActionBase } from '../../components/property-action/property-action-base.controller.js'; +import { UmbPropertyActionBase } from '../../property-action-base.js'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; export class UmbClearPropertyAction extends UmbPropertyActionBase { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/copy/property-action-copy.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/copy/property-action-copy.controller.ts deleted file mode 100644 index 8f43967b1cac..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/common/copy/property-action-copy.controller.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { UmbPropertyActionBase } from '../../components/property-action/property-action-base.controller.js'; -import { UMB_NOTIFICATION_CONTEXT, type UmbNotificationDefaultData } from '@umbraco-cms/backoffice/notification'; -import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; - -export class UmbCopyPropertyAction extends UmbPropertyActionBase { - override async execute() { - const propertyContext = await this.getContext(UMB_PROPERTY_CONTEXT); - const value = propertyContext.getValue(); - - const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); - // TODO: Temporary solution to make something happen: [NL] - const data: UmbNotificationDefaultData = { headline: 'Copied to clipboard', message: value }; - notificationContext?.peek('positive', { data }); - } -} -export default UmbCopyPropertyAction; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/index.ts index fbac247b61f4..ab8f238b15e8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/index.ts @@ -1,2 +1 @@ -export type * from './property-action/index.js'; export * from './property-action-menu/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action-menu/property-action-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action-menu/property-action-menu.element.ts index 1692b706a694..836590680945 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action-menu/property-action-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action-menu/property-action-menu.element.ts @@ -1,4 +1,4 @@ -import type { UmbPropertyActionArgs } from '../property-action/types.js'; +import type { UmbPropertyActionArgs } from '../../types.js'; import { css, customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import type { ManifestPropertyAction, MetaPropertyAction } from '@umbraco-cms/backoffice/property-action'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/index.ts deleted file mode 100644 index a80158f0b60c..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type * from './property-action.interface.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/manifests.ts deleted file mode 100644 index f506aba3d7f1..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/manifests.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { manifests as defaultWorkspaceActionManifests } from './default/manifests.js'; -import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; - -export const manifests: Array = [...defaultWorkspaceActionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/index.ts index 041b53161a62..5ebdbf141168 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/index.ts @@ -1,2 +1,6 @@ export * from './components/index.js'; -export type * from './extensions/property-action.extension.js'; +export * from './property-action-base.js'; +export type * from './property-action.interface.js'; +export type * from './types.js'; +export type * from './property-action.extension.js'; +export { UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST } from './kinds/default/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/index.ts new file mode 100644 index 000000000000..962991512ec6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/index.ts @@ -0,0 +1 @@ +export { UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST } from './manifests.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/default.action.kind.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/manifests.ts similarity index 65% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/default.action.kind.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/manifests.ts index 16664f5a3ad9..b3de79bbc246 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/default.action.kind.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/manifests.ts @@ -1,6 +1,6 @@ import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; -export const manifest: UmbExtensionManifestKind = { +export const UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST: UmbExtensionManifestKind = { type: 'kind', alias: 'Umb.Kind.PropertyAction.Default', matchKind: 'default', @@ -16,3 +16,7 @@ export const manifest: UmbExtensionManifestKind = { }, }, }; + +export const manifests: Array = [ + UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/property-action.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/property-action.element.ts similarity index 94% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/property-action.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/property-action.element.ts index bfa162057b8e..c5dd8a585b1d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/property-action.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/default/property-action.element.ts @@ -1,12 +1,11 @@ -import type { UmbPropertyAction } from '../index.js'; import type { ManifestPropertyActionDefaultKind, MetaPropertyActionDefaultKind, -} from '../../../extensions/property-action.extension.js'; +} from '../../property-action.extension.js'; +import type { UmbPropertyAction } from '../../property-action.interface.js'; import { UmbActionExecutedEvent } from '@umbraco-cms/backoffice/event'; import { html, customElement, property, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; - import type { UUIMenuItemEvent } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-property-action') diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/manifests.ts similarity index 56% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/manifests.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/manifests.ts index 8d8ea584a198..85cca5802937 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/default/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/kinds/manifests.ts @@ -1,4 +1,4 @@ -import { manifest as defaultKindManifest } from './default.action.kind.js'; +import { manifests as defaultKindManifests } from './default/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; -export const manifests: Array = [defaultKindManifest]; +export const manifests: Array = [...defaultKindManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/manifests.ts index d26963d35ab7..573e050c8167 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/manifests.ts @@ -1,26 +1,9 @@ -import { manifests as defaultManifests } from './components/property-action/manifests.js'; +import { manifests as kindManifests } from './kinds/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; import { UMB_WRITABLE_PROPERTY_CONDITION_ALIAS } from '@umbraco-cms/backoffice/property'; export const manifests: Array = [ - { - type: 'propertyAction', - kind: 'default', - alias: 'Umb.PropertyAction.Copy', - name: 'Copy Property Action', - api: () => import('./common/copy/property-action-copy.controller.js'), - forPropertyEditorUis: [], - meta: { - icon: 'icon-paste-in', - label: 'Copy', - }, - conditions: [ - { - alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, - }, - ], - }, { type: 'propertyAction', kind: 'default', @@ -38,5 +21,5 @@ export const manifests: Array = }, ], }, - ...defaultManifests, + ...kindManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/property-action-base.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action-base.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/property-action-base.controller.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action-base.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/extensions/property-action.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action.extension.ts similarity index 93% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/extensions/property-action.extension.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action.extension.ts index c47d7c8df569..7353ddf5508a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/extensions/property-action.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action.extension.ts @@ -1,4 +1,4 @@ -import type { UmbPropertyAction } from '../components/property-action/property-action.interface.js'; +import type { UmbPropertyAction } from './property-action.interface.js'; import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import type { ManifestElementAndApi, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/property-action.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action.interface.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/property-action.interface.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/property-action.interface.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-action/types.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property-action/components/property-action/types.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property-action/types.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/index.ts new file mode 100644 index 000000000000..a0f23eba90e9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/index.ts @@ -0,0 +1,3 @@ +export * from './property-layout/index.js'; +export * from './property/index.js'; +export * from './unsupported-property/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/index.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/property-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/property-layout.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/property-layout.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property-layout/property-layout.stories.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/index.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts similarity index 89% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts index 5b67188c4d10..1a2608931b6d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts @@ -1,4 +1,4 @@ -import { UMB_PROPERTY_DATASET_CONTEXT } from '../property-dataset/index.js'; +import { UMB_PROPERTY_DATASET_CONTEXT } from '../../property-dataset/index.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { @@ -13,6 +13,7 @@ import { import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import type { + ManifestPropertyEditorUi, UmbPropertyEditorConfigProperty, UmbPropertyEditorUiElement, } from '@umbraco-cms/backoffice/property-editor'; @@ -56,16 +57,44 @@ export class UmbPropertyContext extends UmbContextBase(undefined); public readonly editor = this.#editor.asObservable(); + #editorManifest = new UmbBasicState(undefined); + public readonly editorManifest = this.#editorManifest.asObservable(); + #isReadOnly = new UmbBooleanState(false); public readonly isReadOnly = this.#isReadOnly.asObservable(); - setEditor(editor: UmbPropertyEditorUiElement | undefined) { - this.#editor.setValue(editor ?? undefined); + /** + * Set the property editor UI element for this property. + * @param {UmbPropertyEditorUiElement | undefined} editorElement The property editor UI element + */ + setEditor(editorElement: UmbPropertyEditorUiElement | undefined) { + this.#editor.setValue(editorElement ?? undefined); } - getEditor() { + + /** + * Get the property editor UI element for this property. + * @returns {UmbPropertyEditorUiElement | undefined} The property editor UI element + */ + getEditor(): UmbPropertyEditorUiElement | undefined { return this.#editor.getValue(); } + /** + * Set the property editor manifest for this property. + * @param {ManifestPropertyEditorUi | undefined} manifest The property editor manifest + */ + setEditorManifest(manifest: ManifestPropertyEditorUi | undefined) { + this.#editorManifest.setValue(manifest ?? undefined); + } + + /** + * Get the property editor manifest for this property. + * @returns {UmbPropertyEditorUiElement | undefined} The property editor manifest + */ + getEditorManifest(): ManifestPropertyEditorUi | undefined { + return this.#editorManifest.getValue(); + } + // property variant ID: #variantId = new UmbClassState(undefined); public readonly variantId = this.#variantId.asObservable(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts similarity index 94% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts index 68145e5e03d3..1ead62134757 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/property/property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts @@ -1,6 +1,6 @@ import { UmbPropertyContext } from './property.context.js'; import { css, customElement, html, property, state, nothing } from '@umbraco-cms/backoffice/external/lit'; -import { createExtensionElement } from '@umbraco-cms/backoffice/extension-api'; +import { createExtensionElement, UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @@ -178,6 +178,7 @@ export class UmbPropertyElement extends UmbLitElement { #validationMessageBinder?: UmbBindServerValidationToFormControl; #valueObserver?: UmbObserverController; #configObserver?: UmbObserverController; + #extensionsController?: UmbExtensionsApiInitializer; constructor() { super(); @@ -274,7 +275,9 @@ export class UmbPropertyElement extends UmbLitElement { } private async _gotEditorUI(manifest?: ManifestPropertyEditorUi | null): Promise { + this.#extensionsController?.destroy(); this.#propertyContext.setEditor(undefined); + this.#propertyContext.setEditorManifest(manifest ?? undefined); if (!manifest) { // TODO: if propertyEditorUiAlias didn't exist in store, we should do some nice fail UI. @@ -352,12 +355,27 @@ export class UmbPropertyElement extends UmbLitElement { } this._element.toggleAttribute('readonly', this._isReadOnly); + this.#createController(manifest); } this.requestUpdate('element', oldElement); } } + #createController(propertyEditorUiManifest: ManifestPropertyEditorUi): void { + if (this.#extensionsController) { + this.#extensionsController.destroy(); + } + + this.#extensionsController = new UmbExtensionsApiInitializer( + this, + umbExtensionsRegistry, + 'propertyContext', + [], + (manifest) => manifest.forPropertyEditorUis.includes(propertyEditorUiManifest.alias), + ); + } + override render() { return html` + implements UmbExtensionCondition +{ + constructor(host: UmbControllerHost, args: UmbConditionControllerArguments) { + super(host, args); + + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.observe(context.value, (value) => { + this.permitted = value !== undefined; + }); + }); + } +} + +export { UmbPropertyHasValueCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/manifests.ts new file mode 100644 index 000000000000..37d128c0f24c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'Property Has Value Condition', + alias: UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS, + api: () => import('./has-value.condition.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/types.ts new file mode 100644 index 000000000000..bb2406b4ead9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/has-value/types.ts @@ -0,0 +1,12 @@ +import type { UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS } from './constants.js'; +import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbPropertyHasValueConditionConfig + extends UmbConditionConfigBase {} + +declare global { + interface UmbExtensionConditionConfigMap { + UmbPropertyHasValueConditionConfig: UmbPropertyHasValueConditionConfig; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/manifests.ts index 2eade224a45e..5cd1ebe8d259 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/manifests.ts @@ -1,10 +1,4 @@ -import { UMB_WRITABLE_PROPERTY_CONDITION_ALIAS } from './constants.js'; +import { manifests as hasValueManifests } from './has-value/manifests.js'; +import { manifests as writableManifests } from './writable/manifests.js'; -export const manifests: Array = [ - { - type: 'condition', - name: 'Writable Property Condition', - alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, - api: () => import('./writable-property.condition.js'), - }, -]; +export const manifests: Array = [...hasValueManifests, ...writableManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/types.ts new file mode 100644 index 000000000000..b180bf032edc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/types.ts @@ -0,0 +1 @@ +export type * from './has-value/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/constants.ts new file mode 100644 index 000000000000..e3a28db5ad1b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/constants.ts @@ -0,0 +1 @@ +export const UMB_WRITABLE_PROPERTY_CONDITION_ALIAS = 'Umb.Condition.Property.Writable'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/manifests.ts new file mode 100644 index 000000000000..2eade224a45e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_WRITABLE_PROPERTY_CONDITION_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'Writable Property Condition', + alias: UMB_WRITABLE_PROPERTY_CONDITION_ALIAS, + api: () => import('./writable-property.condition.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable-property.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/writable-property.condition.ts similarity index 91% rename from src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable-property.condition.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/writable-property.condition.ts index c186c3f4f049..c33a7e4b8cab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable-property.condition.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/conditions/writable/writable-property.condition.ts @@ -1,4 +1,4 @@ -import { UMB_PROPERTY_CONTEXT } from '../property/property.context.js'; +import { UMB_PROPERTY_CONTEXT } from '../../components/index.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbConditionConfigBase, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.test.ts new file mode 100644 index 000000000000..8103a91acbfc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.test.ts @@ -0,0 +1,273 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { + ManifestPropertyValueResolver, + ManifestPropertyValueCloner, + UmbPropertyValueData, + UmbPropertyValueResolver, + UmbPropertyValueCloner, +} from '../types.js'; +import type { UmbVariantDataModel } from '@umbraco-cms/backoffice/variant'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbPropertyValueCloneController } from './property-value-clone.controller.js'; + +@customElement('umb-test-controller-host') +export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +type TestPropertyValueWithId = { + id: string; +}; + +type TestPropertyValueNestedType = TestPropertyValueWithId & { + nestedValue?: UmbPropertyValueData; +}; + +export class TestPropertyValueResolver + implements + UmbPropertyValueResolver< + UmbPropertyValueData, + UmbPropertyValueData, + UmbVariantDataModel + > +{ + async processValues( + property: UmbPropertyValueData, + valuesCallback: (values: Array) => Promise | undefined>, + ): Promise> { + if (property.value) { + const nestedValue = property.value.nestedValue; + const processedValues = nestedValue ? await valuesCallback([nestedValue]) : undefined; + return { + ...property, + value: { + ...property.value, + nestedValue: processedValues ? processedValues[0] : undefined, + }, + } as UmbPropertyValueData; + } + return property; + } + + async processVariants( + property: UmbPropertyValueData, + variantsCallback: (values: Array) => Promise | undefined>, + ) { + return property; + } + + destroy(): void {} +} + +export class TestPropertyValueCloner implements UmbPropertyValueCloner { + async cloneValue(value: TestPropertyValueWithId): Promise { + return { ...value, id: 'updated-id' }; + } + + destroy(): void {} +} + +describe('UmbPropertyValueCloneController', () => { + describe('Cloner', () => { + beforeEach(async () => { + const manifestCloner: ManifestPropertyValueCloner = { + type: 'propertyValueCloner', + name: 'test-cloner-1', + alias: 'Umb.Test.Cloner.1', + api: TestPropertyValueCloner, + forEditorAlias: 'test-editor', + }; + + umbExtensionsRegistry.register(manifestCloner); + }); + afterEach(async () => { + umbExtensionsRegistry.unregister('Umb.Test.Cloner.1'); + }); + + it('clones value', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + id: 'not-updated-id', + }, + }; + + const result = await ctrl.clone(value); + + expect((result.value as TestPropertyValueNestedType | undefined)?.id).to.be.equal('updated-id'); + }); + }); + + describe('Resolvers and Cloner', () => { + beforeEach(async () => { + const manifestResolver: ManifestPropertyValueResolver = { + type: 'propertyValueResolver', + name: 'test-resolver-1', + alias: 'Umb.Test.Resolver.1', + api: TestPropertyValueResolver, + forEditorAlias: 'test-editor', + }; + + umbExtensionsRegistry.register(manifestResolver); + + const manifestResolverOnly: ManifestPropertyValueResolver = { + type: 'propertyValueResolver', + name: 'test-resolver-1', + alias: 'Umb.Test.Resolver.2', + api: TestPropertyValueResolver, + forEditorAlias: 'only-resolver-editor', + }; + + umbExtensionsRegistry.register(manifestResolverOnly); + + const manifestCloner: ManifestPropertyValueCloner = { + type: 'propertyValueCloner', + name: 'test-cloner-1', + alias: 'Umb.Test.Cloner.1', + api: TestPropertyValueCloner, + forEditorAlias: 'test-editor', + }; + + umbExtensionsRegistry.register(manifestCloner); + }); + afterEach(async () => { + umbExtensionsRegistry.unregister('Umb.Test.Resolver.1'); + umbExtensionsRegistry.unregister('Umb.Test.Resolver.2'); + umbExtensionsRegistry.unregister('Umb.Test.Cloner.1'); + }); + + it('clones value and inner values', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + id: 'not-updated-id', + }, + }; + + const result = await ctrl.clone(value); + + expect((result.value as TestPropertyValueNestedType | undefined)?.id).to.be.equal('updated-id'); + }); + + it('clones only inner values', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'only-resolver-editor', + alias: 'not-to-be-handled', + culture: null, + segment: null, + value: { + id: 'not-updated-id', + nestedValue: { + editorAlias: 'test-editor', + alias: 'some', + culture: null, + segment: null, + value: { + id: 'inner-not-updated-id', + }, + }, + }, + }; + + const result = await ctrl.clone(value); + + expect((result.value as TestPropertyValueNestedType | undefined)?.id).to.be.equal('not-updated-id'); + expect((result.value as TestPropertyValueNestedType | undefined)?.nestedValue?.value?.id).to.be.equal( + 'updated-id', + ); + }); + + it('clones value and inner values', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + id: 'not-updated-id', + nestedValue: { + editorAlias: 'test-editor', + alias: 'some', + culture: null, + segment: null, + value: { + id: 'inner-not-updated-id', + }, + }, + }, + }; + + const result = await ctrl.clone(value); + + expect((result.value as TestPropertyValueNestedType | undefined)?.id).to.be.equal('updated-id'); + expect((result.value as TestPropertyValueNestedType | undefined)?.nestedValue?.value?.id).to.be.equal( + 'updated-id', + ); + }); + + it('clones value and inner values for two levels', async () => { + const ctrlHost = new UmbTestControllerHostElement(); + const ctrl = new UmbPropertyValueCloneController(ctrlHost); + + const value = { + editorAlias: 'test-editor', + alias: 'test', + culture: null, + segment: null, + value: { + id: 'not-updated-id', + nestedValue: { + editorAlias: 'test-editor', + alias: 'some', + culture: null, + segment: null, + value: { + id: 'inner-not-updated-id', + nestedValue: { + editorAlias: 'test-editor', + alias: 'another', + culture: null, + segment: null, + value: { + id: 'inner-inner-not-updated-id', + }, + }, + }, + }, + }, + }; + + const result = await ctrl.clone(value); + + expect((result.value as TestPropertyValueNestedType | undefined)?.id).to.be.equal('updated-id'); + expect((result.value as TestPropertyValueNestedType | undefined)?.nestedValue?.value?.id).to.be.equal( + 'updated-id', + ); + expect( + ( + (result.value as TestPropertyValueNestedType | undefined)?.nestedValue?.value as + | TestPropertyValueNestedType + | undefined + )?.nestedValue?.value?.id, + ).to.be.equal('updated-id'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.ts new file mode 100644 index 000000000000..581fa269abf8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/controllers/property-value-clone.controller.ts @@ -0,0 +1,107 @@ +import type { UmbPropertyValueDataPotentiallyWithEditorAlias } from '../index.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { createExtensionApi } from '@umbraco-cms/backoffice/extension-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbPropertyValueCloneController extends UmbControllerBase { + /** + * Clones the property data. + * @param {UmbPropertyValueDataPotentiallyWithEditorAlias} property - The property data. + * @returns {Promise} - A promise that resolves to the cloned property data. + */ + async clone( + property: UmbPropertyValueDataPotentiallyWithEditorAlias, + ): Promise> { + const result = await this.#cloneProperty(property); + + this.destroy(); + + return result ?? property; + } + + async #cloneProperty( + property: UmbPropertyValueDataPotentiallyWithEditorAlias, + ): Promise> { + const clonedProperty = await this.#cloneValue(property); + return await this.#cloneInnerValues(clonedProperty); + } + + async #cloneValue( + incomingProperty: UmbPropertyValueDataPotentiallyWithEditorAlias, + ): Promise> { + const editorAlias = (incomingProperty as any).editorAlias as string | undefined; + if (!editorAlias) { + console.error(`Editor alias not found for ${incomingProperty.alias}`); + return incomingProperty; + } + + // Find the cloner for this editor alias: + const manifest = umbExtensionsRegistry.getByTypeAndFilter( + 'propertyValueCloner', + (x) => x.forEditorAlias === editorAlias, + )[0]; + + if (!manifest) { + return incomingProperty; + } + + const api = await createExtensionApi(this, manifest); + if (!api) { + return incomingProperty; + } + + let clonedProperty = incomingProperty; + + if (api.cloneValue) { + const clonedValue = await api.cloneValue(incomingProperty.value); + if (clonedValue) { + clonedProperty = { ...incomingProperty, value: clonedValue }; + } + } + + return clonedProperty; + } + + async #cloneInnerValues( + incomingProperty: UmbPropertyValueDataPotentiallyWithEditorAlias, + ): Promise> { + const editorAlias = (incomingProperty as any).editorAlias as string | undefined; + if (!editorAlias) { + return incomingProperty; + } + + // Find the resolver for this editor alias: + const manifest = umbExtensionsRegistry.getByTypeAndFilter( + 'propertyValueResolver', + // TODO: Remove depcrated filter option in v.17 [NL] + (x) => x.forEditorAlias === editorAlias || x.meta?.editorAlias === editorAlias, + )[0]; + + if (!manifest) { + return incomingProperty; + } + + const api = await createExtensionApi(this, manifest); + if (!api) { + return incomingProperty; + } + + if (api.processValues) { + return ( + (await api.processValues(incomingProperty, async (properties) => { + // Transform the values: + const clonedValues = await Promise.all( + properties.map(async (value) => { + return (await this.#cloneProperty(value)) ?? value; + }), + ); + + return clonedValues; + })) ?? incomingProperty + ); + } + + // the api did not provide a value processor, so we will return the incoming property: + return incomingProperty; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/property-context.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/property-context.extension.ts new file mode 100644 index 000000000000..6da4cf632966 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/property-context.extension.ts @@ -0,0 +1,18 @@ +import type { ManifestApi, ManifestWithDynamicConditions, UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface ManifestPropertyContext + extends ManifestWithDynamicConditions, + ManifestApi { + type: 'propertyContext'; + forPropertyEditorUis: string[]; + meta: MetaType; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MetaPropertyContext {} + +declare global { + interface UmbExtensionManifestMap { + ManifestPropertyContext: ManifestPropertyContext; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/types.ts new file mode 100644 index 000000000000..a30769606cbf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/extensions/types.ts @@ -0,0 +1 @@ +export type * from './property-context.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/index.ts index ff9f69bba0a0..3e9e25957511 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/index.ts @@ -1,6 +1,5 @@ +export * from './components/index.js'; export * from './conditions/index.js'; +export * from './controllers/property-value-clone.controller.js'; export * from './property-dataset/index.js'; -export * from './property-layout/index.js'; -export * from './property/index.js'; -export * from './unsupported-property/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/property-value-cloner.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/property-value-cloner.extension.ts new file mode 100644 index 000000000000..41f07eaa4079 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/property-value-cloner.extension.ts @@ -0,0 +1,13 @@ +import type { UmbPropertyValueCloner } from './types.js'; +import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface ManifestPropertyValueCloner extends ManifestApi> { + type: 'propertyValueCloner'; + forEditorAlias: string; +} + +declare global { + interface UmbExtensionManifestMap { + ManifestPropertyValueTransformer: ManifestPropertyValueCloner; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/types.ts new file mode 100644 index 000000000000..118c72e5cfe7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-cloner/types.ts @@ -0,0 +1,19 @@ +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export type * from './property-value-cloner.extension.js'; + +export interface UmbPropertyValueCloner extends UmbApi { + /** + * Clones a property value. + * @param value The value to clone. + * @returns A promise that resolves with the clonal(cloned value). + */ + cloneValue?: UmbPropertyValueClonerMethod; +} + +/** + * Clones a property value. + * @param value The value to clone. + * @returns A promise that resolves with the clonal(cloned value). + */ +export type UmbPropertyValueClonerMethod = (value: ValueType) => PromiseLike; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-resolver/property-value-resolver.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-resolver/property-value-resolver.extension.ts index 40a439dd6d98..9499e2dde022 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-resolver/property-value-resolver.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/property-value-resolver/property-value-resolver.extension.ts @@ -3,11 +3,15 @@ import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; export interface ManifestPropertyValueResolver extends ManifestApi> { type: 'propertyValueResolver'; - meta: MetaPropertyValueResolver; + meta?: MetaPropertyValueResolver; + forEditorAlias: string; } export interface MetaPropertyValueResolver { - editorAlias: string; + /** + * @deprecated use `forEditorAlias` instead + */ + editorAlias?: string; } declare global { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/types.ts index 1436bf5550d8..d474dcd917e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/types.ts @@ -1,2 +1,5 @@ -export type * from './types/index.js'; +export type * from './conditions/types.js'; +export type * from './property-value-cloner/types.js'; export type * from './property-value-resolver/types.js'; +export type * from './types/index.js'; +export type * from './extensions/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/types/property-value-data.type.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/types/property-value-data.type.ts index 68be94c74a04..bbb8d2b5f435 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/types/property-value-data.type.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/types/property-value-data.type.ts @@ -1,4 +1,9 @@ -export type UmbPropertyValueData = { +export interface UmbPropertyValueData { alias: string; value?: ValueType; -}; +} + +export interface UmbPropertyValueDataPotentiallyWithEditorAlias + extends UmbPropertyValueData { + editorAlias?: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/data-source-response.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/data-source-response.interface.ts index 2c8aabb23b92..613ec250787f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/data-source-response.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/data-source-response.interface.ts @@ -5,5 +5,8 @@ export interface UmbDataSourceResponse extends UmbDataSourceErrorRe } export interface UmbDataSourceErrorResponse { + // TODO: we should not rely on the ApiError and CancelError types from the backend-api package + // We need to be able to return a generic error type that can be used in the frontend + // Example: the clipboard is getting is data from local storage, so it should not use the ApiError type error?: ApiError | CancelError; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/constants.ts new file mode 100644 index 000000000000..d8eff8938d09 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/constants.ts @@ -0,0 +1 @@ +export const UMB_COLOR_PICKER_PROPERTY_EDITOR_UI_ALIAS = 'Umb.PropertyEditorUi.ColorPicker'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/manifests.ts index 4fd2a6f70f2b..a11b6a3d3c07 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/manifests.ts @@ -1,9 +1,10 @@ +import { UMB_COLOR_PICKER_PROPERTY_EDITOR_UI_ALIAS } from './constants.js'; import { manifest as schemaManifest } from './Umbraco.ColorPicker.js'; export const manifests: Array = [ { type: 'propertyEditorUi', - alias: 'Umb.PropertyEditorUi.ColorPicker', + alias: UMB_COLOR_PICKER_PROPERTY_EDITOR_UI_ALIAS, name: 'Color Picker Property Editor UI', element: () => import('./property-editor-ui-color-picker.element.js'), meta: { diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 614b77176bfd..d665316af580 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -177,16 +177,18 @@ export abstract class UmbPropertyEditorUiRteElementBase extends UmbLitElement im } protected _filterUnusedBlocks(usedContentKeys: (string | null)[]) { - const unusedBlockContents = this.#managerContext.getContents().filter((x) => usedContentKeys.indexOf(x.key) === -1); - unusedBlockContents.forEach((blockContent) => { - this.#managerContext.removeOneContent(blockContent.key); - }); - const unusedBlocks = this.#managerContext.getLayouts().filter((x) => usedContentKeys.indexOf(x.contentKey) === -1); - unusedBlocks.forEach((blockLayout) => { - this.#managerContext.removeOneLayout(blockLayout.contentKey); - }); - } + const unusedLayouts = this.#managerContext.getLayouts().filter((x) => usedContentKeys.indexOf(x.contentKey) === -1); + const unusedContentKeys = unusedLayouts.map((x) => x.contentKey); + + const unusedSettingsKeys = unusedLayouts + .map((x) => x.settingsKey) + .filter((x) => typeof x === 'string') as Array; + + this.#managerContext.removeManyContent(unusedContentKeys); + this.#managerContext.removeManySettings(unusedSettingsKeys); + this.#managerContext.removeManyLayouts(unusedContentKeys); + } protected _fireChangeEvent() { this.dispatchEvent(new UmbPropertyValueChangeEvent()); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/property-value-resolver/manifest.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/property-value-resolver/manifest.ts index 91932d04e471..6b773015bd85 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/property-value-resolver/manifest.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/property-value-resolver/manifest.ts @@ -7,7 +7,5 @@ export const manifest: ManifestPropertyValueResolver = { alias: 'Umb.PropertyValueResolver.RichTextBlocks', name: 'Block Value Resolver', api: UmbRteBlockValueResolver, - meta: { - editorAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, - }, + forEditorAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/utils/is-current-user.function.test.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/utils/is-current-user.function.test.ts index 8fcaaff4c70c..8b4fe707386b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/utils/is-current-user.function.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/utils/is-current-user.function.test.ts @@ -24,12 +24,16 @@ class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLEle describe('isCurrentUser', async () => { let hostElement: UmbTestControllerHostElement; - beforeEach(async () => { + before(async () => { hostElement = new UmbTestControllerHostElement(); document.body.appendChild(hostElement); await hostElement.init(); }); + after(() => { + document.body.innerHTML = ''; + }); + it('should return true if the current user is the user with the given unique id', async () => { expect(await isCurrentUser(hostElement, 'bca6c733-a63d-4353-a271-9a8b6bcca8bd')).to.be.true; }); diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index f998cf1eaf55..da094f92b187 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -52,6 +52,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/block-rte": ["./src/packages/block/block-rte/index.ts"], "@umbraco-cms/backoffice/block-type": ["./src/packages/block/block-type/index.ts"], "@umbraco-cms/backoffice/block": ["./src/packages/block/block/index.ts"], + "@umbraco-cms/backoffice/clipboard": ["./src/packages/clipboard/index.ts"], "@umbraco-cms/backoffice/code-editor": ["./src/packages/code-editor/index.ts"], "@umbraco-cms/backoffice/collection": ["./src/packages/core/collection/index.ts"], "@umbraco-cms/backoffice/components": ["./src/packages/core/components/index.ts"], diff --git a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/imports.ts b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/imports.ts index aa0947329897..4d0720208b39 100644 --- a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/imports.ts +++ b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/imports.ts @@ -18,90 +18,91 @@ import * as import15 from '@umbraco-cms/backoffice/block-list'; import * as import16 from '@umbraco-cms/backoffice/block-rte'; import * as import17 from '@umbraco-cms/backoffice/block-type'; import * as import18 from '@umbraco-cms/backoffice/block'; -import * as import19 from '@umbraco-cms/backoffice/code-editor'; -import * as import20 from '@umbraco-cms/backoffice/collection'; -import * as import21 from '@umbraco-cms/backoffice/components'; -import * as import22 from '@umbraco-cms/backoffice/const'; -import * as import23 from '@umbraco-cms/backoffice/content-type'; -import * as import24 from '@umbraco-cms/backoffice/content'; -import * as import25 from '@umbraco-cms/backoffice/culture'; -import * as import26 from '@umbraco-cms/backoffice/current-user'; -import * as import27 from '@umbraco-cms/backoffice/dashboard'; -import * as import28 from '@umbraco-cms/backoffice/data-type'; -import * as import29 from '@umbraco-cms/backoffice/debug'; -import * as import30 from '@umbraco-cms/backoffice/dictionary'; -import * as import31 from '@umbraco-cms/backoffice/document-blueprint'; -import * as import32 from '@umbraco-cms/backoffice/document-type'; -import * as import33 from '@umbraco-cms/backoffice/document'; -import * as import34 from '@umbraco-cms/backoffice/entity-action'; -import * as import35 from '@umbraco-cms/backoffice/entity-bulk-action'; -import * as import36 from '@umbraco-cms/backoffice/entity-create-option-action'; -import * as import37 from '@umbraco-cms/backoffice/entity'; -import * as import38 from '@umbraco-cms/backoffice/event'; -import * as import39 from '@umbraco-cms/backoffice/extension-registry'; -import * as import40 from '@umbraco-cms/backoffice/health-check'; -import * as import41 from '@umbraco-cms/backoffice/help'; -import * as import42 from '@umbraco-cms/backoffice/icon'; -import * as import43 from '@umbraco-cms/backoffice/id'; -import * as import44 from '@umbraco-cms/backoffice/imaging'; -import * as import45 from '@umbraco-cms/backoffice/language'; -import * as import46 from '@umbraco-cms/backoffice/lit-element'; -import * as import47 from '@umbraco-cms/backoffice/localization'; -import * as import48 from '@umbraco-cms/backoffice/log-viewer'; -import * as import49 from '@umbraco-cms/backoffice/media-type'; -import * as import50 from '@umbraco-cms/backoffice/media'; -import * as import51 from '@umbraco-cms/backoffice/member-group'; -import * as import52 from '@umbraco-cms/backoffice/member-type'; -import * as import53 from '@umbraco-cms/backoffice/member'; -import * as import54 from '@umbraco-cms/backoffice/menu'; -import * as import55 from '@umbraco-cms/backoffice/modal'; -import * as import56 from '@umbraco-cms/backoffice/multi-url-picker'; -import * as import57 from '@umbraco-cms/backoffice/notification'; -import * as import58 from '@umbraco-cms/backoffice/object-type'; -import * as import59 from '@umbraco-cms/backoffice/package'; -import * as import60 from '@umbraco-cms/backoffice/partial-view'; -import * as import61 from '@umbraco-cms/backoffice/picker-input'; -import * as import62 from '@umbraco-cms/backoffice/picker'; -import * as import63 from '@umbraco-cms/backoffice/property-action'; -import * as import64 from '@umbraco-cms/backoffice/property-editor'; -import * as import65 from '@umbraco-cms/backoffice/property-type'; -import * as import66 from '@umbraco-cms/backoffice/property'; -import * as import67 from '@umbraco-cms/backoffice/recycle-bin'; -import * as import68 from '@umbraco-cms/backoffice/relation-type'; -import * as import69 from '@umbraco-cms/backoffice/relations'; -import * as import70 from '@umbraco-cms/backoffice/repository'; -import * as import71 from '@umbraco-cms/backoffice/resources'; -import * as import72 from '@umbraco-cms/backoffice/router'; -import * as import73 from '@umbraco-cms/backoffice/rte'; -import * as import74 from '@umbraco-cms/backoffice/script'; -import * as import75 from '@umbraco-cms/backoffice/search'; -import * as import76 from '@umbraco-cms/backoffice/section'; -import * as import77 from '@umbraco-cms/backoffice/server-file-system'; -import * as import78 from '@umbraco-cms/backoffice/settings'; -import * as import79 from '@umbraco-cms/backoffice/sorter'; -import * as import80 from '@umbraco-cms/backoffice/static-file'; -import * as import81 from '@umbraco-cms/backoffice/store'; -import * as import82 from '@umbraco-cms/backoffice/style'; -import * as import83 from '@umbraco-cms/backoffice/stylesheet'; -import * as import84 from '@umbraco-cms/backoffice/sysinfo'; -import * as import85 from '@umbraco-cms/backoffice/tags'; -import * as import86 from '@umbraco-cms/backoffice/template'; -import * as import87 from '@umbraco-cms/backoffice/temporary-file'; -import * as import88 from '@umbraco-cms/backoffice/themes'; -import * as import89 from '@umbraco-cms/backoffice/tiny-mce'; -import * as import90 from '@umbraco-cms/backoffice/tiptap'; -import * as import91 from '@umbraco-cms/backoffice/translation'; -import * as import92 from '@umbraco-cms/backoffice/tree'; -import * as import93 from '@umbraco-cms/backoffice/ufm'; -import * as import94 from '@umbraco-cms/backoffice/user-change-password'; -import * as import95 from '@umbraco-cms/backoffice/user-group'; -import * as import96 from '@umbraco-cms/backoffice/user-permission'; -import * as import97 from '@umbraco-cms/backoffice/user'; -import * as import98 from '@umbraco-cms/backoffice/utils'; -import * as import99 from '@umbraco-cms/backoffice/validation'; -import * as import100 from '@umbraco-cms/backoffice/variant'; -import * as import101 from '@umbraco-cms/backoffice/webhook'; -import * as import102 from '@umbraco-cms/backoffice/workspace'; +import * as import19 from '@umbraco-cms/backoffice/clipboard'; +import * as import20 from '@umbraco-cms/backoffice/code-editor'; +import * as import21 from '@umbraco-cms/backoffice/collection'; +import * as import22 from '@umbraco-cms/backoffice/components'; +import * as import23 from '@umbraco-cms/backoffice/const'; +import * as import24 from '@umbraco-cms/backoffice/content-type'; +import * as import25 from '@umbraco-cms/backoffice/content'; +import * as import26 from '@umbraco-cms/backoffice/culture'; +import * as import27 from '@umbraco-cms/backoffice/current-user'; +import * as import28 from '@umbraco-cms/backoffice/dashboard'; +import * as import29 from '@umbraco-cms/backoffice/data-type'; +import * as import30 from '@umbraco-cms/backoffice/debug'; +import * as import31 from '@umbraco-cms/backoffice/dictionary'; +import * as import32 from '@umbraco-cms/backoffice/document-blueprint'; +import * as import33 from '@umbraco-cms/backoffice/document-type'; +import * as import34 from '@umbraco-cms/backoffice/document'; +import * as import35 from '@umbraco-cms/backoffice/entity-action'; +import * as import36 from '@umbraco-cms/backoffice/entity-bulk-action'; +import * as import37 from '@umbraco-cms/backoffice/entity-create-option-action'; +import * as import38 from '@umbraco-cms/backoffice/entity'; +import * as import39 from '@umbraco-cms/backoffice/event'; +import * as import40 from '@umbraco-cms/backoffice/extension-registry'; +import * as import41 from '@umbraco-cms/backoffice/health-check'; +import * as import42 from '@umbraco-cms/backoffice/help'; +import * as import43 from '@umbraco-cms/backoffice/icon'; +import * as import44 from '@umbraco-cms/backoffice/id'; +import * as import45 from '@umbraco-cms/backoffice/imaging'; +import * as import46 from '@umbraco-cms/backoffice/language'; +import * as import47 from '@umbraco-cms/backoffice/lit-element'; +import * as import48 from '@umbraco-cms/backoffice/localization'; +import * as import49 from '@umbraco-cms/backoffice/log-viewer'; +import * as import50 from '@umbraco-cms/backoffice/media-type'; +import * as import51 from '@umbraco-cms/backoffice/media'; +import * as import52 from '@umbraco-cms/backoffice/member-group'; +import * as import53 from '@umbraco-cms/backoffice/member-type'; +import * as import54 from '@umbraco-cms/backoffice/member'; +import * as import55 from '@umbraco-cms/backoffice/menu'; +import * as import56 from '@umbraco-cms/backoffice/modal'; +import * as import57 from '@umbraco-cms/backoffice/multi-url-picker'; +import * as import58 from '@umbraco-cms/backoffice/notification'; +import * as import59 from '@umbraco-cms/backoffice/object-type'; +import * as import60 from '@umbraco-cms/backoffice/package'; +import * as import61 from '@umbraco-cms/backoffice/partial-view'; +import * as import62 from '@umbraco-cms/backoffice/picker-input'; +import * as import63 from '@umbraco-cms/backoffice/picker'; +import * as import64 from '@umbraco-cms/backoffice/property-action'; +import * as import65 from '@umbraco-cms/backoffice/property-editor'; +import * as import66 from '@umbraco-cms/backoffice/property-type'; +import * as import67 from '@umbraco-cms/backoffice/property'; +import * as import68 from '@umbraco-cms/backoffice/recycle-bin'; +import * as import69 from '@umbraco-cms/backoffice/relation-type'; +import * as import70 from '@umbraco-cms/backoffice/relations'; +import * as import71 from '@umbraco-cms/backoffice/repository'; +import * as import72 from '@umbraco-cms/backoffice/resources'; +import * as import73 from '@umbraco-cms/backoffice/router'; +import * as import74 from '@umbraco-cms/backoffice/rte'; +import * as import75 from '@umbraco-cms/backoffice/script'; +import * as import76 from '@umbraco-cms/backoffice/search'; +import * as import77 from '@umbraco-cms/backoffice/section'; +import * as import78 from '@umbraco-cms/backoffice/server-file-system'; +import * as import79 from '@umbraco-cms/backoffice/settings'; +import * as import80 from '@umbraco-cms/backoffice/sorter'; +import * as import81 from '@umbraco-cms/backoffice/static-file'; +import * as import82 from '@umbraco-cms/backoffice/store'; +import * as import83 from '@umbraco-cms/backoffice/style'; +import * as import84 from '@umbraco-cms/backoffice/stylesheet'; +import * as import85 from '@umbraco-cms/backoffice/sysinfo'; +import * as import86 from '@umbraco-cms/backoffice/tags'; +import * as import87 from '@umbraco-cms/backoffice/template'; +import * as import88 from '@umbraco-cms/backoffice/temporary-file'; +import * as import89 from '@umbraco-cms/backoffice/themes'; +import * as import90 from '@umbraco-cms/backoffice/tiny-mce'; +import * as import91 from '@umbraco-cms/backoffice/tiptap'; +import * as import92 from '@umbraco-cms/backoffice/translation'; +import * as import93 from '@umbraco-cms/backoffice/tree'; +import * as import94 from '@umbraco-cms/backoffice/ufm'; +import * as import95 from '@umbraco-cms/backoffice/user-change-password'; +import * as import96 from '@umbraco-cms/backoffice/user-group'; +import * as import97 from '@umbraco-cms/backoffice/user-permission'; +import * as import98 from '@umbraco-cms/backoffice/user'; +import * as import99 from '@umbraco-cms/backoffice/utils'; +import * as import100 from '@umbraco-cms/backoffice/validation'; +import * as import101 from '@umbraco-cms/backoffice/variant'; +import * as import102 from '@umbraco-cms/backoffice/webhook'; +import * as import103 from '@umbraco-cms/backoffice/workspace'; export const imports = [ { @@ -181,340 +182,344 @@ import * as import102 from '@umbraco-cms/backoffice/workspace'; package: import18 }, { - path: '@umbraco-cms/backoffice/code-editor', + path: '@umbraco-cms/backoffice/clipboard', package: import19 }, { - path: '@umbraco-cms/backoffice/collection', + path: '@umbraco-cms/backoffice/code-editor', package: import20 }, { - path: '@umbraco-cms/backoffice/components', + path: '@umbraco-cms/backoffice/collection', package: import21 }, { - path: '@umbraco-cms/backoffice/const', + path: '@umbraco-cms/backoffice/components', package: import22 }, { - path: '@umbraco-cms/backoffice/content-type', + path: '@umbraco-cms/backoffice/const', package: import23 }, { - path: '@umbraco-cms/backoffice/content', + path: '@umbraco-cms/backoffice/content-type', package: import24 }, { - path: '@umbraco-cms/backoffice/culture', + path: '@umbraco-cms/backoffice/content', package: import25 }, { - path: '@umbraco-cms/backoffice/current-user', + path: '@umbraco-cms/backoffice/culture', package: import26 }, { - path: '@umbraco-cms/backoffice/dashboard', + path: '@umbraco-cms/backoffice/current-user', package: import27 }, { - path: '@umbraco-cms/backoffice/data-type', + path: '@umbraco-cms/backoffice/dashboard', package: import28 }, { - path: '@umbraco-cms/backoffice/debug', + path: '@umbraco-cms/backoffice/data-type', package: import29 }, { - path: '@umbraco-cms/backoffice/dictionary', + path: '@umbraco-cms/backoffice/debug', package: import30 }, { - path: '@umbraco-cms/backoffice/document-blueprint', + path: '@umbraco-cms/backoffice/dictionary', package: import31 }, { - path: '@umbraco-cms/backoffice/document-type', + path: '@umbraco-cms/backoffice/document-blueprint', package: import32 }, { - path: '@umbraco-cms/backoffice/document', + path: '@umbraco-cms/backoffice/document-type', package: import33 }, { - path: '@umbraco-cms/backoffice/entity-action', + path: '@umbraco-cms/backoffice/document', package: import34 }, { - path: '@umbraco-cms/backoffice/entity-bulk-action', + path: '@umbraco-cms/backoffice/entity-action', package: import35 }, { - path: '@umbraco-cms/backoffice/entity-create-option-action', + path: '@umbraco-cms/backoffice/entity-bulk-action', package: import36 }, { - path: '@umbraco-cms/backoffice/entity', + path: '@umbraco-cms/backoffice/entity-create-option-action', package: import37 }, { - path: '@umbraco-cms/backoffice/event', + path: '@umbraco-cms/backoffice/entity', package: import38 }, { - path: '@umbraco-cms/backoffice/extension-registry', + path: '@umbraco-cms/backoffice/event', package: import39 }, { - path: '@umbraco-cms/backoffice/health-check', + path: '@umbraco-cms/backoffice/extension-registry', package: import40 }, { - path: '@umbraco-cms/backoffice/help', + path: '@umbraco-cms/backoffice/health-check', package: import41 }, { - path: '@umbraco-cms/backoffice/icon', + path: '@umbraco-cms/backoffice/help', package: import42 }, { - path: '@umbraco-cms/backoffice/id', + path: '@umbraco-cms/backoffice/icon', package: import43 }, { - path: '@umbraco-cms/backoffice/imaging', + path: '@umbraco-cms/backoffice/id', package: import44 }, { - path: '@umbraco-cms/backoffice/language', + path: '@umbraco-cms/backoffice/imaging', package: import45 }, { - path: '@umbraco-cms/backoffice/lit-element', + path: '@umbraco-cms/backoffice/language', package: import46 }, { - path: '@umbraco-cms/backoffice/localization', + path: '@umbraco-cms/backoffice/lit-element', package: import47 }, { - path: '@umbraco-cms/backoffice/log-viewer', + path: '@umbraco-cms/backoffice/localization', package: import48 }, { - path: '@umbraco-cms/backoffice/media-type', + path: '@umbraco-cms/backoffice/log-viewer', package: import49 }, { - path: '@umbraco-cms/backoffice/media', + path: '@umbraco-cms/backoffice/media-type', package: import50 }, { - path: '@umbraco-cms/backoffice/member-group', + path: '@umbraco-cms/backoffice/media', package: import51 }, { - path: '@umbraco-cms/backoffice/member-type', + path: '@umbraco-cms/backoffice/member-group', package: import52 }, { - path: '@umbraco-cms/backoffice/member', + path: '@umbraco-cms/backoffice/member-type', package: import53 }, { - path: '@umbraco-cms/backoffice/menu', + path: '@umbraco-cms/backoffice/member', package: import54 }, { - path: '@umbraco-cms/backoffice/modal', + path: '@umbraco-cms/backoffice/menu', package: import55 }, { - path: '@umbraco-cms/backoffice/multi-url-picker', + path: '@umbraco-cms/backoffice/modal', package: import56 }, { - path: '@umbraco-cms/backoffice/notification', + path: '@umbraco-cms/backoffice/multi-url-picker', package: import57 }, { - path: '@umbraco-cms/backoffice/object-type', + path: '@umbraco-cms/backoffice/notification', package: import58 }, { - path: '@umbraco-cms/backoffice/package', + path: '@umbraco-cms/backoffice/object-type', package: import59 }, { - path: '@umbraco-cms/backoffice/partial-view', + path: '@umbraco-cms/backoffice/package', package: import60 }, { - path: '@umbraco-cms/backoffice/picker-input', + path: '@umbraco-cms/backoffice/partial-view', package: import61 }, { - path: '@umbraco-cms/backoffice/picker', + path: '@umbraco-cms/backoffice/picker-input', package: import62 }, { - path: '@umbraco-cms/backoffice/property-action', + path: '@umbraco-cms/backoffice/picker', package: import63 }, { - path: '@umbraco-cms/backoffice/property-editor', + path: '@umbraco-cms/backoffice/property-action', package: import64 }, { - path: '@umbraco-cms/backoffice/property-type', + path: '@umbraco-cms/backoffice/property-editor', package: import65 }, { - path: '@umbraco-cms/backoffice/property', + path: '@umbraco-cms/backoffice/property-type', package: import66 }, { - path: '@umbraco-cms/backoffice/recycle-bin', + path: '@umbraco-cms/backoffice/property', package: import67 }, { - path: '@umbraco-cms/backoffice/relation-type', + path: '@umbraco-cms/backoffice/recycle-bin', package: import68 }, { - path: '@umbraco-cms/backoffice/relations', + path: '@umbraco-cms/backoffice/relation-type', package: import69 }, { - path: '@umbraco-cms/backoffice/repository', + path: '@umbraco-cms/backoffice/relations', package: import70 }, { - path: '@umbraco-cms/backoffice/resources', + path: '@umbraco-cms/backoffice/repository', package: import71 }, { - path: '@umbraco-cms/backoffice/router', + path: '@umbraco-cms/backoffice/resources', package: import72 }, { - path: '@umbraco-cms/backoffice/rte', + path: '@umbraco-cms/backoffice/router', package: import73 }, { - path: '@umbraco-cms/backoffice/script', + path: '@umbraco-cms/backoffice/rte', package: import74 }, { - path: '@umbraco-cms/backoffice/search', + path: '@umbraco-cms/backoffice/script', package: import75 }, { - path: '@umbraco-cms/backoffice/section', + path: '@umbraco-cms/backoffice/search', package: import76 }, { - path: '@umbraco-cms/backoffice/server-file-system', + path: '@umbraco-cms/backoffice/section', package: import77 }, { - path: '@umbraco-cms/backoffice/settings', + path: '@umbraco-cms/backoffice/server-file-system', package: import78 }, { - path: '@umbraco-cms/backoffice/sorter', + path: '@umbraco-cms/backoffice/settings', package: import79 }, { - path: '@umbraco-cms/backoffice/static-file', + path: '@umbraco-cms/backoffice/sorter', package: import80 }, { - path: '@umbraco-cms/backoffice/store', + path: '@umbraco-cms/backoffice/static-file', package: import81 }, { - path: '@umbraco-cms/backoffice/style', + path: '@umbraco-cms/backoffice/store', package: import82 }, { - path: '@umbraco-cms/backoffice/stylesheet', + path: '@umbraco-cms/backoffice/style', package: import83 }, { - path: '@umbraco-cms/backoffice/sysinfo', + path: '@umbraco-cms/backoffice/stylesheet', package: import84 }, { - path: '@umbraco-cms/backoffice/tags', + path: '@umbraco-cms/backoffice/sysinfo', package: import85 }, { - path: '@umbraco-cms/backoffice/template', + path: '@umbraco-cms/backoffice/tags', package: import86 }, { - path: '@umbraco-cms/backoffice/temporary-file', + path: '@umbraco-cms/backoffice/template', package: import87 }, { - path: '@umbraco-cms/backoffice/themes', + path: '@umbraco-cms/backoffice/temporary-file', package: import88 }, { - path: '@umbraco-cms/backoffice/tiny-mce', + path: '@umbraco-cms/backoffice/themes', package: import89 }, { - path: '@umbraco-cms/backoffice/tiptap', + path: '@umbraco-cms/backoffice/tiny-mce', package: import90 }, { - path: '@umbraco-cms/backoffice/translation', + path: '@umbraco-cms/backoffice/tiptap', package: import91 }, { - path: '@umbraco-cms/backoffice/tree', + path: '@umbraco-cms/backoffice/translation', package: import92 }, { - path: '@umbraco-cms/backoffice/ufm', + path: '@umbraco-cms/backoffice/tree', package: import93 }, { - path: '@umbraco-cms/backoffice/user-change-password', + path: '@umbraco-cms/backoffice/ufm', package: import94 }, { - path: '@umbraco-cms/backoffice/user-group', + path: '@umbraco-cms/backoffice/user-change-password', package: import95 }, { - path: '@umbraco-cms/backoffice/user-permission', + path: '@umbraco-cms/backoffice/user-group', package: import96 }, { - path: '@umbraco-cms/backoffice/user', + path: '@umbraco-cms/backoffice/user-permission', package: import97 }, { - path: '@umbraco-cms/backoffice/utils', + path: '@umbraco-cms/backoffice/user', package: import98 }, { - path: '@umbraco-cms/backoffice/validation', + path: '@umbraco-cms/backoffice/utils', package: import99 }, { - path: '@umbraco-cms/backoffice/variant', + path: '@umbraco-cms/backoffice/validation', package: import100 }, { - path: '@umbraco-cms/backoffice/webhook', + path: '@umbraco-cms/backoffice/variant', package: import101 }, { - path: '@umbraco-cms/backoffice/workspace', + path: '@umbraco-cms/backoffice/webhook', package: import102 + }, +{ + path: '@umbraco-cms/backoffice/workspace', + package: import103 } ]; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts index aeff4bcdcf99..434709065d4e 100644 --- a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts +++ b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts @@ -60,7 +60,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/block-grid', - consts: ["UMB_BLOCK_GRID_AREA_CONFIG_ENTRY_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_MODAL","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_ALIAS","UMB_BLOCK_GRID_TYPE","UMB_BLOCK_GRID","UMB_BLOCK_GRID_ENTRIES_CONTEXT","UMB_BLOCK_GRID_ENTRY_CONTEXT","UMB_BLOCK_GRID_MANAGER_CONTEXT","UMB_BLOCK_GRID_DEFAULT_LAYOUT_STYLESHEET","UMB_BLOCK_GRID_AREA_TYPE_ENTRIES_CONTEXT","UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS","UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS","UMB_BLOCK_GRID_TYPE_WORKSPACE_MODAL","UMB_BLOCK_GRID_WORKSPACE_MODAL","UMB_BLOCK_GRID_TYPE_WORKSPACE_ALIAS"] + consts: ["UMB_GRID_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE","UMB_BLOCK_GRID_AREA_CONFIG_ENTRY_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_CONTEXT","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_MODAL","UMB_BLOCK_GRID_AREA_TYPE_WORKSPACE_ALIAS","UMB_BLOCK_GRID_TYPE","UMB_BLOCK_GRID","UMB_BLOCK_GRID_ENTRIES_CONTEXT","UMB_BLOCK_GRID_ENTRY_CONTEXT","UMB_BLOCK_GRID_MANAGER_CONTEXT","UMB_BLOCK_GRID_DEFAULT_LAYOUT_STYLESHEET","UMB_BLOCK_GRID_AREA_TYPE_ENTRIES_CONTEXT","UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS","UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS","UMB_BLOCK_GRID_TYPE_WORKSPACE_MODAL","UMB_BLOCK_GRID_WORKSPACE_MODAL","UMB_BLOCK_GRID_TYPE_WORKSPACE_ALIAS"] }, { path: '@umbraco-cms/backoffice/block-list', @@ -68,7 +68,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/block-rte', - consts: ["UMB_BLOCK_RTE_TYPE","UMB_BLOCK_RTE","UMB_BLOCK_RTE_ENTRIES_CONTEXT","UMB_BLOCK_RTE_ENTRY_CONTEXT","UMB_BLOCK_RTE_MANAGER_CONTEXT","UMB_BLOCK_RTE_WORKSPACE_MODAL","UMB_BLOCK_RTE_TYPE_WORKSPACE_ALIAS"] + consts: ["UMB_BLOCK_RTE_TYPE","UMB_BLOCK_RTE","UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS","UMB_BLOCK_RTE_ENTRIES_CONTEXT","UMB_BLOCK_RTE_ENTRY_CONTEXT","UMB_BLOCK_RTE_MANAGER_CONTEXT","UMB_BLOCK_RTE_WORKSPACE_MODAL","UMB_BLOCK_RTE_TYPE_WORKSPACE_ALIAS"] }, { path: '@umbraco-cms/backoffice/block-type', @@ -76,7 +76,11 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/block', - consts: ["UMB_BLOCK_ENTRIES_CONTEXT","UMB_BLOCK_ENTRY_CONTEXT","UMB_BLOCK_MANAGER_CONTEXT","UMB_BLOCK_CATALOGUE_MODAL","UMB_BLOCK_ELEMENT_PROPERTY_DATASET_CONTEXT","UMB_BLOCK_WORKSPACE_CONTEXT","UMB_BLOCK_WORKSPACE_MODAL","UMB_BLOCK_WORKSPACE_ALIAS"] + consts: ["UMB_BLOCK_CLIPBOARD_ENTRY_VALUE_TYPE","UMB_BLOCK_ENTRIES_CONTEXT","UMB_BLOCK_ENTRY_CONTEXT","UMB_BLOCK_MANAGER_CONTEXT","UMB_BLOCK_CATALOGUE_MODAL","UMB_BLOCK_ELEMENT_PROPERTY_DATASET_CONTEXT","UMB_BLOCK_WORKSPACE_CONTEXT","UMB_BLOCK_WORKSPACE_MODAL","UMB_BLOCK_WORKSPACE_ALIAS"] + }, +{ + path: '@umbraco-cms/backoffice/clipboard', + consts: ["UMB_CLIPBOARD_ENTRY_DETAIL_STORE_CONTEXT","UMB_CLIPBOARD_ENTRY_DETAIL_REPOSITORY_ALIAS","UMB_CLIPBOARD_ENTRY_DETAIL_STORE_ALIAS","UMB_CLIPBOARD_ENTRY_ENTITY_TYPE","UMB_CLIPBOARD_ENTRY_ITEM_STORE_CONTEXT","UMB_CLIPBOARD_ENTRY_ITEM_REPOSITORY_ALIAS","UMB_CLIPBOARD_ENTRY_ITEM_STORE_ALIAS","UMB_CLIPBOARD_ENTRY_PICKER_MODAL","UMB_CLIPBOARD_ENTRY_PICKER_MODAL_ALIAS","UMB_CLIPBOARD_ROOT_WORKSPACE_ALIAS","UMB_CLIPBOARD_ROOT_ENTITY_TYPE","UMB_CLIPBOARD_COLLECTION_ALIAS","UMB_CLIPBOARD_COLLECTION_REPOSITORY_ALIAS","UMB_CLIPBOARD_TABLE_COLLECTION_VIEW_ALIAS","UMB_CLIPBOARD_CONTEXT","UMB_CLIPBOARD_PROPERTY_CONTEXT"] }, { path: '@umbraco-cms/backoffice/code-editor', @@ -264,7 +268,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/property-action', - consts: [] + consts: ["UMB_PROPERTY_ACTION_DEFAULT_KIND_MANIFEST"] }, { path: '@umbraco-cms/backoffice/property-editor', @@ -276,7 +280,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/property', - consts: ["UMB_WRITABLE_PROPERTY_CONDITION_ALIAS","UMB_PROPERTY_CONTEXT","UMB_NAMEABLE_PROPERTY_DATASET_CONTEXT","UMB_PROPERTY_DATASET_CONTEXT","UMB_UNSUPPORTED_EDITOR_SCHEMA_ALIASES"] + consts: ["UMB_PROPERTY_CONTEXT","UMB_UNSUPPORTED_EDITOR_SCHEMA_ALIASES","UMB_PROPERTY_HAS_VALUE_CONDITION_ALIAS","UMB_WRITABLE_PROPERTY_CONDITION_ALIAS","UMB_NAMEABLE_PROPERTY_DATASET_CONTEXT","UMB_PROPERTY_DATASET_CONTEXT"] }, { path: '@umbraco-cms/backoffice/recycle-bin', diff --git a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs index ba1a12854fde..ba5e5cca5be0 100644 --- a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs +++ b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs @@ -28,7 +28,7 @@ export default { rootDir: '.', files: ['./src/**/*.test.ts'], nodeResolve: { exportConditions: mode === 'dev' ? ['development'] : [], preferBuiltins: false, browser: false }, - browsers: [playwrightLauncher({ product: 'chromium' }), playwrightLauncher({ product: 'webkit' })], + browsers: [playwrightLauncher({ product: 'chromium' })], /* TODO: fix coverage report coverageConfig: { reporters: ['lcovonly', 'text-summary'],