Introduce Variant Context (#19334)

* add variant context

* set culture in variant context when changing app language

* set variant context when swithing variant in a workspace

* Update content-detail-workspace-base.ts

* clean up

* remove from split view manager

* Update property-dataset-base-context.ts

* change name to fallbackCulture

* simplify

* make all methods async

* Update current-user-action.extension.ts

* remove culture and segment state

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
This commit is contained in:
Mads Rasmussen
2025-05-16 12:30:29 +02:00
committed by GitHub
parent 28fc81756a
commit e959850a66
11 changed files with 209 additions and 86 deletions

View File

@@ -12,7 +12,7 @@ import {
createObservablePart,
mergeObservables,
} from '@umbraco-cms/backoffice/observable-api';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbVariantContext, UmbVariantId } from '@umbraco-cms/backoffice/variant';
import type { UmbContentTypeModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
@@ -47,6 +47,8 @@ export abstract class UmbElementPropertyDatasetContext<
protected _readOnly = new UmbBooleanState(false);
public readOnly = this._readOnly.asObservable();
#variantContext = new UmbVariantContext(this);
getEntityType(): string {
return this._dataOwner.getEntityType();
}
@@ -64,6 +66,7 @@ export abstract class UmbElementPropertyDatasetContext<
super(host, UMB_PROPERTY_DATASET_CONTEXT);
this._dataOwner = dataOwner;
this.#variantId = variantId ?? UmbVariantId.CreateInvariant();
this.#variantContext.setVariantId(this.#variantId);
this.#propertyVariantIdPromise = new Promise((resolve) => {
this.#propertyVariantIdPromiseResolver = resolve as any;

View File

@@ -5,7 +5,7 @@ import type { UmbNameablePropertyDatasetContext } from './nameable-property-data
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbArrayState, UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbVariantContext, UmbVariantId } from '@umbraco-cms/backoffice/variant';
/**
* A base property dataset context implementation.
@@ -32,26 +32,34 @@ export class UmbPropertyDatasetContextBase
#readOnly = new UmbBooleanState(false);
public readOnly = this.#readOnly.asObservable();
#variantId: UmbVariantId = UmbVariantId.CreateInvariant();
#variantContext = new UmbVariantContext(this);
getEntityType() {
return this._entityType;
}
getUnique() {
return this._unique;
}
getName() {
return this.#name.getValue();
}
setName(name: string | undefined) {
this.#name.setValue(name);
}
getVariantId() {
return UmbVariantId.CreateInvariant();
return this.#variantId;
}
// variant id for a specific property?
constructor(host: UmbControllerHost) {
// The controller alias, is a very generic name cause we want only one of these for this controller host.
super(host, UMB_PROPERTY_DATASET_CONTEXT);
this.#variantContext.setVariantId(this.getVariantId());
}
/**

View File

@@ -0,0 +1 @@
export * from './context/constants.js';

View File

@@ -0,0 +1 @@
export { UMB_VARIANT_CONTEXT } from './variant.context.token.js';

View File

@@ -0,0 +1 @@
export { UmbVariantContext } from './variant.context.js';

View File

@@ -0,0 +1,4 @@
import type { UmbVariantContext } from './variant.context.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_VARIANT_CONTEXT = new UmbContextToken<UmbVariantContext>('UmbVariantContext');

View File

@@ -0,0 +1,110 @@
import { UmbVariantId } from '../variant-id.class.js';
import { UMB_VARIANT_CONTEXT } from './variant.context.token.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbClassState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
/**
* A context for the current variant state.
* @class UmbVariantContext
* @augments {UmbContextBase}
* @implements {UmbVariantContext}
*/
export class UmbVariantContext extends UmbContextBase {
#variantId = new UmbClassState<UmbVariantId | undefined>(undefined);
public readonly variantId = this.#variantId.asObservable();
public readonly culture = this.#variantId.asObservablePart((x) => x?.culture);
public readonly segment = this.#variantId.asObservablePart((x) => x?.segment);
#fallbackCulture = new UmbStringState<string | null | undefined>(undefined);
public fallbackCulture = this.#fallbackCulture.asObservable();
constructor(host: UmbControllerHost) {
super(host, UMB_VARIANT_CONTEXT);
this.consumeContext(UMB_VARIANT_CONTEXT, (context) => {
this.observe(
context?.fallbackCulture,
(fallbackCulture) => {
if (!fallbackCulture) return;
this.setFallbackCulture(fallbackCulture);
},
'observeFallbackCulture',
);
}).skipHost();
}
/**
* Sets the variant id state
* @param {UmbVariantId | undefined} variantId - The variant to set
* @memberof UmbVariantContext
*/
async setVariantId(variantId: UmbVariantId | undefined): Promise<void> {
this.#variantId.setValue(variantId);
}
/**
* Gets variant state
* @returns {Promise<UmbVariantId | undefined>} - The variant state
* @memberof UmbVariantContext
*/
async getVariantId(): Promise<UmbVariantId | undefined> {
return this.#variantId.getValue();
}
/**
* Gets the culture state
* @returns {(Promise<string | null | undefined>)} - The culture state
* @memberof UmbVariantContext
*/
async getCulture(): Promise<string | null | undefined> {
return this.#variantId.getValue()?.culture;
}
/**
* Sets the variant culture state
* @param {string | undefined} culture - The culture to set
* @memberof UmbVariantContext
*/
async setCulture(culture: string | null): Promise<void> {
const variantId = new UmbVariantId(culture, this.#variantId.getValue()?.segment);
this.#variantId.setValue(variantId);
}
/**
* Gets the variant segment state
* @returns {(Promise<string | null | undefined>)} - The segment state
* @memberof UmbVariantContext
*/
async getSegment(): Promise<string | null | undefined> {
return this.#variantId.getValue()?.segment;
}
/**
* Sets the variant segment state
* @param {string | undefined} segment - The segment to set
* @memberof UmbVariantContext
*/
async setSegment(segment: string | null): Promise<void> {
const variantId = new UmbVariantId(this.#variantId.getValue()?.culture, segment);
this.#variantId.setValue(variantId);
}
/**
* Gets the fallback culture state
* @returns {(Promise<string | null | undefined>)} - The fallback culture state
* @memberof UmbVariantContext
*/
async getFallbackCulture(): Promise<string | null | undefined> {
return this.#fallbackCulture.getValue();
}
/**
* Sets the fallback culture state
* @param {string | undefined} culture - The fallback culture to set
* @memberof UmbVariantContext
*/
async setFallbackCulture(culture: string | null): Promise<void> {
this.#fallbackCulture.setValue(culture);
}
}

View File

@@ -1,3 +1,5 @@
export * from './constants.js';
export * from './context/index.js';
export * from './variant-id.class.js';
export * from './variant-object-compare.function.js';

View File

@@ -7,7 +7,7 @@ import type {
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbVariantContext, UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbBooleanState, type Observable } from '@umbraco-cms/backoffice/observable-api';
/**
@@ -23,6 +23,7 @@ export class UmbInvariantWorkspacePropertyDatasetContext<
public readOnly = this.#readOnly.asObservable();
#workspace: WorkspaceType;
#variantContext = new UmbVariantContext(this);
name;
@@ -49,6 +50,7 @@ export class UmbInvariantWorkspacePropertyDatasetContext<
this.#workspace = workspace;
this.name = this.#workspace.name;
this.#variantContext.setVariantId(this.getVariantId());
}
get properties(): Observable<Array<UmbPropertyValueData> | undefined> {

View File

@@ -3,10 +3,8 @@ import type { UmbDocumentItemModel } from './types.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language';
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbBooleanState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { type UmbVariantContext, UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/variant';
type UmbDocumentItemDataResolverModel = Omit<UmbDocumentItemModel, 'parent' | 'hasChildren'>;
@@ -17,51 +15,54 @@ type UmbDocumentItemDataResolverModel = Omit<UmbDocumentItemModel, 'parent' | 'h
* @augments {UmbControllerBase}
*/
export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataResolverModel> extends UmbControllerBase {
#defaultCulture?: string;
#appCulture?: string;
#propertyDataSetCulture?: UmbVariantId;
#data?: DataType | undefined;
#data = new UmbObjectState<DataType | undefined>(undefined);
#init: Promise<unknown>;
#unique = new UmbStringState(undefined);
public readonly unique = this.#unique.asObservable();
public readonly unique = this.#data.asObservablePart((x) => x?.unique);
public readonly icon = this.#data.asObservablePart((x) => x?.documentType.icon);
public readonly isTrashed = this.#data.asObservablePart((x) => x?.isTrashed);
#name = new UmbStringState(undefined);
public readonly name = this.#name.asObservable();
#icon = new UmbStringState(undefined);
public readonly icon = this.#icon.asObservable();
#state = new UmbStringState(undefined);
public readonly state = this.#state.asObservable();
#isTrashed = new UmbBooleanState(undefined);
public readonly isTrashed = this.#isTrashed.asObservable();
#isDraft = new UmbBooleanState(undefined);
public readonly isDraft = this.#isDraft.asObservable();
#variantContext?: UmbVariantContext;
#variantId?: UmbVariantId;
#fallbackCulture?: string | null;
constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => {
this.#propertyDataSetCulture = context?.getVariantId();
this.#setVariantAwareValues();
this.consumeContext(UMB_VARIANT_CONTEXT, (context) => {
this.#variantContext = context;
this.#observeVariantContext();
});
}
// We do not depend on this context because we know is it only available in some cases
this.#init = this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (context) => {
this.observe(context?.appLanguageCulture, (culture) => {
this.#appCulture = culture;
#observeVariantContext() {
this.observe(
this.#variantContext?.variantId,
(variantId) => {
if (variantId === undefined) return;
this.#variantId = variantId;
this.#setVariantAwareValues();
});
},
'umbObserveVariantId',
);
this.observe(context?.appDefaultLanguage, (value) => {
this.#defaultCulture = value?.unique;
this.observe(
this.#variantContext?.fallbackCulture,
(fallbackCulture) => {
if (fallbackCulture === undefined) return;
this.#fallbackCulture = fallbackCulture;
this.#setVariantAwareValues();
});
}).asPromise();
},
'umbObserveFallbackCulture',
);
}
/**
@@ -70,7 +71,7 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
getData(): DataType | undefined {
return this.#data;
return this.#data.getValue();
}
/**
@@ -79,20 +80,7 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
setData(data: DataType | undefined) {
this.#data = data;
if (!this.#data) {
this.#unique.setValue(undefined);
this.#name.setValue(undefined);
this.#icon.setValue(undefined);
this.#isTrashed.setValue(undefined);
this.#isDraft.setValue(undefined);
return;
}
this.#unique.setValue(this.#data.unique);
this.#icon.setValue(this.#data.documentType.icon);
this.#isTrashed.setValue(this.#data.isTrashed);
this.#data.setValue(data);
this.#setVariantAwareValues();
}
@@ -102,8 +90,7 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getUnique(): Promise<string | undefined> {
await this.#init;
return this.#unique.getValue();
return this.#data?.getValue()?.unique;
}
/**
@@ -112,7 +99,6 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getName(): Promise<string> {
await this.#init;
return this.#name.getValue() || '';
}
@@ -122,8 +108,7 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getIcon(): Promise<string | undefined> {
await this.#init;
return this.#data?.documentType.icon;
return this.#data?.getValue()?.documentType.icon;
}
/**
@@ -132,8 +117,8 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getState(): Promise<DocumentVariantStateModel | null | undefined> {
await this.#init;
return this.#getCurrentVariant()?.state;
const variant = await this.#getCurrentVariant();
return variant?.state;
}
/**
@@ -142,7 +127,6 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getIsDraft(): Promise<boolean> {
await this.#init;
return this.#isDraft.getValue() ?? false;
}
@@ -152,52 +136,56 @@ export class UmbDocumentItemDataResolver<DataType extends UmbDocumentItemDataRes
* @memberof UmbDocumentItemDataResolver
*/
async getIsTrashed(): Promise<boolean> {
await this.#init;
return this.#data?.isTrashed ?? false;
return this.#data?.getValue()?.isTrashed ?? false;
}
#setVariantAwareValues() {
if (!this.#variantContext) return;
if (!this.#variantId) return;
if (!this.#fallbackCulture) return;
if (!this.#data) return;
this.#setName();
this.#setIsDraft();
this.#setState();
}
#setName() {
const variant = this.#getCurrentVariant();
async #setName() {
const variant = await this.#getCurrentVariant();
if (variant) {
this.#name.setValue(variant.name);
return;
}
const fallbackName = this.#findVariant(this.#defaultCulture)?.name;
const fallbackName = this.#findVariant(this.#fallbackCulture!)?.name;
this.#name.setValue(`(${fallbackName})`);
}
#setIsDraft() {
const variant = this.#getCurrentVariant();
async #setIsDraft() {
const variant = await this.#getCurrentVariant();
const isDraft = variant?.state === UmbDocumentVariantState.DRAFT || false;
this.#isDraft.setValue(isDraft);
}
#setState() {
const variant = this.#getCurrentVariant();
async #setState() {
const variant = await this.#getCurrentVariant();
const state = variant?.state || UmbDocumentVariantState.NOT_CREATED;
this.#state.setValue(state);
}
#findVariant(culture: string | undefined) {
return this.#data?.variants.find((x) => x.culture === culture);
return this.getData()?.variants.find((x) => x.culture === culture);
}
#getCurrentVariant() {
async #getCurrentVariant() {
if (this.#isInvariant()) {
return this.#data?.variants?.[0];
return this.getData()?.variants?.[0];
}
const culture = this.#propertyDataSetCulture?.culture || this.#appCulture;
return this.#findVariant(culture);
const culture = this.#variantId?.culture;
return this.#findVariant(culture!);
}
#isInvariant() {
return this.#data?.variants?.[0]?.culture === null;
return this.getData()?.variants?.[0]?.culture === null;
}
}

View File

@@ -8,6 +8,7 @@ import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import { UmbReadOnlyStateManager } from '@umbraco-cms/backoffice/utils';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { UmbVariantContext } from '@umbraco-cms/backoffice/variant';
// TODO: Make a store for the App Languages.
// TODO: Implement default language end-point, in progress at backend team, so we can avoid getting all languages.
@@ -51,6 +52,8 @@ export class UmbAppLanguageContext extends UmbContextBase implements UmbApi {
#readOnlyStateIdentifier = 'UMB_LANGUAGE_PERMISSION_';
#localStorageKey = 'umb:appLanguage';
#variantContext = new UmbVariantContext(this);
constructor(host: UmbControllerHost) {
super(host, UMB_APP_LANGUAGE_CONTEXT);
@@ -96,6 +99,9 @@ export class UmbAppLanguageContext extends UmbContextBase implements UmbApi {
// set the new language
this.#appLanguage.update(language);
// Update the variant context with the new language
this.#variantContext.setCulture(language.unique);
// store the new language in local storage
localStorage.setItem(this.#localStorageKey, language?.unique);
@@ -119,23 +125,20 @@ export class UmbAppLanguageContext extends UmbContextBase implements UmbApi {
}
#initAppLanguage() {
// get the selected language from local storage
const uniqueFromLocalStorage = localStorage.getItem(this.#localStorageKey);
if (uniqueFromLocalStorage) {
const language = this.#findLanguage(uniqueFromLocalStorage);
if (language) {
this.setLanguage(language.unique);
return;
}
}
const defaultLanguage = this.#languages.getValue().find((x) => x.isDefault);
const defaultLanguageUnique = this.#languages.getValue().find((x) => x.isDefault)?.unique;
// TODO: do we always have a default language?
// do we always get the default language on the first request, or could it be on page 2?
// in that case do we then need an endpoint to get the default language?
if (!defaultLanguage?.unique) return;
this.setLanguage(defaultLanguage.unique);
if (!defaultLanguageUnique) return;
this.#variantContext.setFallbackCulture(defaultLanguageUnique);
// get the selected language from local storage
const uniqueFromLocalStorage = localStorage.getItem(this.#localStorageKey);
const languageFromLocalStorage = this.#findLanguage(uniqueFromLocalStorage || '');
const languageUniqueToSet = languageFromLocalStorage ? languageFromLocalStorage.unique : defaultLanguageUnique;
this.setLanguage(languageUniqueToSet);
}
#findLanguage(unique: string) {