@gisce/ooui 2.40.0-alpha.6 → 2.40.0-alpha.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gisce/ooui",
3
- "version": "2.40.0-alpha.6",
3
+ "version": "2.40.0-alpha.8",
4
4
  "engines": {
5
5
  "node": "20.5.0"
6
6
  },
package/src/Kanban.ts CHANGED
@@ -5,6 +5,7 @@ import { replaceEntities } from "./helpers/attributeParser";
5
5
  import { parseBoolAttribute, ParsedNode } from "./helpers/nodeParser";
6
6
  import * as txml from "txml";
7
7
  import { parseContext } from "./helpers/contextParser";
8
+ import { parseOnChange } from "./helpers/onChangeParser";
8
9
 
9
10
  export type KanbanField = Widget & {
10
11
  sum?: string; // Aggregation label (e.g., "Total hours")
@@ -106,6 +107,16 @@ class Kanban {
106
107
  return this._colors;
107
108
  }
108
109
 
110
+ /**
111
+ * Custom function to call when a card moves between columns
112
+ * Example: "handle_state_change" or with args "handle_state_change(field, from, to)"
113
+ * If not defined, the frontend should use the default on_change_column method
114
+ */
115
+ _on_change_column: { method: string; args: string[] } | null = null;
116
+ get on_change_column(): { method: string; args: string[] } | null {
117
+ return this._on_change_column;
118
+ }
119
+
109
120
  /**
110
121
  * Context for each field in the kanban
111
122
  */
@@ -165,6 +176,11 @@ class Kanban {
165
176
  this._status = replaceEntities(this._status);
166
177
  }
167
178
 
179
+ // Parse on_change_column attribute
180
+ if (view.attributes.on_change_column) {
181
+ this._on_change_column = parseOnChange(view.attributes.on_change_column);
182
+ }
183
+
168
184
  const widgetFactory = new WidgetFactory();
169
185
 
170
186
  // Parse children (fields and buttons)
@@ -0,0 +1,84 @@
1
+ import WidgetFactory from "./WidgetFactory";
2
+ import Container from "./Container";
3
+ import Kanban from "./Kanban";
4
+ import { evaluateButtonStates } from "./helpers/stateParser";
5
+ import { evaluateAttributes } from "./helpers/attributeParser";
6
+ import Button from "./Button";
7
+
8
+ export type KanbanCardParseOptions = {
9
+ readOnly?: boolean;
10
+ };
11
+
12
+ class KanbanCard {
13
+ _kanbanDef: Kanban;
14
+ get kanbanDef(): Kanban {
15
+ return this._kanbanDef;
16
+ }
17
+
18
+ constructor(kanbanDef: Kanban) {
19
+ this._kanbanDef = kanbanDef;
20
+ }
21
+
22
+ parse(record: any, options?: KanbanCardParseOptions): Container {
23
+ const readOnly = options?.readOnly ?? false;
24
+ const container = new Container(1, 12, readOnly);
25
+ const widgetFactory = new WidgetFactory();
26
+ let keyIdx = 0;
27
+
28
+ this._kanbanDef.card_fields.forEach((field: any) => {
29
+ keyIdx++;
30
+
31
+ const evaluatedAttrs = evaluateAttributes({
32
+ tagAttributes: field.raw_props || {},
33
+ values: record,
34
+ fields: this._kanbanDef.fields,
35
+ widgetType: field.type || "field",
36
+ });
37
+
38
+ // Extract properties from the existing field widget
39
+ const widgetProps = {
40
+ ...(field.raw_props || {}),
41
+ ...evaluatedAttrs,
42
+ key: `field_${keyIdx}`,
43
+ };
44
+
45
+ if (readOnly) {
46
+ widgetProps.readonly = true;
47
+ }
48
+
49
+ // Determine widget type from the field
50
+ const widgetType = field.type || "field";
51
+ const widget = widgetFactory.createWidget(widgetType, widgetProps);
52
+ container.addWidget(widget, { addLabel: !field.nolabel });
53
+ });
54
+
55
+ // Add buttons to container
56
+ this._kanbanDef.buttons.forEach((button: any) => {
57
+ keyIdx++;
58
+
59
+ const evaluatedStates = button.states
60
+ ? evaluateButtonStates({
61
+ states: button.states,
62
+ values: record,
63
+ })
64
+ : {};
65
+
66
+ const buttonProps = {
67
+ ...(button.raw_props || {}),
68
+ ...evaluatedStates,
69
+ key: `button_${keyIdx}`,
70
+ };
71
+
72
+ if (readOnly) {
73
+ buttonProps.readonly = true;
74
+ }
75
+
76
+ const buttonWidget = new Button(buttonProps);
77
+ container.addWidget(buttonWidget, { addLabel: false });
78
+ });
79
+
80
+ return container;
81
+ }
82
+ }
83
+
84
+ export default KanbanCard;
@@ -3,7 +3,14 @@ const parseOnChange = (onChangeString: string) => {
3
3
 
4
4
  const method = splitted[0];
5
5
  const argsGross = splitted[1];
6
- const argsSplitted = argsGross.split(",").map((arg) => arg.trim());
6
+
7
+ // Handle case where there are no parentheses (no arguments)
8
+ const argsSplitted = argsGross
9
+ ? argsGross
10
+ .split(",")
11
+ .map((arg) => arg.trim())
12
+ .filter((arg) => arg.length > 0)
13
+ : [];
7
14
 
8
15
  return {
9
16
  method,
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import Avatar from "./Avatar";
2
2
  import Form from "./Form";
3
3
  import Tree from "./Tree";
4
4
  import Kanban from "./Kanban";
5
+ import KanbanCard from "./KanbanCard";
5
6
  import Char from "./Char";
6
7
  import Container from "./Container";
7
8
  import ContainerWidget from "./ContainerWidget";
@@ -89,6 +90,7 @@ export {
89
90
  Form,
90
91
  Tree,
91
92
  Kanban,
93
+ KanbanCard,
92
94
  NewLine,
93
95
  Boolean,
94
96
  One2many,
@@ -360,4 +360,43 @@ describe("A Kanban", () => {
360
360
  expect(tree.aggregations.planned_hours).toBe("Total Planned");
361
361
  expect(tree.aggregations.priority).toBe("Total Priority");
362
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
+ });
363
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
+ });