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:
@@ -1,10 +1,23 @@
|
||||
<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";
|
||||
|
||||
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;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: TreeNode[];
|
||||
fields?: Partial<{
|
||||
id: string;
|
||||
@@ -24,7 +37,8 @@ const props = withDefaults(defineProps<{
|
||||
progressiveBatchSize?: number;
|
||||
loadMoreLabel?: string;
|
||||
ariaLabel?: string;
|
||||
}>(), {
|
||||
}>(),
|
||||
{
|
||||
data: () => [],
|
||||
fields: () => ({}),
|
||||
selectionMode: "none",
|
||||
@@ -38,7 +52,8 @@ const props = withDefaults(defineProps<{
|
||||
progressiveBatchSize: 0,
|
||||
loadMoreLabel: "Show {count} more",
|
||||
ariaLabel: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: any];
|
||||
@@ -70,6 +85,11 @@ const state = reactive({
|
||||
loadErrorKeys: [] as any[],
|
||||
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 keyString = (key: any) => String(key ?? "");
|
||||
@@ -78,6 +98,8 @@ const childrenOf = (node: TreeNode) => {
|
||||
return Array.isArray(children) ? children : [];
|
||||
};
|
||||
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 children = childrenOf(node);
|
||||
return children.length > 0 ? children : cachedChildrenOf(node);
|
||||
@@ -92,7 +114,8 @@ const visibleChildrenOf = (node: TreeNode) => {
|
||||
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
|
||||
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 showMoreChildren = (node: TreeNode) => {
|
||||
if (!hasMoreChildren(node)) {
|
||||
@@ -102,10 +125,8 @@ const showMoreChildren = (node: TreeNode) => {
|
||||
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
|
||||
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
|
||||
};
|
||||
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
|
||||
"{count}",
|
||||
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
|
||||
);
|
||||
const loadMoreLabelFor = (node: TreeNode) =>
|
||||
props.loadMoreLabel.replace("{count}", String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node))));
|
||||
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
|
||||
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
|
||||
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
|
||||
@@ -124,18 +145,6 @@ const collectKeys = (node: TreeNode): any[] => {
|
||||
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(
|
||||
() => props.selected,
|
||||
(value) => {
|
||||
@@ -159,18 +168,6 @@ watch(
|
||||
{ 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[]) => {
|
||||
state.expandedKeys = [...keys];
|
||||
emit("update:expandedKeys", [...state.expandedKeys]);
|
||||
@@ -181,33 +178,146 @@ const setCheckedKeys = (keys: any[]) => {
|
||||
emit("update:checkedKeys", [...state.checkedKeys]);
|
||||
};
|
||||
|
||||
const loadNode = async (node: TreeNode) => {
|
||||
const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
|
||||
if (!props.lazy || !props.load) {
|
||||
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
|
||||
}
|
||||
const key = keyOf(node);
|
||||
if (key === undefined || key === null) {
|
||||
return { children: [], failed: false, cancelled: false };
|
||||
}
|
||||
const cacheKey = keyString(key);
|
||||
if (childrenOf(node).length > 0 || hasCachedChildren(node)) {
|
||||
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
|
||||
}
|
||||
const inFlight = inFlightLoads.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const requestedDataVersion = dataVersion;
|
||||
let loadPromise: Promise<LoadNodeResult>;
|
||||
loadPromise = (async () => {
|
||||
state.loadingKeys = [...new Set([...state.loadingKeys, key])];
|
||||
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
||||
emit("load-start", node, key);
|
||||
|
||||
try {
|
||||
const loadedChildren = await props.load(node);
|
||||
if (requestedDataVersion !== dataVersion) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
inFlightLoads.set(cacheKey, loadPromise);
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
if (state.loadingKeys.includes(key)) {
|
||||
const normalizedKey = keyString(key);
|
||||
if (visited.has(normalizedKey)) {
|
||||
return;
|
||||
}
|
||||
visited.add(normalizedKey);
|
||||
expandedKeys.push(key);
|
||||
|
||||
state.loadingKeys = [...state.loadingKeys, key];
|
||||
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
||||
emit("load-start", node, 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;
|
||||
}
|
||||
|
||||
try {
|
||||
state.lazyChildrenCache[keyString(key)] = await props.load(node);
|
||||
if (progressiveBatchSize.value > 0) {
|
||||
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
|
||||
const cancelled = operationVersion !== bulkOperationVersion;
|
||||
if (!cancelled) {
|
||||
setExpandedKeys(expandedKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
|
||||
emit("load-error", error, node, key);
|
||||
} finally {
|
||||
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
|
||||
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) => {
|
||||
@@ -312,9 +422,7 @@ const handleNodeClick = async (node: TreeNode) => {
|
||||
emit("select", node, key);
|
||||
} else if (props.selectionMode === "multiple") {
|
||||
const selected = Array.isArray(state.selected) ? state.selected : [];
|
||||
state.selected = selected.includes(key)
|
||||
? selected.filter((current) => current !== key)
|
||||
: [...selected, key];
|
||||
state.selected = selected.includes(key) ? selected.filter((current) => current !== key) : [...selected, key];
|
||||
emit("update:selected", state.selected);
|
||||
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", {
|
||||
props,
|
||||
state,
|
||||
@@ -347,7 +498,7 @@ provide("BuefyTreeContext", {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
|
||||
<ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
|
||||
<BuefyTreeNode
|
||||
v-for="(node, index) in data"
|
||||
:key="keyOf(node) ?? index"
|
||||
|
||||
@@ -4428,10 +4428,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Accepter forslag",
|
||||
"actions": "Handlinger",
|
||||
"collapse_all": "Fold alle sammen",
|
||||
"delete": "Slet",
|
||||
"delete_lines": "Slet linjer",
|
||||
"deny_xlvask": "Afvis forslag",
|
||||
"download": "Download",
|
||||
"expand_all": "Udfold alle",
|
||||
"exclude_invoice": "Ekskluder",
|
||||
"ignore": "Ignorer",
|
||||
"include_invoice": "Inkluder",
|
||||
|
||||
@@ -4538,10 +4538,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Vorschlag akzeptieren",
|
||||
"actions": "Aktionen",
|
||||
"collapse_all": "Alle einklappen",
|
||||
"delete": "Löschen",
|
||||
"delete_lines": "Zeilen löschen",
|
||||
"deny_xlvask": "Vorschlag ablehnen",
|
||||
"download": "Download",
|
||||
"expand_all": "Alle aufklappen",
|
||||
"exclude_invoice": "Ausschließen",
|
||||
"ignore": "Ignorieren",
|
||||
"include_invoice": "Einschließen",
|
||||
|
||||
@@ -4259,10 +4259,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Accept suggestion",
|
||||
"actions": "Actions",
|
||||
"collapse_all": "Collapse all",
|
||||
"delete": "Delete",
|
||||
"delete_lines": "Delete lines",
|
||||
"deny_xlvask": "Deny suggestion",
|
||||
"download": "Download",
|
||||
"expand_all": "Expand all",
|
||||
"exclude_invoice": "Exclude",
|
||||
"ignore": "Ignore",
|
||||
"include_invoice": "Include",
|
||||
|
||||
@@ -3648,10 +3648,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
|
||||
"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_lines": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete_lines'}",
|
||||
"deny_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
|
||||
"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'}",
|
||||
"ignore": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.ignore'}",
|
||||
"include_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.include_invoice'}",
|
||||
|
||||
@@ -4541,10 +4541,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Godta forslag",
|
||||
"actions": "Handlinger",
|
||||
"collapse_all": "Slå sammen alle",
|
||||
"delete": "Slett",
|
||||
"delete_lines": "Slett linjer",
|
||||
"deny_xlvask": "Avvis forslag",
|
||||
"download": "Last ned",
|
||||
"expand_all": "Utvid alle",
|
||||
"exclude_invoice": "Ekskluder",
|
||||
"ignore": "Ignorer",
|
||||
"include_invoice": "Inkluder",
|
||||
|
||||
@@ -4591,10 +4591,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Acceptera förslag",
|
||||
"actions": "Åtgärder",
|
||||
"collapse_all": "Fäll ihop alla",
|
||||
"delete": "Ta bort",
|
||||
"delete_lines": "Ta bort rader",
|
||||
"deny_xlvask": "Avvisa förslag",
|
||||
"download": "Ladda ner",
|
||||
"expand_all": "Fäll ut alla",
|
||||
"exclude_invoice": "Exkludera",
|
||||
"ignore": "Ignorera",
|
||||
"include_invoice": "Inkludera",
|
||||
|
||||
@@ -86,10 +86,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Accepter forslag",
|
||||
"actions": "Handlinger",
|
||||
"collapse_all": "Fold alle sammen",
|
||||
"delete": "Slet",
|
||||
"delete_lines": "Slet linjer",
|
||||
"deny_xlvask": "Afvis forslag",
|
||||
"download": "Download",
|
||||
"expand_all": "Udfold alle",
|
||||
"exclude_invoice": "Ekskluder",
|
||||
"ignore": "Ignorer",
|
||||
"include_invoice": "Inkluder",
|
||||
|
||||
@@ -86,10 +86,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Vorschlag akzeptieren",
|
||||
"actions": "Aktionen",
|
||||
"collapse_all": "Alle einklappen",
|
||||
"delete": "Löschen",
|
||||
"delete_lines": "Zeilen löschen",
|
||||
"deny_xlvask": "Vorschlag ablehnen",
|
||||
"download": "Download",
|
||||
"expand_all": "Alle aufklappen",
|
||||
"exclude_invoice": "Ausschließen",
|
||||
"ignore": "Ignorieren",
|
||||
"include_invoice": "Einschließen",
|
||||
|
||||
@@ -86,10 +86,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Accept suggestion",
|
||||
"actions": "Actions",
|
||||
"collapse_all": "Collapse all",
|
||||
"delete": "Delete",
|
||||
"delete_lines": "Delete lines",
|
||||
"deny_xlvask": "Deny suggestion",
|
||||
"download": "Download",
|
||||
"expand_all": "Expand all",
|
||||
"exclude_invoice": "Exclude",
|
||||
"ignore": "Ignore",
|
||||
"include_invoice": "Include",
|
||||
|
||||
@@ -85,10 +85,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
|
||||
"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_lines": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete_lines'}",
|
||||
"deny_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
|
||||
"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'}",
|
||||
"ignore": "@:{'phrases.compat.invoicing_period.object_tree.buttons.ignore'}",
|
||||
"include_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.include_invoice'}",
|
||||
|
||||
@@ -86,10 +86,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Godta forslag",
|
||||
"actions": "Handlinger",
|
||||
"collapse_all": "Slå sammen alle",
|
||||
"delete": "Slett",
|
||||
"delete_lines": "Slett linjer",
|
||||
"deny_xlvask": "Avvis forslag",
|
||||
"download": "Last ned",
|
||||
"expand_all": "Utvid alle",
|
||||
"exclude_invoice": "Ekskluder",
|
||||
"ignore": "Ignorer",
|
||||
"include_invoice": "Inkluder",
|
||||
|
||||
@@ -86,10 +86,12 @@
|
||||
"buttons": {
|
||||
"accept_xlvask": "Acceptera förslag",
|
||||
"actions": "Åtgärder",
|
||||
"collapse_all": "Fäll ihop alla",
|
||||
"delete": "Ta bort",
|
||||
"delete_lines": "Ta bort rader",
|
||||
"deny_xlvask": "Avvisa förslag",
|
||||
"download": "Ladda ner",
|
||||
"expand_all": "Fäll ut alla",
|
||||
"exclude_invoice": "Exkludera",
|
||||
"ignore": "Ignorera",
|
||||
"include_invoice": "Inkludera",
|
||||
|
||||
+730
-304
File diff suppressed because it is too large
Load Diff
@@ -1382,6 +1382,77 @@ test.describe("Invoicing period tab", () => {
|
||||
.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 }) => {
|
||||
const usageOrderRequests = [];
|
||||
const fastLinkRequests = [];
|
||||
@@ -1913,6 +1984,12 @@ test.describe("Invoicing period tab", () => {
|
||||
"quantity"
|
||||
);
|
||||
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);
|
||||
const alignedAmountBoxes = await Promise.all([
|
||||
getBoundingBox(
|
||||
|
||||
@@ -93,4 +93,97 @@ describe("BuefyTree checkbox selection", () => {
|
||||
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toHaveLength(1000);
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountTree();
|
||||
|
||||
|
||||
@@ -651,6 +651,9 @@ describe("Periode tab contract", () => {
|
||||
expect(buefyTreeSource).toContain("progressiveBatchSize");
|
||||
expect(buefyTreeSource).toContain("visibleChildrenOf");
|
||||
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(':retry="() => tree.retryLoad(node)"');
|
||||
expect(buefyTreeNodeSource).toContain("b-tree-load-more__button");
|
||||
@@ -669,6 +672,7 @@ describe("Periode tab contract", () => {
|
||||
expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem");
|
||||
expect(periodObjectTreeSource).toContain("fetchAttachmentContent");
|
||||
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}`");
|
||||
});
|
||||
|
||||
@@ -742,6 +746,8 @@ describe("Periode tab contract", () => {
|
||||
it("defines mixed-selection action menu locale labels", () => {
|
||||
localeMessages.forEach(({ messages }) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user