Skip to content

Commit 87fa167

Browse files
committed
🎨 Support cross-region dock entry sorting #18285
1 parent d73ab44 commit 87fa167

6 files changed

Lines changed: 639 additions & 114 deletions

File tree

app/src/assets/scss/business/_config.scss

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,45 @@
10341034
}
10351035
}
10361036

1037+
&__dock-column {
1038+
flex-basis: clamp(180px, 20vw, 240px);
1039+
}
1040+
1041+
&__dock-group {
1042+
border-bottom: 1px solid var(--b3-border-color);
1043+
min-height: 72px;
1044+
1045+
&--drop {
1046+
box-shadow: inset 0 0 0 2px var(--b3-theme-primary);
1047+
}
1048+
1049+
&-title {
1050+
align-items: center;
1051+
background-color: var(--b3-theme-surface-lighter);
1052+
color: var(--b3-theme-on-surface-light);
1053+
display: flex;
1054+
font-size: 12px;
1055+
height: 32px;
1056+
overflow: hidden;
1057+
padding: 0 12px;
1058+
text-overflow: ellipsis;
1059+
white-space: nowrap;
1060+
}
1061+
}
1062+
1063+
&__dock-list {
1064+
min-height: 40px;
1065+
}
1066+
1067+
&__dock-empty {
1068+
align-items: center;
1069+
color: var(--b3-theme-on-surface-light);
1070+
display: flex;
1071+
font-size: 12px;
1072+
height: 40px;
1073+
justify-content: center;
1074+
}
1075+
10371076
&__location {
10381077
border-bottom: 1px solid var(--b3-border-color);
10391078
border-radius: 0;

app/src/config/entryVisibility/dockOrder.test.ts

Lines changed: 268 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
createDockEntryOrderSnapshot,
88
DOCK_ORDER_SCOPE_BY_POSITION,
99
DOCK_ORDER_SCOPES,
10+
getCurrentDockEntryOrderSnapshot,
1011
getDockEntryOrderSnapshot,
1112
getDockOrderContainer,
1213
isDockOrderScope,
1314
mergeCurrentDockEntryOrders,
1415
mergeDockEntryOrderSnapshot,
16+
moveDockEntryOrderSnapshot,
17+
type TDockEntryMover,
1518
type IDockOrderLayout,
1619
type TDockOrderSnapshot,
1720
} from "./dockOrder";
@@ -36,6 +39,18 @@ class FakeDockItem {
3639
}
3740
return null;
3841
}
42+
43+
public before(item: FakeDockItem) {
44+
this.parent?.insertBefore(item, this);
45+
}
46+
47+
public after(item: FakeDockItem) {
48+
this.parent?.insertBefore(item, this.parent.children[this.parent.children.indexOf(this) + 1]);
49+
}
50+
51+
public get parentElement() {
52+
return this.parent;
53+
}
3954
}
4055

4156
class FakeDockContainer {
@@ -46,13 +61,21 @@ class FakeDockContainer {
4661
}
4762

4863
public append(item: FakeDockItem) {
64+
this.insertBefore(item);
65+
}
66+
67+
public insertBefore(item: FakeDockItem, reference?: FakeDockItem) {
68+
let index = reference ? this.children.indexOf(reference) : this.children.length;
4969
if (item.parent) {
5070
const oldIndex = item.parent.children.indexOf(item);
5171
if (oldIndex > -1) {
5272
item.parent.children.splice(oldIndex, 1);
73+
if (item.parent === this && oldIndex < index) {
74+
index--;
75+
}
5376
}
5477
}
55-
this.children.push(item);
78+
this.children.splice(index < 0 ? this.children.length : index, 0, item);
5679
item.parent = this;
5780
}
5881
}
@@ -129,6 +152,22 @@ test("runtime dock snapshot reads direct items, keeps hidden items, and uses sta
129152
assert.equal(hiddenOutline.classList.contains("fn__none"), true);
130153
});
131154

155+
test("current dock snapshot contains only DOM entries without catalog defaults", () => {
156+
const plugin = new FakeDockItem("plugin-runtime-type", {entryId: "plugin:sample:dock"});
157+
const outline = new FakeDockItem("outline");
158+
const containers = [
159+
new FakeDockContainer([outline, plugin]),
160+
...Array.from({length: 5}, () => new FakeDockContainer()),
161+
];
162+
163+
const current = getCurrentDockEntryOrderSnapshot(layoutWith(containers));
164+
165+
assert.deepEqual(current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["outline", "plugin:sample:dock"]);
166+
assert.deepEqual(DOCK_ORDER_SCOPES.flatMap((scope) => current[scope]), ["outline", "plugin:sample:dock"]);
167+
assert.equal(getDockEntryOrderSnapshot(layoutWith(containers))[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]
168+
.includes("file"), true);
169+
});
170+
132171
test("merging a dock snapshot preserves an unavailable plugin key at its saved slot", () => {
133172
const defaults = emptySnapshot();
134173
defaults[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["file", "outline"];
@@ -151,6 +190,36 @@ test("synchronizing a dock snapshot keeps the current loaded order and preserves
151190
["outline", "plugin:disabled:dock", "file"]);
152191
});
153192

193+
test("synchronizing uses current placement while preserving globally unique unavailable plugins", () => {
194+
const current = emptySnapshot();
195+
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["outline"];
196+
current[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom] = ["file"];
197+
const merged = mergeCurrentDockEntryOrders(current, {
198+
[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]: ["file", "plugin:disabled:dock", "outline"],
199+
[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom]: ["plugin:disabled:dock", "file"],
200+
});
201+
202+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["plugin:disabled:dock", "outline"]);
203+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom], ["file"]);
204+
assert.equal(DOCK_ORDER_SCOPES.flatMap((scope) => merged[scope])
205+
.filter((key) => key === "plugin:disabled:dock").length, 1);
206+
assert.equal(DOCK_ORDER_SCOPES.flatMap((scope) => merged[scope])
207+
.filter((key) => key === "file").length, 1);
208+
});
209+
210+
test("synchronizing filters stale loaded placements before preserving unknown slots", () => {
211+
const current = emptySnapshot();
212+
current[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["loaded"];
213+
const merged = mergeCurrentDockEntryOrders(current, {
214+
[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]: ["loaded"],
215+
[DOCK_ORDER_SCOPE_BY_POSITION.RightTop]: ["unknown-before", "loaded", "unknown-after"],
216+
});
217+
218+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], []);
219+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop],
220+
["unknown-before", "loaded", "unknown-after"]);
221+
});
222+
154223
test("profiles without dock orders preserve the current six-slot layout", () => {
155224
const current = emptySnapshot();
156225
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["outline", "file"];
@@ -159,17 +228,119 @@ test("profiles without dock orders preserve the current six-slot layout", () =>
159228
assert.deepEqual(mergeDockEntryOrderSnapshot(current), current);
160229
});
161230

162-
test("merging a dock snapshot removes a loaded entry from its stale previous slot", () => {
231+
test("merging a dock snapshot applies saved placement and the first saved scope owns duplicate keys", () => {
163232
const current = emptySnapshot();
164-
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["outline"];
233+
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["file", "outline"];
165234
current[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["file", "graph"];
166235
const merged = mergeDockEntryOrderSnapshot(current, {
167-
[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]: ["file", "outline", "plugin:disabled:dock"],
168-
[DOCK_ORDER_SCOPE_BY_POSITION.RightTop]: ["graph", "file"],
236+
[DOCK_ORDER_SCOPE_BY_POSITION.LeftBottom]: ["file", "plugin:disabled:dock"],
237+
[DOCK_ORDER_SCOPE_BY_POSITION.RightTop]: ["graph", "file", "plugin:disabled:dock"],
238+
});
239+
240+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["outline"]);
241+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftBottom], ["file", "plugin:disabled:dock"]);
242+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop], ["graph"]);
243+
assert.equal(DOCK_ORDER_SCOPES.flatMap((scope) => merged[scope]).filter((key) => key === "file").length, 1);
244+
assert.equal(DOCK_ORDER_SCOPES.flatMap((scope) => merged[scope])
245+
.filter((key) => key === "plugin:disabled:dock").length, 1);
246+
});
247+
248+
test("merging a partial saved snapshot moves declared entries and keeps undeclared current entries", () => {
249+
const current = emptySnapshot();
250+
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["file", "outline"];
251+
current[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["graph"];
252+
253+
const merged = mergeDockEntryOrderSnapshot(current, {
254+
[DOCK_ORDER_SCOPE_BY_POSITION.RightTop]: ["file", "graph"],
255+
});
256+
257+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["outline"]);
258+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop], ["file", "graph"]);
259+
});
260+
261+
test("merging keeps the saved order of loaded entries moved from another scope", () => {
262+
const current = emptySnapshot();
263+
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["new-entry", "file", "outline"];
264+
current[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["graph"];
265+
266+
const merged = mergeDockEntryOrderSnapshot(current, {
267+
[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]: ["file", "graph", "outline"],
169268
});
170269

171-
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["outline", "plugin:disabled:dock"]);
172-
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop], ["graph", "file"]);
270+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop],
271+
["new-entry", "file", "graph", "outline"]);
272+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop], []);
273+
});
274+
275+
test("merging saved placement is idempotent and keeps new current entries", () => {
276+
const current = emptySnapshot();
277+
current[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["outline", "new-left"];
278+
current[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["file", "graph"];
279+
const saved = {
280+
[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop]: ["file", "plugin:disabled:dock", "outline"],
281+
[DOCK_ORDER_SCOPE_BY_POSITION.RightTop]: ["graph", "file"],
282+
};
283+
284+
const merged = mergeDockEntryOrderSnapshot(current, saved);
285+
286+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop],
287+
["file", "plugin:disabled:dock", "outline", "new-left"]);
288+
assert.deepEqual(merged[DOCK_ORDER_SCOPE_BY_POSITION.RightTop], ["graph"]);
289+
assert.deepEqual(mergeDockEntryOrderSnapshot(merged, saved), merged);
290+
});
291+
292+
test("moving a dock entry snapshot is atomic across scopes and supports empty targets", () => {
293+
const snapshot = emptySnapshot();
294+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["file", "outline"];
295+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom] = ["backlink"];
296+
297+
const crossed = moveDockEntryOrderSnapshot(
298+
snapshot,
299+
"file",
300+
DOCK_ORDER_SCOPE_BY_POSITION.RightBottom,
301+
"backlink",
302+
);
303+
assert.deepEqual(crossed?.[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["outline"]);
304+
assert.deepEqual(crossed?.[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom], ["file", "backlink"]);
305+
assert.equal(DOCK_ORDER_SCOPES.flatMap((scope) => crossed?.[scope] || [])
306+
.filter((key) => key === "file").length, 1);
307+
308+
const emptied = moveDockEntryOrderSnapshot(
309+
crossed,
310+
"file",
311+
DOCK_ORDER_SCOPE_BY_POSITION.BottomLeft,
312+
);
313+
assert.deepEqual(emptied?.[DOCK_ORDER_SCOPE_BY_POSITION.RightBottom], ["backlink"]);
314+
assert.deepEqual(emptied?.[DOCK_ORDER_SCOPE_BY_POSITION.BottomLeft], ["file"]);
315+
});
316+
317+
test("moving within one dock scope detects invalid and no-op drops", () => {
318+
const snapshot = emptySnapshot();
319+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["file", "outline", "inbox"];
320+
321+
assert.deepEqual(moveDockEntryOrderSnapshot(
322+
snapshot,
323+
"inbox",
324+
DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
325+
"file",
326+
)?.[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop], ["inbox", "file", "outline"]);
327+
assert.equal(moveDockEntryOrderSnapshot(
328+
snapshot,
329+
"file",
330+
DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
331+
"outline",
332+
), undefined);
333+
assert.equal(moveDockEntryOrderSnapshot(
334+
snapshot,
335+
"file",
336+
DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
337+
"file",
338+
), undefined);
339+
assert.equal(moveDockEntryOrderSnapshot(
340+
snapshot,
341+
"missing",
342+
DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
343+
), undefined);
173344
});
174345

175346
test("applying dock order sorts hidden items and keeps an unknown plugin in its current slot", () => {
@@ -190,11 +361,100 @@ test("applying dock order sorts hidden items and keeps an unknown plugin in its
190361
assert.equal(asElement(file).dataset.type, "file");
191362
});
192363

364+
test("applying dock order moves entries across containers and is idempotent", () => {
365+
const file = new FakeDockItem("file");
366+
const outline = new FakeDockItem("outline", {hidden: true});
367+
const graph = new FakeDockItem("graph");
368+
const leftTop = new FakeDockContainer([file, outline]);
369+
const rightTop = new FakeDockContainer([graph]);
370+
const containers = [
371+
leftTop,
372+
new FakeDockContainer(),
373+
rightTop,
374+
...Array.from({length: 3}, () => new FakeDockContainer()),
375+
];
376+
const layout = layoutWith(containers);
377+
const snapshot = emptySnapshot();
378+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = ["outline"];
379+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.RightTop] = ["graph", "file"];
380+
381+
assert.equal(applyDockEntryOrderSnapshot(snapshot, layout), true);
382+
assert.deepEqual(leftTop.children, [outline]);
383+
assert.deepEqual(rightTop.children, [graph, file]);
384+
assert.equal(file.parent, rightTop);
385+
assert.equal(outline.classList.contains("fn__none"), true);
386+
assert.equal(applyDockEntryOrderSnapshot(snapshot, layout), false);
387+
});
388+
389+
test("applying dock order invokes the mover for cross-scope and same-scope changes", () => {
390+
const first = new FakeDockItem("first");
391+
const second = new FakeDockItem("second");
392+
const third = new FakeDockItem("third");
393+
const fourth = new FakeDockItem("fourth");
394+
const leftTop = new FakeDockContainer([first, second]);
395+
const rightTop = new FakeDockContainer([third, fourth]);
396+
const containers = [
397+
leftTop,
398+
new FakeDockContainer(),
399+
rightTop,
400+
...Array.from({length: 3}, () => new FakeDockContainer()),
401+
];
402+
const layout = layoutWith(containers);
403+
const snapshot = emptySnapshot();
404+
snapshot[DOCK_ORDER_SCOPE_BY_POSITION.LeftTop] = [
405+
"fourth",
406+
"plugin:disabled:dock",
407+
"second",
408+
"first",
409+
"third",
410+
];
411+
const calls: Array<{scope: string; type?: string; previousType?: string}> = [];
412+
const mover: TDockEntryMover = (scope, item, previousItem) => {
413+
calls.push({
414+
scope,
415+
type: item.dataset.type,
416+
previousType: previousItem?.dataset.type,
417+
});
418+
const target = getDockOrderContainer(scope, layout) as unknown as FakeDockContainer;
419+
if (previousItem) {
420+
(previousItem as unknown as FakeDockItem).after(item as unknown as FakeDockItem);
421+
} else {
422+
target.insertBefore(item as unknown as FakeDockItem, target.children[0]);
423+
}
424+
};
425+
426+
applyDockEntryOrderSnapshot(snapshot, layout, mover);
427+
428+
assert.deepEqual(calls, [{
429+
scope: DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
430+
type: "fourth",
431+
previousType: undefined,
432+
}, {
433+
scope: DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
434+
type: "third",
435+
previousType: "first",
436+
}, {
437+
scope: DOCK_ORDER_SCOPE_BY_POSITION.LeftTop,
438+
type: "second",
439+
previousType: "fourth",
440+
}]);
441+
assert.deepEqual(leftTop.children, [fourth, second, first, third]);
442+
assert.deepEqual(rightTop.children, []);
443+
calls.length = 0;
444+
assert.equal(applyDockEntryOrderSnapshot(snapshot, layout, mover), false);
445+
assert.deepEqual(calls, []);
446+
});
447+
193448
test("dock ordering is applied at runtime and synchronized after direct dock moves", () => {
194449
const runtimeSource = readFileSync(resolve(process.cwd(), "src/config/entryVisibility/runtime.ts"), "utf8");
195450
const dockSource = readFileSync(resolve(process.cwd(), "src/layout/dock/index.ts"), "utf8");
451+
const uiSource = readFileSync(resolve(process.cwd(), "src/config/entryVisibility/ui.ts"), "utf8");
196452

197453
assert.match(runtimeSource, /applyDockEntryOrderSnapshot\(mergeDockEntryOrderSnapshot\(/);
454+
assert.match(runtimeSource, /const currentOrders = getCurrentDockEntryOrderSnapshot\(\);/);
455+
assert.match(runtimeSource, /\{syncEntryOrders: false\}/);
198456
assert.match(runtimeSource, /const key = getDockEntryKey\(item\);/);
199-
assert.match(dockSource, /syncDockEntryOrders\(\);/);
457+
assert.match(dockSource, /options\.syncEntryOrders !== false/);
458+
assert.match(uiSource, /data-entry-drop-scope/);
459+
assert.match(uiSource, /moveDockEntryOrderSnapshot\(/);
200460
});

0 commit comments

Comments
 (0)