diff --git a/src/Umbraco.Cms.Api.Management/Factories/ContentTypeEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ContentTypeEditingPresentationFactory.cs index f979c17649ff..2b78847dc369 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/ContentTypeEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/ContentTypeEditingPresentationFactory.cs @@ -1,4 +1,5 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; using ContentTypeEditingModels = Umbraco.Cms.Core.Models.ContentTypeEditing; @@ -38,12 +39,27 @@ protected TContentTypeEditingModel MapContentTypeEditingModel< VariesByCulture = viewModel.VariesByCulture, VariesBySegment = viewModel.VariesBySegment, Containers = MapContainers(viewModel.Containers), - Properties = MapProperties(viewModel.Properties) + Properties = MapProperties(viewModel.Properties), }; return editingModel; } + protected Guid? CalculateCreateContainerKey(ReferenceByIdModel? parent, IDictionary compositions) + { + // special case: + // the API is somewhat confusing when it comes to inheritance. the parent denotes a container (folder), but it + // is easily confused with the parent for inheritance. + // if the request model contains the same key for container and "inheritance composition", we'll be lenient and + // allow it - just remove the container, inheritance takes precedence as intent. + Guid? parentId = parent?.Id; + return parentId.HasValue + && compositions.TryGetValue(parentId.Value, out ContentTypeViewModels.CompositionType compositionType) + && compositionType is ContentTypeViewModels.CompositionType.Inheritance + ? null + : parentId; + } + protected T MapCompositionModel(ContentTypeAvailableCompositionsResult compositionResult) where T : ContentTypeViewModels.AvailableContentTypeCompositionResponseModelBase, new() { diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentTypeEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentTypeEditingPresentationFactory.cs index d0fbc9317276..27b4ef8b4510 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentTypeEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentTypeEditingPresentationFactory.cs @@ -25,12 +25,14 @@ public ContentTypeCreateModel MapCreateModel(CreateDocumentTypeRequestModel requ MapCleanup(createModel, requestModel.Cleanup); createModel.Key = requestModel.Id; - createModel.ContainerKey = requestModel.Parent?.Id; createModel.AllowedTemplateKeys = requestModel.AllowedTemplates.Select(reference => reference.Id).ToArray(); createModel.DefaultTemplateKey = requestModel.DefaultTemplate?.Id; createModel.ListView = requestModel.Collection?.Id; createModel.AllowedContentTypes = MapAllowedContentTypes(requestModel.AllowedDocumentTypes); - createModel.Compositions = MapCompositions(requestModel.Compositions); + + IDictionary compositionTypesByKey = CompositionTypesByKey(requestModel.Compositions); + createModel.Compositions = MapCompositions(compositionTypesByKey); + createModel.ContainerKey = CalculateCreateContainerKey(requestModel.Parent, compositionTypesByKey); return createModel; } @@ -51,7 +53,7 @@ public ContentTypeUpdateModel MapUpdateModel(UpdateDocumentTypeRequestModel requ updateModel.DefaultTemplateKey = requestModel.DefaultTemplate?.Id; updateModel.ListView = requestModel.Collection?.Id; updateModel.AllowedContentTypes = MapAllowedContentTypes(requestModel.AllowedDocumentTypes); - updateModel.Compositions = MapCompositions(requestModel.Compositions); + updateModel.Compositions = MapCompositions(CompositionTypesByKey(requestModel.Compositions)); return updateModel; } @@ -72,8 +74,8 @@ private IEnumerable MapAllowedContentTypes(IEnumerable t.DocumentType.Id) .ToDictionary(t => t.DocumentType.Id, t => t.SortOrder)); - private IEnumerable MapCompositions(IEnumerable documentTypeCompositions) - => MapCompositions(documentTypeCompositions + private IDictionary CompositionTypesByKey(IEnumerable documentTypeCompositions) + => documentTypeCompositions .DistinctBy(c => c.DocumentType.Id) - .ToDictionary(c => c.DocumentType.Id, c => c.CompositionType)); + .ToDictionary(c => c.DocumentType.Id, c => c.CompositionType); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/MediaTypeEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/MediaTypeEditingPresentationFactory.cs index c5cd70ed4995..db08e47fd60b 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/MediaTypeEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/MediaTypeEditingPresentationFactory.cs @@ -23,11 +23,13 @@ public MediaTypeCreateModel MapCreateModel(CreateMediaTypeRequestModel requestMo >(requestModel); createModel.Key = requestModel.Id; - createModel.ContainerKey = requestModel.Parent?.Id; createModel.AllowedContentTypes = MapAllowedContentTypes(requestModel.AllowedMediaTypes); - createModel.Compositions = MapCompositions(requestModel.Compositions); createModel.ListView = requestModel.Collection?.Id; + IDictionary compositionTypesByKey = CompositionTypesByKey(requestModel.Compositions); + createModel.Compositions = MapCompositions(compositionTypesByKey); + createModel.ContainerKey = CalculateCreateContainerKey(requestModel.Parent, compositionTypesByKey); + return createModel; } @@ -42,7 +44,8 @@ public MediaTypeUpdateModel MapUpdateModel(UpdateMediaTypeRequestModel requestMo >(requestModel); updateModel.AllowedContentTypes = MapAllowedContentTypes(requestModel.AllowedMediaTypes); - updateModel.Compositions = MapCompositions(requestModel.Compositions); + updateModel.Compositions = MapCompositions(CompositionTypesByKey(requestModel.Compositions)); + updateModel.ListView = requestModel.Collection?.Id; return updateModel; @@ -56,8 +59,8 @@ private IEnumerable MapAllowedContentTypes(IEnumerable t.MediaType.Id) .ToDictionary(t => t.MediaType.Id, t => t.SortOrder)); - private IEnumerable MapCompositions(IEnumerable documentTypeCompositions) - => MapCompositions(documentTypeCompositions + private IDictionary CompositionTypesByKey(IEnumerable documentTypeCompositions) + => documentTypeCompositions .DistinctBy(c => c.MediaType.Id) - .ToDictionary(c => c.MediaType.Id, c => c.CompositionType)); + .ToDictionary(c => c.MediaType.Id, c => c.CompositionType); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/ContentType/ContentTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/ContentType/ContentTypeMapDefinition.cs index 7cc15e2a7645..acfd8e6c5e91 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/ContentType/ContentTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/ContentType/ContentTypeMapDefinition.cs @@ -83,24 +83,9 @@ protected static CompositionType CalculateCompositionType(int contentTypeParentI ? CompositionType.Inheritance : CompositionType.Composition; - protected static IEnumerable MapNestedCompositions(IEnumerable directCompositions, int contentTypeParentId, Func contentTypeCompositionFactory) - { - var allCompositions = new List(); - - foreach (var composition in directCompositions) - { - CompositionType compositionType = CalculateCompositionType(contentTypeParentId, composition); - T contentTypeComposition = contentTypeCompositionFactory(new ReferenceByIdModel(composition.Key), compositionType); - allCompositions.Add(contentTypeComposition); - - // When we have composition inheritance, we have to find all ancestor compositions recursively - if (compositionType == CompositionType.Inheritance && composition.ContentTypeComposition.Any()) - { - var nestedCompositions = MapNestedCompositions(composition.ContentTypeComposition, composition.ParentId, contentTypeCompositionFactory); - allCompositions.AddRange(nestedCompositions); - } - } - - return allCompositions; - } + protected static IEnumerable MapCompositions(IEnumerable directCompositions, int contentTypeParentId, Func contentTypeCompositionFactory) + => directCompositions + .Select(composition => contentTypeCompositionFactory( + new ReferenceByIdModel(composition.Key), + CalculateCompositionType(contentTypeParentId, composition))).ToArray(); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs index 958dff64e424..0e9aeba492f5 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs @@ -41,7 +41,7 @@ private void Map(IContentType source, DocumentTypeResponseModel target, MapperCo new DocumentTypeSort { DocumentType = new ReferenceByIdModel(ct.Key), SortOrder = ct.SortOrder }) .OrderBy(ct => ct.SortOrder) .ToArray() ?? Enumerable.Empty(); - target.Compositions = MapNestedCompositions( + target.Compositions = MapCompositions( source.ContentTypeComposition, source.ParentId, (referenceByIdModel, compositionType) => new DocumentTypeComposition diff --git a/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs index 32fdf427b865..af1a0fff090d 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs @@ -40,7 +40,7 @@ private void Map(IMediaType source, MediaTypeResponseModel target, MapperContext target.AllowedMediaTypes = source.AllowedContentTypes?.Select(ct => new MediaTypeSort { MediaType = new ReferenceByIdModel(ct.Key), SortOrder = ct.SortOrder }) .ToArray() ?? Enumerable.Empty(); - target.Compositions = MapNestedCompositions( + target.Compositions = MapCompositions( source.ContentTypeComposition, source.ParentId, (referenceByIdModel, compositionType) => new MediaTypeComposition diff --git a/src/Umbraco.Cms.Api.Management/Mapping/MemberType/MemberTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/MemberType/MemberTypeMapDefinition.cs index 7e19a2e45a11..8192c6b585f9 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/MemberType/MemberTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/MemberType/MemberTypeMapDefinition.cs @@ -32,7 +32,7 @@ private void Map(IMemberType source, MemberTypeResponseModel target, MapperConte target.IsElement = source.IsElement; target.Containers = MapPropertyTypeContainers(source); target.Properties = MapPropertyTypes(source); - target.Compositions = MapNestedCompositions( + target.Compositions = MapCompositions( source.ContentTypeComposition, source.ParentId, (referenceByIdModel, compositionType) => new MemberTypeComposition diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs index a86d9186e0b4..fdf160ef4f6b 100644 --- a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs @@ -322,6 +322,15 @@ private ContentTypeOperationStatus ValidateCompositions(TContentType? contentTyp // get the content type keys we want to use for compositions Guid[] compositionKeys = KeysForCompositionTypes(model, CompositionType.Composition); + // if the content type keys are already set as compositions, don't perform any additional validation + // - this covers an edge case where compositions are configured for a content type before child content types are created + if (contentType is not null && contentType.ContentTypeComposition + .Select(c => c.Key) + .ContainsAll(compositionKeys)) + { + return ContentTypeOperationStatus.Success; + } + // verify that all compositions keys are allowed Guid[] allowedCompositionKeys = _contentTypeService.GetAvailableCompositeContentTypes(contentType, allContentTypeCompositions, isElement: model.IsElement) .Results diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts index 579efc0e3c17..3fdf1b10b055 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts @@ -11,11 +11,11 @@ import { UmbDeepState } from './deep-state.js'; * * The ArrayState provides methods to append data when the data is an Object. */ -export class UmbArrayState extends UmbDeepState { - readonly getUniqueMethod: (entry: T) => unknown; +export class UmbArrayState extends UmbDeepState { + readonly getUniqueMethod: (entry: T) => U; #sortMethod?: (a: T, b: T) => number; - constructor(initialData: T[], getUniqueOfEntryMethod: (entry: T) => unknown) { + constructor(initialData: T[], getUniqueOfEntryMethod: (entry: T) => U) { super(initialData); this.getUniqueMethod = getUniqueOfEntryMethod; } @@ -61,6 +61,27 @@ export class UmbArrayState extends UmbDeepState { } } + /** + * @function getHasOne + * @param {U} unique - the unique value to compare with. + * @returns {boolean} Wether it existed + * @description - Check if a unique value exists in the current data of this Subject. + * @example Example check for key to exist. + * const data = [ + * { key: 1, value: 'foo'}, + * { key: 2, value: 'bar'} + * ]; + * const myState = new UmbArrayState(data, (x) => x.key); + * myState.hasOne(1); + */ + getHasOne(unique: U): boolean { + if (this.getUniqueMethod) { + return this.getValue().some((x) => this.getUniqueMethod(x) === unique); + } else { + throw new Error('Cannot use hasOne when no unique method provided to check for uniqueness'); + } + } + /** * @function remove * @param {unknown[]} uniques - The unique values to remove. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index ea180f72b730..dbf3edb45f9a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -73,7 +73,11 @@ export class UmbBlockElementManager this.structure.loadType(id)); + this.observe(this.contentTypeId, (id) => { + if (id) { + this.structure.loadType(id); + } + }); this.observe(this.unique, (key) => { if (key) { this.validation.setDataPath('$.' + dataPathPropertyName + `[?(@.key == '${key}')]`); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.element.ts index 24da1d903204..e0cf37273d21 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.element.ts @@ -35,6 +35,10 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement< @state() private _selection: Array = []; + + @state() + private _usedForInheritance: Array = []; + override connectedCallback() { super.connectedCallback(); @@ -48,6 +52,7 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement< } this._selection = this.data?.selection ?? []; + this._usedForInheritance = this.data?.usedForInheritance ?? []; this.modalContext?.setValue({ selection: this._selection }); const isNew = this.data!.isNew; @@ -206,16 +211,20 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement< return repeat( compositionsList, (compositions) => compositions.unique, - (compositions) => html` - this.#onSelectionAdd(compositions.unique)} - @deselected=${() => this.#onSelectionRemove(compositions.unique)} - ?selected=${this._selection.find((unique) => unique === compositions.unique)}> - - - `, + (compositions) => { + const usedForInheritance = this._usedForInheritance.includes(compositions.unique); + return html` + this.#onSelectionAdd(compositions.unique)} + @deselected=${() => this.#onSelectionRemove(compositions.unique)} + ?selected=${this._selection.find((unique) => unique === compositions.unique)}> + + + `; + }, ); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.token.ts index ec4e047e50f9..a711e4e4df9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.token.ts @@ -4,6 +4,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export interface UmbCompositionPickerModalData { compositionRepositoryAlias: string; selection: Array; + usedForInheritance: Array; unique: string | null; isElement: boolean; currentPropertyAliases: Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts index 6c2a98b60e5d..6ff9acab953c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-property-structure-helper.class.ts @@ -9,7 +9,7 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbArrayState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; -type UmbPropertyTypeId = UmbPropertyTypeModel['id']; +type UmbPropertyTypeUnique = UmbPropertyTypeModel['unique']; /** * This class is a helper class for managing the structure of containers in a content type. @@ -24,7 +24,7 @@ export class UmbContentTypePropertyStructureHelper([], (x) => x.id); + #propertyStructure = new UmbArrayState([], (x) => x.unique); readonly propertyStructure = this.#propertyStructure.asObservable(); constructor(host: UmbControllerHost) { @@ -164,18 +164,20 @@ export class UmbContentTypePropertyStructureHelper x?.properties.some((y) => y.id === propertyId)); + return this.#structure.ownerContentTypeObservablePart((x) => + x?.properties.some((y) => y.unique === propertyUnique), + ); } - async contentTypeOfProperty(propertyId: UmbPropertyTypeId) { + async contentTypeOfProperty(propertyUnique: UmbPropertyTypeUnique) { await this.#init; if (!this.#structure) return; - return this.#structure.contentTypeOfProperty(propertyId); + return this.#structure.contentTypeOfProperty(propertyUnique); } // TODO: consider moving this to another class, to separate 'viewer' from 'manipulator': @@ -194,11 +196,11 @@ export class UmbContentTypePropertyStructureHelper) => array.indexOf(value) === index; @@ -35,22 +40,26 @@ const UmbFilterDuplicateStrings = (value: string, index: number, array: Array extends UmbControllerBase { - #initResolver?: (respoonse: UmbRepositoryResponse) => void; - #init = new Promise>((resolve) => { + #initResolver?: (result: T) => void; + #init = new Promise((resolve) => { this.#initResolver = resolve; }); + #editedTypes = new UmbArrayState([], (x) => x); + #repository?: UmbDetailRepository; - #initRepositoryResolver?: () => void; + #initRepositoryResolver?: (repo: UmbDetailRepository) => void; - #initRepository = new Promise((resolve) => { + #initRepository = new Promise>((resolve) => { if (this.#repository) { - resolve(); + resolve(this.#repository); } else { this.#initRepositoryResolver = resolve; } }); + #repoManager?: UmbRepositoryDetailsManager; + async whenLoaded() { await this.#init; return true; @@ -68,25 +77,30 @@ export class UmbContentTypeStructureManager< readonly ownerContentTypeName = createObservablePart(this.ownerContentType, (x) => x?.name); readonly ownerContentTypeCompositions = createObservablePart(this.ownerContentType, (x) => x?.compositions); + readonly contentTypeCompositions = this.#contentTypes.asObservablePart((contentTypes) => { + return contentTypes.flatMap((x) => x.compositions ?? []); + }); + async getContentTypeCompositions() { + return await this.observe(this.contentTypeCompositions).asPromise(); + } + async getOwnerContentTypeCompositions() { + return await this.observe(this.ownerContentTypeCompositions).asPromise(); + } readonly #contentTypeContainers = this.#contentTypes.asObservablePart((contentTypes) => { - // Notice this may need to use getValue to avoid resetting it self. [NL] return contentTypes.flatMap((x) => x.containers ?? []); }); readonly contentTypeProperties = this.#contentTypes.asObservablePart((contentTypes) => { - // Notice this may need to use getValue to avoid resetting it self. [NL] return contentTypes.flatMap((x) => x.properties ?? []); }); async getContentTypeProperties() { return await this.observe(this.contentTypeProperties).asPromise(); } readonly contentTypeDataTypeUniques = this.#contentTypes.asObservablePart((contentTypes) => { - // Notice this may need to use getValue to avoid resetting it self. [NL] return contentTypes .flatMap((x) => x.properties?.map((p) => p.dataType.unique) ?? []) .filter(UmbFilterDuplicateStrings); }); readonly contentTypeHasProperties = this.#contentTypes.asObservablePart((contentTypes) => { - // Notice this may need to use getValue to avoid resetting it self. [NL] return contentTypes.some((x) => x.properties.length > 0); }); readonly contentTypePropertyAliases = createObservablePart(this.contentTypeProperties, (properties) => @@ -113,17 +127,45 @@ export class UmbContentTypeStructureManager< this.#observeRepository(typeRepository); } else { this.#repository = typeRepository; - this.#initRepositoryResolver?.(); + this.#initRepositoryResolver?.(typeRepository); } - // Observe owner content type compositions, as we only allow one level of compositions at this moment. [NL] - // But, we could support more, we would just need to flatMap all compositions and make sure the entries are unique and then base the observation on that. [NL] - this.observe(this.ownerContentTypeCompositions, (ownerContentTypeCompositions) => { - this.#loadContentTypeCompositions(ownerContentTypeCompositions); - }); - this.observe(this.#contentTypeContainers, (contentTypeContainers) => { - this.#containers.setValue(contentTypeContainers); + this.#initRepository.then(() => { + if (!this.#repository) { + throw new Error( + 'Content Type Structure Manager failed cause it could not initialize or receive the Content Type Detail Repository.', + ); + } + this.#repoManager = new UmbRepositoryDetailsManager(this, typeRepository); + this.observe( + this.#repoManager.entries, + (entries) => { + // Prevent updating once that are have edited here. + entries = entries.filter( + (x) => !(this.#editedTypes.getHasOne(x.unique) && this.#contentTypes.getHasOne(x.unique)), + ); + + this.#contentTypes.append(entries); + }, + null, + ); }); + + // Observe all Content Types compositions: [NL] + this.observe( + this.contentTypeCompositions, + (contentTypeCompositions) => { + this.#loadContentTypeCompositions(contentTypeCompositions); + }, + null, + ); + this.observe( + this.#contentTypeContainers, + (contentTypeContainers) => { + this.#containers.setValue(contentTypeContainers); + }, + null, + ); } /** @@ -132,32 +174,42 @@ export class UmbContentTypeStructureManager< * @param {string} unique - The unique of the ContentType to load. * @returns {Promise} - Promise resolved */ - public async loadType(unique?: string) { + public async loadType(unique: string): Promise> { if (this.#ownerContentTypeUnique === unique) { // Its the same, but we do not know if its done loading jet, so we will wait for the load promise to finish. [NL] await this.#init; - return; + return { data: this.getOwnerContentType(), asObservable: () => this.ownerContentType }; } + await this.#initRepository; this.#clear(); this.#ownerContentTypeUnique = unique; - if (!unique) return; - const result = await this.#loadType(unique); + if (!unique) { + return Promise.reject( + new Error('The unique identifier is missing. A valid unique identifier is required to load the content type.'), + ); + } + this.#repoManager!.setUniques([unique]); + const result = await this.observe(this.#repoManager!.entryByUnique(unique)).asPromise(); this.#initResolver?.(result); - return result; + await this.#init; + return { data: result, asObservable: () => this.ownerContentType }; } - public async createScaffold(preset?: Partial) { + public async createScaffold(preset?: Partial): Promise> { await this.#initRepository; this.#clear(); const repsonse = await this.#repository!.createScaffold(preset); - if (!repsonse.data) return {}; + const { data } = repsonse; + if (!data) return { error: repsonse.error }; - this.#ownerContentTypeUnique = repsonse.data.unique; + this.#ownerContentTypeUnique = data.unique; // Add the new content type to the list of content types, this holds our draft state of this scaffold. - this.#contentTypes.appendOne(repsonse.data); - this.#initResolver?.(repsonse); + this.#contentTypes.appendOne(data); + // Make a entry in the repo manager: + this.#repoManager!.addEntry(data); + this.#initResolver?.(data); return repsonse; } @@ -165,7 +217,7 @@ export class UmbContentTypeStructureManager< * Save the owner content type. Notice this is for a Content Type that is already stored on the server. * @returns {Promise} - A promise that will be resolved when the content type is saved. */ - public async save() { + public async save(): Promise { await this.#initRepository; const contentType = this.getOwnerContentType(); if (!contentType || !contentType.unique) throw new Error('Could not find the Content Type to save'); @@ -178,6 +230,8 @@ export class UmbContentTypeStructureManager< // Update state with latest version: this.#contentTypes.updateOne(contentType.unique, data); + // Update entry in the repo manager: + this.#repoManager!.addEntry(data); return data; } @@ -186,86 +240,32 @@ export class UmbContentTypeStructureManager< * @param {string | null} parentUnique - The unique of the parent content type * @returns {Promise} - a promise that is resolved when the content type has been created. */ - public async create(parentUnique: string | null) { + public async create(parentUnique: string | null): Promise { await this.#initRepository; const contentType = this.getOwnerContentType(); if (!contentType || !contentType.unique) { throw new Error('Could not find the Content Type to create'); } - - const { data } = await this.#repository!.create(contentType, parentUnique); - if (!data) return Promise.reject(); + const { error, data } = await this.#repository!.create(contentType, parentUnique); + if (error || !data) { + throw error?.message ?? 'Repository did not return data after create.'; + } // Update state with latest version: this.#contentTypes.updateOne(contentType.unique, data); - // Start observe the new content type in the store, as we did not do that when it was a scaffold/local-version. - this.#observeContentType(data); + // Let the repo manager know about this new unique, so it can be loaded: + this.#repoManager!.addEntry(data); + return data; } - async #loadContentTypeCompositions(ownerContentTypeCompositions: T['compositions'] | undefined) { - if (!ownerContentTypeCompositions) { - // Owner content type was undefined, so we cannot load compositions. - // But to clean up existing compositions, we set the array to empty to still be able to execute the clean-up code. - ownerContentTypeCompositions = []; - } - + async #loadContentTypeCompositions(contentTypeCompositions: T['compositions'] | undefined) { const ownerUnique = this.getOwnerContentTypeUnique(); - // Remove content types that does not exist as compositions anymore: - this.#contentTypes.getValue().forEach((x) => { - if ( - x.unique !== ownerUnique && - !ownerContentTypeCompositions.find((comp) => comp.contentType.unique === x.unique) - ) { - this.#contentTypeObservers.find((y) => y.controllerAlias === 'observeContentType_' + x.unique)?.destroy(); - this.#contentTypes.removeOne(x.unique); - } - }); - ownerContentTypeCompositions.forEach((composition) => { - this.#ensureType(composition.contentType.unique); - }); - } - - async #ensureType(unique?: string) { - if (!unique) return; - if (this.#contentTypes.getValue().find((x) => x.unique === unique)) return; - await this.#loadType(unique); - } - - async #loadType(unique?: string) { - if (!unique) return {}; - await this.#initRepository; - - // Lets initiate the content type: - const { data, asObservable } = await this.#repository!.requestByUnique(unique); - if (!data) return {}; - - await this.#observeContentType(data); - return { data, asObservable }; - } - - async #observeContentType(data: T) { - if (!data.unique) return; - await this.#initRepository; - - // Notice we do not store the content type in the store here, cause it will happen shortly after when the observations gets its first initial callback. [NL] - - const ctrl = this.observe( - // Then lets start observation of the content type: - await this.#repository!.byUnique(data.unique), - (docType) => { - if (docType) { - this.#contentTypes.appendOne(docType); - } else { - // Remove the content type from the store, if it does not exist anymore. - this.#contentTypes.removeOne(data.unique); - } - }, - 'observeContentType_' + data.unique, - // Controller Alias is used to stop observation when no longer needed. [NL] - ); - - this.#contentTypeObservers.push(ctrl); + if (!ownerUnique) return; + const compositionUniques = contentTypeCompositions?.map((x) => x.contentType.unique) ?? []; + const newUniques = [ownerUnique, ...compositionUniques]; + this.#contentTypes.filter((x) => newUniques.includes(x.unique)); + this.#repoManager!.setUniques(newUniques); } /** Public methods for consuming structure: */ @@ -300,6 +300,7 @@ export class UmbContentTypeStructureManager< } updateOwnerContentType(entry: Partial) { + this.#editedTypes.appendOne(this.#ownerContentTypeUnique!); this.#contentTypes.updateOne(this.#ownerContentTypeUnique, entry); } @@ -349,6 +350,7 @@ export class UmbContentTypeStructureManager< ): Promise { await this.#init; toContentTypeUnique = toContentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(toContentTypeUnique); // Find container. const container = this.#containers.getValue().find((x) => x.id === containerId); @@ -377,10 +379,7 @@ export class UmbContentTypeStructureManager< 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 }); + this.#contentTypes.updateOne(toContentTypeUnique, { containers } as Partial); return clonedContainer; } @@ -394,6 +393,7 @@ export class UmbContentTypeStructureManager< this.getOwnerContainers(type, parentId)?.forEach((container) => { if (container.name === '') { const newName = 'Unnamed'; + this.#editedTypes.appendOne(contentTypeUnique); this.updateContainer(null, container.id, { name: this.makeContainerNameUniqueForOwnerContentType(container.id, newName, type, parentId) ?? newName, }); @@ -409,6 +409,7 @@ export class UmbContentTypeStructureManager< ): Promise { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); if (parentId) { const duplicatedParentContainer = await this.ensureContainerOf(parentId, contentTypeUnique); @@ -433,10 +434,7 @@ export class UmbContentTypeStructureManager< const containers = [...(contentTypes.find((x) => x.unique === contentTypeUnique)?.containers ?? [])]; containers.push(container); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { containers }); + this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial); return container; } @@ -459,10 +457,7 @@ export class UmbContentTypeStructureManager< const containers = appendToFrozenArray(frozenContainers, container, (x) => x.id === container.id); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { containers }); + this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial); }*/ makeEmptyContainerName( @@ -500,6 +495,7 @@ export class UmbContentTypeStructureManager< ) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); /* // If we have a container, we need to ensure it exists, and then update the container with the new parent id. @@ -523,7 +519,11 @@ export class UmbContentTypeStructureManager< ); } - const containers = partialUpdateFrozenArray(frozenContainers, partialUpdate, (x) => x.id === containerId); + const containers: UmbPropertyTypeContainerModel[] = partialUpdateFrozenArray( + frozenContainers, + partialUpdate, + (x) => x.id === containerId, + ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -534,6 +534,7 @@ export class UmbContentTypeStructureManager< async removeContainer(contentTypeUnique: string | null, containerId: string | null = null) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); const contentType = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique); if (!contentType) { @@ -550,15 +551,13 @@ export class UmbContentTypeStructureManager< x.container ? !removedContainerIds.some((ids) => ids === x.container?.id) : true, ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { containers, properties }); + this.#contentTypes.updateOne(contentTypeUnique, { containers, properties } as Partial); } async insertProperty(contentTypeUnique: string | null, property: UmbPropertyTypeModel) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); // If we have a container, we need to ensure it exists, and then update the container with the new parent id. [NL] if (property.container) { @@ -579,53 +578,46 @@ export class UmbContentTypeStructureManager< const frozenProperties = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.properties ?? []; - const properties = appendToFrozenArray(frozenProperties, property, (x) => x.id === property.id); + const properties = appendToFrozenArray(frozenProperties, property, (x) => x.unique === property.unique); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { properties }); + this.#contentTypes.updateOne(contentTypeUnique, { properties } as Partial); } - async removeProperty(contentTypeUnique: string | null, propertyId: string) { + async removeProperty(contentTypeUnique: string | null, propertyUnique: string) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); const frozenProperties = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.properties ?? []; - const properties = filterFrozenArray(frozenProperties, (x) => x.id !== propertyId); + const properties = filterFrozenArray(frozenProperties, (x) => x.unique !== propertyUnique); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { properties }); + this.#contentTypes.updateOne(contentTypeUnique, { properties } as Partial); } async updateProperty( contentTypeUnique: string | null, - propertyId: string, + propertyUnique: string, partialUpdate: Partial, ) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + this.#editedTypes.appendOne(contentTypeUnique); const frozenProperties = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.properties ?? []; - const properties = partialUpdateFrozenArray(frozenProperties, partialUpdate, (x) => x.id === propertyId); + const properties = partialUpdateFrozenArray(frozenProperties, partialUpdate, (x) => x.unique === propertyUnique); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: fix TS partial complaint - this.#contentTypes.updateOne(contentTypeUnique, { properties }); + this.#contentTypes.updateOne(contentTypeUnique, { properties } as Partial); } // TODO: Refactor: These property methods, should maybe be named without structure in their name. - async propertyStructureById(propertyId: string) { + async propertyStructureById(propertyUnique: string) { await this.#init; return this.#contentTypes.asObservablePart((docTypes) => { for (const docType of docTypes) { - const foundProp = docType.properties?.find((property) => property.id === propertyId); + const foundProp = docType.properties?.find((property) => property.unique === propertyUnique); if (foundProp) { return foundProp; } @@ -646,10 +638,10 @@ export class UmbContentTypeStructureManager< }); } - async getPropertyStructureById(propertyId: string) { + async getPropertyStructureById(propertyUnique: string) { await this.#init; for (const docType of this.#contentTypes.getValue()) { - const foundProp = docType.properties?.find((property) => property.id === propertyId); + const foundProp = docType.properties?.find((property) => property.unique === propertyUnique); if (foundProp) { return foundProp; } @@ -774,9 +766,9 @@ export class UmbContentTypeStructureManager< .find((contentType) => contentType.containers.some((c) => c.id === containerId)); } - contentTypeOfProperty(propertyId: UmbPropertyTypeId) { + contentTypeOfProperty(propertyId: UmbPropertyTypeUnique) { return this.#contentTypes.asObservablePart((contentTypes) => - contentTypes.find((contentType) => contentType.properties.some((p) => p.id === propertyId)), + contentTypes.find((contentType) => contentType.properties.some((p) => p.unique === propertyId)), ); } @@ -790,7 +782,9 @@ export class UmbContentTypeStructureManager< [this._host], (permitted, ctrl) => { this.#repository = permitted ? ctrl.api : undefined; - this.#initRepositoryResolver?.(); + if (this.#repository) { + this.#initRepositoryResolver?.(this.#repository); + } }, ); } @@ -799,11 +793,11 @@ export class UmbContentTypeStructureManager< this.#init = new Promise((resolve) => { this.#initResolver = resolve; }); - this.#contentTypes.setValue([]); this.#contentTypeObservers.forEach((observer) => observer.destroy()); this.#contentTypeObservers = []; - this.#contentTypes.setValue([]); this.#containers.setValue([]); + this.#repoManager?.clear(); + this.#contentTypes.setValue([]); } public override destroy() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index 030b2cefc2a6..a2b0cd669f4c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -2,7 +2,11 @@ import type { UmbContentTypeCompositionModel, UmbContentTypeDetailModel, UmbCont import { UmbContentTypeStructureManager } from '../structure/index.js'; import type { UmbContentTypeWorkspaceContext } from './content-type-workspace-context.interface.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; +import type { + UmbDetailRepository, + UmbRepositoryResponse, + UmbRepositoryResponseWithAsObservable, +} from '@umbraco-cms/backoffice/repository'; import { UmbEntityDetailWorkspaceContextBase, type UmbEntityDetailWorkspaceContextArgs, @@ -105,11 +109,13 @@ export abstract class UmbContentTypeWorkspaceContextBase< /** * Loads the data for the workspace * @param { string } unique The unique identifier of the data to load - * @returns { Promise } The loaded data + * @returns { Promise | UmbRepositoryResponseWithAsObservable> } The loaded data */ - override async load(unique: string) { + override async load( + unique: string, + ): Promise | UmbRepositoryResponseWithAsObservable> { if (unique === this.getUnique() && this._getDataPromise) { - return (await this._getDataPromise) as any; + return await this._getDataPromise; } this.resetState(); @@ -118,13 +124,12 @@ export abstract class UmbContentTypeWorkspaceContextBase< this._getDataPromise = this.structure.loadType(unique); const response = await this._getDataPromise; const data = response.data; - if (data) { this._data.setPersisted(data); this.setIsNew(false); this.observe( - response.asObservable(), + this.structure.ownerContentType, (entity: any) => this.#onDetailStoreChange(entity), 'umbContentTypeDetailStoreObserver', ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts index 8e2f5ee4fefb..22c705d91a39 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts @@ -29,7 +29,7 @@ const SORTER_CONFIG: UmbSorterConfig { - return modelEntry.id; + return modelEntry.unique; }, identifier: 'content-type-property-sorter', itemSelector: 'umb-content-type-design-editor-property', @@ -52,7 +52,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { if (this._containerId === undefined) { throw new Error('ContainerId is not set'); } - this.#propertyStructureHelper.partialUpdateProperty(item.id, { + this.#propertyStructureHelper.partialUpdateProperty(item.unique, { container: this._containerId ? { id: this._containerId } : null, }); }, @@ -66,7 +66,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { * the overlap if true, which may cause another overlap, so we loop through them till no more overlaps... */ const model = this._properties; - const newIndex = model.findIndex((entry) => entry.id === item.id); + const newIndex = model.findIndex((entry) => entry.unique === item.unique); // Doesn't exist in model if (newIndex === -1) return; @@ -80,7 +80,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { } // increase the prevSortOrder and use it for the moved item, - this.#propertyStructureHelper.partialUpdateProperty(item.id, { + this.#propertyStructureHelper.partialUpdateProperty(item.unique, { sortOrder: ++prevSortOrder, }); @@ -90,7 +90,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { // 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, { + this.#propertyStructureHelper.partialUpdateProperty(entry.unique, { sortOrder: ++prevSortOrder, }); @@ -267,11 +267,11 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
${repeat( this._properties, - (property) => property.id, + (property) => property.unique, (property) => { return html` { this._inherited = this._propertyStructureHelper?.getStructureManager()?.getOwnerContentTypeUnique() !== contentType?.unique; @@ -115,7 +115,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { #partialUpdate(partialObject: UmbPropertyTypeModel) { if (!this._property || !this._propertyStructureHelper) return; - this._propertyStructureHelper.partialUpdateProperty(this._property.id, partialObject); + this._propertyStructureHelper.partialUpdateProperty(this._property.unique, partialObject); } #singleValueUpdate( @@ -125,7 +125,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { if (!this._property || !this._propertyStructureHelper) return; const partialObject: Partial = {}; partialObject[propertyName] = value === null ? undefined : value; - this._propertyStructureHelper.partialUpdateProperty(this._property.id, partialObject); + this._propertyStructureHelper.partialUpdateProperty(this._property.unique, partialObject); } #onToggleAliasLock(event: CustomEvent) { @@ -155,17 +155,19 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { async #requestRemove(e: Event) { e.preventDefault(); e.stopImmediatePropagation(); - if (!this._property || !this._property.id) return; + if (!this._property || !this._property.unique) return; + + const unique = this._property.unique; // 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}
`, + content: html`Are you sure you want to delete the property ${this._property.name ?? unique}`, confirmLabel: this.localize.term('actions_delete'), color: 'danger', }); - this._propertyStructureHelper?.removeProperty(this._property.id); + this._propertyStructureHelper?.removeProperty(unique); } #onAliasChanged(event: UUIInputEvent) { @@ -250,7 +252,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { look="outline" label=${this.localize.term('contentTypeEditor_editorSettings')} href=${this.editPropertyTypePath + - UMB_EDIT_PROPERTY_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.property.id })}> + UMB_EDIT_PROPERTY_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.property.unique })}> ${this.renderPropertyTags()} @@ -262,6 +264,9 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { } } + #onPropertyOrderChanged = (e: UUIInputEvent) => + this.#partialUpdate({ sortOrder: parseInt(e.target.value as string) ?? 0 } as UmbPropertyTypeModel); + renderSortableProperty() { if (!this.property) return; return html` @@ -274,9 +279,8 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { type="number" ?disabled=${this._inherited} label="sort order" - @change=${(e: UUIInputEvent) => - this.#partialUpdate({ sortOrder: parseInt(e.target.value as string) ?? 0 } as UmbPropertyTypeModel)} - .value=${this.property.sortOrder ?? 0}> + @change=${this.#onPropertyOrderChanged} + .value=${(this.property.sortOrder ?? 0).toString()}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts index e92baa60f32a..35bf37453c4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts @@ -1,5 +1,9 @@ import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '../../content-type-workspace.context-token.js'; -import type { UmbContentTypeModel, UmbPropertyTypeContainerModel } from '../../../types.js'; +import type { + UmbContentTypeCompositionModel, + UmbContentTypeModel, + UmbPropertyTypeContainerModel, +} from '../../../types.js'; import { UmbContentTypeContainerStructureHelper, UmbContentTypeMoveRootGroupsIntoFirstTabHelper, @@ -127,11 +131,9 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements this.#sorter.disable(); } }, - '_observeIsSorting', + null, ); - //TODO: We need to differentiate between local and composition tabs (and hybrids) - this.#tabsStructureHelper.setContainerChildType('Tab'); this.#tabsStructureHelper.setIsRoot(true); this.observe(this.#tabsStructureHelper.mergedContainers, (tabs) => { @@ -373,17 +375,29 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements if (!unique) { throw new Error('Content Type unique is undefined'); } - const contentTypes = this.#workspaceContext.structure.getContentTypes(); const ownerContentType = this.#workspaceContext.structure.getOwnerContentType(); if (!ownerContentType) { throw new Error('Owner Content Type not found'); } + const currentCompositions = await this.#workspaceContext.structure.getContentTypeCompositions(); + const currentInheritanceCompositions = currentCompositions.filter( + (composition) => composition.compositionType === CompositionTypeModel.INHERITANCE, + ); + + const currentOwnerCompositions = await this.#workspaceContext.structure.getOwnerContentTypeCompositions(); + const currentOwnerCompositionCompositions = currentOwnerCompositions.filter( + (composition) => composition.compositionType === CompositionTypeModel.COMPOSITION, + ); + const currentOwnerInheritanceCompositions = currentOwnerCompositions.filter( + (composition) => composition.compositionType === CompositionTypeModel.INHERITANCE, + ); + const compositionConfiguration = { compositionRepositoryAlias: this._compositionRepositoryAlias, unique: unique, - // Here we use the loaded content types to declare what we already inherit. That puts a pressure on cleaning up, but thats a good thing. [NL] - selection: contentTypes.map((contentType) => contentType.unique).filter((id) => id !== unique), + selection: currentOwnerCompositionCompositions.map((composition) => composition.contentType.unique), + usedForInheritance: currentInheritanceCompositions.map((composition) => composition.contentType.unique), isElement: ownerContentType.isElement, currentPropertyAliases: [], isNew: this.#workspaceContext.getIsNew()!, @@ -397,9 +411,16 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements const compositionIds = value.selection; - this.#workspaceContext?.setCompositions( - compositionIds.map((unique) => ({ contentType: { unique }, compositionType: CompositionTypeModel.COMPOSITION })), - ); + this.#workspaceContext.setCompositions([ + ...currentOwnerInheritanceCompositions, + ...compositionIds.map((unique) => { + const model: UmbContentTypeCompositionModel = { + contentType: { unique }, + compositionType: CompositionTypeModel.COMPOSITION, + }; + return model; + }), + ]); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts index 25add74dbc56..d3c218dc2834 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts @@ -16,14 +16,16 @@ import { } from '@umbraco-cms/backoffice/workspace'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; +import type { UmbPropertyTypeScaffoldModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; -export class UmbPropertyTypeWorkspaceContext - extends UmbSubmittableWorkspaceContextBase +type PropertyTypeDataModel = UmbPropertyTypeScaffoldModel; + +export class UmbPropertyTypeWorkspaceContext + extends UmbSubmittableWorkspaceContextBase implements UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext { // Just for context token safety: @@ -34,11 +36,11 @@ export class UmbPropertyTypeWorkspaceContext(undefined); + #data = new UmbObjectState(undefined); readonly data = this.#data.asObservable(); readonly name = this.#data.asObservablePart((data) => data?.name); @@ -57,12 +59,12 @@ export class UmbPropertyTypeWorkspaceContext { if (unique) { - this.validationgContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique })); + this.validationContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique })); } }); @@ -118,7 +120,7 @@ export class UmbPropertyTypeWorkspaceContext { if (property) { - this.#data.setValue(property as PropertyTypeData); + this.#data.setValue(property as PropertyTypeDataModel); //this.#persistedData.setValue(property); //this.#currentData.setValue(property); @@ -135,8 +137,10 @@ export class UmbPropertyTypeWorkspaceContext) { + updateData(partialData: Partial) { this.#data?.update(partialData); } @@ -194,12 +198,12 @@ export class UmbPropertyTypeWorkspaceContext | undefined>} * @description Get an Observable for the value of this property. */ - async propertyValueByAlias(propertyAlias: string) { - return this.#data.asObservablePart((data) => data?.[propertyAlias as keyof PropertyTypeData] as ReturnType); + async propertyValueByAlias(propertyAlias: keyof PropertyTypeDataModel) { + return this.#data.asObservablePart((data) => data?.[propertyAlias] as ReturnType); } - getPropertyValue(propertyAlias: string) { - return this.#data.getValue()?.[propertyAlias as keyof PropertyTypeData] as ReturnType; + getPropertyValue(propertyAlias: keyof PropertyTypeDataModel) { + return this.#data.getValue()?.[propertyAlias] as ReturnType; } /** @@ -230,8 +234,8 @@ export class UmbPropertyTypeWorkspaceContext) { + updateValue(partialValue: Partial) { this.#context?.updateData(partialValue); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/index.ts index a344a151a986..8ccfa5a3e6fc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/index.ts @@ -3,6 +3,7 @@ export * from './data-mapper/index.js'; export * from './detail/index.js'; export * from './item/index.js'; export * from './repository-base.js'; +export * from './repository-details.manager.js'; export * from './repository-items.manager.js'; export type { UmbDataSourceResponse, UmbDataSourceErrorResponse } from './data-source-response.interface.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-details.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-details.manager.ts new file mode 100644 index 000000000000..fb58c28815b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-details.manager.ts @@ -0,0 +1,277 @@ +import type { UmbDetailRepository } from './detail/detail-repository.interface.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbArrayState, type Observable } from '@umbraco-cms/backoffice/observable-api'; +import { type ManifestRepository, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UmbEntityUpdatedEvent } from '@umbraco-cms/backoffice/entity-action'; + +interface UmbRepositoryRequestStatus { + state: { + type: 'success' | 'error' | 'loading'; + error?: string; + }; + unique: string; +} + +/** + * @export + * @class UmbRepositoryDetailsManager + * @augments {UmbControllerBase} + * @template DetailType + */ +export class UmbRepositoryDetailsManager extends UmbControllerBase { + // + repository?: UmbDetailRepository; + + #init: Promise; + #eventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + + // the init promise is used externally for recognizing when the manager is ready. + public get init() { + return this.#init; + } + + #uniques = new UmbArrayState([], (x) => x); + uniques = this.#uniques.asObservable(); + + #entries = new UmbArrayState([], (x) => x.unique); + entries = this.#entries.asObservable(); + + #statuses = new UmbArrayState([], (x) => x.unique); + statuses = this.#statuses.asObservable(); + + /** + * Creates an instance of UmbRepositoryDetailsManager. + * @param {UmbControllerHost} host - The host for the controller. + * @param {string} repository - The alias of the repository to use. + * @memberof UmbRepositoryDetailsManager + */ + constructor(host: UmbControllerHost, repository: UmbDetailRepository | string) { + super(host); + + this.#entries.sortBy((a, b) => { + const uniques = this.getUniques(); + const aIndex = uniques.indexOf(a.unique); + const bIndex = uniques.indexOf(b.unique); + return aIndex - bIndex; + }); + + if (typeof repository === 'string') { + this.#init = new UmbExtensionApiInitializer>>( + this, + umbExtensionsRegistry, + repository, + [this], + (permitted, repository) => { + this.repository = permitted ? repository.api : undefined; + }, + ).asPromise(); + } else { + this.repository = repository; + this.#init = Promise.resolve(); + } + + this.observe( + this.uniques, + (uniques) => { + // remove entries based on no-longer existing uniques: + const removedEntries = this.#entries.getValue().filter((entry) => !uniques.includes(entry.unique)); + this.#entries.remove(removedEntries); + this.#statuses.remove(removedEntries); + removedEntries.forEach((entry) => { + this.removeUmbControllerByAlias('observeEntry_' + entry.unique); + }); + + this.#requestNewDetails(); + }, + null, + ); + + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (context) => { + this.#eventContext?.removeEventListener( + UmbEntityUpdatedEvent.TYPE, + this.#onEntityUpdatedEvent as unknown as EventListener, + ); + + this.#eventContext = context; + this.#eventContext.addEventListener( + UmbEntityUpdatedEvent.TYPE, + this.#onEntityUpdatedEvent as unknown as EventListener, + ); + }); + } + + /** + * Clear the manager + * @memberof UmbRepositoryDetailsManager + */ + clear(): void { + this.#uniques.setValue([]); + this.#entries.setValue([]); + this.#statuses.setValue([]); + } + + /** + * Get the uniques in the manager + * @returns {Array} - The uniques in the manager. + * @memberof UmbRepositoryDetailsManager + */ + getUniques(): Array { + return this.#uniques.getValue(); + } + + /** + * Set the uniques in the manager + * @param {(string[] | undefined)} uniques + * @memberof UmbRepositoryDetailsManager + */ + setUniques(uniques: Array | undefined): void { + this.#uniques.setValue(uniques ?? []); + } + + /** + * Add a unique to the manager + * @param {string} unique + * @memberof UmbRepositoryDetailsManager + */ + addUnique(unique: string): void { + this.#uniques.appendOne(unique); + } + + /** + * Add an entry to the manager + * @param {DetailType} data + * @memberof UmbRepositoryDetailsManager + */ + addEntry(data: DetailType): void { + const unique = data.unique; + this.#statuses.appendOne({ + state: { + type: 'success', + }, + unique, + }); + this.#entries.appendOne(data); + this.#uniques.appendOne(unique); + // Notice in this case we do not have a observable from the repo, but it should maybe be fine that we just listen for ACTION EVENTS. + } + + /** + * Get all entries in the manager + * @returns {Array} - The entries in the manager. + * @memberof UmbRepositoryDetailsManager + */ + getEntries(): Array { + return this.#entries.getValue(); + } + + /** + * Get an entry observable by unique + * @param {string} unique + * @returns {Observable} - The entry observable. + * @memberof UmbRepositoryDetailsManager + */ + entryByUnique(unique: string): Observable { + return this.#entries.asObservablePart((items) => items.find((item) => item.unique === unique)); + } + + async #requestNewDetails(): Promise { + await this.#init; + if (!this.repository) throw new Error('Repository is not initialized'); + + const requestedUniques = this.getUniques(); + + const newRequestedUniques = requestedUniques.filter((unique) => { + const item = this.#statuses.getValue().find((status) => status.unique === unique); + return !item; + }); + + newRequestedUniques.forEach((unique) => { + this.#requestDetails(unique); + }); + } + + async #reloadDetails(unique: string): Promise { + return await this.#requestDetails(unique); + } + + async #requestDetails(unique: string): Promise { + await this.#init; + if (!this.repository) throw new Error('Repository is not initialized'); + + this.#statuses.appendOne({ + state: { + type: 'loading', + }, + unique, + }); + + const { data, error, asObservable } = await this.repository.requestByUnique(unique); + + if (error) { + this.#statuses.appendOne({ + state: { + type: 'error', + error: '#general_notFound', + }, + unique, + } as UmbRepositoryRequestStatus); + this.#entries.removeOne(unique); + this.removeUmbControllerByAlias('observeEntry_' + unique); + } + + if (data) { + //Check it still exists in uniques: + const uniques = this.getUniques(); + if (!uniques.includes(unique)) { + this.#statuses.removeOne(unique); + return; + } + this.#entries.appendOne(data); + + this.#statuses.appendOne({ + state: { + type: 'success', + }, + unique, + }); + + if (asObservable) { + this.observe( + asObservable(), + (data) => { + if (data) { + this.#entries.updateOne(unique, data); + } else { + this.#entries.removeOne(unique); + } + }, + 'observeEntry_' + unique, + ); + } + } + } + + #onEntityUpdatedEvent = (event: UmbEntityUpdatedEvent) => { + const eventUnique = event.getUnique(); + + const items = this.getEntries(); + if (items.length === 0) return; + + // Ignore events if the entity is not in the list of items. + const item = items.find((item) => item.unique === eventUnique); + if (!item) return; + + this.#reloadDetails(item.unique); + }; + + override destroy(): void { + this.#eventContext?.removeEventListener( + UmbEntityUpdatedEvent.TYPE, + this.#onEntityUpdatedEvent as unknown as EventListener, + ); + super.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts index 16f4e60bfaf2..24f8d84fe8dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts @@ -41,11 +41,12 @@ export class UmbRepositoryItemsManager exte #statuses = new UmbArrayState([], (x) => x.unique); statuses = this.#statuses.asObservable(); + // TODO: Align with the other manager(details), and make a generic type/base for these. v.17.0 [NL] /** * Creates an instance of UmbRepositoryItemsManager. * @param {UmbControllerHost} host - The host for the controller. * @param {string} repositoryAlias - The alias of the repository to use. - * @param {((entry: ItemType) => string | undefined)} [getUniqueMethod] - DEPRECATED since 15.3. Will be removed in v. 17: A method to get the unique key from the item. + * @param {((entry: ItemType) => string | undefined)} [getUniqueMethod] - DEPRECATED since 15.3. Will be removed in v.17.0: A method to get the unique key from the item. * @memberof UmbRepositoryItemsManager */ constructor( diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts index 6ff40b9e3709..9b4b33b05acf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts @@ -13,7 +13,11 @@ import { } from '@umbraco-cms/backoffice/entity-action'; import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; -import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; +import type { + UmbDetailRepository, + UmbRepositoryResponse, + UmbRepositoryResponseWithAsObservable, +} from '@umbraco-cms/backoffice/repository'; import { UmbDeprecation, UmbStateManager } from '@umbraco-cms/backoffice/utils'; import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; import { UmbId } from '@umbraco-cms/backoffice/id'; @@ -44,7 +48,9 @@ export abstract class UmbEntityDetailWorkspaceContextBase< public readonly persistedData = this._data.persisted; public readonly loading = new UmbStateManager(this); - protected _getDataPromise?: Promise; + protected _getDataPromise?: Promise< + UmbRepositoryResponse | UmbRepositoryResponseWithAsObservable + >; protected _detailRepository?: DetailRepositoryType; #eventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; @@ -166,9 +172,11 @@ export abstract class UmbEntityDetailWorkspaceContextBase< return this.#parent.getValue()?.entityType; } - async load(unique: string) { + async load( + unique: string, + ): Promise | UmbRepositoryResponseWithAsObservable> { if (unique === this.getUnique() && this._getDataPromise) { - return (await this._getDataPromise) as GetDataType; + return await this._getDataPromise; } this.resetState(); this.setIsNew(false); @@ -176,8 +184,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Loading ${this.getEntityType()} Details` }); await this.#init; this._getDataPromise = this._detailRepository!.requestByUnique(unique); - type GetDataType = Awaited['requestByUnique']>>; - const response = (await this._getDataPromise) as GetDataType; + const response = await this._getDataPromise; const data = response.data; if (data) { @@ -185,7 +192,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< this._data.setCurrent(data); this.observe( - response.asObservable(), + (response as UmbRepositoryResponseWithAsObservable).asObservable?.(), (entity) => this.#onDetailStoreChange(entity), 'umbEntityDetailTypeStoreObserver', ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/workspace/document-blueprint-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/workspace/document-blueprint-workspace.context.ts index aacaad252d7e..ab33e43fa53e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/workspace/document-blueprint-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/workspace/document-blueprint-workspace.context.ts @@ -47,7 +47,15 @@ export class UmbDocumentBlueprintWorkspaceContext contentTypePropertyName: 'documentType', }); - this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique), null); + this.observe( + this.contentTypeUnique, + (unique) => { + if (unique) { + this.structure.loadType(unique); + } + }, + null, + ); this.routes.setRoutes([ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/manifests.ts index 09ad63c51eda..f0a18c7cb745 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/manifests.ts @@ -10,7 +10,11 @@ export const manifests: Array = alias: 'Umb.EntityAction.DocumentType.Create', name: 'Create Document Type Entity Action', weight: 1200, - forEntityTypes: [UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE, UMB_DOCUMENT_TYPE_FOLDER_ENTITY_TYPE], + forEntityTypes: [ + UMB_DOCUMENT_TYPE_ENTITY_TYPE, + UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE, + UMB_DOCUMENT_TYPE_FOLDER_ENTITY_TYPE, + ], meta: { icon: 'icon-add', label: '#actions_create', diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/modal/document-type-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/modal/document-type-create-options-modal.element.ts index 941c581e24e6..a80df1de7054 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/modal/document-type-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/modal/document-type-create-options-modal.element.ts @@ -1,6 +1,8 @@ import { UMB_DOCUMENT_TYPE_FOLDER_REPOSITORY_ALIAS } from '../../../tree/index.js'; import { UMB_CREATE_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN, + UMB_CREATE_DOCUMENT_TYPE_WORKSPACE_PRESET_ELEMENT, + UMB_CREATE_DOCUMENT_TYPE_WORKSPACE_PRESET_TEMPLATE, type UmbCreateDocumentTypeWorkspacePresetType, } from '../../../paths.js'; import type { UmbDocumentTypeEntityTypeUnion } from '../../../entity.js'; @@ -9,8 +11,10 @@ import { html, customElement, map } from '@umbraco-cms/backoffice/external/lit'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbCreateFolderEntityAction } from '@umbraco-cms/backoffice/tree'; +const CREATE_FOLDER_PRESET = 'folder'; + // Include the types from the DocumentTypeWorkspacePresetType + folder. -type OptionsPresetType = UmbCreateDocumentTypeWorkspacePresetType | 'folder' | null; +type OptionsPresetType = UmbCreateDocumentTypeWorkspacePresetType | typeof CREATE_FOLDER_PRESET | null; /** @deprecated No longer used internally. This will be removed in Umbraco 17. [LK] */ @customElement('umb-document-type-create-options-modal') @@ -30,13 +34,13 @@ export class UmbDataTypeCreateOptionsModalElement extends UmbModalBaseElement this.structure.loadType(unique), null); + this.observe( + this.contentTypeUnique, + (unique) => { + if (unique) { + this.structure.loadType(unique); + } + }, + null, + ); // TODO: Remove this in v17 as we have moved the publishing methods to the UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT. this.consumeContext(UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT, (context) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/default/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/default/manifests.ts index 72db1ed5b2a0..fe22736b14bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/default/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/default/manifests.ts @@ -1,4 +1,8 @@ -import { UMB_MEDIA_TYPE_FOLDER_ENTITY_TYPE, UMB_MEDIA_TYPE_ROOT_ENTITY_TYPE } from '../../../entity.js'; +import { + UMB_MEDIA_TYPE_ENTITY_TYPE, + UMB_MEDIA_TYPE_FOLDER_ENTITY_TYPE, + UMB_MEDIA_TYPE_ROOT_ENTITY_TYPE, +} from '../../../entity.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; export const manifests: Array = [ @@ -8,7 +12,7 @@ export const manifests: Array = name: 'Default Media Type Entity Create Option Action', weight: 1200, api: () => import('./default-media-type-create-option-action.js'), - forEntityTypes: [UMB_MEDIA_TYPE_ROOT_ENTITY_TYPE, UMB_MEDIA_TYPE_FOLDER_ENTITY_TYPE], + forEntityTypes: [UMB_MEDIA_TYPE_ROOT_ENTITY_TYPE, UMB_MEDIA_TYPE_ENTITY_TYPE, UMB_MEDIA_TYPE_FOLDER_ENTITY_TYPE], meta: { icon: 'icon-picture', label: '#content_mediatype', diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace.context.ts index 3e4d2ad86312..b13b52dceab6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace.context.ts @@ -12,6 +12,7 @@ import type { UmbContentTypeSortModel, UmbContentTypeWorkspaceContext } from '@u import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { CompositionTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; type DetailModelType = UmbMediaTypeDetailModel; export class UmbMediaTypeWorkspaceContext @@ -33,7 +34,8 @@ export class UmbMediaTypeWorkspaceContext const parentEntityType = info.match.params.parentEntityType; const parentUnique = info.match.params.parentUnique === 'null' ? null : info.match.params.parentUnique; const parent: UmbEntityModel = { entityType: parentEntityType, unique: parentUnique }; - await this.createScaffold({ parent }); + + await this.#onScaffoldSetup(parent); new UmbWorkspaceIsNewRedirectController( this, @@ -83,8 +85,26 @@ export class UmbMediaTypeWorkspaceContext * @memberof UmbMediaTypeWorkspaceContext */ async create(parent: UmbEntityModel) { + console.warn('create() is deprecated. Use createScaffold() instead.'); this.createScaffold({ parent }); } + + async #onScaffoldSetup(parent: UmbEntityModel) { + let preset: Partial | undefined = undefined; + + if (parent.unique && parent.entityType === UMB_MEDIA_TYPE_ENTITY_TYPE) { + preset = { + compositions: [ + { + contentType: { unique: parent.unique }, + compositionType: CompositionTypeModel.INHERITANCE, + }, + ], + }; + } + + this.createScaffold({ parent, preset }); + } } export { UmbMediaTypeWorkspaceContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts index 029aae91fa72..7412ca8b7ae3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts @@ -54,7 +54,15 @@ export class UmbMediaWorkspaceContext contentTypePropertyName: 'mediaType', }); - this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique), null); + this.observe( + this.contentTypeUnique, + (unique) => { + if (unique) { + this.structure.loadType(unique); + } + }, + null, + ); this.propertyViewGuard.fallbackToPermitted(); this.propertyWriteGuard.fallbackToPermitted(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/workspace/member-group/views/info/member-type-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/workspace/member-group/views/info/member-type-workspace-view-info.element.ts index d56c718f6178..5abc7a6fa8e9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/workspace/member-group/views/info/member-type-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/workspace/member-group/views/info/member-type-workspace-view-info.element.ts @@ -1,4 +1,3 @@ -// import { UMB_COMPOSITION_PICKER_MODAL, type UmbCompositionPickerModalData } from '../../../modals/index.js'; import { UMB_MEMBER_GROUP_WORKSPACE_CONTEXT } from '../../member-group-workspace.context-token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts index 426a039c9509..19b0327095ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts @@ -44,7 +44,15 @@ export class UmbMemberWorkspaceContext contentTypePropertyName: 'memberType', }); - this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique), null); + this.observe( + this.contentTypeUnique, + (unique) => { + if (unique) { + this.structure.loadType(unique); + } + }, + null, + ); this.propertyViewGuard.fallbackToPermitted(); this.propertyWriteGuard.fallbackToPermitted(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts index 4f6e625a1cdc..712896f81209 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts @@ -1,4 +1,3 @@ -// import { UMB_COMPOSITION_PICKER_MODAL, type UmbCompositionPickerModalData } from '../../../modals/index.js'; import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../member-workspace.context-token.js'; import { UmbMemberKind, type UmbMemberKindType } from '../../../../utils/index.js'; import { TimeFormatOptions } from './utils.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts index 99f6625512ab..dc74e0f923d2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts @@ -10,6 +10,7 @@ import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/wor import { UmbEntityDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/workspace'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbRepositoryResponseWithAsObservable } from '@umbraco-cms/backoffice/repository'; type EntityType = UmbUserDetailModel; @@ -60,7 +61,11 @@ export class UmbUserWorkspaceContext override async load(unique: string) { const response = await super.load(unique); - this.observe(response.asObservable?.(), (user) => this.onUserStoreChanges(user), 'umbUserStoreObserver'); + this.observe( + (response as UmbRepositoryResponseWithAsObservable).asObservable?.(), + (user) => this.onUserStoreChanges(user), + 'umbUserStoreObserver', + ); if (!this._detailRepository) { throw new Error('Detail repository is missing'); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Create.cs index b8c671710b21..814d1b5be79c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Create.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Create.cs @@ -544,6 +544,35 @@ public async Task Can_Create_Grandchild() }); } + [Test] + public async Task Can_Create_Child_To_Content_Type_With_Composition() + { + var compositionContentType = (await ContentTypeEditingService.CreateAsync(ContentTypeCreateModel("Composition"), Constants.Security.SuperUserKey)).Result!; + var parentContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Parent", + compositions: [new Composition { CompositionType = CompositionType.Composition, Key = compositionContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + var result = await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Child", + compositions: [new Composition { CompositionType = CompositionType.Inheritance, Key = parentContentType.Key }]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + + // Ensure it's actually persisted + var childContentType = await ContentTypeService.GetAsync(result.Result!.Key); + + Assert.IsNotNull(childContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Child", childContentType.Name); + Assert.AreEqual(1, childContentType.ContentTypeComposition.Count()); + Assert.AreEqual(parentContentType.Key, childContentType.ContentTypeComposition.Single().Key); + }); + } + [Test] public async Task Cannot_Be_Both_Parent_And_Composition() { @@ -691,6 +720,32 @@ public async Task Cannot_Mix_Inheritance_And_ParentKey() Assert.AreEqual(ContentTypeOperationStatus.InvalidParent, result.Status); } + [Test] + public async Task Cannot_Have_Same_Key_For_Inheritance_And_Parent() + { + var parentModel = ContentTypeCreateModel("Parent"); + var parent = (await ContentTypeEditingService.CreateAsync(parentModel, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(parent); + + Composition[] composition = + { + new() + { + CompositionType = CompositionType.Inheritance, Key = parent.Key, + } + }; + + var childModel = ContentTypeCreateModel( + "Child", + containerKey: parent.Key, + compositions: composition); + + var result = await ContentTypeEditingService.CreateAsync(childModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentTypeOperationStatus.InvalidParent, result.Status); + } + [Test] public async Task Cannot_Use_As_ParentKey() { @@ -796,7 +851,7 @@ public async Task Cannot_Use_Empty_Name_For_PropertyType_Container() [TestCase(".")] [TestCase("-")] [TestCase("!\"#¤%&/()=)?`")] - [TestCaseSource(nameof(DifferentCapitalizedAlias), new object[] { "System"})] + [TestCaseSource(nameof(DifferentCapitalizedAlias), new object[] { "System" })] public async Task Cannot_Use_Invalid_Alias(string contentTypeAlias) { var createModel = ContentTypeCreateModel("Test", contentTypeAlias); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Update.cs index 00c9cb262308..89f8b126bf6f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentTypeEditingServiceTests.Update.cs @@ -601,6 +601,91 @@ public async Task Can_Update_History_Cleanup() Assert.AreEqual(567, contentType.HistoryCleanup.KeepLatestVersionPerDayForDays); } + [Test] + public async Task Can_Reapply_Compositions_For_Content_Type_With_Children() + { + var compositionContentType = (await ContentTypeEditingService.CreateAsync(ContentTypeCreateModel("Composition"), Constants.Security.SuperUserKey)).Result!; + var parentContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Parent", + compositions: [new Composition { CompositionType = CompositionType.Composition, Key = compositionContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + var childContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Child", + compositions: [new Composition { CompositionType = CompositionType.Inheritance, Key = parentContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + + var updateModel = ContentTypeUpdateModel( + "Parent Updated", + compositions: [new() { CompositionType = CompositionType.Composition, Key = compositionContentType.Key }]); + + var result = await ContentTypeEditingService.UpdateAsync(parentContentType, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Ensure it's actually persisted + parentContentType = await ContentTypeService.GetAsync(parentContentType.Key); + + Assert.IsNotNull(parentContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Parent Updated", parentContentType.Name); + Assert.AreEqual(1, parentContentType.ContentTypeComposition.Count()); + Assert.AreEqual(compositionContentType.Key, parentContentType.ContentTypeComposition.Single().Key); + }); + + childContentType = await ContentTypeService.GetAsync(childContentType.Key); + + Assert.IsNotNull(childContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Child", childContentType.Name); + Assert.AreEqual(1, childContentType.ContentTypeComposition.Count()); + Assert.AreEqual(parentContentType.Key, childContentType.ContentTypeComposition.Single().Key); + }); + } + + [Test] + public async Task Can_Remove_Compositions_For_Content_Type_With_Children() + { + var compositionContentType = (await ContentTypeEditingService.CreateAsync(ContentTypeCreateModel("Composition"), Constants.Security.SuperUserKey)).Result!; + var parentContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Parent", + compositions: [new Composition { CompositionType = CompositionType.Composition, Key = compositionContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + var childContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Child", + compositions: [new Composition { CompositionType = CompositionType.Inheritance, Key = parentContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + + var updateModel = ContentTypeUpdateModel("Parent Updated", compositions: []); + + var result = await ContentTypeEditingService.UpdateAsync(parentContentType, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Ensure it's actually persisted + parentContentType = await ContentTypeService.GetAsync(parentContentType.Key); + + Assert.IsNotNull(parentContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Parent Updated", parentContentType.Name); + Assert.IsEmpty(parentContentType.ContentTypeComposition); + }); + + childContentType = await ContentTypeService.GetAsync(childContentType.Key); + + Assert.IsNotNull(childContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Child", childContentType.Name); + Assert.AreEqual(1, childContentType.ContentTypeComposition.Count()); + Assert.AreEqual(parentContentType.Key, childContentType.ContentTypeComposition.Single().Key); + }); + } + [TestCase(false)] [TestCase(true)] public async Task Cannot_Move_Properties_To_Non_Existing_Containers(bool isElement) @@ -826,4 +911,44 @@ public async Task Cannot_Update_Container_Types_To_Unknown_Types(string containe Assert.IsFalse(result.Success); Assert.AreEqual(ContentTypeOperationStatus.InvalidContainerType, result.Status); } + + + [Test] + public async Task Cannot_Add_Compositions_For_Content_Type_With_Children() + { + var compositionContentType = (await ContentTypeEditingService.CreateAsync(ContentTypeCreateModel("Composition"), Constants.Security.SuperUserKey)).Result!; + var parentContentType = (await ContentTypeEditingService.CreateAsync(ContentTypeCreateModel("Parent"), Constants.Security.SuperUserKey)).Result!; + var childContentType = (await ContentTypeEditingService.CreateAsync( + ContentTypeCreateModel( + "Child", + compositions: [new Composition { CompositionType = CompositionType.Inheritance, Key = parentContentType.Key }]), + Constants.Security.SuperUserKey)).Result!; + + var updateModel = ContentTypeUpdateModel( + "Parent Updated", + compositions: [new() { CompositionType = CompositionType.Composition, Key = compositionContentType.Key }]); + + var result = await ContentTypeEditingService.UpdateAsync(parentContentType, updateModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + + // Ensure nothing was persisted + parentContentType = await ContentTypeService.GetAsync(parentContentType.Key); + + Assert.IsNotNull(parentContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Parent", parentContentType.Name); + Assert.AreEqual(0, parentContentType.ContentTypeComposition.Count()); + }); + + childContentType = await ContentTypeService.GetAsync(childContentType.Key); + + Assert.IsNotNull(childContentType); + Assert.Multiple(() => + { + Assert.AreEqual("Child", childContentType.Name); + Assert.AreEqual(1, childContentType.ContentTypeComposition.Count()); + Assert.AreEqual(parentContentType.Key, childContentType.ContentTypeComposition.Single().Key); + }); + } }