Segmentation: The Backoffice should use the enhanced endpoint to fetch segment options for documents based on GUID (#20340)

* feat: adds new repository for document by id segment options

* chore: mocks up the new endpoint

* feat: all 'null' segments should appear on all languages

* feat: uses new endpoint in content detail workspace base

* feat: maps up the name of the segment

* chore: mock segment data

* feat: adds filter on available segments

* feat: do not alter behavior depending on "undefined" and "null"

* chore: updates mock handler

* feat: ensures that the segments are loaded based on an override method (because they only work for documents) and that they use a generic type (to avoid circular imports)

* feat: refines the segment filter

* chore: updates deprecated model

* feat: treats all culture-less segments as applying to everything

* docs: updates console warn for developers
This commit is contained in:
Jacob Overgaard
2025-10-06 09:09:17 +02:00
committed by GitHub
parent 0daf5ea506
commit c67478874e
13 changed files with 195 additions and 26 deletions

View File

@@ -946,7 +946,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
icon: 'icon-document',
allowedAsRoot: true,
variesByCulture: true,
variesBySegment: false,
variesBySegment: true,
isElement: false,
hasChildren: false,
parent: null,

View File

@@ -769,7 +769,7 @@ export const data: Array<UmbMockDocumentModel> = [
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:31:51.354764',
culture: 'da-dk',
culture: 'da',
segment: null,
name: 'Artikel på Dansk',
createDate: '2023-02-06T15:31:46.876902',
@@ -777,6 +777,39 @@ export const data: Array<UmbMockDocumentModel> = [
id: 'artikel-pa-dansk',
flags: [],
},
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:31:51.354764',
culture: 'da',
segment: 'vip',
name: 'VIP: Artikel på Dansk',
createDate: '2023-02-06T15:31:46.876902',
updateDate: '2023-02-06T15:31:51.354764',
id: 'artikel-pa-dansk-vip',
flags: [],
},
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:31:51.354764',
culture: null,
segment: 'vip-invariant',
name: 'Invariant VIP Segmented Article',
createDate: '2023-02-06T15:31:46.876902',
updateDate: '2023-02-06T15:31:51.354764',
id: 'invariant-vip-segmented-article',
flags: [],
},
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:31:51.354764',
culture: null,
segment: 'generic',
name: 'Generic VIP Segmented Article',
createDate: '2023-02-06T15:31:46.876902',
updateDate: '2023-02-06T15:31:51.354764',
id: 'generic-vip-segmented-article',
flags: [],
},
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:31:51.354764',

View File

@@ -5,6 +5,7 @@ import { UMB_SLUG } from './slug.js';
import type {
CreateDocumentRequestModel,
DefaultReferenceResponseModel,
GetDocumentByIdAvailableSegmentOptionsResponse,
GetDocumentByIdReferencedDescendantsResponse,
PagedIReferenceResponseModel,
UpdateDocumentRequestModel,
@@ -73,6 +74,43 @@ export const detailHandlers = [
return res(ctx.status(200), ctx.json(ReferencedDescendantsResponse));
}),
rest.get(umbracoPath(`${UMB_SLUG}/:id/available-segment-options`), (req, res, ctx) => {
const id = req.params.id as string;
if (!id) return res(ctx.status(400));
const document = umbDocumentMockDb.detail.read(id);
if (!document) return res(ctx.status(404));
const availableSegments = document.variants.filter((v) => !!v.segment).map((v) => v.segment!) ?? [];
const response: GetDocumentByIdAvailableSegmentOptionsResponse = {
total: availableSegments.length,
items: availableSegments.map((alias) => {
// If the segment is generic (i.e. not tied to any culture) we show the segment on all cultures
const isGeneric = alias.includes('generic');
const whichCulturesHaveThisSegment: string[] | undefined = isGeneric
? undefined
: document.variants.filter((v) => v.segment === alias).map((v) => v.culture!);
let availableSegmentOptions: string[] | null = whichCulturesHaveThisSegment ?? null;
if (whichCulturesHaveThisSegment) {
const hasNull = whichCulturesHaveThisSegment.some((c) => c === null);
if (hasNull) {
availableSegmentOptions = null;
}
}
return {
alias,
name: `Segment: ${alias}`,
cultures: availableSegmentOptions,
};
}),
};
return res(ctx.status(200), ctx.json(response));
}),
rest.put(umbracoPath(`${UMB_SLUG}/:id/validate`, 'v1.1'), (_req, res, ctx) => {
const id = _req.params.id as string;
if (!id) return res(ctx.status(400));

View File

@@ -32,7 +32,6 @@ import {
UmbPropertyValuePresetVariantBuilderController,
UmbVariantPropertyGuardManager,
} from '@umbraco-cms/backoffice/property';
import { UmbSegmentCollectionRepository } from '@umbraco-cms/backoffice/segment';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import {
@@ -44,7 +43,7 @@ import {
} from '@umbraco-cms/backoffice/validation';
import type { ClassConstructor } from '@umbraco-cms/backoffice/extension-api';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbContentTypeModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbContentTypeDetailModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbDetailRepository, UmbDetailRepositoryConstructor } from '@umbraco-cms/backoffice/repository';
import type {
@@ -56,11 +55,11 @@ import type { UmbEntityVariantModel, UmbEntityVariantOptionModel } from '@umbrac
import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
import type { UmbPropertyTypePresetModel, UmbPropertyTypePresetModelTypeModel } from '@umbraco-cms/backoffice/property';
import type { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbSegmentCollectionItemModel } from '@umbraco-cms/backoffice/segment';
import type { UmbSegmentModel } from '@umbraco-cms/backoffice/segment';
export interface UmbContentDetailWorkspaceContextArgs<
DetailModelType extends UmbContentDetailModel<VariantModelType>,
ContentTypeDetailModelType extends UmbContentTypeModel = UmbContentTypeModel,
ContentTypeDetailModelType extends UmbContentTypeDetailModel = UmbContentTypeDetailModel,
VariantModelType extends UmbEntityVariantModel = DetailModelType extends { variants: UmbEntityVariantModel[] }
? DetailModelType['variants'][0]
: never,
@@ -93,7 +92,7 @@ export interface UmbContentDetailWorkspaceContextArgs<
export abstract class UmbContentDetailWorkspaceContextBase<
DetailModelType extends UmbContentDetailModel<VariantModelType>,
DetailRepositoryType extends UmbDetailRepository<DetailModelType> = UmbDetailRepository<DetailModelType>,
ContentTypeDetailModelType extends UmbContentTypeModel = UmbContentTypeModel,
ContentTypeDetailModelType extends UmbContentTypeDetailModel = UmbContentTypeDetailModel,
VariantModelType extends UmbEntityVariantModel = DetailModelType extends { variants: UmbEntityVariantModel[] }
? DetailModelType['variants'][0]
: never,
@@ -156,9 +155,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
*/
public readonly languages = this.#languages.asObservable();
#segmentRepository = new UmbSegmentCollectionRepository(this);
#segments = new UmbArrayState<UmbSegmentCollectionItemModel>([], (x) => x.unique);
protected readonly _segments = this.#segments.asObservable();
protected readonly _segments = new UmbArrayState<UmbSegmentModel>([], (x) => x.alias);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -226,7 +223,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
);
this.variantOptions = mergeObservables(
[this.variesByCulture, this.variesBySegment, this.variants, this.languages, this._segments],
[this.variesByCulture, this.variesBySegment, this.variants, this.languages, this._segments.asObservable()],
([variesByCulture, variesBySegment, variants, languages, segments]) => {
if ((variesByCulture || variesBySegment) === undefined) {
return [];
@@ -270,14 +267,16 @@ export abstract class UmbContentDetailWorkspaceContextBase<
unique: new UmbVariantId().toString(),
} as VariantOptionModelType;
const segmentsForInvariantCulture = segments.map((segment) => {
// Find all segments that are either generic (undefined) or invariant (null)
const availableSegments = segments.filter((s) => !s.cultures);
const segmentsForInvariantCulture = availableSegments.map((segment) => {
return {
variant: variants.find((x) => x.culture === null && x.segment === segment.unique),
variant: variants.find((x) => x.culture === null && x.segment === segment.alias),
language: languages.find((x) => x.isDefault),
segmentInfo: segment,
culture: null,
segment: segment.unique,
unique: new UmbVariantId(null, segment.unique).toString(),
segment: segment.alias,
unique: new UmbVariantId(null, segment.alias).toString(),
} as VariantOptionModelType;
});
@@ -295,14 +294,16 @@ export abstract class UmbContentDetailWorkspaceContextBase<
unique: new UmbVariantId(language.unique).toString(),
} as VariantOptionModelType;
const segmentsForCulture = segments.map((segment) => {
// Find all segments that are either generic (undefined) or that contains this culture
const availableSegments = segments.filter((s) => !s.cultures || s.cultures.includes(language.unique));
const segmentsForCulture = availableSegments.map((segment) => {
return {
variant: variants.find((x) => x.culture === language.unique && x.segment === segment.unique),
variant: variants.find((x) => x.culture === language.unique && x.segment === segment.alias),
language,
segmentInfo: segment,
culture: language.unique,
segment: segment.unique,
unique: new UmbVariantId(language.unique, segment.unique).toString(),
segment: segment.alias,
unique: new UmbVariantId(language.unique, segment.alias).toString(),
} as VariantOptionModelType;
});
@@ -367,6 +368,11 @@ export abstract class UmbContentDetailWorkspaceContextBase<
(varies) => {
this._data.setVariesBySegment(varies);
this.#variesBySegment = varies;
if (varies) {
this.loadSegments();
} else {
this._segments.setValue([]);
}
},
null,
);
@@ -379,7 +385,6 @@ export abstract class UmbContentDetailWorkspaceContextBase<
);
this.loadLanguages();
this.#loadSegments();
}
public async loadLanguages() {
@@ -388,9 +393,11 @@ export abstract class UmbContentDetailWorkspaceContextBase<
this.#languages.setValue(data?.items ?? []);
}
async #loadSegments() {
const { data } = await this.#segmentRepository.requestCollection({});
this.#segments.setValue(data?.items ?? []);
protected async loadSegments() {
console.warn(
`UmbContentDetailWorkspaceContextBase: Segments are not implemented in the workspace context for "${this.getEntityType()}" types.`,
);
this._segments.setValue([]);
}
protected override async _scaffoldProcessData(data: DetailModelType): Promise<DetailModelType> {

View File

@@ -34,9 +34,8 @@ export interface UmbEntityVariantOptionModel<VariantType extends UmbEntityVarian
language: UmbLanguageDetailModel;
segmentInfo?: {
alias: string;
entityType: string;
name: string;
unique: string;
cultures?: string[] | null;
};
/**
* The unique identifier is a VariantId string.

View File

@@ -1,4 +1,5 @@
export { UmbDocumentDetailRepository } from './detail/index.js';
export { UmbDocumentPreviewRepository } from './preview/index.js';
export { UmbDocumentSegmentRepository } from './segment/index.js';
export * from './constants.js';

View File

@@ -0,0 +1,41 @@
import type { UmbDocumentSegmentFilterModel } from './types.js';
import { UmbRepositoryBase, type UmbRepositoryResponse } from '@umbraco-cms/backoffice/repository';
import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';
import type { UmbSegmentModel, UmbSegmentResponseModel } from '@umbraco-cms/backoffice/segment';
export class UmbDocumentSegmentRepository extends UmbRepositoryBase {
/**
* Get available segment options for a document by its ID.
* @param {string} unique The unique identifier of the document.
* @param {UmbDocumentSegmentFilterModel} filter The filter options to apply.
* @returns A promise that resolves with the available segment options.
*/
async getDocumentByIdSegmentOptions(
unique: string,
filter: UmbDocumentSegmentFilterModel,
): Promise<UmbRepositoryResponse<UmbSegmentResponseModel>> {
const { data, error } = await tryExecute(
this,
// eslint-disable-next-line @typescript-eslint/no-deprecated
DocumentService.getDocumentByIdAvailableSegmentOptions({ path: { id: unique }, query: filter }),
);
if (data) {
const items = data.items.map((item) => {
const model: UmbSegmentModel = {
alias: item.alias,
name: item.name,
// eslint-disable-next-line @typescript-eslint/no-deprecated
cultures: item.cultures,
};
return model;
});
return { data: { items, total: data.total } };
}
return { error };
}
}

View File

@@ -0,0 +1 @@
export * from './document-segment.repository.js';

View File

@@ -0,0 +1,4 @@
export interface UmbDocumentSegmentFilterModel {
skip?: number;
take?: number;
}

View File

@@ -0,0 +1 @@
export type * from './segment/types.js';

View File

@@ -16,6 +16,7 @@ export type * from './item/types.js';
export type * from './modals/types.js';
export type * from './publishing/types.js';
export type * from './recycle-bin/types.js';
export type * from './repository/types.js';
export type * from './tree/types.js';
export type * from './url/types.js';
export type * from './user-permissions/types.js';

View File

@@ -1,7 +1,7 @@
import { UmbDocumentTypeDetailRepository } from '../../document-types/repository/detail/document-type-detail.repository.js';
import { UmbDocumentPropertyDatasetContext } from '../property-dataset-context/document-property-dataset.context.js';
import type { UmbDocumentDetailRepository } from '../repository/index.js';
import { UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS } from '../repository/index.js';
import { UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS, UmbDocumentSegmentRepository } from '../repository/index.js';
import type { UmbDocumentDetailModel, UmbDocumentVariantModel } from '../types.js';
import {
UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN,
@@ -63,6 +63,7 @@ export class UmbDocumentWorkspaceContext
readonly templateId = this._data.createObservablePartOfCurrent((data) => data?.template?.unique || null);
#isTrashedContext = new UmbIsTrashedEntityContext(this);
#documentSegmentRepository = new UmbDocumentSegmentRepository(this);
constructor(host: UmbControllerHost) {
super(host, {
@@ -207,6 +208,24 @@ export class UmbDocumentWorkspaceContext
return response;
}
protected override async loadSegments(): Promise<void> {
this.observe(
this.unique,
async (unique) => {
if (!unique) {
this._segments.setValue([]);
return;
}
const { data } = await this.#documentSegmentRepository.getDocumentByIdSegmentOptions(unique, {
skip: 0,
take: 9999,
});
this._segments.setValue(data?.items ?? []);
},
'_loadSegmentsUnique',
);
}
async create(parent: UmbEntityModel, documentTypeUnique: string, blueprintUnique?: string) {
if (blueprintUnique) {
const blueprintRepository = new UmbDocumentBlueprintDetailRepository(this);

View File

@@ -1,2 +1,26 @@
export type * from './entity.js';
export type * from './collection/types.js';
export interface UmbSegmentModel {
/**
* The unique alias of the segment.
*/
alias: string;
/**
* The name of the segment used for display purposes.
*/
name: string;
/**
* An optional list of culture codes that the segment applies to.
* If null, the segment applies to the invariant culture.
* If undefined, the segment is considered generic and applies to all cultures.
*/
cultures?: Array<string> | null;
}
export interface UmbSegmentResponseModel {
items: Array<UmbSegmentModel>;
total: number;
}