@@ -362,10 +365,10 @@ export class UmbPropertySettingsModalElement extends UmbModalBaseElement<
}
#renderVariationControls() {
- return this._documentVariesByCulture || this._documentVariesBySegment
+ return this._contentTypeVariesByCulture || this._contentTypeVariesBySegment
? html`
Variation
- ${this._documentVariesByCulture ? this.#renderVaryByCulture() : ''}
+ ${this._contentTypeVariesByCulture ? this.#renderVaryByCulture() : ''}
`
: '';
@@ -510,10 +513,10 @@ export class UmbPropertySettingsModalElement extends UmbModalBaseElement<
];
}
-export default UmbPropertySettingsModalElement;
+export default UmbPropertyTypeSettingsModalElement;
declare global {
interface HTMLElementTagNameMap {
- 'umb-property-settings-modal': UmbPropertySettingsModalElement;
+ 'umb-property-type-settings-modal': UmbPropertyTypeSettingsModalElement;
}
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/modals/property-type-settings/property-type-settings-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/modals/property-type-settings/property-type-settings-modal.token.ts
new file mode 100644
index 0000000000..63fe6fe95c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/modals/property-type-settings/property-type-settings-modal.token.ts
@@ -0,0 +1,22 @@
+import { UmbModalToken } from '../../../modal/token/modal-token.js';
+import type { UmbPropertyTypeModel, UmbPropertyTypeScaffoldModel } from '@umbraco-cms/backoffice/content-type';
+
+export type UmbPropertyTypeSettingsModalData = {
+ contentTypeId: string;
+};
+export type UmbPropertyTypeSettingsModalValue = UmbPropertyTypeModel | UmbPropertyTypeScaffoldModel;
+
+export const UMB_PROPERTY_TYPE_SETTINGS_MODAL = new UmbModalToken<
+ UmbPropertyTypeSettingsModalData,
+ UmbPropertyTypeSettingsModalValue
+>('Umb.Modal.PropertyTypeSettings', {
+ modal: {
+ type: 'sidebar',
+ size: 'small',
+ },
+ value: {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ validation: {},
+ },
+});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/index.ts
new file mode 100644
index 0000000000..c590b6690d
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/index.ts
@@ -0,0 +1 @@
+export * from './structure/index.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-data-source.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-data-source.interface.ts
similarity index 100%
rename from src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-data-source.interface.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-data-source.interface.ts
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-repository-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-repository-base.ts
similarity index 100%
rename from src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-repository-base.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-repository-base.ts
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-repository.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-repository.interface.ts
similarity index 100%
rename from src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-repository.interface.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-repository.interface.ts
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-server-data-source-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-server-data-source-base.ts
similarity index 96%
rename from src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-server-data-source-base.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-server-data-source-base.ts
index a9e8896cce..262330e804 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-server-data-source-base.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/content-type-structure-server-data-source-base.ts
@@ -1,4 +1,4 @@
-import type { UmbPagedModel } from '../../repository/types.js';
+import type { UmbPagedModel } from '../../../repository/types.js';
import type { UmbContentTypeStructureDataSource } from './content-type-structure-data-source.interface.js';
import type { AllowedContentTypeModel, ItemResponseModelBaseModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/index.ts
new file mode 100644
index 0000000000..d1a5aa8c6b
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/repository/structure/index.ts
@@ -0,0 +1,4 @@
+export * from './content-type-structure-repository-base.js';
+export * from './content-type-structure-repository.interface.js';
+export * from './content-type-structure-server-data-source-base.js';
+export * from './content-type-structure-data-source.interface.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-container-structure-helper.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-container-structure-helper.class.ts
new file mode 100644
index 0000000000..378294b492
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-container-structure-helper.class.ts
@@ -0,0 +1,272 @@
+import type { UmbContentTypeModel, UmbPropertyContainerTypes, UmbPropertyTypeContainerModel } from '../types.js';
+import type { UmbContentTypeStructureManager } from './content-type-structure-manager.class.js';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
+import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
+
+/**
+ * This class is a helper class for managing the structure of containers in a content type.
+ * This requires a structure manager {@link UmbContentTypeStructureManager} to manage the structure.
+ */
+export class UmbContentTypeContainerStructureHelper
extends UmbControllerBase {
+ #init;
+ #initResolver?: (value: unknown) => void;
+
+ _containerId?: string | null;
+ _childType?: UmbPropertyContainerTypes = 'Group';
+
+ #structure?: UmbContentTypeStructureManager;
+
+ // State containing the all containers defined in the data:
+ #containers = new UmbArrayState([], (x) => x.id);
+ readonly containers = this.#containers.asObservable();
+
+ // State containing the merged containers (only one pr. name):
+ #mergedContainers = new UmbArrayState([], (x) => x.id);
+ readonly mergedContainers = this.#mergedContainers.asObservable();
+
+ // Owner containers are containers owned by the owner Content Type (The specific one up for editing)
+ private _ownerContainers: UmbPropertyTypeContainerModel[] = [];
+
+ #hasProperties = new UmbBooleanState(false);
+ readonly hasProperties = this.#hasProperties.asObservable();
+
+ constructor(host: UmbControllerHost) {
+ super(host);
+ this.#init = new Promise((resolve) => {
+ this.#initResolver = resolve;
+ });
+
+ this.#mergedContainers.sortBy((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
+ this.observe(this.containers, this.#performContainerMerge, null);
+ }
+
+ public setStructureManager(structure: UmbContentTypeStructureManager) {
+ if (this.#structure === structure) return;
+ if (this.#structure) {
+ throw new Error(
+ 'Structure manager is already set, the helpers are not designed to be re-setup with new managers',
+ );
+ }
+ this.#structure = structure;
+ this.#initResolver?.(undefined);
+ this.#initResolver = undefined;
+ this.#observeContainers();
+ }
+
+ public getStructureManager() {
+ return this.#structure;
+ }
+
+ public setIsRoot(value: boolean) {
+ if (value === true) {
+ this.setContainerId(null);
+ }
+ }
+ public getIsRoot() {
+ return this._containerId === null;
+ }
+
+ public setContainerId(value: string | null | undefined) {
+ if (this._containerId === value) return;
+ this._containerId = value;
+ this.#observeContainers();
+ }
+ public getContainerId() {
+ return this._containerId;
+ }
+
+ public setContainerChildType(value?: UmbPropertyContainerTypes) {
+ if (this._childType === value) return;
+ this._childType = value;
+ this.#observeContainers();
+ }
+ public getContainerChildType() {
+ return this._childType;
+ }
+
+ private _containerName?: string;
+ private _containerType?: UmbPropertyContainerTypes;
+ private _parentName?: string | null;
+ private _parentType?: UmbPropertyContainerTypes;
+
+ #observeContainers() {
+ if (!this.#structure || this._containerId === undefined) return;
+
+ if (this._containerId === null) {
+ this.#observeHasPropertiesOf(null);
+ this.#observeRootContainers();
+ this.removeControllerByAlias('_observeContainers');
+ } else {
+ this.observe(
+ this.#structure.containerById(this._containerId),
+ (container) => {
+ if (container) {
+ this._containerName = container.name ?? '';
+ this._containerType = container.type;
+ if (container.parent) {
+ // We have a parent for our main container, so lets observe that one as well:
+ this.observe(
+ this.#structure!.containerById(container.parent.id),
+ (parent) => {
+ if (parent) {
+ this._parentName = parent.name ?? '';
+ this._parentType = parent.type;
+ this.#observeSimilarContainers();
+ } else {
+ this.removeControllerByAlias('_observeContainers');
+ this._parentName = undefined;
+ this._parentType = undefined;
+ }
+ },
+ '_observeMainParentContainer',
+ );
+ } else {
+ this.removeControllerByAlias('_observeMainParentContainer');
+ this._parentName = null; //In this way we want to look for one without a parent. [NL]
+ this._parentType = undefined;
+ this.#observeSimilarContainers();
+ }
+ } else {
+ this.removeControllerByAlias('_observeContainers');
+ this._containerName = undefined;
+ this._containerType = undefined;
+ // TODO: reset has Properties.
+ this.#hasProperties.setValue(false);
+ }
+ },
+ '_observeMainContainer',
+ );
+ }
+ }
+
+ #observeSimilarContainers() {
+ if (!this._containerName || !this._containerType || this._parentName === undefined) return;
+ this.observe(
+ this.#structure!.containersByNameAndTypeAndParent(
+ this._containerName,
+ this._containerType,
+ this._parentName,
+ this._parentType,
+ ),
+ (containers) => {
+ // We want to remove hasProperties of groups that does not exist anymore.:
+ // this.#removeHasPropertiesOfGroup()
+ this.#hasProperties.setValue(false);
+ this.#containers.setValue([]);
+
+ containers.forEach((container) => {
+ this.#observeHasPropertiesOf(container.id);
+
+ this.observe(
+ this.#structure!.containersOfParentId(container.id, this._childType!),
+ (containers) => {
+ // Remove existing containers that are not the parent of the new containers:
+ this.#containers.filter((x) => x.parent?.id !== container.id || containers.some((y) => y.id === x.id));
+
+ this.#containers.append(containers);
+ },
+ '_observeGroupsOf_' + container.id,
+ );
+ });
+ },
+ '_observeContainers',
+ );
+ }
+
+ #observeRootContainers() {
+ if (!this.#structure || !this._childType || !this._containerId === undefined) return;
+
+ this.observe(
+ this.#structure.rootContainers(this._childType),
+ (rootContainers) => {
+ // Here (When getting root containers) we get containers from all ContentTypes. It also means we need to do an extra filtering to ensure we only get one of each containers. [NL]
+
+ // For that we get the owner containers first (We do not need to observe as this observation will be triggered if one of the owner containers change) [NL]
+ this._ownerContainers = this.#structure!.getOwnerContainers(this._childType!, this._containerId!) ?? [];
+ this.#containers.setValue(rootContainers);
+ },
+ '_observeRootContainers',
+ );
+ }
+
+ #observeHasPropertiesOf(groupId?: string | null) {
+ if (!this.#structure || groupId === undefined) return;
+
+ this.observe(
+ this.#structure.hasPropertyStructuresOf(groupId),
+ (hasProperties) => {
+ // TODO: Make this an array/map/state, so we only change the groupId. then hasProperties should be a observablePart that checks the array for true. [NL]
+ this.#hasProperties.setValue(hasProperties);
+ },
+ '_observePropertyStructureOfGroup' + groupId,
+ );
+ }
+
+ #filterNonOwnerContainers(containers: Array) {
+ return this._ownerContainers.length > 0
+ ? containers.filter(
+ (anyCon) =>
+ !this._ownerContainers.some(
+ (ownerCon) =>
+ // Then if this is not the owner container but matches one by name & type, then we do not want it.
+ ownerCon.id !== anyCon.id && ownerCon.name === anyCon.name && ownerCon.type === anyCon.type,
+ ),
+ )
+ : containers;
+ }
+
+ #performContainerMerge = (containers: Array) => {
+ // Remove containers that matches with a owner container:
+ let merged = this.#filterNonOwnerContainers(containers);
+ // Remove containers of same name and type:
+ // This only works cause we are dealing with a single level of containers in this Helper, if we had more levels we would need to be more clever about the parent as well. [NL]
+ merged = merged.filter((x, i, cons) => i === cons.findIndex((y) => y.name === x.name && y.type === x.type));
+ this.#mergedContainers.setValue(merged);
+ };
+
+ /**
+ * Returns true if the container is an owner container.
+ */
+ isOwnerChildContainer(containerId?: string) {
+ if (!this.#structure || !containerId) return;
+ return this._ownerContainers.some((x) => x.id === containerId);
+ }
+
+ containersByNameAndType(name: string, type: UmbPropertyContainerTypes) {
+ return this.#containers.asObservablePart((cons) => cons.filter((x) => x.name === name && x.type === type));
+ }
+
+ /** Manipulate methods: */
+
+ async insertContainer(container: UmbPropertyTypeContainerModel, sortOrder = 0) {
+ await this.#init;
+ if (!this.#structure) return false;
+
+ const newContainer = { ...container, sortOrder };
+
+ await this.#structure.insertContainer(null, newContainer);
+ return true;
+ }
+
+ async addContainer(parentContainerId?: string | null, sortOrder?: number) {
+ if (!this.#structure) return;
+
+ await this.#structure.createContainer(null, parentContainerId, this._childType, sortOrder);
+ }
+
+ async removeContainer(groupId: string) {
+ await this.#init;
+ if (!this.#structure) return false;
+
+ await this.#structure.removeContainer(null, groupId);
+ return true;
+ }
+
+ async partialUpdateContainer(containerId: string, partialUpdate: Partial) {
+ await this.#init;
+ if (!this.#structure || !containerId || !partialUpdate) return;
+
+ return await this.#structure.updateContainer(null, containerId, partialUpdate);
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-property-structure-helper.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-property-structure-helper.class.ts
new file mode 100644
index 0000000000..0b05ad6c58
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-property-structure-helper.class.ts
@@ -0,0 +1,228 @@
+import type {
+ UmbContentTypeModel,
+ UmbPropertyContainerTypes,
+ UmbPropertyTypeContainerModel,
+ UmbPropertyTypeModel,
+} from '../types.js';
+import type { UmbContentTypeStructureManager } from './content-type-structure-manager.class.js';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
+import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
+
+type UmbPropertyTypeId = UmbPropertyTypeModel['id'];
+
+/**
+ * This class is a helper class for managing the structure of containers in a content type.
+ * This requires a structure manager {@link UmbContentTypeStructureManager} to manage the structure.
+ */
+export class UmbContentTypePropertyStructureHelper extends UmbControllerBase {
+ #init;
+ #initResolver?: (value: unknown) => void;
+
+ #structure?: UmbContentTypeStructureManager;
+
+ private _containerId?: string | null;
+
+ #propertyStructure = new UmbArrayState([], (x) => x.id);
+ readonly propertyStructure = this.#propertyStructure.asObservable();
+
+ constructor(host: UmbControllerHost) {
+ super(host);
+ this.#init = new Promise((resolve) => {
+ this.#initResolver = resolve;
+ });
+ this.#propertyStructure.sortBy((a, b) => a.sortOrder - b.sortOrder);
+ }
+
+ async contentTypes() {
+ await this.#init;
+ if (!this.#structure) return;
+ return this.#structure.contentTypes;
+ }
+
+ public setStructureManager(structure: UmbContentTypeStructureManager) {
+ if (this.#structure === structure) return;
+ if (this.#structure) {
+ throw new Error(
+ 'Structure manager is already set, the helpers are not designed to be re-setup with new managers',
+ );
+ }
+ this.#structure = structure;
+ this.#initResolver?.(undefined);
+ this.#initResolver = undefined;
+ this.#observeContainers();
+ }
+
+ public getStructureManager() {
+ return this.#structure;
+ }
+
+ public setContainerId(value?: string | null) {
+ if (this._containerId === value) return;
+ this._containerId = value;
+ this.#observeContainers();
+ }
+ public getContainerId() {
+ return this._containerId;
+ }
+
+ private _containerName?: string;
+ private _containerType?: UmbPropertyContainerTypes;
+ private _parentName?: string | null;
+ private _parentType?: UmbPropertyContainerTypes;
+
+ #containers?: Array;
+ #observeContainers() {
+ if (!this.#structure || this._containerId === undefined) return;
+
+ if (this._containerId === null) {
+ this.#observePropertyStructureOf(null);
+ this.removeControllerByAlias('_observeContainers');
+ } else {
+ this.observe(
+ this.#structure.containerById(this._containerId),
+ (container) => {
+ if (container) {
+ this._containerName = container.name ?? '';
+ this._containerType = container.type;
+ if (container.parent) {
+ // We have a parent for our main container, so lets observe that one as well:
+ this.observe(
+ this.#structure!.containerById(container.parent.id),
+ (parent) => {
+ if (parent) {
+ this._parentName = parent.name ?? '';
+ this._parentType = parent.type;
+ this.#observeSimilarContainers();
+ } else {
+ this.removeControllerByAlias('_observeContainers');
+ this._parentName = undefined;
+ this._parentType = undefined;
+ }
+ },
+ '_observeMainParentContainer',
+ );
+ } else {
+ this.removeControllerByAlias('_observeMainParentContainer');
+ this._parentName = null; //In this way we want to look for one without a parent. [NL]
+ this._parentType = undefined;
+ this.#observeSimilarContainers();
+ }
+ } else {
+ this.removeControllerByAlias('_observeContainers');
+ this._containerName = undefined;
+ this._containerType = undefined;
+ this.#propertyStructure.setValue([]);
+ }
+ },
+ '_observeMainContainer',
+ );
+ }
+ }
+
+ #observeSimilarContainers() {
+ if (!this._containerName || !this._containerType || this._parentName === undefined) return;
+ this.observe(
+ this.#structure!.containersByNameAndTypeAndParent(
+ this._containerName,
+ this._containerType,
+ this._parentName,
+ this._parentType,
+ ),
+ (groupContainers) => {
+ if (this.#containers) {
+ // We want to remove properties of groups that does not exist anymore: [NL]
+ const goneGroupContainers = this.#containers.filter((x) => !groupContainers.some((y) => y.id === x.id));
+ const _propertyStructure = this.#propertyStructure
+ .getValue()
+ .filter((x) => !goneGroupContainers.some((y) => y.id === x.container?.id));
+ this.#propertyStructure.setValue(_propertyStructure);
+ }
+
+ groupContainers.forEach((group) => this.#observePropertyStructureOf(group.id));
+ this.#containers = groupContainers;
+ },
+ '_observeContainers',
+ );
+ }
+
+ #observePropertyStructureOf(groupId?: string | null) {
+ if (!this.#structure || groupId === undefined) return;
+
+ this.observe(
+ this.#structure.propertyStructuresOf(groupId),
+ (properties) => {
+ // Lets remove the properties that does not exists any longer:
+ const _propertyStructure = this.#propertyStructure
+ .getValue()
+ .filter((x) => !(x.container?.id === groupId && !properties.some((y) => y.id === x.id)));
+
+ // Lets append the properties that does not exists already:
+ properties?.forEach((property) => {
+ if (!_propertyStructure.find((x) => x.alias === property.alias)) {
+ _propertyStructure.push(property);
+ }
+ });
+
+ // Fire update to subscribers:
+ this.#propertyStructure.setValue(_propertyStructure);
+ },
+ '_observePropertyStructureOfGroup' + groupId,
+ );
+ }
+
+ async isOwnerProperty(propertyId: UmbPropertyTypeId) {
+ await this.#init;
+ if (!this.#structure) return;
+
+ return this.#structure.ownerContentTypePart((x) => x?.properties.some((y) => y.id === propertyId));
+ }
+
+ // TODO: consider moving this to another class, to separate 'viewer' from 'manipulator':
+ /** Manipulate methods: */
+
+ async createPropertyScaffold(ownerId?: string) {
+ await this.#init;
+ if (!this.#structure) return;
+
+ return await this.#structure.createPropertyScaffold(ownerId);
+ }
+ /*
+ Only used by legacy implementation:
+ @deprecated
+ */
+ async addProperty(containerId?: string, sortOrder?: number) {
+ await this.#init;
+ if (!this.#structure) return;
+
+ return await this.#structure.createProperty(null, containerId, sortOrder);
+ }
+
+ async insertProperty(property: UmbPropertyTypeModel, sortOrder?: number) {
+ await this.#init;
+ if (!this.#structure) return false;
+
+ const newProperty = { ...property };
+ if (sortOrder) {
+ newProperty.sortOrder = sortOrder;
+ }
+
+ await this.#structure.insertProperty(null, newProperty);
+ return true;
+ }
+
+ async removeProperty(propertyId: UmbPropertyTypeId) {
+ await this.#init;
+ if (!this.#structure) return false;
+
+ await this.#structure.removeProperty(null, propertyId);
+ return true;
+ }
+
+ // Takes optional arguments as this is easier for the implementation in the view:
+ async partialUpdateProperty(propertyKey?: string, partialUpdate?: Partial) {
+ await this.#init;
+ if (!this.#structure || !propertyKey || !partialUpdate) return;
+ return await this.#structure.updateProperty(null, propertyKey, partialUpdate);
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/content-type-structure-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts
similarity index 65%
rename from src/Umbraco.Web.UI.Client/src/packages/core/content-type/content-type-structure-manager.class.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts
index 4bbc6e7abb..d2dba68bcb 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/content-type-structure-manager.class.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/content-type-structure-manager.class.ts
@@ -4,7 +4,7 @@ import type {
UmbPropertyTypeContainerModel,
UmbPropertyTypeModel,
UmbPropertyTypeScaffoldModel,
-} from './types.js';
+} from '../types.js';
import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository';
import { UmbId } from '@umbraco-cms/backoffice/id';
import type { UmbControllerHost, UmbController } from '@umbraco-cms/backoffice/controller-api';
@@ -19,13 +19,21 @@ import {
import { incrementString } from '@umbraco-cms/backoffice/utils';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
-export class UmbContentTypePropertyStructureManager extends UmbControllerBase {
+/**
+ * Manages a structure of a Content Type and its properties and containers.
+ * This loads and merges the structures of the Content Type and its inherited and composed Content Types.
+ * To help manage the data, there is two helper classes:
+ * - {@link UmbContentTypePropertyStructureHelper} for managing the structure of properties, optional of another container or root.
+ * - {@link UmbContentTypeContainerStructureHelper} for managing the structure of containers, optional of another container or root.
+ */
+export class UmbContentTypeStructureManager extends UmbControllerBase {
#init!: Promise;
- #contentTypeRepository: UmbDetailRepository;
+ #repository: UmbDetailRepository;
#ownerContentTypeUnique?: string;
#contentTypeObservers = new Array();
+
#contentTypes = new UmbArrayState([], (x) => x.unique);
readonly contentTypes = this.#contentTypes.asObservable();
readonly ownerContentType = this.#contentTypes.asObservablePart((x) =>
@@ -39,10 +47,13 @@ export class UmbContentTypePropertyStructureManager x.id,
);
+ containerById(id: string) {
+ return this.#containers.asObservablePart((x) => x.find((y) => y.id === id));
+ }
constructor(host: UmbControllerHost, typeRepository: UmbDetailRepository) {
super(host);
- this.#contentTypeRepository = typeRepository;
+ this.#repository = typeRepository;
this.observe(this.contentTypes, (contentTypes) => {
contentTypes.forEach((contentType) => {
@@ -55,7 +66,7 @@ export class UmbContentTypePropertyStructureManager {
+ this._ensureType(composition.contentType.unique);
+ });
+ }
+
private async _ensureType(unique?: string) {
if (!unique) return;
if (this.#contentTypes.getValue().find((x) => x.unique === unique)) return;
@@ -129,11 +146,11 @@ export class UmbContentTypePropertyStructureManager {
if (docType) {
// TODO: Handle if there was changes made to the owner document type in this context. [NL]
@@ -163,12 +180,6 @@ export class UmbContentTypePropertyStructureManager {
- this._ensureType(composition.contentType.unique);
- });
- }
-
/** Public methods for consuming structure: */
ownerContentTypePart(mappingFunction: MappingFunction) {
@@ -183,7 +194,81 @@ export class UmbContentTypePropertyStructureManager
+ */
+ async ensureContainerOf(
+ containerId: string,
+ contentTypeUnique: string,
+ ): Promise {
+ await this.#init;
+ const contentType = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique);
+ if (!contentType) {
+ throw new Error('Could not find the Content Type to ensure containers for');
+ }
+ const containers = contentType?.containers;
+ const container = containers?.find((x) => x.id === containerId);
+ if (!container) {
+ return this.cloneContainerTo(containerId, contentTypeUnique);
+ }
+ return container;
+ }
+
+ /**
+ * Clone a container to a specific Content Type.
+ * @param containerId - The container to clone, assuming it does not already exist on the given Content Type.
+ * @param toContentTypeUnique - The content type to clone to.
+ * @returns Promise
+ */
+ async cloneContainerTo(
+ containerId: string,
+ toContentTypeUnique?: string,
+ ): Promise {
+ await this.#init;
+ toContentTypeUnique = toContentTypeUnique ?? this.#ownerContentTypeUnique!;
+
+ // Find container.
+ const container = this.#containers.getValue().find((x) => x.id === containerId);
+ if (!container) throw new Error('Container to clone was not found');
+
+ const clonedContainer: UmbPropertyTypeContainerModel = {
+ ...container,
+ id: UmbId.new(),
+ };
+ if (container.parent) {
+ // Investigate parent container. (See if we have one that matches if not, then clone it.)
+ const parentContainer = await this.ensureContainerOf(container.parent.id, toContentTypeUnique);
+ if (!parentContainer) {
+ throw new Error('Parent container for cloning could not be found or created');
+ }
+ // Clone container.
+ clonedContainer.parent = { id: parentContainer.id };
+ }
+ // Spread containers, so we can append to it, and then update the specific content-type with the new set of containers: [NL]
+ // Correction the spread is removed now, cause we do a filter: [NL]
+ // And then we remove the existing one, to have the more local one replacing it. [NL]
+ const containers = [
+ ...(this.#contentTypes.getValue().find((x) => x.unique === toContentTypeUnique)?.containers ?? []),
+ ];
+ //.filter((x) => x.name !== clonedContainer.name && x.type === clonedContainer.type);
+ containers.push(clonedContainer);
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // TODO: fix TS partial complaint [NL]
+ this.#contentTypes.updateOne(toContentTypeUnique, { containers });
+
+ return clonedContainer;
+ }
async createContainer(
contentTypeUnique: string | null,
@@ -219,12 +304,20 @@ export class UmbContentTypePropertyStructureManager x.unique === contentTypeUnique)?.containers ?? [];
const containers = appendToFrozenArray(frozenContainers, container, (x) => x.id === container.id);
- console.log(frozenContainers, containers);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// TODO: fix TS partial complaint
@@ -236,7 +329,7 @@ export class UmbContentTypePropertyStructureManager x.unique === contentTypeUnique)?.containers ?? [];
+ const ownerContainer = frozenContainers.find((x) => x.id === containerId);
+ if (!ownerContainer) {
+ console.error(
+ 'We do not have this container on the requested id, we should clone the container and append the change to it. [NL]',
+ );
+ }
+
const containers = partialUpdateFrozenArray(frozenContainers, partialUpdate, (x) => x.id === containerId);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -282,7 +394,7 @@ export class UmbContentTypePropertyStructureManager = [
...(this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.properties ?? []),
@@ -330,6 +453,16 @@ export class UmbContentTypePropertyStructureManager x.unique === contentTypeUnique)?.properties ?? [];
@@ -474,13 +607,18 @@ export class UmbContentTypePropertyStructureManager x.containers?.filter((x) => x.type === containerType) ?? []);
+ ownerContainersOf(containerType: UmbPropertyContainerTypes, parentId: string | null) {
+ return this.ownerContentTypeObservablePart(
+ (x) =>
+ x.containers?.filter(
+ (x) => (parentId ? x.parent?.id === parentId : x.parent === null) && x.type === containerType,
+ ) ?? [],
+ );
}
- getOwnerContainers(containerType: UmbPropertyContainerTypes, parentId: string | null = null) {
- return this.getOwnerContentType()?.containers?.filter((x) =>
- parentId ? x.parent?.id === parentId : x.parent === null && x.type === containerType,
+ getOwnerContainers(containerType: UmbPropertyContainerTypes, parentId: string | null) {
+ return this.getOwnerContentType()?.containers?.filter(
+ (x) => (parentId ? x.parent?.id === parentId : x.parent === null) && x.type === containerType,
);
}
@@ -488,7 +626,7 @@ export class UmbContentTypePropertyStructureManager x.id === containerId);
}
- containersOfParentKey(parentId: string, containerType: UmbPropertyContainerTypes) {
+ containersOfParentId(parentId: string, containerType: UmbPropertyContainerTypes) {
return this.#containers.asObservablePart((data) => {
return data.filter((x) => x.parent?.id === parentId && x.type === containerType);
});
@@ -501,6 +639,30 @@ export class UmbContentTypePropertyStructureManager {
+ return data.filter(
+ (x) =>
+ // Match name and type:
+ x.name === name &&
+ x.type === containerType &&
+ // If we look for a parent name, then we need to match that as well:
+ (parentName
+ ? // And we have a parent on this container, then we need to match the parent name and type as well
+ x.parent
+ ? data.some((y) => x.parent!.id === y.id && y.name === parentName && y.type === parentType)
+ : false
+ : // if we do not have a parent then its not a match
+ x.parent === null), // it parentName === null then we expect the container parent to be null.
+ );
+ });
+ }
+
private _reset() {
this.#contentTypeObservers.forEach((observer) => observer.destroy());
this.#contentTypeObservers = [];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/index.ts
index d1a5aa8c6b..c587218a3d 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/structure/index.ts
@@ -1,4 +1,3 @@
-export * from './content-type-structure-repository-base.js';
-export * from './content-type-structure-repository.interface.js';
-export * from './content-type-structure-server-data-source-base.js';
-export * from './content-type-structure-data-source.interface.js';
+export * from './content-type-container-structure-helper.class.js';
+export * from './content-type-property-structure-helper.class.js';
+export * from './content-type-structure-manager.class.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/types.ts
index 9ef9820ce7..22f3f1c9f0 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/types.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/types.ts
@@ -14,7 +14,7 @@ export interface UmbContentTypeModel {
unique: string;
name: string;
alias: string;
- description: string | null;
+ description: string;
icon: string;
allowedAtRoot: boolean;
variesByCulture: boolean;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context.interface.ts
new file mode 100644
index 0000000000..743abd5e8e
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context.interface.ts
@@ -0,0 +1,27 @@
+import type { UmbContentTypeCompositionModel, UmbContentTypeModel, UmbContentTypeSortModel } from '../types.js';
+import type { UmbContentTypeStructureManager } from '../structure/index.js';
+import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
+import type { UmbSaveableWorkspaceContextInterface } from '@umbraco-cms/backoffice/workspace';
+
+export interface UmbContentTypeWorkspaceContext
+ extends UmbSaveableWorkspaceContextInterface {
+ readonly IS_CONTENT_TYPE_WORKSPACE_CONTEXT: true;
+
+ readonly name: Observable;
+ readonly alias: Observable;
+ readonly description: Observable;
+ readonly icon: Observable;
+
+ readonly allowedAtRoot: Observable;
+ readonly variesByCulture: Observable;
+ readonly variesBySegment: Observable;
+ //readonly isElement: Observable;
+ readonly allowedContentTypes: Observable;
+ readonly compositions: Observable;
+
+ readonly structure: UmbContentTypeStructureManager;
+
+ setAlias(alias: string): void;
+
+ setCompositions(compositions: Array): void;
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace.context-token.ts
new file mode 100644
index 0000000000..ce5f5a9ebe
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace.context-token.ts
@@ -0,0 +1,11 @@
+import type { UmbContentTypeWorkspaceContext } from './content-type-workspace-context.interface.js';
+import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
+
+export const UMB_CONTENT_TYPE_WORKSPACE_CONTEXT = new UmbContextToken<
+ UmbContentTypeWorkspaceContext,
+ UmbContentTypeWorkspaceContext
+>(
+ 'UmbWorkspaceContext',
+ undefined,
+ (context): context is UmbContentTypeWorkspaceContext => (context as any).IS_CONTENT_TYPE_WORKSPACE_CONTEXT,
+);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/index.ts
new file mode 100644
index 0000000000..1a9af36bce
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/index.ts
@@ -0,0 +1,2 @@
+export type * from './content-type-workspace-context.interface.js';
+export * from './content-type-workspace.context-token.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/manifests.ts
new file mode 100644
index 0000000000..5ae405e6ca
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/manifests.ts
@@ -0,0 +1,3 @@
+import { contentTypeDesignEditorManifest } from './views/design/manifest.js';
+
+export const manifests = [contentTypeDesignEditorManifest];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-group.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-group.element.ts
new file mode 100644
index 0000000000..32d46e9fa4
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-group.element.ts
@@ -0,0 +1,172 @@
+import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
+import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
+import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
+import type {
+ UmbContentTypeContainerStructureHelper,
+ UmbContentTypeModel,
+ UmbPropertyTypeContainerModel,
+} from '@umbraco-cms/backoffice/content-type';
+
+import './content-type-design-editor-properties.element.js';
+
+@customElement('umb-content-type-design-editor-group')
+export class UmbContentTypeWorkspaceViewEditGroupElement extends UmbLitElement {
+ @property({ attribute: false })
+ public set group(value: UmbPropertyTypeContainerModel | undefined) {
+ if (value === this._group) return;
+ this._group = value;
+ this._groupId = value?.id;
+ this.#checkInherited();
+ }
+ public get group(): UmbPropertyTypeContainerModel | undefined {
+ return this._group;
+ }
+ private _group?: UmbPropertyTypeContainerModel | undefined;
+
+ @property({ attribute: false })
+ public set groupStructureHelper(value: UmbContentTypeContainerStructureHelper | undefined) {
+ if (value === this._groupStructureHelper) return;
+ this._groupStructureHelper = value;
+ this.#checkInherited();
+ }
+ public get groupStructureHelper(): UmbContentTypeContainerStructureHelper | undefined {
+ return this._groupStructureHelper;
+ }
+ private _groupStructureHelper?: UmbContentTypeContainerStructureHelper | undefined;
+
+ @property({ type: Boolean, attribute: 'sort-mode-active', reflect: true })
+ sortModeActive = false;
+
+ @state()
+ _groupId?: string;
+
+ @state()
+ _hasOwnerContainer?: boolean;
+
+ @state()
+ _inherited?: boolean;
+
+ #checkInherited() {
+ if (this.groupStructureHelper && this.group) {
+ // Check is this container matches with any other group. If so it is inherited aka. merged with others. [NL]
+ if (this.group.name) {
+ // We can first match with something if we have a name [NL]
+ this.observe(
+ this.groupStructureHelper.containersByNameAndType(this.group.name, 'Group'),
+ (containers) => {
+ const hasAOwnerContainer = containers.some((con) =>
+ this.groupStructureHelper!.isOwnerChildContainer(con.id),
+ );
+ const pureOwnerContainer = hasAOwnerContainer && containers.length === 1;
+
+ this._hasOwnerContainer = hasAOwnerContainer;
+ this._inherited = !pureOwnerContainer;
+ },
+ 'observeGroupContainers',
+ );
+ } else {
+ // We use name match to determine inheritance, so no name cannot inherit.
+ this._inherited = false;
+ this._hasOwnerContainer = true;
+ this.removeControllerByAlias('observeGroupContainers');
+ }
+ }
+ }
+
+ _singleValueUpdate(propertyName: string, value: string | number | boolean | null | undefined) {
+ if (!this._groupStructureHelper || !this.group) return;
+
+ const partialObject = {} as any;
+ partialObject[propertyName] = value;
+
+ this._groupStructureHelper.partialUpdateContainer(this.group.id, partialObject);
+ }
+
+ #renameGroup(e: InputEvent) {
+ if (!this.groupStructureHelper || !this._group) return;
+ let newName = (e.target as HTMLInputElement).value;
+ const changedName = this.groupStructureHelper
+ .getStructureManager()!
+ .makeContainerNameUniqueForOwnerContentType(newName, 'Group', this._group.parent?.id ?? null);
+ if (changedName) {
+ newName = changedName;
+ }
+ this._singleValueUpdate('name', newName);
+ }
+
+ render() {
+ return this._inherited !== undefined && this._groupId
+ ? html`
+
+ ${this.#renderContainerHeader()}
+
+
+ `
+ : '';
+ }
+
+ #renderContainerHeader() {
+ return html`
+
+
+
+
+ ${this.sortModeActive
+ ? html`
+ this._singleValueUpdate('sortOrder', parseInt(e.target.value as string) || 0)}
+ .value=${this.group!.sortOrder ?? 0}
+ ?disabled=${!this._hasOwnerContainer}>`
+ : ''}
+
`;
+ }
+
+ static styles = [
+ css`
+ :host([drag-placeholder]) {
+ opacity: 0.5;
+ }
+
+ :host([drag-placeholder]) > * {
+ visibility: hidden;
+ }
+
+ div[slot='header'] {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ div[slot='header'] > div {
+ display: flex;
+ align-items: center;
+ gap: var(--uui-size-3);
+ }
+
+ uui-input[type='number'] {
+ max-width: 75px;
+ }
+
+ :host([sort-mode-active]) div[slot='header'] {
+ cursor: grab;
+ }
+ `,
+ ];
+}
+
+export default UmbContentTypeWorkspaceViewEditGroupElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-content-type-design-editor-group': UmbContentTypeWorkspaceViewEditGroupElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-properties.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-properties.element.ts
new file mode 100644
index 0000000000..ec0fb04c71
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-properties.element.ts
@@ -0,0 +1,258 @@
+import './content-type-design-editor-property.element.js';
+import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '../../content-type-workspace.context-token.js';
+import type { UmbContentTypeDesignEditorPropertyElement } from './content-type-design-editor-property.element.js';
+import { UMB_CONTENT_TYPE_DESIGN_EDITOR_CONTEXT } from './content-type-design-editor.context.js';
+import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
+import { css, html, customElement, property, state, repeat, ifDefined } from '@umbraco-cms/backoffice/external/lit';
+import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
+import type { UmbContentTypeModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
+import {
+ UmbContentTypePropertyStructureHelper,
+ UMB_PROPERTY_TYPE_SETTINGS_MODAL,
+} from '@umbraco-cms/backoffice/content-type';
+import { type UmbSorterConfig, UmbSorterController } from '@umbraco-cms/backoffice/sorter';
+import {
+ type UmbModalRouteBuilder,
+ UmbModalRouteRegistrationController,
+ UMB_WORKSPACE_MODAL,
+} from '@umbraco-cms/backoffice/modal';
+
+const SORTER_CONFIG: UmbSorterConfig = {
+ getUniqueOfElement: (element) => {
+ return element.getAttribute('data-umb-property-id');
+ },
+ getUniqueOfModel: (modelEntry) => {
+ return modelEntry.id;
+ },
+ identifier: 'content-type-property-sorter',
+ itemSelector: 'umb-content-type-design-editor-property',
+ //disabledItemSelector: '[inherited]',
+ //TODO: Set the property list (sorter wrapper) to inherited, if its inherited
+ // This is because we don't want to move local properties into an inherited group container.
+ // Or maybe we do, but we still need to check if the group exists locally, if not, then it needs to be created before we move a property into it.
+ // TODO: Fix bug where a local property turn into an inherited when moved to a new group container.
+ containerSelector: '#property-list',
+};
+
+@customElement('umb-content-type-design-editor-properties')
+export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
+ #sorter = new UmbSorterController(this, {
+ ...SORTER_CONFIG,
+ onChange: ({ model }) => {
+ this._propertyStructure = model;
+ },
+ onEnd: ({ item }) => {
+ if (this._containerId === undefined) {
+ throw new Error('ContainerId is not set, we have not made a local duplicated of this container.');
+ return;
+ }
+ /** Explanation: If the item is the first in list, we compare it to the item behind it to set a sortOrder.
+ * If it's not the first in list, we will compare to the item in before it, and check the following item to see if it caused overlapping sortOrder, then update
+ * the overlap if true, which may cause another overlap, so we loop through them till no more overlaps...
+ */
+ const model = this._propertyStructure;
+ const newIndex = model.findIndex((entry) => entry.id === item.id);
+
+ // Doesn't exist in model
+ if (newIndex === -1) return;
+
+ // As origin we set prev sort order to -1, so if no other then our item will become 0
+ let prevSortOrder = -1;
+
+ // Not first in list
+ if (newIndex > 0 && model.length > 0) {
+ prevSortOrder = model[newIndex - 1].sortOrder;
+ }
+
+ // increase the prevSortOrder and use it for the moved item,
+ this.#propertyStructureHelper.partialUpdateProperty(item.id, {
+ sortOrder: ++prevSortOrder,
+ });
+
+ // Adjust everyone right after, meaning until there is a gap between the sortOrders:
+ let i = newIndex + 1;
+ let entry: UmbPropertyTypeModel | undefined;
+ // As long as there is an item with the index & the sortOrder is less or equal to the prevSortOrder, we will update the sortOrder:
+ while ((entry = model[i]) !== undefined && entry.sortOrder <= prevSortOrder) {
+ // Increase the prevSortOrder and use it for the item:
+ this.#propertyStructureHelper.partialUpdateProperty(entry.id, {
+ sortOrder: ++prevSortOrder,
+ });
+
+ i++;
+ }
+ },
+ });
+
+ private _containerId: string | null | undefined;
+
+ @property({ type: String, attribute: 'container-id', reflect: false })
+ public get containerId(): string | null | undefined {
+ return this._containerId;
+ }
+ public set containerId(value: string | null | undefined) {
+ if (value === this._containerId) return;
+ const oldValue = this._containerId;
+ this._containerId = value;
+ this.#propertyStructureHelper.setContainerId(value);
+ this.#addPropertyModal.setUniquePathValue('container-id', value === null ? 'root' : value);
+ this.requestUpdate('containerId', oldValue);
+ }
+
+ #addPropertyModal: UmbModalRouteRegistrationController;
+ #workspaceModal?: UmbModalRouteRegistrationController;
+
+ #propertyStructureHelper = new UmbContentTypePropertyStructureHelper(this);
+
+ @state()
+ private _propertyStructure: Array = [];
+
+ @state()
+ private _ownerContentType?: UmbContentTypeModel;
+
+ @state()
+ private _modalRouteBuilderNewProperty?: UmbModalRouteBuilder;
+
+ @state()
+ private _editContentTypePath?: string;
+
+ @state()
+ private _sortModeActive?: boolean;
+
+ constructor() {
+ super();
+
+ this.#sorter.disable();
+
+ this.consumeContext(UMB_CONTENT_TYPE_DESIGN_EDITOR_CONTEXT, (context) => {
+ this.observe(
+ context.isSorting,
+ (isSorting) => {
+ this._sortModeActive = isSorting;
+ if (isSorting) {
+ this.#sorter.enable();
+ } else {
+ this.#sorter.disable();
+ }
+ },
+ '_observeIsSorting',
+ );
+ });
+
+ this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, async (workspaceContext) => {
+ this.#propertyStructureHelper.setStructureManager(workspaceContext.structure);
+
+ const entityType = workspaceContext.getEntityType();
+
+ this.#workspaceModal?.destroy();
+ this.#workspaceModal = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
+ .addAdditionalPath(entityType)
+ .onSetup(async () => {
+ return { data: { entityType: entityType, preset: {} } };
+ })
+ .observeRouteBuilder((routeBuilder) => {
+ this._editContentTypePath = routeBuilder({});
+ });
+
+ this.observe(
+ workspaceContext.structure.ownerContentType,
+ (contentType) => {
+ this._ownerContentType = contentType;
+ },
+ 'observeOwnerContentType',
+ );
+ });
+ this.observe(this.#propertyStructureHelper.propertyStructure, (propertyStructure) => {
+ this._propertyStructure = propertyStructure;
+ this.#sorter.setModel(this._propertyStructure);
+ });
+
+ // Note: Route for adding a new property
+ this.#addPropertyModal = new UmbModalRouteRegistrationController(this, UMB_PROPERTY_TYPE_SETTINGS_MODAL)
+ .addUniquePaths(['container-id'])
+ .addAdditionalPath('add-property/:sortOrder')
+ .onSetup(async (params) => {
+ if (!this._ownerContentType || !this._containerId) return false;
+
+ const propertyData = await this.#propertyStructureHelper.createPropertyScaffold(this._containerId);
+ if (propertyData === undefined) return false;
+ if (params.sortOrder !== undefined) {
+ let sortOrderInt = parseInt(params.sortOrder, 10);
+ if (sortOrderInt === -1) {
+ // Find the highest sortOrder and add 1 to it:
+ sortOrderInt = Math.max(...this._propertyStructure.map((x) => x.sortOrder), -1);
+ }
+ propertyData.sortOrder = sortOrderInt + 1;
+ }
+ return { data: { contentTypeId: this._ownerContentType.unique }, value: propertyData };
+ })
+ .onSubmit(async (value) => {
+ if (!this._ownerContentType) return false;
+ // TODO: The model requires a data-type to be set, we cheat currently. But this should be re-though when we implement validation(As we most likely will have to com up with partial models for the runtime model.) [NL]
+ this.#propertyStructureHelper.insertProperty(value as UmbPropertyTypeModel);
+ return true;
+ })
+ .observeRouteBuilder((routeBuilder) => {
+ this._modalRouteBuilderNewProperty = routeBuilder;
+ });
+ }
+
+ render() {
+ return this._ownerContentType
+ ? html`
+
+ ${repeat(
+ this._propertyStructure,
+ (property) => property.id,
+ (property) => {
+ return html`
+
+
+ `;
+ },
+ )}
+
+
+ ${!this._sortModeActive
+ ? html`
+ Add property
+ `
+ : ''}
+ `
+ : '';
+ }
+
+ static styles = [
+ UmbTextStyles,
+ css`
+ #add {
+ width: 100%;
+ }
+
+ #property-list[sort-mode-active]:not(:has(umb-content-type-design-editor-property)) {
+ /* Some height so that the sorter can target the area if the group is empty*/
+ min-height: var(--uui-size-layout-1);
+ }
+ `,
+ ];
+}
+
+export default UmbContentTypeDesignEditorPropertiesElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-content-type-design-editor-properties': UmbContentTypeDesignEditorPropertiesElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts
similarity index 75%
rename from src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts
index 975e0a9137..6c0167ed66 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts
@@ -2,25 +2,41 @@ import { UmbDataTypeDetailRepository } from '@umbraco-cms/backoffice/data-type';
import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui';
import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
import { css, html, customElement, property, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit';
-import {
- UMB_PROPERTY_SETTINGS_MODAL,
- UMB_WORKSPACE_MODAL,
- UmbModalRouteRegistrationController,
- umbConfirmModal,
-} from '@umbraco-cms/backoffice/modal';
+import { UmbModalRouteRegistrationController, umbConfirmModal } from '@umbraco-cms/backoffice/modal';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { generateAlias } from '@umbraco-cms/backoffice/utils';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
-import type { UmbPropertyTypeModel, UmbPropertyTypeScaffoldModel } from '@umbraco-cms/backoffice/content-type';
+import {
+ UMB_PROPERTY_TYPE_SETTINGS_MODAL,
+ type UmbContentTypeModel,
+ type UmbContentTypePropertyStructureHelper,
+ type UmbPropertyTypeModel,
+ type UmbPropertyTypeScaffoldModel,
+} from '@umbraco-cms/backoffice/content-type';
/**
- * @element umb-document-type-workspace-view-edit-property
+ * @element umb-content-type-design-editor-property
* @description - Element for displaying a property in an workspace.
* @slot editor - Slot for rendering the Property Editor
*/
-@customElement('umb-document-type-workspace-view-edit-property')
-export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement {
- private _property?: UmbPropertyTypeModel | UmbPropertyTypeScaffoldModel | undefined;
+@customElement('umb-content-type-design-editor-property')
+export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement {
+ //
+ #dataTypeDetailRepository = new UmbDataTypeDetailRepository(this);
+
+ #settingsModal;
+
+ @property({ attribute: false })
+ public set propertyStructureHelper(value: UmbContentTypePropertyStructureHelper | undefined) {
+ if (value === this._propertyStructureHelper) return;
+ this._propertyStructureHelper = value;
+ this.#checkInherited();
+ }
+ public get propertyStructureHelper(): UmbContentTypePropertyStructureHelper | undefined {
+ return this._propertyStructureHelper;
+ }
+ private _propertyStructureHelper?: UmbContentTypePropertyStructureHelper | undefined;
+
/**
* Property, the data object for the property.
* @type {UmbPropertyTypeModel | UmbPropertyTypeScaffoldModel | undefined}
@@ -34,14 +50,16 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement {
public set property(value: UmbPropertyTypeModel | UmbPropertyTypeScaffoldModel | undefined) {
const oldValue = this._property;
this._property = value;
- this.#modalRegistration.setUniquePathValue('propertyId', value?.id?.toString());
+ this.#checkInherited();
+ this.#settingsModal.setUniquePathValue('propertyId', value?.id);
this.setDataType(this._property?.dataType?.unique);
this.requestUpdate('property', oldValue);
}
+ private _property?: UmbPropertyTypeModel | UmbPropertyTypeScaffoldModel | undefined;
/**
- * Inherited, Determines if the property is part of the main document type thats being edited.
- * If true, then the property is inherited from another document type, not a part of the main document type.
+ * Inherited, Determines if the property is part of the main content type thats being edited.
+ * If true, then the property is inherited from another content type, not a part of the main content type.
* @type {boolean}
* @attr
* @default undefined
@@ -52,45 +70,36 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement {
@property({ type: Boolean, reflect: true, attribute: 'sort-mode-active' })
public sortModeActive = false;
- #dataTypeDetailRepository = new UmbDataTypeDetailRepository(this);
+ @property({ type: String, attribute: 'owner-content-type-id' })
+ public ownerContentTypeId?: string;
- #modalRegistration;
+ @property({ type: String, attribute: 'owner-content-type-name' })
+ public ownerContentTypeName?: string;
+
+ @property({ type: String, attribute: 'edit-content-type-path' })
+ public editContentTypePath?: string;
@state()
protected _modalRoute?: string;
- @state()
- protected _editDocumentTypePath?: string;
-
- @property()
- public get modalRoute() {
- return this._modalRoute;
- }
-
- @property({ type: String, attribute: 'owner-document-type-id' })
- public ownerDocumentTypeId?: string;
-
- @property({ type: String, attribute: 'owner-document-type-name' })
- public ownerDocumentTypeName?: string;
-
@state()
private _dataTypeName?: string;
- async setDataType(dataTypeId: string | undefined) {
- if (!dataTypeId) return;
- this.#dataTypeDetailRepository.requestByUnique(dataTypeId).then((x) => (this._dataTypeName = x?.data?.name));
- }
+ @state()
+ private _aliasLocked = true;
constructor() {
super();
- this.#modalRegistration = new UmbModalRouteRegistrationController(this, UMB_PROPERTY_SETTINGS_MODAL)
+
+ // TODO: consider if this can be registered more globally/contextually. [NL]
+ this.#settingsModal = new UmbModalRouteRegistrationController(this, UMB_PROPERTY_TYPE_SETTINGS_MODAL)
.addUniquePaths(['propertyId'])
.onSetup(() => {
- const documentTypeId = this.ownerDocumentTypeId;
- if (documentTypeId === undefined) return false;
+ const id = this.ownerContentTypeId;
+ if (id === undefined) return false;
const propertyData = this.property;
if (propertyData === undefined) return false;
- return { data: { documentTypeId }, value: propertyData };
+ return { data: { contentTypeId: id }, value: propertyData };
})
.onSubmit((result) => {
this._partialUpdate(result as UmbPropertyTypeModel);
@@ -98,53 +107,64 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement {
.observeRouteBuilder((routeBuilder) => {
this._modalRoute = routeBuilder(null);
});
+ }
- new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
- .addAdditionalPath('document-type')
- .onSetup(() => {
- return { data: { entityType: 'document-type', preset: {} } };
- })
- .observeRouteBuilder((routeBuilder) => {
- this._editDocumentTypePath = routeBuilder({});
- });
+ async #checkInherited() {
+ if (this._propertyStructureHelper && this._property) {
+ // We can first match with something if we have a name [NL]
+ this.observe(
+ await this._propertyStructureHelper!.isOwnerProperty(this._property.id),
+ (isOwned) => {
+ this.inherited = !isOwned;
+ },
+ 'observeIsOwnerProperty',
+ );
+ }
}
_partialUpdate(partialObject: UmbPropertyTypeModel) {
- this.dispatchEvent(new CustomEvent('partial-property-update', { detail: partialObject }));
+ if (!this._property || !this._propertyStructureHelper) return;
+ this._propertyStructureHelper.partialUpdateProperty(this._property.id, partialObject);
}
- _singleValueUpdate(propertyName: string, value: string | number | boolean | null | undefined) {
- const partialObject = {} as any;
- partialObject[propertyName] = value;
-
- this.dispatchEvent(new CustomEvent('partial-property-update', { detail: partialObject }));
+ _singleValueUpdate(
+ propertyName: PropertyNameType,
+ value: UmbPropertyTypeModel[PropertyNameType],
+ ) {
+ if (!this._property || !this._propertyStructureHelper) return;
+ const partialObject: Partial = {};
+ partialObject[propertyName] = value === null ? undefined : value;
+ this._propertyStructureHelper.partialUpdateProperty(this._property.id, partialObject);
}
- @state()
- private _aliasLocked = true;
-
#onToggleAliasLock() {
this._aliasLocked = !this._aliasLocked;
}
+ async setDataType(dataTypeId: string | undefined) {
+ if (!dataTypeId) return;
+ this.#dataTypeDetailRepository.requestByUnique(dataTypeId).then((x) => (this._dataTypeName = x?.data?.name));
+ }
+
async #requestRemove(e: Event) {
e.preventDefault();
e.stopImmediatePropagation();
- if (!this.property || !this.property.id) return;
+ if (!this._property || !this._property.id) return;
+ // TODO: Do proper localization here: [NL]
await umbConfirmModal(this, {
headline: `${this.localize.term('actions_delete')} property`,
content: html`
- Are you sure you want to delete the property ${this.property.name || this.property.id}
+ Are you sure you want to delete the property ${this._property.name ?? this._property.id}
`,
confirmLabel: this.localize.term('actions_delete'),
color: 'danger',
});
- this.dispatchEvent(new CustomEvent('property-delete'));
+ this._propertyStructureHelper?.removeProperty(this._property.id);
}
#onNameChange(event: UUIInputEvent) {
@@ -166,21 +186,38 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement {
}
}
}
- renderSortableProperty() {
+
+ render() {
+ // TODO: Only show alias on label if user has access to DocumentType within settings: [NL]
+ return this.inherited ? this.renderInheritedProperty() : this.renderEditableProperty();
+ }
+
+ renderInheritedProperty() {
if (!this.property) return;
- return html`
-