@tuturuuu/ui 0.9.0 → 0.10.0

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 (94) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  17. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  18. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  19. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  20. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  21. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  23. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  24. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  25. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  26. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  27. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  28. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  29. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  30. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  31. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  33. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  34. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  35. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  36. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  37. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  38. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  39. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  40. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  41. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  42. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  43. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  44. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  45. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  46. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  47. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  51. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  52. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  53. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
  54. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  55. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  56. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  57. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
  58. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  59. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  60. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  61. package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
  62. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  63. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  64. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  65. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  66. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  69. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  87. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
  88. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  89. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  90. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  91. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  92. package/src/hooks/useBoardPresence.ts +364 -0
  93. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  94. package/src/lib/workspace-actions.ts +2 -6
@@ -13,6 +13,8 @@ interface DragPreviewProps {
13
13
  columns: TaskList[];
14
14
  boardId: string;
15
15
  isPersonalWorkspace: boolean;
16
+ canUseBoardAssignees?: boolean;
17
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
16
18
  isMultiSelectMode: boolean;
17
19
  selectedTasks: Set<string>;
18
20
  onUpdate: () => void;
@@ -26,6 +28,8 @@ export function DragPreview({
26
28
  columns,
27
29
  boardId,
28
30
  isPersonalWorkspace,
31
+ canUseBoardAssignees,
32
+ assigneeMemberSource,
29
33
  isMultiSelectMode,
30
34
  selectedTasks,
31
35
  onUpdate,
@@ -52,6 +56,8 @@ export function DragPreview({
52
56
  isOverlay
53
57
  onUpdate={onUpdate}
54
58
  isPersonalWorkspace={isPersonalWorkspace}
59
+ canUseBoardAssignees={canUseBoardAssignees}
60
+ assigneeMemberSource={assigneeMemberSource}
55
61
  />
56
62
  {isMultiCardDrag && (
57
63
  <>
@@ -74,6 +80,8 @@ export function DragPreview({
74
80
  boardId,
75
81
  onUpdate,
76
82
  isPersonalWorkspace,
83
+ canUseBoardAssignees,
84
+ assigneeMemberSource,
77
85
  isMultiSelectMode,
78
86
  selectedTasks,
79
87
  ]);
@@ -87,11 +95,22 @@ export function DragPreview({
87
95
  tasks={tasks.filter((task) => task.list_id === activeColumn.id)}
88
96
  isOverlay
89
97
  isPersonalWorkspace={isPersonalWorkspace}
98
+ canUseBoardAssignees={canUseBoardAssignees}
99
+ assigneeMemberSource={assigneeMemberSource}
90
100
  onUpdate={onUpdate}
91
101
  wsId={wsId}
92
102
  />
93
103
  ) : null,
94
- [activeColumn, tasks, boardId, isPersonalWorkspace, onUpdate, wsId]
104
+ [
105
+ activeColumn,
106
+ tasks,
107
+ boardId,
108
+ isPersonalWorkspace,
109
+ canUseBoardAssignees,
110
+ assigneeMemberSource,
111
+ onUpdate,
112
+ wsId,
113
+ ]
95
114
  );
96
115
 
97
116
  return <>{MemoizedTaskOverlay || MemoizedColumnOverlay}</>;
@@ -20,9 +20,20 @@ vi.mock('@dnd-kit/sortable', () => ({
20
20
  }));
21
21
 
22
22
  vi.mock('../../board-column', () => ({
23
- BoardColumn: ({ column }: { column: TaskList }) => (
23
+ BoardColumn: ({
24
+ column,
25
+ specialPinned,
26
+ specialStickyOffset,
27
+ }: {
28
+ column: TaskList;
29
+ specialPinned?: boolean;
30
+ specialStickyOffset?: string;
31
+ }) => (
24
32
  <section
33
+ data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
25
34
  data-kanban-real-column={column.is_external_staging ? undefined : 'true'}
35
+ data-special-pinned={String(specialPinned === true)}
36
+ data-special-sticky-offset={specialStickyOffset}
26
37
  data-testid={`column-${column.id}`}
27
38
  />
28
39
  ),
@@ -117,6 +128,20 @@ function task(overrides: Partial<Task>): Task {
117
128
  };
118
129
  }
119
130
 
131
+ function getMockRect(left: number, right: number) {
132
+ return {
133
+ bottom: 0,
134
+ height: 0,
135
+ left,
136
+ right,
137
+ toJSON: () => ({}),
138
+ top: 0,
139
+ width: right - left,
140
+ x: left,
141
+ y: 0,
142
+ } as DOMRect;
143
+ }
144
+
120
145
  describe('KanbanColumns', () => {
121
146
  beforeEach(() => {
122
147
  cursorOverlayMock.mockClear();
@@ -525,7 +550,7 @@ describe('KanbanColumns', () => {
525
550
  ).toEqual(['Alpha task', 'Zulu task']);
526
551
  });
527
552
 
528
- it('anchors the first load on the first real task list when special columns render to the left', () => {
553
+ it('anchors the first load on the first real task list when empty special columns render to the left', () => {
529
554
  const frameCallbacks: FrameRequestCallback[] = [];
530
555
  const originalRequestAnimationFrame = window.requestAnimationFrame;
531
556
  const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -563,15 +588,9 @@ describe('KanbanColumns', () => {
563
588
  }}
564
589
  deadlineSections={{
565
590
  overdue: [],
566
- upcoming: [
567
- task({
568
- end_date: '2026-06-02T00:00:00.000Z',
569
- id: 'upcoming-task',
570
- list_id: 'list-1',
571
- name: 'Upcoming task',
572
- }),
573
- ],
591
+ upcoming: [],
574
592
  }}
593
+ deadlineSectionsLoading
575
594
  />
576
595
  );
577
596
  const scrollContainer = container.firstElementChild as HTMLElement;
@@ -614,15 +633,9 @@ describe('KanbanColumns', () => {
614
633
  }}
615
634
  deadlineSections={{
616
635
  overdue: [],
617
- upcoming: [
618
- task({
619
- end_date: '2026-06-02T00:00:00.000Z',
620
- id: 'upcoming-task',
621
- list_id: 'list-1',
622
- name: 'Upcoming task',
623
- }),
624
- ],
636
+ upcoming: [],
625
637
  }}
638
+ deadlineSectionsLoading
626
639
  />
627
640
  );
628
641
 
@@ -633,6 +646,69 @@ describe('KanbanColumns', () => {
633
646
  }
634
647
  });
635
648
 
649
+ it('keeps the first real task list visible when pinned special lists are sticky', () => {
650
+ const frameCallbacks: FrameRequestCallback[] = [];
651
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
652
+ const originalCancelAnimationFrame = window.cancelAnimationFrame;
653
+ const collapsedExternalList = {
654
+ ...externalList,
655
+ is_external_collapsed: true,
656
+ };
657
+
658
+ window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
659
+ frameCallbacks.push(callback);
660
+ return frameCallbacks.length;
661
+ });
662
+ window.cancelAnimationFrame = vi.fn();
663
+
664
+ try {
665
+ const { container } = render(
666
+ <KanbanColumns
667
+ columns={[collapsedExternalList, ...lists]}
668
+ tasks={[]}
669
+ boardId="board-1"
670
+ workspaceId="ws-1"
671
+ isPersonalWorkspace
672
+ disableSort={false}
673
+ selectedTasks={new Set()}
674
+ isMultiSelectMode={false}
675
+ setIsMultiSelectMode={vi.fn()}
676
+ onTaskSelect={vi.fn()}
677
+ onClearSelection={vi.fn()}
678
+ onUpdate={vi.fn()}
679
+ createTask={vi.fn()}
680
+ taskHeightsRef={{ current: new Map() }}
681
+ optimisticUpdateInProgress={new Set()}
682
+ bulkUpdateCustomDueDate={vi.fn()}
683
+ boardRef={{ current: null }}
684
+ columnsId={[collapsedExternalList, ...lists].map((list) => list.id)}
685
+ specialTaskListPins={{ external_tasks: true }}
686
+ />
687
+ );
688
+ const scrollContainer = container.firstElementChild as HTMLElement;
689
+ const firstRealColumn = screen.getByTestId('column-list-1');
690
+ const pinnedExternalColumn = screen.getByTestId('column-external-list');
691
+
692
+ Object.defineProperty(firstRealColumn, 'offsetLeft', {
693
+ configurable: true,
694
+ value: 320,
695
+ });
696
+ Object.defineProperty(pinnedExternalColumn, 'getBoundingClientRect', {
697
+ configurable: true,
698
+ value: () => getMockRect(8, 64),
699
+ });
700
+
701
+ act(() => {
702
+ for (const callback of frameCallbacks) callback(0);
703
+ });
704
+
705
+ expect(scrollContainer.scrollLeft).toBe(256);
706
+ } finally {
707
+ window.requestAnimationFrame = originalRequestAnimationFrame;
708
+ window.cancelAnimationFrame = originalCancelAnimationFrame;
709
+ }
710
+ });
711
+
636
712
  it('renders external deadline cards with their staging list context without exposing the staging list as a move target', () => {
637
713
  render(
638
714
  <KanbanColumns
@@ -748,6 +824,110 @@ describe('KanbanColumns', () => {
748
824
  ).toBeInTheDocument();
749
825
  });
750
826
 
827
+ it('keeps pinned deadline sections sticky without forcing them expanded', () => {
828
+ render(
829
+ <KanbanColumns
830
+ columns={lists}
831
+ tasks={[]}
832
+ boardId="board-1"
833
+ workspaceId="ws-1"
834
+ isPersonalWorkspace={false}
835
+ disableSort={false}
836
+ selectedTasks={new Set()}
837
+ isMultiSelectMode={false}
838
+ setIsMultiSelectMode={vi.fn()}
839
+ onTaskSelect={vi.fn()}
840
+ onClearSelection={vi.fn()}
841
+ onUpdate={vi.fn()}
842
+ createTask={vi.fn()}
843
+ taskHeightsRef={{ current: new Map() }}
844
+ optimisticUpdateInProgress={new Set()}
845
+ bulkUpdateCustomDueDate={vi.fn()}
846
+ boardRef={{ current: null }}
847
+ columnsId={lists.map((list) => list.id)}
848
+ deadlineLabels={{
849
+ overdue: 'Overdue',
850
+ upcoming: 'Upcoming',
851
+ }}
852
+ deadlineSections={{
853
+ overdue: [],
854
+ upcoming: [],
855
+ }}
856
+ deadlineSectionsCollapsed={{ overdue: true }}
857
+ specialTaskListPins={{ overdue: true, upcoming: true }}
858
+ />
859
+ );
860
+
861
+ const collapsedOverdue = screen.getByTestId(
862
+ 'kanban-deadline-section-overdue-collapsed'
863
+ );
864
+ const upcoming = screen.getByTestId('kanban-deadline-section-upcoming');
865
+
866
+ expect(collapsedOverdue).toHaveAttribute(
867
+ 'data-kanban-pinned-special',
868
+ 'true'
869
+ );
870
+ expect(collapsedOverdue).toHaveClass('sticky');
871
+ expect(collapsedOverdue.style.left).toBe(
872
+ 'calc(var(--kanban-snap-left-padding) + 0px)'
873
+ );
874
+ expect(upcoming).toHaveAttribute('data-kanban-pinned-special', 'true');
875
+ expect(upcoming).toHaveClass('sticky');
876
+ expect(upcoming.style.left).toBe(
877
+ 'calc(var(--kanban-snap-left-padding) + calc(3.5rem + 0.75rem))'
878
+ );
879
+ });
880
+
881
+ it('assigns sticky offsets to pinned external and closed task lists', () => {
882
+ const collapsedExternalList = {
883
+ ...externalList,
884
+ is_external_collapsed: true,
885
+ };
886
+
887
+ render(
888
+ <KanbanColumns
889
+ columns={[collapsedExternalList, ...lists, collapsedClosedList]}
890
+ tasks={[]}
891
+ boardId="board-1"
892
+ workspaceId="ws-1"
893
+ isPersonalWorkspace
894
+ disableSort={false}
895
+ selectedTasks={new Set()}
896
+ isMultiSelectMode={false}
897
+ setIsMultiSelectMode={vi.fn()}
898
+ onTaskSelect={vi.fn()}
899
+ onClearSelection={vi.fn()}
900
+ onUpdate={vi.fn()}
901
+ createTask={vi.fn()}
902
+ taskHeightsRef={{ current: new Map() }}
903
+ optimisticUpdateInProgress={new Set()}
904
+ bulkUpdateCustomDueDate={vi.fn()}
905
+ boardRef={{ current: null }}
906
+ columnsId={[collapsedExternalList, ...lists, collapsedClosedList].map(
907
+ (list) => list.id
908
+ )}
909
+ specialTaskListPins={{ closed_tasks: true, external_tasks: true }}
910
+ />
911
+ );
912
+
913
+ expect(screen.getByTestId('column-external-list')).toHaveAttribute(
914
+ 'data-special-pinned',
915
+ 'true'
916
+ );
917
+ expect(screen.getByTestId('column-external-list')).toHaveAttribute(
918
+ 'data-special-sticky-offset',
919
+ '0px'
920
+ );
921
+ expect(screen.getByTestId('column-closed-list')).toHaveAttribute(
922
+ 'data-special-pinned',
923
+ 'true'
924
+ );
925
+ expect(screen.getByTestId('column-closed-list')).toHaveAttribute(
926
+ 'data-special-sticky-offset',
927
+ 'calc(3.5rem + 0.75rem)'
928
+ );
929
+ });
930
+
751
931
  it('passes deadline tick props to upcoming deadline cards', () => {
752
932
  render(
753
933
  <KanbanColumns
@@ -800,8 +980,8 @@ describe('KanbanColumns', () => {
800
980
  );
801
981
  });
802
982
 
803
- it('omits deadline panels when both deadline sections are empty', () => {
804
- render(
983
+ it('reserves empty deadline panels before deadline tasks load', () => {
984
+ const { container, rerender } = render(
805
985
  <KanbanColumns
806
986
  columns={lists}
807
987
  tasks={[]}
@@ -821,15 +1001,77 @@ describe('KanbanColumns', () => {
821
1001
  bulkUpdateCustomDueDate={vi.fn()}
822
1002
  boardRef={{ current: null }}
823
1003
  columnsId={lists.map((list) => list.id)}
1004
+ listStatusFilter="all"
824
1005
  deadlineLabels={{
825
1006
  overdue: 'Overdue',
826
1007
  upcoming: 'Upcoming',
827
1008
  }}
828
1009
  deadlineSections={{ overdue: [], upcoming: [] }}
1010
+ deadlineSectionsLoading
829
1011
  />
830
1012
  );
831
1013
 
832
- expect(screen.queryByTestId('kanban-deadline-panels')).toBeNull();
1014
+ expect(screen.getByTestId('kanban-deadline-panels')).toBeInTheDocument();
1015
+ expect(
1016
+ screen.getByTestId('kanban-deadline-section-overdue')
1017
+ ).toBeInTheDocument();
1018
+ expect(
1019
+ screen.getByTestId('kanban-deadline-section-upcoming')
1020
+ ).toBeInTheDocument();
1021
+ expect(
1022
+ screen.getByTestId('kanban-deadline-section-overdue-count')
1023
+ ).toHaveTextContent('0');
1024
+ expect(
1025
+ screen.getByTestId('kanban-deadline-section-upcoming-count')
1026
+ ).toHaveTextContent('0');
1027
+ expect(
1028
+ screen.getByTestId('kanban-deadline-section-overdue-loading')
1029
+ ).toBeInTheDocument();
1030
+ expect(
1031
+ screen.getByTestId('kanban-deadline-section-upcoming-loading')
1032
+ ).toBeInTheDocument();
1033
+ expect(
1034
+ (container.firstElementChild as HTMLElement).style.getPropertyValue(
1035
+ '--kanban-column-width'
1036
+ )
1037
+ ).toContain('/ 4');
833
1038
  expect(screen.getByTestId('column-list-1')).toBeInTheDocument();
1039
+
1040
+ rerender(
1041
+ <KanbanColumns
1042
+ columns={lists}
1043
+ tasks={[]}
1044
+ boardId="board-1"
1045
+ workspaceId="ws-1"
1046
+ isPersonalWorkspace={false}
1047
+ disableSort={false}
1048
+ selectedTasks={new Set()}
1049
+ isMultiSelectMode={false}
1050
+ setIsMultiSelectMode={vi.fn()}
1051
+ onTaskSelect={vi.fn()}
1052
+ onClearSelection={vi.fn()}
1053
+ onUpdate={vi.fn()}
1054
+ createTask={vi.fn()}
1055
+ taskHeightsRef={{ current: new Map() }}
1056
+ optimisticUpdateInProgress={new Set()}
1057
+ bulkUpdateCustomDueDate={vi.fn()}
1058
+ boardRef={{ current: null }}
1059
+ columnsId={lists.map((list) => list.id)}
1060
+ listStatusFilter="all"
1061
+ deadlineLabels={{
1062
+ overdue: 'Overdue',
1063
+ upcoming: 'Upcoming',
1064
+ }}
1065
+ deadlineSections={{ overdue: [], upcoming: [] }}
1066
+ />
1067
+ );
1068
+
1069
+ expect(screen.getByTestId('kanban-deadline-panels')).toBeInTheDocument();
1070
+ expect(
1071
+ screen.queryByTestId('kanban-deadline-section-overdue-loading')
1072
+ ).not.toBeInTheDocument();
1073
+ expect(
1074
+ screen.queryByTestId('kanban-deadline-section-upcoming-loading')
1075
+ ).not.toBeInTheDocument();
834
1076
  });
835
1077
  });
@@ -29,12 +29,17 @@ import {
29
29
  } from './kanban-deadline-panels';
30
30
  import type { KanbanDeadlineSections } from './kanban-deadline-tasks';
31
31
 
32
+ const KANBAN_COLUMN_GAP = '0.75rem';
33
+ const COLLAPSED_SPECIAL_LIST_WIDTH = '3.5rem';
34
+
32
35
  interface KanbanColumnsProps {
33
36
  columns: TaskList[];
34
37
  tasks: Task[];
35
38
  boardId: string;
36
39
  workspaceId: string;
37
40
  isPersonalWorkspace: boolean;
41
+ canUseBoardAssignees?: boolean;
42
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
38
43
  cursorsEnabled?: boolean;
39
44
  disableSort: boolean;
40
45
  selectedTasks: Set<string>;
@@ -62,6 +67,7 @@ interface KanbanColumnsProps {
62
67
  onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
63
68
  deadlineLabels?: KanbanDeadlineLabels;
64
69
  deadlineSections?: KanbanDeadlineSections;
70
+ deadlineSectionsLoading?: boolean;
65
71
  deadlineSectionsCollapsed?: KanbanDeadlineCollapsedState;
66
72
  deadlineNow?: number;
67
73
  onDeadlineSectionCollapsedChange?: (
@@ -76,12 +82,52 @@ interface KanbanColumnsProps {
76
82
  readOnly?: boolean;
77
83
  }
78
84
 
85
+ interface PinnedSpecialListLayout {
86
+ offsets: Record<string, string>;
87
+ totalWidth: string;
88
+ }
89
+
90
+ function toCalcExpression(parts: string[]) {
91
+ if (parts.length === 0) return '0px';
92
+ if (parts.length === 1) return parts[0] ?? '0px';
93
+ return `calc(${parts.join(' + ')})`;
94
+ }
95
+
96
+ function getSpecialListWidth(collapsed: boolean) {
97
+ return collapsed
98
+ ? COLLAPSED_SPECIAL_LIST_WIDTH
99
+ : 'var(--kanban-column-width)';
100
+ }
101
+
102
+ function getPinnedSpecialRailWidth(container: HTMLElement) {
103
+ const elements = Array.from(
104
+ container.querySelectorAll<HTMLElement>(
105
+ '[data-kanban-pinned-special="true"]'
106
+ )
107
+ );
108
+
109
+ if (elements.length === 0) return 0;
110
+
111
+ const rects = elements
112
+ .map((element) => element.getBoundingClientRect())
113
+ .filter((rect) => rect.width > 0);
114
+
115
+ if (rects.length === 0) return 0;
116
+
117
+ const left = Math.min(...rects.map((rect) => rect.left));
118
+ const right = Math.max(...rects.map((rect) => rect.right));
119
+
120
+ return Math.max(0, right - left);
121
+ }
122
+
79
123
  export function KanbanColumns({
80
124
  columns,
81
125
  tasks,
82
126
  boardId,
83
127
  workspaceId,
84
128
  isPersonalWorkspace,
129
+ canUseBoardAssignees,
130
+ assigneeMemberSource,
85
131
  cursorsEnabled = true,
86
132
  disableSort,
87
133
  selectedTasks,
@@ -104,6 +150,7 @@ export function KanbanColumns({
104
150
  onTaskListCollapsedChange,
105
151
  deadlineLabels,
106
152
  deadlineSections,
153
+ deadlineSectionsLoading,
107
154
  deadlineSectionsCollapsed,
108
155
  deadlineNow,
109
156
  onDeadlineSectionCollapsedChange,
@@ -114,26 +161,78 @@ export function KanbanColumns({
114
161
  const initialScrollAnchoredBoardRef = useRef<string | null>(null);
115
162
  const realColumns = columns.filter((column) => !column.is_external_staging);
116
163
  const deadlineSectionOrder: KanbanDeadlineSection[] = ['overdue', 'upcoming'];
117
- const visibleDeadlineSections =
118
- !readOnly && deadlineSections
119
- ? deadlineSectionOrder.filter(
120
- (section) => deadlineSections[section].length > 0
121
- )
122
- : [];
123
- const snapEdgePadding = columns.length > 0 ? '0.5rem' : '0px';
164
+ const deadlinePanelsEnabled =
165
+ !readOnly && Boolean(boardId && deadlineSections && deadlineLabels);
166
+ const reservedDeadlineSections = deadlinePanelsEnabled
167
+ ? deadlineSectionOrder
168
+ : [];
169
+ const snapEdgePadding =
170
+ columns.length > 0 || reservedDeadlineSections.length > 0
171
+ ? '0.5rem'
172
+ : '0px';
124
173
  const collapsedColumnCount =
125
174
  columns.filter(isKanbanColumnCollapsed).length +
126
- visibleDeadlineSections.filter(
175
+ reservedDeadlineSections.filter(
127
176
  (section) => deadlineSectionsCollapsed?.[section] === true
128
177
  ).length;
129
178
  const dynamicColumnWidth = getKanbanColumnWidth({
130
- columnCount: columns.length + visibleDeadlineSections.length,
179
+ columnCount: columns.length + reservedDeadlineSections.length,
131
180
  collapsedColumnCount,
132
181
  snapEdgePadding,
133
182
  fillAvailableWidth: listStatusFilter === 'all',
134
183
  });
184
+ const pinnedSpecialListLayout = (() => {
185
+ const entries: { key: string; width: string }[] = [];
186
+
187
+ if (deadlinePanelsEnabled && specialTaskListPins?.overdue) {
188
+ entries.push({
189
+ key: 'deadline:overdue',
190
+ width: getSpecialListWidth(deadlineSectionsCollapsed?.overdue === true),
191
+ });
192
+ }
193
+
194
+ if (deadlinePanelsEnabled && specialTaskListPins?.upcoming) {
195
+ entries.push({
196
+ key: 'deadline:upcoming',
197
+ width: getSpecialListWidth(
198
+ deadlineSectionsCollapsed?.upcoming === true
199
+ ),
200
+ });
201
+ }
202
+
203
+ for (const column of columns) {
204
+ const pinned =
205
+ column.is_external_staging === true
206
+ ? specialTaskListPins?.external_tasks === true
207
+ : column.status === 'closed'
208
+ ? specialTaskListPins?.closed_tasks === true
209
+ : false;
210
+
211
+ if (!pinned) continue;
212
+
213
+ entries.push({
214
+ key: `column:${column.id}`,
215
+ width: getSpecialListWidth(isKanbanColumnCollapsed(column)),
216
+ });
217
+ }
218
+
219
+ const offsets: Record<string, string> = {};
220
+ const parts: string[] = [];
221
+
222
+ entries.forEach((entry, index) => {
223
+ if (index > 0) parts.push(KANBAN_COLUMN_GAP);
224
+
225
+ offsets[entry.key] = toCalcExpression(parts);
226
+ parts.push(entry.width);
227
+ });
228
+
229
+ return {
230
+ offsets,
231
+ totalWidth: toCalcExpression(parts),
232
+ } satisfies PinnedSpecialListLayout;
233
+ })();
135
234
  const hasLeftSpecialColumns =
136
- visibleDeadlineSections.length > 0 ||
235
+ reservedDeadlineSections.length > 0 ||
137
236
  columns.some((column) => column.is_external_staging);
138
237
 
139
238
  useLayoutEffect(() => {
@@ -151,11 +250,16 @@ export function KanbanColumns({
151
250
  initialScrollAnchoredBoardRef.current = boardId;
152
251
 
153
252
  const anchor = () => {
154
- container.scrollLeft = Math.max(0, target.offsetLeft - 8);
253
+ const pinnedRailWidth = getPinnedSpecialRailWidth(container);
254
+ container.scrollLeft = Math.max(
255
+ 0,
256
+ target.offsetLeft - pinnedRailWidth - 8
257
+ );
155
258
  };
156
259
 
260
+ anchor();
261
+
157
262
  if (typeof window.requestAnimationFrame !== 'function') {
158
- anchor();
159
263
  return;
160
264
  }
161
265
 
@@ -172,7 +276,10 @@ export function KanbanColumns({
172
276
  '--kanban-snap-left-padding': snapEdgePadding,
173
277
  '--kanban-snap-right-padding': snapEdgePadding,
174
278
  '--kanban-column-width': dynamicColumnWidth,
175
- scrollPaddingLeft: 'var(--kanban-snap-left-padding)',
279
+ scrollPaddingLeft:
280
+ pinnedSpecialListLayout.totalWidth === '0px'
281
+ ? 'var(--kanban-snap-left-padding)'
282
+ : `calc(var(--kanban-snap-left-padding) + ${pinnedSpecialListLayout.totalWidth})`,
176
283
  scrollPaddingRight: 'var(--kanban-snap-right-padding)',
177
284
  } as React.CSSProperties
178
285
  }
@@ -188,13 +295,15 @@ export function KanbanColumns({
188
295
  paddingRight: 'var(--kanban-snap-right-padding)',
189
296
  }}
190
297
  >
191
- {!readOnly && deadlineSections && deadlineLabels && (
298
+ {deadlinePanelsEnabled && deadlineSections && deadlineLabels && (
192
299
  <KanbanDeadlinePanels
193
300
  availableLists={realColumns}
194
301
  boardId={boardId}
195
302
  bulkUpdateCustomDueDate={bulkUpdateCustomDueDate}
196
303
  isMultiSelectMode={isMultiSelectMode}
197
304
  isPersonalWorkspace={isPersonalWorkspace}
305
+ canUseBoardAssignees={canUseBoardAssignees}
306
+ assigneeMemberSource={assigneeMemberSource}
198
307
  labels={deadlineLabels}
199
308
  onClearSelection={onClearSelection}
200
309
  onSectionCollapsedChange={onDeadlineSectionCollapsedChange}
@@ -202,12 +311,17 @@ export function KanbanColumns({
202
311
  onUpdate={onUpdate}
203
312
  optimisticUpdateInProgress={optimisticUpdateInProgress}
204
313
  sections={deadlineSections}
314
+ loading={deadlineSectionsLoading}
205
315
  collapsedSections={deadlineSectionsCollapsed}
206
316
  deadlineNow={deadlineNow}
207
317
  pinnedSections={{
208
318
  overdue: specialTaskListPins?.overdue,
209
319
  upcoming: specialTaskListPins?.upcoming,
210
320
  }}
321
+ stickyOffsets={{
322
+ overdue: pinnedSpecialListLayout.offsets['deadline:overdue'],
323
+ upcoming: pinnedSpecialListLayout.offsets['deadline:upcoming'],
324
+ }}
211
325
  onSectionPinnedChange={(section, pinned) =>
212
326
  onSpecialTaskListPinnedChange?.(section, pinned)
213
327
  }
@@ -261,6 +375,8 @@ export function KanbanColumns({
261
375
  tasks={listTasks}
262
376
  availableLists={realColumns}
263
377
  isPersonalWorkspace={isPersonalWorkspace}
378
+ canUseBoardAssignees={canUseBoardAssignees}
379
+ assigneeMemberSource={assigneeMemberSource}
264
380
  onUpdate={onUpdate}
265
381
  onAddTask={() =>
266
382
  boardId && createTask(boardId, list.id, realColumns, filters)
@@ -280,6 +396,9 @@ export function KanbanColumns({
280
396
  wsId={workspaceId}
281
397
  onExternalTasksCollapsedChange={onExternalTasksCollapsedChange}
282
398
  onTaskListCollapsedChange={onTaskListCollapsedChange}
399
+ specialStickyOffset={
400
+ pinnedSpecialListLayout.offsets[`column:${list.id}`]
401
+ }
283
402
  specialPinned={
284
403
  list.is_external_staging
285
404
  ? specialTaskListPins?.external_tasks === true