Restore expand-all control in invoice period object tree (#248)

Add a persistent recursive expand/collapse control above collected invoices and keep order-item quantity and price in the canonical editable field layout.
This commit is contained in:
Jeppe B
2026-08-03 09:03:57 +02:00
committed by GitHub
parent 7b769eeb24
commit f2453ba0a3
18 changed files with 1492 additions and 652 deletions
+237 -86
View File
@@ -1,44 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide, reactive, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, provide, reactive, readonly, ref, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue"; import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>; type TreeNode = Record<string, any>;
type LoadNodeResult = {
children: TreeNode[];
failed: boolean;
cancelled: boolean;
};
type ExpandAllResult = {
expandedKeys: any[];
failedKeys: any[];
cancelled: boolean;
};
const props = withDefaults(defineProps<{ const EXPAND_ALL_CONCURRENCY = 4;
data: TreeNode[];
fields?: Partial<{ const props = withDefaults(
id: string; defineProps<{
label: string; data: TreeNode[];
children: string; fields?: Partial<{
isLeaf: string; id: string;
disabled: string; label: string;
}>; children: string;
selectionMode?: "none" | "single" | "multiple" | "checkbox"; isLeaf: string;
selected?: any; disabled: string;
expandedKeys?: any[]; }>;
checkedKeys?: any[]; selectionMode?: "none" | "single" | "multiple" | "checkbox";
defaultExpandAll?: boolean; selected?: any;
expandOnClickNode?: boolean; expandedKeys?: any[];
lazy?: boolean; checkedKeys?: any[];
load?: (_node: TreeNode) => Promise<TreeNode[]>; defaultExpandAll?: boolean;
progressiveBatchSize?: number; expandOnClickNode?: boolean;
loadMoreLabel?: string; lazy?: boolean;
ariaLabel?: string; load?: (_node: TreeNode) => Promise<TreeNode[]>;
}>(), { progressiveBatchSize?: number;
data: () => [], loadMoreLabel?: string;
fields: () => ({}), ariaLabel?: string;
selectionMode: "none", }>(),
selected: null, {
expandedKeys: () => [], data: () => [],
checkedKeys: () => [], fields: () => ({}),
defaultExpandAll: false, selectionMode: "none",
expandOnClickNode: true, selected: null,
lazy: false, expandedKeys: () => [],
load: undefined, checkedKeys: () => [],
progressiveBatchSize: 0, defaultExpandAll: false,
loadMoreLabel: "Show {count} more", expandOnClickNode: true,
ariaLabel: undefined, lazy: false,
}); load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
"update:selected": [value: any]; "update:selected": [value: any];
@@ -70,6 +85,11 @@ const state = reactive({
loadErrorKeys: [] as any[], loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>, renderedLimits: {} as Record<string, number>,
}); });
const bulkExpanding = ref(false);
const inFlightLoads = new Map<string, Promise<LoadNodeResult>>();
let activeBulkExpansion: Promise<ExpandAllResult> | null = null;
let bulkOperationVersion = 0;
let dataVersion = 0;
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id]; const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? ""); const keyString = (key: any) => String(key ?? "");
@@ -78,6 +98,8 @@ const childrenOf = (node: TreeNode) => {
return Array.isArray(children) ? children : []; return Array.isArray(children) ? children : [];
}; };
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || []; const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const hasCachedChildren = (node: TreeNode) =>
Object.prototype.hasOwnProperty.call(state.lazyChildrenCache, keyString(keyOf(node)));
const effectiveChildrenOf = (node: TreeNode) => { const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node); const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node); return children.length > 0 ? children : cachedChildrenOf(node);
@@ -92,7 +114,8 @@ const visibleChildrenOf = (node: TreeNode) => {
const limit = state.renderedLimits[key] || progressiveBatchSize.value; const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit); return children.slice(0, limit);
}; };
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length); const remainingChildrenCount = (node: TreeNode) =>
Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0; const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => { const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) { if (!hasMoreChildren(node)) {
@@ -102,10 +125,8 @@ const showMoreChildren = (node: TreeNode) => {
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value; const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value); state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
}; };
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace( const loadMoreLabelFor = (node: TreeNode) =>
"{count}", props.loadMoreLabel.replace("{count}", String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node))));
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]); const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false; const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true; const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
@@ -124,18 +145,6 @@ const collectKeys = (node: TreeNode): any[] => {
return keys; return keys;
}; };
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch( watch(
() => props.selected, () => props.selected,
(value) => { (value) => {
@@ -159,18 +168,6 @@ watch(
{ deep: true } { deep: true }
); );
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => { const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys]; state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]); emit("update:expandedKeys", [...state.expandedKeys]);
@@ -181,33 +178,146 @@ const setCheckedKeys = (keys: any[]) => {
emit("update:checkedKeys", [...state.checkedKeys]); emit("update:checkedKeys", [...state.checkedKeys]);
}; };
const loadNode = async (node: TreeNode) => { const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
if (!props.lazy || !props.load) { if (!props.lazy || !props.load) {
return; return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
} }
const key = keyOf(node); const key = keyOf(node);
if (key === undefined || key === null) { if (key === undefined || key === null) {
return; return { children: [], failed: false, cancelled: false };
} }
if (state.loadingKeys.includes(key)) { const cacheKey = keyString(key);
return; if (childrenOf(node).length > 0 || hasCachedChildren(node)) {
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
}
const inFlight = inFlightLoads.get(cacheKey);
if (inFlight) {
return inFlight;
} }
state.loadingKeys = [...state.loadingKeys, key]; const requestedDataVersion = dataVersion;
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key); let loadPromise: Promise<LoadNodeResult>;
emit("load-start", node, key); loadPromise = (async () => {
state.loadingKeys = [...new Set([...state.loadingKeys, key])];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try { try {
state.lazyChildrenCache[keyString(key)] = await props.load(node); const loadedChildren = await props.load(node);
if (progressiveBatchSize.value > 0) { if (requestedDataVersion !== dataVersion) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value; return { children: [], failed: false, cancelled: true };
}
const children = Array.isArray(loadedChildren) ? loadedChildren : [];
state.lazyChildrenCache[cacheKey] = children;
if (progressiveBatchSize.value > 0) {
state.renderedLimits[cacheKey] = progressiveBatchSize.value;
}
return { children, failed: false, cancelled: false };
} catch (error) {
if (requestedDataVersion !== dataVersion) {
return { children: [], failed: false, cancelled: true };
}
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
return { children: [], failed: true, cancelled: false };
} finally {
if (requestedDataVersion === dataVersion) {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
if (inFlightLoads.get(cacheKey) === loadPromise) {
inFlightLoads.delete(cacheKey);
}
} }
} catch (error) { })();
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])]; inFlightLoads.set(cacheKey, loadPromise);
emit("load-error", error, node, key); return loadPromise;
} finally { };
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
const mapWithConcurrency = async <T>(items: T[], concurrency: number, callback: (item: T) => Promise<void>) => {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await callback(item);
}
});
await Promise.all(workers);
};
const expandAll = (): Promise<ExpandAllResult> => {
if (activeBulkExpansion) {
return activeBulkExpansion;
} }
const operationVersion = ++bulkOperationVersion;
bulkExpanding.value = true;
const expansion = (async (): Promise<ExpandAllResult> => {
const expandedKeys: any[] = [];
const failedKeys: any[] = [];
const visited = new Set<string>();
let currentLevel = [...props.data];
while (currentLevel.length > 0 && operationVersion === bulkOperationVersion) {
const nextLevel: TreeNode[] = [];
await mapWithConcurrency(currentLevel, EXPAND_ALL_CONCURRENCY, async (node) => {
if (operationVersion !== bulkOperationVersion || isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
const normalizedKey = keyString(key);
if (visited.has(normalizedKey)) {
return;
}
visited.add(normalizedKey);
expandedKeys.push(key);
const result = await loadNode(node);
if (operationVersion !== bulkOperationVersion || result.cancelled) {
return;
}
if (result.failed) {
failedKeys.push(key);
return;
}
state.renderedLimits[normalizedKey] = result.children.length;
nextLevel.push(...result.children);
});
currentLevel = nextLevel;
}
const cancelled = operationVersion !== bulkOperationVersion;
if (!cancelled) {
setExpandedKeys(expandedKeys);
}
return {
expandedKeys: cancelled ? [...state.expandedKeys] : expandedKeys,
failedKeys,
cancelled,
};
})();
activeBulkExpansion = expansion;
void expansion.finally(() => {
if (activeBulkExpansion === expansion) {
activeBulkExpansion = null;
}
if (operationVersion === bulkOperationVersion) {
bulkExpanding.value = false;
}
});
return expansion;
};
const collapseAll = () => {
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
state.renderedLimits = {};
setExpandedKeys([]);
}; };
const ensureChildrenLoadedForCheck = async (node: TreeNode) => { const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
@@ -312,9 +422,7 @@ const handleNodeClick = async (node: TreeNode) => {
emit("select", node, key); emit("select", node, key);
} else if (props.selectionMode === "multiple") { } else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : []; const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key) state.selected = selected.includes(key) ? selected.filter((current) => current !== key) : [...selected, key];
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected); emit("update:selected", state.selected);
emit("select", node, key); emit("select", node, key);
} }
@@ -323,6 +431,49 @@ const handleNodeClick = async (node: TreeNode) => {
} }
}; };
watch(
() => props.data,
() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
state.lazyChildrenCache = {};
state.loadingKeys = [];
state.loadErrorKeys = [];
state.renderedLimits = {};
setExpandedKeys([]);
if (props.defaultExpandAll) {
void nextTick(() => expandAll());
}
},
{ immediate: true }
);
watch(
() => props.defaultExpandAll,
(shouldExpand, wasExpanded) => {
if (shouldExpand && !wasExpanded) {
void expandAll();
}
}
);
defineExpose({
bulkExpanding: readonly(bulkExpanding),
collapseAll,
expandAll,
});
onBeforeUnmount(() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
});
provide("BuefyTreeContext", { provide("BuefyTreeContext", {
props, props,
state, state,
@@ -347,7 +498,7 @@ provide("BuefyTreeContext", {
</script> </script>
<template> <template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel"> <ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
<BuefyTreeNode <BuefyTreeNode
v-for="(node, index) in data" v-for="(node, index) in data"
:key="keyOf(node) ?? index" :key="keyOf(node) ?? index"
+2
View File
@@ -4428,10 +4428,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Accepter forslag", "accept_xlvask": "Accepter forslag",
"actions": "Handlinger", "actions": "Handlinger",
"collapse_all": "Fold alle sammen",
"delete": "Slet", "delete": "Slet",
"delete_lines": "Slet linjer", "delete_lines": "Slet linjer",
"deny_xlvask": "Afvis forslag", "deny_xlvask": "Afvis forslag",
"download": "Download", "download": "Download",
"expand_all": "Udfold alle",
"exclude_invoice": "Ekskluder", "exclude_invoice": "Ekskluder",
"ignore": "Ignorer", "ignore": "Ignorer",
"include_invoice": "Inkluder", "include_invoice": "Inkluder",
+2
View File
@@ -4538,10 +4538,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Vorschlag akzeptieren", "accept_xlvask": "Vorschlag akzeptieren",
"actions": "Aktionen", "actions": "Aktionen",
"collapse_all": "Alle einklappen",
"delete": "Löschen", "delete": "Löschen",
"delete_lines": "Zeilen löschen", "delete_lines": "Zeilen löschen",
"deny_xlvask": "Vorschlag ablehnen", "deny_xlvask": "Vorschlag ablehnen",
"download": "Download", "download": "Download",
"expand_all": "Alle aufklappen",
"exclude_invoice": "Ausschließen", "exclude_invoice": "Ausschließen",
"ignore": "Ignorieren", "ignore": "Ignorieren",
"include_invoice": "Einschließen", "include_invoice": "Einschließen",
+2
View File
@@ -4259,10 +4259,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Accept suggestion", "accept_xlvask": "Accept suggestion",
"actions": "Actions", "actions": "Actions",
"collapse_all": "Collapse all",
"delete": "Delete", "delete": "Delete",
"delete_lines": "Delete lines", "delete_lines": "Delete lines",
"deny_xlvask": "Deny suggestion", "deny_xlvask": "Deny suggestion",
"download": "Download", "download": "Download",
"expand_all": "Expand all",
"exclude_invoice": "Exclude", "exclude_invoice": "Exclude",
"ignore": "Ignore", "ignore": "Ignore",
"include_invoice": "Include", "include_invoice": "Include",
+2
View File
@@ -3648,10 +3648,12 @@
"buttons": { "buttons": {
"accept_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.accept_xlvask'}", "accept_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
"actions": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.actions'}", "actions": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.actions'}",
"collapse_all": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.collapse_all'}",
"delete": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete'}", "delete": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete'}",
"delete_lines": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete_lines'}", "delete_lines": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete_lines'}",
"deny_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.deny_xlvask'}", "deny_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
"download": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.download'}", "download": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.download'}",
"expand_all": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.expand_all'}",
"exclude_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.exclude_invoice'}", "exclude_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.exclude_invoice'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.ignore'}", "ignore": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.ignore'}",
"include_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.include_invoice'}", "include_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.include_invoice'}",
+2
View File
@@ -4541,10 +4541,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Godta forslag", "accept_xlvask": "Godta forslag",
"actions": "Handlinger", "actions": "Handlinger",
"collapse_all": "Slå sammen alle",
"delete": "Slett", "delete": "Slett",
"delete_lines": "Slett linjer", "delete_lines": "Slett linjer",
"deny_xlvask": "Avvis forslag", "deny_xlvask": "Avvis forslag",
"download": "Last ned", "download": "Last ned",
"expand_all": "Utvid alle",
"exclude_invoice": "Ekskluder", "exclude_invoice": "Ekskluder",
"ignore": "Ignorer", "ignore": "Ignorer",
"include_invoice": "Inkluder", "include_invoice": "Inkluder",
+2
View File
@@ -4591,10 +4591,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Acceptera förslag", "accept_xlvask": "Acceptera förslag",
"actions": "Åtgärder", "actions": "Åtgärder",
"collapse_all": "Fäll ihop alla",
"delete": "Ta bort", "delete": "Ta bort",
"delete_lines": "Ta bort rader", "delete_lines": "Ta bort rader",
"deny_xlvask": "Avvisa förslag", "deny_xlvask": "Avvisa förslag",
"download": "Ladda ner", "download": "Ladda ner",
"expand_all": "Fäll ut alla",
"exclude_invoice": "Exkludera", "exclude_invoice": "Exkludera",
"ignore": "Ignorera", "ignore": "Ignorera",
"include_invoice": "Inkludera", "include_invoice": "Inkludera",
@@ -86,10 +86,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Accepter forslag", "accept_xlvask": "Accepter forslag",
"actions": "Handlinger", "actions": "Handlinger",
"collapse_all": "Fold alle sammen",
"delete": "Slet", "delete": "Slet",
"delete_lines": "Slet linjer", "delete_lines": "Slet linjer",
"deny_xlvask": "Afvis forslag", "deny_xlvask": "Afvis forslag",
"download": "Download", "download": "Download",
"expand_all": "Udfold alle",
"exclude_invoice": "Ekskluder", "exclude_invoice": "Ekskluder",
"ignore": "Ignorer", "ignore": "Ignorer",
"include_invoice": "Inkluder", "include_invoice": "Inkluder",
@@ -86,10 +86,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Vorschlag akzeptieren", "accept_xlvask": "Vorschlag akzeptieren",
"actions": "Aktionen", "actions": "Aktionen",
"collapse_all": "Alle einklappen",
"delete": "Löschen", "delete": "Löschen",
"delete_lines": "Zeilen löschen", "delete_lines": "Zeilen löschen",
"deny_xlvask": "Vorschlag ablehnen", "deny_xlvask": "Vorschlag ablehnen",
"download": "Download", "download": "Download",
"expand_all": "Alle aufklappen",
"exclude_invoice": "Ausschließen", "exclude_invoice": "Ausschließen",
"ignore": "Ignorieren", "ignore": "Ignorieren",
"include_invoice": "Einschließen", "include_invoice": "Einschließen",
@@ -86,10 +86,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Accept suggestion", "accept_xlvask": "Accept suggestion",
"actions": "Actions", "actions": "Actions",
"collapse_all": "Collapse all",
"delete": "Delete", "delete": "Delete",
"delete_lines": "Delete lines", "delete_lines": "Delete lines",
"deny_xlvask": "Deny suggestion", "deny_xlvask": "Deny suggestion",
"download": "Download", "download": "Download",
"expand_all": "Expand all",
"exclude_invoice": "Exclude", "exclude_invoice": "Exclude",
"ignore": "Ignore", "ignore": "Ignore",
"include_invoice": "Include", "include_invoice": "Include",
@@ -85,10 +85,12 @@
"buttons": { "buttons": {
"accept_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.accept_xlvask'}", "accept_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
"actions": "@:{'phrases.compat.invoicing_period.object_tree.buttons.actions'}", "actions": "@:{'phrases.compat.invoicing_period.object_tree.buttons.actions'}",
"collapse_all": "@:{'phrases.compat.invoicing_period.object_tree.buttons.collapse_all'}",
"delete": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete'}", "delete": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete'}",
"delete_lines": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete_lines'}", "delete_lines": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete_lines'}",
"deny_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.deny_xlvask'}", "deny_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
"download": "@:{'phrases.compat.invoicing_period.object_tree.buttons.download'}", "download": "@:{'phrases.compat.invoicing_period.object_tree.buttons.download'}",
"expand_all": "@:{'phrases.compat.invoicing_period.object_tree.buttons.expand_all'}",
"exclude_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.exclude_invoice'}", "exclude_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.exclude_invoice'}",
"ignore": "@:{'phrases.compat.invoicing_period.object_tree.buttons.ignore'}", "ignore": "@:{'phrases.compat.invoicing_period.object_tree.buttons.ignore'}",
"include_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.include_invoice'}", "include_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.include_invoice'}",
@@ -86,10 +86,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Godta forslag", "accept_xlvask": "Godta forslag",
"actions": "Handlinger", "actions": "Handlinger",
"collapse_all": "Slå sammen alle",
"delete": "Slett", "delete": "Slett",
"delete_lines": "Slett linjer", "delete_lines": "Slett linjer",
"deny_xlvask": "Avvis forslag", "deny_xlvask": "Avvis forslag",
"download": "Last ned", "download": "Last ned",
"expand_all": "Utvid alle",
"exclude_invoice": "Ekskluder", "exclude_invoice": "Ekskluder",
"ignore": "Ignorer", "ignore": "Ignorer",
"include_invoice": "Inkluder", "include_invoice": "Inkluder",
@@ -86,10 +86,12 @@
"buttons": { "buttons": {
"accept_xlvask": "Acceptera förslag", "accept_xlvask": "Acceptera förslag",
"actions": "Åtgärder", "actions": "Åtgärder",
"collapse_all": "Fäll ihop alla",
"delete": "Ta bort", "delete": "Ta bort",
"delete_lines": "Ta bort rader", "delete_lines": "Ta bort rader",
"deny_xlvask": "Avvisa förslag", "deny_xlvask": "Avvisa förslag",
"download": "Ladda ner", "download": "Ladda ner",
"expand_all": "Fäll ut alla",
"exclude_invoice": "Exkludera", "exclude_invoice": "Exkludera",
"ignore": "Ignorera", "ignore": "Ignorera",
"include_invoice": "Inkludera", "include_invoice": "Inkludera",
+77
View File
@@ -1382,6 +1382,77 @@ test.describe("Invoicing period tab", () => {
.toBe(true); .toBe(true);
}); });
test("@smoke @pr period review expands and collapses the selected customer's complete object tree", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
const orderItemRequests = [];
page.on("request", (request) => {
if (request.method() === "GET" && new URL(request.url()).pathname.endsWith("/order/items")) {
orderItemRequests.push(request.url());
}
});
await openPeriodView(page, {
payloadFactory: createObjectTreePeriodPayload,
beforeGoto: routeObjectTreeOrderEndpoints,
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4101")).toBeVisible();
const toggleAll = page.getByTestId("invoice-period-tree-toggle-all");
const firstInvoiceCollection = page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001");
await expect(toggleAll).toBeVisible();
await expect(toggleAll).toBeEnabled();
await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
const toggleBox = await toggleAll.boundingBox();
const collectionBox = await firstInvoiceCollection.boundingBox();
expect(toggleBox).not.toBeNull();
expect(collectionBox).not.toBeNull();
expect(toggleBox.y + toggleBox.height).toBeLessThanOrEqual(collectionBox.y);
await toggleAll.click();
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-order_item:7701")).toBeVisible();
await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
await expect(toggleAll).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page
.getByTestId("invoice-period-object-tree")
.locator('[role="treeitem"][aria-expanded]')
.evaluateAll(
(nodes) => nodes.length > 0 && nodes.every((node) => node.getAttribute("aria-expanded") === "true")
)
)
.toBe(true);
const firstExpansionRequestCount = orderItemRequests.length;
expect(firstExpansionRequestCount).toBeGreaterThan(0);
await toggleAll.click();
await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
await expect(toggleAll).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-node-key="collected_order_invoice:3001"]')).toHaveAttribute(
"aria-expanded",
"false"
);
await toggleAll.click();
await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
expect(orderItemRequests).toHaveLength(firstExpansionRequestCount);
for (const viewport of [
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await expect(toggleAll).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
}
});
test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => { test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => {
const usageOrderRequests = []; const usageOrderRequests = [];
const fastLinkRequests = []; const fastLinkRequests = [];
@@ -1913,6 +1984,12 @@ test.describe("Invoicing period tab", () => {
"quantity" "quantity"
); );
const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price"); const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price");
const firstOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7701");
await expect(firstOrderItem.locator(".invoice-period-tree-node__subtitle")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-quantity")).toContainText(
/Antal|Quantity/i
);
await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-price")).toContainText(/Pris|Price/i);
expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2); expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2);
const alignedAmountBoxes = await Promise.all([ const alignedAmountBoxes = await Promise.all([
getBoundingBox( getBoundingBox(
+93
View File
@@ -93,4 +93,97 @@ describe("BuefyTree checkbox selection", () => {
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toHaveLength(1000); expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toHaveLength(1000);
expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(100); expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(100);
}); });
it("recursively expands lazy branches, reveals every child, and reuses the cache after collapse", async () => {
let activeLoads = 0;
let maxActiveLoads = 0;
const load = vi.fn(async (node) => {
activeLoads += 1;
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
await Promise.resolve();
activeLoads -= 1;
if (node.id === "root") {
return [
{ id: "branch:a", label: "Branch A", isLeaf: false },
{ id: "branch:b", label: "Branch B", isLeaf: false },
];
}
if (node.id === "branch:a") {
return [
{ id: "leaf:a1", label: "Leaf A1", isLeaf: true },
{ id: "leaf:a2", label: "Leaf A2", isLeaf: true },
];
}
if (node.id === "branch:b") {
return [{ id: "branch:b1", label: "Branch B1", isLeaf: false }];
}
if (node.id === "branch:b1") {
return [{ id: "leaf:b1", label: "Leaf B1", isLeaf: true }];
}
return [];
});
const wrapper = mount(BuefyTree, {
props: {
data: [{ id: "root", label: "Root", isLeaf: false }],
lazy: true,
load,
progressiveBatchSize: 1,
},
});
const [firstResult, duplicateResult] = await Promise.all([wrapper.vm.expandAll(), wrapper.vm.expandAll()]);
await flushPromises();
expect(firstResult).toEqual(duplicateResult);
expect(firstResult.cancelled).toBe(false);
expect(firstResult.failedKeys).toEqual([]);
expect(firstResult.expandedKeys).toEqual(["root", "branch:a", "branch:b", "branch:b1"]);
expect(load).toHaveBeenCalledTimes(4);
expect(maxActiveLoads).toBeGreaterThan(1);
expect(maxActiveLoads).toBeLessThanOrEqual(4);
expect(wrapper.findAll('[data-node-key^="branch:"]')).toHaveLength(3);
expect(wrapper.findAll('[data-node-key^="leaf:"]')).toHaveLength(3);
wrapper.vm.collapseAll();
await flushPromises();
expect(wrapper.findAll('[data-node-key^="branch:"]')).toHaveLength(0);
await wrapper.vm.expandAll();
await flushPromises();
expect(load).toHaveBeenCalledTimes(4);
expect(wrapper.findAll('[data-node-key^="leaf:"]')).toHaveLength(3);
});
it("continues expanding sibling branches when one lazy branch fails", async () => {
const load = vi.fn(async (node) => {
if (node.id === "root") {
return [
{ id: "branch:good", label: "Good", isLeaf: false },
{ id: "branch:bad", label: "Bad", isLeaf: false },
];
}
if (node.id === "branch:bad") {
throw new Error("Branch failed");
}
return [{ id: "leaf:good", label: "Good leaf", isLeaf: true }];
});
const wrapper = mount(BuefyTree, {
props: {
data: [{ id: "root", label: "Root", isLeaf: false }],
lazy: true,
load,
},
});
const result = await wrapper.vm.expandAll();
await flushPromises();
expect(result.cancelled).toBe(false);
expect(result.failedKeys).toEqual(["branch:bad"]);
expect(result.expandedKeys).toEqual(["root", "branch:good", "branch:bad"]);
expect(wrapper.emitted("load-error")?.[0]?.[0]).toEqual(new Error("Branch failed"));
expect(wrapper.get('[data-node-key="leaf:good"]').text()).toContain("Good leaf");
expect(wrapper.get('[data-node-key="branch:bad"]').classes()).toContain("has-load-error");
});
}); });
@@ -331,6 +331,69 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).toContain("Premium wash"); expect(wrapper.text()).toContain("Premium wash");
}); });
it("shows order-item quantity and price once through the standard editable fields", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
const orderItem = wrapper.get('[data-testid="invoice-period-tree-node-order_item:501"]');
expect(orderItem.find(".invoice-period-tree-node__subtitle").exists()).toBe(false);
const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]');
const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]');
expect(quantity.text()).toContain("Antal");
expect(quantity.text()).toContain("2");
expect(price.text()).toContain("Pris");
expect(price.text()).toContain("120 DKK");
expect(quantity.find(".editable-table-column-stub").exists()).toBe(true);
expect(price.find(".editable-table-column-stub").exists()).toBe(true);
});
it("keeps a visible expand-all toggle and reuses loaded branches after collapse", async () => {
const wrapper = mountTree();
const toggle = () => wrapper.get('[data-testid="invoice-period-tree-toggle-all"]');
expect(wrapper.get('[data-testid="invoice-period-tree-toolbar"]').exists()).toBe(true);
expect(
wrapper.find('[data-testid="invoice-period-tree-toolbar"] .invoice-period-tree-toolbar__actions').exists()
).toBe(false);
expect(toggle().text()).toContain("Udfold alle");
expect(toggle().attributes("aria-pressed")).toBe("false");
await toggle().trigger("click");
await flushPromises();
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
expect(toggle().text()).toContain("Fold alle sammen");
expect(toggle().attributes("aria-pressed")).toBe("true");
const loadCallCount = mocks.request.mock.calls.length;
await toggle().trigger("click");
await flushPromises();
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).not.toContain("is-expanded");
expect(toggle().text()).toContain("Udfold alle");
await toggle().trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(loadCallCount);
expect(toggle().text()).toContain("Fold alle sammen");
});
it("fully expands lazy content by default for invoice-per-order views", async () => {
const wrapper = mountTree({ autoExpandAll: true });
await flushPromises();
expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
});
it("renders row action wheels for actionable collection, order, and order item nodes", async () => { it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
const wrapper = mountTree(); const wrapper = mountTree();
@@ -651,6 +651,9 @@ describe("Periode tab contract", () => {
expect(buefyTreeSource).toContain("progressiveBatchSize"); expect(buefyTreeSource).toContain("progressiveBatchSize");
expect(buefyTreeSource).toContain("visibleChildrenOf"); expect(buefyTreeSource).toContain("visibleChildrenOf");
expect(buefyTreeSource).toContain("remainingChildrenCount"); expect(buefyTreeSource).toContain("remainingChildrenCount");
expect(buefyTreeSource).toContain("const expandAll =");
expect(buefyTreeSource).toContain("EXPAND_ALL_CONCURRENCY = 4");
expect(buefyTreeSource).toContain("defineExpose");
expect(buefyTreeNodeSource).toContain('<slot name="icon"'); expect(buefyTreeNodeSource).toContain('<slot name="icon"');
expect(buefyTreeNodeSource).toContain(':retry="() => tree.retryLoad(node)"'); expect(buefyTreeNodeSource).toContain(':retry="() => tree.retryLoad(node)"');
expect(buefyTreeNodeSource).toContain("b-tree-load-more__button"); expect(buefyTreeNodeSource).toContain("b-tree-load-more__button");
@@ -669,6 +672,7 @@ describe("Periode tab contract", () => {
expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem"); expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem");
expect(periodObjectTreeSource).toContain("fetchAttachmentContent"); expect(periodObjectTreeSource).toContain("fetchAttachmentContent");
expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toolbar"'); expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toolbar"');
expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toggle-all"');
expect(periodObjectTreeSource).toContain("`invoice-period-tree-actions-dropdown-${group.type}`"); expect(periodObjectTreeSource).toContain("`invoice-period-tree-actions-dropdown-${group.type}`");
}); });
@@ -742,6 +746,8 @@ describe("Periode tab contract", () => {
it("defines mixed-selection action menu locale labels", () => { it("defines mixed-selection action menu locale labels", () => {
localeMessages.forEach(({ messages }) => { localeMessages.forEach(({ messages }) => {
expect(messages.invoicing_period.object_tree.buttons.actions).toBeTruthy(); expect(messages.invoicing_period.object_tree.buttons.actions).toBeTruthy();
expect(messages.invoicing_period.object_tree.buttons.collapse_all).toBeTruthy();
expect(messages.invoicing_period.object_tree.buttons.expand_all).toBeTruthy();
expect(messages.invoicing_period.object_tree.selection.no_actions).toBeTruthy(); expect(messages.invoicing_period.object_tree.selection.no_actions).toBeTruthy();
}); });
}); });