@tuturuuu/utils 0.24.2 → 0.25.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 +14 -0
- package/biome.json +1 -1
- package/package.json +9 -9
- package/src/__tests__/optimistic-task-cache.test.ts +117 -0
- package/src/__tests__/task-reorder-cache.test.ts +18 -0
- package/src/next-config.test.ts +11 -0
- package/src/next-config.ts +6 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task/reorder.ts +40 -15
- package/src/task-helper/optimistic-task-cache.ts +105 -0
- package/src/task-helper/task-hooks-basic.ts +20 -47
- package/src/task-helper.ts +1 -0
- package/src/workspace-helper.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.0](https://github.com/tutur3u/platform/compare/utils-v0.24.2...utils-v0.25.0) (2026-08-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **workspaces:** assign roles to pending invites ([#5125](https://github.com/tutur3u/platform/issues/5125)) ([d4052fd](https://github.com/tutur3u/platform/commit/d4052fd40de66bda4e9535740bbcfd5a8da19123))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **contacts:** harden satellite workspace access ([b160723](https://github.com/tutur3u/platform/commit/b160723b509062a4f8edf2694c0af27c25c05775))
|
|
14
|
+
* **tasks:** keep dropped cards in destination ([de9353b](https://github.com/tutur3u/platform/commit/de9353b0b3574b240c0caa478ba52935d3ad00bb))
|
|
15
|
+
* **tasks:** render dropped tasks immediately ([1dcdb65](https://github.com/tutur3u/platform/commit/1dcdb655ff960c59f91b93b7f13622d1b65f6f48))
|
|
16
|
+
|
|
3
17
|
## [0.24.2](https://github.com/tutur3u/platform/compare/utils-v0.24.1...utils-v0.24.2) (2026-08-11)
|
|
4
18
|
|
|
5
19
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.25.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@dnd-kit/core": "^6.3.1",
|
|
20
|
-
"@noble/ciphers": "^2.
|
|
21
|
-
"@noble/hashes": "^2.
|
|
20
|
+
"@noble/ciphers": "^2.3.0",
|
|
21
|
+
"@noble/hashes": "^2.3.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.4",
|
|
23
|
-
"@tiptap/core": "3.
|
|
24
|
-
"@tiptap/react": "3.
|
|
23
|
+
"@tiptap/core": "3.30.0",
|
|
24
|
+
"@tiptap/react": "3.30.0",
|
|
25
25
|
"@tuturuuu/google": "0.1.0",
|
|
26
26
|
"@tuturuuu/icons": "0.1.0",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.30.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.0",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.2",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"diff": "^9.0.0",
|
|
35
35
|
"effect": "^3.22.1",
|
|
36
36
|
"moment": "^2.30.1",
|
|
37
|
-
"next": "^16.3.
|
|
37
|
+
"next": "^16.3.1",
|
|
38
38
|
"prosemirror-model": "^1.25.11",
|
|
39
39
|
"react": "^19.2.8",
|
|
40
40
|
"react-dom": "^19.2.8",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"@tuturuuu/types": "0.27.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
|
-
"@types/node": "^26.
|
|
51
|
+
"@types/node": "^26.2.0",
|
|
52
52
|
"@types/react": "^19.2.18",
|
|
53
53
|
"@vitejs/plugin-react": "^6.0.5",
|
|
54
54
|
"typescript": "7.0.2",
|
|
55
|
-
"vite": "^8.2.
|
|
55
|
+
"vite": "^8.2.1",
|
|
56
56
|
"vitest": "^4.1.10"
|
|
57
57
|
},
|
|
58
58
|
"exports": {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
insertOptimisticTaskIntoBoardCaches,
|
|
6
|
+
reconcileOptimisticTaskInBoardCaches,
|
|
7
|
+
removeOptimisticTaskFromBoardCaches,
|
|
8
|
+
} from '../task-helper/optimistic-task-cache';
|
|
9
|
+
|
|
10
|
+
function task(id: string, listId = 'list-1'): Task {
|
|
11
|
+
return {
|
|
12
|
+
assignees: [],
|
|
13
|
+
created_at: '2026-08-14T00:00:00.000Z',
|
|
14
|
+
id,
|
|
15
|
+
labels: [],
|
|
16
|
+
list_id: listId,
|
|
17
|
+
name: id,
|
|
18
|
+
projects: [],
|
|
19
|
+
} as unknown as Task;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('optimistic task creation cache lifecycle', () => {
|
|
23
|
+
it('inserts the pending task into the board and every mounted full-task variant', () => {
|
|
24
|
+
const queryClient = new QueryClient();
|
|
25
|
+
const boardId = 'board-1';
|
|
26
|
+
const existingTask = task('existing');
|
|
27
|
+
const optimisticTask = {
|
|
28
|
+
...task('optimistic-1'),
|
|
29
|
+
_isOptimistic: true,
|
|
30
|
+
} as Task;
|
|
31
|
+
|
|
32
|
+
queryClient.setQueryData(['tasks', boardId], [existingTask]);
|
|
33
|
+
queryClient.setQueryData(['tasks-full', boardId], [existingTask]);
|
|
34
|
+
queryClient.setQueryData(
|
|
35
|
+
['tasks-full', boardId, 'filtered'],
|
|
36
|
+
[existingTask]
|
|
37
|
+
);
|
|
38
|
+
queryClient.setQueryData(['tasks-full', 'another-board'], [existingTask]);
|
|
39
|
+
|
|
40
|
+
insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
|
|
41
|
+
|
|
42
|
+
expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
|
|
43
|
+
existingTask,
|
|
44
|
+
optimisticTask,
|
|
45
|
+
]);
|
|
46
|
+
expect(queryClient.getQueryData(['tasks-full', boardId])).toEqual([
|
|
47
|
+
existingTask,
|
|
48
|
+
optimisticTask,
|
|
49
|
+
]);
|
|
50
|
+
expect(
|
|
51
|
+
queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
|
|
52
|
+
).toEqual([existingTask, optimisticTask]);
|
|
53
|
+
expect(queryClient.getQueryData(['tasks-full', 'another-board'])).toEqual([
|
|
54
|
+
existingTask,
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('replaces the pending task without duplicating a realtime server task', () => {
|
|
59
|
+
const queryClient = new QueryClient();
|
|
60
|
+
const boardId = 'board-1';
|
|
61
|
+
const optimisticTask = {
|
|
62
|
+
...task('optimistic-1'),
|
|
63
|
+
_isOptimistic: true,
|
|
64
|
+
} as Task;
|
|
65
|
+
const createdTask = task('created-1');
|
|
66
|
+
|
|
67
|
+
queryClient.setQueryData(['tasks', boardId], [optimisticTask]);
|
|
68
|
+
queryClient.setQueryData(
|
|
69
|
+
['tasks-full', boardId, 'filtered'],
|
|
70
|
+
[optimisticTask, createdTask]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
reconcileOptimisticTaskInBoardCaches(
|
|
74
|
+
queryClient,
|
|
75
|
+
boardId,
|
|
76
|
+
optimisticTask.id,
|
|
77
|
+
createdTask
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(queryClient.getQueryData(['tasks', boardId])).toEqual([createdTask]);
|
|
81
|
+
expect(
|
|
82
|
+
queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
|
|
83
|
+
).toEqual([createdTask]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rolls back only the failed optimistic task', () => {
|
|
87
|
+
const queryClient = new QueryClient();
|
|
88
|
+
const boardId = 'board-1';
|
|
89
|
+
const optimisticTask = {
|
|
90
|
+
...task('optimistic-1'),
|
|
91
|
+
_isOptimistic: true,
|
|
92
|
+
} as Task;
|
|
93
|
+
const concurrentTask = task('concurrent');
|
|
94
|
+
|
|
95
|
+
queryClient.setQueryData(
|
|
96
|
+
['tasks', boardId],
|
|
97
|
+
[optimisticTask, concurrentTask]
|
|
98
|
+
);
|
|
99
|
+
queryClient.setQueryData(
|
|
100
|
+
['tasks-full', boardId, 'filtered'],
|
|
101
|
+
[optimisticTask, concurrentTask]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
removeOptimisticTaskFromBoardCaches(
|
|
105
|
+
queryClient,
|
|
106
|
+
boardId,
|
|
107
|
+
optimisticTask.id
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
|
|
111
|
+
concurrentTask,
|
|
112
|
+
]);
|
|
113
|
+
expect(
|
|
114
|
+
queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
|
|
115
|
+
).toEqual([concurrentTask]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
1
2
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
2
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
4
|
|
|
4
5
|
import {
|
|
6
|
+
cancelStaleTaskBoardQueries,
|
|
5
7
|
mergeOptimisticReorderedTaskIntoCache,
|
|
6
8
|
mergeServerReorderedTaskIntoCache,
|
|
7
9
|
} from '../task/reorder';
|
|
@@ -19,6 +21,22 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
describe('reorder task cache helpers', () => {
|
|
24
|
+
it('cancels stale board fetches without reverting the dropped destination', () => {
|
|
25
|
+
const queryClient = new QueryClient();
|
|
26
|
+
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
27
|
+
|
|
28
|
+
cancelStaleTaskBoardQueries(queryClient, 'board-1');
|
|
29
|
+
|
|
30
|
+
expect(cancelQueries).toHaveBeenCalledWith(
|
|
31
|
+
{ queryKey: ['tasks', 'board-1'] },
|
|
32
|
+
{ revert: false }
|
|
33
|
+
);
|
|
34
|
+
expect(cancelQueries).toHaveBeenCalledWith(
|
|
35
|
+
{ queryKey: ['tasks-full', 'board-1'] },
|
|
36
|
+
{ revert: false }
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
22
40
|
it('marks optimistic list moves so stale list loads cannot move the card back', () => {
|
|
23
41
|
vi.useFakeTimers();
|
|
24
42
|
vi.setSystemTime(new Date('2026-05-07T01:00:00.000Z'));
|
package/src/next-config.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isTuturuuuTurbopackRustReactCompilerEnabled,
|
|
9
9
|
resolveTuturuuuInfrastructureAppUrl,
|
|
10
10
|
resolveTuturuuuWebAppUrl,
|
|
11
|
+
TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
|
|
11
12
|
TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
|
|
12
13
|
TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
|
|
13
14
|
trimTrailingSlashes,
|
|
@@ -26,6 +27,9 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
26
27
|
expect(config.images?.remotePatterns).toEqual(
|
|
27
28
|
TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS
|
|
28
29
|
);
|
|
30
|
+
expect(config.images?.minimumCacheTTL).toBe(
|
|
31
|
+
TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS
|
|
32
|
+
);
|
|
29
33
|
expect(config.poweredByHeader).toBe(false);
|
|
30
34
|
expect(config.reactCompiler).toBe(true);
|
|
31
35
|
expect(config.reactStrictMode).toBe(true);
|
|
@@ -198,6 +202,7 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
198
202
|
partialPrefetching: false,
|
|
199
203
|
reactCompiler: false,
|
|
200
204
|
transpilePackages: ['@tuturuuu/ui'],
|
|
205
|
+
images: { minimumCacheTTL: 86_400 },
|
|
201
206
|
experimental: {
|
|
202
207
|
cpus: 2,
|
|
203
208
|
turbopackFileSystemCacheForBuild: false,
|
|
@@ -213,6 +218,7 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
213
218
|
expect(config.partialPrefetching).toBe(false);
|
|
214
219
|
expect(config.reactCompiler).toBe(true);
|
|
215
220
|
expect(config.transpilePackages).toEqual(['@tuturuuu/ui']);
|
|
221
|
+
expect(config.images?.minimumCacheTTL).toBe(86_400);
|
|
216
222
|
expect(config.experimental?.cpus).toBe(2);
|
|
217
223
|
expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
|
|
218
224
|
expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
|
|
@@ -259,6 +265,11 @@ describe('createTuturuuuWebWorkspaceApiRewrites', () => {
|
|
|
259
265
|
source: '/api/workspaces/invitations',
|
|
260
266
|
destination: 'https://tuturuuu.com/api/workspaces/invitations',
|
|
261
267
|
},
|
|
268
|
+
{
|
|
269
|
+
source: '/api/v1/workspaces/:wsId/settings/permissions',
|
|
270
|
+
destination:
|
|
271
|
+
'https://tuturuuu.com/api/v1/workspaces/:wsId/settings/permissions',
|
|
272
|
+
},
|
|
262
273
|
]);
|
|
263
274
|
});
|
|
264
275
|
});
|
package/src/next-config.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS = [
|
|
|
15
15
|
|
|
16
16
|
export const TUTURUUU_WEB_WORKSPACE_API_RESERVED_PATHS = [
|
|
17
17
|
'/api/workspaces/invitations',
|
|
18
|
+
'/api/v1/workspaces/:wsId/settings/permissions',
|
|
18
19
|
] as const;
|
|
19
20
|
|
|
20
21
|
type NextImageConfig = NonNullable<NextConfig['images']>;
|
|
@@ -37,6 +38,8 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
|
|
|
37
38
|
},
|
|
38
39
|
] satisfies NextImageRemotePattern[];
|
|
39
40
|
|
|
41
|
+
export const TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
42
|
+
|
|
40
43
|
const TUTURUUU_ANTI_FRAMING_SOURCE =
|
|
41
44
|
'/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)';
|
|
42
45
|
|
|
@@ -111,6 +114,9 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
|
|
|
111
114
|
),
|
|
112
115
|
images: {
|
|
113
116
|
...imageConfig,
|
|
117
|
+
minimumCacheTTL:
|
|
118
|
+
imageConfig.minimumCacheTTL ??
|
|
119
|
+
TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
|
|
114
120
|
remotePatterns: mergeRemotePatterns(
|
|
115
121
|
TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
|
|
116
122
|
imageConfig.remotePatterns
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.37.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.37.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
package/src/task/reorder.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type QueryClient,
|
|
3
|
+
type QueryKey,
|
|
3
4
|
useMutation,
|
|
4
5
|
useQueryClient,
|
|
5
6
|
} from '@tanstack/react-query';
|
|
@@ -31,6 +32,7 @@ type ReorderTaskMutationInput = {
|
|
|
31
32
|
newSortKey: number;
|
|
32
33
|
optimisticPreviousTasks?: Task[];
|
|
33
34
|
optimisticPreviousFullTasks?: Task[];
|
|
35
|
+
optimisticPreviousFullTaskEntries?: [QueryKey, Task[] | undefined][];
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
export function mergeOptimisticReorderedTaskIntoCache(
|
|
@@ -93,9 +95,24 @@ function setReorderedTaskCache(
|
|
|
93
95
|
) {
|
|
94
96
|
queryClient.setQueryData(['tasks', boardId], updater);
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
queryClient.setQueriesData<Task[]>(
|
|
99
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
100
|
+
updater
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function cancelStaleTaskBoardQueries(
|
|
105
|
+
queryClient: QueryClient,
|
|
106
|
+
boardId: string
|
|
107
|
+
) {
|
|
108
|
+
void queryClient.cancelQueries(
|
|
109
|
+
{ queryKey: ['tasks', boardId] },
|
|
110
|
+
{ revert: false }
|
|
111
|
+
);
|
|
112
|
+
void queryClient.cancelQueries(
|
|
113
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
114
|
+
{ revert: false }
|
|
115
|
+
);
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
// Reorder task within the same list or move to a different list with specific position
|
|
@@ -139,19 +156,21 @@ export function useReorderTask(boardId: string, wsId: string) {
|
|
|
139
156
|
newSortKey,
|
|
140
157
|
optimisticPreviousTasks,
|
|
141
158
|
optimisticPreviousFullTasks,
|
|
159
|
+
optimisticPreviousFullTaskEntries,
|
|
142
160
|
}) => {
|
|
143
161
|
// Snapshot the previous value
|
|
144
162
|
const previousTasks = queryClient.getQueryData<Task[]>([
|
|
145
163
|
'tasks',
|
|
146
164
|
boardId,
|
|
147
165
|
]);
|
|
148
|
-
const
|
|
149
|
-
'tasks-full',
|
|
150
|
-
|
|
151
|
-
]);
|
|
166
|
+
const previousFullTaskEntries = queryClient.getQueriesData<Task[]>({
|
|
167
|
+
queryKey: ['tasks-full', boardId],
|
|
168
|
+
});
|
|
152
169
|
|
|
153
|
-
//
|
|
154
|
-
|
|
170
|
+
// Keep the drop preview visible while cancelling stale board responses.
|
|
171
|
+
// The default cancellation behavior reverts to the pre-fetch snapshot,
|
|
172
|
+
// which makes a dropped card briefly jump back to its source list.
|
|
173
|
+
cancelStaleTaskBoardQueries(queryClient, boardId);
|
|
155
174
|
|
|
156
175
|
// Check if moving to a done or closed list
|
|
157
176
|
const targetList = queryClient.getQueryData(['task_lists', boardId]) as
|
|
@@ -194,7 +213,16 @@ export function useReorderTask(boardId: string, wsId: string) {
|
|
|
194
213
|
|
|
195
214
|
return {
|
|
196
215
|
previousTasks: optimisticPreviousTasks ?? previousTasks,
|
|
197
|
-
|
|
216
|
+
previousFullTaskEntries:
|
|
217
|
+
optimisticPreviousFullTaskEntries ??
|
|
218
|
+
(optimisticPreviousFullTasks
|
|
219
|
+
? [
|
|
220
|
+
[['tasks-full', boardId], optimisticPreviousFullTasks] as [
|
|
221
|
+
QueryKey,
|
|
222
|
+
Task[],
|
|
223
|
+
],
|
|
224
|
+
]
|
|
225
|
+
: previousFullTaskEntries),
|
|
198
226
|
blockedTaskIdsPromise,
|
|
199
227
|
};
|
|
200
228
|
},
|
|
@@ -202,11 +230,8 @@ export function useReorderTask(boardId: string, wsId: string) {
|
|
|
202
230
|
if (context?.previousTasks) {
|
|
203
231
|
queryClient.setQueryData(['tasks', boardId], context.previousTasks);
|
|
204
232
|
}
|
|
205
|
-
|
|
206
|
-
queryClient.setQueryData(
|
|
207
|
-
['tasks-full', boardId],
|
|
208
|
-
context.previousFullTasks
|
|
209
|
-
);
|
|
233
|
+
for (const [queryKey, tasks] of context?.previousFullTaskEntries ?? []) {
|
|
234
|
+
queryClient.setQueryData(queryKey, tasks);
|
|
210
235
|
}
|
|
211
236
|
|
|
212
237
|
console.error('Failed to reorder task:', err);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
3
|
+
|
|
4
|
+
export type OptimisticTask = Task & { _isOptimistic: true };
|
|
5
|
+
|
|
6
|
+
function updateOptimisticTaskCaches(
|
|
7
|
+
queryClient: QueryClient,
|
|
8
|
+
boardId: string,
|
|
9
|
+
updater: (tasks: Task[] | undefined) => Task[] | undefined
|
|
10
|
+
) {
|
|
11
|
+
queryClient.setQueryData<Task[]>(['tasks', boardId], updater);
|
|
12
|
+
queryClient.setQueriesData<Task[]>(
|
|
13
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
14
|
+
updater
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createOptimisticTask(
|
|
19
|
+
task: Partial<Task> & Pick<Task, 'list_id' | 'name'>,
|
|
20
|
+
options: { id?: string; now?: Date } = {}
|
|
21
|
+
): OptimisticTask {
|
|
22
|
+
const now = options.now ?? new Date();
|
|
23
|
+
const id =
|
|
24
|
+
options.id ??
|
|
25
|
+
`optimistic-${
|
|
26
|
+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
27
|
+
? crypto.randomUUID()
|
|
28
|
+
: `${now.getTime()}-${Math.random().toString(36).slice(2)}`
|
|
29
|
+
}`;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...task,
|
|
33
|
+
_isOptimistic: true,
|
|
34
|
+
assignees: task.assignees ?? [],
|
|
35
|
+
created_at: task.created_at ?? now.toISOString(),
|
|
36
|
+
id,
|
|
37
|
+
labels: task.labels ?? [],
|
|
38
|
+
projects: task.projects ?? [],
|
|
39
|
+
} as OptimisticTask;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function insertOptimisticTaskIntoBoardCaches(
|
|
43
|
+
queryClient: QueryClient,
|
|
44
|
+
boardId: string,
|
|
45
|
+
optimisticTask: Task
|
|
46
|
+
) {
|
|
47
|
+
void queryClient.cancelQueries(
|
|
48
|
+
{ queryKey: ['tasks', boardId] },
|
|
49
|
+
{ revert: false }
|
|
50
|
+
);
|
|
51
|
+
void queryClient.cancelQueries(
|
|
52
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
53
|
+
{ revert: false }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
|
|
57
|
+
const currentTasks = tasks ?? [];
|
|
58
|
+
if (currentTasks.some((task) => task.id === optimisticTask.id)) {
|
|
59
|
+
return currentTasks;
|
|
60
|
+
}
|
|
61
|
+
return [...currentTasks, optimisticTask];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function reconcileOptimisticTaskInBoardCaches(
|
|
66
|
+
queryClient: QueryClient,
|
|
67
|
+
boardId: string,
|
|
68
|
+
optimisticTaskId: string,
|
|
69
|
+
createdTask: Task
|
|
70
|
+
) {
|
|
71
|
+
updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
|
|
72
|
+
if (!tasks) return [createdTask];
|
|
73
|
+
|
|
74
|
+
const optimisticIndex = tasks.findIndex(
|
|
75
|
+
(task) => task.id === optimisticTaskId
|
|
76
|
+
);
|
|
77
|
+
const createdIndex = tasks.findIndex((task) => task.id === createdTask.id);
|
|
78
|
+
const insertionIndex =
|
|
79
|
+
optimisticIndex >= 0
|
|
80
|
+
? optimisticIndex
|
|
81
|
+
: createdIndex >= 0
|
|
82
|
+
? createdIndex
|
|
83
|
+
: tasks.length;
|
|
84
|
+
const nextTasks = tasks.filter(
|
|
85
|
+
(task) => task.id !== optimisticTaskId && task.id !== createdTask.id
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
nextTasks.splice(
|
|
89
|
+
Math.min(insertionIndex, nextTasks.length),
|
|
90
|
+
0,
|
|
91
|
+
createdTask
|
|
92
|
+
);
|
|
93
|
+
return nextTasks;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function removeOptimisticTaskFromBoardCaches(
|
|
98
|
+
queryClient: QueryClient,
|
|
99
|
+
boardId: string,
|
|
100
|
+
optimisticTaskId: string
|
|
101
|
+
) {
|
|
102
|
+
updateOptimisticTaskCaches(queryClient, boardId, (tasks) =>
|
|
103
|
+
tasks?.filter((task) => task.id !== optimisticTaskId)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -6,6 +6,12 @@ import {
|
|
|
6
6
|
} from '@tuturuuu/internal-api/tasks';
|
|
7
7
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
8
8
|
|
|
9
|
+
import {
|
|
10
|
+
createOptimisticTask,
|
|
11
|
+
insertOptimisticTaskIntoBoardCaches,
|
|
12
|
+
reconcileOptimisticTaskInBoardCaches,
|
|
13
|
+
removeOptimisticTaskFromBoardCaches,
|
|
14
|
+
} from './optimistic-task-cache';
|
|
9
15
|
import { getBrowserApiOptions, toWorkspaceTaskUpdatePayload } from './shared';
|
|
10
16
|
|
|
11
17
|
export function useUpdateTask(boardId: string, wsId?: string) {
|
|
@@ -165,69 +171,36 @@ export function useCreateTask(boardId: string, wsId?: string) {
|
|
|
165
171
|
return createdTask as Task;
|
|
166
172
|
},
|
|
167
173
|
onMutate: async ({ listId, task }) => {
|
|
168
|
-
await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
|
|
169
|
-
|
|
170
|
-
const previousTasks = queryClient.getQueryData(['tasks', boardId]);
|
|
171
174
|
const trimmedName = task.name?.trim() ?? '';
|
|
172
|
-
|
|
173
|
-
const optimisticTask: Task = {
|
|
175
|
+
const optimisticTask = createOptimisticTask({
|
|
174
176
|
...task,
|
|
175
|
-
id: `temp-${Date.now()}`,
|
|
176
177
|
name: trimmedName,
|
|
177
178
|
list_id: listId,
|
|
178
179
|
closed_at: undefined,
|
|
179
180
|
deleted_at: undefined,
|
|
180
|
-
|
|
181
|
-
updated_at: new Date().toISOString(),
|
|
182
|
-
assignees: [],
|
|
183
|
-
} as Task;
|
|
181
|
+
});
|
|
184
182
|
|
|
185
|
-
queryClient
|
|
186
|
-
['tasks', boardId],
|
|
187
|
-
(old: Task[] | undefined) => {
|
|
188
|
-
if (!old) return [optimisticTask];
|
|
189
|
-
return [...old, optimisticTask];
|
|
190
|
-
}
|
|
191
|
-
);
|
|
183
|
+
insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
|
|
192
184
|
|
|
193
|
-
return {
|
|
185
|
+
return { optimisticTask };
|
|
194
186
|
},
|
|
195
187
|
onError: (err, _, context) => {
|
|
196
|
-
if (context?.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
(old: Task[] | undefined) => {
|
|
202
|
-
if (!old) return old;
|
|
203
|
-
const nextTasks = old.filter(
|
|
204
|
-
(task) => task.id !== context.optimisticTask.id
|
|
205
|
-
);
|
|
206
|
-
return nextTasks.length > 0 ? nextTasks : undefined;
|
|
207
|
-
}
|
|
188
|
+
if (context?.optimisticTask) {
|
|
189
|
+
removeOptimisticTaskFromBoardCaches(
|
|
190
|
+
queryClient,
|
|
191
|
+
boardId,
|
|
192
|
+
context.optimisticTask.id
|
|
208
193
|
);
|
|
209
194
|
}
|
|
210
195
|
|
|
211
196
|
console.error('Failed to create task:', err);
|
|
212
197
|
},
|
|
213
198
|
onSuccess: (newTask, _, context) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (!old) return [newTask];
|
|
220
|
-
|
|
221
|
-
const nextTasks = optimisticTaskId
|
|
222
|
-
? old.map((task) => (task.id === optimisticTaskId ? newTask : task))
|
|
223
|
-
: [...old];
|
|
224
|
-
|
|
225
|
-
if (nextTasks.some((task) => task.id === newTask.id)) {
|
|
226
|
-
return nextTasks;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return [...nextTasks, newTask];
|
|
230
|
-
}
|
|
199
|
+
reconcileOptimisticTaskInBoardCaches(
|
|
200
|
+
queryClient,
|
|
201
|
+
boardId,
|
|
202
|
+
context?.optimisticTask.id ?? newTask.id,
|
|
203
|
+
newTask
|
|
231
204
|
);
|
|
232
205
|
},
|
|
233
206
|
});
|
package/src/task-helper.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { reorderTask, useReorderTask } from './task/reorder';
|
|
|
2
2
|
export { transformAssignees, transformTaskRecord } from './task/transformers';
|
|
3
3
|
export * from './task-helper/board';
|
|
4
4
|
export * from './task-helper/bulk-actions';
|
|
5
|
+
export * from './task-helper/optimistic-task-cache';
|
|
5
6
|
export * from './task-helper/personal-external-staging';
|
|
6
7
|
export * from './task-helper/recycle-bin';
|
|
7
8
|
export * from './task-helper/relationships';
|
package/src/workspace-helper.ts
CHANGED
|
@@ -676,7 +676,7 @@ export interface PermissionsResult {
|
|
|
676
676
|
withoutPermission(permission: PermissionId): boolean;
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
-
async function
|
|
679
|
+
export async function resolveWorkspaceIdForPrincipal({
|
|
680
680
|
authorizationClient,
|
|
681
681
|
principal,
|
|
682
682
|
wsId,
|
|
@@ -773,7 +773,7 @@ async function getPermissionsImpl({
|
|
|
773
773
|
// Handle "personal" workspace slug by looking up the user's personal workspace
|
|
774
774
|
let resolvedWorkspaceId: string;
|
|
775
775
|
try {
|
|
776
|
-
resolvedWorkspaceId = await
|
|
776
|
+
resolvedWorkspaceId = await resolveWorkspaceIdForPrincipal({
|
|
777
777
|
authorizationClient: sbAdmin,
|
|
778
778
|
principal,
|
|
779
779
|
wsId,
|