Feature: Data mapping extension + aligning reference lists (#18318)

* wip entity-item-ref extension point

* clean up

* add ref list element

* fix styling

* Update document-item-ref.element.ts

* move item repo

* implement for input member

* enable action slot

* add null check

* fix sorting again

* fix sorting again

* use member element

* add draft styling back

* move item repository

* implement for user input

* pass readonly and standalone props

* make editPath a state

* Update member-item-ref.element.ts

* Fix user item ref

* remove open button

* remove unused

* remove unused

* check for section permission

* add fallback element

* wip data mapper concept

* add unique to modal route registration

* add unique to modal router

* remove unused id

* Update member-item-ref.element.ts

* append unique

* compare with old value

* only recreate the controller if the entity type changes

* fix console warning

* implement for document item ref

* Added $type to ReferenceResponseModels

* move logic to item data resolver

* render draft as a tag

* Update document-item-ref.element.ts

* generate server models

* add more helpers to data resolver

* export resolver

* add observables

* use observables in document item ref

* add data resolver to tree item

* add observable state

* use const

* align models

* get icon from document type object

* observe name and state

* update observed value when a new item is set

* update method name

* update method names

* pass model type

* pass context type

* use api prop instead of context

* use api prop instead of context

* fix types

* use addUniquePaths for modal registration

* add fallback

* use ref list

* use reference items for media

* make mapper name more generic

* make default ref item always readonly

* export types

* temp fake variants array

* add variants array to model

* Update media-references-workspace-info-app.element.ts

* add variants to model

* hardcode fake array

* register media ref item

* update mock data

* dot not allow conditions for data mappers

* add data mapper

* prefix info routes

* prefix all ref routes

* return undefined if there is not edit path

* fix name collision

* require data source identifier

* use management api mapper

* add management api mapper

* fix type errors

* Update index.ts

* align naming

* add todo comment

* implement path pattern for media item

* clean up

* more clean up

* sort imports

* member edit path pattern

* clean up

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
This commit is contained in:
Mads Rasmussen
2025-02-20 15:48:02 +01:00
committed by GitHub
parent 60eb55cefe
commit 78554f81f3
37 changed files with 537 additions and 294 deletions

View File

@@ -79,6 +79,7 @@ export class UmbClipboardPastePropertyValueTranslatorValueResolver<
}
// Pick the manifest with the highest priority
// TODO: This should have been handled in the extension registry, but until then we do it here: [NL]
return supportedManifests.sort((a: ManifestBase, b: ManifestBase): number => (b.weight || 0) - (a.weight || 0))[0];
}

View File

@@ -7,9 +7,6 @@ export class UmbDefaultItemRefElement extends UmbLitElement {
@property({ type: Object })
item?: UmbDefaultItemModel;
@property({ type: Boolean })
readonly = false;
@property({ type: Boolean })
standalone = false;
@@ -17,7 +14,7 @@ export class UmbDefaultItemRefElement extends UmbLitElement {
if (!this.item) return nothing;
return html`
<uui-ref-node name=${this.item.name} ?readonly=${this.readonly} ?standalone=${this.standalone}>
<uui-ref-node name=${this.item.name} ?standalone=${this.standalone} readonly>
<slot name="actions" slot="actions"></slot>
${this.#renderIcon(this.item)}
</uui-ref-node>

View File

@@ -7,6 +7,7 @@ import { UMB_MARK_ATTRIBUTE_NAME } from '@umbraco-cms/backoffice/const';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import './default-item-ref.element.js';
import { UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router';
@customElement('umb-entity-item-ref')
export class UmbEntityItemRefElement extends UmbLitElement {
@@ -33,6 +34,8 @@ export class UmbEntityItemRefElement extends UmbLitElement {
return;
}
this.#pathAddendum.setAddendum('ref/' + value.entityType + '/' + value.unique);
// If the component is already created, but the entity type is different, we need to destroy the component.
this.#createController(value.entityType);
}
@@ -63,6 +66,8 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
}
#pathAddendum = new UmbRoutePathAddendumContext(this);
protected override firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.firstUpdated(_changedProperties);
this.setAttribute(UMB_MARK_ATTRIBUTE_NAME, 'entity-item-ref');

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { UmbDataMappingResolver } from './mapping/data-mapping-resolver.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
export interface UmbDataMapperMapArgs<fromModelType = unknown, toModelType = unknown> {
forDataModel: string;
forDataSource: string;
data: fromModelType;
fallback?: (data: fromModelType) => Promise<toModelType>;
}
export class UmbDataMapper<fromModelType = unknown, toModelType = unknown> extends UmbControllerBase {
#dataMappingResolver = new UmbDataMappingResolver(this);
async map(args: UmbDataMapperMapArgs<fromModelType, toModelType>) {
if (!args.forDataSource) {
throw new Error('data source identifier is required');
}
if (!args.forDataModel) {
throw new Error('data identifier is required');
}
if (!args.data) {
throw new Error('data is required');
}
const dataMapping = await this.#dataMappingResolver.resolve(args.forDataSource, args.forDataModel);
if (!dataMapping && !args.fallback) {
throw new Error('Data mapping not found and no fallback provided.');
}
if (!dataMapping && args.fallback) {
return args.fallback(args.data);
}
if (!dataMapping?.map) {
throw new Error('Data mapping does not have a map method.');
}
return dataMapping.map(args.data);
}
}

View File

@@ -0,0 +1,3 @@
export * from './data-mapper.js';
export * from './mapping/index.js';
export * from './management-api/management-api-data-mapper.js';

View File

@@ -0,0 +1 @@
export const UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER = 'Umb.ManagementApi';

View File

@@ -0,0 +1,19 @@
import { UmbDataMapper, type UmbDataMapperMapArgs } from '../data-mapper.js';
import { UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER } from './constants.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbManagementApiDataMapper extends UmbControllerBase {
#dataMapper = new UmbDataMapper(this);
constructor(host: UmbControllerHost) {
super(host);
}
map(args: Omit<UmbDataMapperMapArgs, 'forDataSource'>) {
return this.#dataMapper.map({
...args,
forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER,
});
}
}

View File

@@ -0,0 +1,64 @@
import type { UmbDataMapping } from './types.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { createExtensionApi, type ManifestBase } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
export class UmbDataMappingResolver extends UmbControllerBase {
#apiCache = new Map<string, UmbDataMapping>();
async resolve(forDataSource: string, forDataModel: string): Promise<UmbDataMapping | undefined> {
if (!forDataSource) {
throw new Error('data source identifier is required');
}
if (!forDataModel) {
throw new Error('data identifier is required');
}
const manifest = this.#getManifestWithBestFit(forDataSource, forDataModel);
if (!manifest) {
return undefined;
}
// Check the cache before creating a new instance
if (this.#apiCache.has(manifest.alias)) {
return this.#apiCache.get(manifest.alias)!;
}
const dataMapping = await createExtensionApi<UmbDataMapping>(this, manifest);
if (!dataMapping) {
return undefined;
}
if (!dataMapping.map) {
throw new Error('Data Mapping does not have a map method.');
}
// Cache the api instance for future use
this.#apiCache.set(manifest.alias, dataMapping);
return dataMapping;
}
#getManifestWithBestFit(forDataSource: string, forDataModel: string) {
const supportedManifests = this.#getSupportedManifests(forDataSource, forDataModel);
if (!supportedManifests.length) {
return undefined;
}
// Pick the manifest with the highest priority
// TODO: This should have been handled in the extension registry, but until then we do it here: [NL]
return supportedManifests.sort((a: ManifestBase, b: ManifestBase): number => (b.weight || 0) - (a.weight || 0))[0];
}
#getSupportedManifests(forDataSource: string, forDataModel: string) {
const supportedManifests = umbExtensionsRegistry.getByTypeAndFilter('dataMapping', (manifest) => {
return manifest.forDataSource === forDataSource && manifest.forDataModel === forDataModel;
});
return supportedManifests;
}
}

View File

@@ -0,0 +1,19 @@
import type { UmbDataMapping } from './types.js';
import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestDataMapping<MetaType extends MetaDataMapping = MetaDataMapping>
extends ManifestApi<UmbDataMapping> {
type: 'dataMapping';
forDataSource: string;
forDataModel: string;
meta: MetaType;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MetaDataMapping {}
declare global {
interface UmbExtensionManifestMap {
umbManifestDataMapping: ManifestDataMapping;
}
}

View File

@@ -0,0 +1 @@
export * from './data-mapping-resolver.js';

View File

@@ -0,0 +1,6 @@
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
export type * from './data-mapping.extension.js';
export interface UmbDataMapping<fromModelType = any, toModelType = any> extends UmbApi {
map: (data: fromModelType) => Promise<toModelType>;
}

View File

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

View File

@@ -1,8 +1,9 @@
export * from './repository-items.manager.js';
export * from './repository-base.js';
export * from './item/index.js';
export * from './constants.js';
export * from './data-mapper/index.js';
export * from './detail/index.js';
export * from './item/index.js';
export * from './repository-base.js';
export * from './repository-items.manager.js';
export type { UmbDataSourceResponse, UmbDataSourceErrorResponse } from './data-source-response.interface.js';
export type * from './types.js';

View File

@@ -14,3 +14,5 @@ export interface UmbRepositoryErrorResponse extends UmbDataSourceErrorResponse {
export interface UmbRepositoryResponseWithAsObservable<T> extends UmbRepositoryResponse<T> {
asObservable: () => Observable<T | undefined>;
}
export type * from './data-mapper/mapping/types.js';

View File

@@ -16,19 +16,7 @@ export class UmbDocumentItemRefElement extends UmbLitElement {
return this.#item.getData();
}
public set item(value: UmbDocumentItemModel | undefined) {
const oldValue = this.#item.getData();
this.#item.setData(value);
if (!value) {
this.#modalRoute?.destroy();
return;
}
if (oldValue?.unique === value.unique) {
return;
}
this.#modalRoute?.setUniquePathValue('unique', value.unique);
}
@property({ type: Boolean })
@@ -55,13 +43,10 @@ export class UmbDocumentItemRefElement extends UmbLitElement {
@state()
_editPath = '';
#modalRoute?: any;
constructor() {
super();
this.#modalRoute = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath(UMB_DOCUMENT_ENTITY_TYPE)
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addUniquePaths(['unique'])
.onSetup(() => {
return { data: { entityType: UMB_DOCUMENT_ENTITY_TYPE, preset: {} } };
@@ -78,6 +63,7 @@ export class UmbDocumentItemRefElement extends UmbLitElement {
}
#getHref() {
if (!this._unique) return;
const path = UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this._unique });
return `${this._editPath}/${path}`;
}

View File

@@ -1 +1 @@
export type { UmbDocumentItemModel } from './repository/types.js';
export type * from './repository/types.js';

View File

@@ -1,20 +1,14 @@
import { UmbDocumentReferenceRepository } from '../repository/index.js';
import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../../constants.js';
import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { isDefaultReference, isDocumentReference, isMediaReference } from '@umbraco-cms/backoffice/relations';
import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import type { UmbReferenceModel } from '@umbraco-cms/backoffice/relations';
import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations';
import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
@customElement('umb-document-references-workspace-info-app')
export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement {
@state()
private _editDocumentPath = '';
@state()
private _currentPage = 1;
@@ -22,7 +16,7 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement
private _total = 0;
@state()
private _items?: Array<UmbReferenceModel> = [];
private _items?: Array<UmbReferenceItemModel> = [];
#itemsPerPage = 10;
#referenceRepository = new UmbDocumentReferenceRepository(this);
@@ -32,15 +26,6 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement
constructor() {
super();
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath('document')
.onSetup(() => {
return { data: { entityType: 'document', preset: {} } };
})
.observeRouteBuilder((routeBuilder) => {
this._editDocumentPath = routeBuilder({});
});
this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => {
this.#workspaceContext = context;
this.#observeDocumentUnique();
@@ -91,98 +76,28 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement
this.#getReferences();
}
#getIcon(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return item.documentType.icon ?? 'icon-document';
}
if (isMediaReference(item)) {
return item.mediaType.icon ?? 'icon-picture';
}
if (isDefaultReference(item)) {
return item.icon ?? 'icon-document';
}
return 'icon-document';
}
#getPublishedStatus(item: UmbReferenceModel) {
return isDocumentReference(item) ? item.published : true;
}
#getContentTypeName(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return item.documentType.name;
}
if (isMediaReference(item)) {
return item.mediaType.name;
}
if (isDefaultReference(item)) {
return item.type;
}
return '';
}
#getContentType(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return item.documentType.alias;
}
if (isMediaReference(item)) {
return item.mediaType.alias;
}
if (isDefaultReference(item)) {
return item.type;
}
return '';
}
override render() {
if (!this._items?.length) return nothing;
return html`
<umb-workspace-info-app-layout headline="#references_labelUsedByItems">
<uui-table>
<uui-table-head>
<uui-table-head-cell></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_name">Name</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_status">Status</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_typeName">Type Name</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_type">Type</umb-localize></uui-table-head-cell>
</uui-table-head>
${repeat(
this._items,
(item) => item.id,
(item) => html`
<uui-table-row>
<uui-table-cell style="text-align:center;">
<umb-icon name=${this.#getIcon(item)}></umb-icon>
</uui-table-cell>
<uui-table-cell class="link-cell">
${when(
isDocumentReference(item),
() => html`
<uui-button
label="${this.localize.term('general_edit')} ${item.name}"
href="${this._editDocumentPath}edit/${item.id}">
${item.name}
</uui-button>
`,
() => item.name,
)}
</uui-table-cell>
<uui-table-cell>
${this.#getPublishedStatus(item)
? this.localize.term('content_published')
: this.localize.term('content_unpublished')}
</uui-table-cell>
<uui-table-cell>${this.#getContentTypeName(item)}</uui-table-cell>
<uui-table-cell>${this.#getContentType(item)}</uui-table-cell>
</uui-table-row>
`,
)}
</uui-table>
${this.#renderReferencePagination()}
${this.#renderItems()} ${this.#renderReferencePagination()}
</umb-workspace-info-app-layout>
`;
}
#renderItems() {
if (!this._items) return;
return html`
<uui-ref-list>
${repeat(
this._items,
(item) => item.unique,
(item) => html`<umb-entity-item-ref .item=${item}></umb-entity-item-ref>`,
)}
</uui-ref-list>
`;
}
#renderReferencePagination() {
if (!this._total) return nothing;

View File

@@ -0,0 +1,38 @@
import type { UmbDocumentReferenceModel } from '../types.js';
import { UMB_DOCUMENT_ENTITY_TYPE } from '../../entity.js';
import {
DocumentVariantStateModel,
type DocumentReferenceResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbDataMapping } from '@umbraco-cms/backoffice/repository';
export class UmbDocumentReferenceResponseManagementApiDataMapping
extends UmbControllerBase
implements UmbDataMapping<DocumentReferenceResponseModel, UmbDocumentReferenceModel>
{
async map(data: DocumentReferenceResponseModel): Promise<UmbDocumentReferenceModel> {
return {
documentType: {
alias: data.documentType.alias,
icon: data.documentType.icon,
name: data.documentType.name,
},
entityType: UMB_DOCUMENT_ENTITY_TYPE,
id: data.id,
name: data.name,
published: data.published,
// TODO: this is a hardcoded array until the server can return the correct variants array
variants: [
{
culture: null,
name: data.name ?? '',
state: data.published ? DocumentVariantStateModel.PUBLISHED : null,
},
],
unique: data.id,
};
}
}
export { UmbDocumentReferenceResponseManagementApiDataMapping as api };

View File

@@ -1,30 +1,47 @@
import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbManagementApiDataMapper } from '@umbraco-cms/backoffice/repository';
/**
* @class UmbDocumentReferenceServerDataSource
* @implements {RepositoryDetailDataSource}
*/
export class UmbDocumentReferenceServerDataSource {
#host: UmbControllerHost;
/**
* Creates an instance of UmbDocumentReferenceServerDataSource.
* @param {UmbControllerHost} host - The controller host for this controller to be appended to
* @memberof UmbDocumentReferenceServerDataSource
*/
constructor(host: UmbControllerHost) {
this.#host = host;
}
export class UmbDocumentReferenceServerDataSource extends UmbControllerBase {
#dataMapper = new UmbManagementApiDataMapper(this);
/**
* Fetches the item for the given unique from the server
* @param {string} id
* @param {string} unique - The unique identifier of the item to fetch
* @returns {*}
* @memberof UmbDocumentReferenceServerDataSource
*/
async getReferencedBy(id: string, skip = 0, take = 20) {
return await tryExecuteAndNotify(this.#host, DocumentService.getDocumentByIdReferencedBy({ id, skip, take }));
async getReferencedBy(unique: string, skip = 0, take = 20) {
const { data, error } = await tryExecuteAndNotify(
this,
DocumentService.getDocumentByIdReferencedBy({ id: unique, skip, take }),
);
if (data) {
const promises = data.items.map(async (item) => {
return this.#dataMapper.map({
forDataModel: item.$type,
data: item,
fallback: async () => {
return {
...item,
unique: item.id,
entityType: 'unknown',
};
},
});
});
const items = await Promise.all(promises);
return { data: { items, total: data.total } };
}
return { data, error };
}
}

View File

@@ -1,4 +1,5 @@
import { UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS } from './constants.js';
import { UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER } from '@umbraco-cms/backoffice/repository';
export const manifests: Array<UmbExtensionManifest> = [
{
@@ -7,4 +8,12 @@ export const manifests: Array<UmbExtensionManifest> = [
name: 'Document Reference Repository',
api: () => import('./document-reference.repository.js'),
},
{
type: 'dataMapping',
alias: 'Umb.DataMapping.ManagementApi.DocumentReferenceResponse',
name: 'Document Reference Response Management Api Data Mapping',
api: () => import('./document-reference-response.management-api.mapping.js'),
forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER,
forDataModel: 'DocumentReferenceResponseModel',
},
];

View File

@@ -0,0 +1,28 @@
import type { UmbDocumentItemVariantModel } from '../item/types.js';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type { TrackedReferenceDocumentTypeModel } from '@umbraco-cms/backoffice/external/backend-api';
export interface UmbDocumentReferenceModel extends UmbEntityModel {
/**
* @deprecated use unique instead
* @type {string}
* @memberof UmbDocumentReferenceModel
*/
id: string;
/**
* @deprecated use name on the variant array instead
* @type {(string | null)}
* @memberof UmbDocumentReferenceModel
*/
name?: string | null;
/**
* @deprecated use state on variant array instead
* @type {boolean}
* @memberof UmbDocumentReferenceModel
*/
published?: boolean | null;
documentType: TrackedReferenceDocumentTypeModel;
variants: Array<UmbDocumentItemVariantModel>;
}

View File

@@ -60,7 +60,7 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement {
super();
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath(':entityType')
.addAdditionalPath('general/:entityType')
.onSetup((params) => {
return { data: { entityType: params.entityType, preset: {} } };
})

View File

@@ -0,0 +1,11 @@
import { UMB_MEDIA_ENTITY_TYPE } from '../entity.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'entityItemRef',
alias: 'Umb.EntityItemRef.Media',
name: 'Member Entity Item Reference',
element: () => import('./media-item-ref.element.js'),
forEntityTypes: [UMB_MEDIA_ENTITY_TYPE],
},
];

View File

@@ -0,0 +1,93 @@
import type { UmbMediaItemModel } from '../repository/types.js';
import { UMB_MEDIA_SECTION_ALIAS } from '../../media-section/constants.js';
import { UMB_MEDIA_ENTITY_TYPE } from '../entity.js';
import { UMB_EDIT_MEDIA_WORKSPACE_PATH_PATTERN } from '../paths.js';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
@customElement('umb-media-item-ref')
export class UmbMediaItemRefElement extends UmbLitElement {
#item?: UmbMediaItemModel | undefined;
@property({ type: Object })
public get item(): UmbMediaItemModel | undefined {
return this.#item;
}
public set item(value: UmbMediaItemModel | undefined) {
this.#item = value;
}
@property({ type: Boolean })
readonly = false;
@property({ type: Boolean })
standalone = false;
@state()
_editPath = '';
@state()
_userHasSectionAccess = false;
constructor() {
super();
createExtensionApiByAlias(this, UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS, [
{
config: {
match: UMB_MEDIA_SECTION_ALIAS,
},
onChange: (permitted: boolean) => {
this._userHasSectionAccess = permitted;
},
},
]);
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addUniquePaths(['unique'])
.onSetup(() => {
return { data: { entityType: UMB_MEDIA_ENTITY_TYPE, preset: {} } };
})
.observeRouteBuilder((routeBuilder) => {
this._editPath = routeBuilder({});
});
}
#getHref(item: UmbMediaItemModel) {
if (!this._editPath) return;
const path = UMB_EDIT_MEDIA_WORKSPACE_PATH_PATTERN.generateLocal({ unique: item.unique });
return `${this._editPath}/${path}`;
}
override render() {
if (!this.item) return nothing;
return html`
<uui-ref-node
name=${this.item.name}
href=${ifDefined(this.#getHref(this.item))}
?readonly=${this.readonly || !this._userHasSectionAccess}
?standalone=${this.standalone}>
<slot name="actions" slot="actions"></slot>
${this.#renderIcon(this.item)}
</uui-ref-node>
`;
}
#renderIcon(item: UmbMediaItemModel) {
if (!item.mediaType.icon) return;
return html`<umb-icon slot="icon" name=${item.mediaType.icon}></umb-icon>`;
}
}
export { UmbMediaItemRefElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-media-item-ref': UmbMediaItemRefElement;
}
}

View File

@@ -4,6 +4,7 @@ import { manifests as dropzoneManifests } from './dropzone/manifests.js';
import { manifests as entityActionsManifests } from './entity-actions/manifests.js';
import { manifests as entityBulkActionsManifests } from './entity-bulk-actions/manifests.js';
import { manifests as fileUploadPreviewManifests } from './components/input-upload-field/manifests.js';
import { manifests as itemManifests } from './item/manifests.js';
import { manifests as menuManifests } from './menu/manifests.js';
import { manifests as modalManifests } from './modals/manifests.js';
import { manifests as propertyEditorsManifests } from './property-editors/manifests.js';
@@ -23,6 +24,7 @@ export const manifests: Array<UmbExtensionManifest> = [
...entityActionsManifests,
...entityBulkActionsManifests,
...fileUploadPreviewManifests,
...itemManifests,
...menuManifests,
...modalManifests,
...propertyEditorsManifests,

View File

@@ -1,13 +1,9 @@
import { UmbMediaReferenceRepository } from '../repository/index.js';
import { UMB_MEDIA_WORKSPACE_CONTEXT } from '../../workspace/constants.js';
import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { isDefaultReference, isDocumentReference, isMediaReference } from '@umbraco-cms/backoffice/relations';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import type { UmbReferenceModel } from '@umbraco-cms/backoffice/relations';
import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router';
import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations';
import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
@@ -17,8 +13,6 @@ export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement {
#referenceRepository;
#routeBuilder?: UmbModalRouteBuilder;
@state()
private _currentPage = 1;
@@ -26,7 +20,7 @@ export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement {
private _total = 0;
@state()
private _items?: Array<UmbReferenceModel> = [];
private _items?: Array<UmbReferenceItemModel> = [];
@state()
private _loading = true;
@@ -38,15 +32,6 @@ export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement {
super();
this.#referenceRepository = new UmbMediaReferenceRepository(this);
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath(':entityType')
.onSetup((params) => {
return { data: { entityType: params.entityType, preset: {} } };
})
.observeRouteBuilder((routeBuilder) => {
this.#routeBuilder = routeBuilder;
});
this.consumeContext(UMB_MEDIA_WORKSPACE_CONTEXT, (context) => {
this.#workspaceContext = context;
this.#observeMediaUnique();
@@ -106,54 +91,6 @@ export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement {
this.#getReferences();
}
#getEditPath(item: UmbReferenceModel) {
const entityType = this.#getEntityType(item);
return this.#routeBuilder && entityType ? `${this.#routeBuilder({ entityType })}edit/${item.id}` : '#';
}
#getIcon(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return item.documentType.icon ?? 'icon-document';
}
if (isMediaReference(item)) {
return item.mediaType.icon ?? 'icon-picture';
}
if (isDefaultReference(item)) {
return item.icon ?? 'icon-document';
}
return 'icon-document';
}
#getPublishedStatus(item: UmbReferenceModel) {
return isDocumentReference(item) ? item.published : true;
}
#getContentTypeName(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return item.documentType.name;
}
if (isMediaReference(item)) {
return item.mediaType.name;
}
if (isDefaultReference(item)) {
return item.type;
}
return null;
}
#getEntityType(item: UmbReferenceModel) {
if (isDocumentReference(item)) {
return 'document';
}
if (isMediaReference(item)) {
return 'media';
}
if (isDefaultReference(item)) {
return item.type;
}
return null;
}
override render() {
if (!this._items?.length) return nothing;
return html`
@@ -168,44 +105,15 @@ export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement {
}
#renderItems() {
if (!this._items?.length) return nothing;
if (!this._items) return;
return html`
<uui-table>
<uui-table-head>
<uui-table-head-cell><umb-localize key="general_name">Name</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_status">Status</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_typeName">Type Name</umb-localize></uui-table-head-cell>
<uui-table-head-cell><umb-localize key="general_type">Type</umb-localize></uui-table-head-cell>
</uui-table-head>
<uui-ref-list>
${repeat(
this._items,
(item) => item.id,
(item) => html`
<uui-table-row>
<uui-table-cell>
<uui-ref-node name=${item.name!} href=${this.#getEditPath(item)}>
<umb-icon slot="icon" name=${this.#getIcon(item)}></umb-icon>
</uui-ref-node>
</uui-table-cell>
<uui-table-cell>
${when(
this.#getPublishedStatus(item),
() =>
html`<uui-tag color="positive" look="secondary"
>${this.localize.term('content_published')}</uui-tag
>`,
() =>
html`<uui-tag color="default" look="secondary"
>${this.localize.term('content_unpublished')}</uui-tag
>`,
)}
</uui-table-cell>
<uui-table-cell>${this.#getContentTypeName(item)}</uui-table-cell>
<uui-table-cell>${this.#getEntityType(item)}</uui-table-cell>
</uui-table-row>
`,
(item) => item.unique,
(item) => html`<umb-entity-item-ref .item=${item}></umb-entity-item-ref>`,
)}
</uui-table>
</uui-ref-list>
`;
}

View File

@@ -1,4 +1,5 @@
import { UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS } from './constants.js';
import { UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER } from '@umbraco-cms/backoffice/repository';
export const manifests: Array<UmbExtensionManifest> = [
{
@@ -7,4 +8,12 @@ export const manifests: Array<UmbExtensionManifest> = [
name: 'Media Reference Repository',
api: () => import('./media-reference.repository.js'),
},
{
type: 'dataMapping',
alias: 'Umb.DataMapping.ManagementApi.MediaReferenceResponse',
name: 'Media Reference Response Management Api Data Mapping',
api: () => import('./media-reference-response.management-api.mapping.js'),
forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_IDENTIFIER,
forDataModel: 'MediaReferenceResponseModel',
},
];

View File

@@ -0,0 +1,33 @@
import { UMB_MEDIA_ENTITY_TYPE } from '../../entity.js';
import type { UmbMediaReferenceModel } from './types.js';
import type { MediaReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbDataMapping } from '@umbraco-cms/backoffice/repository';
export class UmbMediaReferenceResponseManagementApiDataMapping
extends UmbControllerBase
implements UmbDataMapping<MediaReferenceResponseModel, UmbMediaReferenceModel>
{
async map(data: MediaReferenceResponseModel): Promise<UmbMediaReferenceModel> {
return {
entityType: UMB_MEDIA_ENTITY_TYPE,
id: data.id,
mediaType: {
alias: data.mediaType.alias,
icon: data.mediaType.icon,
name: data.mediaType.name,
},
name: data.name,
// TODO: this is a hardcoded array until the server can return the correct variants array
variants: [
{
culture: null,
name: data.name ?? '',
},
],
unique: data.id,
};
}
}
export { UmbMediaReferenceResponseManagementApiDataMapping as api };

View File

@@ -1,30 +1,47 @@
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { MediaService } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbManagementApiDataMapper } from '@umbraco-cms/backoffice/repository';
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
/**
* @class UmbMediaReferenceServerDataSource
* @implements {RepositoryDetailDataSource}
*/
export class UmbMediaReferenceServerDataSource {
#host: UmbControllerHost;
/**
* Creates an instance of UmbMediaReferenceServerDataSource.
* @param {UmbControllerHost} host - The controller host for this controller to be appended to
* @memberof UmbMediaReferenceServerDataSource
*/
constructor(host: UmbControllerHost) {
this.#host = host;
}
export class UmbMediaReferenceServerDataSource extends UmbControllerBase {
#dataMapper = new UmbManagementApiDataMapper(this);
/**
* Fetches the item for the given id from the server
* @param {Array<string>} ids
* @param {string} unique - The unique identifier of the item to fetch
* @returns {*}
* @memberof UmbMediaReferenceServerDataSource
*/
async getReferencedBy(id: string, skip = 0, take = 20) {
return await tryExecuteAndNotify(this.#host, MediaService.getMediaByIdReferencedBy({ id, skip, take }));
async getReferencedBy(unique: string, skip = 0, take = 20) {
const { data, error } = await tryExecuteAndNotify(
this,
MediaService.getMediaByIdReferencedBy({ id: unique, skip, take }),
);
if (data) {
const promises = data.items.map(async (item) => {
return this.#dataMapper.map({
forDataModel: item.$type,
data: item,
fallback: async () => {
return {
...item,
unique: item.id,
entityType: 'unknown',
};
},
});
});
const items = await Promise.all(promises);
return { data: { items, total: data.total } };
}
return { data, error };
}
}

View File

@@ -0,0 +1,28 @@
import type { UmbMediaItemVariantModel } from '../../repository/item/types.js';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type { TrackedReferenceMediaTypeModel } from '@umbraco-cms/backoffice/external/backend-api';
export interface UmbMediaReferenceModel extends UmbEntityModel {
/**
* @deprecated use unique instead
* @type {string}
* @memberof UmbMediaReferenceModel
*/
id: string;
/**
* @deprecated use name on the variant array instead
* @type {(string | null)}
* @memberof UmbMediaReferenceModel
*/
name?: string | null;
/**
* @deprecated use state on variant array instead
* @type {boolean}
* @memberof UmbMediaReferenceModel
*/
published?: boolean | null;
mediaType: TrackedReferenceMediaTypeModel;
variants: Array<UmbMediaItemVariantModel>;
}

View File

@@ -1,5 +1,6 @@
import { UMB_MEMBER_ENTITY_TYPE } from '../entity.js';
import { UMB_MEMBER_MANAGEMENT_SECTION_ALIAS } from '../../section/constants.js';
import { UMB_EDIT_MEMBER_WORKSPACE_PATH_PATTERN } from '../paths.js';
import type { UmbMemberItemModel } from './repository/types.js';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
@@ -17,18 +18,7 @@ export class UmbMemberItemRefElement extends UmbLitElement {
return this.#item;
}
public set item(value: UmbMemberItemModel | undefined) {
const oldValue = this.#item;
this.#item = value;
if (!this.#item) {
this.#modalRoute?.destroy();
return;
}
if (oldValue?.unique === this.#item.unique) {
return;
}
this.#modalRoute?.setUniquePathValue('unique', this.#item.unique);
}
@property({ type: Boolean })
@@ -43,8 +33,6 @@ export class UmbMemberItemRefElement extends UmbLitElement {
@state()
_userHasSectionAccess = false;
#modalRoute?: any;
constructor() {
super();
@@ -59,9 +47,7 @@ export class UmbMemberItemRefElement extends UmbLitElement {
},
]);
this.#modalRoute = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath(UMB_MEMBER_ENTITY_TYPE)
.addUniquePaths(['unique'])
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.onSetup(() => {
return { data: { entityType: UMB_MEMBER_ENTITY_TYPE, preset: {} } };
})
@@ -71,7 +57,9 @@ export class UmbMemberItemRefElement extends UmbLitElement {
}
#getHref(item: UmbMemberItemModel) {
return `${this._editPath}/edit/${item.unique}`;
if (!this._editPath) return;
const path = UMB_EDIT_MEMBER_WORKSPACE_PATH_PATTERN.generateLocal({ unique: item.unique });
return `${this._editPath}/${path}`;
}
override render() {

View File

@@ -16,3 +16,5 @@ export const UMB_MEMBER_ROOT_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generat
export const UMB_CREATE_MEMBER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{
memberTypeUnique: string;
}>('create/:memberTypeUnique', UMB_MEMBER_WORKSPACE_PATH);
export const UMB_EDIT_MEMBER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>('edit/:unique');

View File

@@ -1,4 +1,5 @@
import type { UmbRelationEntityType } from './entity.js';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type {
DefaultReferenceResponseModel,
DocumentReferenceResponseModel,
@@ -23,6 +24,9 @@ export interface UmbRelationDetailModel {
comment: string | null;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbReferenceItemModel extends UmbEntityModel {}
export type UmbReferenceModel =
| DefaultReferenceResponseModel
| DocumentReferenceResponseModel

View File

@@ -1,7 +1,8 @@
import { UMB_USER_ENTITY_TYPE } from '../entity.js';
import type { UmbUserItemModel } from '../repository/index.js';
import { UMB_USER_MANAGEMENT_SECTION_ALIAS } from '../../section/constants.js';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UMB_EDIT_USER_WORKSPACE_PATH_PATTERN } from '../paths.js';
import { css, customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section';
@@ -17,19 +18,7 @@ export class UmbUserItemRefElement extends UmbLitElement {
return this.#item;
}
public set item(value: UmbUserItemModel | undefined) {
const oldValue = this.#item;
this.#item = value;
if (!this.#item) {
this.#modalRoute?.destroy();
return;
}
if (oldValue?.unique === this.#item.unique) {
return;
}
this.#modalRoute?.setUniquePathValue('unique', this.#item.unique);
}
@property({ type: Boolean })
@@ -44,8 +33,6 @@ export class UmbUserItemRefElement extends UmbLitElement {
@state()
_userHasSectionAccess = false;
#modalRoute?: any;
constructor() {
super();
@@ -60,9 +47,7 @@ export class UmbUserItemRefElement extends UmbLitElement {
},
]);
this.#modalRoute = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath(UMB_USER_ENTITY_TYPE)
.addUniquePaths(['unique'])
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.onSetup(() => {
return { data: { entityType: UMB_USER_ENTITY_TYPE, preset: {} } };
})
@@ -72,7 +57,9 @@ export class UmbUserItemRefElement extends UmbLitElement {
}
#getHref(item: UmbUserItemModel) {
return `${this._editPath}/edit/${item.unique}`;
if (!this._editPath) return;
const path = UMB_EDIT_USER_WORKSPACE_PATH_PATTERN.generateLocal({ unique: item.unique });
return `${this._editPath}/${path}`;
}
override render() {
@@ -81,7 +68,7 @@ export class UmbUserItemRefElement extends UmbLitElement {
return html`
<uui-ref-node-user
name=${this.item.name}
href=${this.#getHref(this.item)}
href=${ifDefined(this.#getHref(this.item))}
?readonly=${this.readonly || !this._userHasSectionAccess}
?standalone=${this.standalone}>
<umb-user-avatar

View File

@@ -1,5 +1,6 @@
import { UMB_USER_SECTION_PATHNAME } from '../section/paths.js';
import { UMB_USER_ENTITY_TYPE, UMB_USER_ROOT_ENTITY_TYPE } from './entity.js';
import { UmbPathPattern } from '@umbraco-cms/backoffice/router';
import { UMB_WORKSPACE_PATH_PATTERN } from '@umbraco-cms/backoffice/workspace';
export const UMB_USER_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generateAbsolute({
@@ -11,3 +12,5 @@ export const UMB_USER_ROOT_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generateA
sectionName: UMB_USER_SECTION_PATHNAME,
entityType: UMB_USER_ROOT_ENTITY_TYPE,
});
export const UMB_EDIT_USER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>('edit/:unique');