@dxos/plugin-kanban 0.8.4-main.ae835ea → 0.8.4-main.bcb3aa67d6

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.
Files changed (219) hide show
  1. package/dist/lib/browser/blueprints/index.mjs +27 -0
  2. package/dist/lib/browser/blueprints/index.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-A3PBV3S5.mjs +105 -0
  4. package/dist/lib/browser/chunk-A3PBV3S5.mjs.map +7 -0
  5. package/dist/lib/browser/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +7 -0
  7. package/dist/lib/browser/delete-card-VPNVIWOA.mjs +32 -0
  8. package/dist/lib/browser/delete-card-VPNVIWOA.mjs.map +7 -0
  9. package/dist/lib/browser/delete-card-field-4HHF2GYX.mjs +50 -0
  10. package/dist/lib/browser/delete-card-field-4HHF2GYX.mjs.map +7 -0
  11. package/dist/lib/browser/index.mjs +101 -85
  12. package/dist/lib/browser/index.mjs.map +4 -4
  13. package/dist/lib/browser/meta.json +1 -1
  14. package/dist/lib/browser/operations/index.mjs +13 -0
  15. package/dist/lib/browser/operations/index.mjs.map +7 -0
  16. package/dist/lib/browser/restore-card-4GG2RYKR.mjs +29 -0
  17. package/dist/lib/browser/restore-card-4GG2RYKR.mjs.map +7 -0
  18. package/dist/lib/browser/restore-card-field-3T26ACYX.mjs +48 -0
  19. package/dist/lib/browser/restore-card-field-3T26ACYX.mjs.map +7 -0
  20. package/dist/lib/browser/types/index.mjs +95 -6
  21. package/dist/lib/browser/types/index.mjs.map +4 -4
  22. package/dist/lib/node-esm/blueprints/index.mjs +28 -0
  23. package/dist/lib/node-esm/blueprints/index.mjs.map +7 -0
  24. package/dist/lib/node-esm/chunk-6LELYA2G.mjs +106 -0
  25. package/dist/lib/node-esm/chunk-6LELYA2G.mjs.map +7 -0
  26. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +11 -0
  27. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +7 -0
  28. package/dist/lib/node-esm/delete-card-5PW5OMFN.mjs +33 -0
  29. package/dist/lib/node-esm/delete-card-5PW5OMFN.mjs.map +7 -0
  30. package/dist/lib/node-esm/delete-card-field-KPJU2AQ3.mjs +51 -0
  31. package/dist/lib/node-esm/delete-card-field-KPJU2AQ3.mjs.map +7 -0
  32. package/dist/lib/node-esm/index.mjs +101 -85
  33. package/dist/lib/node-esm/index.mjs.map +4 -4
  34. package/dist/lib/node-esm/meta.json +1 -1
  35. package/dist/lib/node-esm/operations/index.mjs +14 -0
  36. package/dist/lib/node-esm/operations/index.mjs.map +7 -0
  37. package/dist/lib/node-esm/restore-card-X2TKMU5A.mjs +30 -0
  38. package/dist/lib/node-esm/restore-card-X2TKMU5A.mjs.map +7 -0
  39. package/dist/lib/node-esm/restore-card-field-IUTL4RTR.mjs +49 -0
  40. package/dist/lib/node-esm/restore-card-field-IUTL4RTR.mjs.map +7 -0
  41. package/dist/lib/node-esm/types/index.mjs +95 -6
  42. package/dist/lib/node-esm/types/index.mjs.map +4 -4
  43. package/dist/types/src/KanbanPlugin.d.ts +2 -1
  44. package/dist/types/src/KanbanPlugin.d.ts.map +1 -1
  45. package/dist/types/src/blueprints/index.d.ts +2 -0
  46. package/dist/types/src/blueprints/index.d.ts.map +1 -0
  47. package/dist/types/src/blueprints/kanban-blueprint.d.ts +4 -0
  48. package/dist/types/src/blueprints/kanban-blueprint.d.ts.map +1 -0
  49. package/dist/types/src/capabilities/artifact-definition.d.ts +3 -2
  50. package/dist/types/src/capabilities/artifact-definition.d.ts.map +1 -1
  51. package/dist/types/src/capabilities/blueprint-definition.d.ts +5 -4
  52. package/dist/types/src/capabilities/blueprint-definition.d.ts.map +1 -1
  53. package/dist/types/src/capabilities/index.d.ts +6 -3
  54. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  55. package/dist/types/src/capabilities/operation-handler.d.ts +6 -0
  56. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  57. package/dist/types/src/capabilities/react-surface.d.ts +3 -2
  58. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  59. package/dist/types/src/capabilities/undo-mappings.d.ts +5 -0
  60. package/dist/types/src/capabilities/undo-mappings.d.ts.map +1 -0
  61. package/dist/types/src/components/KanbanBoard/KanbanBoard.d.ts +65 -0
  62. package/dist/types/src/components/KanbanBoard/KanbanBoard.d.ts.map +1 -0
  63. package/dist/types/src/components/KanbanBoard/KanbanBoard.stories.d.ts +72 -0
  64. package/dist/types/src/components/KanbanBoard/KanbanBoard.stories.d.ts.map +1 -0
  65. package/dist/types/src/components/KanbanBoard/KanbanCard.d.ts +10 -0
  66. package/dist/types/src/components/KanbanBoard/KanbanCard.d.ts.map +1 -0
  67. package/dist/types/src/components/KanbanBoard/KanbanColumn.d.ts +9 -0
  68. package/dist/types/src/components/KanbanBoard/KanbanColumn.d.ts.map +1 -0
  69. package/dist/types/src/components/KanbanBoard/index.d.ts +2 -0
  70. package/dist/types/src/components/KanbanBoard/index.d.ts.map +1 -0
  71. package/dist/types/src/components/index.d.ts +1 -2
  72. package/dist/types/src/components/index.d.ts.map +1 -1
  73. package/dist/types/src/containers/KanbanContainer/KanbanContainer.d.ts +6 -0
  74. package/dist/types/src/containers/KanbanContainer/KanbanContainer.d.ts.map +1 -0
  75. package/dist/types/src/containers/KanbanContainer/KanbanContainer.stories.d.ts +79 -0
  76. package/dist/types/src/containers/KanbanContainer/KanbanContainer.stories.d.ts.map +1 -0
  77. package/dist/types/src/containers/KanbanContainer/index.d.ts +3 -0
  78. package/dist/types/src/containers/KanbanContainer/index.d.ts.map +1 -0
  79. package/dist/types/src/containers/KanbanViewEditor/KanbanViewEditor.d.ts +6 -0
  80. package/dist/types/src/containers/KanbanViewEditor/KanbanViewEditor.d.ts.map +1 -0
  81. package/dist/types/src/containers/KanbanViewEditor/index.d.ts +3 -0
  82. package/dist/types/src/containers/KanbanViewEditor/index.d.ts.map +1 -0
  83. package/dist/types/src/containers/index.d.ts +4 -0
  84. package/dist/types/src/containers/index.d.ts.map +1 -0
  85. package/dist/types/src/hooks/index.d.ts +6 -0
  86. package/dist/types/src/hooks/index.d.ts.map +1 -0
  87. package/dist/types/src/hooks/useEchoChangeCallback.d.ts +13 -0
  88. package/dist/types/src/hooks/useEchoChangeCallback.d.ts.map +1 -0
  89. package/dist/types/src/hooks/useKanbanBoardModel.d.ts +16 -0
  90. package/dist/types/src/hooks/useKanbanBoardModel.d.ts.map +1 -0
  91. package/dist/types/src/hooks/useKanbanBoardModel.test.d.ts +2 -0
  92. package/dist/types/src/hooks/useKanbanBoardModel.test.d.ts.map +1 -0
  93. package/dist/types/src/hooks/useKanbanColumnEventHandler.d.ts +22 -0
  94. package/dist/types/src/hooks/useKanbanColumnEventHandler.d.ts.map +1 -0
  95. package/dist/types/src/hooks/useKanbanItemEventHandler.d.ts +19 -0
  96. package/dist/types/src/hooks/useKanbanItemEventHandler.d.ts.map +1 -0
  97. package/dist/types/src/hooks/useProjectionModel.d.ts +15 -0
  98. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  99. package/dist/types/src/meta.d.ts +2 -2
  100. package/dist/types/src/meta.d.ts.map +1 -1
  101. package/dist/types/src/operations/definitions.d.ts +52 -0
  102. package/dist/types/src/operations/definitions.d.ts.map +1 -0
  103. package/dist/types/src/operations/delete-card-field.d.ts +5 -0
  104. package/dist/types/src/operations/delete-card-field.d.ts.map +1 -0
  105. package/dist/types/src/operations/delete-card.d.ts +5 -0
  106. package/dist/types/src/operations/delete-card.d.ts.map +1 -0
  107. package/dist/types/src/operations/index.d.ts +4 -0
  108. package/dist/types/src/operations/index.d.ts.map +1 -0
  109. package/dist/types/src/operations/restore-card-field.d.ts +5 -0
  110. package/dist/types/src/operations/restore-card-field.d.ts.map +1 -0
  111. package/dist/types/src/operations/restore-card.d.ts +5 -0
  112. package/dist/types/src/operations/restore-card.d.ts.map +1 -0
  113. package/dist/types/src/playwright/board-manager.d.ts +5 -0
  114. package/dist/types/src/playwright/board-manager.d.ts.map +1 -0
  115. package/dist/types/src/playwright/playwright.config.d.ts +3 -0
  116. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -0
  117. package/dist/types/src/playwright/smoke.spec.d.ts +2 -0
  118. package/dist/types/src/playwright/smoke.spec.d.ts.map +1 -0
  119. package/dist/types/src/testing/KanbanCardTileSimple.d.ts +7 -0
  120. package/dist/types/src/testing/KanbanCardTileSimple.d.ts.map +1 -0
  121. package/dist/types/src/testing/index.d.ts +2 -0
  122. package/dist/types/src/testing/index.d.ts.map +1 -0
  123. package/dist/types/src/translations.d.ts +50 -22
  124. package/dist/types/src/translations.d.ts.map +1 -1
  125. package/dist/types/src/types/Kanban.d.ts +37 -0
  126. package/dist/types/src/types/Kanban.d.ts.map +1 -0
  127. package/dist/types/src/types/constants.d.ts +6 -0
  128. package/dist/types/src/types/constants.d.ts.map +1 -0
  129. package/dist/types/src/types/index.d.ts +2 -0
  130. package/dist/types/src/types/index.d.ts.map +1 -1
  131. package/dist/types/src/types/schema.d.ts +3 -51
  132. package/dist/types/src/types/schema.d.ts.map +1 -1
  133. package/dist/types/src/types/types.d.ts +28 -0
  134. package/dist/types/src/types/types.d.ts.map +1 -1
  135. package/dist/types/src/util/arrangement.d.ts +68 -0
  136. package/dist/types/src/util/arrangement.d.ts.map +1 -0
  137. package/dist/types/src/util/arrangement.test.d.ts +2 -0
  138. package/dist/types/src/util/arrangement.test.d.ts.map +1 -0
  139. package/dist/types/src/util/index.d.ts +2 -0
  140. package/dist/types/src/util/index.d.ts.map +1 -0
  141. package/dist/types/tsconfig.tsbuildinfo +1 -1
  142. package/package.json +84 -46
  143. package/src/KanbanPlugin.tsx +49 -56
  144. package/src/blueprints/index.ts +5 -0
  145. package/src/blueprints/kanban-blueprint.ts +28 -0
  146. package/src/capabilities/artifact-definition.ts +115 -112
  147. package/src/capabilities/blueprint-definition.ts +11 -24
  148. package/src/capabilities/index.ts +9 -4
  149. package/src/capabilities/operation-handler.ts +14 -0
  150. package/src/capabilities/react-surface.tsx +70 -68
  151. package/src/capabilities/undo-mappings.ts +34 -0
  152. package/src/components/KanbanBoard/KanbanBoard.stories.tsx +142 -0
  153. package/src/components/KanbanBoard/KanbanBoard.tsx +193 -0
  154. package/src/components/KanbanBoard/KanbanCard.tsx +86 -0
  155. package/src/components/KanbanBoard/KanbanColumn.tsx +69 -0
  156. package/src/components/KanbanBoard/index.ts +5 -0
  157. package/src/components/index.ts +1 -2
  158. package/src/containers/KanbanContainer/KanbanContainer.stories.tsx +269 -0
  159. package/src/containers/KanbanContainer/KanbanContainer.tsx +96 -0
  160. package/src/containers/KanbanContainer/index.ts +7 -0
  161. package/src/containers/KanbanViewEditor/KanbanViewEditor.tsx +63 -0
  162. package/src/containers/KanbanViewEditor/index.ts +7 -0
  163. package/src/containers/index.ts +8 -0
  164. package/src/hooks/index.ts +9 -0
  165. package/src/hooks/useEchoChangeCallback.ts +30 -0
  166. package/src/hooks/useKanbanBoardModel.test.ts +235 -0
  167. package/src/hooks/useKanbanBoardModel.ts +143 -0
  168. package/src/hooks/useKanbanColumnEventHandler.ts +106 -0
  169. package/src/hooks/useKanbanItemEventHandler.ts +133 -0
  170. package/src/hooks/useProjectionModel.ts +58 -0
  171. package/src/meta.ts +3 -3
  172. package/src/operations/definitions.ts +63 -0
  173. package/src/operations/delete-card-field.ts +47 -0
  174. package/src/operations/delete-card.ts +23 -0
  175. package/src/operations/index.ts +12 -0
  176. package/src/operations/restore-card-field.ts +41 -0
  177. package/src/operations/restore-card.ts +21 -0
  178. package/src/playwright/board-manager.ts +13 -0
  179. package/src/playwright/playwright.config.ts +19 -0
  180. package/src/playwright/smoke.spec.ts +107 -0
  181. package/src/testing/KanbanCardTileSimple.tsx +82 -0
  182. package/src/testing/index.ts +5 -0
  183. package/src/translations.ts +28 -20
  184. package/src/types/Kanban.ts +71 -0
  185. package/src/types/constants.ts +9 -0
  186. package/src/types/index.ts +2 -0
  187. package/src/types/schema.ts +14 -44
  188. package/src/types/types.ts +35 -0
  189. package/src/util/arrangement.test.ts +208 -0
  190. package/src/util/arrangement.ts +167 -0
  191. package/src/util/index.ts +5 -0
  192. package/dist/lib/browser/blueprint-definition-UYVX622Q.mjs +0 -28
  193. package/dist/lib/browser/blueprint-definition-UYVX622Q.mjs.map +0 -7
  194. package/dist/lib/browser/chunk-3UDST345.mjs +0 -85
  195. package/dist/lib/browser/chunk-3UDST345.mjs.map +0 -7
  196. package/dist/lib/browser/intent-resolver-VVBNS2TO.mjs +0 -111
  197. package/dist/lib/browser/intent-resolver-VVBNS2TO.mjs.map +0 -7
  198. package/dist/lib/browser/react-surface-FNXJ6VJX.mjs +0 -255
  199. package/dist/lib/browser/react-surface-FNXJ6VJX.mjs.map +0 -7
  200. package/dist/lib/node-esm/blueprint-definition-42P47FUY.mjs +0 -30
  201. package/dist/lib/node-esm/blueprint-definition-42P47FUY.mjs.map +0 -7
  202. package/dist/lib/node-esm/chunk-JBOARUAT.mjs +0 -87
  203. package/dist/lib/node-esm/chunk-JBOARUAT.mjs.map +0 -7
  204. package/dist/lib/node-esm/intent-resolver-ACN7UALP.mjs +0 -112
  205. package/dist/lib/node-esm/intent-resolver-ACN7UALP.mjs.map +0 -7
  206. package/dist/lib/node-esm/react-surface-ZHYHCV5N.mjs +0 -256
  207. package/dist/lib/node-esm/react-surface-ZHYHCV5N.mjs.map +0 -7
  208. package/dist/types/src/capabilities/intent-resolver.d.ts +0 -4
  209. package/dist/types/src/capabilities/intent-resolver.d.ts.map +0 -1
  210. package/dist/types/src/components/KanbanContainer.d.ts +0 -7
  211. package/dist/types/src/components/KanbanContainer.d.ts.map +0 -1
  212. package/dist/types/src/components/KanbanContainer.stories.d.ts +0 -41
  213. package/dist/types/src/components/KanbanContainer.stories.d.ts.map +0 -1
  214. package/dist/types/src/components/KanbanViewEditor.d.ts +0 -8
  215. package/dist/types/src/components/KanbanViewEditor.d.ts.map +0 -1
  216. package/src/capabilities/intent-resolver.ts +0 -71
  217. package/src/components/KanbanContainer.stories.tsx +0 -193
  218. package/src/components/KanbanContainer.tsx +0 -95
  219. package/src/components/KanbanViewEditor.tsx +0 -64
@@ -0,0 +1,235 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import { act, renderHook } from '@testing-library/react';
7
+ import * as Schema from 'effect/Schema';
8
+ import { beforeEach, describe, test } from 'vitest';
9
+
10
+ import { Filter, JsonSchema, Obj, Query, Type } from '@dxos/echo';
11
+ import { type View } from '@dxos/echo';
12
+ import { Format, FormatAnnotation, PropertyMetaAnnotationId } from '@dxos/echo/internal';
13
+ import { ObjectId } from '@dxos/keys';
14
+ import { ProjectionModel, ViewModel, createDirectChangeCallback } from '@dxos/schema';
15
+
16
+ import { Kanban } from '#types';
17
+
18
+ import { useKanbanBoardModel } from './useKanbanBoardModel';
19
+
20
+ // TODO(wittjosiah): Consider adding single-select to TestSchema.Task and using that instead.
21
+ const KanbanTaskSchema = Schema.Struct({
22
+ title: Schema.optional(Schema.String),
23
+ status: Schema.Literal('__uncategorized__', 'a', 'b').pipe(
24
+ FormatAnnotation.set(Format.TypeFormat.SingleSelect),
25
+ Schema.annotations({
26
+ title: 'Status',
27
+ [PropertyMetaAnnotationId]: {
28
+ singleSelect: {
29
+ options: [
30
+ { id: '__uncategorized__', title: 'Uncategorized', color: 'neutral' },
31
+ { id: 'a', title: 'A', color: 'blue' },
32
+ { id: 'b', title: 'B', color: 'green' },
33
+ ],
34
+ },
35
+ },
36
+ }),
37
+ Schema.optional,
38
+ ),
39
+ }).pipe(
40
+ Type.object({
41
+ typename: 'com.example.type.kanban-task',
42
+ version: '0.1.0',
43
+ }),
44
+ );
45
+
46
+ type KanbanTask = Schema.Schema.Type<typeof KanbanTaskSchema>;
47
+
48
+ describe('useKanbanBoardModel', () => {
49
+ let registry: Registry.Registry;
50
+ let view: View.View;
51
+ let kanban: Kanban.Kanban;
52
+ let projection: ProjectionModel;
53
+
54
+ beforeEach(() => {
55
+ registry = Registry.make();
56
+ const jsonSchema = JsonSchema.toJsonSchema(KanbanTaskSchema) as Parameters<typeof createDirectChangeCallback>[1];
57
+ view = ViewModel.make({
58
+ query: Query.select(Filter.type(KanbanTaskSchema)),
59
+ jsonSchema,
60
+ pivotFieldName: 'status',
61
+ });
62
+ kanban = Kanban.make({
63
+ view: view,
64
+ arrangement: {
65
+ order: ['a', 'b'],
66
+ columns: { a: { ids: [] }, b: { ids: [] } },
67
+ },
68
+ });
69
+ projection = new ProjectionModel({
70
+ registry,
71
+ view,
72
+ baseSchema: jsonSchema,
73
+ change: createDirectChangeCallback(view.projection, jsonSchema),
74
+ });
75
+ });
76
+
77
+ test('returns model with getColumns and getItems', ({ expect }) => {
78
+ const itemA = Obj.make(KanbanTaskSchema, { status: 'a' });
79
+ const itemB = Obj.make(KanbanTaskSchema, { status: 'b' });
80
+ const initialItems: KanbanTask[] = [itemA, itemB];
81
+ const itemsAtom = Atom.make<KanbanTask[]>(() => initialItems);
82
+
83
+ const { result } = renderHook(() => useKanbanBoardModel(kanban, projection, itemsAtom, registry));
84
+
85
+ const model = result.current;
86
+ expect(model.getColumnId).toBeDefined();
87
+ expect(model.getItemId).toBeDefined();
88
+ expect(model.isColumn).toBeDefined();
89
+ expect(model.isItem).toBeDefined();
90
+
91
+ const columns = model.getColumns();
92
+ expect(columns.length).toBeGreaterThanOrEqual(1);
93
+ const columnValues = columns.map((col) => col.columnValue);
94
+ expect(columnValues).toContain('a');
95
+ expect(columnValues).toContain('b');
96
+
97
+ const colA = columns.find((c) => c.columnValue === 'a');
98
+ const colB = columns.find((c) => c.columnValue === 'b');
99
+ expect(colA).toBeDefined();
100
+ expect(colB).toBeDefined();
101
+
102
+ const itemsA = model.getItems(colA!);
103
+ const itemsB = model.getItems(colB!);
104
+ expect(itemsA.map((i) => i.id)).toContain(itemA.id);
105
+ expect(itemsB.map((i) => i.id)).toContain(itemB.id);
106
+ });
107
+
108
+ test('getItems updates when itemsAtom source changes', ({ expect }) => {
109
+ const initialItem = Obj.make(KanbanTaskSchema, { status: 'a' });
110
+ const initialItems: KanbanTask[] = [initialItem];
111
+ const initialItemsAtom = Atom.make<KanbanTask[]>(() => initialItems);
112
+
113
+ const { result, rerender } = renderHook(
114
+ ({ itemsAtom }) => useKanbanBoardModel(kanban, projection, itemsAtom, registry),
115
+ { initialProps: { itemsAtom: initialItemsAtom } },
116
+ );
117
+
118
+ const columns = result.current.getColumns();
119
+ const colA = columns.find((c) => c.columnValue === 'a');
120
+ expect(colA).toBeDefined();
121
+ expect(result.current.getItems(colA!).length).toBe(1);
122
+ expect(result.current.getItems(colA!).map((i) => i.id)).toEqual([initialItem.id]);
123
+
124
+ const secondItem = Obj.make(KanbanTaskSchema, { status: 'a' });
125
+ const newItems: KanbanTask[] = [initialItem, secondItem];
126
+ const newItemsAtom = Atom.make<KanbanTask[]>(() => newItems);
127
+ act(() => {
128
+ rerender({ itemsAtom: newItemsAtom });
129
+ });
130
+
131
+ expect(result.current.getItems(colA!).length).toBe(2);
132
+ expect(result.current.getItems(colA!).map((i) => i.id)).toEqual([initialItem.id, secondItem.id]);
133
+ });
134
+
135
+ test('columns atom updates when kanban arrangement changes', ({ expect }) => {
136
+ const itemsAtom = Atom.make<KanbanTask[]>(() => []);
137
+ const { result } = renderHook(() => useKanbanBoardModel(kanban, projection, itemsAtom, registry));
138
+
139
+ let columnsUpdateCount = 0;
140
+ registry.subscribe(result.current.columns, () => {
141
+ columnsUpdateCount++;
142
+ });
143
+
144
+ const columnsBefore = result.current.getColumns();
145
+ const orderBefore = columnsBefore.map((c) => c.columnValue);
146
+ expect(orderBefore).toEqual(['__uncategorized__', 'a', 'b']);
147
+
148
+ act(() => {
149
+ Obj.change(kanban, (kanban) => {
150
+ kanban.arrangement.order = ['b', 'a'];
151
+ });
152
+ });
153
+
154
+ const columnsAfter = registry.get(result.current.columns) ?? [];
155
+ const orderAfter = columnsAfter.map((c) => c.columnValue);
156
+ expect(orderAfter).toEqual(['__uncategorized__', 'b', 'a']);
157
+ // TODO(wittjosiah): Try to reduce to 1.
158
+ expect(columnsUpdateCount).toBe(2);
159
+ });
160
+
161
+ test('getItems returns items in column ordered by arrangement ids', ({ expect }) => {
162
+ const item1 = Obj.make(KanbanTaskSchema, {
163
+ id: ObjectId.random(),
164
+ status: 'a',
165
+ });
166
+ const item2 = Obj.make(KanbanTaskSchema, {
167
+ id: ObjectId.random(),
168
+ status: 'a',
169
+ });
170
+ const item3 = Obj.make(KanbanTaskSchema, {
171
+ id: ObjectId.random(),
172
+ status: 'a',
173
+ });
174
+ const items: KanbanTask[] = [item1, item2, item3];
175
+ const itemsAtom = Atom.make<KanbanTask[]>(() => items);
176
+
177
+ const { result } = renderHook(() => useKanbanBoardModel(kanban, projection, itemsAtom, registry));
178
+
179
+ const columns = result.current.getColumns();
180
+ const colA = columns.find((c) => c.columnValue === 'a');
181
+ expect(colA).toBeDefined();
182
+ expect(result.current.getItems(colA!).length).toBe(3);
183
+
184
+ let itemsColAUpdateCount = 0;
185
+ registry.subscribe(result.current.items(colA!), () => {
186
+ itemsColAUpdateCount++;
187
+ });
188
+
189
+ act(() => {
190
+ Obj.change(kanban, (kanban) => {
191
+ kanban.arrangement.columns['a'] = {
192
+ ids: [item3.id, item1.id, item2.id],
193
+ };
194
+ });
195
+ });
196
+
197
+ const itemsAfter = result.current.getItems(colA!);
198
+ expect(itemsAfter.map((i) => i.id)).toEqual([item3.id, item1.id, item2.id]);
199
+ expect(itemsColAUpdateCount).toBe(1);
200
+ });
201
+
202
+ test('subscribing to one column items atom does not fire when another column changes', ({ expect }) => {
203
+ const itemA = Obj.make(KanbanTaskSchema, {
204
+ id: ObjectId.random(),
205
+ status: 'a',
206
+ });
207
+ const itemB = Obj.make(KanbanTaskSchema, {
208
+ id: ObjectId.random(),
209
+ status: 'b',
210
+ });
211
+ const items: KanbanTask[] = [itemA, itemB];
212
+ const itemsAtom = Atom.make<KanbanTask[]>(() => items);
213
+
214
+ const { result } = renderHook(() => useKanbanBoardModel(kanban, projection, itemsAtom, registry));
215
+
216
+ const columns = result.current.getColumns();
217
+ const colA = columns.find((c) => c.columnValue === 'a');
218
+ const colB = columns.find((c) => c.columnValue === 'b');
219
+ expect(colA).toBeDefined();
220
+ expect(colB).toBeDefined();
221
+
222
+ let itemsColAUpdateCount = 0;
223
+ registry.subscribe(result.current.items(colA!), () => {
224
+ itemsColAUpdateCount++;
225
+ });
226
+
227
+ act(() => {
228
+ Obj.change(kanban, (kanban) => {
229
+ kanban.arrangement.columns['b'] = { ids: [itemB.id] };
230
+ });
231
+ });
232
+
233
+ expect(itemsColAUpdateCount).toBe(0);
234
+ });
235
+ });
@@ -0,0 +1,143 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Atom, type Registry } from '@effect-atom/atom-react';
6
+ import { useMemo } from 'react';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { AtomObj } from '@dxos/echo-atom';
10
+ import type { BoardModel } from '@dxos/react-ui-mosaic';
11
+ import type { ProjectionModel } from '@dxos/schema';
12
+
13
+ import { type BaseKanbanItem, type ColumnStructure, type Kanban } from '#types';
14
+ import {
15
+ computeColumnStructure,
16
+ getOrderByColumnFromArrangement,
17
+ getOrderFromArrangement,
18
+ orderItemsInColumn,
19
+ } from '../util';
20
+
21
+ /**
22
+ * Builds a board model that maps kanban arrangement and projection onto columns and per-column items.
23
+ *
24
+ * @template T - Item type (must have id; defaults to BaseKanbanItem).
25
+ * @param kanban - Kanban object (arrangement, view).
26
+ * @param projection - ProjectionModel for pivot field and options.
27
+ * @param itemsAtom - Atom holding the full item list.
28
+ * @param registry - Registry for reading atom values.
29
+ * @returns BoardModel with columns atom, items family, and getColumns/getItems.
30
+ */
31
+ export function useKanbanBoardModel<T extends BaseKanbanItem = BaseKanbanItem>(
32
+ kanban: Kanban.Kanban,
33
+ projection: ProjectionModel,
34
+ itemsAtom: Atom.Atom<T[]>,
35
+ registry: Registry.Registry,
36
+ ): BoardModel<ColumnStructure, T> {
37
+ // Source atoms: reactive reads from the kanban object; items come from the passed-in atom (e.g. AtomQuery or in-memory).
38
+ const arrangementAtom = useMemo(() => AtomObj.makeProperty(kanban, 'arrangement'), [kanban]);
39
+ const viewSnapshotAtom = useMemo(
40
+ () => (kanban?.view ? AtomObj.make(kanban.view) : Atom.make<undefined>(() => undefined)),
41
+ [kanban?.view],
42
+ );
43
+
44
+ /** Only changes when view.projection.pivotFieldId changes; keeps columns from firing on other view updates. */
45
+ const pivotFieldIdAtom = useMemo(
46
+ () => Atom.make((get) => get(viewSnapshotAtom)?.projection?.pivotFieldId as string | undefined),
47
+ [viewSnapshotAtom],
48
+ );
49
+
50
+ // Effective per-column ids: from kanban.arrangement.columns; empty when arrangement has no columns.
51
+ const effectiveByColumnAtom = useMemo(
52
+ () => Atom.make((get) => getOrderByColumnFromArrangement(get(arrangementAtom))),
53
+ [arrangementAtom],
54
+ );
55
+
56
+ // Column structure: depends on pivotFieldId (not full view), arrangement, and projection so columns only fire when pivot or arrangement changes.
57
+ const columnsAtom = useMemo(
58
+ () =>
59
+ Atom.make((get) => {
60
+ const pivotFieldId = get(pivotFieldIdAtom);
61
+ if (pivotFieldId === undefined) {
62
+ return [];
63
+ }
64
+
65
+ get(projection.fields);
66
+ const fieldProj = projection.tryGetFieldProjection(pivotFieldId);
67
+ if (!fieldProj) {
68
+ return [];
69
+ }
70
+
71
+ const selectOptions = fieldProj.props.options ?? [];
72
+ if (selectOptions.length === 0) {
73
+ return [];
74
+ }
75
+
76
+ const arrangement = get(arrangementAtom);
77
+ const order = getOrderFromArrangement(arrangement);
78
+ const byColumn = getOrderByColumnFromArrangement(arrangement);
79
+ return computeColumnStructure(order, byColumn, selectOptions);
80
+ }),
81
+ [pivotFieldIdAtom, arrangementAtom, projection],
82
+ );
83
+
84
+ // Per-column slice of arrangement so each column’s items atom only depends on that column’s ids.
85
+ const columnArrangementAtomFamily = useMemo(
86
+ () =>
87
+ Atom.family<string, Atom.Atom<ColumnStructure>>((columnValue: string) =>
88
+ Atom.make((get) => {
89
+ const byColumn = get(effectiveByColumnAtom);
90
+ return {
91
+ columnValue,
92
+ ids: [...(byColumn[columnValue]?.ids ?? [])],
93
+ };
94
+ }),
95
+ ),
96
+ [effectiveByColumnAtom],
97
+ );
98
+
99
+ // Items for a single column: filter all items by pivot field, sort by this column’s ids, then append new items.
100
+ const itemsAtomFamily = useMemo(
101
+ () =>
102
+ Atom.family<string, Atom.Atom<T[]>>((columnValue: string) =>
103
+ Atom.make((get) => {
104
+ const columnArr = get(columnArrangementAtomFamily(columnValue));
105
+ const allItems = get(itemsAtom);
106
+ const pivotFieldId = get(pivotFieldIdAtom);
107
+
108
+ if (pivotFieldId === undefined) {
109
+ return [];
110
+ }
111
+
112
+ // TODO(wittjosiah): Try to narrow this down further.
113
+ get(projection.fields);
114
+ const fieldProj = projection.tryGetFieldProjection(pivotFieldId);
115
+ if (!fieldProj) {
116
+ return [];
117
+ }
118
+
119
+ const selectOptions = fieldProj.props.options ?? [];
120
+ const pivotPath = fieldProj.props.property;
121
+ const validColumnValues = new Set(selectOptions.map((opt) => opt.id));
122
+ return orderItemsInColumn(allItems, columnArr.ids, columnValue, pivotPath, validColumnValues);
123
+ }),
124
+ ),
125
+ [columnArrangementAtomFamily, itemsAtom, pivotFieldIdAtom, projection],
126
+ );
127
+
128
+ return useMemo(
129
+ () => ({
130
+ getColumnId: (data) => data.columnValue,
131
+ getItemId: (data) => (data as T).id,
132
+ isColumn: (obj): obj is ColumnStructure =>
133
+ typeof obj === 'object' && obj !== null && 'columnValue' in obj && 'ids' in obj,
134
+ // TODO(wittjosiah): This should be restricted to objects of the type of the kanban view.
135
+ isItem: (obj): obj is T => Obj.isObject(obj),
136
+ columns: columnsAtom,
137
+ items: (column) => itemsAtomFamily(column.columnValue),
138
+ getColumns: () => registry.get(columnsAtom) ?? [],
139
+ getItems: (column) => registry.get(itemsAtomFamily(column.columnValue)) ?? [],
140
+ }),
141
+ [columnsAtom, itemsAtomFamily, registry],
142
+ );
143
+ }
@@ -0,0 +1,106 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useMemo } from 'react';
6
+
7
+ import type { BoardModel, MosaicEventHandler, MosaicTileData } from '@dxos/react-ui-mosaic';
8
+ import type { ProjectionModel } from '@dxos/schema';
9
+ import { arrayMove } from '@dxos/util';
10
+
11
+ import { type BaseKanbanItem, type ColumnStructure, type KanbanChangeCallback, UNCATEGORIZED_VALUE } from '#types';
12
+
13
+ /**
14
+ * Builds the column drag-and-drop handler for the kanban board (reorder columns).
15
+ *
16
+ * @template T - Item type (extends BaseKanbanItem).
17
+ * @param id - Handler id.
18
+ * @param model - Board model for getColumns / getColumnId.
19
+ * @param projection - ProjectionModel for pivot field options (column order).
20
+ * @param pivotFieldId - Pivot field id; undefined disables drop.
21
+ * @param change - Callback to persist kanban.arrangement.order.
22
+ * @returns MosaicEventHandler for column tiles.
23
+ */
24
+ export function useKanbanColumnEventHandler<T extends BaseKanbanItem>({
25
+ id,
26
+ model,
27
+ projection,
28
+ pivotFieldId,
29
+ change,
30
+ }: {
31
+ id: string;
32
+ model: BoardModel<ColumnStructure, T>;
33
+ projection: ProjectionModel | undefined;
34
+ pivotFieldId: string | undefined;
35
+ change: KanbanChangeCallback<T>;
36
+ }): MosaicEventHandler<ColumnStructure> {
37
+ return useMemo<MosaicEventHandler<ColumnStructure>>(
38
+ () => ({
39
+ id,
40
+ canDrop: ({ source }) => {
41
+ if (!projection) {
42
+ return false;
43
+ }
44
+ const data = source.data as ColumnStructure;
45
+ const columnValue = model.getColumnId(data);
46
+ return (
47
+ model.isColumn(source.data) &&
48
+ columnValue !== UNCATEGORIZED_VALUE &&
49
+ (source as MosaicTileData<ColumnStructure>).id !== UNCATEGORIZED_VALUE
50
+ );
51
+ },
52
+ onDrop: ({ source, target }) => {
53
+ if (!projection || pivotFieldId === undefined) {
54
+ return;
55
+ }
56
+ const sourceColumnData = source.data as ColumnStructure;
57
+ const sourceColumnId = model.getColumnId(sourceColumnData);
58
+ if (sourceColumnId === UNCATEGORIZED_VALUE) {
59
+ return;
60
+ }
61
+
62
+ // 1. Current column order from model; find source index.
63
+ const currentColumns = model.getColumns();
64
+ const sourceIndex = currentColumns.findIndex((c) => model.getColumnId(c) === sourceColumnId);
65
+ if (sourceIndex === -1) {
66
+ return;
67
+ }
68
+
69
+ // 2. Resolve drop target to an index in the column list.
70
+ let targetIndex: number;
71
+ if (target?.type === 'tile' || target?.type === 'placeholder') {
72
+ targetIndex = typeof target.location === 'number' ? Math.floor(target.location) : -1;
73
+ } else if (target?.type === 'container') {
74
+ targetIndex = currentColumns.length;
75
+ } else {
76
+ return;
77
+ }
78
+ if (targetIndex < 0) {
79
+ return;
80
+ }
81
+
82
+ // 3. New column order after move.
83
+ const currentColumnIds = currentColumns.map((c) => model.getColumnId(c));
84
+ const reorderedColumnIds = arrayMove([...currentColumnIds], sourceIndex, targetIndex);
85
+
86
+ // 4. Persist reordered options to projection (pivot field options = column order).
87
+ const fieldProjection = projection.getFieldProjection(pivotFieldId);
88
+ const currentOptions = [...(fieldProjection.props.options ?? [])];
89
+ const optionsInNewOrder = reorderedColumnIds
90
+ .map((columnId) => currentOptions.find((o) => o.id === columnId))
91
+ .filter((o): o is NonNullable<typeof o> => o != null);
92
+
93
+ projection.setFieldProjection({
94
+ ...fieldProjection,
95
+ props: { ...fieldProjection.props, options: optionsInNewOrder },
96
+ });
97
+
98
+ // Persist column order to kanban.arrangement so the board UI reflects the new order.
99
+ change.kanban((kanban) => {
100
+ kanban.arrangement.order = reorderedColumnIds.filter((columnId) => columnId !== UNCATEGORIZED_VALUE);
101
+ });
102
+ },
103
+ }),
104
+ [id, model, projection, pivotFieldId, change],
105
+ );
106
+ }
@@ -0,0 +1,133 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useMemo } from 'react';
6
+
7
+ import type { BoardModel, MosaicEventHandler, MosaicTileData } from '@dxos/react-ui-mosaic';
8
+
9
+ import {
10
+ type ArrangedCards,
11
+ type BaseKanbanItem,
12
+ type ColumnStructure,
13
+ type KanbanChangeCallback,
14
+ UNCATEGORIZED_VALUE,
15
+ } from '#types';
16
+
17
+ function findColumn<T extends BaseKanbanItem>(
18
+ id: string,
19
+ arrangement: ArrangedCards<T>,
20
+ ): { columnValue: string; cards: T[] } | undefined {
21
+ return arrangement.find(({ columnValue, cards }) => columnValue === id || cards.some((card) => card.id === id));
22
+ }
23
+
24
+ /**
25
+ * Builds the item drag-and-drop handler for a single column (reorder and move between columns).
26
+ *
27
+ * @template T - Item type (extends BaseKanbanItem).
28
+ * @param column - Column structure for this tile.
29
+ * @param columnFieldPath - Item property path for the pivot field (used when moving to another column).
30
+ * @param model - Board model for getColumns / getItems.
31
+ * @param change - Callback to persist arrangement and item field updates.
32
+ * @returns MosaicEventHandler for item tiles in this column.
33
+ */
34
+ export function useKanbanItemEventHandler<T extends BaseKanbanItem>({
35
+ column,
36
+ columnFieldPath,
37
+ model,
38
+ change,
39
+ }: {
40
+ column: ColumnStructure;
41
+ columnFieldPath: string | undefined;
42
+ model: BoardModel<ColumnStructure, T>;
43
+ change: KanbanChangeCallback<T>;
44
+ }): MosaicEventHandler<T> {
45
+ return useMemo<MosaicEventHandler<T>>(
46
+ () => ({
47
+ id: column.columnValue,
48
+ canDrop: ({ source }) => model.isItem(source.data),
49
+ onTake: ({ source }, cb) => {
50
+ void cb(source.data as T);
51
+ },
52
+ onDrop: ({ source, target }) => {
53
+ // 1. Snapshot current arrangement from model (read-only).
54
+ const columns = model.getColumns();
55
+ const currentArrangement: ArrangedCards<T> = columns.map((col) => ({
56
+ columnValue: col.columnValue,
57
+ cards: model.getItems(col) ?? [],
58
+ }));
59
+ const sourceColumnInSnapshot = findColumn(source.id, currentArrangement);
60
+ if (!sourceColumnInSnapshot) {
61
+ return;
62
+ }
63
+
64
+ // 2. Working copy to mutate, then persist.
65
+ const workingArrangement = currentArrangement.map((col) => ({
66
+ columnValue: col.columnValue,
67
+ cards: [...col.cards],
68
+ }));
69
+ const sourceColumnInWorking = workingArrangement.find(
70
+ (c) => c.columnValue === sourceColumnInSnapshot.columnValue || c.cards.some((card) => card.id === source.id),
71
+ );
72
+ const targetColumnInWorking = workingArrangement.find((c) => c.columnValue === column.columnValue);
73
+ if (!sourceColumnInWorking || !targetColumnInWorking) {
74
+ return;
75
+ }
76
+
77
+ // 3. Remove card from source column in working copy.
78
+ const sourceIndex = sourceColumnInWorking.cards.findIndex((card) => card.id === source.id);
79
+ if (sourceIndex === -1) {
80
+ return;
81
+ }
82
+ const [movedCard] = sourceColumnInWorking.cards.splice(sourceIndex, 1);
83
+
84
+ // 4. Update card's pivot field to target column value.
85
+ if (columnFieldPath !== undefined) {
86
+ const newValue =
87
+ targetColumnInWorking.columnValue === UNCATEGORIZED_VALUE ? undefined : targetColumnInWorking.columnValue;
88
+ change.setItemField(movedCard, columnFieldPath, newValue);
89
+ }
90
+
91
+ // 5. Compute insert index in target column, then insert.
92
+ const existingTargetIndex =
93
+ target?.type === 'tile'
94
+ ? targetColumnInWorking.cards.findIndex(
95
+ (card) => model.getItemId(card) === (target as MosaicTileData<T>).id,
96
+ )
97
+ : -1;
98
+ const closestEdge: 'top' | 'bottom' =
99
+ target?.type === 'placeholder' && typeof target.location === 'number'
100
+ ? target.location <= targetColumnInWorking.cards.length / 2
101
+ ? 'top'
102
+ : 'bottom'
103
+ : 'bottom';
104
+
105
+ let insertIndex: number;
106
+ if (target?.type === 'placeholder' && typeof target.location === 'number') {
107
+ insertIndex = Math.max(0, Math.min(targetColumnInWorking.cards.length, Math.floor(target.location)));
108
+ } else if (target?.type === 'container' || existingTargetIndex === -1) {
109
+ insertIndex = targetColumnInWorking.cards.length;
110
+ } else if (sourceColumnInWorking.columnValue === targetColumnInWorking.columnValue) {
111
+ insertIndex = closestEdge === 'bottom' ? existingTargetIndex + 1 : existingTargetIndex;
112
+ if (sourceIndex < existingTargetIndex) {
113
+ insertIndex -= 1;
114
+ }
115
+ } else {
116
+ insertIndex = closestEdge === 'bottom' ? existingTargetIndex + 1 : existingTargetIndex;
117
+ }
118
+ targetColumnInWorking.cards.splice(insertIndex, 0, movedCard);
119
+
120
+ // 6. Persist arrangement to kanban.
121
+ change.kanban((kanban) => {
122
+ kanban.arrangement = {
123
+ order: workingArrangement.map(({ columnValue }) => columnValue),
124
+ columns: Object.fromEntries(
125
+ workingArrangement.map(({ columnValue, cards }) => [columnValue, { ids: cards.map((c) => c.id) }]),
126
+ ),
127
+ };
128
+ });
129
+ },
130
+ }),
131
+ [column, columnFieldPath, model, change],
132
+ );
133
+ }
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Registry } from '@effect-atom/atom-react';
6
+ import { useState } from 'react';
7
+
8
+ import { JsonSchema, Type } from '@dxos/echo';
9
+ import { log } from '@dxos/log';
10
+ import { useAsyncEffect } from '@dxos/react-ui';
11
+ import { ProjectionModel, createEchoChangeCallback } from '@dxos/schema';
12
+
13
+ import { type Kanban } from '#types';
14
+
15
+ /**
16
+ * Loads the kanban view and builds a ProjectionModel for field projections and pivot.
17
+ *
18
+ * @template S - Entity schema type.
19
+ * @param schema - Echo schema for the viewed type (or undefined).
20
+ * @param kanban - Kanban object whose view to load (or undefined).
21
+ * @param registry - Atom registry for reactive state.
22
+ * @returns ProjectionModel when loaded, or undefined while loading or when schema/kanban are missing.
23
+ */
24
+ export const useProjectionModel = <S extends Type.AnyEntity>(
25
+ schema: S | undefined,
26
+ kanban: Kanban.Kanban | undefined,
27
+ registry: Registry.Registry,
28
+ ) => {
29
+ const [projection, setProjection] = useState<ProjectionModel | undefined>();
30
+
31
+ useAsyncEffect(
32
+ async (controller) => {
33
+ if (!schema || !kanban) {
34
+ return;
35
+ }
36
+ try {
37
+ const view = await kanban.view.load();
38
+ if (controller.signal.aborted) {
39
+ return;
40
+ }
41
+
42
+ const jsonSchema = Type.isMutable(schema) ? schema.jsonSchema : JsonSchema.toJsonSchema(schema);
43
+ const change = createEchoChangeCallback(view, Type.isMutable(schema) ? schema : undefined);
44
+
45
+ const projection = new ProjectionModel({ registry, view, baseSchema: jsonSchema, change });
46
+ projection.normalizeView();
47
+ if (!controller.signal.aborted) {
48
+ setProjection(projection);
49
+ }
50
+ } catch (err) {
51
+ log.catch(err, { schema, kanban });
52
+ }
53
+ },
54
+ [schema, kanban, registry],
55
+ );
56
+
57
+ return projection;
58
+ };
package/src/meta.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type PluginMeta } from '@dxos/app-framework';
5
+ import { type Plugin } from '@dxos/app-framework';
6
6
  import { trim } from '@dxos/util';
7
7
 
8
- export const meta: PluginMeta = {
9
- id: 'dxos.org/plugin/kanban',
8
+ export const meta: Plugin.Meta = {
9
+ id: 'org.dxos.plugin.kanban',
10
10
  name: 'Kanban',
11
11
  description: trim`
12
12
  Visual project management using customizable kanban boards to track workflow progress.