Skip to content

Commit f054a17

Browse files
feat: New field.loaded event in ModelListField
1 parent 5795d1c commit f054a17

4 files changed

Lines changed: 258 additions & 5 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { beforeEach, describe, expect, it, vi } from "@test/unit";
2+
import { mount } from "@vue/test-utils";
3+
import ModelListField from "./ModelListField.vue";
4+
5+
const events = { emit: vi.fn(), off: vi.fn(), on: vi.fn() };
6+
const api = { get: vi.fn() };
7+
const panel = { error: vi.fn() };
8+
9+
const initial = {
10+
columns: {},
11+
models: [],
12+
pagination: { page: 1, total: 0 }
13+
};
14+
15+
function factory() {
16+
return mount(ModelListField, {
17+
props: {
18+
endpoints: { field: "pages/test/fields/drafts" },
19+
initial,
20+
name: "drafts"
21+
},
22+
shallow: true,
23+
global: {
24+
mocks: {
25+
$api: api,
26+
$events: events,
27+
$panel: panel
28+
}
29+
}
30+
});
31+
}
32+
33+
/**
34+
* The arguments of the last emitted event. The instance
35+
* is compared by identity, as enumerating its keys warns.
36+
*/
37+
function lastEmit() {
38+
return events.emit.mock.calls.at(-1) ?? [];
39+
}
40+
41+
describe("ModelListField.vue", () => {
42+
beforeEach(() => {
43+
api.get.mockReset();
44+
events.emit.mockClear();
45+
panel.error.mockClear();
46+
});
47+
48+
it("announces itself once it is mounted", () => {
49+
const wrapper = factory();
50+
51+
expect(events.emit).toHaveBeenCalledTimes(1);
52+
expect(lastEmit()[0]).toBe("field.loaded");
53+
expect(lastEmit()[1]).toBe(wrapper.vm);
54+
});
55+
56+
it("listens to model updates while mounted", () => {
57+
const wrapper = factory();
58+
59+
expect(events.on).toHaveBeenCalledWith("model.update", expect.anything());
60+
61+
wrapper.unmount();
62+
63+
expect(events.off).toHaveBeenCalledWith("model.update", expect.anything());
64+
});
65+
66+
it("announces itself again after a reload", async () => {
67+
const state = { ...initial, pagination: { page: 2, total: 25 } };
68+
api.get.mockResolvedValue(state);
69+
70+
const wrapper = factory();
71+
await wrapper.vm.reload({ page: 2 });
72+
73+
expect(api.get).toHaveBeenCalledWith("pages/test/fields/drafts", {
74+
page: 2,
75+
searchterm: null
76+
});
77+
78+
// the fresh state replaces the initial one
79+
expect(wrapper.vm.state).toStrictEqual(state);
80+
81+
// once on mount, once after the reload
82+
expect(events.emit).toHaveBeenCalledTimes(2);
83+
expect(lastEmit()[0]).toBe("field.loaded");
84+
expect(lastEmit()[1]).toBe(wrapper.vm);
85+
});
86+
87+
it("announces itself even when the reload fails", async () => {
88+
const error = new Error("Nope");
89+
api.get.mockRejectedValue(error);
90+
91+
const wrapper = factory();
92+
await wrapper.vm.reload();
93+
94+
expect(panel.error).toHaveBeenCalledWith(error);
95+
expect(wrapper.vm.isProcessing).toBe(false);
96+
expect(events.emit).toHaveBeenCalledTimes(2);
97+
expect(lastEmit()[0]).toBe("field.loaded");
98+
expect(lastEmit()[1]).toBe(wrapper.vm);
99+
});
100+
});

panel/src/components/Forms/Field/ModelListField.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ export default {
253253
this.$events.on(event, this.onRefresh);
254254
}
255255
},
256+
mounted() {
257+
this.$events.emit("field.loaded", this);
258+
},
256259
unmounted() {
257260
for (const event of this.refreshEvents()) {
258261
this.$events.off(event, this.onRefresh);
@@ -305,6 +308,9 @@ export default {
305308
} finally {
306309
this.isProcessing = false;
307310
}
311+
312+
await this.$nextTick();
313+
this.$events.emit("field.loaded", this);
308314
},
309315
/**
310316
* Runs the callback and announces the change afterwards,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { beforeEach, describe, expect, it, vi } from "@test/unit";
2+
import { mount } from "@vue/test-utils";
3+
import PreviewForm from "./PreviewForm.vue";
4+
5+
const events = { off: vi.fn(), on: vi.fn() };
6+
const panel = {
7+
config: { debug: false },
8+
view: { path: "/pages/test" }
9+
};
10+
11+
function factory() {
12+
return mount(PreviewForm, {
13+
props: {
14+
api: "pages/test",
15+
blueprint: "default",
16+
content: {},
17+
tab: { name: "main" },
18+
tabs: [{ name: "main" }]
19+
},
20+
shallow: true,
21+
global: {
22+
mocks: {
23+
$events: events,
24+
$panel: panel
25+
}
26+
}
27+
});
28+
}
29+
30+
type Link = HTMLAnchorElement & {
31+
__vue__: { onClick: (event: Event) => void; to?: string };
32+
};
33+
34+
/**
35+
* Creates a fake field or section instance with links
36+
* for the given urls, wrapped in item titles
37+
*/
38+
function loaded(...urls: (string | undefined)[]) {
39+
const $el = document.createElement("div");
40+
const links = urls.map((to) => {
41+
const title = document.createElement("p");
42+
title.className = "k-item-title";
43+
44+
const link = document.createElement("a") as Link;
45+
link.className = "k-link";
46+
link.__vue__ = { onClick: original, to };
47+
48+
title.append(link);
49+
$el.append(title);
50+
51+
return link;
52+
});
53+
54+
return { $el, links };
55+
}
56+
57+
const original = vi.fn();
58+
59+
describe("PreviewForm.vue", () => {
60+
beforeEach(() => {
61+
events.off.mockClear();
62+
events.on.mockClear();
63+
original.mockClear();
64+
});
65+
66+
it("listens to loaded fields and sections while mounted", () => {
67+
const wrapper = factory();
68+
69+
expect(events.on).toHaveBeenCalledWith("field.loaded", wrapper.vm.fixLinks);
70+
expect(events.on).toHaveBeenCalledWith(
71+
"section.loaded",
72+
wrapper.vm.fixLinks
73+
);
74+
75+
const fixLinks = wrapper.vm.fixLinks;
76+
wrapper.unmount();
77+
78+
expect(events.off).toHaveBeenCalledWith("field.loaded", fixLinks);
79+
expect(events.off).toHaveBeenCalledWith("section.loaded", fixLinks);
80+
});
81+
82+
it("redirects page links to the preview form", () => {
83+
const wrapper = factory();
84+
const { $el, links } = loaded("/pages/test+child");
85+
86+
wrapper.vm.fixLinks({ $el });
87+
88+
const event = { preventDefault: vi.fn() };
89+
links[0].__vue__.onClick(event as unknown as Event);
90+
91+
expect(event.preventDefault).toHaveBeenCalled();
92+
expect(original).not.toHaveBeenCalled();
93+
expect(wrapper.emitted("navigate")).toStrictEqual([
94+
["/pages/test+child/preview/form"]
95+
]);
96+
});
97+
98+
it("redirects all page links of the element", () => {
99+
const wrapper = factory();
100+
const { $el, links } = loaded("/pages/a", "/pages/b");
101+
102+
wrapper.vm.fixLinks({ $el });
103+
104+
for (const link of links) {
105+
link.__vue__.onClick({ preventDefault: vi.fn() } as unknown as Event);
106+
}
107+
108+
expect(wrapper.emitted("navigate")).toStrictEqual([
109+
["/pages/a/preview/form"],
110+
["/pages/b/preview/form"]
111+
]);
112+
});
113+
114+
it("keeps links that don't point to a page view", () => {
115+
const wrapper = factory();
116+
const { $el, links } = loaded(
117+
"/pages/test+child/files/test.jpg",
118+
"/users/test",
119+
undefined
120+
);
121+
122+
wrapper.vm.fixLinks({ $el });
123+
124+
for (const link of links) {
125+
expect(link.__vue__.onClick).toBe(original);
126+
127+
link.__vue__.onClick({ preventDefault: vi.fn() } as unknown as Event);
128+
}
129+
130+
expect(original).toHaveBeenCalledTimes(3);
131+
expect(wrapper.emitted("navigate")).toBeUndefined();
132+
});
133+
134+
it("ignores links outside of item titles", () => {
135+
const wrapper = factory();
136+
const { $el, links } = loaded("/pages/test+child");
137+
138+
// move the link out of the item title
139+
$el.append(links[0]);
140+
141+
wrapper.vm.fixLinks({ $el });
142+
143+
expect(links[0].__vue__.onClick).toBe(original);
144+
});
145+
});

panel/src/components/Views/Preview/PreviewForm.vue

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,20 @@ export default {
7373
}
7474
},
7575
mounted() {
76-
this.$events.on("section.loaded", this.fixLinksInSection);
76+
this.$events.on("field.loaded", this.fixLinks);
77+
this.$events.on("section.loaded", this.fixLinks);
7778
},
7879
unmounted() {
79-
this.$events.off("section.loaded", this.fixLinksInSection);
80+
this.$events.off("field.loaded", this.fixLinks);
81+
this.$events.off("section.loaded", this.fixLinks);
8082
},
8183
methods: {
8284
/**
83-
* Overwrites all links to page views in the section
85+
* Overwrites all links to page views in the field or section
8486
* to open the corresponding page preview view instead
8587
*/
86-
fixLinksInSection(section) {
87-
const links = section.$el.querySelectorAll(".k-item-title > .k-link");
88+
fixLinks(element) {
89+
const links = element.$el.querySelectorAll(".k-item-title > .k-link");
8890
for (const link of links) {
8991
const url = link.__vue__.to;
9092

0 commit comments

Comments
 (0)