@gisce/ooui 2.42.0 → 2.43.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,402 @@
1
+ import { it, expect, describe } from "vitest";
2
+ import Kanban from "../Kanban";
3
+ import Field from "../Field";
4
+ import Button from "../Button";
5
+
6
+ const XML_VIEW_KANBAN = `<?xml version="1.0"?>
7
+ <kanban string="Tasks" column_field="state" drag="1" sort="sequence" set_max_cards="1" colors="blue:state=='draft';green:state=='done'">
8
+ <field name="name"/>
9
+ <field name="user_id" widget="avatar"/>
10
+ <field name="planned_hours" sum="Total hours" widget="float_time"/>
11
+ <field name="state"/>
12
+ <button name="do_open" type="object" string="Start" states="draft"/>
13
+ <button name="do_done" type="object" string="Complete" states="open,pending"/>
14
+ </kanban>
15
+ `;
16
+
17
+ const XML_VIEW_KANBAN_MINIMAL = `<?xml version="1.0"?>
18
+ <kanban column_field="status">
19
+ <field name="name"/>
20
+ </kanban>
21
+ `;
22
+
23
+ const XML_VIEW_KANBAN_NO_DRAG = `<?xml version="1.0"?>
24
+ <kanban column_field="state" drag="0">
25
+ <field name="name"/>
26
+ <field name="priority"/>
27
+ </kanban>
28
+ `;
29
+
30
+ const FIELDS = {
31
+ name: {
32
+ required: true,
33
+ size: 128,
34
+ string: "Task Summary",
35
+ type: "char",
36
+ views: {},
37
+ },
38
+ user_id: {
39
+ context: "",
40
+ domain: [],
41
+ relation: "res.users",
42
+ size: 64,
43
+ string: "Assigned to",
44
+ type: "many2one",
45
+ views: {},
46
+ widget: "avatar",
47
+ },
48
+ planned_hours: {
49
+ help: "Estimated time to complete the task",
50
+ string: "Planned Hours",
51
+ type: "float",
52
+ views: {},
53
+ widget: "float_time",
54
+ },
55
+ state: {
56
+ required: true,
57
+ readonly: true,
58
+ selection: [
59
+ ["draft", "Draft"],
60
+ ["open", "In Progress"],
61
+ ["pending", "Pending"],
62
+ ["cancelled", "Cancelled"],
63
+ ["done", "Done"],
64
+ ],
65
+ string: "State",
66
+ type: "selection",
67
+ views: {},
68
+ },
69
+ status: {
70
+ selection: [
71
+ ["new", "New"],
72
+ ["active", "Active"],
73
+ ["completed", "Completed"],
74
+ ],
75
+ string: "Status",
76
+ type: "selection",
77
+ views: {},
78
+ },
79
+ priority: {
80
+ selection: [
81
+ ["0", "Low"],
82
+ ["1", "Normal"],
83
+ ["2", "High"],
84
+ ],
85
+ string: "Priority",
86
+ type: "selection",
87
+ views: {},
88
+ },
89
+ };
90
+
91
+ describe("A Kanban", () => {
92
+ it("should parse xml with all attributes", () => {
93
+ const tree = new Kanban(FIELDS);
94
+ tree.parse(XML_VIEW_KANBAN);
95
+
96
+ expect(tree.type).toBe("kanban");
97
+ expect(tree.string).toBe("Tasks");
98
+ expect(tree.column_field).toBe("state");
99
+ expect(tree.drag).toBe(true);
100
+ expect(tree.sort).toBe("sequence");
101
+ expect(tree.set_max_cards).toBe(true);
102
+ expect(tree.colors).toBe("blue:state=='draft';green:state=='done'");
103
+ });
104
+
105
+ it("should parse minimal kanban xml", () => {
106
+ const tree = new Kanban(FIELDS);
107
+ tree.parse(XML_VIEW_KANBAN_MINIMAL);
108
+
109
+ expect(tree.type).toBe("kanban");
110
+ expect(tree.string).toBe(null);
111
+ expect(tree.column_field).toBe("status");
112
+ expect(tree.drag).toBe(true); // Default value
113
+ expect(tree.sort).toBeUndefined(); // No sort field specified
114
+ expect(tree.set_max_cards).toBe(false); // Default value
115
+ });
116
+
117
+ it("should parse drag as false", () => {
118
+ const tree = new Kanban(FIELDS);
119
+ tree.parse(XML_VIEW_KANBAN_NO_DRAG);
120
+
121
+ expect(tree.drag).toBe(false);
122
+ expect(tree.sort).toBeUndefined();
123
+ });
124
+
125
+ it("should fallback to 'state' when column_field is missing", () => {
126
+ const tree = new Kanban(FIELDS);
127
+ const xmlWithoutColumnField = `<?xml version="1.0"?>
128
+ <kanban string="Tasks">
129
+ <field name="name"/>
130
+ </kanban>
131
+ `;
132
+
133
+ tree.parse(xmlWithoutColumnField);
134
+ expect(tree.column_field).toBe("state");
135
+ });
136
+
137
+ it("should parse fields correctly", () => {
138
+ const tree = new Kanban(FIELDS);
139
+ tree.parse(XML_VIEW_KANBAN);
140
+
141
+ expect(tree.card_fields).toHaveLength(4);
142
+ expect(tree.card_fields[0].id).toBe("name");
143
+ expect(tree.card_fields[1].id).toBe("user_id");
144
+ expect(tree.card_fields[2].id).toBe("planned_hours");
145
+ expect(tree.card_fields[3].id).toBe("state");
146
+ });
147
+
148
+ it("should parse field with sum aggregation", () => {
149
+ const tree = new Kanban(FIELDS);
150
+ tree.parse(XML_VIEW_KANBAN);
151
+
152
+ const hoursField = tree.card_fields.find((f) => f.id === "planned_hours");
153
+ expect(hoursField).toBeDefined();
154
+ expect((hoursField as any).sum).toBe("Total hours");
155
+
156
+ expect(tree.aggregations).toHaveProperty("planned_hours");
157
+ expect(tree.aggregations.planned_hours).toBe("Total hours");
158
+ });
159
+
160
+ it("should preserve field widget attribute", () => {
161
+ const tree = new Kanban(FIELDS);
162
+ tree.parse(XML_VIEW_KANBAN);
163
+
164
+ const avatarField = tree.card_fields.find((f) => f.id === "user_id");
165
+ expect(avatarField).toBeDefined();
166
+ expect((avatarField as any).raw_props?.widget).toBe("avatar");
167
+
168
+ const hoursField = tree.card_fields.find((f) => f.id === "planned_hours");
169
+ expect(hoursField).toBeDefined();
170
+ expect((hoursField as any).raw_props?.widget).toBe("float_time");
171
+ });
172
+
173
+ it("should parse buttons correctly", () => {
174
+ const tree = new Kanban(FIELDS);
175
+ tree.parse(XML_VIEW_KANBAN);
176
+
177
+ expect(tree.buttons).toHaveLength(2);
178
+
179
+ const startButton = tree.buttons[0];
180
+ expect(startButton.id).toBe("do_open");
181
+ expect(startButton.buttonType).toBe("object");
182
+ expect(startButton.caption).toBe("Start");
183
+ expect((startButton as any).states).toBe("draft");
184
+
185
+ const completeButton = tree.buttons[1];
186
+ expect(completeButton.id).toBe("do_done");
187
+ expect(completeButton.buttonType).toBe("object");
188
+ expect(completeButton.caption).toBe("Complete");
189
+ expect((completeButton as any).states).toBe("open,pending");
190
+ });
191
+
192
+ it("should handle button without states attribute", () => {
193
+ const xmlWithButton = `<?xml version="1.0"?>
194
+ <kanban column_field="state">
195
+ <field name="name"/>
196
+ <button name="action_test" type="object" string="Test"/>
197
+ </kanban>
198
+ `;
199
+ const tree = new Kanban(FIELDS);
200
+ tree.parse(xmlWithButton);
201
+
202
+ expect(tree.buttons).toHaveLength(1);
203
+ const button = tree.buttons[0];
204
+ expect(button.id).toBe("action_test");
205
+ expect((button as any).states).toBeUndefined();
206
+ });
207
+
208
+ it("should merge field attributes correctly", () => {
209
+ const tree = new Kanban(FIELDS);
210
+ tree.parse(XML_VIEW_KANBAN);
211
+
212
+ const nameField = tree.card_fields.find((f) => f.id === "name");
213
+ expect(nameField).toBeDefined();
214
+ expect(nameField?.type).toBe("char");
215
+ expect((nameField as any).required).toBe(true);
216
+ expect((nameField as any).size).toBe(128);
217
+ });
218
+
219
+ it("should handle context for fields", () => {
220
+ const xmlWithContext = `<?xml version="1.0"?>
221
+ <kanban column_field="state">
222
+ <field name="user_id" context="{'show_all': True}"/>
223
+ </kanban>
224
+ `;
225
+ const tree = new Kanban(FIELDS);
226
+ tree.parse(xmlWithContext);
227
+
228
+ expect(tree.contextForFields).toHaveProperty("user_id");
229
+ expect(tree.contextForFields.user_id).toBeDefined();
230
+ });
231
+
232
+ it("should throw error for non-existent field", () => {
233
+ const xmlWithInvalidField = `<?xml version="1.0"?>
234
+ <kanban column_field="state">
235
+ <field name="nonexistent_field"/>
236
+ </kanban>
237
+ `;
238
+ const tree = new Kanban(FIELDS);
239
+
240
+ expect(() => tree.parse(xmlWithInvalidField)).toThrow(
241
+ "Field nonexistent_field doesn't exist in fields definition",
242
+ );
243
+ });
244
+
245
+ it("should find field by id", () => {
246
+ const tree = new Kanban(FIELDS);
247
+ tree.parse(XML_VIEW_KANBAN);
248
+
249
+ const nameField = tree.findById("name");
250
+ expect(nameField).toBeDefined();
251
+ expect(nameField?.id).toBe("name");
252
+ });
253
+
254
+ it("should find button by id", () => {
255
+ const tree = new Kanban(FIELDS);
256
+ tree.parse(XML_VIEW_KANBAN);
257
+
258
+ const button = tree.findById("do_open");
259
+ expect(button).toBeDefined();
260
+ expect(button?.id).toBe("do_open");
261
+ });
262
+
263
+ it("should return null for non-existent id", () => {
264
+ const tree = new Kanban(FIELDS);
265
+ tree.parse(XML_VIEW_KANBAN);
266
+
267
+ const result = tree.findById("nonexistent");
268
+ expect(result).toBeNull();
269
+ });
270
+
271
+ it("should handle empty kanban (only column_field)", () => {
272
+ const emptyXml = `<?xml version="1.0"?>
273
+ <kanban column_field="state"></kanban>
274
+ `;
275
+ const tree = new Kanban(FIELDS);
276
+ tree.parse(emptyXml);
277
+
278
+ expect(tree.card_fields).toHaveLength(0);
279
+ expect(tree.buttons).toHaveLength(0);
280
+ expect(tree.column_field).toBe("state");
281
+ });
282
+
283
+ it("should handle HTML entities in string attribute", () => {
284
+ const xmlWithEntities = `<?xml version="1.0"?>
285
+ <kanban string="Tasks &amp; Projects" column_field="state">
286
+ <field name="name"/>
287
+ </kanban>
288
+ `;
289
+ const tree = new Kanban(FIELDS);
290
+ tree.parse(xmlWithEntities);
291
+
292
+ expect(tree.string).toBe("Tasks & Projects");
293
+ });
294
+
295
+ it("should handle HTML entities in colors attribute", () => {
296
+ const xmlWithEntities = `<?xml version="1.0"?>
297
+ <kanban column_field="state" colors="red:priority&gt;1;blue:priority&lt;1">
298
+ <field name="name"/>
299
+ <field name="priority"/>
300
+ </kanban>
301
+ `;
302
+ const tree = new Kanban(FIELDS);
303
+ tree.parse(xmlWithEntities);
304
+
305
+ expect(tree.colors).toBe("red:priority>1;blue:priority<1");
306
+ });
307
+
308
+ it("should skip invisible fields", () => {
309
+ const xmlWithInvisible = `<?xml version="1.0"?>
310
+ <kanban column_field="state">
311
+ <field name="name"/>
312
+ <field name="priority" invisible="1"/>
313
+ </kanban>
314
+ `;
315
+ const tree = new Kanban(FIELDS);
316
+ tree.parse(xmlWithInvisible);
317
+
318
+ expect(tree.card_fields).toHaveLength(1);
319
+ expect(tree.card_fields[0].id).toBe("name");
320
+ });
321
+
322
+ it("should handle buttons without name (skip them)", () => {
323
+ const xmlWithUnnamedButton = `<?xml version="1.0"?>
324
+ <kanban column_field="state">
325
+ <field name="name"/>
326
+ <button type="object" string="Test"/>
327
+ </kanban>
328
+ `;
329
+ const tree = new Kanban(FIELDS);
330
+ tree.parse(xmlWithUnnamedButton);
331
+
332
+ expect(tree.buttons).toHaveLength(0);
333
+ });
334
+
335
+ it("should handle fields without name (skip them)", () => {
336
+ const xmlWithUnnamedField = `<?xml version="1.0"?>
337
+ <kanban column_field="state">
338
+ <field name="name"/>
339
+ <field widget="custom"/>
340
+ </kanban>
341
+ `;
342
+ const tree = new Kanban(FIELDS);
343
+ tree.parse(xmlWithUnnamedField);
344
+
345
+ expect(tree.card_fields).toHaveLength(1);
346
+ expect(tree.card_fields[0].id).toBe("name");
347
+ });
348
+
349
+ it("should store multiple aggregations", () => {
350
+ const xmlWithMultipleAggs = `<?xml version="1.0"?>
351
+ <kanban column_field="state">
352
+ <field name="planned_hours" sum="Total Planned"/>
353
+ <field name="priority" sum="Total Priority"/>
354
+ </kanban>
355
+ `;
356
+ const tree = new Kanban(FIELDS);
357
+ tree.parse(xmlWithMultipleAggs);
358
+
359
+ expect(Object.keys(tree.aggregations)).toHaveLength(2);
360
+ expect(tree.aggregations.planned_hours).toBe("Total Planned");
361
+ expect(tree.aggregations.priority).toBe("Total Priority");
362
+ });
363
+
364
+ it("should parse on_change_column attribute with simple method name", () => {
365
+ const xmlWithOnChange = `<?xml version="1.0"?>
366
+ <kanban column_field="state" on_change_column="handle_state_change">
367
+ <field name="name"/>
368
+ </kanban>
369
+ `;
370
+ const tree = new Kanban(FIELDS);
371
+ tree.parse(xmlWithOnChange);
372
+
373
+ expect(tree.on_change_column).toBeDefined();
374
+ expect(tree.on_change_column?.method).toBe("handle_state_change");
375
+ expect(tree.on_change_column?.args).toHaveLength(0);
376
+ });
377
+
378
+ it("should parse on_change_column attribute with method and arguments", () => {
379
+ const xmlWithOnChangeArgs = `<?xml version="1.0"?>
380
+ <kanban column_field="state" on_change_column="handle_column_change(field, from_column, to_column, context)">
381
+ <field name="name"/>
382
+ </kanban>
383
+ `;
384
+ const tree = new Kanban(FIELDS);
385
+ tree.parse(xmlWithOnChangeArgs);
386
+
387
+ expect(tree.on_change_column).toBeDefined();
388
+ expect(tree.on_change_column?.method).toBe("handle_column_change");
389
+ expect(tree.on_change_column?.args).toHaveLength(4);
390
+ expect(tree.on_change_column?.args[0]).toBe("field");
391
+ expect(tree.on_change_column?.args[1]).toBe("from_column");
392
+ expect(tree.on_change_column?.args[2]).toBe("to_column");
393
+ expect(tree.on_change_column?.args[3]).toBe("context");
394
+ });
395
+
396
+ it("should return null for on_change_column when not defined", () => {
397
+ const tree = new Kanban(FIELDS);
398
+ tree.parse(XML_VIEW_KANBAN_MINIMAL);
399
+
400
+ expect(tree.on_change_column).toBeNull();
401
+ });
402
+ });
@@ -0,0 +1,217 @@
1
+ import { it, expect, describe } from "vitest";
2
+ import Kanban from "../Kanban";
3
+ import KanbanCard from "../KanbanCard";
4
+ import Button from "../Button";
5
+ import Field from "../Field";
6
+
7
+ const XML_VIEW_KANBAN = `<?xml version="1.0"?>
8
+ <kanban string="Tasks" column_field="state" drag="1">
9
+ <field name="name"/>
10
+ <field name="user_id" widget="avatar"/>
11
+ <field name="state"/>
12
+ <button name="do_open" type="object" string="Start" states="draft"/>
13
+ <button name="do_done" type="object" string="Complete" states="open,pending"/>
14
+ <button name="action_always" type="object" string="Always Visible"/>
15
+ </kanban>
16
+ `;
17
+
18
+ const FIELDS = {
19
+ name: {
20
+ required: true,
21
+ size: 128,
22
+ string: "Task Summary",
23
+ type: "char",
24
+ views: {},
25
+ },
26
+ user_id: {
27
+ context: "",
28
+ domain: [],
29
+ relation: "res.users",
30
+ size: 64,
31
+ string: "Assigned to",
32
+ type: "many2one",
33
+ views: {},
34
+ widget: "avatar",
35
+ },
36
+ state: {
37
+ required: true,
38
+ readonly: true,
39
+ selection: [
40
+ ["draft", "Draft"],
41
+ ["open", "In Progress"],
42
+ ["pending", "Pending"],
43
+ ["cancelled", "Cancelled"],
44
+ ["done", "Done"],
45
+ ],
46
+ string: "State",
47
+ type: "selection",
48
+ views: {},
49
+ },
50
+ };
51
+
52
+ describe("A KanbanCard", () => {
53
+ it("should parse and create a Container", () => {
54
+ const kanbanDef = new Kanban(FIELDS);
55
+ kanbanDef.parse(XML_VIEW_KANBAN);
56
+
57
+ const record = {
58
+ id: 1,
59
+ name: "Test Task",
60
+ user_id: [1, "John Doe"],
61
+ state: "draft",
62
+ };
63
+
64
+ const container = new KanbanCard(kanbanDef).parse(record);
65
+
66
+ expect(container).toBeDefined();
67
+ expect(container.rows).toBeDefined();
68
+ expect(container.rows.length).toBeGreaterThan(0);
69
+ });
70
+
71
+ it("should show button when record state matches button states", () => {
72
+ const kanbanDef = new Kanban(FIELDS);
73
+ kanbanDef.parse(XML_VIEW_KANBAN);
74
+
75
+ const record = {
76
+ id: 1,
77
+ name: "Test Task",
78
+ state: "draft", // Matches states="draft" in do_open button
79
+ };
80
+
81
+ const container = new KanbanCard(kanbanDef).parse(record);
82
+
83
+ // Find all buttons in the container
84
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
85
+
86
+ // Find the "do_open" button
87
+ const doOpenButton = buttons.find((b) => b.id === "do_open");
88
+ expect(doOpenButton).toBeDefined();
89
+ expect(doOpenButton!.invisible).toBe(false);
90
+ });
91
+
92
+ it("should hide button when record state doesn't match button states", () => {
93
+ const kanbanDef = new Kanban(FIELDS);
94
+ kanbanDef.parse(XML_VIEW_KANBAN);
95
+
96
+ const record = {
97
+ id: 1,
98
+ name: "Test Task",
99
+ state: "open", // Does NOT match states="draft" in do_open button
100
+ };
101
+
102
+ const container = new KanbanCard(kanbanDef).parse(record);
103
+
104
+ // Find all buttons in the container
105
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
106
+
107
+ // Find the "do_open" button
108
+ const doOpenButton = buttons.find((b) => b.id === "do_open");
109
+ expect(doOpenButton).toBeDefined();
110
+ expect(doOpenButton!.invisible).toBe(true);
111
+ });
112
+
113
+ it("should always show button without states attribute", () => {
114
+ const kanbanDef = new Kanban(FIELDS);
115
+ kanbanDef.parse(XML_VIEW_KANBAN);
116
+
117
+ const record = {
118
+ id: 1,
119
+ name: "Test Task",
120
+ state: "done", // Any state
121
+ };
122
+
123
+ const container = new KanbanCard(kanbanDef).parse(record);
124
+
125
+ // Find all buttons in the container
126
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
127
+
128
+ // Find the "action_always" button (has no states attribute)
129
+ const alwaysButton = buttons.find((b) => b.id === "action_always");
130
+ expect(alwaysButton).toBeDefined();
131
+ expect(alwaysButton!.invisible).toBe(false);
132
+ });
133
+
134
+ it("should show button when state matches one of multiple comma-separated states", () => {
135
+ const kanbanDef = new Kanban(FIELDS);
136
+ kanbanDef.parse(XML_VIEW_KANBAN);
137
+
138
+ const record = {
139
+ id: 1,
140
+ name: "Test Task",
141
+ state: "pending", // Matches one of states="open,pending" in do_done button
142
+ };
143
+
144
+ const container = new KanbanCard(kanbanDef).parse(record);
145
+
146
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
147
+ const doDoneButton = buttons.find((b) => b.id === "do_done");
148
+ expect(doDoneButton).toBeDefined();
149
+ expect(doDoneButton!.invisible).toBe(false);
150
+ });
151
+
152
+ it("should add fields to container", () => {
153
+ const kanbanDef = new Kanban(FIELDS);
154
+ kanbanDef.parse(XML_VIEW_KANBAN);
155
+
156
+ const record = {
157
+ id: 1,
158
+ name: "Test Task",
159
+ user_id: [1, "John Doe"],
160
+ state: "draft",
161
+ };
162
+
163
+ const container = new KanbanCard(kanbanDef).parse(record);
164
+
165
+ // Find all fields in the container
166
+ const fields = container.rows.flat().filter((w) => w instanceof Field);
167
+
168
+ expect(fields.length).toBeGreaterThan(0);
169
+
170
+ // Check that the name field exists
171
+ const nameField = fields.find((f) => f.id === "name");
172
+ expect(nameField).toBeDefined();
173
+ });
174
+
175
+ it("should respect readOnly option", () => {
176
+ const kanbanDef = new Kanban(FIELDS);
177
+ kanbanDef.parse(XML_VIEW_KANBAN);
178
+
179
+ const record = {
180
+ id: 1,
181
+ name: "Test Task",
182
+ state: "draft",
183
+ };
184
+
185
+ const container = new KanbanCard(kanbanDef).parse(record, {
186
+ readOnly: true,
187
+ });
188
+
189
+ expect(container.readOnly).toBe(true);
190
+
191
+ // Check that buttons inherit readOnly
192
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
193
+ buttons.forEach((button) => {
194
+ expect(button.readOnly).toBe(true);
195
+ });
196
+ });
197
+
198
+ it("should handle record without state field gracefully", () => {
199
+ const kanbanDef = new Kanban(FIELDS);
200
+ kanbanDef.parse(XML_VIEW_KANBAN);
201
+
202
+ const record = {
203
+ id: 1,
204
+ name: "Test Task",
205
+ // state field is missing
206
+ };
207
+
208
+ const container = new KanbanCard(kanbanDef).parse(record);
209
+
210
+ // Should not throw error and should create container
211
+ expect(container).toBeDefined();
212
+
213
+ // Buttons should handle undefined state gracefully
214
+ const buttons = container.rows.flat().filter((w) => w instanceof Button);
215
+ expect(buttons.length).toBeGreaterThan(0);
216
+ });
217
+ });