Skip to content

Commit 92060a6

Browse files
feat: New ModelForm component
1 parent 54ab836 commit 92060a6

5 files changed

Lines changed: 477 additions & 0 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
$fields = [
4+
'headline' => [
5+
'label' => 'Headline',
6+
'type' => 'text'
7+
],
8+
'text' => [
9+
'buttons' => ['bold', 'italic', 'link'],
10+
'label' => 'Text',
11+
'size' => 'large',
12+
'type' => 'textarea'
13+
]
14+
];
15+
16+
$sidebar = [
17+
'template' => [
18+
'label' => 'Template',
19+
'options' => [
20+
['text' => 'Default', 'value' => 'default'],
21+
['text' => 'Gallery', 'value' => 'gallery']
22+
],
23+
'type' => 'select'
24+
],
25+
'featured' => [
26+
'label' => 'Featured',
27+
'text' => 'Show on the home page',
28+
'type' => 'toggle'
29+
]
30+
];
31+
32+
return [
33+
'docs' => 'k-model-form',
34+
'api' => 'pages/photography',
35+
'columns' => [
36+
[
37+
'fields' => $fields,
38+
'width' => '2/3'
39+
],
40+
[
41+
'fields' => $sidebar,
42+
'sticky' => true,
43+
'width' => '1/3'
44+
]
45+
],
46+
'content' => [
47+
'featured' => true,
48+
'headline' => 'Photography',
49+
'template' => 'default',
50+
'text' => 'A collection of my favourite shots.'
51+
],
52+
// the diff only marks which fields have unsaved changes,
53+
// the values in it are never rendered
54+
'diff' => [
55+
'headline' => 'Photography'
56+
],
57+
'lock' => [
58+
'email' => 'editor@getkirby.com',
59+
'modified' => '2026-08-14T17:00:00',
60+
'state' => 'lock'
61+
]
62+
];
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<template>
2+
<k-lab-examples>
3+
<k-lab-example label="Default">
4+
<k-model-form
5+
:api="api"
6+
:columns="columns"
7+
:content="values.default"
8+
@input="values.default = $event"
9+
@submit="log('submit')"
10+
/>
11+
</k-lab-example>
12+
13+
<k-lab-example label="Single column">
14+
<k-model-form
15+
:api="api"
16+
:columns="singleColumn"
17+
:content="values.single"
18+
@input="values.single = $event"
19+
/>
20+
</k-lab-example>
21+
22+
<k-lab-example label="Unsaved changes">
23+
<k-model-form
24+
:api="api"
25+
:columns="columns"
26+
:content="values.diff"
27+
:diff="diff"
28+
@input="values.diff = $event"
29+
/>
30+
</k-lab-example>
31+
32+
<k-lab-example label="Locked">
33+
<k-model-form
34+
:api="api"
35+
:columns="columns"
36+
:content="values.locked"
37+
:lock="lock"
38+
/>
39+
</k-lab-example>
40+
41+
<k-lab-example label="Empty">
42+
<k-model-form
43+
:api="api"
44+
:columns="{}"
45+
empty="This page does not have any fields yet."
46+
/>
47+
</k-lab-example>
48+
49+
<k-lab-example :code="false" label="Playground">
50+
<k-stack gap="var(--spacing-12)">
51+
<k-grid
52+
style="
53+
--columns: 2;
54+
--grid-inline-gap: var(--spacing-1);
55+
--grid-block-gap: var(--spacing-1);
56+
"
57+
>
58+
<k-toggle-field
59+
:value="isLocked"
60+
text="lock"
61+
@input="isLocked = $event"
62+
/>
63+
<k-toggle-field
64+
:value="hasDiff"
65+
text="diff"
66+
@input="hasDiff = $event"
67+
/>
68+
</k-grid>
69+
70+
<k-model-form
71+
:api="api"
72+
:columns="columns"
73+
:content="values.playground"
74+
:diff="hasDiff ? diff : {}"
75+
:lock="isLocked ? lock : false"
76+
@input="values.playground = $event"
77+
@submit="log('submit')"
78+
/>
79+
80+
<k-code>{{ values.playground }}</k-code>
81+
</k-stack>
82+
</k-lab-example>
83+
</k-lab-examples>
84+
</template>
85+
86+
<script>
87+
export default {
88+
props: {
89+
api: String,
90+
columns: Array,
91+
content: Object,
92+
diff: Object,
93+
lock: Object
94+
},
95+
data() {
96+
return {
97+
hasDiff: true,
98+
isLocked: false,
99+
// every example gets its own copy of the content,
100+
// otherwise they would all share the same object
101+
values: {
102+
default: { ...this.content },
103+
diff: { ...this.content },
104+
locked: { ...this.content },
105+
playground: { ...this.content },
106+
single: { ...this.content }
107+
}
108+
};
109+
},
110+
computed: {
111+
singleColumn() {
112+
return [
113+
{
114+
fields: this.columns.reduce(
115+
(fields, column) => ({ ...fields, ...column.fields }),
116+
{}
117+
),
118+
width: "1/1"
119+
}
120+
];
121+
}
122+
},
123+
methods: {
124+
log(event) {
125+
alert(event);
126+
}
127+
}
128+
};
129+
</script>
130+
131+
<style>
132+
.k-lab-example .k-model-form + .k-code,
133+
.k-lab-example .k-grid + .k-model-form {
134+
margin-top: var(--spacing-6);
135+
}
136+
</style>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, it } from "@test/unit";
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+
type Field = Record<string, unknown>;
9+
type Fields = Record<string, Field>;
10+
type ResolvedField = Field & {
11+
endpoints: Record<string, string>;
12+
hasDiff: boolean;
13+
};
14+
type ResolvedFields = Record<string, ResolvedField>;
15+
type Column = { fields?: Fields; sticky?: boolean; width?: string };
16+
type ResolvedColumn = Column & { fields: ResolvedFields };
17+
18+
interface Context {
19+
api?: string;
20+
columns?: Record<string, Column>;
21+
diff?: Record<string, unknown>;
22+
fieldsWithAdditionalData: (fields: Fields) => ResolvedFields;
23+
}
24+
25+
/**
26+
* Builds a mocked component context
27+
*/
28+
function context(props: Partial<Context> = {}): Context {
29+
const ctx = {
30+
api: "pages/test",
31+
columns: {},
32+
diff: {},
33+
...props
34+
} as Context;
35+
36+
ctx.fieldsWithAdditionalData = fieldsWithAdditionalData.bind(
37+
ctx
38+
) as Context["fieldsWithAdditionalData"];
39+
40+
return ctx;
41+
}
42+
43+
describe("ModelForm.vue", () => {
44+
const columns = {
45+
0: { width: "1/2", fields: { headline: { type: "text" } } },
46+
1: { width: "1/2", sticky: true, fields: { text: { type: "textarea" } } }
47+
};
48+
49+
it("renders a column for each column of the tab", () => {
50+
const wrapper = mount(ModelForm, {
51+
props: { api: "pages/test", columns, content: { headline: "Test" } }
52+
});
53+
54+
const rendered = wrapper.findAll("k-column");
55+
56+
expect(wrapper.find("form.k-model-form").exists()).toBe(true);
57+
expect(rendered.length).toBe(2);
58+
expect(rendered[0].attributes("width")).toBe("1/2");
59+
expect(rendered[1].attributes("sticky")).toBe("true");
60+
expect(wrapper.findAll("k-fieldset").length).toBe(2);
61+
});
62+
63+
it("renders the empty state instead of the form", () => {
64+
const wrapper = mount(ModelForm, {
65+
props: { columns: {}, empty: "No blueprint" }
66+
});
67+
68+
expect(wrapper.find("k-box").exists()).toBe(true);
69+
expect(wrapper.find("form").exists()).toBe(false);
70+
});
71+
72+
it("passes on the input of a fieldset", async () => {
73+
const wrapper = mount(ModelForm, {
74+
props: { api: "pages/test", columns }
75+
});
76+
77+
await wrapper.find("k-fieldset").trigger("input");
78+
79+
expect(wrapper.emitted("input")).toHaveLength(1);
80+
});
81+
82+
it("submits the form and the fieldsets", async () => {
83+
const wrapper = mount(ModelForm, {
84+
props: { api: "pages/test", columns }
85+
});
86+
87+
await wrapper.find("form").trigger("submit");
88+
expect(wrapper.emitted("submit")).toHaveLength(1);
89+
90+
// the submit of a fieldset bubbles up to the form as well
91+
await wrapper.find("k-fieldset").trigger("submit");
92+
expect(wrapper.emitted("submit")).toHaveLength(3);
93+
});
94+
});
95+
96+
describe("ModelForm.fieldsWithAdditionalData()", () => {
97+
it("points regular fields at the field endpoint", () => {
98+
const ctx = context();
99+
const fields = ctx.fieldsWithAdditionalData({
100+
headline: { type: "text" }
101+
});
102+
103+
expect(fields.headline.endpoints).toStrictEqual({
104+
model: "pages/test",
105+
field: "pages/test/fields/headline"
106+
});
107+
});
108+
109+
it("points section fields at the section endpoint", () => {
110+
const ctx = context();
111+
const fields = ctx.fieldsWithAdditionalData({
112+
mysection: { type: "section" }
113+
});
114+
115+
expect(fields.mysection.endpoints).toStrictEqual({
116+
model: "pages/test",
117+
section: "pages/test/sections/mysection"
118+
});
119+
});
120+
121+
it("flags fields with unsaved changes", () => {
122+
const ctx = context({ diff: { headline: "changed" } });
123+
const fields = ctx.fieldsWithAdditionalData({
124+
headline: { type: "text" },
125+
text: { type: "textarea" }
126+
});
127+
128+
expect(fields.headline.hasDiff).toBe(true);
129+
expect(fields.text.hasDiff).toBe(false);
130+
});
131+
132+
it("survives a missing diff", () => {
133+
const ctx = context({ diff: undefined });
134+
const fields = ctx.fieldsWithAdditionalData({
135+
headline: { type: "text" }
136+
});
137+
138+
expect(fields.headline.hasDiff).toBe(false);
139+
});
140+
});
141+
142+
describe("ModelForm.resolvedColumns()", () => {
143+
it("keeps the column props and resolves its fields", () => {
144+
const ctx = context({
145+
columns: {
146+
0: { width: "2/3", fields: { headline: { type: "text" } } }
147+
}
148+
});
149+
150+
const columns = resolvedColumns.call(ctx) as Record<string, ResolvedColumn>;
151+
152+
expect(columns[0].width).toBe("2/3");
153+
expect(columns[0].fields.headline.endpoints.field).toBe(
154+
"pages/test/fields/headline"
155+
);
156+
});
157+
});
158+
159+
describe("ModelForm.disabled()", () => {
160+
it("is disabled while the model is locked", () => {
161+
expect(disabled.call({ lock: { state: "lock" } })).toBe(true);
162+
expect(disabled.call({ lock: { state: "unlock" } })).toBe(false);
163+
expect(disabled.call({ lock: false })).toBe(false);
164+
});
165+
});
166+
167+
describe("ModelForm.isEmpty()", () => {
168+
it("only reports empty when there are no columns and a text to show", () => {
169+
expect(isEmpty.call({ columns: {}, empty: "No blueprint" })).toBe(
170+
"No blueprint"
171+
);
172+
expect(isEmpty.call({ columns: {}, empty: null })).toBeFalsy();
173+
expect(isEmpty.call({ columns: { 0: {} }, empty: "No blueprint" })).toBe(
174+
false
175+
);
176+
});
177+
});

0 commit comments

Comments
 (0)