From 4acfb1b1b6662a69ff05ff101c76c193f4db214e Mon Sep 17 00:00:00 2001 From: Markus Johansson Date: Tue, 15 Jul 2025 13:43:07 +0200 Subject: [PATCH 1/2] DashboardApps: Added sorting, defaults to all dashboard apps --- .../dashboard/default/dashboard.element.ts | 179 ++++++++++++++---- .../packages/core/dashboard/default/types.ts | 8 + 2 files changed, 148 insertions(+), 39 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts index 49a92bb0fa23..793e9ef70289 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts @@ -1,7 +1,7 @@ import type { ManifestDashboardApp } from '../dashboard-app.extension.js'; import { UMB_DASHBOARD_APP_PICKER_MODAL } from '../app/picker/picker-modal.token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, nothing, ifDefined, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, nothing, ifDefined, state, repeat, styleMap } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbExtensionsElementInitializer, @@ -9,6 +9,10 @@ import { } from '@umbraco-cms/backoffice/extension-api'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; +import type { DashboardAppInstance } from './types.js'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; +import { UmbId } from '@umbraco-cms/backoffice/id'; @customElement('umb-dashboard') export class UmbDashboardElement extends UmbLitElement { @@ -20,16 +24,41 @@ export class UmbDashboardElement extends UmbLitElement { ['large', 'large'], ]); + #gridSizeMap = new Map([ + ['small',{columns:1, rows: 1}], + ['medium',{columns:2, rows: 2}], + ['large',{columns:2, rows: 2}], + ]); + + #sorter? : UmbSorterController; + #extensionsController?: UmbExtensionsElementInitializer; @state() - _appElements: Array = []; + _appElements: Array = []; @state() _appUniques: Array = []; constructor() { super(); + + this.#sorter = new UmbSorterController(this, { + itemSelector: '.dashboard-app', + containerSelector: '.grid-container', + getUniqueOfElement: (element) => element.getAttribute('data-sorter-id'), + getUniqueOfModel: (modelEntry) => modelEntry.key, + onChange: ({ model }) => { + const oldValue = this._appElements; + this._appElements = model; + this.requestUpdate('_appElements', oldValue); + }, + }); + + this.#sorter.setModel(this._appElements); + + this.#observeDashboardApps(); + } #observeDashboardApps(): void { @@ -42,22 +71,45 @@ export class UmbDashboardElement extends UmbLitElement { this, umbExtensionsRegistry, 'dashboardApp', - (manifest) => this._appUniques.includes(manifest.alias), + // If no _appUniques, return all + (manifest) => this._appUniques.length == 0 || this._appUniques.includes(manifest.alias), (extensionControllers) => { - this._appElements = extensionControllers.map((controller) => { + + let newAppElements : DashboardAppInstance[] = []; + + extensionControllers.forEach((controller)=>{ + if (controller.component && controller.manifest) { const size = this.#sizeMap.get(controller.manifest.meta?.size) ?? this.#defaultSize; const headline = controller.manifest?.meta?.headline ? this.localize.string(controller.manifest?.meta?.headline) : undefined; - return html`${controller.component}`; + let gridSize = this.#gridSizeMap.get(size)!; + + newAppElements.push({ + key : UmbId.new(), + columns : gridSize.columns, + rows : gridSize?.rows, + headline : headline, + component : controller.component, + }); + + } else { - return html`Not Found`; + newAppElements.push({ + key : UmbId.new(), + columns : 1, + rows : 1, + } + ); } }); + + this._appElements = newAppElements; + + this.#sorter?.setModel(this._appElements); + }, undefined, // We can leave the alias to undefined, as we destroy this our selfs. ); @@ -86,60 +138,109 @@ export class UmbDashboardElement extends UmbLitElement {
${repeat( this._appElements, - (element) => element, - (element) => element, + (element) => element.key, + (element) => + html` +
+ + ${element.component} + +
`, )}
- `; } - #extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer) => { - if (ext.component && ext.manifest) { - const size = this.#sizeMap.get(ext.manifest.meta?.size) ?? this.#defaultSize; - const headline = ext.manifest?.meta?.headline ? this.localize.string(ext.manifest?.meta?.headline) : undefined; - return html`${ext.component}`; - } - - return nothing; - }; - static override styles = [ UmbTextStyles, css` + :host { + container-type: inline-size; + } + + uui-box { + height:100%; + position:relative; + } + #content { padding: var(--uui-size-layout-1); + container-type: inline-size; } .grid-container { + margin-top:var(--uui-size-layout-1); display: grid; grid-template-columns: repeat(4, 1fr); - //grid-template-rows: repeat(100, 225px); - margin: calc(var(--uui-size-space-3) * -1); - margin-bottom: 20px; + grid-auto-rows: 225px; + gap: 20px; + } + + @container (inline-size < 900px) { + .grid-container { + grid-template-columns: repeat(3, 1fr); + } + } + + @container (inline-size < 601px) { + .grid-container { + grid-template-columns: repeat(1, 1fr); + } + .grid-container > * { + grid-column: span 1 !important; + } } - umb-extension-slot::part(umb-dashboard-app-small) { - grid-column: span 1; - grid-row: span 1; - margin: var(--uui-size-space-3); + .dashboard-app { + position:relative; + display:block; + height:100%; } - umb-extension-slot::part(umb-dashboard-app-medium) { - grid-column: span 2; - grid-row: span 2; - margin: var(--uui-size-space-3); + .dashboard-app::after { + content: ''; + position: absolute; + z-index: 1; + pointer-events: none; + inset: 0; + border: 1px solid transparent; + border-radius: var(--uui-border-radius); + + transition: border-color 240ms ease-in; } - umb-extension-slot::part(umb-dashboard-app-large) { - grid-column: span 2; - grid-row: span 3; - margin: var(--uui-size-space-3); + .dashboard-app[drag-placeholder] { + position: relative; + display: block; + --umb-block-grid-entry-actions-opacity: 0; } + + .dashboard-app[drag-placeholder]::after { + display: block; + border-width: 2px; + border-color: var(--uui-color-interactive-emphasis); + animation: ${UUIBlinkAnimationValue}; + } + + .dashboard-app[drag-placeholder]::before { + content: ''; + position: absolute; + pointer-events: none; + inset: 0; + border-radius: var(--uui-border-radius); + background-color: var(--uui-color-interactive-emphasis); + opacity: 0.12; + } + + .dashboard-app[drag-placeholder] > * { + transition: opacity 50ms 16ms; + opacity: 0; + } + `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts index 317fdcd2d0e2..a5d6e25fff6b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts @@ -9,6 +9,14 @@ export interface MetaDashboardDefaultKind extends MetaDashboard { headline: string; } +export interface DashboardAppInstance { + key? : string; + rows? : number; + columns? : number; + headline? : string; + component? : HTMLElement; +} + declare global { interface UmbExtensionManifestMap { umbManifestDashboardDefaultKind: ManifestDashboardDefaultKind; From 2616c5e3be229b1677b8e1737c588fc546ab51a4 Mon Sep 17 00:00:00 2001 From: Markus Johansson Date: Fri, 25 Jul 2025 00:08:37 +0200 Subject: [PATCH 2/2] POC: Dashboard apps, added storage, introduced edit mode, support for multiple instances --- .../Implement/UserDataRepository.cs | 5 +- .../dashboard/default/dashboard.element.ts | 271 ++++++++++++++---- .../packages/core/dashboard/default/types.ts | 16 +- 3 files changed, 240 insertions(+), 52 deletions(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserDataRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserDataRepository.cs index 85bcceb698d3..b8609abb8b2c 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserDataRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserDataRepository.cs @@ -44,13 +44,14 @@ public async Task> GetAsync(int skip, int take, IUserDataF sql = ApplyFilter(sql, filter); } + // Fetching the total before applying OrderBy to avoid issue with count subquery. + var totalItems = _scopeAccessor.AmbientScope?.Database.Count(sql!) ?? 0; + sql = sql.OrderBy(dto => dto.Identifier); // need to order to skiptake List? userDataDtos = await _scopeAccessor.AmbientScope?.Database.SkipTakeAsync(skip, take, sql)!; - var totalItems = _scopeAccessor.AmbientScope?.Database.Count(sql!) ?? 0; - return new PagedModel { Total = totalItems, Items = DtosToModels(userDataDtos) }; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts index 793e9ef70289..b64b243dbd22 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/dashboard.element.ts @@ -1,18 +1,21 @@ import type { ManifestDashboardApp } from '../dashboard-app.extension.js'; import { UMB_DASHBOARD_APP_PICKER_MODAL } from '../app/picker/picker-modal.token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, nothing, ifDefined, state, repeat, styleMap } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, nothing, ifDefined, state, repeat, styleMap, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { + loadManifestElement, UmbExtensionsElementInitializer, + type PermittedControllerType, type UmbExtensionElementInitializer, } from '@umbraco-cms/backoffice/extension-api'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; -import type { DashboardAppInstance } from './types.js'; +import type { DashboardAppInstance, UserDashboardAppConfiguration } from './types.js'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; import { UmbId } from '@umbraco-cms/backoffice/id'; +import { UserDataService } from '@umbraco-cms/backoffice/external/backend-api'; @customElement('umb-dashboard') export class UmbDashboardElement extends UmbLitElement { @@ -35,11 +38,19 @@ export class UmbDashboardElement extends UmbLitElement { #extensionsController?: UmbExtensionsElementInitializer; @state() - _appElements: Array = []; + _editMode = false; + + @state() + _apps : Array = []; @state() _appUniques: Array = []; + @state() + _availableApps : PermittedControllerType>[] = []; + + _userDataKey : string | undefined; + constructor() { super(); @@ -49,19 +60,21 @@ export class UmbDashboardElement extends UmbLitElement { getUniqueOfElement: (element) => element.getAttribute('data-sorter-id'), getUniqueOfModel: (modelEntry) => modelEntry.key, onChange: ({ model }) => { - const oldValue = this._appElements; - this._appElements = model; + const oldValue = this._apps; + this._apps = model; this.requestUpdate('_appElements', oldValue); }, }); - this.#sorter.setModel(this._appElements); + this.#sorter.setModel(this._apps); + this.#sorter.disable(); this.#observeDashboardApps(); } #observeDashboardApps(): void { + this.#extensionsController?.destroy(); this.#extensionsController = new UmbExtensionsElementInitializer< UmbExtensionManifest, @@ -71,73 +84,215 @@ export class UmbDashboardElement extends UmbLitElement { this, umbExtensionsRegistry, 'dashboardApp', - // If no _appUniques, return all - (manifest) => this._appUniques.length == 0 || this._appUniques.includes(manifest.alias), + (manifest) => true, (extensionControllers) => { - let newAppElements : DashboardAppInstance[] = []; + this._availableApps = extensionControllers; - extensionControllers.forEach((controller)=>{ + this.#load(); - if (controller.component && controller.manifest) { - const size = this.#sizeMap.get(controller.manifest.meta?.size) ?? this.#defaultSize; - const headline = controller.manifest?.meta?.headline - ? this.localize.string(controller.manifest?.meta?.headline) - : undefined; + }, + undefined, // We can leave the alias to undefined, as we destroy this our selfs. + ); + } - let gridSize = this.#gridSizeMap.get(size)!; + async #drawDashboardApps(config : UserDashboardAppConfiguration) { - newAppElements.push({ - key : UmbId.new(), - columns : gridSize.columns, - rows : gridSize?.rows, - headline : headline, - component : controller.component, - }); + let apps = [...this._apps]; + if(config.apps.length == 0) { + //TODO: Draw all? + } + else + { - } else { - newAppElements.push({ - key : UmbId.new(), - columns : 1, - rows : 1, - } - ); - } + for(const configuredApp of config.apps){ + + var controller = this._availableApps.find(x=>x.alias == configuredApp.alias); + if(!controller) + return undefined; + + const size = this.#sizeMap.get(controller.manifest.meta?.size) ?? this.#defaultSize; + const headline = controller.manifest?.meta?.headline + ? this.localize.string(controller.manifest?.meta?.headline) + : undefined; + + let gridSize = this.#gridSizeMap.get(size)!; + + const elementPropsValue = controller.manifest.element ?? controller.manifest.js; + var appElementCtor = await loadManifestElement(elementPropsValue!)!; //TODO: Validate + + if(!appElementCtor) + return; + + apps.push({ + key : UmbId.new(), + alias : controller.alias, + columns : gridSize.columns, + rows : gridSize?.rows, + headline : headline, + component : new appElementCtor(), }); - this._appElements = newAppElements; + } + } - this.#sorter?.setModel(this._appElements); + this._apps = apps; - }, - undefined, // We can leave the alias to undefined, as we destroy this our selfs. - ); } async #openAppPicker() { + const value = await umbOpenModal(this, UMB_DASHBOARD_APP_PICKER_MODAL, { data: { multiple: true, }, - value: { - selection: this._appUniques, - }, + value : { + selection : [] + } }).catch(() => undefined); if (value) { - this._appUniques = value.selection.filter((item) => item !== null) as Array; - this.#observeDashboardApps(); + + let apps = [...this._apps]; + + for(const alias of value.selection) { + + var controller = this._availableApps.find(x=>x.alias == alias); + if(!controller) + return undefined; + + const size = this.#sizeMap.get(controller.manifest.meta?.size) ?? this.#defaultSize; + const headline = controller.manifest?.meta?.headline + ? this.localize.string(controller.manifest?.meta?.headline) + : undefined; + + let gridSize = this.#gridSizeMap.get(size)!; + + const elementPropsValue = controller.manifest.element ?? controller.manifest.js; + var appElementCtor = await loadManifestElement(elementPropsValue!)!; //TODO: Validate + + if(!appElementCtor) + return; + + apps.push({ + key : UmbId.new(), + alias : controller.alias, + columns : gridSize.columns, + rows : gridSize?.rows, + headline : headline, + component : new appElementCtor(), + }); + + } + + this._apps = apps; + + this.#save(); + } } + async #save(){ + + let config = { + apps : this._apps.map((x)=> {return {alias : x.alias }}) + } as UserDashboardAppConfiguration; + + if(this._userDataKey){ + var res = await UserDataService.putUserData({ + body : { + key : this._userDataKey, + group : 'test', + identifier : '', + value : JSON.stringify(config), + } + }); + } + else { + var res = await UserDataService.postUserData({ + body : { + group : 'test', + identifier : '', + value : JSON.stringify(config), + } + }); + } + + } + + async #load(){ + + var res = await UserDataService.getUserData({ + query : { + groups : ['test'] + } + }); + + if(res.data.items.length == 0) + return; + + this._userDataKey = res.data.items[0].key; + + const userConfig = JSON.parse(res.data.items[0].value); + + this.#drawDashboardApps(userConfig); + + } + + async #enterEditMode() { + this.#sorter?.setModel(this._apps); + this.#sorter?.enable() + this._editMode = true; + } + + async #leaveEditMode() { + this.#sorter?.disable(); + this._editMode = false; + this.#save(); + } + + async #remove(elementKey : string) { + + this._apps = this._apps.filter(x=>x.key != elementKey); + + } + + #onPopoverToggle = false; + override render() { return html`
- Add +
+ + ${when(this._editMode, + ()=>html`Done `, + ()=>html` + + + + + + + + + + + + + + `) + } +
${repeat( - this._appElements, + this._apps, (element) => element.key, (element) => html` @@ -146,6 +301,11 @@ export class UmbDashboardElement extends UmbLitElement { class="dashboard-app" data-sorter-id=${element.key}> +
+ ${when(this._editMode, + ()=>html` this.#remove(element.key)}>` + )} +
${element.component}
`, @@ -160,20 +320,32 @@ export class UmbDashboardElement extends UmbLitElement { css` :host { container-type: inline-size; - } - - uui-box { - height:100%; - position:relative; + --uui-menu-item-flat-structure: 1; } #content { padding: var(--uui-size-layout-1); + padding-top: var(--uui-size-space-3); container-type: inline-size; } + .main-actions { + display: flex; + justify-content: flex-end; + } + + uui-box div[slot='header-actions'] uui-button { + font-size:12px; + --uui-button-height: auto; + } + + uui-box { + height:100%; + position:relative; + } + .grid-container { - margin-top:var(--uui-size-layout-1); + margin-top:var(--uui-size-space-3); display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 225px; @@ -252,3 +424,4 @@ declare global { ['umb-dashboard']: UmbDashboardElement; } } + diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts index a5d6e25fff6b..e87e64176c38 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/dashboard/default/types.ts @@ -10,13 +10,27 @@ export interface MetaDashboardDefaultKind extends MetaDashboard { } export interface DashboardAppInstance { - key? : string; + key : string; + /** Dashboard App Alias */ + alias : string; rows? : number; columns? : number; headline? : string; component? : HTMLElement; } +/** + * Defines a configured dashboard app added by a user. Used e.g for serialization. + */ +export type ConfiguredDashboardApp = { + /** Dashboard App Alias */ + alias : string; +} + +export interface UserDashboardAppConfiguration { + apps : ConfiguredDashboardApp[]; +} + declare global { interface UmbExtensionManifestMap { umbManifestDashboardDefaultKind: ManifestDashboardDefaultKind;