Skip to content

Commit 399e0ac

Browse files
refact: Use the pagelist in the default site blueprint
refact: Convert blueprint sections to fields refact: Collect blueprints and errors from fields refact: Resolve field props for the model view refact: New ModelForm component docs: Deprecate the sections components refact: Outsource normalizing logic into its own class test: Add missing unit tests
1 parent 7967e42 commit 399e0ac

38 files changed

Lines changed: 1416 additions & 451 deletions
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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mount } from "@vue/test-utils";
3+
import ModelForm from "./ModelForm.vue";
4+
5+
const { disabled, isEmpty, resolvedColumns } = ModelForm.computed;
6+
const { fieldsWithAdditionalData } = ModelForm.methods;
7+
8+
/**
9+
* Builds a mocked component context
10+
*/
11+
function context(props = {}) {
12+
const ctx = {
13+
api: "pages/test",
14+
columns: {},
15+
diff: {},
16+
...props
17+
};
18+
19+
ctx.fieldsWithAdditionalData = fieldsWithAdditionalData.bind(ctx);
20+
21+
return ctx;
22+
}
23+
24+
describe("ModelForm.vue", () => {
25+
const columns = {
26+
0: { width: "1/2", fields: { headline: { type: "text" } } },
27+
1: { width: "1/2", sticky: true, fields: { text: { type: "textarea" } } }
28+
};
29+
30+
it("renders a column for each column of the tab", () => {
31+
const wrapper = mount(ModelForm, {
32+
props: { api: "pages/test", columns, content: { headline: "Test" } }
33+
});
34+
35+
const rendered = wrapper.findAll("k-column");
36+
37+
expect(wrapper.find("form.k-model-form").exists()).toBe(true);
38+
expect(rendered.length).toBe(2);
39+
expect(rendered[0].attributes("width")).toBe("1/2");
40+
expect(rendered[1].attributes("sticky")).toBe("true");
41+
expect(wrapper.findAll("k-fieldset").length).toBe(2);
42+
});
43+
44+
it("renders the empty state instead of the form", () => {
45+
const wrapper = mount(ModelForm, {
46+
props: { columns: {}, empty: "No blueprint" }
47+
});
48+
49+
expect(wrapper.find("k-box").exists()).toBe(true);
50+
expect(wrapper.find("form").exists()).toBe(false);
51+
});
52+
53+
it("passes on the input of a fieldset", async () => {
54+
const wrapper = mount(ModelForm, {
55+
props: { api: "pages/test", columns }
56+
});
57+
58+
await wrapper.find("k-fieldset").trigger("input");
59+
60+
expect(wrapper.emitted("input")).toHaveLength(1);
61+
});
62+
63+
it("submits the form and the fieldsets", async () => {
64+
const wrapper = mount(ModelForm, {
65+
props: { api: "pages/test", columns }
66+
});
67+
68+
await wrapper.find("form").trigger("submit");
69+
expect(wrapper.emitted("submit")).toHaveLength(1);
70+
71+
// the submit of a fieldset bubbles up to the form as well
72+
await wrapper.find("k-fieldset").trigger("submit");
73+
expect(wrapper.emitted("submit")).toHaveLength(3);
74+
});
75+
});
76+
77+
describe("ModelForm.fieldsWithAdditionalData()", () => {
78+
it("points regular fields at the field endpoint", () => {
79+
const ctx = context();
80+
const fields = ctx.fieldsWithAdditionalData({
81+
headline: { type: "text" }
82+
});
83+
84+
expect(fields.headline.endpoints).toStrictEqual({
85+
model: "pages/test",
86+
field: "pages/test/fields/headline"
87+
});
88+
});
89+
90+
it("points section fields at the section endpoint", () => {
91+
const ctx = context();
92+
const fields = ctx.fieldsWithAdditionalData({
93+
mysection: { type: "section" }
94+
});
95+
96+
expect(fields.mysection.endpoints).toStrictEqual({
97+
model: "pages/test",
98+
section: "pages/test/sections/mysection"
99+
});
100+
});
101+
102+
it("flags fields with unsaved changes", () => {
103+
const ctx = context({ diff: { headline: "changed" } });
104+
const fields = ctx.fieldsWithAdditionalData({
105+
headline: { type: "text" },
106+
text: { type: "textarea" }
107+
});
108+
109+
expect(fields.headline.hasDiff).toBe(true);
110+
expect(fields.text.hasDiff).toBe(false);
111+
});
112+
113+
it("survives a missing diff", () => {
114+
const ctx = context({ diff: undefined });
115+
const fields = ctx.fieldsWithAdditionalData({
116+
headline: { type: "text" }
117+
});
118+
119+
expect(fields.headline.hasDiff).toBe(false);
120+
});
121+
});
122+
123+
describe("ModelForm.resolvedColumns()", () => {
124+
it("keeps the column props and resolves its fields", () => {
125+
const ctx = context({
126+
columns: {
127+
0: { width: "2/3", fields: { headline: { type: "text" } } }
128+
}
129+
});
130+
131+
const columns = resolvedColumns.call(ctx);
132+
133+
expect(columns[0].width).toBe("2/3");
134+
expect(columns[0].fields.headline.endpoints.field).toBe(
135+
"pages/test/fields/headline"
136+
);
137+
});
138+
});
139+
140+
describe("ModelForm.disabled()", () => {
141+
it("is disabled while the model is locked", () => {
142+
expect(disabled.call({ lock: { state: "lock" } })).toBe(true);
143+
expect(disabled.call({ lock: { state: "unlock" } })).toBe(false);
144+
expect(disabled.call({ lock: false })).toBe(false);
145+
});
146+
});
147+
148+
describe("ModelForm.isEmpty()", () => {
149+
it("only reports empty when there are no columns and a text to show", () => {
150+
expect(isEmpty.call({ columns: {}, empty: "No blueprint" })).toBe(
151+
"No blueprint"
152+
);
153+
expect(isEmpty.call({ columns: {}, empty: null })).toBeFalsy();
154+
expect(isEmpty.call({ columns: { 0: {} }, empty: "No blueprint" })).toBe(
155+
false
156+
);
157+
});
158+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<template>
2+
<k-box v-if="isEmpty" :html="true" :text="empty" theme="info" />
3+
4+
<form
5+
v-else
6+
class="k-model-form"
7+
method="POST"
8+
@submit.prevent="$emit('submit', $event)"
9+
>
10+
<k-grid variant="columns">
11+
<k-column
12+
v-for="(column, columnKey) in resolvedColumns"
13+
:key="api + '-column-' + columnKey"
14+
:width="column.width"
15+
:sticky="column.sticky"
16+
>
17+
<k-fieldset
18+
ref="fieldsets"
19+
:disabled="disabled"
20+
:fields="column.fields"
21+
:value="content"
22+
@input="$emit('input', $event)"
23+
@submit="$emit('submit', $event)"
24+
/>
25+
</k-column>
26+
</k-grid>
27+
</form>
28+
</template>
29+
30+
<script>
31+
/**
32+
* Renders all columns of a model view tab as a single form.
33+
*
34+
* Sections are converted to fields on the blueprint level,
35+
* so a model view is nothing but a form.
36+
*
37+
* @copyright Bastian Allgeier
38+
* @license https://getkirby.com/license
39+
* @since 6.0.0
40+
*/
41+
export default {
42+
props: {
43+
api: String,
44+
columns: [Array, Object],
45+
content: Object,
46+
diff: Object,
47+
/**
48+
* Text to show when the model has no columns at all
49+
*/
50+
empty: String,
51+
lock: [Boolean, Object]
52+
},
53+
emits: ["input", "submit"],
54+
computed: {
55+
disabled() {
56+
return this.lock?.state === "lock";
57+
},
58+
isEmpty() {
59+
return Object.keys(this.columns ?? {}).length === 0 && this.empty;
60+
},
61+
resolvedColumns() {
62+
const columns = {};
63+
64+
for (const key in this.columns) {
65+
columns[key] = {
66+
...this.columns[key],
67+
fields: this.fieldsWithAdditionalData(this.columns[key].fields)
68+
};
69+
}
70+
71+
return columns;
72+
}
73+
},
74+
methods: {
75+
fieldsWithAdditionalData(fields) {
76+
const result = {};
77+
78+
for (const name in fields) {
79+
const field = fields[name];
80+
81+
// section fields talk to the section endpoint,
82+
// all other fields to the field endpoint
83+
// TODO: drop this once we use field endpoints
84+
const endpoints =
85+
field.type === "section"
86+
? { model: this.api, section: this.api + "/sections/" + name }
87+
: { model: this.api, field: this.api + "/fields/" + name };
88+
89+
result[name] = {
90+
...field,
91+
endpoints,
92+
hasDiff: Object.hasOwn(this.diff ?? {}, name)
93+
};
94+
}
95+
96+
return result;
97+
}
98+
}
99+
};
100+
</script>

panel/src/components/Forms/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Fieldset from "./Fieldset.vue";
55
import Form from "./Form.vue";
66
import FormControls from "./FormControls.vue";
77
import Input from "./Input.vue";
8+
import ModelForm from "./ModelForm.vue";
89

910
/* Form parts */
1011
import Blocks from "./Blocks/index.js";
@@ -24,6 +25,7 @@ export default {
2425
app.component("k-form", Form);
2526
app.component("k-form-controls", FormControls);
2627
app.component("k-input", Input);
28+
app.component("k-model-form", ModelForm);
2729

2830
app.use(Blocks);
2931
app.use(Inputs);

0 commit comments

Comments
 (0)