Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
} from '@umbraco-cms/backoffice/entity-action';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';

type ResetReason = 'error' | 'empty' | 'fallback';

export class UmbTreeItemChildrenManager<
TreeItemType extends UmbTreeItemModel = UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel = UmbTreeRootModel,
Expand Down Expand Up @@ -218,91 +220,91 @@
async #loadChildren(reload = false) {
if (this.#loadChildrenRetries > this.#requestMaxRetries) {
this.#loadChildrenRetries = 0;
this.#resetChildren();
this.#resetChildren('error');
return;
}

const repository = this.#treeContext?.getRepository();
if (!repository) throw new Error('Could not request children, repository is missing');

this.#isLoading.setValue(true);

const parent = this.getStartNode() || this.getTreeItem();
const foldersOnly = this.getFoldersOnly();
const additionalArgs = this.getAdditionalRequestArgs();
const baseTarget = this.targetPagination.getBaseTarget();

// When reloading we only want to send the target values with the request if we can find the target to reload from.
const canSendTarget = reload === false || (reload && this.targetPagination.hasBaseTargetInCurrentItems());

const targetPaging: UmbTargetPaginationRequestModel | undefined =
baseTarget && baseTarget.unique && canSendTarget
? {
target: {
unique: baseTarget.unique,
entityType: baseTarget.entityType,
},
/* When we load from a target we want to load a few items before the target so the target isn't the first item in the list
Currently we use 5, but this could be anything that feels "right".
When reloading from target when want to retrieve the same number of items that a currently loaded
*/
takeBefore: reload
? this.targetPagination.getNumberOfCurrentItemsBeforeBaseTarget()
: this.#takeBeforeTarget !== undefined
? this.#takeBeforeTarget
: this.targetPagination.getTakeSize(),
takeAfter: reload
? this.targetPagination.getNumberOfCurrentItemsAfterBaseTarget()
: this.#takeAfterTarget !== undefined
? this.#takeAfterTarget
: this.targetPagination.getTakeSize(),
}
: undefined;

const offsetPaging: UmbOffsetPaginationRequestModel = {
// when reloading we want to get everything from the start
skip: reload ? 0 : this.offsetPagination.getSkip(),
take: reload
? this.offsetPagination.getCurrentPageNumber() * this.offsetPagination.getPageSize()
: this.offsetPagination.getPageSize(),
};

const { data, error } = parent?.unique
? await repository.requestTreeItemsOf({
parent: {
unique: parent.unique,
entityType: parent.entityType,
},
skip: offsetPaging.skip, // including this for backward compatibility
take: offsetPaging.take, // including this for backward compatibility
paging: targetPaging || offsetPaging,
foldersOnly,
...additionalArgs,
})
: await repository.requestTreeRootItems({
skip: offsetPaging.skip, // including this for backward compatibility
take: offsetPaging.take, // including this for backward compatibility
paging: targetPaging || offsetPaging,
foldersOnly,
...additionalArgs,
});

// We have used a baseTarget that no longer exists on the sever. We need to retry with a new target
if (error && error.message.includes('not found')) {
this.#loadChildrenRetries++;
const newBaseTarget = this.targetPagination.getNewBaseTarget();
this.targetPagination.removeFromCurrentItems(baseTarget!);

if (newBaseTarget) {
this.targetPagination.setBaseTarget(newBaseTarget);
this.#loadChildren();
} else {
/*
If we can't find a new base target we reload the children from the top.
We cancel the base target and load using skip/take pagination instead.
This can happen if deep linked to a non existing item or all retries have failed.
*/
this.#resetChildren();
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');

Check warning on line 307 in src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (release/17.0)

❌ Getting worse: Complex Method

UmbTreeItemChildrenManager.loadChildren increases in cyclomatic complexity from 25 to 26, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
}
}

Expand All @@ -329,56 +331,56 @@
if (this.#loadPrevItemsRetries > this.#requestMaxRetries) {
// If we have exceeded the maximum number of retries, we need to reset the base target and load from the top
this.#loadPrevItemsRetries = 0;
this.#resetChildren();
this.#resetChildren('error');
return;
}

const repository = this.#treeContext?.getRepository();
if (!repository) throw new Error('Could not request children, repository is missing');

this.#isLoading.setValue(true);
this.#isLoadingPrevChildren.setValue(true);

const parent = this.getStartNode() || this.getTreeItem();
const foldersOnly = this.getFoldersOnly();
const additionalArgs = this.getAdditionalRequestArgs();
const startTarget = this.targetPagination.getStartTarget();

const targetPaging: UmbTargetPaginationRequestModel | undefined = {
target: startTarget,
takeBefore: this.targetPagination.getTakeSize(),
takeAfter: 0,
};

const { data, error } = parent?.unique
? await repository.requestTreeItemsOf({
parent: {
unique: parent.unique,
entityType: parent.entityType,
},
paging: targetPaging,
foldersOnly,
...additionalArgs,
})
: await repository.requestTreeRootItems({
paging: targetPaging,
foldersOnly,
...additionalArgs,
});

if (error && error.message.includes('not found')) {
this.#loadPrevItemsRetries++;
const newStartTarget = this.targetPagination.getNewStartTarget();
this.targetPagination.removeFromCurrentItems(startTarget);

if (newStartTarget) {
this.#loadPrevItemsFromTarget();
} else {
/*
If we can't find a new end target we reload the children from the top.
We cancel the base target and load using skip/take pagination instead.
*/
this.#resetChildren();
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');

Check warning on line 383 in src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (release/17.0)

❌ Getting worse: Complex Method

UmbTreeItemChildrenManager.loadPrevItemsFromTarget increases in cyclomatic complexity from 14 to 15, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
}
}

Expand Down Expand Up @@ -409,65 +411,65 @@
if (this.#loadNextItemsRetries > this.#requestMaxRetries) {
// If we have exceeded the maximum number of retries, we need to reset the base target and load from the top
this.#loadNextItemsRetries = 0;
this.#resetChildren();
this.#resetChildren('error');
return;
}

const repository = this.#treeContext?.getRepository();
if (!repository) throw new Error('Could not request children, repository is missing');

this.#isLoading.setValue(true);
this.#isLoadingNextChildren.setValue(true);

const parent = this.getStartNode() || this.getTreeItem();
const foldersOnly = this.getFoldersOnly();
const additionalArgs = this.getAdditionalRequestArgs();
const endTarget = this.targetPagination.getEndTarget();

const targetPaging: UmbTargetPaginationRequestModel | undefined = {
target: endTarget,
takeBefore: 0,
takeAfter: this.targetPagination.getTakeSize(),
};

const offsetPaging: UmbOffsetPaginationRequestModel = {
skip: this.offsetPagination.getSkip(),
take: this.offsetPagination.getPageSize(),
};

const { data, error } = parent?.unique
? await repository.requestTreeItemsOf({
parent: {
unique: parent.unique,
entityType: parent.entityType,
},
skip: offsetPaging.skip, // including this for backward compatibility
take: offsetPaging.take, // including this for backward compatibility
paging: targetPaging,
foldersOnly,
...additionalArgs,
})
: await repository.requestTreeRootItems({
skip: offsetPaging.skip, // including this for backward compatibility
take: offsetPaging.take, // including this for backward compatibility
paging: targetPaging,
foldersOnly,
...additionalArgs,
});

if (error && error.message.includes('not found')) {
this.#loadNextItemsRetries++;
const newEndTarget = this.targetPagination.getNewEndTarget();
this.targetPagination.removeFromCurrentItems(endTarget);

if (newEndTarget) {
this.#loadNextItemsFromTarget();
} else {
/*
If we can't find a new end target we reload the children from the top.
We cancel the base target and load using skip/take pagination instead.
*/
this.#resetChildren();
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');

Check warning on line 472 in src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (release/17.0)

❌ Getting worse: Complex Method

UmbTreeItemChildrenManager.loadNextItemsFromTarget increases in cyclomatic complexity from 12 to 13, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
}
}

Expand Down Expand Up @@ -520,12 +522,81 @@
this.targetPagination.clear();
}

async #resetChildren() {
/**
* Loads children using offset pagination only.
* This is a "safe" fallback that does NOT:
* - Use target pagination
* - Retry with new targets
* - Call #resetChildren (preventing recursion)
* - Throw errors (fails gracefully)
*/
async #loadChildrenWithOffsetPagination(): Promise<void> {
const repository = this.#treeContext?.getRepository();
if (!repository) {
// Terminal fallback - fail silently rather than throwing
return;
}

this.#isLoading.setValue(true);

const parent = this.getStartNode() || this.getTreeItem();
const foldersOnly = this.getFoldersOnly();
const additionalArgs = this.getAdditionalRequestArgs();

const offsetPaging: UmbOffsetPaginationRequestModel = {
skip: 0, // Always from the start
take: this.offsetPagination.getPageSize(),
};

const { data } = parent?.unique
? await repository.requestTreeItemsOf({
parent: { unique: parent.unique, entityType: parent.entityType },
skip: offsetPaging.skip,
take: offsetPaging.take,
paging: offsetPaging,
foldersOnly,
...additionalArgs,
})
: await repository.requestTreeRootItems({
skip: offsetPaging.skip,
take: offsetPaging.take,
paging: offsetPaging,
foldersOnly,
...additionalArgs,
});

if (data) {
const items = data.items as Array<TreeItemType>;
this.#children.setValue(items);
this.setHasChildren(data.total > 0);
this.offsetPagination.setTotalItems(data.total);
}
// Note: On error, we simply don't update state - UI shows stale data
// This is the terminal fallback, no further recovery

this.#isLoading.setValue(false);
}

async #resetChildren(reason: ResetReason = 'error'): Promise<void> {
// Clear pagination state
this.targetPagination.clear();
this.offsetPagination.clear();
this.loadChildren();
const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notificationManager?.peek('danger', { data: { message: 'Menu loading failed. Showing the first items again.' } });

// Reset retry counters to prevent any lingering retry state
this.#loadChildrenRetries = 0;
this.#loadPrevItemsRetries = 0;
this.#loadNextItemsRetries = 0;

// Load using offset pagination only - this is our terminal fallback
await this.#loadChildrenWithOffsetPagination();

// Only show notification for actual errors
if (reason === 'error') {
const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notificationManager?.peek('danger', {
data: { message: 'Menu loading failed. Showing the first items again.' },
});
}
}

#onPageChange = () => this.loadNextChildren();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ export class UmbManagementApiTreeDataRequestManager<
return args.take !== undefined ? args.take : this.#defaultTakeSize;
}

#getTargetResultHasValidParents(data: Array<TreeItemType>, parentUnique: string | null): boolean {
#getTargetResultHasValidParents(data: Array<TreeItemType> | undefined, parentUnique: string | null): boolean {
if (!data) {
return false;
}
return data.every((item) => {
if (item.parent) {
return item.parent.id === parentUnique;
Expand Down
Loading