@tuturuuu/ui 0.10.0 → 0.11.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.
- package/CHANGELOG.md +16 -0
- package/package.json +4 -4
- package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
- package/src/components/ui/finance/invoices/hooks.ts +11 -2
- package/src/components/ui/finance/invoices/internal-api.ts +5 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
- package/src/components/ui/finance/invoices/utils.test.ts +82 -0
- package/src/components/ui/finance/invoices/utils.ts +240 -7
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
- package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
- package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
- package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
- package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
- package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
|
@@ -69,6 +69,8 @@ export const getLinkedFinanceCategorySelection = (
|
|
|
69
69
|
|
|
70
70
|
const MONTH_VALUE_PATTERN = /^(\d{4})-(\d{2})$/;
|
|
71
71
|
const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
72
|
+
export const MAX_PREPAID_MONTH_COUNT = 12;
|
|
73
|
+
export const PREPAID_MONTH_OPTION_HORIZON = 12;
|
|
72
74
|
|
|
73
75
|
export const parseLocalCalendarDate = (
|
|
74
76
|
value: string | Date | null | undefined
|
|
@@ -122,6 +124,79 @@ export const formatMonthLabel = (month: string, locale: string): string => {
|
|
|
122
124
|
});
|
|
123
125
|
};
|
|
124
126
|
|
|
127
|
+
export const addMonthsToMonthValue = (
|
|
128
|
+
month: string,
|
|
129
|
+
monthOffset: number
|
|
130
|
+
): string => {
|
|
131
|
+
const date = getMonthStartDate(month);
|
|
132
|
+
if (Number.isNaN(date.getTime())) return month;
|
|
133
|
+
|
|
134
|
+
date.setMonth(date.getMonth() + monthOffset);
|
|
135
|
+
return formatMonthValue(date);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const normalizePrepaidMonthCount = (
|
|
139
|
+
value: number | null | undefined
|
|
140
|
+
): number => {
|
|
141
|
+
if (!Number.isFinite(value) || !value) return 1;
|
|
142
|
+
|
|
143
|
+
return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const getCoverageMonths = (
|
|
147
|
+
selectedMonth: string,
|
|
148
|
+
prepaidMonthCount = 1
|
|
149
|
+
): string[] => {
|
|
150
|
+
const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
|
|
151
|
+
const startMonth = getMonthStartDate(selectedMonth);
|
|
152
|
+
if (Number.isNaN(startMonth.getTime())) return [];
|
|
153
|
+
|
|
154
|
+
return Array.from({ length: monthCount }, (_, index) => {
|
|
155
|
+
const date = new Date(startMonth);
|
|
156
|
+
date.setMonth(date.getMonth() + index);
|
|
157
|
+
return formatMonthValue(date);
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const getCoverageEndMonthValue = (
|
|
162
|
+
selectedMonth: string,
|
|
163
|
+
prepaidMonthCount = 1
|
|
164
|
+
): string => {
|
|
165
|
+
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
166
|
+
return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const getCoverageValidUntilMonthValue = (
|
|
170
|
+
selectedMonth: string,
|
|
171
|
+
prepaidMonthCount = 1
|
|
172
|
+
): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
|
|
173
|
+
|
|
174
|
+
export const formatCoverageRangeLabel = ({
|
|
175
|
+
locale,
|
|
176
|
+
prepaidMonthCount,
|
|
177
|
+
selectedMonth,
|
|
178
|
+
}: {
|
|
179
|
+
locale: string;
|
|
180
|
+
prepaidMonthCount?: number | null;
|
|
181
|
+
selectedMonth: string;
|
|
182
|
+
}): string => {
|
|
183
|
+
const coverageMonths = getCoverageMonths(
|
|
184
|
+
selectedMonth,
|
|
185
|
+
prepaidMonthCount ?? 1
|
|
186
|
+
);
|
|
187
|
+
const firstMonth = coverageMonths[0];
|
|
188
|
+
const lastMonth = coverageMonths[coverageMonths.length - 1];
|
|
189
|
+
|
|
190
|
+
if (!firstMonth || !lastMonth || firstMonth === lastMonth) {
|
|
191
|
+
return formatMonthLabel(selectedMonth, locale);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `${formatMonthLabel(firstMonth, locale)} - ${formatMonthLabel(
|
|
195
|
+
lastMonth,
|
|
196
|
+
locale
|
|
197
|
+
)}`;
|
|
198
|
+
};
|
|
199
|
+
|
|
125
200
|
const getComparableTimestamp = (value: string | null | undefined): number => {
|
|
126
201
|
if (!value) return 0;
|
|
127
202
|
const timestamp = new Date(value).getTime();
|
|
@@ -183,6 +258,38 @@ export const isSubscriptionMonthPaidForGroup = (
|
|
|
183
258
|
getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
|
|
184
259
|
);
|
|
185
260
|
|
|
261
|
+
export const isSubscriptionRangePaidForGroup = (
|
|
262
|
+
groupId: string,
|
|
263
|
+
selectedMonth: string,
|
|
264
|
+
prepaidMonthCount: number,
|
|
265
|
+
latestInvoices: SubscriptionCoverageInvoice[]
|
|
266
|
+
): boolean => {
|
|
267
|
+
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
268
|
+
if (coverageMonths.length === 0) return false;
|
|
269
|
+
|
|
270
|
+
return coverageMonths.every((month) =>
|
|
271
|
+
isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const isSubscriptionRangeFullyPaidForGroups = (
|
|
276
|
+
groupIds: string[],
|
|
277
|
+
selectedMonth: string,
|
|
278
|
+
prepaidMonthCount: number,
|
|
279
|
+
latestInvoices: SubscriptionCoverageInvoice[]
|
|
280
|
+
): boolean => {
|
|
281
|
+
if (groupIds.length === 0) return false;
|
|
282
|
+
|
|
283
|
+
return groupIds.every((groupId) =>
|
|
284
|
+
isSubscriptionRangePaidForGroup(
|
|
285
|
+
groupId,
|
|
286
|
+
selectedMonth,
|
|
287
|
+
prepaidMonthCount,
|
|
288
|
+
latestInvoices
|
|
289
|
+
)
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
186
293
|
export const getAttendanceStats = (
|
|
187
294
|
attendance: AttendanceRecord[]
|
|
188
295
|
): AttendanceStats => {
|
|
@@ -283,6 +390,17 @@ export const getBillableSessionsForGroups = (
|
|
|
283
390
|
});
|
|
284
391
|
};
|
|
285
392
|
|
|
393
|
+
export const getBillableSessionsForGroupsInRange = (
|
|
394
|
+
userGroups: UserGroup[],
|
|
395
|
+
groupIds: string[],
|
|
396
|
+
selectedMonth: string,
|
|
397
|
+
prepaidMonthCount = 1,
|
|
398
|
+
latestInvoices: SubscriptionCoverageInvoice[] = []
|
|
399
|
+
): BillableSession[] =>
|
|
400
|
+
getCoverageMonths(selectedMonth, prepaidMonthCount).flatMap((month) =>
|
|
401
|
+
getBillableSessionsForGroups(userGroups, groupIds, month, latestInvoices)
|
|
402
|
+
);
|
|
403
|
+
|
|
286
404
|
export const getBillableAttendanceRecords = (
|
|
287
405
|
attendance: AttendanceRecord[],
|
|
288
406
|
groupIds: string[],
|
|
@@ -305,6 +423,17 @@ export const getBillableAttendanceRecords = (
|
|
|
305
423
|
});
|
|
306
424
|
};
|
|
307
425
|
|
|
426
|
+
export const getBillableAttendanceRecordsInRange = (
|
|
427
|
+
attendance: AttendanceRecord[],
|
|
428
|
+
groupIds: string[],
|
|
429
|
+
selectedMonth: string,
|
|
430
|
+
prepaidMonthCount = 1,
|
|
431
|
+
latestInvoices: SubscriptionCoverageInvoice[] = []
|
|
432
|
+
): AttendanceRecord[] =>
|
|
433
|
+
getCoverageMonths(selectedMonth, prepaidMonthCount).flatMap((month) =>
|
|
434
|
+
getBillableAttendanceRecords(attendance, groupIds, month, latestInvoices)
|
|
435
|
+
);
|
|
436
|
+
|
|
308
437
|
export const getSessionsForMonth = (
|
|
309
438
|
sessionsArray: string[] | null,
|
|
310
439
|
month: string
|
|
@@ -453,26 +582,35 @@ export const getAvailableMonths = (
|
|
|
453
582
|
groupIds: string[],
|
|
454
583
|
latestInvoices: SubscriptionCoverageInvoice[],
|
|
455
584
|
locale: string,
|
|
456
|
-
selectedMonthFallback: string | null = null
|
|
585
|
+
selectedMonthFallback: string | null = null,
|
|
586
|
+
futureMonthHorizon = 0
|
|
457
587
|
): AvailableMonthOption[] => {
|
|
458
588
|
if (groupIds.length === 0) return [];
|
|
459
589
|
const { earliestStart, latestEnd } = getGroupsDateRange(userGroups, groupIds);
|
|
460
590
|
if (!earliestStart) return [];
|
|
461
591
|
|
|
592
|
+
const currentMonthStart = (() => {
|
|
593
|
+
const date = new Date();
|
|
594
|
+
date.setDate(1);
|
|
595
|
+
return date;
|
|
596
|
+
})();
|
|
597
|
+
const futureHorizonEnd = new Date(currentMonthStart);
|
|
598
|
+
futureHorizonEnd.setMonth(
|
|
599
|
+
futureHorizonEnd.getMonth() + Math.max(0, futureMonthHorizon)
|
|
600
|
+
);
|
|
601
|
+
|
|
462
602
|
const resolvedLatestEnd = latestEnd
|
|
463
603
|
? latestEnd
|
|
464
604
|
: selectedMonthFallback
|
|
465
605
|
? getMonthStartDate(selectedMonthFallback)
|
|
466
|
-
:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return d;
|
|
470
|
-
})();
|
|
606
|
+
: currentMonthStart;
|
|
607
|
+
const resolvedRangeEnd =
|
|
608
|
+
futureMonthHorizon > 0 && !latestEnd ? futureHorizonEnd : resolvedLatestEnd;
|
|
471
609
|
|
|
472
610
|
const months: AvailableMonthOption[] = [];
|
|
473
611
|
const currentDate = new Date(earliestStart);
|
|
474
612
|
currentDate.setDate(1);
|
|
475
|
-
const normalizedLatestEnd = new Date(
|
|
613
|
+
const normalizedLatestEnd = new Date(resolvedRangeEnd);
|
|
476
614
|
normalizedLatestEnd.setDate(1);
|
|
477
615
|
|
|
478
616
|
while (currentDate <= normalizedLatestEnd) {
|
|
@@ -504,6 +642,101 @@ export const getTotalSessionsForGroups = (
|
|
|
504
642
|
).length;
|
|
505
643
|
};
|
|
506
644
|
|
|
645
|
+
export const getBillableQuantityForGroupRange = ({
|
|
646
|
+
groupId,
|
|
647
|
+
latestInvoices,
|
|
648
|
+
now = new Date(),
|
|
649
|
+
prepaidMonthCount = 1,
|
|
650
|
+
selectedMonth,
|
|
651
|
+
useAttendanceBased,
|
|
652
|
+
userAttendance,
|
|
653
|
+
userGroups,
|
|
654
|
+
}: {
|
|
655
|
+
groupId: string;
|
|
656
|
+
latestInvoices?: SubscriptionCoverageInvoice[];
|
|
657
|
+
now?: Date;
|
|
658
|
+
prepaidMonthCount?: number;
|
|
659
|
+
selectedMonth: string;
|
|
660
|
+
useAttendanceBased: boolean;
|
|
661
|
+
userAttendance: AttendanceRecord[];
|
|
662
|
+
userGroups: UserGroup[];
|
|
663
|
+
}): number => {
|
|
664
|
+
const currentMonthStart = getMonthStartDate(now);
|
|
665
|
+
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
666
|
+
|
|
667
|
+
return coverageMonths.reduce((total, month) => {
|
|
668
|
+
if (isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices ?? [])) {
|
|
669
|
+
return total;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const monthStart = getMonthStartDate(month);
|
|
673
|
+
const useScheduledSessions =
|
|
674
|
+
!useAttendanceBased ||
|
|
675
|
+
(!Number.isNaN(monthStart.getTime()) &&
|
|
676
|
+
!Number.isNaN(currentMonthStart.getTime()) &&
|
|
677
|
+
monthStart > currentMonthStart);
|
|
678
|
+
|
|
679
|
+
if (useScheduledSessions) {
|
|
680
|
+
return (
|
|
681
|
+
total +
|
|
682
|
+
getBillableSessionsForGroups(
|
|
683
|
+
userGroups,
|
|
684
|
+
[groupId],
|
|
685
|
+
month,
|
|
686
|
+
latestInvoices
|
|
687
|
+
).length
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
total +
|
|
693
|
+
getEffectiveAttendanceDays(
|
|
694
|
+
getBillableAttendanceRecords(
|
|
695
|
+
userAttendance,
|
|
696
|
+
[groupId],
|
|
697
|
+
month,
|
|
698
|
+
latestInvoices
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
);
|
|
702
|
+
}, 0);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
export const getBillableQuantityMapForGroupsRange = ({
|
|
706
|
+
groupIds,
|
|
707
|
+
latestInvoices,
|
|
708
|
+
now,
|
|
709
|
+
prepaidMonthCount = 1,
|
|
710
|
+
selectedMonth,
|
|
711
|
+
useAttendanceBased,
|
|
712
|
+
userAttendance,
|
|
713
|
+
userGroups,
|
|
714
|
+
}: {
|
|
715
|
+
groupIds: string[];
|
|
716
|
+
latestInvoices?: SubscriptionCoverageInvoice[];
|
|
717
|
+
now?: Date;
|
|
718
|
+
prepaidMonthCount?: number;
|
|
719
|
+
selectedMonth: string;
|
|
720
|
+
useAttendanceBased: boolean;
|
|
721
|
+
userAttendance: AttendanceRecord[];
|
|
722
|
+
userGroups: UserGroup[];
|
|
723
|
+
}): Record<string, number> =>
|
|
724
|
+
Object.fromEntries(
|
|
725
|
+
groupIds.map((groupId) => [
|
|
726
|
+
groupId,
|
|
727
|
+
getBillableQuantityForGroupRange({
|
|
728
|
+
groupId,
|
|
729
|
+
latestInvoices,
|
|
730
|
+
now,
|
|
731
|
+
prepaidMonthCount,
|
|
732
|
+
selectedMonth,
|
|
733
|
+
useAttendanceBased,
|
|
734
|
+
userAttendance,
|
|
735
|
+
userGroups,
|
|
736
|
+
}),
|
|
737
|
+
])
|
|
738
|
+
);
|
|
739
|
+
|
|
507
740
|
/** Days before valid_until to consider "expiring soon" */
|
|
508
741
|
const EXPIRING_SOON_DAYS = 14;
|
|
509
742
|
|
|
@@ -181,6 +181,9 @@ describe('BoardClient', () => {
|
|
|
181
181
|
'-m-4'
|
|
182
182
|
);
|
|
183
183
|
expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
|
|
184
|
+
expect(
|
|
185
|
+
screen.queryByTestId('task-board-header-skeleton')
|
|
186
|
+
).not.toBeInTheDocument();
|
|
184
187
|
expect(screen.queryByText('Loading board...')).not.toBeInTheDocument();
|
|
185
188
|
});
|
|
186
189
|
|
|
@@ -210,6 +213,10 @@ describe('BoardClient', () => {
|
|
|
210
213
|
'h-[calc(100dvh+2rem)]',
|
|
211
214
|
'w-[calc(100%+2rem)]'
|
|
212
215
|
);
|
|
216
|
+
expect(
|
|
217
|
+
screen.getByTestId('task-board-header-skeleton')
|
|
218
|
+
).toBeInTheDocument();
|
|
219
|
+
expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
|
|
213
220
|
});
|
|
214
221
|
|
|
215
222
|
it('can revalidate loaded board lists without invalidating visible task caches', async () => {
|
|
@@ -20,6 +20,9 @@ describe('TaskBoardLoadingState', () => {
|
|
|
20
20
|
'pr-0'
|
|
21
21
|
);
|
|
22
22
|
expect(screen.getByTestId('kanban-skeleton-frame')).not.toHaveClass('p-2');
|
|
23
|
+
expect(
|
|
24
|
+
screen.queryByTestId('task-board-header-skeleton')
|
|
25
|
+
).not.toBeInTheDocument();
|
|
23
26
|
});
|
|
24
27
|
|
|
25
28
|
it('keeps embedded loading skeletons constrained to the parent width', () => {
|
|
@@ -34,5 +37,39 @@ describe('TaskBoardLoadingState', () => {
|
|
|
34
37
|
'w-[calc(100%+2rem)]'
|
|
35
38
|
);
|
|
36
39
|
expect(screen.getByTestId('kanban-skeleton-frame')).toHaveClass('p-2');
|
|
40
|
+
expect(
|
|
41
|
+
screen.queryByTestId('task-board-header-skeleton')
|
|
42
|
+
).not.toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('can include the board header skeleton above the kanban skeleton', () => {
|
|
46
|
+
render(<TaskBoardLoadingState root showHeader />);
|
|
47
|
+
|
|
48
|
+
expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
|
|
49
|
+
'-m-4',
|
|
50
|
+
'h-[calc(100dvh+2rem)]',
|
|
51
|
+
'w-[calc(100%+2rem)]',
|
|
52
|
+
'flex',
|
|
53
|
+
'flex-col'
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByTestId('task-board-header-skeleton')).toHaveAttribute(
|
|
56
|
+
'aria-hidden',
|
|
57
|
+
'true'
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByTestId('task-board-header-skeleton')).toHaveClass(
|
|
60
|
+
'border-b',
|
|
61
|
+
'px-2',
|
|
62
|
+
'pt-2',
|
|
63
|
+
'pb-2'
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByTestId('task-board-header-skeleton')).not.toHaveClass(
|
|
66
|
+
'-mt-2'
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId('task-board-loading-body')).toHaveClass(
|
|
69
|
+
'min-h-0',
|
|
70
|
+
'flex-1',
|
|
71
|
+
'overflow-hidden'
|
|
72
|
+
);
|
|
73
|
+
expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
|
|
37
74
|
});
|
|
38
75
|
});
|
|
@@ -247,7 +247,9 @@ export function BoardClient({
|
|
|
247
247
|
]);
|
|
248
248
|
|
|
249
249
|
if (boardLoading && !board) {
|
|
250
|
-
return
|
|
250
|
+
return (
|
|
251
|
+
<TaskBoardLoadingState root={rootLoading} showHeader={rootLoading} />
|
|
252
|
+
);
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
if (!board?.id) {
|
|
@@ -1,14 +1,55 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { Skeleton } from '@tuturuuu/ui/skeleton';
|
|
3
4
|
import { cn } from '@tuturuuu/utils/format';
|
|
4
5
|
import { KanbanSkeleton } from '../boards/boardId/kanban/rendering/kanban-skeleton';
|
|
5
6
|
|
|
7
|
+
const HEADER_ACTIONS = [
|
|
8
|
+
'focus',
|
|
9
|
+
'select',
|
|
10
|
+
'view',
|
|
11
|
+
'status',
|
|
12
|
+
'sort',
|
|
13
|
+
'settings',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function TaskBoardHeaderSkeleton() {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
className="border-b px-2 pt-2 pb-2"
|
|
21
|
+
data-testid="task-board-header-skeleton"
|
|
22
|
+
>
|
|
23
|
+
<div className="flex flex-wrap items-center justify-between gap-1.5 sm:gap-2">
|
|
24
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
25
|
+
<Skeleton className="h-7 w-44 rounded-md sm:h-8 sm:w-56" />
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div className="min-w-0 flex-1 basis-72">
|
|
29
|
+
<Skeleton className="h-6 w-full rounded-md sm:h-8" />
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
|
33
|
+
{HEADER_ACTIONS.map((action) => (
|
|
34
|
+
<Skeleton
|
|
35
|
+
className="h-7 w-7 rounded-md sm:h-8 sm:w-8"
|
|
36
|
+
key={action}
|
|
37
|
+
/>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
export function TaskBoardLoadingState({
|
|
7
46
|
className,
|
|
8
47
|
root = false,
|
|
48
|
+
showHeader = false,
|
|
9
49
|
}: {
|
|
10
50
|
className?: string;
|
|
11
51
|
root?: boolean;
|
|
52
|
+
showHeader?: boolean;
|
|
12
53
|
}) {
|
|
13
54
|
return (
|
|
14
55
|
<div
|
|
@@ -18,11 +59,24 @@ export function TaskBoardLoadingState({
|
|
|
18
59
|
root
|
|
19
60
|
? '-m-4 h-[calc(100dvh+2rem)] min-h-[calc(32rem+2rem)] w-[calc(100%+2rem)] min-w-[calc(100%+2rem)]'
|
|
20
61
|
: 'h-[calc(100dvh-1rem)] min-h-[32rem] w-full',
|
|
62
|
+
showHeader && 'flex flex-col',
|
|
21
63
|
className
|
|
22
64
|
)}
|
|
23
65
|
data-testid="task-board-loading-state"
|
|
24
66
|
>
|
|
25
|
-
|
|
67
|
+
{showHeader ? (
|
|
68
|
+
<>
|
|
69
|
+
<TaskBoardHeaderSkeleton />
|
|
70
|
+
<div
|
|
71
|
+
className="min-h-0 flex-1 overflow-hidden"
|
|
72
|
+
data-testid="task-board-loading-body"
|
|
73
|
+
>
|
|
74
|
+
<KanbanSkeleton root={root} />
|
|
75
|
+
</div>
|
|
76
|
+
</>
|
|
77
|
+
) : (
|
|
78
|
+
<KanbanSkeleton root={root} />
|
|
79
|
+
)}
|
|
26
80
|
</div>
|
|
27
81
|
);
|
|
28
82
|
}
|
package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx
CHANGED
|
@@ -54,6 +54,7 @@ export interface TaskDescriptionEditorProps {
|
|
|
54
54
|
onImageUpload?: (file: File) => Promise<string>;
|
|
55
55
|
onEditorReady: (editor: Editor) => void;
|
|
56
56
|
onConvertToTask?: () => void | Promise<void>;
|
|
57
|
+
onDescriptionSnapshotChange?: (description: JSONContent | null) => void;
|
|
57
58
|
onDescriptionStorageLengthChange: (storageLength: number) => void;
|
|
58
59
|
descriptionStorageLength: number;
|
|
59
60
|
descriptionPercentLeft: number;
|
|
@@ -109,6 +110,7 @@ export function TaskDescriptionEditor({
|
|
|
109
110
|
onImageUpload,
|
|
110
111
|
onEditorReady,
|
|
111
112
|
onConvertToTask,
|
|
113
|
+
onDescriptionSnapshotChange,
|
|
112
114
|
onDescriptionStorageLengthChange,
|
|
113
115
|
descriptionStorageLength,
|
|
114
116
|
descriptionPercentLeft,
|
|
@@ -290,6 +292,7 @@ export function TaskDescriptionEditor({
|
|
|
290
292
|
content={description}
|
|
291
293
|
onChange={setDescription}
|
|
292
294
|
onImmediateChange={(nextDescription) => {
|
|
295
|
+
onDescriptionSnapshotChange?.(nextDescription);
|
|
293
296
|
if (allowYjsSync) {
|
|
294
297
|
setDescription(nextDescription);
|
|
295
298
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildRecoverableTaskDescriptionVersions,
|
|
4
|
+
extractRecoverableTaskDescriptionValue,
|
|
5
|
+
} from './description-versions';
|
|
6
|
+
|
|
7
|
+
const makeDoc = (text: string) =>
|
|
8
|
+
JSON.stringify({
|
|
9
|
+
type: 'doc',
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: 'paragraph',
|
|
13
|
+
content: [{ type: 'text', text }],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('task description tracked versions', () => {
|
|
19
|
+
it('does not treat null, empty docs, or legacy placeholders as recoverable', () => {
|
|
20
|
+
expect(extractRecoverableTaskDescriptionValue(null)).toBeNull();
|
|
21
|
+
expect(extractRecoverableTaskDescriptionValue('')).toBeNull();
|
|
22
|
+
expect(extractRecoverableTaskDescriptionValue('has_content')).toBeNull();
|
|
23
|
+
expect(
|
|
24
|
+
extractRecoverableTaskDescriptionValue(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
type: 'doc',
|
|
27
|
+
content: [{ type: 'paragraph' }],
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('normalizes plain text and serialized TipTap content', () => {
|
|
34
|
+
const plain = extractRecoverableTaskDescriptionValue('Plain description');
|
|
35
|
+
const serialized = extractRecoverableTaskDescriptionValue(
|
|
36
|
+
makeDoc('Serialized description')
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(plain?.description).toContain('Plain description');
|
|
40
|
+
expect(plain?.previewText).toBe('Plain description');
|
|
41
|
+
expect(serialized?.description).toContain('Serialized description');
|
|
42
|
+
expect(serialized?.previewText).toBe('Serialized description');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('prefers the previous value from the latest wipe entry', () => {
|
|
46
|
+
const versions = buildRecoverableTaskDescriptionVersions([
|
|
47
|
+
{
|
|
48
|
+
id: 'older',
|
|
49
|
+
changed_at: '2026-06-25T10:00:00.000Z',
|
|
50
|
+
change_type: 'field_updated',
|
|
51
|
+
field_name: 'description',
|
|
52
|
+
old_value: makeDoc('Older version'),
|
|
53
|
+
new_value: makeDoc('Intermediate version'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'wipe',
|
|
57
|
+
changed_at: '2026-06-26T10:00:00.000Z',
|
|
58
|
+
change_type: 'field_updated',
|
|
59
|
+
field_name: 'description',
|
|
60
|
+
old_value: makeDoc('Latest real version'),
|
|
61
|
+
new_value: null,
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(versions[0]?.historyId).toBe('wipe');
|
|
66
|
+
expect(versions[0]?.source).toBe('old_value');
|
|
67
|
+
expect(versions[0]?.reason).toBe('before_clear');
|
|
68
|
+
expect(versions[0]?.previewText).toBe('Latest real version');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('dedupes identical canonical descriptions while keeping newest first', () => {
|
|
72
|
+
const duplicated = makeDoc('Same description');
|
|
73
|
+
const versions = buildRecoverableTaskDescriptionVersions([
|
|
74
|
+
{
|
|
75
|
+
id: 'newer',
|
|
76
|
+
changed_at: '2026-06-26T10:00:00.000Z',
|
|
77
|
+
change_type: 'field_updated',
|
|
78
|
+
field_name: 'description',
|
|
79
|
+
old_value: makeDoc('Different description'),
|
|
80
|
+
new_value: duplicated,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'older',
|
|
84
|
+
changed_at: '2026-06-25T10:00:00.000Z',
|
|
85
|
+
change_type: 'field_updated',
|
|
86
|
+
field_name: 'description',
|
|
87
|
+
old_value: duplicated,
|
|
88
|
+
new_value: duplicated,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(versions.map((version) => version.previewText)).toEqual([
|
|
93
|
+
'Same description',
|
|
94
|
+
'Different description',
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
});
|