Skip to content

Commit 639f5f9

Browse files
authored
Merge branch 'v17/dev' into v17/bugfix/23226-dynamic-route-for-variants
2 parents d3e6540 + 20d1e00 commit 639f5f9

34 files changed

Lines changed: 743 additions & 411 deletions

src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { umbLocalizationRegistry } from '@umbraco-cms/backoffice/localization';
3131

3232
import './app-logo.element.js';
3333
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
34+
import { UmbOutlineStyleController } from './outline-style.controller.js';
3435

3536
const CORE_PACKAGES: Array<Promise<{ name: string; extensions: Array<ManifestBase | UmbExtensionManifestKind> }>> = [
3637
import('../../packages/block/umbraco-package.js'),
@@ -200,6 +201,8 @@ export class UmbAppElement extends UmbLitElement {
200201

201202
new UmbViewContext(this, null);
202203

204+
new UmbOutlineStyleController(this);
205+
203206
this.consumeContext(UMB_CURRENT_USER_CONTEXT, (userContext) => {
204207
this.#currentUser = userContext;
205208
if (userContext) {
@@ -336,7 +339,7 @@ export class UmbAppElement extends UmbLitElement {
336339
umbExtensionsRegistry.registerMany((await this.#packageModules).flatMap((modules) => modules.extensions));
337340
}
338341

339-
// TODO (V18): Unregister extensions on sign-out. [NL]
342+
// TODO: Unregister extensions on sign-out for v.19. [NL]
340343
/*
341344
async #unregisterExtensions() {
342345
if (!this.#packageModules) return;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
2+
3+
export class UmbOutlineStyleController extends UmbControllerBase {
4+
#hideFocusOutline = false;
5+
6+
override hostConnected(): void {
7+
super.hostConnected();
8+
9+
const host = this.getHostElement();
10+
host.addEventListener('pointerdown', this.#onPointerDown);
11+
host.addEventListener('keydown', this.#onKeyDown as EventListener, { capture: true });
12+
}
13+
14+
readonly #onPointerDown = () => {
15+
if (!this.#hideFocusOutline) {
16+
document.body.style.setProperty('--uui-show-focus-outline', '0');
17+
this.#hideFocusOutline = true;
18+
}
19+
};
20+
readonly #onKeyDown = (e: KeyboardEvent) => {
21+
// If tab or shift tab is pressed, we want to show the focus outline, but only if the last interaction was not a pointer event.
22+
if (e.key !== 'Tab') return;
23+
24+
// If the last interaction was a pointer event, we don't want to show the focus outline.
25+
if (this.#hideFocusOutline) {
26+
document.body.style.removeProperty('--uui-show-focus-outline');
27+
this.#hideFocusOutline = false;
28+
}
29+
};
30+
31+
override hostDisconnected(): void {
32+
super.hostDisconnected();
33+
document.body.style.removeProperty('--uui-show-focus-outline');
34+
35+
const host = this.getHostElement();
36+
host.removeEventListener('pointerdown', this.#onPointerDown);
37+
host.removeEventListener('keydown', this.#onKeyDown as EventListener, { capture: true });
38+
}
39+
}

src/Umbraco.Web.UI.Client/src/libs/extension-api/initializers/extension-initializer-base.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,6 @@ describe('UmbExtensionInitializerBase — loaded signal', () => {
129129
.asPromise()
130130
.then(() => instantiated.includes('Umb.Test.SlowBoot'));
131131

132-
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.be.true;
132+
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.equal(true);
133133
});
134134
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './content-published-pending-changes-manager-base.js';
2+
export * from './publish/index.js';
23
export * from './unpublish/index.js';
34

45
export type * from './types.js';
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { manifests as publishManifests } from './publish/manifests.js';
12
import { manifests as unpublishManifests } from './unpublish/manifests.js';
23

3-
export const manifests = [...unpublishManifests];
4+
export const manifests = [...publishManifests, ...unpublishManifests];
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { UMB_ENTITY_ACTION_DEFAULT_KIND_MANIFEST } from '@umbraco-cms/backoffice/entity-action';
2+
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
3+
4+
export const UMB_ENTITY_ACTION_CONTENT_PUBLISH_KIND_MANIFEST: UmbExtensionManifestKind = {
5+
type: 'kind',
6+
alias: 'Umb.Kind.EntityAction.ContentPublish',
7+
matchKind: 'contentPublish',
8+
matchType: 'entityAction',
9+
manifest: {
10+
...UMB_ENTITY_ACTION_DEFAULT_KIND_MANIFEST.manifest,
11+
type: 'entityAction',
12+
kind: 'contentPublish',
13+
api: () => import('./content-publish.action.js'),
14+
weight: 600,
15+
forEntityTypes: [],
16+
meta: {
17+
icon: 'icon-globe',
18+
label: '#actions_publish',
19+
additionalOptions: true,
20+
detailRepositoryAlias: '',
21+
publishingRepositoryAlias: '',
22+
},
23+
},
24+
};
25+
26+
export const manifest = UMB_ENTITY_ACTION_CONTENT_PUBLISH_KIND_MANIFEST;
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { UMB_CONTENT_PUBLISH_MODAL } from '../modal/constants.js';
2+
import type { UmbContentDetailModel } from '../../../types.js';
3+
import type { MetaEntityActionContentPublishKind, UmbContentPublishingRepository } from './types.js';
4+
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
5+
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
6+
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
7+
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
8+
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
9+
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
10+
import { UMB_APP_LANGUAGE_CONTEXT, UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language';
11+
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
12+
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
13+
import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository';
14+
import type { UmbEntityVariantOptionModel } from '@umbraco-cms/backoffice/variant';
15+
import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
16+
17+
/**
18+
* @description - Shared entity action for publishing content-like entities (Document, Element, ...).
19+
* Resolves its detail/publishing repositories by alias from `meta`, so a single implementation can serve every
20+
* consumer registered against the `contentPublish` kind.
21+
* @exports
22+
* @class UmbContentPublishEntityAction
23+
* @augments UmbEntityActionBase
24+
*/
25+
export class UmbContentPublishEntityAction extends UmbEntityActionBase<MetaEntityActionContentPublishKind> {
26+
override async execute() {
27+
if (!this.args.unique) throw new Error('The unique identifier is missing');
28+
const unique = this.args.unique;
29+
30+
const { languageData, detailData } = await this.#resolveDetailData(unique);
31+
const { appCulture, currentUserAllowedLanguages, currentUserHasAccessToAllLanguages } =
32+
await this.#resolveCurrentUserLanguageAccess();
33+
34+
const options = this.#buildVariantOptions(detailData, languageData, appCulture);
35+
const selection = this.#determineDefaultSelection(options, appCulture);
36+
37+
const result = await umbOpenModal(this, UMB_CONTENT_PUBLISH_MODAL, {
38+
data: {
39+
confirmLabel: '#actions_publish',
40+
options,
41+
pickableFilter: (option) =>
42+
this.#isLanguagePickable(option, currentUserAllowedLanguages, currentUserHasAccessToAllLanguages),
43+
},
44+
value: { selection },
45+
}).catch(() => undefined);
46+
47+
if (!result?.selection.length) return;
48+
49+
const variantIds = result.selection.map((x) => UmbVariantId.FromString(x));
50+
const publishableVariantIds = this.#resolvePublishableVariantIds(detailData, variantIds);
51+
52+
if (!publishableVariantIds.length) return;
53+
54+
await this.#publish(unique, publishableVariantIds);
55+
await this.#notifyPublished(options, detailData, result.selection);
56+
await this.#dispatchReloadEvent(unique);
57+
}
58+
59+
async #resolveDetailData(unique: string): Promise<{
60+
languageData: { items: Array<UmbLanguageDetailModel> } | undefined;
61+
detailData: UmbContentDetailModel;
62+
}> {
63+
if (!this.args.meta.detailRepositoryAlias) throw new Error('The detail repository alias is missing in meta');
64+
65+
const languageRepository = new UmbLanguageCollectionRepository(this._host);
66+
const { data: languageData } = await languageRepository.requestAllItems();
67+
68+
const detailRepository = await createExtensionApiByAlias<UmbDetailRepository<UmbContentDetailModel>>(
69+
this,
70+
this.args.meta.detailRepositoryAlias,
71+
);
72+
const { data: detailData } = await detailRepository.requestByUnique(unique);
73+
74+
if (!detailData) throw new Error('The item was not found');
75+
76+
return { languageData, detailData };
77+
}
78+
79+
async #resolveCurrentUserLanguageAccess(): Promise<{
80+
appCulture: string | undefined;
81+
currentUserAllowedLanguages: Array<string>;
82+
currentUserHasAccessToAllLanguages: boolean;
83+
}> {
84+
const appLanguageContext = await this.getContext(UMB_APP_LANGUAGE_CONTEXT);
85+
if (!appLanguageContext) throw new Error('The app language context is missing');
86+
const appCulture = appLanguageContext.getAppCulture();
87+
88+
const currentUserContext = await this.getContext(UMB_CURRENT_USER_CONTEXT);
89+
if (!currentUserContext) throw new Error('The current user context is missing');
90+
const currentUserAllowedLanguages = currentUserContext.getLanguages();
91+
const currentUserHasAccessToAllLanguages = currentUserContext.getHasAccessToAllLanguages();
92+
93+
if (currentUserAllowedLanguages === undefined) throw new Error('The current user languages are missing');
94+
if (currentUserHasAccessToAllLanguages === undefined)
95+
throw new Error('The current user access to all languages is missing');
96+
97+
return { appCulture, currentUserAllowedLanguages, currentUserHasAccessToAllLanguages };
98+
}
99+
100+
#buildVariantOptions(
101+
detailData: UmbContentDetailModel,
102+
languageData: { items: Array<UmbLanguageDetailModel> } | undefined,
103+
appCulture: string | undefined,
104+
): Array<UmbEntityVariantOptionModel> {
105+
// only display culture variants as options
106+
const cultureVariantOptions = detailData.variants.filter((variant) => variant.segment === null);
107+
108+
return cultureVariantOptions.map<UmbEntityVariantOptionModel>((variant) => ({
109+
culture: variant.culture,
110+
segment: variant.segment,
111+
language: languageData?.items.find((language) => language.unique === variant.culture) ?? {
112+
name: appCulture!,
113+
entityType: 'language',
114+
fallbackIsoCode: null,
115+
isDefault: true,
116+
isMandatory: false,
117+
unique: appCulture!,
118+
},
119+
variant,
120+
unique: new UmbVariantId(variant.culture, variant.segment).toString(),
121+
}));
122+
}
123+
124+
#determineDefaultSelection(
125+
options: Array<UmbEntityVariantOptionModel>,
126+
appCulture: string | undefined,
127+
): Array<string> {
128+
if (!options.length) throw new Error('No variants are available to publish');
129+
130+
// If the app language is one of the options, select it by default:
131+
if (appCulture && options.some((o) => o.unique === appCulture)) {
132+
return [new UmbVariantId(appCulture, null).toString()];
133+
}
134+
135+
// If not, select the first option by default:
136+
return [options[0].unique];
137+
}
138+
139+
#isLanguagePickable(
140+
option: UmbEntityVariantOptionModel,
141+
currentUserAllowedLanguages: Array<string>,
142+
currentUserHasAccessToAllLanguages: boolean,
143+
): boolean {
144+
if (!option.culture) return false;
145+
if (currentUserHasAccessToAllLanguages) return true;
146+
return currentUserAllowedLanguages.includes(option.culture);
147+
}
148+
149+
#resolvePublishableVariantIds(
150+
detailData: UmbContentDetailModel,
151+
variantIds: Array<UmbVariantId>,
152+
): Array<UmbVariantId> {
153+
// find all segments of a selected culture
154+
return variantIds.flatMap((variantId) =>
155+
detailData.variants
156+
.filter((variant) => variantId.culture === variant.culture)
157+
.map((variant) => UmbVariantId.Create(variant).toSegment(variant.segment)),
158+
);
159+
}
160+
161+
async #publish(unique: string, publishableVariantIds: Array<UmbVariantId>): Promise<void> {
162+
if (!this.args.meta.publishingRepositoryAlias)
163+
throw new Error('The publishing repository alias is missing in meta');
164+
165+
const publishingRepository = await createExtensionApiByAlias<UmbContentPublishingRepository>(
166+
this,
167+
this.args.meta.publishingRepositoryAlias,
168+
);
169+
const { error } = await publishingRepository.publish(
170+
unique,
171+
publishableVariantIds.map((variantId) => ({ variantId })),
172+
);
173+
174+
if (error) {
175+
throw error;
176+
}
177+
}
178+
179+
async #notifyPublished(
180+
options: Array<UmbEntityVariantOptionModel>,
181+
detailData: UmbContentDetailModel,
182+
selection: Array<string>,
183+
): Promise<void> {
184+
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
185+
const localize = new UmbLocalizationController(this);
186+
187+
// If the content is invariant, we need to show a different notification
188+
const isInvariant = options.length === 1 && options[0].culture === null;
189+
190+
if (isInvariant) {
191+
notificationContext?.peek('positive', {
192+
data: {
193+
headline: localize.term('speechBubbles_editContentPublishedHeader'),
194+
message: localize.string(
195+
this.args.meta.publishedNotificationMessage || '#speechBubbles_editContentPublishedText',
196+
),
197+
},
198+
});
199+
} else {
200+
const publishedVariants = detailData.variants.filter((variant) => selection.includes(variant.culture!));
201+
notificationContext?.peek('positive', {
202+
data: {
203+
headline: localize.term('speechBubbles_editContentPublishedHeader'),
204+
message: localize.string(
205+
this.args.meta.variantPublishedNotificationMessage || '#speechBubbles_editVariantPublishedText',
206+
localize.list(publishedVariants.map((v) => UmbVariantId.Create(v).toString() ?? v.name)),
207+
),
208+
},
209+
});
210+
}
211+
}
212+
213+
async #dispatchReloadEvent(unique: string): Promise<void> {
214+
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
215+
const event = new UmbRequestReloadStructureForEntityEvent({
216+
unique,
217+
entityType: this.args.entityType,
218+
});
219+
220+
actionEventContext?.dispatchEvent(event);
221+
}
222+
}
223+
224+
export { UmbContentPublishEntityAction as api };
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './content-publish.action.js';
2+
export type * from './types.js';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { manifest as contentPublishKindManifest } from './content-publish.action.kind.js';
2+
3+
export const manifests = [contentPublishKindManifest];
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ManifestEntityAction, MetaEntityActionDefaultKind } from '@umbraco-cms/backoffice/entity-action';
2+
import type { UmbRepositoryResponse } from '@umbraco-cms/backoffice/repository';
3+
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
4+
import type { UmbEntityVariantPublishModel } from '@umbraco-cms/backoffice/variant';
5+
6+
/**
7+
* Manifest for an entity action registered against the shared `contentPublish` kind.
8+
*/
9+
export interface ManifestEntityActionContentPublishKind extends ManifestEntityAction<MetaEntityActionContentPublishKind> {
10+
type: 'entityAction';
11+
kind: 'contentPublish';
12+
}
13+
14+
/**
15+
* Meta configuration for the `contentPublish` entity action kind.
16+
*/
17+
export interface MetaEntityActionContentPublishKind extends MetaEntityActionDefaultKind {
18+
/**
19+
* Overrides the success notification message when invariant content is published.
20+
* Use a localization key, e.g. `#speechBubbles_editContentPublishedText`.
21+
*/
22+
publishedNotificationMessage?: string;
23+
/**
24+
* Overrides the success notification message when one or more variants are published.
25+
* Use a localization key, e.g. `#speechBubbles_editVariantPublishedText` — the list of published variants
26+
* is passed as the first term argument, so a plain (non-key) string will not include it.
27+
*/
28+
variantPublishedNotificationMessage?: string;
29+
/**
30+
* Alias of the detail repository used to load the content item.
31+
*/
32+
detailRepositoryAlias: string;
33+
/**
34+
* Alias of the publishing repository used to perform the publish operation.
35+
*/
36+
publishingRepositoryAlias: string;
37+
}
38+
39+
/**
40+
* Contract that the repository registered under `publishingRepositoryAlias` must fulfil.
41+
*/
42+
export interface UmbContentPublishingRepository extends UmbApi {
43+
publish(unique: string, variants: Array<UmbEntityVariantPublishModel>): Promise<UmbRepositoryResponse<void>>;
44+
}
45+
46+
declare global {
47+
interface UmbExtensionManifestMap {
48+
umbEntityActionContentPublishKind: ManifestEntityActionContentPublishKind;
49+
}
50+
}

0 commit comments

Comments
 (0)