Allow sort of children by name and create date (#17904)

* Added create date to document and media children endpoints.

* Sort by name or create date for documents and media.

* Fix build issues.

* Only render column headers for sorting if all pages of children are loaded.

* Add indicator and debounce sorting by column headers.
This commit is contained in:
Andy Butland
2025-01-08 12:46:12 +01:00
committed by GitHub
parent e425f0ba41
commit cbd162b3c7
21 changed files with 224 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Factories;
@@ -54,6 +54,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
responseModel.IsProtected = _publicAccessService.IsProtected(entity.Path);
responseModel.IsTrashed = entity.Trashed;
responseModel.Id = entity.Key;
responseModel.CreateDate = entity.CreateDate;
responseModel.Variants = _documentPresentationFactory.CreateVariantsItemResponseModels(documentEntitySlim);
responseModel.DocumentType = _documentPresentationFactory.CreateDocumentTypeReferenceResponseModel(documentEntitySlim);

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Factories;
@@ -50,6 +50,7 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
{
responseModel.IsTrashed = entity.Trashed;
responseModel.Id = entity.Key;
responseModel.CreateDate = entity.CreateDate;
responseModel.Variants = _mediaPresentationFactory.CreateVariantsItemResponseModels(mediaEntitySlim);
responseModel.MediaType = _mediaPresentationFactory.CreateMediaTypeReferenceResponseModel(mediaEntitySlim);

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Controllers.Content;
@@ -54,6 +54,7 @@ public abstract class RecycleBinControllerBase<TItem> : ContentControllerBase
var viewModel = new TItem
{
Id = entity.Key,
CreateDate = entity.CreateDate,
HasChildren = entity.HasChildren,
Parent = parentKey.HasValue
? new ItemReferenceByIdResponseModel

View File

@@ -36901,6 +36901,7 @@
},
"DocumentRecycleBinItemResponseModel": {
"required": [
"createDate",
"documentType",
"hasChildren",
"id",
@@ -36912,6 +36913,10 @@
"type": "string",
"format": "uuid"
},
"createDate": {
"type": "string",
"format": "date-time"
},
"hasChildren": {
"type": "boolean"
},
@@ -37040,6 +37045,7 @@
},
"DocumentTreeItemResponseModel": {
"required": [
"createDate",
"documentType",
"hasChildren",
"id",
@@ -37071,6 +37077,10 @@
"type": "string",
"format": "uuid"
},
"createDate": {
"type": "string",
"format": "date-time"
},
"isProtected": {
"type": "boolean"
},
@@ -39014,6 +39024,7 @@
},
"MediaRecycleBinItemResponseModel": {
"required": [
"createDate",
"hasChildren",
"id",
"mediaType",
@@ -39025,6 +39036,10 @@
"type": "string",
"format": "uuid"
},
"createDate": {
"type": "string",
"format": "date-time"
},
"hasChildren": {
"type": "boolean"
},
@@ -39141,6 +39156,7 @@
},
"MediaTreeItemResponseModel": {
"required": [
"createDate",
"hasChildren",
"id",
"isTrashed",
@@ -39171,6 +39187,10 @@
"type": "string",
"format": "uuid"
},
"createDate": {
"type": "string",
"format": "date-time"
},
"mediaType": {
"oneOf": [
{

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Api.Management.ViewModels.Item;
namespace Umbraco.Cms.Api.Management.ViewModels.RecycleBin;
@@ -8,6 +8,8 @@ public abstract class RecycleBinItemResponseModelBase
[Required]
public Guid Id { get; set; }
public DateTimeOffset CreateDate { get; set; }
[Required]
public bool HasChildren { get; set; }

View File

@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Tree;
namespace Umbraco.Cms.Api.Management.ViewModels.Tree;
public abstract class ContentTreeItemResponseModel : EntityTreeItemResponseModel
{
@@ -7,4 +7,6 @@ public abstract class ContentTreeItemResponseModel : EntityTreeItemResponseModel
public bool IsTrashed { get; set; }
public Guid Id { get; set; }
public DateTimeOffset CreateDate { get; set; }
}

View File

@@ -673,6 +673,7 @@ export type DocumentPermissionPresentationModel = {
export type DocumentRecycleBinItemResponseModel = {
id: string;
createDate: string;
hasChildren: boolean;
parent?: ((ItemReferenceByIdResponseModel) | null);
documentType: (DocumentTypeReferenceResponseModel);
@@ -702,6 +703,7 @@ export type DocumentTreeItemResponseModel = {
noAccess: boolean;
isTrashed: boolean;
id: string;
createDate: string;
isProtected: boolean;
documentType: (DocumentTypeReferenceResponseModel);
variants: Array<(DocumentVariantItemResponseModel)>;
@@ -1196,6 +1198,7 @@ export type MediaItemResponseModel = {
export type MediaRecycleBinItemResponseModel = {
id: string;
createDate: string;
hasChildren: boolean;
parent?: ((ItemReferenceByIdResponseModel) | null);
mediaType: (MediaTypeReferenceResponseModel);
@@ -1223,6 +1226,7 @@ export type MediaTreeItemResponseModel = {
noAccess: boolean;
isTrashed: boolean;
id: string;
createDate: string;
mediaType: (MediaTypeReferenceResponseModel);
variants: Array<(VariantItemResponseModel)>;
};

View File

@@ -14,6 +14,7 @@ export const data: Array<UmbMockDocumentBlueprintModel> = [
],
template: null,
id: 'the-simplest-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'the-simplest-document-type-id',

View File

@@ -45,6 +45,7 @@ const treeItemMapper = (model: UmbMockDocumentBlueprintModel): Omit<DocumentTree
noAccess: model.noAccess,
parent: model.parent,
variants: model.variants,
createDate: model.createDate,
};
};
@@ -62,6 +63,7 @@ const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel):
},
hasChildren: false,
id: request.id ? request.id : UmbId.new(),
createDate: now,
isProtected: false,
isTrashed: false,
noAccess: false,

View File

@@ -17,6 +17,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'the-simplest-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'the-simplest-document-type-id',
@@ -56,6 +57,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'all-property-editors-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'all-property-editors-document-type-id',
@@ -601,6 +603,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'c05da24d-7740-447b-9cdc-bd8ce2172e38',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: '29643452-cff9-47f2-98cd-7de4b6807681',
@@ -734,6 +737,7 @@ export const data: Array<UmbMockDocumentModel> = [
urls: [],
template: null,
id: 'fd56a0b5-01a0-4da2-b428-52773bfa9cc4',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: '29643452-cff9-47f2-98cd-7de4b6807681',
@@ -822,6 +826,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'simple-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'simple-document-type-id',
@@ -869,6 +874,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'all-rtes-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'all-rtes-document-type-id',
@@ -938,6 +944,7 @@ export const data: Array<UmbMockDocumentModel> = [
],
template: null,
id: 'block-editors-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'block-editors-document-type-id',

View File

@@ -69,6 +69,7 @@ const treeItemMapper = (model: UmbMockDocumentModel): DocumentTreeItemResponseMo
noAccess: model.noAccess,
parent: model.parent,
variants: model.variants,
createDate: model.createDate
};
};
@@ -86,6 +87,7 @@ const createMockDocumentMapper = (request: CreateDocumentRequestModel): UmbMockD
},
hasChildren: false,
id: request.id ? request.id : UmbId.new(),
createDate: now,
isProtected: false,
isTrashed: false,
noAccess: false,

View File

@@ -10,6 +10,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: 'f2f81a40-c989-4b6b-84e2-057cecd3adc1',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
noAccess: false,
isTrashed: false,
@@ -39,6 +40,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: '69431027-8867-45bf-a93b-72bbdabfb177',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
noAccess: false,
isTrashed: false,
@@ -68,6 +70,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: true,
id: '69461027-8867-45bf-a93b-72bbdabfb177',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
noAccess: false,
isTrashed: false,
@@ -92,6 +95,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: true,
id: '69461027-8867-45bf-a93b-5224dabfb177',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
noAccess: false,
isTrashed: false,
@@ -116,6 +120,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: '69431027-8867-45s7-a93b-7uibdabfb177',
createDate: '2023-02-06T15:32:05.350038',
parent: { id: '69461027-8867-45bf-a93b-72bbdabfb177' },
noAccess: false,
isTrashed: false,
@@ -145,6 +150,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: '69431027-8867-45s7-a93b-7uibdabf2147',
createDate: '2023-02-06T15:32:05.350038',
parent: { id: '69461027-8867-45bf-a93b-72bbdabfb177' },
noAccess: false,
isTrashed: false,
@@ -174,6 +180,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: '694hdj27-8867-45s7-a93b-7uibdabf2147',
createDate: '2023-02-06T15:32:05.350038',
parent: { id: '69461027-8867-45bf-a93b-5224dabfb177' },
noAccess: false,
isTrashed: false,
@@ -203,6 +210,7 @@ export const data: Array<UmbMockMediaModel> = [
{
hasChildren: false,
id: '694hdj27-1237-45s7-a93b-7uibdabfas47',
createDate: '2023-02-06T15:32:05.350038',
parent: { id: '69461027-8867-45bf-a93b-5224dabfb177' },
noAccess: false,
isTrashed: false,

View File

@@ -45,6 +45,7 @@ const treeItemMapper = (model: UmbMockMediaModel): MediaTreeItemResponseModel =>
noAccess: model.noAccess,
parent: model.parent,
variants: model.variants,
createDate: model.createDate,
};
};
@@ -62,6 +63,7 @@ const createMockMediaMapper = (request: CreateMediaRequestModel): UmbMockMediaMo
},
hasChildren: false,
id: request.id ? request.id : UmbId.new(),
createDate: now,
isTrashed: false,
noAccess: false,
parent: request.parent,

View File

@@ -8,6 +8,8 @@ import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-reg
import type { UmbTreeRepository, UmbTreeItemModel, UmbSortChildrenOfRepository } from '@umbraco-cms/backoffice/tree';
import { UmbPaginationManager } from '@umbraco-cms/backoffice/utils';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import type { UmbDocumentTreeItemModel } from '@umbraco-cms/backoffice/document';
import type { UmbMediaTreeItemModel } from '@umbraco-cms/backoffice/media';
const elementName = 'umb-sort-children-of-modal';
@@ -25,10 +27,28 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
@state()
_totalPages = 1;
@state()
_isSorting: boolean = false;
#hasMorePages() {
return this._currentPage < this._totalPages;;
}
#pagination = new UmbPaginationManager();
#sortedUniques = new Set<string>();
#sorter?: UmbSorterController<UmbTreeItemModel>;
#sortBy: string = "";
#sortDirection: string = "";
#localizeDateOptions: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
};
constructor() {
super();
this.#pagination.setPageSize(50);
@@ -90,8 +110,8 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
return modelEntry.unique;
},
identifier: 'Umb.SorterIdentifier.SortChildrenOfModal',
itemSelector: 'uui-ref-node',
containerSelector: 'uui-ref-list',
itemSelector: 'uui-table-row',
containerSelector: 'uui-table',
onChange: ({ model }) => {
const oldValue = this._children;
this._children = model;
@@ -129,6 +149,49 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
}
}
#onSortChildrenBy(key: string) {
if (this._isSorting) {
return;
}
this._isSorting = true;
const oldValue = this._children;
// If switching column, revert to ascending sort. Otherwise switch from whatever was previously selected.
if (this.#sortBy !== key) {
this.#sortDirection = "asc";
} else {
this.#sortDirection = this.#sortDirection === "asc" ? "desc" : "asc";
}
// Sort by the new column.
this.#sortBy = key;
this._children = [...this._children].sort((a, b) => {
switch (key) {
case "name":
return a.name.localeCompare(b.name);
case "createDate":
return Date.parse(this.#getCreateDate(a)) - Date.parse(this.#getCreateDate(b));
default:
return 0;
}
});
// Reverse the order if sorting descending.
if (this.#sortDirection === "desc") {
this._children.reverse();
}
this.#sortedUniques.clear();
this._children.map(c => c.unique).forEach(u => this.#sortedUniques.add(u));
this.requestUpdate('_children', oldValue);
this._isSorting = false;
}
#getSortOrderOfSortedItems() {
const sorting = [];
@@ -148,6 +211,21 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
return sorting;
}
#getCreateDate(item: UmbTreeItemModel) : string {
let date = "";
const itemAsDocumentTreeItemModel = item as UmbDocumentTreeItemModel;
if (itemAsDocumentTreeItemModel) {
date = itemAsDocumentTreeItemModel.createDate;
} else {
const itemAsMediaTreeItemModel = item as UmbMediaTreeItemModel;
if (itemAsMediaTreeItemModel) {
date = itemAsMediaTreeItemModel.createDate;
}
}
return date;
}
override render() {
return html`
<umb-body-layout headline=${'Sort Children'}>
@@ -161,15 +239,29 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
#renderChildren() {
if (this._children.length === 0) return html`<uui-label>There are no children</uui-label>`;
return html`
<uui-ref-list>
<uui-table>
<uui-table-head>
<uui-table-head-cell></uui-table-head-cell>
${this.#renderHeaderCell("name", "general_name")}
${this.#renderHeaderCell("createDate", "content_createDate")}
</uui-table-head>
${this._isSorting
? html`
<uui-table-row>
<uui-table-cell></uui-table-cell>
<uui-table-cell><uui-loader-circle></uui-loader-circle></uui-table-cell>
<uui-table-cell></uui-table-cell>
</uui-table-row>
`
: nothing}
${repeat(
this._children,
(child) => child.unique,
(child) => this.#renderChild(child),
)}
</uui-ref-list>
this._children,
(child) => child.unique,
(child) => this.#renderChild(child),
)}
</uui-table>
${this._currentPage < this._totalPages
${this.#hasMorePages()
? html`
<uui-button id="loadMoreButton" look="secondary" @click=${this.#onLoadMore}
>Load More (${this._currentPage}/${this._totalPages})</uui-button
@@ -179,8 +271,43 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
`;
}
#renderHeaderCell(key: string, labelKey: string) {
// Only provide buttons for sorting via the column headers if all pages have been loaded.
return html`
<uui-table-head-cell>
${this.#hasMorePages()
? html`
<span>${this.localize.term(labelKey)}</span>
`
: html`
<button @click=${() => this.#onSortChildrenBy(key)}>
${this.localize.term(labelKey)}
<uui-symbol-sort
?active=${this.#sortBy === key}
?descending=${this.#sortDirection === "desc"}
></uui-symbol-sort>
</button>
`}
</uui-table-head-cell>`;
}
#renderChild(item: UmbTreeItemModel) {
return html`<uui-ref-node .name=${item.name} data-unique=${item.unique}></uui-ref-node>`;
return html`
<uui-table-row data-unique=${item.unique} class="${this._isSorting ? "hidden" : ""}">
<uui-table-cell><uui-icon name="icon-navigation" aria-hidden="true"></uui-icon></uui-table-cell>
<uui-table-cell>${item.name}</uui-table-cell>
<uui-table-cell>${this.#renderCreateDate(item)}</uui-table-cell>
</uui-table-row>`;
}
#renderCreateDate(item: UmbTreeItemModel) {
const date = this.#getCreateDate(item);
if (date.length === 0) {
return nothing;
}
return html`<umb-localize-date date="${date}" .options=${this.#localizeDateOptions}></umb-localize-date>`;
}
static override styles = [
@@ -189,6 +316,28 @@ export class UmbSortChildrenOfModalElement extends UmbModalBaseElement<
#loadMoreButton {
width: 100%;
}
uui-table-head-cell button {
background-color: transparent;
color: inherit;
border: none;
cursor: pointer;
font-weight: inherit;
font-size: inherit;
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--uui-size-5) var(--uui-size-1);
}
uui-table-row.hidden {
visibility: hidden;
}
uui-icon[name="icon-navigation"] {
cursor: hand
}
`,
];
}

View File

@@ -16,7 +16,7 @@ export class UmbSortChildrenOfEntityAction extends UmbEntityActionBase<MetaEntit
},
});
await modal.onSubmit();
await modal.onSubmit().catch(() => undefined);
const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);

View File

@@ -85,5 +85,6 @@ const mapper = (item: DocumentRecycleBinItemResponseModel): UmbDocumentRecycleBi
}),
name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution.
isFolder: false,
createDate: item.createDate
};
};

View File

@@ -91,5 +91,6 @@ const mapper = (item: DocumentTreeItemResponseModel): UmbDocumentTreeItemModel =
}),
name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution.
isFolder: false,
createDate: item.createDate,
};
};

View File

@@ -18,6 +18,7 @@ export interface UmbDocumentTreeItemModel extends UmbTreeItemModel {
icon: string;
collection: UmbReferenceByUnique | null;
};
createDate: string;
variants: Array<UmbDocumentTreeItemVariantModel>;
}

View File

@@ -82,5 +82,6 @@ const mapper = (item: MediaRecycleBinItemResponseModel): UmbMediaRecycleBinTreeI
}),
name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution.
isFolder: false,
createDate: item.createDate
};
};

View File

@@ -83,5 +83,6 @@ const mapper = (item: MediaTreeItemResponseModel): UmbMediaTreeItemModel => {
culture: variant.culture || null,
};
}),
createDate: item.createDate,
};
};

View File

@@ -17,6 +17,7 @@ export interface UmbMediaTreeItemModel extends UmbTreeItemModel {
collection: UmbReferenceByUnique | null;
};
variants: Array<UmbMediaTreeItemVariantModel>;
createDate: string;
}
export interface UmbMediaTreeRootModel extends UmbTreeRootModel {