Skip to content

Content Type inheritance #19034

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2802bfb
content type nesting
nielslyngsoe Apr 14, 2025
12f1685
TODOs
nielslyngsoe Apr 14, 2025
54c7e50
repository detail manager
nielslyngsoe Apr 15, 2025
40bf0d0
todo
nielslyngsoe Apr 15, 2025
eb1fcb2
implement unlimited compositions
nielslyngsoe Apr 15, 2025
3140b92
a little refactor
nielslyngsoe Apr 15, 2025
fc294cf
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 15, 2025
2a21b96
warn
nielslyngsoe Apr 15, 2025
990682d
clear state
nielslyngsoe Apr 15, 2025
cb54166
refactor to use unique
nielslyngsoe Apr 15, 2025
9a45194
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 15, 2025
1f809c0
note
nielslyngsoe Apr 15, 2025
1fccfc1
code corrections to match with types
nielslyngsoe Apr 15, 2025
d62a05f
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 16, 2025
ce0a719
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 16, 2025
6ab4430
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 22, 2025
5d9174a
unique type for Array State
nielslyngsoe Apr 22, 2025
0738ea0
implement usedForInheritance and editedTypes for Structure Manager an…
nielslyngsoe Apr 22, 2025
1a773c7
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
madsrasmussen Apr 23, 2025
5f17a13
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
madsrasmussen Apr 23, 2025
964e9cc
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
madsrasmussen Apr 23, 2025
7da6258
rename method
madsrasmussen Apr 23, 2025
c69ad19
Update repository-details.manager.ts
madsrasmussen Apr 23, 2025
9cff888
avoid type casting
madsrasmussen Apr 23, 2025
21a791d
align naming
madsrasmussen Apr 23, 2025
39de990
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 23, 2025
b011e57
do not await
nielslyngsoe Apr 23, 2025
21b0faa
fix race condition when switching document types fast
nielslyngsoe Apr 23, 2025
e813ec2
remove test prop
nielslyngsoe Apr 23, 2025
6a62cbc
Update manifests.ts
madsrasmussen Apr 23, 2025
1ebd2ba
Merge branch 'v16/feature/content-type-inheritance' of https://github…
madsrasmussen Apr 23, 2025
d5a5e32
UmbMediaTypeWorkspaceContext Routes for inheritance
nielslyngsoe Apr 23, 2025
a36cdf2
import
nielslyngsoe Apr 23, 2025
ef2adb1
Merge branch 'v16/dev' into v16/feature/content-type-inheritance
nielslyngsoe Apr 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,12 +39,27 @@ protected TContentTypeEditingModel MapContentTypeEditingModel<
VariesByCulture = viewModel.VariesByCulture,
VariesBySegment = viewModel.VariesBySegment,
Containers = MapContainers<TPropertyTypeContainerEditingModel>(viewModel.Containers),
Properties = MapProperties<TPropertyTypeEditingModel>(viewModel.Properties)
Properties = MapProperties<TPropertyTypeEditingModel>(viewModel.Properties),
};

return editingModel;
}

protected Guid? CalculateCreateContainerKey(ReferenceByIdModel? parent, IDictionary<Guid, ContentTypeViewModels.CompositionType> 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<T>(ContentTypeAvailableCompositionsResult compositionResult)
where T : ContentTypeViewModels.AvailableContentTypeCompositionResponseModelBase, new()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Guid, ViewModels.ContentType.CompositionType> compositionTypesByKey = CompositionTypesByKey(requestModel.Compositions);
createModel.Compositions = MapCompositions(compositionTypesByKey);
createModel.ContainerKey = CalculateCreateContainerKey(requestModel.Parent, compositionTypesByKey);

return createModel;
}
Expand All @@ -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;
}
Expand All @@ -72,8 +74,8 @@ private IEnumerable<ContentTypeSort> MapAllowedContentTypes(IEnumerable<Document
.DistinctBy(t => t.DocumentType.Id)
.ToDictionary(t => t.DocumentType.Id, t => t.SortOrder));

private IEnumerable<Composition> MapCompositions(IEnumerable<DocumentTypeComposition> documentTypeCompositions)
=> MapCompositions(documentTypeCompositions
private IDictionary<Guid, ViewModels.ContentType.CompositionType> CompositionTypesByKey(IEnumerable<DocumentTypeComposition> documentTypeCompositions)
=> documentTypeCompositions
.DistinctBy(c => c.DocumentType.Id)
.ToDictionary(c => c.DocumentType.Id, c => c.CompositionType));
.ToDictionary(c => c.DocumentType.Id, c => c.CompositionType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Guid, ViewModels.ContentType.CompositionType> compositionTypesByKey = CompositionTypesByKey(requestModel.Compositions);
createModel.Compositions = MapCompositions(compositionTypesByKey);
createModel.ContainerKey = CalculateCreateContainerKey(requestModel.Parent, compositionTypesByKey);

return createModel;
}

Expand All @@ -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;
Expand All @@ -56,8 +59,8 @@ private IEnumerable<ContentTypeSort> MapAllowedContentTypes(IEnumerable<MediaTyp
.DistinctBy(t => t.MediaType.Id)
.ToDictionary(t => t.MediaType.Id, t => t.SortOrder));

private IEnumerable<Composition> MapCompositions(IEnumerable<MediaTypeComposition> documentTypeCompositions)
=> MapCompositions(documentTypeCompositions
private IDictionary<Guid, ViewModels.ContentType.CompositionType> CompositionTypesByKey(IEnumerable<MediaTypeComposition> documentTypeCompositions)
=> documentTypeCompositions
.DistinctBy(c => c.MediaType.Id)
.ToDictionary(c => c.MediaType.Id, c => c.CompositionType));
.ToDictionary(c => c.MediaType.Id, c => c.CompositionType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,24 +83,9 @@ protected static CompositionType CalculateCompositionType(int contentTypeParentI
? CompositionType.Inheritance
: CompositionType.Composition;

protected static IEnumerable<T> MapNestedCompositions<T>(IEnumerable<IContentTypeComposition> directCompositions, int contentTypeParentId, Func<ReferenceByIdModel, CompositionType, T> contentTypeCompositionFactory)
{
var allCompositions = new List<T>();

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<T> MapCompositions<T>(IEnumerable<IContentTypeComposition> directCompositions, int contentTypeParentId, Func<ReferenceByIdModel, CompositionType, T> contentTypeCompositionFactory)
=> directCompositions
.Select(composition => contentTypeCompositionFactory(
new ReferenceByIdModel(composition.Key),
CalculateCompositionType(contentTypeParentId, composition))).ToArray();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentTypeSort>();
target.Compositions = MapNestedCompositions(
target.Compositions = MapCompositions(
source.ContentTypeComposition,
source.ParentId,
(referenceByIdModel, compositionType) => new DocumentTypeComposition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaTypeSort>();
target.Compositions = MapNestedCompositions(
target.Compositions = MapCompositions(
source.ContentTypeComposition,
source.ParentId,
(referenceByIdModel, compositionType) => new MediaTypeComposition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends UmbDeepState<T[]> {
readonly getUniqueMethod: (entry: T) => unknown;
export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
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;
}
Expand Down Expand Up @@ -61,6 +61,27 @@ export class UmbArrayState<T> extends UmbDeepState<T[]> {
}
}

/**
* @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 <caption>Example check for key to exist.</caption>
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export class UmbBlockElementManager<LayoutDataType extends UmbBlockLayoutBaseMod
this.propertyViewGuard.fallbackToPermitted();
this.propertyWriteGuard.fallbackToPermitted();

this.observe(this.contentTypeId, (id) => 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}')]`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@

@state()
private _selection: Array<string> = [];

@state()
private _usedForInheritance: Array<string> = [];

override connectedCallback() {
super.connectedCallback();

Expand All @@ -48,6 +52,7 @@
}

this._selection = this.data?.selection ?? [];
this._usedForInheritance = this.data?.usedForInheritance ?? [];

Check warning on line 55 in src/Umbraco.Web.UI.Client/src/packages/content/content-type/modals/composition-picker/composition-picker-modal.element.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (v16/dev)

❌ New issue: Complex Method

UmbCompositionPickerModalElement.connectedCallback has a cyclomatic complexity of 9, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
this.modalContext?.setValue({ selection: this._selection });

const isNew = this.data!.isNew;
Expand Down Expand Up @@ -206,16 +211,20 @@
return repeat(
compositionsList,
(compositions) => compositions.unique,
(compositions) => html`
<uui-menu-item
label=${this.localize.string(compositions.name)}
selectable
@selected=${() => this.#onSelectionAdd(compositions.unique)}
@deselected=${() => this.#onSelectionRemove(compositions.unique)}
?selected=${this._selection.find((unique) => unique === compositions.unique)}>
<umb-icon name=${compositions.icon} slot="icon"></umb-icon>
</uui-menu-item>
`,
(compositions) => {
const usedForInheritance = this._usedForInheritance.includes(compositions.unique);
return html`
<uui-menu-item
label=${this.localize.string(compositions.name)}
?selectable=${!usedForInheritance}
?disabled=${usedForInheritance}
@selected=${() => this.#onSelectionAdd(compositions.unique)}
@deselected=${() => this.#onSelectionRemove(compositions.unique)}
?selected=${this._selection.find((unique) => unique === compositions.unique)}>
<umb-icon name=${compositions.icon} slot="icon"></umb-icon>
</uui-menu-item>
`;
},
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export interface UmbCompositionPickerModalData {
compositionRepositoryAlias: string;
selection: Array<string>;
usedForInheritance: Array<string>;
unique: string | null;
isElement: boolean;
currentPropertyAliases: Array<string>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,7 +24,7 @@ export class UmbContentTypePropertyStructureHelper<T extends UmbContentTypeModel
#containerId?: string | null;

// State which holds all the properties of the current container, this is a composition of all properties from the containers that matches our target [NL]
#propertyStructure = new UmbArrayState<UmbPropertyTypeModel>([], (x) => x.id);
#propertyStructure = new UmbArrayState<UmbPropertyTypeModel>([], (x) => x.unique);
readonly propertyStructure = this.#propertyStructure.asObservable();

constructor(host: UmbControllerHost) {
Expand Down Expand Up @@ -164,18 +164,20 @@ export class UmbContentTypePropertyStructureHelper<T extends UmbContentTypeModel
);
}

async isOwnerProperty(propertyId: UmbPropertyTypeId) {
async isOwnerProperty(propertyUnique: UmbPropertyTypeUnique) {
await this.#init;
if (!this.#structure) return;

return this.#structure.ownerContentTypeObservablePart((x) => 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':
Expand All @@ -194,11 +196,11 @@ export class UmbContentTypePropertyStructureHelper<T extends UmbContentTypeModel
return true;
}

async removeProperty(propertyId: UmbPropertyTypeId) {
async removeProperty(propertyUnique: UmbPropertyTypeUnique) {
await this.#init;
if (!this.#structure) return false;

await this.#structure.removeProperty(null, propertyId);
await this.#structure.removeProperty(null, propertyUnique);
return true;
}

Expand Down
Loading
Loading