diff --git a/src/Umbraco.Web.UI.Client/.github/workflows/pr-first-response.yml b/src/Umbraco.Web.UI.Client/.github/workflows/pr-first-response.yml index f3573ade2c..fe5225a929 100644 --- a/src/Umbraco.Web.UI.Client/.github/workflows/pr-first-response.yml +++ b/src/Umbraco.Web.UI.Client/.github/workflows/pr-first-response.yml @@ -11,15 +11,10 @@ jobs: issues: write pull-requests: write steps: - - name: Install dependencies - run: | - npm install node-fetch@2 - name: Fetch random comment 🗣️ and add it to the PR uses: actions/github-script@v6 with: script: | - const fetch = require('node-fetch') - const response = await fetch('https://collaboratorsv2.euwest01.umbraco.io/umbraco/api/comments/PostComment', { method: 'post', body: JSON.stringify({ diff --git a/src/Umbraco.Web.UI.Client/.storybook/preview.js b/src/Umbraco.Web.UI.Client/.storybook/preview.js index 73d791ca82..dbc166a597 100644 --- a/src/Umbraco.Web.UI.Client/.storybook/preview.js +++ b/src/Umbraco.Web.UI.Client/.storybook/preview.js @@ -94,6 +94,7 @@ export const parameters = { storySort: { method: 'alphabetical', includeNames: true, + order: ['Guides', ['Getting started'], '*'] }, }, actions: { argTypesRegex: '^on.*' }, diff --git a/src/Umbraco.Web.UI.Client/.vscode/extensions.json b/src/Umbraco.Web.UI.Client/.vscode/extensions.json index 5c7db24a59..3e18e13dc4 100644 --- a/src/Umbraco.Web.UI.Client/.vscode/extensions.json +++ b/src/Umbraco.Web.UI.Client/.vscode/extensions.json @@ -7,6 +7,7 @@ "runem.lit-plugin", "esbenp.prettier-vscode", "hbenl.vscode-test-explorer", - "vunguyentuan.vscode-css-variables" + "vunguyentuan.vscode-css-variables", + "unifiedjs.vscode-mdx" ] } diff --git a/src/Umbraco.Web.UI.Client/libs/context-api/consume/context-request.event.ts b/src/Umbraco.Web.UI.Client/libs/context-api/consume/context-request.event.ts index 9d8fa86dec..ab96c5bd51 100644 --- a/src/Umbraco.Web.UI.Client/libs/context-api/consume/context-request.event.ts +++ b/src/Umbraco.Web.UI.Client/libs/context-api/consume/context-request.event.ts @@ -1,6 +1,7 @@ import { UmbContextToken } from '../context-token'; export const umbContextRequestEventType = 'umb:context-request'; +export const umbDebugContextEventType = 'umb:debug-contexts'; export type UmbContextCallback = (instance: T) => void; @@ -31,3 +32,10 @@ export class UmbContextRequestEventImplementation extends Event imp export const isUmbContextRequestEvent = (event: Event): event is UmbContextRequestEventImplementation => { return event.type === umbContextRequestEventType; }; + + +export class UmbContextDebugRequest extends Event { + public constructor(public readonly callback:any) { + super(umbDebugContextEventType, { bubbles: true, composed: true, cancelable: false }); + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/libs/context-api/provide/context-provider.ts b/src/Umbraco.Web.UI.Client/libs/context-api/provide/context-provider.ts index 458ddcb8a0..c9ce3ab9c3 100644 --- a/src/Umbraco.Web.UI.Client/libs/context-api/provide/context-provider.ts +++ b/src/Umbraco.Web.UI.Client/libs/context-api/provide/context-provider.ts @@ -1,4 +1,4 @@ -import { umbContextRequestEventType, isUmbContextRequestEvent } from '../consume/context-request.event'; +import { umbContextRequestEventType, isUmbContextRequestEvent, umbDebugContextEventType } from '../consume/context-request.event'; import { UmbContextToken } from '../context-token'; import { UmbContextProvideEventImplementation } from './context-provide.event'; @@ -40,6 +40,9 @@ export class UmbContextProvider { public hostConnected() { this.host.addEventListener(umbContextRequestEventType, this._handleContextRequest); this.host.dispatchEvent(new UmbContextProvideEventImplementation(this._contextAlias)); + + // Listen to our debug event 'umb:debug-contexts' + this.host.addEventListener(umbDebugContextEventType, this._handleDebugContextRequest); } /** @@ -63,6 +66,20 @@ export class UmbContextProvider { event.callback(this.#instance); }; + private _handleDebugContextRequest = (event: any) => { + // If the event doesn't have an instances property, create it. + if(!event.instances){ + event.instances = new Map(); + } + + // If the event doesn't have an instance for this context, add it. + // Nearest to the DOM element of will be added first + // as contexts can change/override deeper in the DOM + if(!event.instances.has(this._contextAlias)){ + event.instances.set(this._contextAlias, this.#instance); + } + }; + destroy(): void { // I want to make sure to call this, but for now it was too overwhelming to require the destroy method on context instances. (this.#instance as any).destroy?.(); diff --git a/src/Umbraco.Web.UI.Client/libs/models/index.ts b/src/Umbraco.Web.UI.Client/libs/models/index.ts index 4f66e3349c..157a142fbc 100644 --- a/src/Umbraco.Web.UI.Client/libs/models/index.ts +++ b/src/Umbraco.Web.UI.Client/libs/models/index.ts @@ -146,3 +146,8 @@ export interface DataSourceResponse { data?: T; error?: ProblemDetailsModel; } + +export interface SwatchDetails { + label: string; + value: string; +} diff --git a/src/Umbraco.Web.UI.Client/libs/repository/detail-repository.interface.ts b/src/Umbraco.Web.UI.Client/libs/repository/detail-repository.interface.ts index ff6e58a689..ea842ef36f 100644 --- a/src/Umbraco.Web.UI.Client/libs/repository/detail-repository.interface.ts +++ b/src/Umbraco.Web.UI.Client/libs/repository/detail-repository.interface.ts @@ -1,7 +1,7 @@ import type { ProblemDetailsModel } from '@umbraco-cms/backend-api'; export interface UmbDetailRepository { - createDetailsScaffold(parentKey: string | null): Promise<{ + createScaffold(parentKey: string | null): Promise<{ data?: DetailType; error?: ProblemDetailsModel; }>; @@ -11,11 +11,11 @@ export interface UmbDetailRepository { error?: ProblemDetailsModel; }>; - createDetail(data: DetailType): Promise<{ + create(data: DetailType): Promise<{ error?: ProblemDetailsModel; }>; - saveDetail(data: DetailType): Promise<{ + save(data: DetailType): Promise<{ error?: ProblemDetailsModel; }>; diff --git a/src/Umbraco.Web.UI.Client/libs/repository/repository-detail-data-source.interface.ts b/src/Umbraco.Web.UI.Client/libs/repository/repository-detail-data-source.interface.ts index 04f2894b39..7715d5a61e 100644 --- a/src/Umbraco.Web.UI.Client/libs/repository/repository-detail-data-source.interface.ts +++ b/src/Umbraco.Web.UI.Client/libs/repository/repository-detail-data-source.interface.ts @@ -5,5 +5,5 @@ export interface RepositoryDetailDataSource { get(key: string): Promise>; insert(data: DetailType): Promise>; update(data: DetailType): Promise>; - trash(key: string): Promise>; + delete(key: string): Promise>; } diff --git a/src/Umbraco.Web.UI.Client/libs/utils/index.ts b/src/Umbraco.Web.UI.Client/libs/utils/index.ts index 22bcaeabb7..b7439f0c3a 100644 --- a/src/Umbraco.Web.UI.Client/libs/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/libs/utils/index.ts @@ -1,2 +1,3 @@ export * from './utils'; export * from './umbraco-path'; +export * from './udi-service'; diff --git a/src/Umbraco.Web.UI.Client/libs/utils/udi-service.ts b/src/Umbraco.Web.UI.Client/libs/utils/udi-service.ts new file mode 100644 index 0000000000..ae4f84fdc8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/libs/utils/udi-service.ts @@ -0,0 +1,23 @@ +export function buildUdi(entityType: string, guid: string) { + return `umb://${entityType}/${guid.replace(/-/g, '')}`; +} + +export function getKeyFromUdi(udi: string) { + if (typeof udi !== 'string') { + throw 'udi is not a string'; + } + if (!udi.startsWith('umb://')) { + throw 'udi does not start with umb://'; + } + const withoutScheme = udi.substring('umb://'.length); + const withoutHost = withoutScheme.substring(withoutScheme.indexOf('/') + 1).trim(); + + if (withoutHost.length !== 32) { + throw 'udi is not 32 chars'; + } + + return `${withoutHost.substring(0, 8)}-${withoutHost.substring(8, 12)}-${withoutHost.substring( + 12, + 16 + )}-${withoutHost.substring(16, 20)}-${withoutHost.substring(20)}`; +} diff --git a/src/Umbraco.Web.UI.Client/src/app.ts b/src/Umbraco.Web.UI.Client/src/app.ts index 22ab81b634..18d11ccbf2 100644 --- a/src/Umbraco.Web.UI.Client/src/app.ts +++ b/src/Umbraco.Web.UI.Client/src/app.ts @@ -19,6 +19,7 @@ import { UmbLitElement } from '@umbraco-cms/element'; import { tryExecuteAndNotify } from '@umbraco-cms/resources'; import { OpenAPI, RuntimeLevelModel, ServerResource } from '@umbraco-cms/backend-api'; import { UmbIconStore } from '@umbraco-cms/store'; +import { UmbContextDebugRequest, umbDebugContextEventType } from '@umbraco-cms/context-api'; @customElement('umb-app') export class UmbApp extends UmbLitElement { @@ -83,6 +84,16 @@ export class UmbApp extends UmbLitElement { await this._setInitStatus(); await this._registerExtensionManifestsFromServer(); this._redirect(); + + // Listen for the debug event from the component + this.addEventListener(umbDebugContextEventType, (event: any) => { + // Once we got to the outter most component + // we can send the event containing all the contexts + // we have collected whilst coming up through the DOM + // and pass it back down to the callback in + // the component that originally fired the event + event.callback(event.instances); + }); } private async _setup() { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts index dae0f7ba21..69e02e26c2 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts @@ -38,8 +38,10 @@ import { UmbDataTypeTreeStore } from './settings/data-types/repository/data-type import { UmbTemplateTreeStore } from './templating/templates/tree/data/template.tree.store'; import { UmbTemplateDetailStore } from './templating/templates/workspace/data/template.detail.store'; import { UmbThemeContext } from './themes/theme.context'; -import { UmbLanguageStore } from './settings/languages/language.store'; +import { UmbLanguageStore } from './settings/languages/repository/language.store'; import { UmbNotificationService, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/notification'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { UMB_APP_LANGUAGE_CONTEXT_TOKEN, UmbAppLanguageContext } from './settings/languages/app-language.context'; import '@umbraco-cms/router'; @@ -54,7 +56,6 @@ import './packages'; import './search'; import './templating'; import './shared'; -import { UmbLitElement } from '@umbraco-cms/element'; @defineElement('umb-backoffice') export class UmbBackofficeElement extends UmbLitElement { @@ -108,6 +109,7 @@ export class UmbBackofficeElement extends UmbLitElement { new UmbTemplateDetailStore(this); new UmbLanguageStore(this); + this.provideContext(UMB_APP_LANGUAGE_CONTEXT_TOKEN, new UmbAppLanguageContext(this)); this.provideContext(UMB_BACKOFFICE_CONTEXT_TOKEN, new UmbBackofficeContext()); this.provideContext(UMB_CURRENT_USER_HISTORY_STORE_CONTEXT_TOKEN, new UmbCurrentUserHistoryStore()); new UmbThemeContext(this); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/document-type.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/document-type.repository.ts index f296ca5bdb..085cd78f5e 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/document-type.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/document-type.repository.ts @@ -113,7 +113,7 @@ export class UmbDocumentTypeRepository implements UmbTreeRepository, UmbDetailRe // DETAILS: - async createDetailsScaffold(parentKey: string | null) { + async createScaffold(parentKey: string | null) { await this.#init; if (!parentKey) { @@ -148,7 +148,7 @@ export class UmbDocumentTypeRepository implements UmbTreeRepository, UmbDetailRe // Could potentially be general methods: - async createDetail(template: ItemType) { + async create(template: ItemType) { await this.#init; if (!template || !template.key) { @@ -170,7 +170,7 @@ export class UmbDocumentTypeRepository implements UmbTreeRepository, UmbDetailRe return { error }; } - async saveDetail(item: ItemType) { + async save(item: ItemType) { await this.#init; if (!item || !item.key) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/sources/document-type.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/sources/document-type.server.data.ts index 3bb0855410..f014c438ba 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/sources/document-type.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/documents/document-types/repository/sources/document-type.server.data.ts @@ -157,23 +157,33 @@ export class UmbDocumentTypeServerDataSource implements RepositoryDetailDataSour * @return {*} * @memberof UmbDocumentTypeServerDataSource */ - // TODO: Error mistake in this: async delete(key: string) { if (!key) { const error: ProblemDetailsModel = { title: 'Key is missing' }; return { error }; } - // TODO: use resources when end point is ready: - return tryExecuteAndNotify( - this.#host, - fetch('/umbraco/management/api/v1/document-type/trash', { + let problemDetails: ProblemDetailsModel | undefined = undefined; + + try { + await fetch('/umbraco/management/api/v1/document-type/trash', { method: 'POST', body: JSON.stringify([key]), headers: { 'Content-Type': 'application/json', }, - }) + }); + } catch (error) { + problemDetails = { title: 'Delete document Failed' }; + } + + return { error: problemDetails }; + + // TODO: use resources when end point is ready: + /* + return tryExecuteAndNotify( + this.#host, ); + */ } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/document.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/document.repository.ts index 7c0149a51f..7e0188a602 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/document.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/document.repository.ts @@ -113,7 +113,7 @@ export class UmbDocumentRepository implements UmbTreeRepository, UmbDetailReposi // DETAILS: - async createDetailsScaffold(parentKey: string | null) { + async createScaffold(parentKey: string | null) { await this.#init; if (!parentKey) { @@ -143,7 +143,7 @@ export class UmbDocumentRepository implements UmbTreeRepository, UmbDetailReposi // Could potentially be general methods: - async createDetail(item: ItemType) { + async create(item: ItemType) { await this.#init; if (!item || !item.key) { @@ -165,7 +165,7 @@ export class UmbDocumentRepository implements UmbTreeRepository, UmbDetailReposi return { error }; } - async saveDetail(item: ItemType) { + async save(item: ItemType) { await this.#init; if (!item || !item.key) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/sources/document.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/sources/document.server.data.ts index d51f53c069..5fe01e86d2 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/sources/document.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/repository/sources/document.server.data.ts @@ -158,23 +158,30 @@ export class UmbDocumentServerDataSource implements RepositoryDetailDataSource { + trash(key: string): Promise>; +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/workspace/document-workspace.context.ts index bbb5fb3892..3a21e815f4 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/documents/documents/workspace/document-workspace.context.ts @@ -19,13 +19,13 @@ export type ActiveVariant = { culture: string | null; segment: string | null; }; +// TODO: Should we have a DocumentStructureContext and maybe even a DocumentDraftContext? type EntityType = DocumentModel; export class UmbDocumentWorkspaceContext extends UmbWorkspaceContext implements UmbWorkspaceVariableEntityContextInterface { - #isNew = false; #host: UmbControllerHostInterface; #documentRepository: UmbDocumentRepository; #documentTypeRepository: UmbDocumentTypeRepository; @@ -71,17 +71,17 @@ export class UmbDocumentWorkspaceContext const { data } = await this.#documentRepository.requestByKey(entityKey); if (!data) return undefined; - this.#isNew = false; + this.setIsNew(false); this.#document.next(data); this.#draft.next(data); return data || undefined; } async createScaffold(parentKey: string | null) { - const { data } = await this.#documentRepository.createDetailsScaffold(parentKey); + const { data } = await this.#documentRepository.createScaffold(parentKey); if (!data) return undefined; - this.#isNew = true; + this.setIsNew(true); this.#document.next(data); this.#draft.next(data); return data || undefined; @@ -262,13 +262,13 @@ export class UmbDocumentWorkspaceContext async save() { if (!this.#draft.value) return; - if (this.#isNew) { - await this.#documentRepository.createDetail(this.#draft.value); + if (this.getIsNew()) { + await this.#documentRepository.create(this.#draft.value); } else { - await this.#documentRepository.saveDetail(this.#draft.value); + await this.#documentRepository.save(this.#draft.value); } // If it went well, then its not new anymore?. - this.#isNew = false; + this.setIsNew(false); } async delete(key: string) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/repository/media-type.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/repository/media-type.repository.ts index 71c8a21df6..bb3e3ff038 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/repository/media-type.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/repository/media-type.repository.ts @@ -1,13 +1,13 @@ -import { UmbMediaTypeTreeStore, UMB_MEDIA_TYPE_TREE_STORE_CONTEXT_TOKEN } from "./media-type.tree.store"; -import { UmbMediaTypeDetailServerDataSource } from "./sources/media-type.detail.server.data"; -import { UmbMediaTypeDetailStore, UMB_MEDIA_TYPE_DETAIL_STORE_CONTEXT_TOKEN } from "./media-type.detail.store"; -import { MediaTypeTreeServerDataSource } from "./sources/media-type.tree.server.data"; -import { ProblemDetailsModel } from "@umbraco-cms/backend-api"; -import { UmbContextConsumerController } from "@umbraco-cms/context-api"; -import { UmbControllerHostInterface } from "@umbraco-cms/controller"; -import type { MediaTypeDetails } from "@umbraco-cms/models"; -import { UmbNotificationService, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN } from "@umbraco-cms/notification"; -import { UmbTreeRepository, RepositoryTreeDataSource } from "@umbraco-cms/repository"; +import { UmbMediaTypeTreeStore, UMB_MEDIA_TYPE_TREE_STORE_CONTEXT_TOKEN } from './media-type.tree.store'; +import { UmbMediaTypeDetailServerDataSource } from './sources/media-type.detail.server.data'; +import { UmbMediaTypeDetailStore, UMB_MEDIA_TYPE_DETAIL_STORE_CONTEXT_TOKEN } from './media-type.detail.store'; +import { MediaTypeTreeServerDataSource } from './sources/media-type.tree.server.data'; +import { ProblemDetailsModel } from '@umbraco-cms/backend-api'; +import { UmbContextConsumerController } from '@umbraco-cms/context-api'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import type { MediaTypeDetails } from '@umbraco-cms/models'; +import { UmbNotificationService, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/notification'; +import { UmbTreeRepository, RepositoryTreeDataSource } from '@umbraco-cms/repository'; export class UmbMediaTypeRepository implements UmbTreeRepository { #init!: Promise; @@ -103,7 +103,7 @@ export class UmbMediaTypeRepository implements UmbTreeRepository { // DETAILS - async createDetailsScaffold() { + async createScaffold() { await this.#init; return this.#detailSource.createScaffold(); } @@ -130,7 +130,7 @@ export class UmbMediaTypeRepository implements UmbTreeRepository { return this.#detailSource.delete(key); } - async saveDetail(mediaType: MediaTypeDetails) { + async save(mediaType: MediaTypeDetails) { await this.#init; // TODO: should we show a notification if the media type is missing? @@ -157,7 +157,7 @@ export class UmbMediaTypeRepository implements UmbTreeRepository { return { error }; } - async createDetail(mediaType: MediaTypeDetails) { + async create(mediaType: MediaTypeDetails) { await this.#init; if (!mediaType.name) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/workspace/media-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/workspace/media-type-workspace.context.ts index 4433b62d75..2445086e43 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/workspace/media-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/media/media-types/workspace/media-type-workspace.context.ts @@ -51,16 +51,18 @@ export class UmbWorkspaceMediaTypeContext } async createScaffold() { - const { data } = await this.#repo.createDetailsScaffold(); + const { data } = await this.#repo.createScaffold(); if (!data) return; + this.setIsNew(true); this.#data.next(data); } async save() { if (!this.#data.value) return; - this.#repo.saveDetail(this.#data.value); + await this.#repo.save(this.#data.value); + this.setIsNew(false); } - + public destroy(): void { this.#data.complete(); } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/media.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/media.repository.ts index 365e728589..831af8b644 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/media.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/media.repository.ts @@ -111,7 +111,7 @@ export class UmbMediaRepository implements UmbTreeRepository, UmbDetailRepositor // DETAILS: - async createDetailsScaffold(parentKey: string | null) { + async createScaffold(parentKey: string | null) { await this.#init; if (!parentKey) { @@ -141,7 +141,7 @@ export class UmbMediaRepository implements UmbTreeRepository, UmbDetailRepositor // Could potentially be general methods: - async createDetail(template: ItemDetailType) { + async create(template: ItemDetailType) { await this.#init; if (!template || !template.key) { @@ -163,7 +163,7 @@ export class UmbMediaRepository implements UmbTreeRepository, UmbDetailRepositor return { error }; } - async saveDetail(document: ItemDetailType) { + async save(document: ItemDetailType) { await this.#init; if (!document || !document.key) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/sources/media.detail.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/sources/media.detail.server.data.ts index 9b3cb61c47..2cbbe19cc0 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/sources/media.detail.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/repository/sources/media.detail.server.data.ts @@ -182,22 +182,30 @@ export class UmbMediaDetailServerDataSource implements RepositoryDetailDataSourc * @return {*} * @memberof UmbTemplateDetailServerDataSource */ - // TODO: Error mistake in this: async delete(key: string) { if (!key) { const error: ProblemDetailsModel = { title: 'Key is missing' }; return { error }; } - return tryExecuteAndNotify( - this.#host, - fetch('/umbraco/management/api/v1/media/delete', { + let problemDetails: ProblemDetailsModel | undefined = undefined; + + try { + await fetch('/umbraco/management/api/v1/media/delete', { method: 'POST', body: JSON.stringify([key]), headers: { 'Content-Type': 'application/json', }, - }) - ); + }); + } catch (error) { + problemDetails = { title: 'Delete document Failed' }; + } + + return { error: problemDetails }; + + /* TODO: use backend cli when available. + return tryExecuteAndNotify(this.#host); + */ } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/workspace/media-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/workspace/media-workspace.context.ts index c287034bf1..fe27ecb936 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/media/media/workspace/media-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/media/media/workspace/media-workspace.context.ts @@ -10,7 +10,6 @@ export class UmbMediaWorkspaceContext extends UmbWorkspaceContext implements UmbWorkspaceEntityContextInterface { - #isNew = false; #host: UmbControllerHostInterface; #detailRepository: UmbMediaRepository; @@ -54,27 +53,27 @@ export class UmbMediaWorkspaceContext async load(entityKey: string) { const { data } = await this.#detailRepository.requestByKey(entityKey); if (data) { - this.#isNew = false; + this.setIsNew(false); this.#data.next(data); } } async createScaffold(parentKey: string | null) { - const { data } = await this.#detailRepository.createDetailsScaffold(parentKey); + const { data } = await this.#detailRepository.createScaffold(parentKey); if (!data) return; - this.#isNew = true; + this.setIsNew(true); this.#data.next(data); } async save() { if (!this.#data.value) return; - if (this.#isNew) { - await this.#detailRepository.createDetail(this.#data.value); + if (this.isNew) { + await this.#detailRepository.create(this.#data.value); } else { - await this.#detailRepository.saveDetail(this.#data.value); + await this.#detailRepository.save(this.#data.value); } // If it went well, then its not new anymore?. - this.#isNew = false; + this.setIsNew(false); } async delete(key: string) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/repository/member-group.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/repository/member-group.repository.ts index 0084c13140..7f273071e5 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/repository/member-group.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/repository/member-group.repository.ts @@ -39,7 +39,7 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep new UmbContextConsumerController(this.#host, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN, (instance) => { this.#notificationService = instance; - }); + }); } async requestRootTreeItems() { @@ -89,9 +89,9 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep // DETAIL - async createDetailsScaffold() { + async createScaffold() { await this.#init; - return this.#detailSource.createScaffold(); + return this.#detailSource.createScaffold(); } async requestByKey(key: string) { @@ -111,7 +111,7 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep return { data, error }; } - async createDetail(detail: MemberGroupDetails) { + async create(detail: MemberGroupDetails) { await this.#init; if (!detail.name) { @@ -129,9 +129,9 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep return { data, error }; } - async saveDetail(memberGroup: MemberGroupDetails) { + async save(memberGroup: MemberGroupDetails) { await this.#init; - + if (!memberGroup || !memberGroup.name) { const error: ProblemDetailsModel = { title: 'Member group is missing' }; return { error }; @@ -140,7 +140,7 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep const { error } = await this.#detailSource.update(memberGroup); if (!error) { - const notification = { data: { message: `Member group '${memberGroup.name} saved`}}; + const notification = { data: { message: `Member group '${memberGroup.name} saved` } }; this.#notificationService?.peek('positive', notification); } @@ -149,7 +149,7 @@ export class UmbMemberGroupRepository implements UmbTreeRepository, UmbDetailRep return { error }; } - + async delete(key: string) { await this.#init; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/workspace/member-group-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/workspace/member-group-workspace.context.ts index 726841e634..194e50c0ae 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/workspace/member-group-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-groups/workspace/member-group-workspace.context.ts @@ -52,16 +52,18 @@ export class UmbWorkspaceMemberGroupContext } async createScaffold() { - const { data } = await this.#repo.createDetailsScaffold(); + const { data } = await this.#repo.createScaffold(); if (!data) return; + this.setIsNew(true); this.#data.next(data); } async save() { if (!this.#data.value) return; - this.#repo.saveDetail(this.#data.value); + await this.#repo.save(this.#data.value); + this.setIsNew(true); } - + public destroy(): void { this.#data.complete(); } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/member-type.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/member-type.repository.ts index d2512d0aa0..c03d4eeb44 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/member-type.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/member-type.repository.ts @@ -106,9 +106,9 @@ export class UmbMemberTypeRepository implements UmbTreeRepository, UmbDetailRepo // DETAILS - async createDetailsScaffold() { + async createScaffold() { await this.#init; - return this.#detailSource.createDetailsScaffold(); + return this.#detailSource.createScaffold(); } async requestByKey(key: string) { @@ -153,7 +153,7 @@ export class UmbMemberTypeRepository implements UmbTreeRepository, UmbDetailRepo return { error }; } - async saveDetail(detail: ItemType) { + async save(detail: ItemType) { await this.#init; // TODO: should we show a notification if the MemberType is missing? @@ -163,7 +163,7 @@ export class UmbMemberTypeRepository implements UmbTreeRepository, UmbDetailRepo return { error }; } - const { error } = await this.#detailSource.saveDetail(detail); + const { error } = await this.#detailSource.save(detail); if (!error) { const notification = { data: { message: `Member type '${detail.name}' saved` } }; @@ -180,7 +180,7 @@ export class UmbMemberTypeRepository implements UmbTreeRepository, UmbDetailRepo return { error }; } - async createDetail(detail: MemberTypeDetails) { + async create(detail: MemberTypeDetails) { await this.#init; if (!detail.name) { @@ -188,7 +188,7 @@ export class UmbMemberTypeRepository implements UmbTreeRepository, UmbDetailRepo return { error }; } - const { data, error } = await this.#detailSource.createDetail(detail); + const { data, error } = await this.#detailSource.create(detail); if (!error) { const notification = { data: { message: `Member type '${detail.name}' created` } }; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/sources/member-type.detail.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/sources/member-type.detail.server.data.ts index 01f2f5537a..58ec90e115 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/sources/member-type.detail.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/repository/sources/member-type.detail.server.data.ts @@ -22,7 +22,7 @@ export class UmbMemberTypeDetailServerDataSource implements UmbDetailRepository< * @return {*} * @memberof UmbMemberTypeDetailServerDataSource */ - async createDetailsScaffold() { + async createScaffold() { const data = {} as MemberTypeDetails; return { data }; } @@ -45,7 +45,7 @@ export class UmbMemberTypeDetailServerDataSource implements UmbDetailRepository< * @return {*} * @memberof UmbMemberTypeDetailServerDataSource */ - async saveDetail(memberType: MemberTypeDetails) { + async save(memberType: MemberTypeDetails) { if (!memberType.key) { const error: ProblemDetailsModel = { title: 'MemberType key is missing' }; return { error }; @@ -73,7 +73,7 @@ export class UmbMemberTypeDetailServerDataSource implements UmbDetailRepository< * @return {*} * @memberof UmbMemberTypeDetailServerDataSource */ - async createDetail(data: MemberTypeDetails) { + async create(data: MemberTypeDetails) { const requestBody = { name: data.name, }; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/workspace/member-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/workspace/member-type-workspace.context.ts index 5f076fa40b..166d07c469 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/workspace/member-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/member-types/workspace/member-type-workspace.context.ts @@ -11,7 +11,6 @@ export class UmbWorkspaceMemberTypeContext extends UmbWorkspaceContext implements UmbWorkspaceEntityContextInterface { - #isNew = false; #host: UmbControllerHostInterface; #dataTypeRepository: UmbMemberTypeRepository; @@ -27,22 +26,22 @@ export class UmbWorkspaceMemberTypeContext async load(entityKey: string) { const { data } = await this.#dataTypeRepository.requestByKey(entityKey); if (data) { - this.#isNew = false; + this.setIsNew(false); this.#data.next(data); } } async createScaffold() { - const { data } = await this.#dataTypeRepository.createDetailsScaffold(); + const { data } = await this.#dataTypeRepository.createScaffold(); if (!data) return; - this.#isNew = true; + this.setIsNew(true); this.#data.next(data); } getData() { return this.#data.getValue(); } - + getEntityKey() { return this.getData()?.key || ''; } @@ -61,13 +60,13 @@ export class UmbWorkspaceMemberTypeContext async save() { if (!this.#data.value) return; - if (this.#isNew) { - await this.#dataTypeRepository.createDetail(this.#data.value); + if (this.isNew) { + await this.#dataTypeRepository.create(this.#data.value); } else { - await this.#dataTypeRepository.saveDetail(this.#data.value); + await this.#dataTypeRepository.save(this.#data.value); } // If it went well, then its not new anymore?. - this.#isNew = false; + this.setIsNew(false); } async delete(key: string) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/members/members/workspace/member-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/members/members/workspace/member-workspace.context.ts index d010b9edd0..e3ea0a4a1a 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/members/members/workspace/member-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/members/members/workspace/member-workspace.context.ts @@ -18,7 +18,7 @@ export class UmbWorkspaceMemberContext } setName(name: string) { - this.#manager.state.update({name}); + this.#manager.state.update({ name }); } getEntityType = this.#manager.getEntityType; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/manifests.ts new file mode 100644 index 0000000000..2bd802ce9c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as repositoryManifests } from './repository/manifests'; + +export const manifests = [...repositoryManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/culture.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/culture.repository.ts new file mode 100644 index 0000000000..abdd4e6e72 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/culture.repository.ts @@ -0,0 +1,29 @@ +import { UmbCultureServerDataSource } from './sources/culture.server.data'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { UmbContextConsumerController } from '@umbraco-cms/context-api'; +import { UmbNotificationService, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/notification'; + +export class UmbCultureRepository { + #init!: Promise; + #host: UmbControllerHostInterface; + + #dataSource: UmbCultureServerDataSource; + + #notificationService?: UmbNotificationService; + + constructor(host: UmbControllerHostInterface) { + this.#host = host; + + this.#dataSource = new UmbCultureServerDataSource(this.#host); + + this.#init = Promise.all([ + new UmbContextConsumerController(this.#host, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN, (instance) => { + this.#notificationService = instance; + }), + ]); + } + + requestCultures({ skip, take } = { skip: 0, take: 1000 }) { + return this.#dataSource.getCollection({ skip, take }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/manifests.ts new file mode 100644 index 0000000000..0bc2ae18bd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/manifests.ts @@ -0,0 +1,13 @@ +import { UmbCultureRepository } from '../repository/culture.repository'; +import { ManifestRepository } from 'libs/extensions-registry/repository.models'; + +export const CULTURE_REPOSITORY_ALIAS = 'Umb.Repository.Cultures'; + +const repository: ManifestRepository = { + type: 'repository', + alias: CULTURE_REPOSITORY_ALIAS, + name: 'Cultures Repository', + class: UmbCultureRepository, +}; + +export const manifests = [repository]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/culture.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/culture.server.data.ts new file mode 100644 index 0000000000..02ac62a9bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/culture.server.data.ts @@ -0,0 +1,32 @@ +import { UmbCultureDataSource } from '.'; +import { CultureResource } from '@umbraco-cms/backend-api'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { tryExecuteAndNotify } from '@umbraco-cms/resources'; + +/** + * A data source for the Language that fetches data from the server + * @export + * @class UmbLanguageServerDataSource + * @implements {RepositoryDetailDataSource} + */ +export class UmbCultureServerDataSource implements UmbCultureDataSource { + #host: UmbControllerHostInterface; + + /** + * Creates an instance of UmbLanguageServerDataSource. + * @param {UmbControllerHostInterface} host + * @memberof UmbLanguageServerDataSource + */ + constructor(host: UmbControllerHostInterface) { + this.#host = host; + } + + /** + * Get a list of cultures on the server + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async getCollection({ skip, take }: { skip: number; take: number }) { + return tryExecuteAndNotify(this.#host, CultureResource.getCulture({ skip, take })); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/index.ts new file mode 100644 index 0000000000..8b3555ed3f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/cultures/repository/sources/index.ts @@ -0,0 +1,12 @@ +import { PagedCultureModel } from '@umbraco-cms/backend-api'; +import type { DataSourceResponse } from '@umbraco-cms/models'; + +// TODO: This is a temporary solution until we have a proper paging interface +type paging = { + skip: number; + take: number; +}; + +export interface UmbCultureDataSource { + getCollection(paging: paging): Promise>; +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/dashboards/examine-management/views/modal-views/fields-viewer.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/dashboards/examine-management/views/modal-views/fields-viewer.element.ts index 6e81567c79..e55b6b4b22 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/dashboards/examine-management/views/modal-views/fields-viewer.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/dashboards/examine-management/views/modal-views/fields-viewer.element.ts @@ -16,11 +16,7 @@ export class UmbModalLayoutFieldsViewerElement extends UmbModalLayoutElement

${this._publishedStatusText}

{ - #isNew = false; #host: UmbControllerHostInterface; #dataTypeRepository: UmbDataTypeRepository; @@ -28,24 +26,26 @@ export class UmbDataTypeWorkspaceContext async load(key: string) { const { data } = await this.#dataTypeRepository.requestByKey(key); if (data) { - this.#isNew = false; + this.setIsNew(false); this.#data.update(data); } } async createScaffold(parentKey: string | null) { - const { data } = await this.#dataTypeRepository.createDetailsScaffold(parentKey); + const { data } = await this.#dataTypeRepository.createScaffold(parentKey); if (!data) return; - this.#isNew = true; + this.setIsNew(true); this.#data.next(data); } getData() { return this.#data.getValue(); } + getEntityKey() { return this.getData()?.key || ''; } + getEntityType() { return 'data-type'; } @@ -75,13 +75,13 @@ export class UmbDataTypeWorkspaceContext async save() { if (!this.#data.value) return; - if (this.#isNew) { - await this.#dataTypeRepository.createDetail(this.#data.value); + if (this.isNew) { + await this.#dataTypeRepository.create(this.#data.value); } else { - await this.#dataTypeRepository.saveDetail(this.#data.value); + await this.#dataTypeRepository.save(this.#data.value); } // If it went well, then its not new anymore?. - this.#isNew = false; + this.setIsNew(false); } async delete(key: string) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/index.ts index de60d18c58..43ef6bfa40 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/index.ts @@ -2,6 +2,7 @@ import { manifests as settingsSectionManifests } from './section.manifests'; import { manifests as dashboardManifests } from './dashboards/manifests'; import { manifests as dataTypeManifests } from './data-types/manifests'; import { manifests as extensionManifests } from './extensions/manifests'; +import { manifests as cultureManifests } from './cultures/manifests'; import { manifests as languageManifests } from './languages/manifests'; import { manifests as logviewerManifests } from './logviewer/manifests'; @@ -20,6 +21,7 @@ registerExtensions([ ...dashboardManifests, ...dataTypeManifests, ...extensionManifests, + ...cultureManifests, ...languageManifests, ...logviewerManifests, ]); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language-select.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language-select.element.ts new file mode 100644 index 0000000000..1415471f37 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language-select.element.ts @@ -0,0 +1,149 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { UUIMenuItemEvent } from '@umbraco-ui/uui'; +import { UmbLanguageRepository } from './repository/language.repository'; +import { UMB_APP_LANGUAGE_CONTEXT_TOKEN, UmbAppLanguageContext } from './app-language.context'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { LanguageModel } from '@umbraco-cms/backend-api'; + +@customElement('umb-app-language-select') +export class UmbAppLanguageSelectElement extends UmbLitElement { + static styles = [ + UUITextStyles, + css` + :host { + display: block; + position: relative; + z-index: 10; + } + + #toggle { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + height: 70px; + padding: 0 var(--uui-size-8); + border-bottom: 1px solid var(--uui-color-border); + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + } + + #toggle:hover { + background-color: var(--uui-color-surface-emphasis); + } + `, + ]; + + @state() + private _languages: Array = []; + + @state() + private _appLanguage?: LanguageModel; + + @state() + private _isOpen = false; + + #repository = new UmbLanguageRepository(this); + #appLanguageContext?: UmbAppLanguageContext; + #languagesObserver?: any; + + constructor() { + super(); + + this.consumeContext(UMB_APP_LANGUAGE_CONTEXT_TOKEN, (instance) => { + this.#appLanguageContext = instance; + this.#observeAppLanguage(); + }); + } + + async #observeAppLanguage() { + if (!this.#appLanguageContext) return; + + this.observe(this.#appLanguageContext.appLanguage, (isoCode) => { + this._appLanguage = isoCode; + }); + } + + async #observeLanguages() { + const { asObservable } = await this.#repository.requestLanguages(); + + this.#languagesObserver = this.observe(asObservable(), (languages) => { + this._languages = languages; + }); + } + + #onClick() { + this.#toggleDropdown(); + } + + #onClose() { + this.#closeDropdown(); + } + + #toggleDropdown() { + this._isOpen = !this._isOpen; + + // first start observing the languages when the dropdown is opened + if (this._isOpen && !this.#languagesObserver) { + this.#observeLanguages(); + } + } + + #closeDropdown() { + this._isOpen = false; + } + + #onLabelClick(event: UUIMenuItemEvent) { + const menuItem = event.target; + const isoCode = menuItem.dataset.isoCode; + + // TODO: handle error + if (!isoCode) return; + + this.#appLanguageContext?.setLanguage(isoCode); + this._isOpen = false; + } + + render() { + return html` + ${this.#renderTrigger()} ${this.#renderContent()} + `; + } + + #renderTrigger() { + return html``; + } + + #renderContent() { + return html`
+ ${repeat( + this._languages, + (language) => language.isoCode, + (language) => + html` + + ` + )} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-app-language-select': UmbAppLanguageSelectElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language.context.ts new file mode 100644 index 0000000000..603e2e4955 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/app-language.context.ts @@ -0,0 +1,50 @@ +import { UmbLanguageRepository } from './repository/language.repository'; +import { ObjectState, UmbObserverController } from '@umbraco-cms/observable-api'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { UmbContextToken } from '@umbraco-cms/context-api'; +import { LanguageModel } from '@umbraco-cms/backend-api'; + +export class UmbAppLanguageContext { + #host: UmbControllerHostInterface; + #languageRepository: UmbLanguageRepository; + + #languages: Array = []; + + #appLanguage = new ObjectState(undefined); + appLanguage = this.#appLanguage.asObservable(); + + constructor(host: UmbControllerHostInterface) { + this.#host = host; + this.#languageRepository = new UmbLanguageRepository(this.#host); + this.#observeLanguages(); + } + + setLanguage(isoCode: string) { + const language = this.#languages.find((x) => x.isoCode === isoCode); + this.#appLanguage.update(language); + } + + async #observeLanguages() { + const { asObservable } = await this.#languageRepository.requestLanguages(); + + new UmbObserverController(this.#host, asObservable(), (languages) => { + this.#languages = languages; + + // If the app language is not set, set it to the default language + if (!this.#appLanguage.getValue()) { + this.#initAppLanguage(); + } + }); + } + + #initAppLanguage() { + const defaultLanguage = this.#languages.find((x) => x.isDefault); + // 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?.isoCode) return; + this.setLanguage(defaultLanguage.isoCode); + } +} + +export const UMB_APP_LANGUAGE_CONTEXT_TOKEN = new UmbContextToken(UmbAppLanguageContext.name); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/entity-actions/manifests.ts new file mode 100644 index 0000000000..84b57075b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/entity-actions/manifests.ts @@ -0,0 +1,22 @@ +import { UmbDeleteEntityAction } from '../../../shared/entity-actions/delete/delete.action'; +import { ManifestEntityAction } from '@umbraco-cms/extensions-registry'; + +const entityType = 'language'; +const repositoryAlias = 'Umb.Repository.Languages'; + +const entityActions: Array = [ + { + type: 'entityAction', + alias: 'Umb.EntityAction.Language.Delete', + name: 'Delete Language Entity Action', + meta: { + entityType, + repositoryAlias, + icon: 'umb:trash', + label: 'Delete', + api: UmbDeleteEntityAction, + }, + }, +]; + +export const manifests = [...entityActions]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts new file mode 100644 index 0000000000..7e5f9af383 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts @@ -0,0 +1,69 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { repeat } from 'lit-html/directives/repeat.js'; +import { UUIMenuItemElement, UUIMenuItemEvent } from '@umbraco-ui/uui'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { UmbModalLayoutPickerBase } from '../../../../core/modal/layouts/modal-layout-picker-base'; +import { UmbLanguageRepository } from '../repository/language.repository'; +import { LanguageModel } from '@umbraco-cms/backend-api'; + +export interface UmbLanguagePickerModalData { + multiple: boolean; + selection: string[]; +} + +@customElement('umb-language-picker-modal-layout') +export class UmbLanguagePickerModalLayoutElement extends UmbModalLayoutPickerBase { + static styles = [UUITextStyles, css``]; + + @state() + private _languages: Array = []; + + private _languageRepository = new UmbLanguageRepository(this); + + async firstUpdated() { + const { data } = await this._languageRepository.requestLanguages(); + this._languages = data?.items ?? []; + } + + #onSelection(event: UUIMenuItemEvent) { + event?.stopPropagation(); + const language = event?.target as UUIMenuItemElement; + const isoCode = language.dataset.isoCode; + if (!isoCode) return; + this.handleSelection(isoCode); + } + + render() { + return html` + + ${repeat( + this._languages, + (item) => item.isoCode, + (item) => html` + + + + ` + )} + +
+ + +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-language-picker-modal-layout': UmbLanguagePickerModalLayoutElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language.store.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language.store.ts deleted file mode 100644 index ffc3f178ba..0000000000 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language.store.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Observable } from 'rxjs'; -import { CultureModel, CultureResource, LanguageModel, LanguageResource } from '@umbraco-cms/backend-api'; -import { tryExecuteAndNotify } from '@umbraco-cms/resources'; -import { UmbContextToken } from '@umbraco-cms/context-api'; -import { UmbStoreBase } from '@umbraco-cms/store'; -import { ArrayState } from '@umbraco-cms/observable-api'; -import { UmbControllerHostInterface } from '@umbraco-cms/controller'; - -export type UmbLanguageStoreItemType = LanguageModel; -export const UMB_LANGUAGE_STORE_CONTEXT_TOKEN = new UmbContextToken('umbLanguageStore'); - -/** - * @export - * @class UmbLanguageStore - * @extends {UmbStoreBase} - * @description - Data Store for languages - */ -export class UmbLanguageStore extends UmbStoreBase { - #data = new ArrayState([], (x) => x.isoCode); - #availableLanguages = new ArrayState([], (x) => x.name); - - public readonly availableLanguages = this.#availableLanguages.asObservable(); - - constructor(host: UmbControllerHostInterface) { - super(host, UMB_LANGUAGE_STORE_CONTEXT_TOKEN.toString()); - } - - getByIsoCode(isoCode: string) { - tryExecuteAndNotify(this._host, LanguageResource.getLanguageByIsoCode({ isoCode })).then(({ data }) => { - if (data) { - this.#data.appendOne(data); - } - }); - - return this.#data.getObservablePart((items) => items.find((item) => item.isoCode === isoCode)); - } - - getAll(): Observable> { - tryExecuteAndNotify(this._host, LanguageResource.getLanguage({ skip: 0, take: 1000 })).then(({ data }) => { - this.#data.append(data?.items ?? []); - }); - - return this.#data; - } - - getAvailableCultures() { - tryExecuteAndNotify(this._host, CultureResource.getCulture({ skip: 0, take: 1000 })).then(({ data }) => { - if (!data) return; - this.#availableLanguages.append(data.items); - }); - - return this.availableLanguages; - } - - async save(language: UmbLanguageStoreItemType): Promise { - if (language.isoCode) { - const { data: updatedLanguage } = await tryExecuteAndNotify( - this._host, - LanguageResource.putLanguageByIsoCode({ isoCode: language.isoCode, requestBody: language }) - ); - if (updatedLanguage) { - this.#data.appendOne(updatedLanguage); - } - } else { - const { data: newLanguage } = await tryExecuteAndNotify( - this._host, - LanguageResource.postLanguage({ requestBody: language }) - ); - if (newLanguage) { - this.#data.appendOne(newLanguage); - } - } - } - - async delete(isoCodes: Array) { - // TODO: revisit this. It looks a bit weird with the nested tryExecuteAndNotify - const queue = isoCodes.map((isoCode) => - tryExecuteAndNotify( - this._host, - tryExecuteAndNotify(this._host, LanguageResource.deleteLanguageByIsoCode({ isoCode })).then(() => isoCode) - ) - ); - const results = await Promise.all(queue); - const filtered = results.filter((x) => !!x).map((result) => result.data); - this.#data.remove(filtered); - } -} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/manifests.ts index 450deac4e9..c7e92f8c72 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/manifests.ts @@ -1,4 +1,6 @@ +import { manifests as repositoryManifests } from './repository/manifests'; import { manifests as treeManifests } from './sidebar-menu-item/manifests'; +import { manifests as entityActions } from './entity-actions/manifests'; import { manifests as workspaceManifests } from './workspace/manifests'; -export const manifests = [...treeManifests, ...workspaceManifests]; +export const manifests = [...repositoryManifests, ...entityActions, ...treeManifests, ...workspaceManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.repository.ts new file mode 100644 index 0000000000..9fa8683457 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.repository.ts @@ -0,0 +1,145 @@ +import { UmbLanguageServerDataSource } from './sources/language.server.data'; +import { UmbLanguageStore, UMB_LANGUAGE_STORE_CONTEXT_TOKEN } from './language.store'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { UmbContextConsumerController } from '@umbraco-cms/context-api'; +import { UmbNotificationService, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/notification'; +import { LanguageModel, ProblemDetailsModel } from '@umbraco-cms/backend-api'; + +export class UmbLanguageRepository { + #init!: Promise; + + #host: UmbControllerHostInterface; + + #dataSource: UmbLanguageServerDataSource; + #languageStore?: UmbLanguageStore; + + #notificationService?: UmbNotificationService; + + constructor(host: UmbControllerHostInterface) { + this.#host = host; + + this.#dataSource = new UmbLanguageServerDataSource(this.#host); + + this.#init = Promise.all([ + new UmbContextConsumerController(this.#host, UMB_NOTIFICATION_SERVICE_CONTEXT_TOKEN, (instance) => { + this.#notificationService = instance; + }), + + new UmbContextConsumerController(this.#host, UMB_LANGUAGE_STORE_CONTEXT_TOKEN, (instance) => { + this.#languageStore = instance; + }), + ]); + } + + // TODO: maybe this should be renamed to something more generic? + async requestByIsoCode(isoCode: string) { + await this.#init; + + if (!isoCode) { + const error: ProblemDetailsModel = { title: 'Iso code is missing' }; + return { error }; + } + + return this.#dataSource.get(isoCode); + } + + // TODO: maybe this should be renamed to something more generic. + // Revisit when collection are in place + async requestLanguages({ skip, take } = { skip: 0, take: 1000 }) { + await this.#init; + + const { data, error } = await this.#dataSource.getCollection({ skip, take }); + + if (data) { + // TODO: allow to append an array of items to the store + data.items.forEach((x) => this.#languageStore?.append(x)); + } + + return { data, error, asObservable: () => this.#languageStore!.data }; + } + + async requestItems(isoCodes: Array) { + // HACK: filter client side until we have a proper server side endpoint + // TODO: we will get a different size model here, how do we handle that in the store? + const { data, error } = await this.requestLanguages(); + + let items = undefined; + + if (data) { + // TODO: how do we best handle this? They might have a smaller data set than the details + items = data.items = data.items.filter((x) => isoCodes.includes(x.isoCode!)); + data.items.forEach((x) => this.#languageStore?.append(x)); + } + + return { data: items, error, asObservable: () => this.#languageStore!.items(isoCodes) }; + } + + /** + * Creates a new Language scaffold + * @param + * @return {*} + * @memberof UmbLanguageRepository + */ + async createScaffold() { + return this.#dataSource.createScaffold(); + } + + async create(language: LanguageModel) { + await this.#init; + + const { error } = await this.#dataSource.insert(language); + + if (!error) { + this.#languageStore?.append(language); + const notification = { data: { message: `Language created` } }; + this.#notificationService?.peek('positive', notification); + } + + return { error }; + } + + /** + * Saves a language + * @param {LanguageModel} language + * @return {*} + * @memberof UmbLanguageRepository + */ + async save(language: LanguageModel) { + await this.#init; + + const { error } = await this.#dataSource.update(language); + + if (!error) { + const notification = { data: { message: `Language saved` } }; + this.#notificationService?.peek('positive', notification); + this.#languageStore?.append(language); + } + + return { error }; + } + + /** + * Deletes a language + * @param {string} isoCode + * @return {*} + * @memberof UmbLanguageRepository + */ + async delete(isoCode: string) { + await this.#init; + + if (!isoCode) { + const error: ProblemDetailsModel = { title: 'Language iso code is missing' }; + return { error }; + } + + const { error } = await this.#dataSource.delete(isoCode); + + if (!error) { + this.#languageStore?.remove([isoCode]); + const notification = { data: { message: `Language deleted` } }; + this.#notificationService?.peek('positive', notification); + } + + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.store.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.store.ts new file mode 100644 index 0000000000..7f13331aca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/language.store.ts @@ -0,0 +1,35 @@ +import { UmbContextToken } from '@umbraco-cms/context-api'; +import { UmbStoreBase } from '@umbraco-cms/store'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { ArrayState } from '@umbraco-cms/observable-api'; +import { LanguageModel } from '@umbraco-cms/backend-api'; + +/** + * @export + * @class UmbLanguageStore + * @extends {UmbStoreBase} + * @description - Details Data Store for Languages + */ +export class UmbLanguageStore extends UmbStoreBase { + #data = new ArrayState([], (x) => x.isoCode); + data = this.#data.asObservable(); + + constructor(host: UmbControllerHostInterface) { + super(host, UmbLanguageStore.name); + } + + append(language: LanguageModel) { + this.#data.append([language]); + } + + remove(uniques: string[]) { + this.#data.remove(uniques); + } + + // TODO: how do we best handle this? They might have a smaller data set than the details + items(isoCodes: Array) { + return this.#data.getObservablePart((items) => items.filter((item) => isoCodes.includes(item.isoCode ?? ''))); + } +} + +export const UMB_LANGUAGE_STORE_CONTEXT_TOKEN = new UmbContextToken(UmbLanguageStore.name); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/manifests.ts new file mode 100644 index 0000000000..7020f13c22 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/manifests.ts @@ -0,0 +1,13 @@ +import { UmbLanguageRepository } from '../repository/language.repository'; +import { ManifestRepository } from 'libs/extensions-registry/repository.models'; + +export const LANGUAGE_REPOSITORY_ALIAS = 'Umb.Repository.Languages'; + +const repository: ManifestRepository = { + type: 'repository', + alias: LANGUAGE_REPOSITORY_ALIAS, + name: 'Languages Repository', + class: UmbLanguageRepository, +}; + +export const manifests = [repository]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/index.ts new file mode 100644 index 0000000000..81efc0303f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/index.ts @@ -0,0 +1,16 @@ +import { LanguageModel, PagedLanguageModel } from '@umbraco-cms/backend-api'; +import type { DataSourceResponse } from '@umbraco-cms/models'; +import { RepositoryDetailDataSource } from '@umbraco-cms/repository'; + +// TODO: This is a temporary solution until we have a proper paging interface +type paging = { + skip: number; + take: number; +}; + +export interface UmbLanguageDataSource extends RepositoryDetailDataSource { + createScaffold(): Promise>; + get(isoCode: string): Promise>; + delete(isoCode: string): Promise>; + getCollection(paging: paging): Promise>; +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/language.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/language.server.data.ts new file mode 100644 index 0000000000..cca5af2960 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/repository/sources/language.server.data.ts @@ -0,0 +1,120 @@ +import { ProblemDetailsModel, LanguageResource, LanguageModel } from '@umbraco-cms/backend-api'; +import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { tryExecuteAndNotify } from '@umbraco-cms/resources'; + +/** + * A data source for the Language that fetches data from the server + * @export + * @class UmbLanguageServerDataSource + * @implements {RepositoryDetailDataSource} + */ +export class UmbLanguageServerDataSource implements UmbLanguageServerDataSource { + #host: UmbControllerHostInterface; + + /** + * Creates an instance of UmbLanguageServerDataSource. + * @param {UmbControllerHostInterface} host + * @memberof UmbLanguageServerDataSource + */ + constructor(host: UmbControllerHostInterface) { + this.#host = host; + } + + /** + * Fetches a Language with the given iso code from the server + * @param {string} isoCode + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async get(isoCode: string) { + if (!isoCode) { + const error: ProblemDetailsModel = { title: 'Iso Code is missing' }; + return { error }; + } + + return tryExecuteAndNotify( + this.#host, + LanguageResource.getLanguageByIsoCode({ + isoCode, + }) + ); + } + + /** + * Creates a new Language scaffold + * @param + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async createScaffold() { + const data: LanguageModel = { + name: '', + isDefault: false, + isMandatory: false, + fallbackIsoCode: '', + isoCode: '', + }; + + return { data }; + } + + /** + * Inserts a new Language on the server + * @param {LanguageModel} language + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async insert(language: LanguageModel) { + if (!language.isoCode) { + const error: ProblemDetailsModel = { title: 'Language iso code is missing' }; + return { error }; + } + + return tryExecuteAndNotify(this.#host, LanguageResource.postLanguage({ requestBody: language })); + } + + /** + * Updates a Language on the server + * @param {LanguageModel} language + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async update(language: LanguageModel) { + if (!language.isoCode) { + const error: ProblemDetailsModel = { title: 'Language iso code is missing' }; + return { error }; + } + + return tryExecuteAndNotify( + this.#host, + LanguageResource.putLanguageByIsoCode({ isoCode: language.isoCode, requestBody: language }) + ); + } + + /** + * Deletes a Language on the server + * @param {string} isoCode + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async delete(isoCode: string) { + if (!isoCode) { + const error: ProblemDetailsModel = { title: 'Iso code is missing' }; + return { error }; + } + + return tryExecuteAndNotify( + this.#host, + tryExecuteAndNotify(this.#host, LanguageResource.deleteLanguageByIsoCode({ isoCode })).then(() => isoCode) + ); + } + + /** + * Get a list of Languages on the server + * @return {*} + * @memberof UmbLanguageServerDataSource + */ + async getCollection({ skip, take }: { skip: number; take: number }) { + return tryExecuteAndNotify(this.#host, LanguageResource.getLanguage({ skip, take })); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-table-delete-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-table-delete-column-layout.element.ts index ac3b22a1f4..d318a967fa 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-table-delete-column-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-table-delete-column-layout.element.ts @@ -1,64 +1,47 @@ import { UUITextStyles } from '@umbraco-ui/uui-css'; import { css, html, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { UmbLanguageStore, UmbLanguageStoreItemType, UMB_LANGUAGE_STORE_CONTEXT_TOKEN } from '../../language.store'; -import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '../../../../../core/modal'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; import { UmbLitElement } from '@umbraco-cms/element'; +import { LanguageModel } from '@umbraco-cms/backend-api'; @customElement('umb-language-root-table-delete-column-layout') export class UmbLanguageRootTableDeleteColumnLayoutElement extends UmbLitElement { static styles = [UUITextStyles, css``]; @property({ attribute: false }) - value!: UmbLanguageStoreItemType; + value!: LanguageModel; - #languageStore?: UmbLanguageStore; - #modalService?: UmbModalService; + @state() + _isOpen = false; - constructor() { - super(); - this.consumeContext(UMB_LANGUAGE_STORE_CONTEXT_TOKEN, (instance) => { - this.#languageStore = instance; - }); - - this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (instance) => { - this.#modalService = instance; - }); + #onActionExecuted() { + this._isOpen = false; } - #handleDelete(event: MouseEvent) { - event.stopImmediatePropagation(); - if (!this.#languageStore) return; + #onClick() { + this._isOpen = !this._isOpen; + } - const modalHandler = this.#modalService?.confirm({ - headline: 'Delete language', - content: html` -
- This will delete language ${this.value.name}. -
- Are you sure you want to delete? - `, - color: 'danger', - confirmLabel: 'Delete', - }); - - modalHandler?.onClose().then(({ confirmed }) => { - if (confirmed) { - this.#languageStore?.delete([this.value.isoCode!]); - } - }); + #onClose() { + this._isOpen = false; } render() { + // TODO: we need to use conditionals on each action here. But until we have that in place + // we'll just remove all actions on the default language. if (this.value.isDefault) return nothing; - return html``; + return html` + + + + + `; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-workspace.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-workspace.element.ts index 28e918ee79..aba3ffb008 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-workspace.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language-root/language-root-workspace.element.ts @@ -1,17 +1,16 @@ import { UUITextStyles } from '@umbraco-ui/uui-css'; import { css, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import { UmbLanguageStore, UmbLanguageStoreItemType, UMB_LANGUAGE_STORE_CONTEXT_TOKEN } from '../../language.store'; import { UmbTableColumn, UmbTableConfig, UmbTableItem } from '../../../../shared/components/table'; -import { UmbWorkspaceEntityElement } from '../../../../shared/components/workspace/workspace-entity-element.interface'; +import { UmbLanguageRepository } from '../../repository/language.repository'; import { UmbLitElement } from '@umbraco-cms/element'; +import { LanguageModel } from '@umbraco-cms/backend-api'; -import '../language/language-workspace.element'; import './language-root-table-delete-column-layout.element'; import './language-root-table-name-column-layout.element'; @customElement('umb-language-root-workspace') -export class UmbLanguageRootWorkspaceElement extends UmbLitElement implements UmbWorkspaceEntityElement { +export class UmbLanguageRootWorkspaceElement extends UmbLitElement { static styles = [ UUITextStyles, css` @@ -35,7 +34,7 @@ export class UmbLanguageRootWorkspaceElement extends UmbLitElement implements Um @state() private _tableColumns: Array = [ { - name: 'Language', + name: 'Name', alias: 'languageName', elementName: 'umb-language-root-table-name-column-layout', }, @@ -44,15 +43,15 @@ export class UmbLanguageRootWorkspaceElement extends UmbLitElement implements Um alias: 'isoCode', }, { - name: 'Default language', + name: 'Default', alias: 'defaultLanguage', }, { - name: 'Mandatory language', + name: 'Mandatory', alias: 'mandatoryLanguage', }, { - name: 'Fall back language', + name: 'Fallback', alias: 'fallBackLanguage', }, { @@ -65,32 +64,22 @@ export class UmbLanguageRootWorkspaceElement extends UmbLitElement implements Um @state() private _tableItems: Array = []; - #languageStore?: UmbLanguageStore; + #languageRepository = new UmbLanguageRepository(this); - constructor() { - super(); - - this.consumeContext(UMB_LANGUAGE_STORE_CONTEXT_TOKEN, (instance) => { - this.#languageStore = instance; - this.#observeLanguages(); - }); + connectedCallback() { + super.connectedCallback(); + this.#observeLanguages(); } - load(): void { - // Not relevant for this workspace + async #observeLanguages() { + const { asObservable } = await this.#languageRepository.requestLanguages(); + + if (asObservable) { + this.observe(asObservable(), (languages) => this.#createTableItems(languages)); + } } - create(): void { - // Not relevant for this workspace - } - - #observeLanguages() { - this.#languageStore?.getAll().subscribe((languages) => { - this.#createTableItems(languages); - }); - } - - #createTableItems(languages: Array) { + #createTableItems(languages: Array) { this._tableItems = languages.map((language) => { return { key: language.isoCode ?? '', @@ -139,6 +128,7 @@ export class UmbLanguageRootWorkspaceElement extends UmbLitElement implements Um color="default" href="section/settings/language/create/root">
+ diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.context.ts index abd98b217b..8744f0c8f9 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.context.ts @@ -1,70 +1,66 @@ -import { UmbLanguageStore, UmbLanguageStoreItemType, UMB_LANGUAGE_STORE_CONTEXT_TOKEN } from '../../language.store'; +import { UmbLanguageRepository } from '../../repository/language.repository'; +import { UmbWorkspaceContext } from '../../../../shared/components/workspace/workspace-context/workspace-context'; +import type { LanguageModel } from '@umbraco-cms/backend-api'; +import { ObjectState } from '@umbraco-cms/observable-api'; import { UmbControllerHostInterface } from '@umbraco-cms/controller'; -import { ObjectState, UmbObserverController } from '@umbraco-cms/observable-api'; -import { UmbContextConsumerController } from '@umbraco-cms/context-api'; -const DefaultLanguageData: UmbLanguageStoreItemType = { - name: '', - isoCode: '', - isDefault: false, - isMandatory: false, -}; +export class UmbLanguageWorkspaceContext extends UmbWorkspaceContext { + #host: UmbControllerHostInterface; + #languageRepository: UmbLanguageRepository; -export class UmbWorkspaceLanguageContext { - public host: UmbControllerHostInterface; + #data = new ObjectState(undefined); + data = this.#data.asObservable(); - #entityKey: string | null; - - #data; - public readonly data; - - #store: UmbLanguageStore | null = null; - protected _storeObserver?: UmbObserverController; - - constructor(host: UmbControllerHostInterface, entityKey: string | null) { - this.host = host; - this.#entityKey = entityKey; - - this.#data = new ObjectState(DefaultLanguageData); - this.data = this.#data.asObservable(); - - new UmbContextConsumerController(host, UMB_LANGUAGE_STORE_CONTEXT_TOKEN, (_instance: UmbLanguageStore) => { - this.#store = _instance; - this.#observeStore(); - }); + constructor(host: UmbControllerHostInterface) { + super(host); + this.#host = host; + this.#languageRepository = new UmbLanguageRepository(this.#host); } - #observeStore(): void { - if (!this.#store || this.#entityKey === null) { - return; + async load(isoCode: string) { + const { data } = await this.#languageRepository.requestByIsoCode(isoCode); + if (data) { + this.setIsNew(false); + this.#data.update(data); } - - this._storeObserver?.destroy(); - this._storeObserver = new UmbObserverController(this.host, this.#store.getByIsoCode(this.#entityKey), (content) => { - if (!content) return; // TODO: Handle nicely if there is no content data. - this.update(content); - }); } - public getData() { + async createScaffold() { + const { data } = await this.#languageRepository.createScaffold(); + if (!data) return; + this.setIsNew(true); + this.#data.update(data); + } + + getData() { return this.#data.getValue(); } - public getAvailableCultures() { - //TODO: Don't use !, however this will be changed with the introduction of repositories. - return this.#store!.getAvailableCultures(); + getEntityType() { + return 'language'; } - public update(data: Partial) { - this.#data.next({ ...this.getData(), ...data }); + setName(name: string) { + this.#data.update({ name }); } - public save(): Promise { - if (!this.#store) { - // TODO: more beautiful error: - console.error('Could not save cause workspace context has no store.'); - return Promise.resolve(); - } - return this.#store.save(this.getData()); + setCulture(isoCode: string) { + this.#data.update({ isoCode }); + } + + setMandatory(isMandatory: boolean) { + this.#data.update({ isMandatory }); + } + + setDefault(isDefault: boolean) { + this.#data.update({ isDefault }); + } + + setFallbackCulture(isoCode: string) { + this.#data.update({ fallbackIsoCode: isoCode }); + } + + destroy(): void { + this.#data.complete(); } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.element.ts index 7bf01a12ef..409d475777 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/language-workspace.element.ts @@ -1,12 +1,12 @@ import { UUITextStyles } from '@umbraco-ui/uui-css'; import { css, html, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, state } from 'lit/decorators.js'; import { UUIInputElement, UUIInputEvent } from '@umbraco-ui/uui'; -import { UmbLanguageStoreItemType } from '../../language.store'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; import { UmbWorkspaceEntityElement } from '../../../../shared/components/workspace/workspace-entity-element.interface'; -import { UmbWorkspaceLanguageContext } from './language-workspace.context'; +import { UmbLanguageWorkspaceContext } from './language-workspace.context'; import { UmbLitElement } from '@umbraco-cms/element'; -import '../../../../shared/components/workspace/workspace-action/save/workspace-action-node-save.element.ts'; +import { LanguageModel } from '@umbraco-cms/backend-api'; @customElement('umb-language-workspace') export class UmbLanguageWorkspaceElement extends UmbLitElement implements UmbWorkspaceEntityElement { @@ -25,25 +25,25 @@ export class UmbLanguageWorkspaceElement extends UmbLitElement implements UmbWor `, ]; - @property() - language?: UmbLanguageStoreItemType; + @state() + _language?: LanguageModel; - #languageWorkspaceContext?: UmbWorkspaceLanguageContext; + #languageWorkspaceContext = new UmbLanguageWorkspaceContext(this); + + constructor() { + super(); + + this.observe(this.#languageWorkspaceContext.data, (data) => { + this._language = data; + }); + } load(key: string): void { - this.provideLanguageWorkspaceContext(key); + this.#languageWorkspaceContext.load(key); } - create(parentKey: string | null): void { - this.provideLanguageWorkspaceContext(parentKey); - } - - public provideLanguageWorkspaceContext(entityKey: string | null) { - this.#languageWorkspaceContext = new UmbWorkspaceLanguageContext(this, entityKey); - this.provideContext('umbWorkspaceContext', this.#languageWorkspaceContext); - this.#languageWorkspaceContext.data.subscribe((language) => { - this.language = language; - }); + create(): void { + this.#languageWorkspaceContext.createScaffold(); } #handleInput(event: UUIInputEvent) { @@ -51,13 +51,13 @@ export class UmbLanguageWorkspaceElement extends UmbLitElement implements UmbWor const target = event.composedPath()[0] as UUIInputElement; if (typeof target?.value === 'string') { - this.#languageWorkspaceContext?.update({ name: target.value }); + this.#languageWorkspaceContext?.setName(target.value); } } } render() { - if (!this.language) return nothing; + if (!this._language) return nothing; return html` @@ -65,7 +65,7 @@ export class UmbLanguageWorkspaceElement extends UmbLitElement implements UmbWor - + `; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/manifests.ts index 1c686ba026..b2581f133d 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/manifests.ts @@ -1,3 +1,5 @@ +import { UmbSaveWorkspaceAction } from '../../../../shared/workspace-actions/save.action'; +import { LANGUAGE_REPOSITORY_ALIAS } from '../../repository/manifests'; import type { ManifestWorkspace, ManifestWorkspaceAction, ManifestWorkspaceView } from '@umbraco-cms/models'; const workspace: ManifestWorkspace = { @@ -10,27 +12,12 @@ const workspace: ManifestWorkspace = { }, }; -const workspaceActions: Array = [ - { - type: 'workspaceAction', - alias: 'Umb.WorkspaceAction.Language.Save', - name: 'Save Language Workspace Action', - loader: () => - import('../../../../shared/components/workspace/workspace-action/save/workspace-action-node-save.element'), - meta: { - workspaces: ['Umb.Workspace.Language'], - look: 'primary', - color: 'positive', - }, - }, -]; - const workspaceViews: Array = [ { type: 'workspaceView', alias: 'Umb.WorkspaceView.Language.Edit', name: 'Language Workspace Edit View', - loader: () => import('./views/edit/workspace-view-language-edit.element'), + loader: () => import('./views/edit/edit-language-workspace-view.element'), weight: 90, meta: { workspaces: ['Umb.Workspace.Language'], @@ -41,4 +28,20 @@ const workspaceViews: Array = [ }, ]; +const workspaceActions: Array = [ + { + type: 'workspaceAction', + alias: 'Umb.WorkspaceAction.Language.Save', + name: 'Save Language Workspace Action', + meta: { + workspaces: ['Umb.Workspace.Language'], + look: 'primary', + color: 'positive', + label: 'Save', + repositoryAlias: LANGUAGE_REPOSITORY_ALIAS, + api: UmbSaveWorkspaceAction, + }, + }, +]; + export const manifests = [workspace, ...workspaceViews, ...workspaceActions]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts new file mode 100644 index 0000000000..751237ecd4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts @@ -0,0 +1,198 @@ +import { UUIBooleanInputEvent, UUIToggleElement } from '@umbraco-ui/uui'; +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { css, html, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { UmbLanguageWorkspaceContext } from '../../language-workspace.context'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { LanguageModel } from '@umbraco-cms/backend-api'; +import { UmbChangeEvent } from 'src/core/events'; +import UmbInputCultureSelectElement from 'src/backoffice/shared/components/input-culture-select/input-culture-select.element'; +import UmbInputLanguagePickerElement from 'src/backoffice/shared/components/input-language-picker/input-language-picker.element'; + +@customElement('umb-edit-language-workspace-view') +export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { + static styles = [ + UUITextStyles, + css` + :host { + display: block; + padding: var(--uui-size-space-6); + } + + uui-combobox { + width: 100%; + } + + hr { + border: none; + border-bottom: 1px solid var(--uui-color-divider); + } + + #default-language-warning { + background-color: var(--uui-color-warning); + color: var(--uui-color-warning-contrast); + border-color: var(--uui-color-warning-standalone); + padding: var(--uui-size-space-4) var(--uui-size-space-5); + border: 1px solid; + margin-top: var(--uui-size-space-4); + border-radius: var(--uui-border-radius); + } + `, + ]; + + @state() + _language?: LanguageModel; + + @state() + _isDefaultLanguage = false; + + @state() + _isNew = false; + + #languageWorkspaceContext?: UmbLanguageWorkspaceContext; + + constructor() { + super(); + + let initialStateSet = false; + + this.consumeContext('umbWorkspaceContext', (instance) => { + this.#languageWorkspaceContext = instance; + + this.observe(this.#languageWorkspaceContext.data, (language) => { + this._language = language; + + /* Store the initial value of the default language. + When we change the default language we get notified of the change, + and we need the initial value to compare against */ + if (initialStateSet === false) { + this._isDefaultLanguage = language?.isDefault ?? false; + initialStateSet = true; + } + }); + + this.observe(this.#languageWorkspaceContext.isNew, (value) => { + this._isNew = value; + }); + }); + } + + #handleCultureChange(event: Event) { + if (event instanceof UmbChangeEvent) { + const target = event.target as UmbInputCultureSelectElement; + const isoCode = target.value.toString(); + const cultureName = target.selectedCultureName; + + if (!isoCode) { + // If the isoCode is empty, we reset the value to the original value. + // Provides a way better UX + //TODO: Maybe the combobox should implement something similar? + const resetFunction = () => { + target.value = this._language?.isoCode ?? ''; + }; + + target.addEventListener('close', resetFunction, { once: true }); + target.addEventListener('blur', resetFunction, { once: true }); + return; + } + + this.#languageWorkspaceContext?.setCulture(isoCode); + + // If the language name is not set, we set it to the name of the selected language. + if (!this._language?.name && cultureName) { + this.#languageWorkspaceContext?.setName(cultureName); + } + } + } + + #handleDefaultChange(event: UUIBooleanInputEvent) { + if (event instanceof UUIBooleanInputEvent) { + const target = event.composedPath()[0] as UUIToggleElement; + this.#languageWorkspaceContext?.setDefault(target.checked); + } + } + + #handleMandatoryChange(event: UUIBooleanInputEvent) { + if (event instanceof UUIBooleanInputEvent) { + const target = event.composedPath()[0] as UUIToggleElement; + this.#languageWorkspaceContext?.setMandatory(target.checked); + } + } + + #handleFallbackChange(event: UmbChangeEvent) { + if (event instanceof UmbChangeEvent) { + const target = event.target as UmbInputLanguagePickerElement; + const selectedLanguageIsoCode = target.selectedIsoCodes?.[0]; + this.#languageWorkspaceContext?.setFallbackCulture(selectedLanguageIsoCode); + } + } + + render() { + if (!this._language) return nothing; + + return html` + + +
+ +
+
+ + +
${this._language.isoCode}
+
+ + +
+ +
+ Default language +
An Umbraco site can only have one default language set.
+
+
+ + + ${this._language.isDefault !== this._isDefaultLanguage + ? html`
+ Switching default language may result in default content missing. +
` + : nothing} + +
+ +
+ Mandatory language +
Properties on this language have to be filled out before the node can be published.
+
+
+
+
+ + + + +
+ `; + } +} + +export default UmbEditLanguageWorkspaceViewElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-edit-language-workspace-view': UmbEditLanguageWorkspaceViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/workspace-view-language-edit.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/workspace-view-language-edit.element.ts deleted file mode 100644 index 831f403ca0..0000000000 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/workspace-view-language-edit.element.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { UUIBooleanInputEvent, UUIComboboxElement, UUIComboboxEvent, UUIToggleElement } from '@umbraco-ui/uui'; -import { UUITextStyles } from '@umbraco-ui/uui-css'; -import { css, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { customElement, property, state } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { UmbWorkspaceLanguageContext } from '../../language-workspace.context'; -import { - UmbLanguageStore, - UmbLanguageStoreItemType, - UMB_LANGUAGE_STORE_CONTEXT_TOKEN, -} from '../../../../language.store'; -import { UmbLitElement } from '@umbraco-cms/element'; -import { CultureModel, LanguageModel } from '@umbraco-cms/backend-api'; - -@customElement('umb-workspace-view-language-edit') -export class UmbWorkspaceViewLanguageEditElement extends UmbLitElement { - static styles = [ - UUITextStyles, - css` - :host { - display: block; - padding: var(--uui-size-space-6); - } - uui-combobox { - width: 100%; - } - hr { - border: none; - border-bottom: 1px solid var(--uui-color-divider); - } - #culture-warning, - #default-language-warning { - padding: var(--uui-size-space-4) var(--uui-size-space-5); - border: 1px solid; - margin-top: var(--uui-size-space-4); - border-radius: var(--uui-border-radius); - } - #culture-warning { - background-color: var(--uui-color-danger); - color: var(--uui-color-danger-contrast); - border-color: var(--uui-color-danger-standalone); - } - #default-language-warning { - background-color: var(--uui-color-warning); - color: var(--uui-color-warning-contrast); - border-color: var(--uui-color-warning-standalone); - } - `, - ]; - - @property() - language?: UmbLanguageStoreItemType; - - @state() - private _languages: UmbLanguageStoreItemType[] = []; - - @state() - private _availableCultures: CultureModel[] = []; - - @state() - private _search = ''; - - @state() - private _startData: LanguageModel | null = null; - - #languageWorkspaceContext?: UmbWorkspaceLanguageContext; - - constructor() { - super(); - - this.consumeContext('umbWorkspaceContext', (instance) => { - this.#languageWorkspaceContext = instance; - - if (!this.#languageWorkspaceContext) return; - - this.observe(this.#languageWorkspaceContext.data, (language) => { - this.language = language; - - if (this._startData === null) { - this._startData = language; - } - }); - this.observe(this.#languageWorkspaceContext.getAvailableCultures(), (cultures) => { - this._availableCultures = cultures; - }); - }); - - this.consumeContext(UMB_LANGUAGE_STORE_CONTEXT_TOKEN, (instance: UmbLanguageStore) => { - if (!instance) return; - - instance.getAll().subscribe((languages: Array) => { - this._languages = languages; - }); - }); - } - - #handleLanguageChange(event: Event) { - if (event instanceof UUIComboboxEvent) { - const target = event.composedPath()[0] as UUIComboboxElement; - const isoCode = target.value.toString(); - - if (isoCode) { - this.#languageWorkspaceContext?.update({ isoCode }); - - // If the language name is not set, we set it to the name of the selected language. - if (!this.language?.name) { - const language = this._availableCultures.find((culture) => culture.name === isoCode); - if (language) { - this.#languageWorkspaceContext?.update({ name: language.name }); - } - } - } else { - // If the isoCode is empty, we reset the value to the original value. - // Provides a way better UX - //TODO: Maybe the combobox should implement something similar? - const resetFunction = () => { - target.value = this.language?.isoCode ?? ''; - }; - - target.addEventListener('close', resetFunction, { once: true }); - target.addEventListener('blur', resetFunction, { once: true }); - } - } - } - - // TODO: move some of these methods to the context - #handleSearchChange(event: Event) { - const target = event.composedPath()[0] as UUIComboboxElement; - this._search = target.search; - } - - #handleDefaultChange(event: UUIBooleanInputEvent) { - if (event instanceof UUIBooleanInputEvent) { - const target = event.composedPath()[0] as UUIToggleElement; - - this.#languageWorkspaceContext?.update({ isDefault: target.checked }); - } - } - - #handleMandatoryChange(event: UUIBooleanInputEvent) { - if (event instanceof UUIBooleanInputEvent) { - const target = event.composedPath()[0] as UUIToggleElement; - - this.#languageWorkspaceContext?.update({ isMandatory: target.checked }); - } - } - - #handleFallbackChange(event: UUIComboboxEvent) { - if (event instanceof UUIComboboxEvent) { - const target = event.composedPath()[0] as UUIComboboxElement; - this.#languageWorkspaceContext?.update({ fallbackIsoCode: target.value.toString() }); - } - } - - get #filteredCultures(): Array { - return this._availableCultures.filter((culture) => { - return culture.englishName?.toLowerCase().includes(this._search.toLowerCase()); - }); - } - - get #fallbackLanguages() { - return this._languages.filter((language) => { - return language.isoCode !== this.language?.isoCode; - }); - } - - get #fallbackLanguage() { - return this.#fallbackLanguages.find((language) => language.isoCode === this.language?.fallbackIsoCode); - } - - get #fromAvailableCultures() { - return this._availableCultures.find((culture) => culture.name === this.language?.isoCode); - } - - #renderCultureWarning() { - if (!this._startData?.isoCode || this._startData?.isoCode === this.language?.isoCode) return nothing; - - return html`
- Changing the culture for a language may be an expensive operation and will result in the content cache and indexes - being rebuilt. -
`; - } - - #renderDefaultLanguageWarning() { - if (this._startData?.isDefault || this.language?.isDefault !== true) return nothing; - - return html`
- Switching default language may result in default content missing. -
`; - } - - render() { - if (!this.language) return nothing; - - return html` - - -
- - - ${repeat( - this.#filteredCultures, - (language) => language.name, - (language) => - html` - ${language.englishName} - ` - )} - - - ${this.#renderCultureWarning()} -
-
- -
${this.language.isoCode}
-
- -
- -
- Default language -
An Umbraco site can only have one default language set.
-
-
- ${this.#renderDefaultLanguageWarning()} -
- -
- Mandatory language -
Properties on this language have to be filled out before the node can be published.
-
-
-
-
- - - - ${repeat( - this.#fallbackLanguages, - (language) => language.isoCode, - (language) => - html` - ${language.name} - ` - )} - - - -
- `; - } -} - -export default UmbWorkspaceViewLanguageEditElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-workspace-view-language-edit': UmbWorkspaceViewLanguageEditElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/body-layout/body-layout.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/body-layout/body-layout.element.ts index 1f12bd5873..0770f98a60 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/body-layout/body-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/body-layout/body-layout.element.ts @@ -20,7 +20,7 @@ export class UmbBodyLayout extends LitElement { align-items: center; justify-content: space-between; width: 100%; - min-height: 60px; + height: 70px; background-color: var(--uui-color-surface); border-bottom: 1px solid var(--uui-color-border); box-sizing: border-box; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.element.ts new file mode 100644 index 0000000000..c3567dd204 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.element.ts @@ -0,0 +1,232 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { css, html, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { UmbContextDebugRequest } from '@umbraco-cms/context-api'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/modal'; + +@customElement('umb-debug') +export class UmbDebug extends UmbLitElement { + static styles = [ + UUITextStyles, + css` + #container { + display: block; + font-family: monospace; + + z-index: 10000; + + position: relative; + width: 100%; + padding: 10px 0; + } + + uui-badge { + cursor: pointer; + } + + uui-icon { + font-size: 15px; + } + + .events { + background-color: var(--uui-color-danger); + color: var(--uui-color-selected-contrast); + max-height: 0; + transition: max-height 0.25s ease-out; + overflow: hidden; + } + + .events.open { + max-height: 500px; + overflow: auto; + } + + .events > div { + padding: 10px; + } + + h4 { + margin: 0; + } + `, + ]; + + @property({ reflect: true, type: Boolean }) + enabled = false; + + @property({ reflect: true, type: Boolean }) + dialog = false; + + @property() + contexts = new Map(); + + @state() + private _debugPaneOpen = false; + + private _modalService?: UmbModalService; + + constructor() { + super(); + this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (modalService) => { + this._modalService = modalService; + }); + } + + connectedCallback(): void { + super.connectedCallback(); + + // Dispatch it + this.dispatchEvent( + new UmbContextDebugRequest((contexts: Map) => { + // The Contexts are collected + // When travelling up through the DOM from this element + // to the root of which then uses the callback prop + // of the this event tha has been raised to assign the contexts + // back to this property of the WebComponent + this.contexts = contexts; + }) + ); + } + + render() { + if (this.enabled) { + return this.dialog ? this._renderDialog() : this._renderPanel(); + } else { + return nothing; + } + } + + private _toggleDebugPane() { + this._debugPaneOpen = !this._debugPaneOpen; + } + + private async _openDialog() { + // Open a modal that uses the HTML component called 'umb-debug-modal-layout' + await import('./debug.modal.element.js'); + this._modalService?.open('umb-debug-modal-layout', { + size: 'small', + type: 'sidebar', + data: { + content: this._renderContextAliases(), + }, + }); + } + + private _renderDialog() { + return html`
+ + Debug + +
`; + } + + private _renderPanel() { + return html`
+ + + Debug + + +
+
+
    + ${this._renderContextAliases()} +
+
+
+
`; + } + + private _renderContextAliases() { + const contextsTemplates: TemplateResult[] = []; + + for (const [alias, instance] of this.contexts) { + contextsTemplates.push( + html`
  • + Context: ${alias} + (${typeof instance}) +
      + ${this._renderInstance(instance)} +
    +
  • ` + ); + } + + return contextsTemplates; + } + + private _renderInstance(instance: any) { + const instanceTemplates: TemplateResult[] = []; + + // TODO: WB - Maybe make this a switch statement? + if (typeof instance === 'function') { + return instanceTemplates.push(html`
  • Callable Function
  • `); + } else if (typeof instance === 'object') { + const methodNames = this.getClassMethodNames(instance); + if (methodNames.length) { + instanceTemplates.push( + html` +
  • + Methods +
      + ${methodNames.map((methodName) => html`
    • ${methodName}
    • `)} +
    +
  • + ` + ); + } + + const props: TemplateResult[] = []; + + for (const key in instance) { + if (key.startsWith('_')) { + continue; + } + + const value = instance[key]; + if (typeof value === 'string') { + props.push(html`
  • ${key} = ${value}
  • `); + } else { + props.push(html`
  • ${key} (${typeof value})
  • `); + } + } + + instanceTemplates.push(html` +
  • + Properties +
      + ${props} +
    +
  • + `); + } else { + instanceTemplates.push(html`
  • Context is a primitive with value: ${instance}
  • `); + } + + return instanceTemplates; + } + + private getClassMethodNames(klass: any) { + const isGetter = (x: any, name: string): boolean => !!(Object.getOwnPropertyDescriptor(x, name) || {}).get; + const isFunction = (x: any, name: string): boolean => typeof x[name] === 'function'; + const deepFunctions = (x: any): any => + x !== Object.prototype && + Object.getOwnPropertyNames(x) + .filter((name) => isGetter(x, name) || isFunction(x, name)) + .concat(deepFunctions(Object.getPrototypeOf(x)) || []); + const distinctDeepFunctions = (klass: any) => Array.from(new Set(deepFunctions(klass))); + + const allMethods = + typeof klass.prototype === 'undefined' + ? distinctDeepFunctions(klass) + : Object.getOwnPropertyNames(klass.prototype); + return allMethods.filter((name: any) => name !== 'constructor' && !name.startsWith('_')); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-debug': UmbDebug; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.modal.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.modal.element.ts new file mode 100644 index 0000000000..d9d370aaf0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/debug.modal.element.ts @@ -0,0 +1,79 @@ +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { UmbModalLayoutElement } from '@umbraco-cms/modal'; + +export interface UmbDebugModalData { + content: TemplateResult | string; +} + +@customElement('umb-debug-modal-layout') +export default class UmbDebugModalLayout extends UmbModalLayoutElement { + static styles = [ + UUITextStyles, + css` + uui-dialog-layout { + display: flex; + flex-direction: column; + height: 100%; + + padding: var(--uui-size-space-5); + box-sizing: border-box; + } + + uui-scroll-container { + overflow-y: scroll; + max-height: 100%; + min-height: 0; + flex: 1; + } + + uui-icon { + vertical-align: text-top; + color: var(--uui-color-danger); + } + + .context { + padding: 15px 0; + border-bottom: 1px solid var(--uui-color-danger-emphasis); + } + + h3 { + margin-top: 0; + margin-bottom: 0; + } + + h3 > span { + border-radius: var(--uui-size-4); + background-color: var(--uui-color-danger); + color: var(--uui-color-danger-contrast); + padding: 8px; + font-size: 12px; + } + + ul { + margin-top: 0; + } + `, + ]; + + private _handleClose() { + this.modalHandler?.close(); + } + + render() { + return html` + + Debug: Contexts + ${this.data?.content} + Close + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-debug-modal-layout': UmbDebugModalLayout; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/debug.stories.mdx b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/debug.stories.mdx new file mode 100644 index 0000000000..3497502ad5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/debug.stories.mdx @@ -0,0 +1,44 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs'; +import DebugDialogImage from './umb-debug-dialog.jpg'; +import DebugImage from './umb-debug.jpg'; + + + + + + +# Debugging + +## Debugging Contexts + +The component `` allows you to discover the available contexts from the current DOM element, that you are able to consume and use. + +For example it will help you as a package developer or implementor to know you are able to consume the `DigalogService` and quickly see what properties and methods are available to use. + +This can help with the developer experience to quickly see what is available to use and how to use it. + +### Usage +The `` component can be used in two different ways, either as a button or as a dialog. By default it is rendered as a button and the debug information about available contexts is dissplayed inline to where the element is placed. + + +```typescript +// This will add a Debug button to the UI and once clicked the information about avilable contextes will slide down + +``` + +#### Dialog +This example uses an additional property/attribute `dialog` which adds a smaller badge to the UI as opposed to a button and will open the information in a small dialog/modal from the right hand side, this may be more useful to use when space is limited in the UI to add a button and pane of information directly to where the element is placed. + + +```typescript +// This will open the debug information in a small dialog/modal from the right hand side + +``` + +#### Disable +You may wish to temporarily hide or disable the debug information but return to it later on in the development process. + +```typescript +// To hide or remove the button ensure you remove the enabled attribute or set the enabled property to false + +``` diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug-dialog.jpg b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug-dialog.jpg new file mode 100644 index 0000000000..449ae6dc14 Binary files /dev/null and b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug-dialog.jpg differ diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug.jpg b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug.jpg new file mode 100644 index 0000000000..a33e519014 Binary files /dev/null and b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/debug/stories/umb-debug.jpg differ diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/dropdown/dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/dropdown/dropdown.element.ts new file mode 100644 index 0000000000..ab21ee4f5e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/dropdown/dropdown.element.ts @@ -0,0 +1,54 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { css, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { UmbLitElement } from '@umbraco-cms/element'; + +@customElement('umb-dropdown') +export class UmbDropdownElement extends UmbLitElement { + static styles = [ + UUITextStyles, + css` + #container { + display: inline-block; + } + + #dropdown { + overflow: hidden; + z-index: -1; + background-color: var(--uui-combobox-popover-background-color, var(--uui-color-surface)); + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-border-radius); + width: 100%; + height: 100%; + box-sizing: border-box; + box-shadow: var(--uui-shadow-depth-3); + } + `, + ]; + + @property({ type: Boolean, reflect: true }) + open = false; + + render() { + return html` + + + ${this.open ? this.#renderDropdown() : nothing} + + `; + } + + #renderDropdown() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-dropdown': UmbDropdownElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts index f9c6a0d778..a30c9f44ad 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts @@ -1,4 +1,5 @@ //TODO: we need to figure out what components should be available for extensions and load them upfront +// TODO: we need to move these files into their respective folders/silos. We then need a way for a silo to globally register a component import './backoffice-frame/backoffice-header.element'; import './backoffice-frame/backoffice-main.element'; import './backoffice-frame/backoffice-modal-container.element'; @@ -8,6 +9,7 @@ import './ref-property-editor-ui/ref-property-editor-ui.element'; import './property-type-based-property/property-type-based-property.element'; import './table/table.element'; import './code-block/code-block.element'; +import './dropdown/dropdown.element'; import './extension-slot/extension-slot.element'; import './workspace/workspace-layout/workspace-layout.element'; @@ -27,6 +29,13 @@ import '../entity-actions/entity-action-list.element'; import './input-media-picker/input-media-picker.element'; import './input-document-picker/input-document-picker.element'; +import './input-language-picker/input-language-picker.element'; +import './input-culture-select/input-culture-select.element'; +import './input-color-picker/input-color-picker.element'; +import './input-eye-dropper/input-eye-dropper.element'; +import './input-checkbox-list/input-checkbox-list.element'; +import './input-multi-url-picker/input-multi-url-picker.element'; import './empty-state/empty-state.element'; -import './color-picker/color-picker.element'; + +import './debug/debug.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.element.ts similarity index 68% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.element.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.element.ts index 7532076503..cc2c423039 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.element.ts @@ -1,19 +1,20 @@ -import { css, html } from 'lit'; +import { html } from 'lit'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { customElement, property } from 'lit/decorators.js'; import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; import { UUIColorSwatchesEvent } from '@umbraco-ui/uui'; import { UmbLitElement } from '@umbraco-cms/element'; +import type { SwatchDetails } from '@umbraco-cms/models'; -@customElement('umb-color-picker') -export class UmbColorPickerElement extends FormControlMixin(UmbLitElement) { +@customElement('umb-input-color-picker') +export class UmbInputColorPickerElement extends FormControlMixin(UmbLitElement) { static styles = [UUITextStyles]; @property({ type: Boolean }) showLabels = false; @property() - colors?: string[]; + swatches?: SwatchDetails[]; constructor() { super(); @@ -36,19 +37,19 @@ export class UmbColorPickerElement extends FormControlMixin(UmbLitElement) { } private _renderColors() { - return html`${this.colors?.map((color) => { + return html`${this.swatches?.map((swatch) => { return html``; })}`; } } -export default UmbColorPickerElement; +export default UmbInputColorPickerElement; declare global { interface HTMLElementTagNameMap { - 'umb-color-picker': UmbColorPickerElement; + 'umb-input-color-picker': UmbInputColorPickerElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.test.ts similarity index 51% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.test.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.test.ts index 977ceb6a8a..1cc62c08c9 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/color-picker/color-picker.test.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-color-picker/input-color-picker.test.ts @@ -1,15 +1,15 @@ import { expect, fixture, html } from '@open-wc/testing'; -import { UmbColorPickerElement } from './color-picker.element'; +import { UmbInputColorPickerElement } from './input-color-picker.element'; import { defaultA11yConfig } from '@umbraco-cms/test-utils'; -describe('UmbColorPickerElement', () => { - let element: UmbColorPickerElement; +describe('UmbInputColorPickerElement', () => { + let element: UmbInputColorPickerElement; beforeEach(async () => { - element = await fixture(html` `); + element = await fixture(html` `); }); it('is defined with its own instance', () => { - expect(element).to.be.instanceOf(UmbColorPickerElement); + expect(element).to.be.instanceOf(UmbInputColorPickerElement); }); it('passes the a11y audit', async () => { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts new file mode 100644 index 0000000000..b9b50242e4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts @@ -0,0 +1,115 @@ +import { css, html } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-ui/uui'; +import { UmbCultureRepository } from '../../../settings/cultures/repository/culture.repository'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { CultureModel } from '@umbraco-cms/backend-api'; +import { UmbChangeEvent } from 'src/core/events'; + +@customElement('umb-input-culture-select') +export class UmbInputCultureSelectElement extends FormControlMixin(UmbLitElement) { + static styles = [UUITextStyles, css``]; + + /** + * Disables the input + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Disables the input + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + readonly = false; + + @state() + private _cultures: CultureModel[] = []; + + @state() + private _search = ''; + + public selectedCultureName?: string; + + #cultureRepository = new UmbCultureRepository(this); + + protected getFormElement() { + return undefined; + } + + protected async firstUpdated() { + const { data } = await this.#cultureRepository.requestCultures(); + if (data) { + this._cultures = data.items; + } + } + + #onSearchChange(event: UUIComboboxEvent) { + event.stopPropagation(); + const target = event.composedPath()[0] as UUIComboboxElement; + this._search = target.search; + } + + #onCultureChange(event: UUIComboboxEvent) { + event.stopPropagation(); + const target = event.composedPath()[0] as UUIComboboxElement; + this._value = target.value; + const culture = this._cultures.find((culture) => culture.name === this._value); + this.selectedCultureName = culture?.englishName; + this.dispatchEvent(new UmbChangeEvent()); + } + + get #filteredCultures() { + return this._cultures.filter((culture) => { + return culture.englishName?.toLowerCase().includes(this._search.toLowerCase()); + }); + } + + get #fromAvailableCultures() { + return this._cultures.find((culture) => culture.name === this.value); + } + + render() { + return html` + + ${this.disabled || this.readonly + ? html`${this.#fromAvailableCultures?.englishName}` + : html` + + + ${repeat( + this.#filteredCultures, + (culture) => culture.name, + (culture) => + html` + ${culture.englishName} + ` + )} + + + `} + `; + } +} + +export default UmbInputCultureSelectElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-culture-select': UmbInputCultureSelectElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.element.ts similarity index 83% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.element.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.element.ts index 3eb73e9edf..75b173a544 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.element.ts @@ -5,8 +5,8 @@ import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; import { UUIColorPickerChangeEvent } from '@umbraco-ui/uui'; import { UmbLitElement } from '@umbraco-cms/element'; -@customElement('umb-eye-dropper') -export class UmbEyeDropperElement extends FormControlMixin(UmbLitElement) { +@customElement('umb-input-eye-dropper') +export class UmbInputEyeDropperElement extends FormControlMixin(UmbLitElement) { static styles = [UUITextStyles, css``]; protected getFormElement() { @@ -36,10 +36,10 @@ export class UmbEyeDropperElement extends FormControlMixin(UmbLitElement) { } } -export default UmbEyeDropperElement; +export default UmbInputEyeDropperElement; declare global { interface HTMLElementTagNameMap { - 'umb-eye-dropper': UmbEyeDropperElement; + 'umb-input-eye-dropper': UmbInputEyeDropperElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.test.ts similarity index 51% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.test.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.test.ts index c147262812..5f471eea16 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/eye-dropper/eye-dropper.test.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-eye-dropper/input-eye-dropper.test.ts @@ -1,15 +1,15 @@ import { expect, fixture, html } from '@open-wc/testing'; -import { UmbEyeDropperElement } from './eye-dropper.element'; +import { UmbInputEyeDropperElement } from './input-eye-dropper.element'; import { defaultA11yConfig } from '@umbraco-cms/test-utils'; -describe('UmbEyeDropperElement', () => { - let element: UmbEyeDropperElement; +describe('UmbInputEyeDropperElement', () => { + let element: UmbInputEyeDropperElement; beforeEach(async () => { - element = await fixture(html` `); + element = await fixture(html` `); }); it('is defined with its own instance', () => { - expect(element).to.be.instanceOf(UmbEyeDropperElement); + expect(element).to.be.instanceOf(UmbInputEyeDropperElement); }); it('passes the a11y audit', async () => { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts new file mode 100644 index 0000000000..610318c5c7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts @@ -0,0 +1,182 @@ +import { css, html } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '../../../../core/modal'; +import { UmbLitElement } from '@umbraco-cms/element'; +import type { LanguageModel } from '@umbraco-cms/backend-api'; +import type { UmbObserverController } from '@umbraco-cms/observable-api'; +import { UmbLanguageRepository } from 'src/backoffice/settings/languages/repository/language.repository'; +import { UmbChangeEvent } from 'src/core/events'; + +@customElement('umb-input-language-picker') +export class UmbInputLanguagePickerElement extends FormControlMixin(UmbLitElement) { + static styles = [ + UUITextStyles, + css` + #add-button { + width: 100%; + } + `, + ]; + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default undefined + */ + @property({ type: Number }) + min?: number; + + /** + * Min validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + minMessage = 'This field need more items'; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + * @default undefined + */ + @property({ type: Number }) + max?: number; + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + private _selectedIsoCodes: Array = []; + public get selectedIsoCodes(): Array { + return this._selectedIsoCodes; + } + public set selectedIsoCodes(keys: Array) { + this._selectedIsoCodes = keys; + super.value = keys.join(','); + this._observePickedItems(); + } + + @property() + public set value(keysString: string) { + if (keysString !== this._value) { + this.selectedIsoCodes = keysString.split(/[ ,]+/); + } + } + + @state() + private _items?: Array; + + private _modalService?: UmbModalService; + private _repository = new UmbLanguageRepository(this); + private _pickedItemsObserver?: UmbObserverController; + + constructor() { + super(); + + this.addValidator( + 'rangeUnderflow', + () => this.minMessage, + () => !!this.min && this._selectedIsoCodes.length < this.min + ); + + this.addValidator( + 'rangeOverflow', + () => this.maxMessage, + () => !!this.max && this._selectedIsoCodes.length > this.max + ); + + this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (instance) => { + this._modalService = instance; + }); + } + + protected getFormElement() { + return undefined; + } + + private async _observePickedItems() { + this._pickedItemsObserver?.destroy(); + if (!this._repository) return; + + const { asObservable } = await this._repository.requestItems(this._selectedIsoCodes); + + this._pickedItemsObserver = this.observe(asObservable(), (items) => { + this._items = items; + }); + } + + private _openPicker() { + const modalHandler = this._modalService?.languagePicker({ + multiple: this.max === 1 ? false : true, + selection: [...this._selectedIsoCodes], + }); + + modalHandler?.onClose().then(({ selection }: any) => { + this._setSelection(selection); + }); + } + + private _removeItem(item: LanguageModel) { + const modalHandler = this._modalService?.confirm({ + color: 'danger', + headline: `Remove ${item.name}?`, + content: 'Are you sure you want to remove this item', + confirmLabel: 'Remove', + }); + + modalHandler?.onClose().then(({ confirmed }) => { + if (confirmed) { + const newSelection = this._selectedIsoCodes.filter((value) => value !== item.isoCode); + this._setSelection(newSelection); + } + }); + } + + private _setSelection(newSelection: Array) { + this.selectedIsoCodes = newSelection; + this.dispatchEvent(new UmbChangeEvent()); + } + + render() { + return html` + ${this._items?.map((item) => this._renderItem(item))} + Add + `; + } + + private _renderItem(item: LanguageModel) { + return html` + + + + this._removeItem(item)} label="Remove ${item.name}">Remove + + + `; + } +} + +export default UmbInputLanguagePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-language-picker': UmbInputLanguagePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts index 90e4c0e49d..381726055c 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts @@ -21,8 +21,9 @@ export class UmbInputMediaPickerElement extends FormControlMixin(UmbLitElement) } #add-button { text-align: center; - min-height: 160px; + height: 202px; } + uui-icon { display: block; margin: 0 auto; @@ -168,7 +169,7 @@ export class UmbInputMediaPickerElement extends FormControlMixin(UmbLitElement) return html` ${this._items?.map((item) => this._renderItem(item))} ${this._renderButton()} `; } private _renderButton() { - if (this.max == 1 && this._items && this._items.length > 0) return; + if (this._items && this.max && this._items.length >= this.max) return; return html` Add diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-multi-url-picker/input-multi-url-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-multi-url-picker/input-multi-url-picker.element.ts new file mode 100644 index 0000000000..6651373138 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-multi-url-picker/input-multi-url-picker.element.ts @@ -0,0 +1,191 @@ +import { css, html } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property } from 'lit/decorators.js'; +import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar'; +import { UmbLitElement } from '@umbraco-cms/element'; +import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/modal'; + +export interface MultiUrlData { + icon?: string; + name?: string; + published?: boolean; + queryString?: string; + target?: string; + trashed?: boolean; + udi?: string; + url?: string; +} + +/** + * @element umb-input-multi-url-picker + * @fires change - when the value of the input changes + * @fires blur - when the input loses focus + * @fires focus - when the input gains focus + */ +@customElement('umb-input-multi-url-picker') +export class UmbInputMultiUrlPickerElement extends FormControlMixin(UmbLitElement) { + static styles = [ + UUITextStyles, + css` + uui-button { + width: 100%; + } + `, + ]; + + protected getFormElement() { + return undefined; + } + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default undefined + */ + @property({ type: Number }) + min?: number; + + /** + * Min validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + minMessage = 'This field needs more items'; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + * @default undefined + */ + @property({ type: Number }) + max?: number; + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + /** + @attr 'hide-anchor' + */ + @property({ type: Boolean, attribute: 'hide-anchor' }) + hideAnchor?: boolean; + + @property() + ignoreUserStartNodes?: boolean; + + /** + * @type {UUIModalSidebarSize} + * @attr + * @default "small" + */ + @property() + overlaySize?: UUIModalSidebarSize; + + /** + * @type {Array} + * @default [] + */ + @property({ attribute: false }) + set urls(data: Array) { + this._urls = data; + super.value = this._urls.map((x) => x.url).join(','); + } + + get urls() { + return this._urls; + } + + private _urls: Array = []; + private _modalService?: UmbModalService; + + constructor() { + super(); + this.addValidator( + 'rangeUnderflow', + () => this.minMessage, + () => !!this.min && this.urls.length < this.min + ); + this.addValidator( + 'rangeOverflow', + () => this.maxMessage, + () => !!this.max && this.urls.length > this.max + ); + + this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (instance) => { + this._modalService = instance; + }); + } + + private _removeItem(index: number) { + this.urls.splice(index, 1); + this._dispatchChangeEvent(); + } + + private _setSelection(selection: MultiUrlData, index?: number) { + if (index !== undefined && index >= 0) this.urls[index] = selection; + else this.urls.push(selection); + + this._dispatchChangeEvent(); + } + + private _dispatchChangeEvent() { + this.requestUpdate(); + this.dispatchEvent(new CustomEvent('change', { composed: true, bubbles: true })); + } + + private _openPicker(data?: MultiUrlData, index?: number) { + const modalHandler = this._modalService?.linkPicker({ + link: { + name: data?.name, + published: data?.published, + queryString: data?.queryString, + target: data?.target, + trashed: data?.trashed, + udi: data?.udi, + url: data?.url, + }, + config: { + hideAnchor: this.hideAnchor, + ignoreUserStartNodes: this.ignoreUserStartNodes, + overlaySize: this.overlaySize || 'small', + }, + }); + modalHandler?.onClose().then((newUrl: MultiUrlData) => { + if (!newUrl) return; + this._setSelection(newUrl, index); + }); + } + + render() { + return html`${this.urls?.map((link, index) => this._renderItem(link, index))} + Add`; + } + + private _renderItem(link: MultiUrlData, index: number) { + return html` + + + Edit + Remove + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-multi-url-picker': UmbInputMultiUrlPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-dashboards/section-dashboards.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-dashboards/section-dashboards.element.ts index a3d8f6a3d2..61f4bf1fd3 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-dashboards/section-dashboards.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-dashboards/section-dashboards.element.ts @@ -25,11 +25,12 @@ export class UmbSectionDashboardsElement extends UmbLitElement { background-color: var(--uui-color-surface); height: 70px; border-bottom: 1px solid var(--uui-color-border); + box-sizing: border-box; } #scroll-container { - flex:1; - position:relative; + flex: 1; + position: relative; } #router-slot { @@ -45,10 +46,10 @@ export class UmbSectionDashboardsElement extends UmbLitElement { width: 100%; min-height: 60px; box-sizing: border-box; - margin:0; - padding:0 var(--uui-size-5); - background-color:var(--uui-color-surface); - border-bottom:1px solid var(--uui-color-border); + margin: 0; + padding: 0 var(--uui-size-5); + background-color: var(--uui-color-surface); + border-bottom: 1px solid var(--uui-color-border); } `, ]; @@ -171,8 +172,7 @@ export class UmbSectionDashboardsElement extends UmbLitElement { }} @change=${(event: UmbRouterSlotChangeEvent) => { this._activePath = event.target.localActiveViewPath; - }} - > + }}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts index ca2411549a..d937a7204d 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts @@ -1,7 +1,6 @@ import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { css, html } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import { UmbSectionContext, UMB_SECTION_CONTEXT_TOKEN } from '../section.context'; +import { customElement } from 'lit/decorators.js'; import '../../tree/context-menu/tree-context-menu.service'; import '../section-sidebar-context-menu/section-sidebar-context-menu.element'; @@ -21,54 +20,27 @@ export class UmbSectionSidebarElement extends UmbLitElement { font-weight: 500; display: flex; flex-direction: column; - z-index:10; + z-index: 10; } - h3 { - padding: var(--uui-size-4) var(--uui-size-8); + #scroll-container { + height: 100%; + overflow-y: auto; } `, ]; - @state() - private _sectionLabel = ''; - - @state() - private _sectionPathname = ''; - - private _sectionContext?: UmbSectionContext; #sectionSidebarContext = new UmbSectionSidebarContext(this); constructor() { super(); - - this.consumeContext(UMB_SECTION_CONTEXT_TOKEN, (sectionContext) => { - this._sectionContext = sectionContext; - this._observeSectionContext(); - }); - this.provideContext(UMB_SECTION_SIDEBAR_CONTEXT_TOKEN, this.#sectionSidebarContext); } - private _observeSectionContext() { - if (!this._sectionContext) return; - - this.observe(this._sectionContext.pathname, (pathname) => { - this._sectionPathname = pathname || ''; - }); - this.observe(this._sectionContext.label, (label) => { - this._sectionLabel = label || ''; - }); - } - render() { return html` - - -

    ${this._sectionLabel}

    -
    - +
    diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts index 6c63ea29ae..009f40afaf 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts @@ -11,6 +11,8 @@ import { UmbLitElement } from '@umbraco-cms/element'; import './section-sidebar-menu/section-sidebar-menu.element.ts'; import './section-views/section-views.element.ts'; +import '../../../settings/languages/app-language-select.element.ts'; + import { UmbRouterSlotChangeEvent } from '@umbraco-cms/router'; @customElement('umb-section') @@ -28,6 +30,10 @@ export class UmbSectionElement extends UmbLitElement { overflow: auto; height: 100%; } + + h3 { + padding: var(--uui-size-4) var(--uui-size-8); + } `, ]; @@ -38,11 +44,16 @@ export class UmbSectionElement extends UmbLitElement { @state() private _menuItems?: Array; - private _workspaces?: Array; - @state() private _views?: Array; + @state() + private _sectionLabel = ''; + + @state() + private _sectionPathname = ''; + + private _workspaces?: Array; private _sectionContext?: UmbSectionContext; private _sectionAlias?: string; @@ -89,7 +100,6 @@ export class UmbSectionElement extends UmbLitElement { } private _createMenuRoutes() { - // TODO: find a way to make this reuseable across: const workspaceRoutes = this._workspaces?.map((workspace: ManifestWorkspace) => { return [ @@ -140,35 +150,38 @@ export class UmbSectionElement extends UmbLitElement { ]; } - - private _observeSection() { if (!this._sectionContext) return; - this.observe( - this._sectionContext.alias, (alias) => { - this._sectionAlias = alias; - this._observeViews(); - } - ); + this.observe(this._sectionContext.alias, (alias) => { + this._sectionAlias = alias; + this._observeViews(); + }); + + this.observe(this._sectionContext.pathname, (pathname) => { + this._sectionPathname = pathname || ''; + }); + + this.observe(this._sectionContext.label, (label) => { + this._sectionLabel = label || ''; + }); } private _observeViews() { - this.observe(umbExtensionsRegistry?.extensionsOfType('sectionView'), (views) => { - const sectionViews = views.filter((view) => { - return this._sectionAlias ? view.meta.sections.includes(this._sectionAlias) : false - }).sort((a, b) => b.meta.weight - a.meta.weight); - if(sectionViews.length > 0) { - this._views = sectionViews; - this._createViewRoutes(); - } + const sectionViews = views + .filter((view) => { + return this._sectionAlias ? view.meta.sections.includes(this._sectionAlias) : false; + }) + .sort((a, b) => b.meta.weight - a.meta.weight); + if (sectionViews.length > 0) { + this._views = sectionViews; + this._createViewRoutes(); } - ); + }); } private _createViewRoutes() { - this._routes = this._views?.map((view) => { return { @@ -190,13 +203,20 @@ export class UmbSectionElement extends UmbLitElement { const view = this._views?.find((view) => 'view/' + view.meta.pathname === currentPath); if (!view) return; this._sectionContext?.setActiveView(view); - } + }; render() { return html` ${this._menuItems && this._menuItems.length > 0 ? html` + + + + + +

    ${this._sectionLabel}

    +
    ` @@ -204,7 +224,10 @@ export class UmbSectionElement extends UmbLitElement { ${this._views && this._views.length > 0 ? html`` : nothing} ${this._routes && this._routes.length > 0 - ? html`` + ? html`` : nothing} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts index 65dfa62416..6b6a0332a9 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts @@ -1,10 +1,10 @@ +import { Observable } from 'rxjs'; + export interface UmbWorkspaceContextInterface { - //readonly data: Observable; - //getUnique(): string | undefined; - + isNew: Observable; + getIsNew(): boolean; + setIsNew(value: boolean): void; getEntityType(): string; - getData(): T; - destroy(): void; } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.ts index 1ee9462a36..ca0407059d 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.ts @@ -1,5 +1,6 @@ import { UmbContextProviderController } from '@umbraco-cms/context-api'; import { UmbControllerHostInterface } from '@umbraco-cms/controller'; +import { DeepState } from '@umbraco-cms/observable-api'; /* @@ -7,12 +8,21 @@ TODO: We need to figure out if we like to keep using same alias for all workspac If so we need to align on a interface that all of these implements. otherwise consumers cant trust the workspace-context. */ export abstract class UmbWorkspaceContext { - protected _host: UmbControllerHostInterface; + #isNew = new DeepState(false); + isNew = this.#isNew.asObservable(); + constructor(host: UmbControllerHostInterface) { this._host = host; new UmbContextProviderController(host, 'umbWorkspaceContext', this); } + getIsNew() { + return this.#isNew.getValue(); + } + + setIsNew(isNew: boolean) { + this.#isNew.next(isNew); + } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-entity-context.interface.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-entity-context.interface.ts index 7effa96d0d..754c57ca46 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-entity-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-entity-context.interface.ts @@ -1,12 +1,8 @@ import type { UmbWorkspaceContextInterface } from './workspace-context.interface'; export interface UmbWorkspaceEntityContextInterface extends UmbWorkspaceContextInterface { - //readonly name: Observable; - getEntityKey(): string | undefined; // COnsider if this should go away now that we have getUnique() getEntityType(): string; - getData(): T; - save(): Promise; } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/entity-actions/delete/delete.action.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/entity-actions/delete/delete.action.ts index 58e9d919ae..3521026991 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/entity-actions/delete/delete.action.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/entity-actions/delete/delete.action.ts @@ -4,7 +4,7 @@ import { UmbControllerHostInterface } from '@umbraco-cms/controller'; import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/modal'; export class UmbDeleteEntityAction< - T extends { delete(unique: string): Promise; requestTreeItems(uniques: Array): any } + T extends { delete(unique: string): Promise; requestItems(uniques: Array): any } > extends UmbEntityActionBase { #modalService?: UmbModalService; @@ -19,7 +19,7 @@ export class UmbDeleteEntityAction< async execute() { if (!this.repository || !this.#modalService) return; - const { data } = await this.repository.requestTreeItems([this.unique]); + const { data } = await this.repository.requestItems([this.unique]); if (data) { const item = data[0]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts index ad1eaff530..0dda2b82d8 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts @@ -1,8 +1,7 @@ import { html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; -import '../../../components/input-checkbox-list/input-checkbox-list.element'; -import type { UmbInputCheckboxListElement } from '../../../components/input-checkbox-list/input-checkbox-list.element'; +import { UmbInputCheckboxListElement } from '../../../components/input-checkbox-list/input-checkbox-list.element'; import { UmbLitElement } from '@umbraco-cms/element'; import type { DataTypePropertyModel } from '@umbraco-cms/backend-api'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/color-picker/property-editor-ui-color-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/color-picker/property-editor-ui-color-picker.element.ts index 8d843e896f..a6fbfe8c1d 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/color-picker/property-editor-ui-color-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/color-picker/property-editor-ui-color-picker.element.ts @@ -2,9 +2,9 @@ import { html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { UUIColorSwatchesEvent } from '@umbraco-ui/uui'; -import '../../../../shared/components/color-picker/color-picker.element'; import { UmbLitElement } from '@umbraco-cms/element'; import type { DataTypePropertyModel } from '@umbraco-cms/backend-api'; +import type { SwatchDetails } from '@umbraco-cms/models'; /** * @element umb-property-editor-ui-color-picker @@ -17,18 +17,18 @@ export class UmbPropertyEditorUIColorPickerElement extends UmbLitElement { value = ''; @state() - private _includeLabels = false; + private _showLabels = false; @state() - private _colorSwatches: string[] = []; + private _swatches: SwatchDetails[] = []; @property({ type: Array, attribute: false }) public set config(config: Array) { - const includeLabels = config.find((x) => x.alias === 'includeLabels'); - if (includeLabels) this._includeLabels = includeLabels.value; + const useLabel = config.find((x) => x.alias === 'useLabel'); + if (useLabel) this._showLabels = useLabel.value; - const colorSwatches = config.find((x) => x.alias === 'colors'); - if (colorSwatches) this._colorSwatches = colorSwatches.value; + const colorSwatches = config.find((x) => x.alias === 'items'); + if (colorSwatches) this._swatches = colorSwatches.value as any[]; } private _onChange(event: UUIColorSwatchesEvent) { @@ -37,10 +37,10 @@ export class UmbPropertyEditorUIColorPickerElement extends UmbLitElement { } render() { - return html``; + .swatches="${this._swatches}" + .showLabels="${this._showLabels}">`; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/eye-dropper/property-editor-ui-eye-dropper.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/eye-dropper/property-editor-ui-eye-dropper.element.ts index d69e7f471a..fae1c624d7 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/eye-dropper/property-editor-ui-eye-dropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/eye-dropper/property-editor-ui-eye-dropper.element.ts @@ -3,7 +3,6 @@ import { customElement, property, state } from 'lit/decorators.js'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { UUIColorPickerChangeEvent } from '@umbraco-ui/uui'; import { UmbLitElement } from '@umbraco-cms/element'; -import '../../../components/eye-dropper/eye-dropper.element'; import type { DataTypePropertyModel } from '@umbraco-cms/backend-api'; /** @@ -37,10 +36,10 @@ export class UmbPropertyEditorUIEyeDropperElement extends UmbLitElement { } render() { - return html``; + .opacity="${this._opacity}">`; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/multi-url-picker/property-editor-ui-multi-url-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/multi-url-picker/property-editor-ui-multi-url-picker.element.ts index dd21df3e24..59d0abd9e4 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/multi-url-picker/property-editor-ui-multi-url-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/multi-url-picker/property-editor-ui-multi-url-picker.element.ts @@ -1,23 +1,71 @@ import { html } from 'lit'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar'; +import { + UmbInputMultiUrlPickerElement, + MultiUrlData, +} from '../../../../shared/components/input-multi-url-picker/input-multi-url-picker.element'; import { UmbLitElement } from '@umbraco-cms/element'; +import { DataTypePropertyModel } from '@umbraco-cms/backend-api'; /** * @element umb-property-editor-ui-multi-url-picker */ + @customElement('umb-property-editor-ui-multi-url-picker') export class UmbPropertyEditorUIMultiUrlPickerElement extends UmbLitElement { static styles = [UUITextStyles]; - @property() - value = ''; + @property({ type: Array }) + value: MultiUrlData[] = []; @property({ type: Array, attribute: false }) - public config = []; + public set config(config: DataTypePropertyModel[]) { + const overlaySize = config.find((x) => x.alias === 'overlaySize'); + if (overlaySize) this._overlaySize = overlaySize.value; + + const hideAnchor = config.find((x) => x.alias === 'hideAnchor'); + if (hideAnchor) this._hideAnchor = hideAnchor.value; + + const ignoreUserStartNodes = config.find((x) => x.alias === 'ignoreUserStartNodes'); + if (ignoreUserStartNodes) this._ignoreUserStartNodes = ignoreUserStartNodes.value; + + const maxNumber = config.find((x) => x.alias === 'maxNumber'); + if (maxNumber) this._maxNumber = maxNumber.value; + + const minNumber = config.find((x) => x.alias === 'minNumber'); + if (minNumber) this._minNumber = minNumber.value; + } + @state() + private _overlaySize?: UUIModalSidebarSize; + + @state() + private _hideAnchor?: boolean; + + @state() + private _ignoreUserStartNodes?: boolean; + + @state() + private _maxNumber?: number; + + @state() + private _minNumber?: number; + + private _onChange(event: CustomEvent) { + this.value = (event.target as UmbInputMultiUrlPickerElement).urls; + this.dispatchEvent(new CustomEvent('property-value-change')); + } render() { - return html`
    umb-property-editor-ui-multi-url-picker
    `; + return html``; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts index 26476e76f1..7c51ab0d96 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts @@ -14,6 +14,12 @@ export class UmbSaveWorkspaceAction extends UmbWorkspaceAction data?.name); content = createObservablePart(this.#data, (data) => data?.content); + isNew = false; + constructor(host: UmbControllerHostInterface) { this.#host = host; this.#templateDetailRepo = new UmbTemplateDetailRepository(this.#host); @@ -39,17 +41,9 @@ export class UmbTemplateWorkspaceContext { } async createScaffold(parentKey: string | null) { + this.isNew = true; const { data } = await this.#templateDetailRepo.createScaffold(parentKey); if (!data) return; this.#data.next(data); } - - async save(isNew: boolean) { - if (!this.#data.value) return; - isNew ? this.#templateDetailRepo.insert(this.#data.value) : this.#templateDetailRepo.update(this.#data.value); - } - - async delete(key: string) { - await this.#templateDetailRepo.delete(key); - } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dashboards/dictionary/dashboard-translation-dictionary.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dashboards/dictionary/dashboard-translation-dictionary.element.ts index 6ff6446f51..c48a2ae248 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dashboards/dictionary/dashboard-translation-dictionary.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dashboards/dictionary/dashboard-translation-dictionary.element.ts @@ -165,7 +165,7 @@ export class UmbDashboardTranslationDictionaryElement extends UmbLitElement { const { name }: UmbCreateDictionaryModalResultData = await modalHandler.onClose(); if (!name) return; - const result = await this.#repo?.createDetail({ name, parentKey: null, translations: [], key: '' }); + const result = await this.#repo?.create({ name, parentKey: null, translations: [], key: '' }); // TODO => get location header to route to new item console.log(result); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/entity-actions/create/create.action.ts b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/entity-actions/create/create.action.ts index 53b6d4117f..aeb8b448a2 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/entity-actions/create/create.action.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/entity-actions/create/create.action.ts @@ -47,7 +47,7 @@ export default class UmbCreateDictionaryEntityAction extends UmbEntityActionBase const { name }: UmbCreateDictionaryModalResultData = await modalHandler.onClose(); if (!name) return; - const result = await this.repository?.createDetail({ name, parentKey: this.unique, translations: [], key: ''}); + const result = await this.repository?.create({ name, parentKey: this.unique, translations: [], key: '' }); // TODO => get location header to route to new item console.log(result); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/repository/dictionary.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/repository/dictionary.repository.ts index dd9c5e23dd..0ed7a54092 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/repository/dictionary.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/repository/dictionary.repository.ts @@ -103,7 +103,7 @@ export class UmbDictionaryRepository implements UmbTreeRepository, UmbDetailRepo // DETAILS - async createDetailsScaffold(parentKey: string | null) { + async createScaffold(parentKey: string | null) { await this.#init; if (!parentKey) { @@ -141,7 +141,7 @@ export class UmbDictionaryRepository implements UmbTreeRepository, UmbDetailRepo return this.#detailSource.delete(key); } - async saveDetail(dictionary: DictionaryDetails) { + async save(dictionary: DictionaryDetails) { await this.#init; // TODO: should we show a notification if the dictionary is missing? @@ -168,7 +168,7 @@ export class UmbDictionaryRepository implements UmbTreeRepository, UmbDetailRepo return { error }; } - async createDetail(detail: DictionaryDetails) { + async create(detail: DictionaryDetails) { await this.#init; if (!detail.name) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/workspace/dictionary-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/workspace/dictionary-workspace.context.ts index 723af850f5..d8d1080ead 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/workspace/dictionary-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/translation/dictionary/workspace/dictionary-workspace.context.ts @@ -47,7 +47,7 @@ export class UmbWorkspaceDictionaryContext const updatedValue = this.#data.value.translations?.map((translationItem) => { if (translationItem.isoCode === isoCode) { - return { ...translationItem, translation}; + return { ...translationItem, translation }; } return translationItem; }) ?? []; @@ -57,27 +57,30 @@ export class UmbWorkspaceDictionaryContext updatedValue?.push({ isoCode, translation }); } - this.#data.next({ ...this.#data.value, translations: updatedValue }); + this.#data.next({ ...this.#data.value, translations: updatedValue }); } async load(entityKey: string) { const { data } = await this.#repo.requestByKey(entityKey); if (data) { + this.setIsNew(false); this.#data.next(data); } } async createScaffold(parentKey: string | null) { - const { data } = await this.#repo.createDetailsScaffold(parentKey); + const { data } = await this.#repo.createScaffold(parentKey); if (!data) return; + this.setIsNew(true); this.#data.next(data); } async save() { if (!this.#data.value) return; - this.#repo.saveDetail(this.#data.value); + await this.#repo.save(this.#data.value); + this.setIsNew(false); } - + public destroy(): void { this.#data.complete(); } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/users/user-groups/workspace/user-group-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/backoffice/users/user-groups/workspace/user-group-workspace.context.ts index 470163bc3c..8f7d07ef24 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/users/user-groups/workspace/user-group-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/users/user-groups/workspace/user-group-workspace.context.ts @@ -4,18 +4,17 @@ import { UmbWorkspaceEntityContextInterface } from '../../../shared/components/w import { UMB_USER_GROUP_STORE_CONTEXT_TOKEN } from '../user-group.store'; import type { UserGroupDetails } from '@umbraco-cms/models'; - -export class UmbWorkspaceUserGroupContext extends UmbWorkspaceContext implements UmbWorkspaceEntityContextInterface { - - - +export class UmbWorkspaceUserGroupContext + extends UmbWorkspaceContext + implements UmbWorkspaceEntityContextInterface +{ #manager = new UmbEntityWorkspaceManager(this._host, 'user-group', UMB_USER_GROUP_STORE_CONTEXT_TOKEN); public readonly data = this.#manager.state.asObservable(); public readonly name = this.#manager.state.getObservablePart((state) => state?.name); setName(name: string) { - this.#manager.state.update({name: name}) + this.#manager.state.update({ name: name }); } getEntityType = this.#manager.getEntityType; getUnique = this.#manager.getEntityKey; diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts index 14eda3b680..963de03cf4 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts @@ -59,12 +59,49 @@ export const data: Array = [ propertyEditorUiAlias: 'Umb.PropertyEditorUI.ColorPicker', data: [ { - alias: 'includeLabels', - value: false, + alias: 'useLabel', + value: true, }, { - alias: 'colors', - value: ['#000000', '#373737', '#9e9e9e', '#607d8b', '#2196f3', '#03a9f4', '#3f51b5', '#9c27b0', '#673ab7'], + alias: 'items', + value: [ + { + value: '#000000', + label: 'Black', + }, + { + value: '#373737', + label: 'Dark', + }, + { + value: '#9e9e9e', + label: 'Light', + }, + { + value: '#607d8b', + label: 'Sage', + }, + { + value: '#2196f3', + label: 'Sapphire', + }, + { + value: '#03a9f4', + label: 'Sky', + }, + { + value: '#3f51b5', + label: 'Blue', + }, + { + value: '#9c27b0', + label: 'Magenta', + }, + { + value: '#673ab7', + label: 'Purps', + }, + ], }, ], }, @@ -93,6 +130,7 @@ export const data: Array = [ propertyEditorUiAlias: 'Umb.PropertyEditorUI.EyeDropper', data: [ { + //showPalette alias: 'palette', value: [ '#d0021b', @@ -127,7 +165,28 @@ export const data: Array = [ parentKey: null, propertyEditorAlias: 'Umbraco.MultiUrlPicker', propertyEditorUiAlias: 'Umb.PropertyEditorUI.MultiUrlPicker', - data: [], + data: [ + { + alias: 'overlaySize', + value: 'small', + }, + { + alias: 'hideAnchor', + value: false, + }, + { + alias: 'ignoreUserStartNodes', + value: false, + }, + { + alias: 'maxNumber', + value: 2, + }, + { + alias: 'minNumber', + value: 0, + }, + ], }, { $type: 'data-type', diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/languages.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/languages.data.ts index f693df0fee..eff6787714 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/languages.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/languages.data.ts @@ -1,14 +1,14 @@ -import { UmbLanguageStoreItemType } from '../../../backoffice/settings/languages/language.store'; import { UmbData } from './data'; +import { LanguageModel } from '@umbraco-cms/backend-api'; // Temp mocked database -class UmbLanguagesData extends UmbData { - constructor(data: UmbLanguageStoreItemType[]) { +class UmbLanguagesData extends UmbData { + constructor(data: LanguageModel[]) { super(data); } // skip can be number or null - getAll(skip = 0, take = this.data.length): Array { + getAll(skip = 0, take = this.data.length): Array { return this.data.slice(skip, take); } @@ -16,7 +16,7 @@ class UmbLanguagesData extends UmbData { return this.data.find((item) => item.isoCode === key); } - save(saveItems: Array) { + save(saveItems: Array) { saveItems.forEach((saveItem) => { const foundIndex = this.data.findIndex((item) => item.isoCode === saveItem.isoCode); if (foundIndex !== -1) { @@ -50,7 +50,7 @@ class UmbLanguagesData extends UmbData { return keys; } - updateData(updateItem: UmbLanguageStoreItemType) { + updateData(updateItem: LanguageModel) { const itemIndex = this.data.findIndex((item) => item.isoCode === updateItem.isoCode); const item = this.data[itemIndex]; if (!item) return; @@ -81,7 +81,7 @@ class UmbLanguagesData extends UmbData { } } -export const MockData: Array = [ +export const MockData: Array = [ { name: 'English', isoCode: 'en', diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts index 0744df0db5..a4399f0cf8 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts @@ -39,9 +39,9 @@ export const handlers = [ data.id = umbLanguagesData.getAll().length + 1; data.key = uuidv4(); - const saved = umbLanguagesData.save([data]); + umbLanguagesData.save([data]); - return res(ctx.status(200), ctx.json(saved[0])); + return res(ctx.status(201)); }), rest.put(umbracoPath('/language/:key'), async (req, res, ctx) => { @@ -49,9 +49,9 @@ export const handlers = [ if (!data) return; - const saved = umbLanguagesData.save([data]); + umbLanguagesData.save([data]); - return res(ctx.status(200), ctx.json(saved[0])); + return res(ctx.status(200)); }), rest.delete(umbracoPath('/language/:key'), async (req, res, ctx) => { diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/content-picker/modal-layout-content-picker.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/content-picker/modal-layout-content-picker.element.ts index 49d823ebfb..72d991c39d 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/content-picker/modal-layout-content-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/content-picker/modal-layout-content-picker.element.ts @@ -72,7 +72,7 @@ export class UmbModalLayoutContentPickerElement extends UmbModalLayoutElement { + static styles = [ + UUITextStyles, + css` + hr { + border: none; + border-bottom: 1px solid var(--uui-color-divider); + margin-bottom: var(--uui-size-space-3); + } + + uui-input, + uui-toggle, + uui-label { + width: 100%; + } + + uui-input, + uui-label { + margin-bottom: var(--uui-size-space-6); + } + + .url-link { + display: flex; + gap: var(--uui-size-space-6); + } + .url-link span { + flex: 1 1 0px; + } + + #select-media { + display: block; + } + `, + ]; + + @state() + _selectedKey?: string; + + @state() + _link: LinkPickerData = { + icon: null, + name: null, + published: true, + queryString: null, + target: null, + trashed: false, + udi: null, + url: null, + }; + + @state() + _layout: LinkPickerConfig = { + hideAnchor: false, + ignoreUserStartNodes: false, + }; + + @query('#link-input') + private _linkInput!: UUIInputElement; + + @query('#anchor-input') + private _linkQueryInput?: UUIInputElement; + + @query('#link-title-input') + private _linkTitleInput!: UUIInputElement; + + connectedCallback() { + super.connectedCallback(); + if (!this.data) return; + this._link = this.data?.link; + this._layout = this.data?.config; + + if (!this._link.udi) return; + this._selectedKey = getKeyFromUdi(this._link.udi); + } + + private _handleQueryString() { + if (!this._linkQueryInput) return; + const query = this._linkQueryInput.value as string; + //TODO: Handle query strings (add # etc) + + this._link.queryString = query; + } + + private _handleSelectionChange(e: CustomEvent, entityType: string) { + //TODO: Update icon, published, trashed + e.stopPropagation(); + const element = e.target as UmbTreeElement; + const selectedKey = element.selection[element.selection.length - 1]; + const udi = buildUdi(entityType, selectedKey); + + this._selectedKey = selectedKey; + this._link.udi = udi; + this._link.url = udi; // TODO + this.requestUpdate(); + } + + private _submit() { + this.modalHandler?.close(this._link); + } + + private _close() { + this.modalHandler?.close(); + } + + render() { + return html` + + + + + Link Title + (this._link.name = this._linkTitleInput.value as string)} + .value="${this._link.name ?? ''}"> + + Target + + Open the link in a new tab + + +
    + + ${this._renderTrees()} +
    +
    + + +
    +
    + `; + } + + private _renderLinkUrlInput() { + return html` + Link + (this._link.url = this._linkInput.value as string)} + .disabled="${this._link.udi ? true : false}"> + `; + } + + private _renderAnchorInput() { + if (this._layout.hideAnchor) return nothing; + return html` + Anchor / querystring + + `; + } + + private _renderTrees() { + return html`Link to page + + this._handleSelectionChange(event, 'document')} + .selection=${[this._selectedKey ?? '']} + selectable> + +
    + + Link to media + + this._handleSelectionChange(event, 'media')} + .selection=${[this._selectedKey ?? '']} + selectable>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-modal-layout-link-picker': UmbModalLayoutLinkPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/link-picker/modal-layout-multi-url-picker.stories.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/link-picker/modal-layout-multi-url-picker.stories.ts new file mode 100644 index 0000000000..6f9b80f05f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/link-picker/modal-layout-multi-url-picker.stories.ts @@ -0,0 +1,20 @@ +import '../../../../backoffice/shared/components/body-layout/body-layout.element'; +import './modal-layout-link-picker.element'; + +import { Meta, Story } from '@storybook/web-components'; +import { html } from 'lit'; + +import type { UmbModalLayoutLinkPickerElement } from './modal-layout-link-picker.element'; + +export default { + title: 'API/Modals/Layouts/Link Picker', + component: 'umb-modal-layout-link-picker', + id: 'modal-layout-link-picker', +} as Meta; + +export const Overview: Story = () => html` + + +`; diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/media-picker/modal-layout-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/media-picker/modal-layout-media-picker.element.ts index e48db5f49c..15f5d39ada 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/media-picker/modal-layout-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/media-picker/modal-layout-media-picker.element.ts @@ -71,7 +71,7 @@ export class UmbModalLayoutMediaPickerElement extends UmbModalLayoutElement { @@ -6,45 +6,47 @@ export interface UmbPickerData { selection: Array; } +// TODO: we should consider moving this into a class/context instead of an element. +// So we don't have to extend an element to get basic picker/selection logic export class UmbModalLayoutPickerBase extends UmbModalLayoutElement> { - @state() - private _selection: Array = []; + @property() + selection: Array = []; connectedCallback(): void { super.connectedCallback(); - this._selection = this.data?.selection || []; + this.selection = this.data?.selection || []; } - protected _submit() { - this.modalHandler?.close({ selection: this._selection }); + submit() { + this.modalHandler?.close({ selection: this.selection }); } - protected _close() { + close() { this.modalHandler?.close(); } protected _handleKeydown(e: KeyboardEvent, key: selectType) { if (e.key === 'Enter') { - this._handleItemClick(key); + this.handleSelection(key); } } /* TODO: Write test for this select/deselect method. */ - protected _handleItemClick(key: selectType) { + handleSelection(key: selectType) { if (this.data?.multiple) { - if (this._isSelected(key)) { - this._selection = this._selection.filter((selectedKey) => selectedKey !== key); + if (this.isSelected(key)) { + this.selection = this.selection.filter((selectedKey) => selectedKey !== key); } else { - this._selection.push(key); + this.selection.push(key); } } else { - this._selection = [key]; + this.selection = [key]; } this.requestUpdate('_selection'); } - protected _isSelected(key: selectType): boolean { - return this._selection.includes(key); + isSelected(key: selectType): boolean { + return this.selection.includes(key); } } diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts index 21b0608262..361e18acc1 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts @@ -73,9 +73,9 @@ export class UmbPickerLayoutSectionElement extends UmbModalLayoutPickerBase { ${this._sections.map( (item) => html`
    this._handleItemClick(item.alias)} + @click=${() => this.handleSelection(item.alias)} @keydown=${(e: KeyboardEvent) => this._handleKeydown(e, item.alias)} - class=${this._isSelected(item.alias) ? 'item selected' : 'item'}> + class=${this.isSelected(item.alias) ? 'item selected' : 'item'}> ${item.meta.label}
    ` @@ -83,8 +83,8 @@ export class UmbPickerLayoutSectionElement extends UmbModalLayoutPickerBase {
    - - + +
    `; diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts index d05474cf87..4e32140f45 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts @@ -82,9 +82,9 @@ export class UmbPickerLayoutUserGroupElement extends UmbModalLayoutPickerBase { ${this._userGroups.map( (item) => html`
    this._handleItemClick(item.key)} + @click=${() => this.handleSelection(item.key)} @keydown=${(e: KeyboardEvent) => this._handleKeydown(e, item.key)} - class=${this._isSelected(item.key) ? 'item selected' : 'item'}> + class=${this.isSelected(item.key) ? 'item selected' : 'item'}> ${item.name}
    @@ -93,8 +93,8 @@ export class UmbPickerLayoutUserGroupElement extends UmbModalLayoutPickerBase {
    - - + +
    `; diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts index 452d26ca4f..ca74c4aefb 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts @@ -86,9 +86,9 @@ export class UmbPickerLayoutUserElement extends UmbModalLayoutPickerBase { ${this._users.map( (item) => html`
    this._handleItemClick(item.key)} + @click=${() => this.handleSelection(item.key)} @keydown=${(e: KeyboardEvent) => this._handleKeydown(e, item.key)} - class=${this._isSelected(item.key) ? 'item selected' : 'item'}> + class=${this.isSelected(item.key) ? 'item selected' : 'item'}> ${item.name}
    @@ -97,8 +97,8 @@ export class UmbPickerLayoutUserElement extends UmbModalLayoutPickerBase {
    - - + +
    `; diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts b/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts index 0ed747171e..1bb11b9a25 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts @@ -5,15 +5,19 @@ import './layouts/media-picker/modal-layout-media-picker.element'; import './layouts/property-editor-ui-picker/modal-layout-property-editor-ui-picker.element'; import './layouts/modal-layout-current-user.element'; import './layouts/icon-picker/modal-layout-icon-picker.element'; +import '../../backoffice/settings/languages/language-picker/language-picker-modal-layout.element'; +import './layouts/link-picker/modal-layout-link-picker.element'; import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar'; import { BehaviorSubject } from 'rxjs'; +import type { UmbLanguagePickerModalData } from '../../backoffice/settings/languages/language-picker/language-picker-modal-layout.element'; import { UmbModalChangePasswordData } from './layouts/modal-layout-change-password.element'; import type { UmbModalIconPickerData } from './layouts/icon-picker/modal-layout-icon-picker.element'; import type { UmbModalConfirmData } from './layouts/confirm/modal-layout-confirm.element'; import type { UmbModalContentPickerData } from './layouts/content-picker/modal-layout-content-picker.element'; import type { UmbModalPropertyEditorUIPickerData } from './layouts/property-editor-ui-picker/modal-layout-property-editor-ui-picker.element'; import type { UmbModalMediaPickerData } from './layouts/media-picker/modal-layout-media-picker.element'; +import type { UmbModalLinkPickerData } from './layouts/link-picker/modal-layout-link-picker.element'; import { UmbModalHandler } from './modal-handler'; import { UmbContextToken } from '@umbraco-cms/context-api'; @@ -25,7 +29,9 @@ export interface UmbModalOptions { data?: UmbModalData; } -// TODO: Should this be called UmbModalContext ? as we don't have 'services' as a term. +// TODO: rename to UmbModalContext +// TODO: we should find a way to easily open a modal without adding custom methods to this context. It would result in a better separation of concerns. +// TODO: move all layouts into their correct "silo" folders. User picker should live with users etc. export class UmbModalService { // TODO: Investigate if we can get rid of HTML elements in our store, so we can use one of our states. #modals = new BehaviorSubject(>[]); @@ -86,6 +92,21 @@ export class UmbModalService { return this.open('umb-modal-layout-icon-picker', { data, type: 'sidebar', size: 'small' }); } + /** + * Opens an Link Picker sidebar modal + * @public + * @param {(LinkPickerData & LinkPickerConfig)} [data] + * @return {*} {UmbModalHandler} + * @memberof UmbModalService + */ + public linkPicker(data?: UmbModalLinkPickerData): UmbModalHandler { + return this.open('umb-modal-layout-link-picker', { + data, + type: 'sidebar', + size: data?.config?.overlaySize || 'small', + }); + } + /** * Opens the user settings sidebar modal * @public @@ -106,6 +127,16 @@ export class UmbModalService { return this.open('umb-modal-layout-change-password', { data, type: 'dialog' }); } + /** + * Opens a language picker sidebar modal + * @public + * @return {*} {UmbModalHandler} + * @memberof UmbModalService + */ + public languagePicker(data: UmbLanguagePickerModalData): UmbModalHandler { + return this.open('umb-language-picker-modal-layout', { data, type: 'sidebar' }); + } + /** * Opens a modal or sidebar modal * @public diff --git a/src/Umbraco.Web.UI.Client/src/stories/guides.stories.mdx b/src/Umbraco.Web.UI.Client/src/stories/gettingstarted.stories.mdx similarity index 100% rename from src/Umbraco.Web.UI.Client/src/stories/guides.stories.mdx rename to src/Umbraco.Web.UI.Client/src/stories/gettingstarted.stories.mdx diff --git a/src/Umbraco.Web.UI.Client/src/stories/intro.stories.mdx b/src/Umbraco.Web.UI.Client/src/stories/intro.stories.mdx deleted file mode 100644 index 03f683de8b..0000000000 --- a/src/Umbraco.Web.UI.Client/src/stories/intro.stories.mdx +++ /dev/null @@ -1,25 +0,0 @@ -import { Meta } from '@storybook/addon-docs'; - - - -# Introduction - -Welcome to the Storybook for the backoffice of Umbraco CMS. - -This is a living styleguide that documents the design system of Umbraco CMS. - -## Getting started - -To get started, you can either use the sidebar to navigate to a component, or you can use the search field to find a component. - -## Contributing - -If you want to contribute to the backoffice, you can do so by following the instructions in the [README](https://github.com/umbraco/Umbraco.CMS.Backoffice#readme). - -## License - -Umbraco CMS is licensed under the [MIT license](https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/LICENSE). - -## Credits - -Umbraco CMS is created and maintained by [Umbraco HQ](https://umbraco.com).