@dxos/plugin-kanban 0.8.4-main.69d29f4 → 0.8.4-main.6fa680abb7

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 (191) hide show
  1. package/dist/lib/browser/KanbanContainer-LUFCGLXA.mjs +303 -0
  2. package/dist/lib/browser/KanbanContainer-LUFCGLXA.mjs.map +7 -0
  3. package/dist/lib/browser/KanbanViewEditor-JGJXYP6E.mjs +66 -0
  4. package/dist/lib/browser/KanbanViewEditor-JGJXYP6E.mjs.map +7 -0
  5. package/dist/lib/browser/blueprint-definition-2NNRB2ZQ.mjs +17 -0
  6. package/dist/lib/browser/blueprint-definition-2NNRB2ZQ.mjs.map +7 -0
  7. package/dist/lib/browser/blueprints/index.mjs +3 -3
  8. package/dist/lib/browser/{chunk-L6N4ZDZ7.mjs → chunk-B6RIAQMI.mjs} +11 -17
  9. package/dist/lib/browser/chunk-B6RIAQMI.mjs.map +7 -0
  10. package/dist/lib/browser/chunk-XNHYJTI4.mjs +385 -0
  11. package/dist/lib/browser/chunk-XNHYJTI4.mjs.map +7 -0
  12. package/dist/lib/browser/chunk-YCJAZ27A.mjs +210 -0
  13. package/dist/lib/browser/chunk-YCJAZ27A.mjs.map +7 -0
  14. package/dist/lib/browser/index.mjs +31 -27
  15. package/dist/lib/browser/index.mjs.map +3 -3
  16. package/dist/lib/browser/meta.json +1 -1
  17. package/dist/lib/browser/{operation-resolver-UEJHX42A.mjs → operation-resolver-ZWHXZBPF.mjs} +7 -7
  18. package/dist/lib/browser/operation-resolver-ZWHXZBPF.mjs.map +7 -0
  19. package/dist/lib/browser/react-surface-JSJYXYPF.mjs +99 -0
  20. package/dist/lib/browser/react-surface-JSJYXYPF.mjs.map +7 -0
  21. package/dist/lib/browser/types/index.mjs +9 -3
  22. package/dist/lib/node-esm/KanbanContainer-LLBEBTVG.mjs +304 -0
  23. package/dist/lib/node-esm/KanbanContainer-LLBEBTVG.mjs.map +7 -0
  24. package/dist/lib/node-esm/KanbanViewEditor-Z6PM75TB.mjs +67 -0
  25. package/dist/lib/node-esm/KanbanViewEditor-Z6PM75TB.mjs.map +7 -0
  26. package/dist/lib/node-esm/{blueprint-definition-APJQFSHJ.mjs → blueprint-definition-5GWECQLZ.mjs} +6 -6
  27. package/dist/lib/node-esm/blueprint-definition-5GWECQLZ.mjs.map +7 -0
  28. package/dist/lib/node-esm/blueprints/index.mjs +3 -3
  29. package/dist/lib/node-esm/chunk-3APLKFTO.mjs +211 -0
  30. package/dist/lib/node-esm/chunk-3APLKFTO.mjs.map +7 -0
  31. package/dist/lib/node-esm/chunk-MS6KRXNX.mjs +386 -0
  32. package/dist/lib/node-esm/chunk-MS6KRXNX.mjs.map +7 -0
  33. package/dist/lib/node-esm/{chunk-ZHRMUKTF.mjs → chunk-SD555I65.mjs} +11 -16
  34. package/dist/lib/node-esm/chunk-SD555I65.mjs.map +7 -0
  35. package/dist/lib/node-esm/index.mjs +31 -27
  36. package/dist/lib/node-esm/index.mjs.map +3 -3
  37. package/dist/lib/node-esm/meta.json +1 -1
  38. package/dist/lib/node-esm/{operation-resolver-5RPWHZCF.mjs → operation-resolver-KV5GNNHN.mjs} +7 -7
  39. package/dist/lib/node-esm/operation-resolver-KV5GNNHN.mjs.map +7 -0
  40. package/dist/lib/node-esm/react-surface-PQEYNBJY.mjs +100 -0
  41. package/dist/lib/node-esm/react-surface-PQEYNBJY.mjs.map +7 -0
  42. package/dist/lib/node-esm/types/index.mjs +9 -3
  43. package/dist/types/src/KanbanPlugin.d.ts.map +1 -1
  44. package/dist/types/src/blueprints/index.d.ts +1 -1
  45. package/dist/types/src/blueprints/index.d.ts.map +1 -1
  46. package/dist/types/src/blueprints/kanban-blueprint.d.ts +3 -21
  47. package/dist/types/src/blueprints/kanban-blueprint.d.ts.map +1 -1
  48. package/dist/types/src/capabilities/artifact-definition/artifact-definition.d.ts +1 -1
  49. package/dist/types/src/capabilities/artifact-definition/artifact-definition.d.ts.map +1 -1
  50. package/dist/types/src/capabilities/blueprint-definition/blueprint-definition.d.ts +3 -6
  51. package/dist/types/src/capabilities/blueprint-definition/blueprint-definition.d.ts.map +1 -1
  52. package/dist/types/src/capabilities/blueprint-definition/index.d.ts +1 -1
  53. package/dist/types/src/capabilities/blueprint-definition/index.d.ts.map +1 -1
  54. package/dist/types/src/capabilities/operation-resolver/index.d.ts +1 -1
  55. package/dist/types/src/capabilities/operation-resolver/index.d.ts.map +1 -1
  56. package/dist/types/src/capabilities/operation-resolver/operation-resolver.d.ts +2 -2
  57. package/dist/types/src/capabilities/operation-resolver/operation-resolver.d.ts.map +1 -1
  58. package/dist/types/src/capabilities/react-surface/index.d.ts +1 -1
  59. package/dist/types/src/capabilities/react-surface/index.d.ts.map +1 -1
  60. package/dist/types/src/capabilities/react-surface/react-surface.d.ts +2 -2
  61. package/dist/types/src/capabilities/react-surface/react-surface.d.ts.map +1 -1
  62. package/dist/types/src/components/KanbanBoard/KanbanBoard.d.ts +59 -0
  63. package/dist/types/src/components/KanbanBoard/KanbanBoard.d.ts.map +1 -0
  64. package/dist/types/src/components/KanbanBoard/KanbanBoard.stories.d.ts +72 -0
  65. package/dist/types/src/components/KanbanBoard/KanbanBoard.stories.d.ts.map +1 -0
  66. package/dist/types/src/components/KanbanBoard/KanbanCard.d.ts +10 -0
  67. package/dist/types/src/components/KanbanBoard/KanbanCard.d.ts.map +1 -0
  68. package/dist/types/src/components/KanbanBoard/KanbanColumn.d.ts +9 -0
  69. package/dist/types/src/components/KanbanBoard/KanbanColumn.d.ts.map +1 -0
  70. package/dist/types/src/components/KanbanBoard/index.d.ts +2 -0
  71. package/dist/types/src/components/KanbanBoard/index.d.ts.map +1 -0
  72. package/dist/types/src/components/index.d.ts +1 -2
  73. package/dist/types/src/components/index.d.ts.map +1 -1
  74. package/dist/types/src/containers/KanbanContainer/KanbanContainer.d.ts +6 -0
  75. package/dist/types/src/containers/KanbanContainer/KanbanContainer.d.ts.map +1 -0
  76. package/dist/types/src/{components → containers/KanbanContainer}/KanbanContainer.stories.d.ts +18 -14
  77. package/dist/types/src/containers/KanbanContainer/KanbanContainer.stories.d.ts.map +1 -0
  78. package/dist/types/src/containers/KanbanContainer/index.d.ts +3 -0
  79. package/dist/types/src/containers/KanbanContainer/index.d.ts.map +1 -0
  80. package/dist/types/src/containers/KanbanViewEditor/KanbanViewEditor.d.ts +6 -0
  81. package/dist/types/src/containers/KanbanViewEditor/KanbanViewEditor.d.ts.map +1 -0
  82. package/dist/types/src/containers/KanbanViewEditor/index.d.ts +3 -0
  83. package/dist/types/src/containers/KanbanViewEditor/index.d.ts.map +1 -0
  84. package/dist/types/src/containers/index.d.ts +4 -0
  85. package/dist/types/src/containers/index.d.ts.map +1 -0
  86. package/dist/types/src/hooks/index.d.ts +6 -0
  87. package/dist/types/src/hooks/index.d.ts.map +1 -0
  88. package/dist/types/src/hooks/useEchoChangeCallback.d.ts +13 -0
  89. package/dist/types/src/hooks/useEchoChangeCallback.d.ts.map +1 -0
  90. package/dist/types/src/hooks/useKanbanBoardModel.d.ts +16 -0
  91. package/dist/types/src/hooks/useKanbanBoardModel.d.ts.map +1 -0
  92. package/dist/types/src/hooks/useKanbanBoardModel.test.d.ts +2 -0
  93. package/dist/types/src/hooks/useKanbanBoardModel.test.d.ts.map +1 -0
  94. package/dist/types/src/hooks/useKanbanColumnEventHandler.d.ts +22 -0
  95. package/dist/types/src/hooks/useKanbanColumnEventHandler.d.ts.map +1 -0
  96. package/dist/types/src/hooks/useKanbanItemEventHandler.d.ts +19 -0
  97. package/dist/types/src/hooks/useKanbanItemEventHandler.d.ts.map +1 -0
  98. package/dist/types/src/hooks/useProjectionModel.d.ts +15 -0
  99. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  100. package/dist/types/src/playwright/board-manager.d.ts +5 -0
  101. package/dist/types/src/playwright/board-manager.d.ts.map +1 -0
  102. package/dist/types/src/playwright/playwright.config.d.ts +3 -0
  103. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -0
  104. package/dist/types/src/playwright/smoke.spec.d.ts +2 -0
  105. package/dist/types/src/playwright/smoke.spec.d.ts.map +1 -0
  106. package/dist/types/src/testing/KanbanCardTileSimple.d.ts +5 -0
  107. package/dist/types/src/testing/KanbanCardTileSimple.d.ts.map +1 -0
  108. package/dist/types/src/testing/index.d.ts +2 -0
  109. package/dist/types/src/testing/index.d.ts.map +1 -0
  110. package/dist/types/src/translations.d.ts +16 -0
  111. package/dist/types/src/translations.d.ts.map +1 -1
  112. package/dist/types/src/types/Kanban.d.ts +37 -0
  113. package/dist/types/src/types/Kanban.d.ts.map +1 -0
  114. package/dist/types/src/types/constants.d.ts +6 -0
  115. package/dist/types/src/types/constants.d.ts.map +1 -0
  116. package/dist/types/src/types/index.d.ts +2 -0
  117. package/dist/types/src/types/index.d.ts.map +1 -1
  118. package/dist/types/src/types/schema.d.ts +5 -5
  119. package/dist/types/src/types/schema.d.ts.map +1 -1
  120. package/dist/types/src/types/types.d.ts +28 -0
  121. package/dist/types/src/types/types.d.ts.map +1 -1
  122. package/dist/types/src/util/arrangement.d.ts +68 -0
  123. package/dist/types/src/util/arrangement.d.ts.map +1 -0
  124. package/dist/types/src/util/arrangement.test.d.ts +2 -0
  125. package/dist/types/src/util/arrangement.test.d.ts.map +1 -0
  126. package/dist/types/src/util/index.d.ts +2 -0
  127. package/dist/types/src/util/index.d.ts.map +1 -0
  128. package/dist/types/tsconfig.tsbuildinfo +1 -1
  129. package/package.json +47 -42
  130. package/src/KanbanPlugin.tsx +11 -12
  131. package/src/blueprints/index.ts +1 -1
  132. package/src/blueprints/kanban-blueprint.ts +14 -7
  133. package/src/capabilities/artifact-definition/artifact-definition.ts +8 -7
  134. package/src/capabilities/blueprint-definition/blueprint-definition.ts +6 -12
  135. package/src/capabilities/operation-resolver/operation-resolver.ts +5 -5
  136. package/src/capabilities/react-surface/react-surface.tsx +14 -14
  137. package/src/components/KanbanBoard/KanbanBoard.stories.tsx +142 -0
  138. package/src/components/KanbanBoard/KanbanBoard.tsx +183 -0
  139. package/src/components/KanbanBoard/KanbanCard.tsx +87 -0
  140. package/src/components/KanbanBoard/KanbanColumn.tsx +69 -0
  141. package/src/components/KanbanBoard/index.ts +5 -0
  142. package/src/components/index.ts +1 -2
  143. package/src/{components → containers/KanbanContainer}/KanbanContainer.stories.tsx +62 -85
  144. package/src/containers/KanbanContainer/KanbanContainer.tsx +87 -0
  145. package/src/containers/KanbanContainer/index.ts +7 -0
  146. package/src/{components → containers/KanbanViewEditor}/KanbanViewEditor.tsx +23 -19
  147. package/src/containers/KanbanViewEditor/index.ts +7 -0
  148. package/src/containers/index.ts +8 -0
  149. package/src/hooks/index.ts +9 -0
  150. package/src/hooks/useEchoChangeCallback.ts +30 -0
  151. package/src/hooks/useKanbanBoardModel.test.ts +235 -0
  152. package/src/hooks/useKanbanBoardModel.ts +143 -0
  153. package/src/hooks/useKanbanColumnEventHandler.ts +106 -0
  154. package/src/hooks/useKanbanItemEventHandler.ts +133 -0
  155. package/src/hooks/useProjectionModel.ts +58 -0
  156. package/src/meta.ts +1 -1
  157. package/src/playwright/board-manager.ts +13 -0
  158. package/src/playwright/playwright.config.ts +19 -0
  159. package/src/playwright/smoke.spec.ts +107 -0
  160. package/src/testing/KanbanCardTileSimple.tsx +76 -0
  161. package/src/testing/index.ts +5 -0
  162. package/src/translations.ts +9 -1
  163. package/src/types/Kanban.ts +67 -0
  164. package/src/types/constants.ts +9 -0
  165. package/src/types/index.ts +2 -0
  166. package/src/types/schema.ts +8 -9
  167. package/src/types/types.ts +35 -0
  168. package/src/util/arrangement.test.ts +208 -0
  169. package/src/util/arrangement.ts +167 -0
  170. package/src/util/index.ts +5 -0
  171. package/dist/lib/browser/blueprint-definition-T2544VMJ.mjs +0 -17
  172. package/dist/lib/browser/blueprint-definition-T2544VMJ.mjs.map +0 -7
  173. package/dist/lib/browser/chunk-L6N4ZDZ7.mjs.map +0 -7
  174. package/dist/lib/browser/chunk-XYQO4VL7.mjs +0 -150
  175. package/dist/lib/browser/chunk-XYQO4VL7.mjs.map +0 -7
  176. package/dist/lib/browser/operation-resolver-UEJHX42A.mjs.map +0 -7
  177. package/dist/lib/browser/react-surface-LFUJAPRL.mjs +0 -236
  178. package/dist/lib/browser/react-surface-LFUJAPRL.mjs.map +0 -7
  179. package/dist/lib/node-esm/blueprint-definition-APJQFSHJ.mjs.map +0 -7
  180. package/dist/lib/node-esm/chunk-NN6JMKIT.mjs +0 -152
  181. package/dist/lib/node-esm/chunk-NN6JMKIT.mjs.map +0 -7
  182. package/dist/lib/node-esm/chunk-ZHRMUKTF.mjs.map +0 -7
  183. package/dist/lib/node-esm/operation-resolver-5RPWHZCF.mjs.map +0 -7
  184. package/dist/lib/node-esm/react-surface-7TSGBRJL.mjs +0 -237
  185. package/dist/lib/node-esm/react-surface-7TSGBRJL.mjs.map +0 -7
  186. package/dist/types/src/components/KanbanContainer.d.ts +0 -6
  187. package/dist/types/src/components/KanbanContainer.d.ts.map +0 -1
  188. package/dist/types/src/components/KanbanContainer.stories.d.ts.map +0 -1
  189. package/dist/types/src/components/KanbanViewEditor.d.ts +0 -8
  190. package/dist/types/src/components/KanbanViewEditor.d.ts.map +0 -1
  191. package/src/components/KanbanContainer.tsx +0 -86
@@ -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
@@ -6,7 +6,7 @@ import { type Plugin } from '@dxos/app-framework';
6
6
  import { trim } from '@dxos/util';
7
7
 
8
8
  export const meta: Plugin.Meta = {
9
- id: 'dxos.org/plugin/kanban',
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.
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { BoardManager as MosaicBoardManager } from '@dxos/react-ui-mosaic/playwright';
6
+
7
+ export class BoardManager extends MosaicBoardManager {
8
+ async waitUntilReady(): Promise<void> {
9
+ await this.columns().first().waitFor({ state: 'visible' });
10
+ await this.columns().nth(2).waitFor({ state: 'visible' });
11
+ await this.column(1).items().first().waitFor({ state: 'visible' });
12
+ }
13
+ }
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { defineConfig } from '@playwright/test';
6
+
7
+ import { e2ePreset } from '@dxos/test-utils/playwright';
8
+
9
+ export default defineConfig({
10
+ ...e2ePreset(import.meta.dirname),
11
+ // TODO(wittjosiah): Stories are slow to start up.
12
+ timeout: 60_000,
13
+ // TODO(wittjosiah): Avoid hard-coding ports.
14
+ webServer: {
15
+ command: 'pnpm storybook dev --ci --quiet --port=9011 --config-dir=.storybook',
16
+ port: 9011,
17
+ reuseExistingServer: false,
18
+ },
19
+ });
@@ -0,0 +1,107 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Page, expect, test } from '@playwright/test';
6
+
7
+ import { setupPage, storybookUrl } from '@dxos/test-utils/playwright';
8
+
9
+ import { BoardManager } from './board-manager';
10
+
11
+ const PORT = 9011;
12
+ const STORY_URL = storybookUrl('plugins-plugin-kanban-containers-kanban--mutable-schema', PORT);
13
+
14
+ test.describe('Kanban MutableSchema', () => {
15
+ let page: Page;
16
+ let board: BoardManager;
17
+
18
+ test.beforeEach(async ({ browser }) => {
19
+ // Larger viewport to avoid triggering scroll-assist behaviour on simple drag operations.
20
+ ({ page } = await setupPage(browser, { url: STORY_URL, viewportSize: { width: 1920, height: 1080 } }));
21
+ board = new BoardManager(page.locator('body'));
22
+ await board.waitUntilReady();
23
+ });
24
+
25
+ test.afterEach(async () => {
26
+ await page.close();
27
+ });
28
+
29
+ test('rearrange columns', async () => {
30
+ const col1Label = await board.column(1).title().textContent();
31
+ const col2Label = await board.column(2).title().textContent();
32
+ expect(col1Label).not.toBeNull();
33
+ expect(col2Label).not.toBeNull();
34
+
35
+ await board.column(1).dragTo(board.column(2).header());
36
+
37
+ await expect(board.column(1).title()).toHaveText(col2Label!);
38
+ await expect(board.column(2).title()).toHaveText(col1Label!);
39
+ });
40
+
41
+ test('rearrange within column', async () => {
42
+ // Column 0 is uncategorized (empty). Use column 1 (first status column).
43
+ const column = board.column(1);
44
+ const countBefore = await column.items().count();
45
+
46
+ const firstLabel = await column.item(0).title().textContent();
47
+ const secondLabel = await column.item(1).title().textContent();
48
+ expect(firstLabel).not.toBeNull();
49
+ expect(secondLabel).not.toBeNull();
50
+
51
+ // Drag first item below the second item.
52
+ await column.item(0).dragTo(column.item(1).locator, { x: 0, y: 200 });
53
+
54
+ // Item count should stay the same.
55
+ await expect(column.items()).toHaveCount(countBefore);
56
+
57
+ // The first item should now be what was previously the second item.
58
+ await expect(column.item(0).title()).toHaveText(secondLabel!);
59
+
60
+ // The original first item should now be at index 1.
61
+ await expect(column.item(1).title()).toHaveText(firstLabel!);
62
+ });
63
+
64
+ test('drag to beginning of another column', async () => {
65
+ // Column 0 is uncategorized (empty). Use columns 1 and 2 (both have items).
66
+ const col1 = board.column(1);
67
+ const col2 = board.column(2);
68
+
69
+ const col1CountBefore = await col1.items().count();
70
+ const col2CountBefore = await col2.items().count();
71
+ const draggedLabel = await col1.item(0).title().textContent();
72
+ expect(draggedLabel).not.toBeNull();
73
+
74
+ // Drop above first item. Kanban cards are taller; use larger negative y so we land in top half.
75
+ await col1.item(0).dragTo(col2.item(0).locator, { x: 0, y: -30 });
76
+
77
+ await expect(col1.items()).toHaveCount(col1CountBefore - 1);
78
+ await expect(col2.items()).toHaveCount(col2CountBefore + 1);
79
+ await expect(col2.item(0).title()).toHaveText(draggedLabel!);
80
+ });
81
+
82
+ test('drag into empty column', async () => {
83
+ // Uncategorized is column 0 (empty); first populated column is at index 1.
84
+ const emptyColumn = board.column(0);
85
+ const sourceColumn = board.column(1);
86
+
87
+ const sourceCountBefore = await sourceColumn.items().count();
88
+ const draggedLabel = await sourceColumn.item(0).title().textContent();
89
+ expect(draggedLabel).not.toBeNull();
90
+
91
+ await sourceColumn.item(0).dragTo(emptyColumn.header(), { x: 0, y: 40 });
92
+
93
+ await expect(sourceColumn.items()).toHaveCount(sourceCountBefore - 1);
94
+ await expect(emptyColumn.items()).toHaveCount(1);
95
+ await expect(emptyColumn.item(0).title()).toHaveText(draggedLabel!);
96
+ });
97
+
98
+ test('create new item', async () => {
99
+ // Use first populated column.
100
+ const column = board.column(1);
101
+ const countBefore = await column.items().count();
102
+
103
+ await column.addItem();
104
+
105
+ await expect(column.items()).toHaveCount(countBefore + 1);
106
+ });
107
+ });
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { forwardRef, useCallback, useMemo, useState } from 'react';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+ import { Card, Toolbar, useTranslation } from '@dxos/react-ui';
9
+ import { Menu, createMenuAction } from '@dxos/react-ui-menu';
10
+ import { Focus, Mosaic, useBoard } from '@dxos/react-ui-mosaic';
11
+
12
+ import { type KanbanCardProps, useKanbanBoard } from '../components';
13
+ import { meta } from '../meta';
14
+
15
+ const KANBAN_CARD_TILE_SIMPLE_NAME = 'KanbanCardTileSimple';
16
+
17
+ /** Card tile without Surface; for stories and tests when plugin manager is not available. */
18
+ export const KanbanCardTileSimple = forwardRef<HTMLDivElement, KanbanCardProps>(
19
+ ({ data, location, debug }, forwardedRef) => {
20
+ const { t } = useTranslation(meta.id);
21
+ const { model } = useBoard(KANBAN_CARD_TILE_SIMPLE_NAME);
22
+ const { onCardRemove } = useKanbanBoard(KANBAN_CARD_TILE_SIMPLE_NAME);
23
+ const [dragHandle, setDragHandle] = useState<HTMLButtonElement | null>(null);
24
+ const dragHandleRef = useCallback((el: HTMLButtonElement | null) => setDragHandle(el), []);
25
+
26
+ const menuItems = useMemo(
27
+ () =>
28
+ onCardRemove
29
+ ? [
30
+ createMenuAction('remove', () => onCardRemove(data), {
31
+ label: t('remove card label'),
32
+ icon: 'ph--trash--regular',
33
+ }),
34
+ ]
35
+ : [],
36
+ [onCardRemove, data, t],
37
+ );
38
+
39
+ return (
40
+ <Mosaic.Tile
41
+ asChild
42
+ id={model.getItemId(data)}
43
+ data={data}
44
+ location={location}
45
+ debug={debug}
46
+ dragHandle={dragHandle}
47
+ >
48
+ <Focus.Group asChild>
49
+ <Menu.Root>
50
+ <Card.Root ref={forwardedRef} data-testid='board-item'>
51
+ <Card.Toolbar>
52
+ <Card.DragHandle ref={dragHandleRef} />
53
+ <Card.Title>{Obj.getLabel(data)}</Card.Title>
54
+ {/* TODO(wittjosiah): Reconcile with Card.Menu. */}
55
+ <Menu.Trigger asChild disabled={!menuItems?.length}>
56
+ <Toolbar.IconButton
57
+ iconOnly
58
+ variant='ghost'
59
+ icon='ph--dots-three-vertical--regular'
60
+ label={t('action menu label')}
61
+ />
62
+ </Menu.Trigger>
63
+ <Menu.Content items={menuItems} />
64
+ </Card.Toolbar>
65
+ <Card.Content>
66
+ <div className='p-2 text-sm text-fg'>{Obj.getLabel(data)}</div>
67
+ </Card.Content>
68
+ </Card.Root>
69
+ </Menu.Root>
70
+ </Focus.Group>
71
+ </Mosaic.Tile>
72
+ );
73
+ },
74
+ );
75
+
76
+ KanbanCardTileSimple.displayName = KANBAN_CARD_TILE_SIMPLE_NAME;
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export { KanbanCardTileSimple } from './KanbanCardTileSimple';
@@ -4,9 +4,9 @@
4
4
 
5
5
  import { Type } from '@dxos/echo';
6
6
  import { type Resource } from '@dxos/react-ui';
7
- import { Kanban } from '@dxos/react-ui-kanban/types';
8
7
 
9
8
  import { meta } from './meta';
9
+ import { Kanban } from './types';
10
10
 
11
11
  export const translations = [
12
12
  {
@@ -17,19 +17,27 @@ export const translations = [
17
17
  'typename label_one': 'Kanban',
18
18
  'typename label_other': 'Kanbans',
19
19
  'object name placeholder': 'New kanban',
20
+ 'add object label': 'Add kanban',
20
21
  'rename object label': 'Rename kanban',
21
22
  'delete object label': 'Delete kanban',
22
23
  'object deleted label': 'Kanban deleted',
23
24
  },
24
25
  [meta.id]: {
26
+ 'action menu label': 'Actions',
25
27
  'plugin name': 'Kanban',
26
28
  'kanban title label': 'Title',
27
29
  'column title label': 'Column title',
28
30
  'column title placeholder': 'New column',
29
31
  'add column label': 'Add column',
32
+ 'add card label': 'Add card',
33
+ 'new column name label': 'New column name',
34
+ 'remove card label': 'Remove card',
35
+ 'remove empty column label': 'Remove empty column',
36
+ 'column drag handle label': 'Drag to rearrange',
30
37
  'delete column label': 'Delete column',
31
38
  'card field deleted label': 'Card field deleted',
32
39
  'card deleted label': 'Card deleted',
40
+ 'select pivot placeholder': 'Select a pivot column in board settings to display columns.',
33
41
  },
34
42
  },
35
43
  },
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { Obj, Ref, Type } from '@dxos/echo';
8
+ import { View } from '@dxos/echo';
9
+ import { FormInputAnnotation, LabelAnnotation } from '@dxos/echo/internal';
10
+ import { ViewAnnotation } from '@dxos/schema';
11
+
12
+ /** Per-column entry (ids order, optional hidden). */
13
+ const ArrangementColumnEntry = Schema.Struct({
14
+ ids: Schema.Array(Obj.ID),
15
+ hidden: Schema.optional(Schema.Boolean),
16
+ });
17
+
18
+ /** Keyed by columnValue. */
19
+ const ArrangementColumns = Schema.Record({
20
+ key: Schema.String,
21
+ value: ArrangementColumnEntry,
22
+ }).pipe(FormInputAnnotation.set(false));
23
+
24
+ /** Column order and per-column card ids. */
25
+ export const Arrangement = Schema.Struct({
26
+ order: Schema.Array(Schema.String).pipe(FormInputAnnotation.set(false)),
27
+ columns: ArrangementColumns,
28
+ }).pipe(FormInputAnnotation.set(false));
29
+
30
+ export type Arrangement = Schema.Schema.Type<typeof Arrangement>;
31
+
32
+ export const Kanban = Schema.Struct({
33
+ name: Schema.optional(Schema.String),
34
+
35
+ view: Ref.Ref(View.View).pipe(FormInputAnnotation.set(false)),
36
+
37
+ /** Column display order and per-column card ids. */
38
+ arrangement: Arrangement,
39
+ }).pipe(
40
+ Type.object({
41
+ typename: 'org.dxos.type.kanban',
42
+ version: '0.1.0',
43
+ }),
44
+ LabelAnnotation.set(['name']),
45
+ ViewAnnotation.set(true),
46
+ );
47
+
48
+ /** Instance type; use Kanban.Kanban in type position so namespace has .Kanban as type and .KanbanSchema as schema. */
49
+ export interface Kanban extends Schema.Schema.Type<typeof Kanban> {}
50
+
51
+ type MakeProps = Omit<Partial<Obj.MakeProps<typeof Kanban>>, 'view'> & {
52
+ view: View.View;
53
+ };
54
+
55
+ /**
56
+ * Make a kanban as a view of a data set.
57
+ */
58
+ export const make = (props: MakeProps): Kanban => {
59
+ const { name, view, arrangement } = props;
60
+ const order = arrangement?.order ?? [];
61
+ const columns = arrangement?.columns ?? {};
62
+ return Obj.make(Kanban, {
63
+ name,
64
+ view: Ref.make(view),
65
+ arrangement: { order, columns },
66
+ });
67
+ };
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export const UNCATEGORIZED_VALUE = '__uncategorized__' as const;
6
+ export const UNCATEGORIZED_ATTRIBUTES = {
7
+ title: 'Uncategorized',
8
+ color: 'neutral',
9
+ } as const;
@@ -2,5 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './constants';
6
+ export * as Kanban from './Kanban';
5
7
  export * from './schema';
6
8
  export type * from './types';