diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts index a057b8bb1596..d4cb4c63381a 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts @@ -1,4 +1,5 @@ import { partialUpdateFrozenArray } from '../utils/partial-update-frozen-array.function.js'; +import { prependToUniqueArray } from '../utils/prepend-to-unique-array.function.js'; import { pushAtToUniqueArray } from '../utils/push-at-to-unique-array.function.js'; import { pushToUniqueArray } from '../utils/push-to-unique-array.function.js'; import { UmbDeepState } from './deep-state.js'; @@ -271,6 +272,36 @@ export class UmbArrayState extends UmbDeepState { return this; } + /** + * @function prepend + * @param {T[]} entries - A array of new data to be added in this Subject. + * @returns {UmbArrayState} Reference to it self. + * @description - Prepend some new data to this Subject, if it compares to existing data it will replace it. + * @example Example prepend some data. + * const data = [ + * { key: 1, value: 'foo'}, + * { key: 2, value: 'bar'} + * ]; + * const myState = new UmbArrayState(data); + * myState.prepend([ + * { key: 0, value: 'another-bla'}, + * { key: 1, value: 'replaced-foo'}, + * ]); + */ + prepend(entries: T[]) { + if (this.getUniqueMethod) { + const next = [...this.getValue()]; + entries.forEach((entry) => { + prependToUniqueArray(next, entry, this.getUniqueMethod!); + }); + this.setValue(next); + } else { + this.setValue([...entries, ...this.getValue()]); + } + + return this; + } + override destroy() { super.destroy(); this.#sortMethod = undefined; diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/prepend-to-unique-array.function.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/prepend-to-unique-array.function.ts new file mode 100644 index 000000000000..9cfa07183792 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/prepend-to-unique-array.function.ts @@ -0,0 +1,22 @@ +/** + * @function prependToUniqueArray + * @param {T[]} data - An array of objects. + * @param {T} entry - The object to insert or replace with. + * @param {getUniqueMethod: (entry: T) => unknown} [getUniqueMethod] - Method to get the unique value of an entry. + * @description - Prepend or replaces an item of an Array. + * @returns {T[]} - The new array with the entry prepended or replaced. + * @example Example prepend new entry for a Array. Where the key is unique and the item will be updated if matched with existing. + * const entry = {key: 'myKey', value: 'myValue'}; + * const newDataSet = prependToUniqueArray([], entry, x => x.key === key); + * myState.setValue(newDataSet); + */ +export function prependToUniqueArray(data: T[], entry: T, getUniqueMethod: (entry: T) => unknown): T[] { + const unique = getUniqueMethod(entry); + const indexToReplace = data.findIndex((x) => getUniqueMethod(x) === unique); + if (indexToReplace !== -1) { + data[indexToReplace] = entry; + } else { + data.unshift(entry); + } + return data; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 8cc5b2ace817..509e02f6c36d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -408,7 +408,7 @@ export type CreateUserGroupRequestModel = { mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; id?: string | null; }; @@ -467,7 +467,7 @@ export type CurrentUserResponseModel = { hasAccessToAllLanguages: boolean; hasAccessToSensitiveData: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; allowedSections: Array; isAdmin: boolean; }; @@ -773,6 +773,12 @@ export type DocumentTypeItemResponseModel = { description?: string | null; }; +export type DocumentTypePermissionPresentationModel = { + $type: string; + verbs: Array; + documentTypeAlias: string; +}; + export type DocumentTypePropertyTypeContainerResponseModel = { id: string; parent?: ReferenceByIdModel | null; @@ -2382,6 +2388,48 @@ export type StylesheetResponseModel = { content: string; }; +export type SubsetDataTypeTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetDocumentBlueprintTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetDocumentTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetDocumentTypeTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetMediaTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetMediaTypeTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetNamedEntityTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + export type TagResponseModel = { id: string; text?: string | null; @@ -2777,7 +2825,7 @@ export type UpdateUserGroupRequestModel = { mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; }; export type UpdateUserGroupsOnUserRequestModel = { @@ -2884,7 +2932,7 @@ export type UserGroupResponseModel = { mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; id: string; isDeletable: boolean; aliasCanBeChanged: boolean; @@ -3771,7 +3819,7 @@ export type GetTreeDataTypeSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetDataTypeTreeItemResponseModel; }; export type GetTreeDataTypeSiblingsResponse = GetTreeDataTypeSiblingsResponses[keyof GetTreeDataTypeSiblingsResponses]; @@ -4709,7 +4757,7 @@ export type GetTreeDocumentBlueprintSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetDocumentBlueprintTreeItemResponseModel; }; export type GetTreeDocumentBlueprintSiblingsResponse = GetTreeDocumentBlueprintSiblingsResponses[keyof GetTreeDocumentBlueprintSiblingsResponses]; @@ -5550,7 +5598,7 @@ export type GetTreeDocumentTypeSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetDocumentTypeTreeItemResponseModel; }; export type GetTreeDocumentTypeSiblingsResponse = GetTreeDocumentTypeSiblingsResponses[keyof GetTreeDocumentTypeSiblingsResponses]; @@ -7156,6 +7204,7 @@ export type GetTreeDocumentSiblingsData = { target?: string; before?: number; after?: number; + dataTypeId?: string; }; url: '/umbraco/management/api/v1/tree/document/siblings'; }; @@ -7175,7 +7224,7 @@ export type GetTreeDocumentSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetDocumentTreeItemResponseModel; }; export type GetTreeDocumentSiblingsResponse = GetTreeDocumentSiblingsResponses[keyof GetTreeDocumentSiblingsResponses]; @@ -9070,7 +9119,7 @@ export type GetTreeMediaTypeSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetMediaTypeTreeItemResponseModel; }; export type GetTreeMediaTypeSiblingsResponse = GetTreeMediaTypeSiblingsResponses[keyof GetTreeMediaTypeSiblingsResponses]; @@ -10016,6 +10065,7 @@ export type GetTreeMediaSiblingsData = { target?: string; before?: number; after?: number; + dataTypeId?: string; }; url: '/umbraco/management/api/v1/tree/media/siblings'; }; @@ -10035,7 +10085,7 @@ export type GetTreeMediaSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetMediaTreeItemResponseModel; }; export type GetTreeMediaSiblingsResponse = GetTreeMediaSiblingsResponses[keyof GetTreeMediaSiblingsResponses]; @@ -14170,7 +14220,7 @@ export type GetTreeTemplateSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetNamedEntityTreeItemResponseModel; }; export type GetTreeTemplateSiblingsResponse = GetTreeTemplateSiblingsResponses[keyof GetTreeTemplateSiblingsResponses]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts index 32d7a5502038..3980abcf10cb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts @@ -2,8 +2,14 @@ import type { UmbDataSourceErrorResponse, UmbDataSourceResponse } from './data-s import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; export interface UmbPagedModel { - total: number; items: Array; + total: number; +} + +export interface UmbTargetPagedModel extends UmbPagedModel { + // TODO: v18: make mandatory + totalAfter?: number; + totalBefore?: number; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/index.ts index dda6b6dd694d..f5b983409b04 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/index.ts @@ -1 +1,2 @@ export * from './tree-load-more-button.element.js'; +export * from './tree-load-prev-button.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/tree-load-prev-button.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/tree-load-prev-button.element.ts new file mode 100644 index 000000000000..3fb6f51d90ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/components/tree-load-prev-button.element.ts @@ -0,0 +1,35 @@ +import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-tree-load-prev-button') +export class UmbTreeLoadPrevButtonElement extends UmbLitElement { + override render() { + return html``; + } + + static override readonly styles = css` + :host { + position: relative; + display: block; + padding-left: var(--uui-size-space-3); + margin-right: var(--uui-size-space-2); + margin-left: calc(var(--uui-menu-item-indent, 0) * var(--uui-size-4)); + } + + uui-button { + width: 100%; + height: var(--uui-size---uui-size-layout-3); + --uui-box-border-radius: calc(var(--uui-border-radius) * 2); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-tree-load-prev-button': UmbTreeLoadPrevButtonElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-data-source.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-data-source.interface.ts index 81d8bf83b7d8..3db542291671 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-data-source.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-data-source.interface.ts @@ -4,7 +4,7 @@ import type { UmbTreeChildrenOfRequestArgs, UmbTreeRootItemsRequestArgs, } from './types.js'; -import type { UmbPagedModel, UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; +import type { UmbDataSourceResponse, UmbTargetPagedModel } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; /** @@ -32,18 +32,18 @@ export interface UmbTreeDataSource< * @returns {*} {Promise>>} * @memberof UmbTreeDataSource */ - getRootItems(args: TreeRootItemsRequestArgsType): Promise>>; + getRootItems(args: TreeRootItemsRequestArgsType): Promise>>; /** * Gets the children of the given parent item. - * @returns {*} {Promise>} + * @returns {Promise>>} * @memberof UmbTreeDataSource */ - getChildrenOf(args: TreeChildrenOfRequestArgsType): Promise>>; + getChildrenOf(args: TreeChildrenOfRequestArgsType): Promise>>; /** * Gets the ancestors of the given item. - * @returns {*} {Promise>} + * @returns {Promise>>} * @memberof UmbTreeDataSource */ getAncestorsOf(args: TreeAncestorsOfRequestArgsType): Promise>>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-repository.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-repository.interface.ts index 84011f15b184..a7847dccd240 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-repository.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-repository.interface.ts @@ -5,9 +5,9 @@ import type { UmbTreeRootItemsRequestArgs, } from './types.js'; import type { - UmbPagedModel, UmbRepositoryResponse, UmbRepositoryResponseWithAsObservable, + UmbTargetPagedModel, } from '@umbraco-cms/backoffice/repository'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; @@ -39,7 +39,7 @@ export interface UmbTreeRepository< */ requestTreeRootItems: ( args: TreeRootItemsRequestArgsType, - ) => Promise, TreeItemType[]>>; + ) => Promise, TreeItemType[]>>; /** * Requests the children of the given parent item. @@ -48,7 +48,7 @@ export interface UmbTreeRepository< */ requestTreeItemsOf: ( args: TreeChildrenOfRequestArgsType, - ) => Promise, TreeItemType[]>>; + ) => Promise, TreeItemType[]>>; /** * Requests the ancestors of the given item. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-server-data-source-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-server-data-source-base.ts index d265b448fe94..ced30fed3a08 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-server-data-source-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/tree-server-data-source-base.ts @@ -7,7 +7,7 @@ import type { } from './types.js'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; +import type { UmbDataSourceResponse, UmbTargetPagedModel } from '@umbraco-cms/backoffice/repository'; export interface UmbTreeServerDataSourceBaseArgs< ServerTreeItemType extends { hasChildren: boolean }, @@ -18,10 +18,10 @@ export interface UmbTreeServerDataSourceBaseArgs< > { getRootItems: ( args: TreeRootItemsRequestArgsType, - ) => Promise>>; + ) => Promise>>; getChildrenOf: ( args: TreeChildrenOfRequestArgsType, - ) => Promise>>; + ) => Promise>>; getAncestorsOf: (args: TreeAncestorsOfRequestArgsType) => Promise>>; mapper: (item: ServerTreeItemType) => ClientTreeItemType; } @@ -85,7 +85,14 @@ export abstract class UmbTreeServerDataSourceBase< if (data) { const items = data?.items.map((item) => this.#mapper(item)); - return { data: { total: data.total, items } }; + return { + data: { + total: data.total, + totalBefore: data.totalBefore, + totalAfter: data.totalAfter, + items, + }, + }; } return { error }; @@ -104,7 +111,14 @@ export abstract class UmbTreeServerDataSourceBase< if (data) { const items = data?.items.map((item: ServerTreeItemType) => this.#mapper(item)); - return { data: { total: data.total, items } }; + return { + data: { + total: data.total, + totalBefore: data.totalBefore, + totalAfter: data.totalAfter, + items, + }, + }; } return { error }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/types.ts index e4615715221d..0296a263f32c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/data/types.ts @@ -1,9 +1,11 @@ import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbTargetPaginationModel } from '@umbraco-cms/backoffice/utils'; export interface UmbTreeRootItemsRequestArgs { foldersOnly?: boolean; skip?: number; take?: number; + target?: UmbTargetPaginationModel; } export interface UmbTreeChildrenOfRequestArgs { @@ -11,6 +13,7 @@ export interface UmbTreeChildrenOfRequestArgs { foldersOnly?: boolean; skip?: number; take?: number; + target?: UmbTargetPaginationModel; } export interface UmbTreeAncestorsOfRequestArgs { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts index 26f6bba57deb..9fc6b10ea175 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts @@ -2,7 +2,7 @@ import type { UmbTreeExpansionModel } from './types.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { Observable } from '@umbraco-cms/backoffice/observable-api'; -import { UmbEntityExpansionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbEntityExpansionManager, type UmbEntityExpansionEntryModel } from '@umbraco-cms/backoffice/utils'; /** * Manages the expansion state of a tree @@ -53,7 +53,7 @@ export class UmbTreeExpansionManager extends UmbControllerBase { * @memberof UmbTreeExpansionManager * @returns {Promise} */ - public async expandItem(entity: UmbEntityModel): Promise { + public async expandItem(entity: UmbEntityExpansionEntryModel): Promise { this.#manager.expandItem(entity); } @@ -77,4 +77,16 @@ export class UmbTreeExpansionManager extends UmbControllerBase { public async collapseAll(): Promise { this.#manager.collapseAll(); } + + /** + * Gets a tree item from the expansion state + * @param {UmbEntityModel} entity The entity to get + * @param {string} entity.entityType The entity type + * @param {string} entity.unique The unique key + * @returns {*} {(Promise)} + * @memberof UmbEntityExpansionManager + */ + public async getItem(entity: UmbEntityModel): Promise { + return this.#manager.getItem(entity); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts index 6ddd369c0372..e7311a5c8e5f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts @@ -17,7 +17,12 @@ import { UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; import type { UmbEntityActionEvent } from '@umbraco-cms/backoffice/entity-action'; -import { UmbDeprecation, UmbPaginationManager, debounce } from '@umbraco-cms/backoffice/utils'; +import { + UmbDeprecation, + UmbPaginationManager, + UmbTargetPaginationManager, + debounce, +} from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbParentEntityContext, type UmbEntityModel, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; import { ensureSlash } from '@umbraco-cms/backoffice/router'; @@ -32,8 +37,26 @@ export abstract class UmbTreeItemContextBase< { public unique?: UmbEntityUnique; public entityType?: string; + + /** + * + * The pagination manager for the tree item context. + * @memberof UmbTreeItemContextBase + */ public readonly pagination = new UmbPaginationManager(); + /** + * The pagination manager for the previous items in the tree item context. + * @memberof UmbTreeItemContextBase + */ + public readonly paginationPrev = new UmbTargetPaginationManager(this); + + /** + * The pagination manager for the next items in the tree item context. + * @memberof UmbTreeItemContextBase + */ + public readonly paginationNext = new UmbTargetPaginationManager(this); + #manifest?: ManifestType; protected readonly _treeItem = new UmbObjectState(undefined); @@ -84,10 +107,11 @@ export abstract class UmbTreeItemContextBase< #hasChildrenContext = new UmbHasChildrenEntityContext(this); #parentContext = new UmbParentEntityContext(this); - // TODO: get this from the tree context + #startTarget: any = undefined; // TODO: fix this type + #endTarget: any = undefined; // TODO: fix this type + #paging = { - skip: 0, - take: 50, + take: 5, }; constructor(host: UmbControllerHost) { @@ -165,16 +189,33 @@ export abstract class UmbTreeItemContextBase< /** * Load children of the tree item * @memberof UmbTreeItemContextBase + * @returns {void} */ public loadChildren = () => this.#loadChildren(); /** * Load more children of the tree item + * @deprecated Use `loadNextItems` instead. Will be removed in v18.0.0. + * @memberof UmbTreeItemContextBase + * @returns {void} + */ + public loadMore = () => this.#loadNextItemsFromTarget(); + + /** + * Load previous items of the tree item * @memberof UmbTreeItemContextBase + * @returns {void} */ - public loadMore = () => this.#loadChildren(true); + public loadPrevItems = () => this.#loadPrevItemsFromTarget(); - async #loadChildren(loadMore = false) { + /** + * Load next items of the tree item + * @memberof UmbTreeItemContextBase + * @returns {void} + */ + public loadNextItems = () => this.#loadNextItemsFromTarget(); + + async #loadChildren(target?: { unique: string; entityType: string }) { if (this.unique === undefined) throw new Error('Could not request children, unique key is missing'); if (this.entityType === undefined) throw new Error('Could not request children, entity type is missing'); @@ -184,8 +225,6 @@ export abstract class UmbTreeItemContextBase< this.#isLoading.setValue(true); - const skip = loadMore ? this.#paging.skip : 0; - const take = loadMore ? this.#paging.take : this.pagination.getCurrentPageNumber() * this.#paging.take; const foldersOnly = this.#foldersOnly.getValue(); const additionalArgs = this.treeContext?.getAdditionalRequestArgs(); @@ -194,24 +233,129 @@ export abstract class UmbTreeItemContextBase< unique: this.unique, entityType: this.entityType, }, + skip: this.pagination.getSkip(), + take: this.pagination.getPageSize(), + target: target + ? { + item: { + unique: target.unique, + entityType: target.entityType, + }, + before: 5, + after: this.pagination.getPageSize(), + } + : undefined, foldersOnly, - skip, - take, ...additionalArgs, }); if (data) { - if (loadMore) { - const currentItems = this.#childItems.getValue(); - this.#childItems.setValue([...currentItems, ...data.items]); - } else { - this.#childItems.setValue(data.items); - } + this.#childItems.setValue(data.items); const hasChildren = data.total > 0; this.#hasChildren.setValue(hasChildren); this.#hasChildrenContext.setHasChildren(hasChildren); + const firstItem = data.items.length > 0 ? data.items[0] : undefined; + this.#startTarget = firstItem ? { unique: firstItem.unique, entityType: firstItem.entityType } : undefined; + + const lastItem = data.items.length > 0 ? data.items[data.items.length - 1] : undefined; + this.#endTarget = lastItem ? { unique: lastItem.unique, entityType: lastItem.entityType } : undefined; + + const totalBefore = data.totalBefore !== undefined ? data.totalBefore : 0; + const totalAfter = + data.totalAfter !== undefined ? data.totalAfter : data.total - this.#childItems.getValue().length; + + this.paginationPrev.setTotalItems(totalBefore); + this.paginationNext.setTotalItems(totalAfter); + this.pagination.setTotalItems(data.total); + } + + this.#isLoading.setValue(false); + } + + async #loadPrevItemsFromTarget() { + if (this.unique === undefined) throw new Error('Could not request children, unique key is missing'); + if (this.entityType === undefined) throw new Error('Could not request children, entity type is missing'); + + const repository = this.treeContext?.getRepository(); + if (!repository) throw new Error('Could not request children, repository is missing'); + + this.#isLoading.setValue(true); + + const foldersOnly = this.#foldersOnly.getValue(); + const additionalArgs = this.treeContext?.getAdditionalRequestArgs(); + + const { data } = await repository.requestTreeItemsOf({ + parent: { + unique: this.unique, + entityType: this.entityType, + }, + foldersOnly, + target: { + item: this.#startTarget, + before: this.pagination.getPageSize(), + after: 0, + }, + ...additionalArgs, + }); + + if (data) { + const reversedItems = [...data.items].reverse(); + this.#childItems.prepend(reversedItems); + + const firstItem = data.items.length > 0 ? data.items[0] : undefined; + this.#startTarget = firstItem ? { unique: firstItem.unique, entityType: firstItem.entityType } : undefined; + + if (data.totalBefore === undefined) { + throw new Error('totalBefore is missing in the response'); + } + + this.paginationPrev.setTotalItems(data.totalBefore); + this.pagination.setTotalItems(data.total); + } + + this.#isLoading.setValue(false); + } + + async #loadNextItemsFromTarget() { + if (this.unique === undefined) throw new Error('Could not request next items, unique key is missing'); + if (this.entityType === undefined) throw new Error('Could not request next items, entity type is missing'); + + const repository = this.treeContext?.getRepository(); + if (!repository) throw new Error('Could not request next items, repository is missing'); + + this.#isLoading.setValue(true); + + const foldersOnly = this.#foldersOnly.getValue(); + const additionalArgs = this.treeContext?.getAdditionalRequestArgs(); + + const { data } = await repository.requestTreeItemsOf({ + parent: { + unique: this.unique, + entityType: this.entityType, + }, + take: this.pagination.getPageSize(), + skip: this.pagination.getSkip(), + foldersOnly, + target: { + item: this.#endTarget, + before: 0, + after: this.pagination.getPageSize(), + }, + ...additionalArgs, + }); + + if (data) { + this.#childItems.append(data.items); + + const lastItem = data.items.length > 0 ? data.items[data.items.length - 1] : undefined; + this.#endTarget = lastItem ? { unique: lastItem.unique, entityType: lastItem.entityType } : undefined; + + const totalAfter = + data.totalAfter !== undefined ? data.totalAfter : data.total - this.#childItems.getValue().length; + this.paginationNext.setTotalItems(totalAfter); + this.pagination.setTotalItems(data.total); } @@ -401,12 +545,19 @@ export abstract class UmbTreeItemContextBase< if (this.unique === undefined) return; if (!this.entityType) return; + const entity: UmbEntityModel = { + entityType: this.entityType, + unique: this.unique, + }; + this.observe( - this.treeContext?.expansion.isExpanded({ entityType: this.entityType, unique: this.unique }), - (isExpanded) => { + this.treeContext?.expansion.isExpanded(entity), + async (isExpanded) => { // If this item has children, load them if (isExpanded && this.#hasChildren.getValue() && this.#isOpen.getValue() === false) { - this.loadChildren(); + const expansionEntry = await this.treeContext?.expansion.getItem(entity); + const target = expansionEntry?.target; + this.#loadChildren(target); } this.#isOpen.setValue(isExpanded ?? false); @@ -433,11 +584,7 @@ export abstract class UmbTreeItemContextBase< } }; - #onPageChange = (event: UmbChangeEvent) => { - const target = event.target as UmbPaginationManager; - this.#paging.skip = target.getSkip(); - this.loadMore(); - }; + #onPageChange = () => this.#loadNextItemsFromTarget(); #debouncedCheckIsActive = debounce(() => this.#checkIsActive(), 100); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts index b325590d1ffa..5f5d6afe8c5f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts @@ -41,6 +41,10 @@ export abstract class UmbTreeItemElementBase< this.observe(this.#api.path, (value) => (this._href = value)); this.observe(this.#api.pagination.currentPage, (value) => (this._currentPage = value)); this.observe(this.#api.pagination.totalPages, (value) => (this._totalPages = value)); + + this.observe(this.#api.paginationPrev?.hasMoreItems, (value) => (this._hasPreviousItems = value || false)); + this.observe(this.#api.paginationNext?.hasMoreItems, (value) => (this._hasNextItems = value || false)); + this.#initTreeItem(); } } @@ -88,6 +92,12 @@ export abstract class UmbTreeItemElementBase< @state() private _currentPage = 1; + @state() + private _hasPreviousItems = false; + + @state() + private _hasNextItems = false; + #initTreeItem() { if (!this.#api) return; if (!this._item) return; @@ -114,11 +124,16 @@ export abstract class UmbTreeItemElementBase< this.#api?.hideChildren(); } - #onLoadMoreClick = (event: any) => { + #onLoadPrev(event: any) { + event.stopPropagation(); + this.#api?.loadPrevItems?.(); + } + + #onLoadNext(event: any) { event.stopPropagation(); const next = (this._currentPage = this._currentPage + 1); this.#api?.pagination.setCurrentPageNumber(next); - }; + } // Note: Currently we want to prevent opening when the item is in a selectable context, but this might change in the future. // If we like to be able to open items in selectable context, then we might want to make it as a menu item action, so you have to click ... and chose an action called 'Edit' @@ -137,11 +152,12 @@ export abstract class UmbTreeItemElementBase< .hasChildren=${this._hasChildren} .showChildren=${this._isOpen} .caretLabel=${this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + this._label} - label=${this._label} + label=${ifDefined(this._label)} href="${ifDefined(this._isSelectableContext ? undefined : this._href)}"> - ${this.renderIconContainer()} ${this.renderLabel()} ${this.#renderActions()} ${this.#renderChildItems()} + ${this.#renderLoadPrevButton()} ${this.renderIconContainer()} ${this.renderLabel()} ${this.#renderActions()} + ${this.#renderChildItems()} - ${this.#renderPaging()} + ${this.#renderLoadNextButton()} `; } @@ -215,11 +231,13 @@ export abstract class UmbTreeItemElementBase< `; } - #renderPaging() { - if (this._totalPages <= 1 || this._currentPage === this._totalPages) { - return nothing; - } + #renderLoadPrevButton() { + if (!this._hasPreviousItems) return nothing; + return html` `; + } - return html` `; + #renderLoadNextButton() { + if (!this._hasNextItems) return nothing; + return html` `; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts index 24432c3dfbff..d361157a4237 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts @@ -3,6 +3,7 @@ import type { UmbPaginationManager } from '../../utils/pagination-manager/pagina import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; import type { UmbContextMinimal } from '@umbraco-cms/backoffice/context-api'; +import type { UmbTargetPaginationManager } from '@umbraco-cms/backoffice/utils'; export interface UmbTreeItemContext extends UmbApi, UmbContextMinimal { unique?: string | null; @@ -19,6 +20,8 @@ export interface UmbTreeItemContext exten hasActions: Observable; path: Observable; pagination: UmbPaginationManager; + paginationPrev?: UmbTargetPaginationManager; + paginationNext?: UmbTargetPaginationManager; getTreeItem(): TreeItemType | undefined; setTreeItem(treeItem: TreeItemType | undefined): void; loadChildren(): void; @@ -29,4 +32,7 @@ export interface UmbTreeItemContext exten loadChildren(): void; showChildren(): void; hideChildren(): void; + // TODO: v18 make these non-optional + loadPrevItems?(): void; + loadNextItems?(): void; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/expansion/entity-expansion.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/expansion/entity-expansion.manager.ts index 2e16a5c269b6..ab275282dd5a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/expansion/entity-expansion.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/expansion/entity-expansion.manager.ts @@ -116,4 +116,15 @@ export class UmbEntityExpansionManager< this._expansion.setValue([]); this.getHostElement()?.dispatchEvent(new UmbExpansionChangeEvent()); } + + /** + * Gets an item from the expansion state + * @param {EntryModelType} entity The entity to get + * @returns {*} {(Promise)} + * @memberof UmbEntityExpansionManager + */ + public async getItem(entity: EntryModelType): Promise { + const expansion = this._expansion.getValue(); + return expansion.find((x) => x.entityType === entity.entityType && x.unique === entity.unique); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts index 99bd098a0225..1fbed7af00e3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts @@ -9,10 +9,12 @@ export * from './expansion/index.js'; export * from './get-guid-from-udi.function.js'; export * from './get-processed-image-url.function.js'; export * from './guard-manager/index.js'; +export * from './is-test-environment.function.js'; export * from './math/math.js'; export * from './media/image-size.function.js'; export * from './object/deep-merge.function.js'; export * from './pagination-manager/pagination.manager.js'; +export * from './pagination/index.js'; export * from './path/ensure-local-path.function.js'; export * from './path/ensure-path-ends-with-slash.function.js'; export * from './path/has-own-opener.function.js'; @@ -30,5 +32,4 @@ export * from './sanitize/sanitize-html.function.js'; export * from './selection-manager/selection.manager.js'; export * from './state-manager/index.js'; export * from './string/index.js'; -export * from './is-test-environment.function.js'; export type * from './type/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/index.ts new file mode 100644 index 000000000000..01bb017ac1d4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/index.ts @@ -0,0 +1,2 @@ +export * from './target-pagination-manager/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/index.ts new file mode 100644 index 000000000000..dc9d878d9f12 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/index.ts @@ -0,0 +1,2 @@ +export * from './target-pagination.manager.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/target-pagination.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/target-pagination.manager.ts new file mode 100644 index 000000000000..832e2122db9b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/target-pagination.manager.ts @@ -0,0 +1,63 @@ +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbBooleanState, UmbNumberState } from '@umbraco-cms/backoffice/observable-api'; + +export class UmbTargetPaginationManager extends UmbControllerBase { + #defaultValues = { + take: 10, + totalItems: 0, + }; + + #pageSize = new UmbNumberState(this.#defaultValues.take); + public readonly pageSize = this.#pageSize.asObservable(); + + #totalItems = new UmbNumberState(this.#defaultValues.totalItems); + public readonly totalItems = this.#totalItems.asObservable(); + + #hasMoreItems = new UmbBooleanState(false); + public readonly hasMoreItems = this.#hasMoreItems.asObservable(); + + /** + * Sets the number of items per page and recalculates the total number of pages + * @param {number} pageSize + * @memberof UmbPaginationManager + */ + public setPageSize(pageSize: number) { + this.#pageSize.setValue(pageSize); + } + + /** + * Gets the number of items per page + * @returns {number} + * @memberof UmbPaginationManager + */ + public getPageSize() { + return this.#pageSize.getValue(); + } + + /** + * Gets the total number of items + * @returns {number} + * @memberof UmbPaginationManager + */ + public getTotalItems() { + return this.#totalItems.getValue(); + } + + /** + * Sets the total number of items and recalculates the total number of pages + * @param {number} totalItems + * @memberof UmbPaginationManager + */ + public setTotalItems(totalItems: number) { + this.#totalItems.setValue(totalItems); + this.#hasMoreItems.setValue(totalItems > 0); + } + + /** + * Clears the pagination manager values and resets them to their default values + * @memberof UmbPaginationManager + */ + public clear() { + this.#totalItems.setValue(this.#defaultValues.totalItems); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/types.ts new file mode 100644 index 000000000000..7bea9371c7b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/target-pagination-manager/types.ts @@ -0,0 +1,8 @@ +export interface UmbTargetPaginationModel { + item: { + unique: string; + entityType: string; + }; + before: number; + after: number; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/types.ts new file mode 100644 index 000000000000..6d830d07325d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/types.ts @@ -0,0 +1 @@ +export type * from './target-pagination-manager/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.server.data-source.ts index 4bb8b14bcc73..11c44a448f4d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.server.data-source.ts @@ -36,20 +36,79 @@ export class UmbDocumentTreeServerDataSource extends UmbTreeServerDataSourceBase } } -const getRootItems = (args: UmbDocumentTreeRootItemsRequestArgs) => - // eslint-disable-next-line local-rules/no-direct-api-import - DocumentService.getTreeDocumentRoot({ - query: { dataTypeId: args.dataType?.unique, skip: args.skip, take: args.take }, - }); +const getRootItems = async (args: UmbDocumentTreeRootItemsRequestArgs) => { + if (args.target) { + // eslint-disable-next-line local-rules/no-direct-api-import + const { data } = await DocumentService.getTreeDocumentSiblings({ + query: { + target: args.target.item.unique, + before: args.target.before, + after: args.target.after, + }, + }); -const getChildrenOf = (args: UmbDocumentTreeChildrenOfRequestArgs) => { - if (args.parent.unique === null) { - return getRootItems(args); + return { + data: { + items: data.items, + total: data.totalBefore + data.items.length + data.totalAfter, + totalBefore: data.totalBefore, + totalAfter: data.totalAfter, + }, + }; } else { // eslint-disable-next-line local-rules/no-direct-api-import - return DocumentService.getTreeDocumentChildren({ - query: { parentId: args.parent.unique, dataTypeId: args.dataType?.unique, skip: args.skip, take: args.take }, + const { data } = await DocumentService.getTreeDocumentRoot({ + query: { dataTypeId: args.dataType?.unique, skip: args.skip, take: args.take }, }); + + return { + data: { + items: data.items, + total: data.total, + totalBefore: 0, + totalAfter: data.total - data.items.length, + }, + }; + } +}; + +const getChildrenOf = async (args: UmbDocumentTreeChildrenOfRequestArgs) => { + if (args.parent.unique === null) { + return getRootItems(args); + } else { + if (args.target) { + // eslint-disable-next-line local-rules/no-direct-api-import + const { data } = await DocumentService.getTreeDocumentSiblings({ + query: { + target: args.target.item.unique, + before: args.target.before, + after: args.target.after, + }, + }); + + return { + data: { + items: data.items, + total: data.totalBefore + data.items.length + data.totalAfter, + totalBefore: data.totalBefore, + totalAfter: data.totalAfter, + }, + }; + } else { + // eslint-disable-next-line local-rules/no-direct-api-import + const { data } = await DocumentService.getTreeDocumentChildren({ + query: { parentId: args.parent.unique, dataTypeId: args.dataType?.unique, skip: args.skip, take: args.take }, + }); + + return { + data: { + items: data.items, + total: data.total, + totalBefore: 0, + totalAfter: data.total - data.items.length, + }, + }; + } } };