@doist/todoist-api-typescript 5.9.0 → 6.0.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/dist/cjs/authentication.js +158 -0
- package/dist/{consts → cjs/consts}/endpoints.js +10 -12
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/rest-client.js +124 -0
- package/dist/{test-utils → cjs/test-utils}/asserts.js +1 -1
- package/dist/{test-utils → cjs/test-utils}/mocks.js +8 -4
- package/dist/cjs/test-utils/msw-setup.js +27 -0
- package/dist/{test-utils → cjs/test-utils}/test-defaults.js +41 -52
- package/dist/cjs/todoist-api.js +1235 -0
- package/dist/{types → cjs/types}/entities.js +19 -30
- package/dist/cjs/types/errors.js +22 -0
- package/dist/cjs/types/http.js +22 -0
- package/dist/cjs/utils/case-conversion.js +69 -0
- package/dist/{utils → cjs/utils}/colors.js +3 -3
- package/dist/cjs/utils/fetch-with-retry.js +150 -0
- package/dist/cjs/utils/multipart-upload.js +126 -0
- package/dist/{utils → cjs/utils}/processing-helpers.js +3 -3
- package/dist/{utils → cjs/utils}/sanitization.js +17 -28
- package/dist/{utils → cjs/utils}/url-helpers.js +15 -15
- package/dist/{utils → cjs/utils}/validators.js +2 -2
- package/dist/esm/authentication.js +151 -0
- package/dist/esm/consts/endpoints.js +65 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/rest-client.js +119 -0
- package/dist/esm/test-utils/asserts.js +8 -0
- package/dist/esm/test-utils/mocks.js +10 -0
- package/dist/esm/test-utils/msw-setup.js +22 -0
- package/dist/esm/test-utils/test-defaults.js +198 -0
- package/dist/esm/todoist-api.js +1231 -0
- package/dist/esm/types/entities.js +366 -0
- package/dist/esm/types/errors.js +18 -0
- package/dist/esm/types/http.js +18 -0
- package/dist/esm/types/index.js +3 -0
- package/dist/esm/types/requests.js +1 -0
- package/dist/esm/types/sync.js +1 -0
- package/dist/esm/utils/activity-helpers.js +36 -0
- package/dist/esm/utils/case-conversion.js +61 -0
- package/dist/esm/utils/colors.js +215 -0
- package/dist/esm/utils/fetch-with-retry.js +147 -0
- package/dist/esm/utils/index.js +3 -0
- package/dist/esm/utils/multipart-upload.js +120 -0
- package/dist/esm/utils/processing-helpers.js +12 -0
- package/dist/esm/utils/sanitization.js +112 -0
- package/dist/esm/utils/url-helpers.js +68 -0
- package/dist/esm/utils/validators.js +97 -0
- package/dist/{authentication.d.ts → types/authentication.d.ts} +6 -1
- package/dist/types/index.d.ts +4 -3
- package/dist/{rest-client.d.ts → types/rest-client.d.ts} +3 -4
- package/dist/types/test-utils/msw-setup.d.ts +3 -0
- package/dist/types/types/http.d.ts +68 -0
- package/dist/types/types/index.d.ts +3 -0
- package/dist/types/utils/case-conversion.d.ts +12 -0
- package/dist/types/utils/fetch-with-retry.d.ts +11 -0
- package/package.json +24 -8
- package/dist/authentication.js +0 -220
- package/dist/index.d.ts +0 -4
- package/dist/rest-client.js +0 -178
- package/dist/todoist-api.js +0 -1845
- package/dist/types/errors.js +0 -39
- package/dist/types/http.d.ts +0 -1
- package/dist/types/http.js +0 -2
- package/dist/utils/multipart-upload.js +0 -171
- /package/dist/{index.js → cjs/index.js} +0 -0
- /package/dist/{types → cjs/types}/index.js +0 -0
- /package/dist/{types → cjs/types}/requests.js +0 -0
- /package/dist/{types → cjs/types}/sync.js +0 -0
- /package/dist/{utils → cjs/utils}/activity-helpers.js +0 -0
- /package/dist/{utils → cjs/utils}/index.js +0 -0
- /package/dist/{consts → types/consts}/endpoints.d.ts +0 -0
- /package/dist/{test-utils → types/test-utils}/asserts.d.ts +0 -0
- /package/dist/{test-utils → types/test-utils}/mocks.d.ts +0 -0
- /package/dist/{test-utils → types/test-utils}/test-defaults.d.ts +0 -0
- /package/dist/{todoist-api.d.ts → types/todoist-api.d.ts} +0 -0
- /package/dist/types/{entities.d.ts → types/entities.d.ts} +0 -0
- /package/dist/types/{errors.d.ts → types/errors.d.ts} +0 -0
- /package/dist/types/{requests.d.ts → types/requests.d.ts} +0 -0
- /package/dist/types/{sync.d.ts → types/sync.d.ts} +0 -0
- /package/dist/{utils → types/utils}/activity-helpers.d.ts +0 -0
- /package/dist/{utils → types/utils}/colors.d.ts +0 -0
- /package/dist/{utils → types/utils}/index.d.ts +0 -0
- /package/dist/{utils → types/utils}/multipart-upload.d.ts +0 -0
- /package/dist/{utils → types/utils}/processing-helpers.d.ts +0 -0
- /package/dist/{utils → types/utils}/sanitization.d.ts +0 -0
- /package/dist/{utils → types/utils}/url-helpers.d.ts +0 -0
- /package/dist/{utils → types/utils}/validators.d.ts +0 -0
|
@@ -4,7 +4,7 @@ exports.formatDateToYYYYMMDD = formatDateToYYYYMMDD;
|
|
|
4
4
|
exports.getTaskUrl = getTaskUrl;
|
|
5
5
|
exports.getProjectUrl = getProjectUrl;
|
|
6
6
|
exports.getSectionUrl = getSectionUrl;
|
|
7
|
-
|
|
7
|
+
const endpoints_1 = require("../consts/endpoints");
|
|
8
8
|
/**
|
|
9
9
|
* Formats a Date object to YYYY-MM-DD string format.
|
|
10
10
|
*
|
|
@@ -13,10 +13,10 @@ var endpoints_1 = require("../consts/endpoints");
|
|
|
13
13
|
* @returns The formatted date string in YYYY-MM-DD format.
|
|
14
14
|
*/
|
|
15
15
|
function formatDateToYYYYMMDD(date) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
16
|
+
const year = date.getFullYear();
|
|
17
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
18
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
19
|
+
return `${year}-${month}-${day}`;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Generate the URL for a given task.
|
|
@@ -26,9 +26,9 @@ function formatDateToYYYYMMDD(date) {
|
|
|
26
26
|
* @returns The URL string for the task view.
|
|
27
27
|
*/
|
|
28
28
|
function getTaskUrl(taskId, content) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
29
|
+
const slug = content ? slugify(content) : undefined;
|
|
30
|
+
const path = slug ? `${slug}-${taskId}` : taskId;
|
|
31
|
+
return `${endpoints_1.TODOIST_WEB_URI}/task/${path}`;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Generate the URL for a given project.
|
|
@@ -38,9 +38,9 @@ function getTaskUrl(taskId, content) {
|
|
|
38
38
|
* @returns The URL string for the project view.
|
|
39
39
|
*/
|
|
40
40
|
function getProjectUrl(projectId, name) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
41
|
+
const slug = name ? slugify(name) : undefined;
|
|
42
|
+
const path = slug ? `${slug}-${projectId}` : projectId;
|
|
43
|
+
return `${endpoints_1.TODOIST_WEB_URI}/project/${path}`;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
46
|
* Generate the URL for a given section.
|
|
@@ -50,9 +50,9 @@ function getProjectUrl(projectId, name) {
|
|
|
50
50
|
* @returns The URL string for the section view.
|
|
51
51
|
*/
|
|
52
52
|
function getSectionUrl(sectionId, name) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
53
|
+
const slug = name ? slugify(name) : undefined;
|
|
54
|
+
const path = slug ? `${slug}-${sectionId}` : sectionId;
|
|
55
|
+
return `${endpoints_1.TODOIST_WEB_URI}/section/${path}`;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Slugify function borrowed from Django.
|
|
@@ -62,7 +62,7 @@ function getSectionUrl(sectionId, name) {
|
|
|
62
62
|
*/
|
|
63
63
|
function slugify(value) {
|
|
64
64
|
// Convert to ASCII
|
|
65
|
-
|
|
65
|
+
let result = value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
|
66
66
|
// Remove non-ASCII characters
|
|
67
67
|
result = result.replace(/[^\x20-\x7E]/g, '');
|
|
68
68
|
// Convert to lowercase and replace non-alphanumeric characters with dashes
|
|
@@ -25,7 +25,7 @@ exports.validateWorkspaceInvitation = validateWorkspaceInvitation;
|
|
|
25
25
|
exports.validateWorkspaceInvitationArray = validateWorkspaceInvitationArray;
|
|
26
26
|
exports.validateWorkspacePlanDetails = validateWorkspacePlanDetails;
|
|
27
27
|
exports.validateJoinWorkspaceResult = validateJoinWorkspaceResult;
|
|
28
|
-
|
|
28
|
+
const entities_1 = require("../types/entities");
|
|
29
29
|
function validateTask(input) {
|
|
30
30
|
return entities_1.TaskSchema.parse(input);
|
|
31
31
|
}
|
|
@@ -106,7 +106,7 @@ function validateWorkspaceUser(input) {
|
|
|
106
106
|
}
|
|
107
107
|
function validateWorkspaceUserArray(input) {
|
|
108
108
|
if (!Array.isArray(input)) {
|
|
109
|
-
throw new Error(
|
|
109
|
+
throw new Error(`Expected array for workspace users, got ${typeof input}`);
|
|
110
110
|
}
|
|
111
111
|
return input.map(validateWorkspaceUser);
|
|
112
112
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { request, isSuccess } from './rest-client.js';
|
|
2
|
+
import { v4 as uuid } from 'uuid';
|
|
3
|
+
import { TodoistRequestError } from './types/index.js';
|
|
4
|
+
import { getAuthBaseUri, getSyncBaseUri, ENDPOINT_AUTHORIZATION, ENDPOINT_GET_TOKEN, ENDPOINT_REVOKE_TOKEN, ENDPOINT_REVOKE, } from './consts/endpoints.js';
|
|
5
|
+
/**
|
|
6
|
+
* Creates a Basic Authentication header value from client credentials.
|
|
7
|
+
* @param clientId - The OAuth client ID
|
|
8
|
+
* @param clientSecret - The OAuth client secret
|
|
9
|
+
* @returns The Basic Auth header value (without the 'Basic ' prefix)
|
|
10
|
+
*/
|
|
11
|
+
function createBasicAuthHeader(clientId, clientSecret) {
|
|
12
|
+
const credentials = `${clientId}:${clientSecret}`;
|
|
13
|
+
return Buffer.from(credentials).toString('base64');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generates a random state parameter for OAuth2 authorization.
|
|
17
|
+
* The state parameter helps prevent CSRF attacks.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const state = getAuthStateParameter()
|
|
22
|
+
* // Store state in session
|
|
23
|
+
* const authUrl = getAuthorizationUrl(clientId, ['data:read'], state)
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @returns A random UUID v4 string
|
|
27
|
+
*/
|
|
28
|
+
export function getAuthStateParameter() {
|
|
29
|
+
return uuid();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generates the authorization URL for the OAuth2 flow.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const url = getAuthorizationUrl(
|
|
37
|
+
* 'your-client-id',
|
|
38
|
+
* ['data:read', 'task:add'],
|
|
39
|
+
* state
|
|
40
|
+
* )
|
|
41
|
+
* // Redirect user to url
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @returns The full authorization URL to redirect users to
|
|
45
|
+
* @see https://todoist.com/api/v1/docs#tag/Authorization/OAuth
|
|
46
|
+
*/
|
|
47
|
+
export function getAuthorizationUrl({ clientId, permissions, state, baseUrl, }) {
|
|
48
|
+
if (!(permissions === null || permissions === void 0 ? void 0 : permissions.length)) {
|
|
49
|
+
throw new Error('At least one scope value should be passed for permissions.');
|
|
50
|
+
}
|
|
51
|
+
const scope = permissions.join(',');
|
|
52
|
+
return `${getAuthBaseUri(baseUrl)}${ENDPOINT_AUTHORIZATION}?client_id=${clientId}&scope=${scope}&state=${state}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Exchanges an authorization code for an access token.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const { accessToken } = await getAuthToken({
|
|
60
|
+
* clientId: 'your-client-id',
|
|
61
|
+
* clientSecret: 'your-client-secret',
|
|
62
|
+
* code: authCode
|
|
63
|
+
* })
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @returns The access token response
|
|
67
|
+
* @throws {@link TodoistRequestError} If the token exchange fails
|
|
68
|
+
*/
|
|
69
|
+
export async function getAuthToken(args, baseUrl) {
|
|
70
|
+
var _a;
|
|
71
|
+
const response = await request({
|
|
72
|
+
httpMethod: 'POST',
|
|
73
|
+
baseUri: getAuthBaseUri(baseUrl),
|
|
74
|
+
relativePath: ENDPOINT_GET_TOKEN,
|
|
75
|
+
apiToken: undefined,
|
|
76
|
+
payload: args,
|
|
77
|
+
});
|
|
78
|
+
if (response.status !== 200 || !((_a = response.data) === null || _a === void 0 ? void 0 : _a.accessToken)) {
|
|
79
|
+
throw new TodoistRequestError('Authentication token exchange failed.', response.status, response.data);
|
|
80
|
+
}
|
|
81
|
+
return response.data;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Revokes an access token, making it invalid for future use.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* await revokeAuthToken({
|
|
89
|
+
* clientId: 'your-client-id',
|
|
90
|
+
* clientSecret: 'your-client-secret',
|
|
91
|
+
* accessToken: token
|
|
92
|
+
* })
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @deprecated Use {@link revokeToken} instead. This function uses a legacy endpoint that will be removed in a future version. The new function uses the RFC 7009 compliant endpoint.
|
|
96
|
+
* @returns True if revocation was successful
|
|
97
|
+
* @see https://todoist.com/api/v1/docs#tag/Authorization/operation/revoke_access_token_api_api_v1_access_tokens_delete
|
|
98
|
+
*/
|
|
99
|
+
export async function revokeAuthToken(args, baseUrl) {
|
|
100
|
+
const response = await request({
|
|
101
|
+
httpMethod: 'POST',
|
|
102
|
+
baseUri: getSyncBaseUri(baseUrl),
|
|
103
|
+
relativePath: ENDPOINT_REVOKE_TOKEN,
|
|
104
|
+
apiToken: undefined,
|
|
105
|
+
payload: args,
|
|
106
|
+
});
|
|
107
|
+
return isSuccess(response);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Revokes a token using the RFC 7009 OAuth 2.0 Token Revocation standard.
|
|
111
|
+
*
|
|
112
|
+
* This function uses HTTP Basic Authentication with client credentials and follows
|
|
113
|
+
* the RFC 7009 specification for token revocation.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* await revokeToken({
|
|
118
|
+
* clientId: 'your-client-id',
|
|
119
|
+
* clientSecret: 'your-client-secret',
|
|
120
|
+
* token: 'access-token-to-revoke'
|
|
121
|
+
* })
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @returns True if revocation was successful
|
|
125
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7009
|
|
126
|
+
* @see https://todoist.com/api/v1/docs#tag/Authorization
|
|
127
|
+
*/
|
|
128
|
+
export async function revokeToken(args, baseUrl) {
|
|
129
|
+
const { clientId, clientSecret, token } = args;
|
|
130
|
+
// Create Basic Auth header as per RFC 7009
|
|
131
|
+
const basicAuth = createBasicAuthHeader(clientId, clientSecret);
|
|
132
|
+
const customHeaders = {
|
|
133
|
+
Authorization: `Basic ${basicAuth}`,
|
|
134
|
+
};
|
|
135
|
+
// Request body only contains the token and optional token_type_hint
|
|
136
|
+
const requestBody = {
|
|
137
|
+
token,
|
|
138
|
+
token_type_hint: 'access_token',
|
|
139
|
+
};
|
|
140
|
+
const response = await request({
|
|
141
|
+
httpMethod: 'POST',
|
|
142
|
+
baseUri: getSyncBaseUri(baseUrl),
|
|
143
|
+
relativePath: ENDPOINT_REVOKE,
|
|
144
|
+
apiToken: undefined,
|
|
145
|
+
payload: requestBody,
|
|
146
|
+
requestId: undefined,
|
|
147
|
+
hasSyncCommands: false,
|
|
148
|
+
customHeaders: customHeaders,
|
|
149
|
+
});
|
|
150
|
+
return isSuccess(response);
|
|
151
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const BASE_URI = 'https://api.todoist.com';
|
|
2
|
+
const TODOIST_URI = 'https://todoist.com';
|
|
3
|
+
export const TODOIST_WEB_URI = 'https://app.todoist.com/app';
|
|
4
|
+
// The API version is not configurable, to ensure
|
|
5
|
+
// compatibility between the API and the client.
|
|
6
|
+
export const API_VERSION = 'v1';
|
|
7
|
+
export const API_BASE_URI = `/api/${API_VERSION}/`;
|
|
8
|
+
const API_AUTHORIZATION_BASE_URI = '/oauth/';
|
|
9
|
+
export function getSyncBaseUri(domainBase = BASE_URI) {
|
|
10
|
+
return new URL(API_BASE_URI, domainBase).toString();
|
|
11
|
+
}
|
|
12
|
+
export function getAuthBaseUri(domainBase = TODOIST_URI) {
|
|
13
|
+
return new URL(API_AUTHORIZATION_BASE_URI, domainBase).toString();
|
|
14
|
+
}
|
|
15
|
+
export const ENDPOINT_REST_TASKS = 'tasks';
|
|
16
|
+
export const ENDPOINT_REST_TASKS_FILTER = ENDPOINT_REST_TASKS + '/filter';
|
|
17
|
+
export const ENDPOINT_REST_TASKS_COMPLETED_BY_COMPLETION_DATE = ENDPOINT_REST_TASKS + '/completed/by_completion_date';
|
|
18
|
+
export const ENDPOINT_REST_TASKS_COMPLETED_BY_DUE_DATE = ENDPOINT_REST_TASKS + '/completed/by_due_date';
|
|
19
|
+
export const ENDPOINT_REST_TASKS_COMPLETED_SEARCH = 'completed/search';
|
|
20
|
+
export const ENDPOINT_REST_SECTIONS = 'sections';
|
|
21
|
+
export const ENDPOINT_REST_LABELS = 'labels';
|
|
22
|
+
export const ENDPOINT_REST_LABELS_SHARED = ENDPOINT_REST_LABELS + '/shared';
|
|
23
|
+
export const ENDPOINT_REST_LABELS_SHARED_RENAME = ENDPOINT_REST_LABELS_SHARED + '/rename';
|
|
24
|
+
export const ENDPOINT_REST_LABELS_SHARED_REMOVE = ENDPOINT_REST_LABELS_SHARED + '/remove';
|
|
25
|
+
export const ENDPOINT_REST_COMMENTS = 'comments';
|
|
26
|
+
export const ENDPOINT_REST_TASK_CLOSE = 'close';
|
|
27
|
+
export const ENDPOINT_REST_TASK_REOPEN = 'reopen';
|
|
28
|
+
export const ENDPOINT_REST_TASK_MOVE = 'move';
|
|
29
|
+
export const ENDPOINT_REST_PROJECTS = 'projects';
|
|
30
|
+
export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archived';
|
|
31
|
+
export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators';
|
|
32
|
+
export const ENDPOINT_REST_USER = 'user';
|
|
33
|
+
export const ENDPOINT_REST_PRODUCTIVITY = ENDPOINT_REST_TASKS + '/completed/stats';
|
|
34
|
+
export const ENDPOINT_REST_ACTIVITIES = 'activities';
|
|
35
|
+
export const ENDPOINT_REST_UPLOADS = 'uploads';
|
|
36
|
+
export const PROJECT_ARCHIVE = 'archive';
|
|
37
|
+
export const PROJECT_UNARCHIVE = 'unarchive';
|
|
38
|
+
export const ENDPOINT_SYNC_QUICK_ADD = ENDPOINT_REST_TASKS + '/quick';
|
|
39
|
+
export const ENDPOINT_SYNC = 'sync';
|
|
40
|
+
export const ENDPOINT_AUTHORIZATION = 'authorize';
|
|
41
|
+
export const ENDPOINT_GET_TOKEN = 'access_token';
|
|
42
|
+
export const ENDPOINT_REVOKE_TOKEN = 'access_tokens/revoke';
|
|
43
|
+
export const ENDPOINT_REVOKE = 'revoke';
|
|
44
|
+
// Workspace endpoints
|
|
45
|
+
export const ENDPOINT_WORKSPACE_INVITATIONS = 'workspaces/invitations';
|
|
46
|
+
export const ENDPOINT_WORKSPACE_INVITATIONS_ALL = 'workspaces/invitations/all';
|
|
47
|
+
export const ENDPOINT_WORKSPACE_INVITATIONS_DELETE = 'workspaces/invitations/delete';
|
|
48
|
+
export const ENDPOINT_WORKSPACE_JOIN = 'workspaces/join';
|
|
49
|
+
export const ENDPOINT_WORKSPACE_LOGO = 'workspaces/logo';
|
|
50
|
+
export const ENDPOINT_WORKSPACE_PLAN_DETAILS = 'workspaces/plan_details';
|
|
51
|
+
export const ENDPOINT_WORKSPACE_USERS = 'workspaces/users';
|
|
52
|
+
// Workspace invitation actions (require invite_code parameter)
|
|
53
|
+
export function getWorkspaceInvitationAcceptEndpoint(inviteCode) {
|
|
54
|
+
return `workspaces/invitations/${inviteCode}/accept`;
|
|
55
|
+
}
|
|
56
|
+
export function getWorkspaceInvitationRejectEndpoint(inviteCode) {
|
|
57
|
+
return `workspaces/invitations/${inviteCode}/reject`;
|
|
58
|
+
}
|
|
59
|
+
// Workspace projects (require workspace_id parameter)
|
|
60
|
+
export function getWorkspaceActiveProjectsEndpoint(workspaceId) {
|
|
61
|
+
return `workspaces/${workspaceId}/projects/active`;
|
|
62
|
+
}
|
|
63
|
+
export function getWorkspaceArchivedProjectsEndpoint(workspaceId) {
|
|
64
|
+
return `workspaces/${workspaceId}/projects/archived`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { TodoistRequestError } from './types/errors.js';
|
|
2
|
+
import { isNetworkError, isHttpError } from './types/http.js';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { API_BASE_URI } from './consts/endpoints.js';
|
|
5
|
+
import { camelCaseKeys, snakeCaseKeys } from './utils/case-conversion.js';
|
|
6
|
+
import { fetchWithRetry } from './utils/fetch-with-retry.js';
|
|
7
|
+
export function paramsSerializer(params) {
|
|
8
|
+
const qs = new URLSearchParams();
|
|
9
|
+
Object.keys(params).forEach((key) => {
|
|
10
|
+
const value = params[key];
|
|
11
|
+
if (value != null) {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
qs.append(key, value.join(','));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
qs.append(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return qs.toString();
|
|
21
|
+
}
|
|
22
|
+
const defaultHeaders = {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
};
|
|
25
|
+
function getAuthHeader(apiKey) {
|
|
26
|
+
return `Bearer ${apiKey}`;
|
|
27
|
+
}
|
|
28
|
+
function getRetryDelay(retryCount) {
|
|
29
|
+
return retryCount === 1 ? 0 : 500;
|
|
30
|
+
}
|
|
31
|
+
function getTodoistRequestError(args) {
|
|
32
|
+
const { error, originalStack } = args;
|
|
33
|
+
const requestError = new TodoistRequestError(error.message);
|
|
34
|
+
requestError.stack = originalStack ? originalStack.stack : error.stack;
|
|
35
|
+
if (isHttpError(error)) {
|
|
36
|
+
requestError.httpStatusCode = error.status;
|
|
37
|
+
requestError.responseData = error.data;
|
|
38
|
+
}
|
|
39
|
+
return requestError;
|
|
40
|
+
}
|
|
41
|
+
function getRequestConfiguration(args) {
|
|
42
|
+
const { baseURL, apiToken, requestId, customHeaders } = args;
|
|
43
|
+
const authHeader = apiToken ? { Authorization: getAuthHeader(apiToken) } : undefined;
|
|
44
|
+
const requestIdHeader = requestId ? { 'X-Request-Id': requestId } : undefined;
|
|
45
|
+
const headers = Object.assign(Object.assign(Object.assign(Object.assign({}, defaultHeaders), authHeader), requestIdHeader), customHeaders);
|
|
46
|
+
return { baseURL, headers };
|
|
47
|
+
}
|
|
48
|
+
function getHttpClientConfig(args) {
|
|
49
|
+
const { baseURL, apiToken, requestId, customHeaders } = args;
|
|
50
|
+
const configuration = getRequestConfiguration({ baseURL, apiToken, requestId, customHeaders });
|
|
51
|
+
return Object.assign(Object.assign({}, configuration), { timeout: 30000, retry: {
|
|
52
|
+
retries: 3,
|
|
53
|
+
retryCondition: isNetworkError,
|
|
54
|
+
retryDelay: getRetryDelay,
|
|
55
|
+
} });
|
|
56
|
+
}
|
|
57
|
+
export function isSuccess(response) {
|
|
58
|
+
return response.status >= 200 && response.status < 300;
|
|
59
|
+
}
|
|
60
|
+
export async function request(args) {
|
|
61
|
+
const { httpMethod, baseUri, relativePath, apiToken, payload, requestId: initialRequestId, hasSyncCommands, customHeaders, } = args;
|
|
62
|
+
// Capture original stack for better error reporting
|
|
63
|
+
const originalStack = new Error();
|
|
64
|
+
try {
|
|
65
|
+
let requestId = initialRequestId;
|
|
66
|
+
// Sync api don't allow a request id in the CORS
|
|
67
|
+
if (httpMethod === 'POST' && !requestId && !baseUri.includes(API_BASE_URI)) {
|
|
68
|
+
requestId = uuidv4();
|
|
69
|
+
}
|
|
70
|
+
const config = getHttpClientConfig({ baseURL: baseUri, apiToken, requestId, customHeaders });
|
|
71
|
+
const url = `${baseUri}${relativePath}`;
|
|
72
|
+
const fetchOptions = {
|
|
73
|
+
method: httpMethod,
|
|
74
|
+
headers: config.headers,
|
|
75
|
+
timeout: config.timeout,
|
|
76
|
+
};
|
|
77
|
+
let finalUrl = url;
|
|
78
|
+
switch (httpMethod) {
|
|
79
|
+
case 'GET':
|
|
80
|
+
// For GET requests, add query parameters to URL
|
|
81
|
+
if (payload) {
|
|
82
|
+
const queryString = paramsSerializer(payload);
|
|
83
|
+
if (queryString) {
|
|
84
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
85
|
+
finalUrl = `${url}${separator}${queryString}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
case 'POST':
|
|
90
|
+
case 'PUT': {
|
|
91
|
+
// Convert payload from camelCase to snake_case
|
|
92
|
+
const convertedPayload = payload ? snakeCaseKeys(payload) : payload;
|
|
93
|
+
const body = hasSyncCommands
|
|
94
|
+
? JSON.stringify(convertedPayload)
|
|
95
|
+
: JSON.stringify(convertedPayload);
|
|
96
|
+
fetchOptions.body = body;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'DELETE':
|
|
100
|
+
// DELETE requests don't have a body
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
// Make the request
|
|
104
|
+
const response = await fetchWithRetry({
|
|
105
|
+
url: finalUrl,
|
|
106
|
+
options: fetchOptions,
|
|
107
|
+
retryConfig: config.retry,
|
|
108
|
+
});
|
|
109
|
+
// Convert snake_case response to camelCase
|
|
110
|
+
const convertedData = camelCaseKeys(response.data);
|
|
111
|
+
return Object.assign(Object.assign({}, response), { data: convertedData });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!(error instanceof Error)) {
|
|
115
|
+
throw new Error('An unknown error occurred during the request');
|
|
116
|
+
}
|
|
117
|
+
throw getTodoistRequestError({ error, originalStack });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Has to use 'any' to express constructor type
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
+
export function assertInstance(value, type) {
|
|
4
|
+
if (value instanceof type) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
throw new TypeError(`Unexpected type ${typeof value}`);
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as restClient from '../rest-client.js';
|
|
2
|
+
export function setupRestClientMock(responseData, status = 200) {
|
|
3
|
+
const response = {
|
|
4
|
+
status,
|
|
5
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
6
|
+
headers: {},
|
|
7
|
+
data: responseData,
|
|
8
|
+
};
|
|
9
|
+
return jest.spyOn(restClient, 'request').mockResolvedValue(response);
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { setupServer } from 'msw/node';
|
|
2
|
+
// Default handlers for common API responses
|
|
3
|
+
export const handlers = [
|
|
4
|
+
// Default handlers can be added here for common endpoints
|
|
5
|
+
// Individual test files will add their own specific handlers
|
|
6
|
+
];
|
|
7
|
+
// Create MSW server instance
|
|
8
|
+
export const server = setupServer(...handlers);
|
|
9
|
+
// Setup MSW for tests
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
server.listen({
|
|
12
|
+
onUnhandledRequest: 'warn', // Log warnings for unhandled requests during development
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
server.resetHandlers(); // Reset handlers between tests
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
server.close(); // Clean up after all tests
|
|
20
|
+
});
|
|
21
|
+
// Export MSW utilities for use in tests
|
|
22
|
+
export { http, HttpResponse } from 'msw';
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { getProjectUrl, getTaskUrl, getSectionUrl } from '../utils/url-helpers.js';
|
|
2
|
+
export const DEFAULT_TASK_ID = '1234';
|
|
3
|
+
export const DEFAULT_TASK_CONTENT = 'This is a task';
|
|
4
|
+
export const DEFAULT_TASK_DESCRIPTION = 'A description';
|
|
5
|
+
export const DEFAULT_TASK_PRIORITY = 1;
|
|
6
|
+
export const DEFAULT_ORDER = 3;
|
|
7
|
+
export const DEFAULT_PROJECT_ID = '123';
|
|
8
|
+
export const DEFAULT_PROJECT_NAME = 'This is a project';
|
|
9
|
+
export const DEFAULT_PROJECT_VIEW_STYLE = 'list';
|
|
10
|
+
const DEFAULT_LABEL_ID = '456';
|
|
11
|
+
const DEFAULT_LABEL_NAME = 'This is a label';
|
|
12
|
+
const DEFAULT_SECTION_ID = '456';
|
|
13
|
+
const DEFAULT_SECTION_NAME = 'This is a section';
|
|
14
|
+
const DEFAULT_PARENT_ID = '5678';
|
|
15
|
+
const DEFAULT_ASSIGNEE = '1234';
|
|
16
|
+
const DEFAULT_CREATOR = '1234';
|
|
17
|
+
const DEFAULT_DATE = '2020-09-08T12:00:00Z';
|
|
18
|
+
const DEFAULT_ENTITY_COLOR = 'berry_red';
|
|
19
|
+
const DEFAULT_LABELS = ['personal', 'work', 'hobby'];
|
|
20
|
+
const DEFAULT_USER_ID = '5';
|
|
21
|
+
const DEFAULT_USER_NAME = 'A User';
|
|
22
|
+
const DEFAULT_USER_EMAIL = 'atestuser@doist.com';
|
|
23
|
+
const DEFAULT_COMMENT_ID = '4';
|
|
24
|
+
const DEFAULT_COMMENT_CONTENT = 'A comment';
|
|
25
|
+
const DEFAULT_COMMENT_REACTIONS = { '👍': ['1234', '5678'] };
|
|
26
|
+
const DEFAULT_NOTE_COUNT = 0;
|
|
27
|
+
const DEFAULT_CAN_ASSIGN_TASKS = true;
|
|
28
|
+
const DEFAULT_IS_ARCHIVED = false;
|
|
29
|
+
const DEFAULT_IS_DELETED = false;
|
|
30
|
+
const DEFAULT_IS_FROZEN = false;
|
|
31
|
+
const DEFAULT_IS_COLLAPSED = false;
|
|
32
|
+
// URL constants using the helper functions
|
|
33
|
+
const DEFAULT_TASK_URL = getTaskUrl(DEFAULT_TASK_ID, DEFAULT_TASK_CONTENT);
|
|
34
|
+
const DEFAULT_PROJECT_URL = getProjectUrl(DEFAULT_PROJECT_ID, DEFAULT_PROJECT_NAME);
|
|
35
|
+
const DEFAULT_SECTION_URL = getSectionUrl(DEFAULT_SECTION_ID, DEFAULT_SECTION_NAME);
|
|
36
|
+
export const DEFAULT_AUTH_TOKEN = 'AToken';
|
|
37
|
+
export const DEFAULT_REQUEST_ID = 'ARequestID';
|
|
38
|
+
export const INVALID_ENTITY_ID = 1234;
|
|
39
|
+
export const DEFAULT_DUE_DATE = {
|
|
40
|
+
isRecurring: false,
|
|
41
|
+
string: 'a date string',
|
|
42
|
+
date: DEFAULT_DATE,
|
|
43
|
+
lang: 'en',
|
|
44
|
+
timezone: null,
|
|
45
|
+
};
|
|
46
|
+
export const DEFAULT_DURATION = {
|
|
47
|
+
amount: 10,
|
|
48
|
+
unit: 'minute',
|
|
49
|
+
};
|
|
50
|
+
export const DEFAULT_DEADLINE = {
|
|
51
|
+
date: '2020-09-08',
|
|
52
|
+
lang: 'en',
|
|
53
|
+
};
|
|
54
|
+
export const DEFAULT_TASK = {
|
|
55
|
+
id: DEFAULT_TASK_ID,
|
|
56
|
+
userId: DEFAULT_CREATOR,
|
|
57
|
+
projectId: DEFAULT_PROJECT_ID,
|
|
58
|
+
sectionId: DEFAULT_SECTION_ID,
|
|
59
|
+
parentId: DEFAULT_PARENT_ID,
|
|
60
|
+
addedByUid: DEFAULT_CREATOR,
|
|
61
|
+
assignedByUid: DEFAULT_CREATOR,
|
|
62
|
+
responsibleUid: DEFAULT_ASSIGNEE,
|
|
63
|
+
labels: DEFAULT_LABELS,
|
|
64
|
+
deadline: DEFAULT_DEADLINE,
|
|
65
|
+
duration: DEFAULT_DURATION,
|
|
66
|
+
checked: false,
|
|
67
|
+
isDeleted: DEFAULT_IS_DELETED,
|
|
68
|
+
addedAt: DEFAULT_DATE,
|
|
69
|
+
completedAt: null,
|
|
70
|
+
updatedAt: DEFAULT_DATE,
|
|
71
|
+
due: DEFAULT_DUE_DATE,
|
|
72
|
+
priority: DEFAULT_TASK_PRIORITY,
|
|
73
|
+
childOrder: DEFAULT_ORDER,
|
|
74
|
+
content: DEFAULT_TASK_CONTENT,
|
|
75
|
+
description: DEFAULT_TASK_DESCRIPTION,
|
|
76
|
+
noteCount: DEFAULT_NOTE_COUNT,
|
|
77
|
+
dayOrder: DEFAULT_ORDER,
|
|
78
|
+
isCollapsed: DEFAULT_IS_COLLAPSED,
|
|
79
|
+
url: DEFAULT_TASK_URL,
|
|
80
|
+
};
|
|
81
|
+
export const INVALID_TASK = Object.assign(Object.assign({}, DEFAULT_TASK), { due: '2020-01-31' });
|
|
82
|
+
export const TASK_WITH_OPTIONALS_AS_NULL = {
|
|
83
|
+
userId: DEFAULT_CREATOR,
|
|
84
|
+
id: DEFAULT_TASK_ID,
|
|
85
|
+
projectId: DEFAULT_PROJECT_ID,
|
|
86
|
+
sectionId: null,
|
|
87
|
+
parentId: null,
|
|
88
|
+
addedByUid: DEFAULT_CREATOR,
|
|
89
|
+
assignedByUid: null,
|
|
90
|
+
responsibleUid: null,
|
|
91
|
+
labels: [],
|
|
92
|
+
deadline: null,
|
|
93
|
+
duration: null,
|
|
94
|
+
checked: false,
|
|
95
|
+
isDeleted: DEFAULT_IS_DELETED,
|
|
96
|
+
addedAt: DEFAULT_DATE,
|
|
97
|
+
completedAt: null,
|
|
98
|
+
updatedAt: DEFAULT_DATE,
|
|
99
|
+
due: null,
|
|
100
|
+
priority: DEFAULT_TASK_PRIORITY,
|
|
101
|
+
childOrder: DEFAULT_ORDER,
|
|
102
|
+
content: DEFAULT_TASK_CONTENT,
|
|
103
|
+
description: DEFAULT_TASK_DESCRIPTION,
|
|
104
|
+
dayOrder: DEFAULT_ORDER,
|
|
105
|
+
isCollapsed: DEFAULT_IS_COLLAPSED,
|
|
106
|
+
noteCount: DEFAULT_NOTE_COUNT,
|
|
107
|
+
url: DEFAULT_TASK_URL,
|
|
108
|
+
};
|
|
109
|
+
export const DEFAULT_PROJECT = {
|
|
110
|
+
id: DEFAULT_PROJECT_ID,
|
|
111
|
+
name: DEFAULT_PROJECT_NAME,
|
|
112
|
+
color: DEFAULT_ENTITY_COLOR,
|
|
113
|
+
childOrder: DEFAULT_ORDER,
|
|
114
|
+
parentId: DEFAULT_PROJECT_ID,
|
|
115
|
+
isFavorite: false,
|
|
116
|
+
isShared: false,
|
|
117
|
+
inboxProject: false,
|
|
118
|
+
viewStyle: DEFAULT_PROJECT_VIEW_STYLE,
|
|
119
|
+
canAssignTasks: DEFAULT_CAN_ASSIGN_TASKS,
|
|
120
|
+
isArchived: DEFAULT_IS_ARCHIVED,
|
|
121
|
+
isDeleted: DEFAULT_IS_DELETED,
|
|
122
|
+
isFrozen: DEFAULT_IS_FROZEN,
|
|
123
|
+
createdAt: DEFAULT_DATE,
|
|
124
|
+
updatedAt: DEFAULT_DATE,
|
|
125
|
+
defaultOrder: DEFAULT_ORDER,
|
|
126
|
+
description: '',
|
|
127
|
+
isCollapsed: DEFAULT_IS_COLLAPSED,
|
|
128
|
+
url: DEFAULT_PROJECT_URL,
|
|
129
|
+
};
|
|
130
|
+
export const INVALID_PROJECT = Object.assign(Object.assign({}, DEFAULT_PROJECT), { name: 123 });
|
|
131
|
+
export const PROJECT_WITH_OPTIONALS_AS_NULL = Object.assign(Object.assign({}, DEFAULT_PROJECT), { parentId: null });
|
|
132
|
+
export const DEFAULT_SECTION = {
|
|
133
|
+
id: DEFAULT_SECTION_ID,
|
|
134
|
+
userId: DEFAULT_USER_ID,
|
|
135
|
+
projectId: DEFAULT_PROJECT_ID,
|
|
136
|
+
addedAt: '2025-03-28T14:01:23.334881Z',
|
|
137
|
+
updatedAt: '2025-03-28T14:01:23.334885Z',
|
|
138
|
+
archivedAt: null,
|
|
139
|
+
name: DEFAULT_SECTION_NAME,
|
|
140
|
+
sectionOrder: DEFAULT_ORDER,
|
|
141
|
+
isArchived: false,
|
|
142
|
+
isDeleted: false,
|
|
143
|
+
isCollapsed: false,
|
|
144
|
+
url: DEFAULT_SECTION_URL,
|
|
145
|
+
};
|
|
146
|
+
export const INVALID_SECTION = Object.assign(Object.assign({}, DEFAULT_SECTION), { projectId: undefined });
|
|
147
|
+
export const DEFAULT_LABEL = {
|
|
148
|
+
id: DEFAULT_LABEL_ID,
|
|
149
|
+
name: DEFAULT_LABEL_NAME,
|
|
150
|
+
color: DEFAULT_ENTITY_COLOR,
|
|
151
|
+
order: DEFAULT_ORDER,
|
|
152
|
+
isFavorite: false,
|
|
153
|
+
};
|
|
154
|
+
export const INVALID_LABEL = Object.assign(Object.assign({}, DEFAULT_LABEL), { isFavorite: 'true' });
|
|
155
|
+
export const DEFAULT_USER = {
|
|
156
|
+
id: DEFAULT_USER_ID,
|
|
157
|
+
name: DEFAULT_USER_NAME,
|
|
158
|
+
email: DEFAULT_USER_EMAIL,
|
|
159
|
+
};
|
|
160
|
+
export const INVALID_USER = Object.assign(Object.assign({}, DEFAULT_USER), { email: undefined });
|
|
161
|
+
export const DEFAULT_ATTACHMENT = {
|
|
162
|
+
resourceType: 'file',
|
|
163
|
+
fileType: 'image/png',
|
|
164
|
+
fileUrl: 'https://someurl.com/image.jpg',
|
|
165
|
+
uploadState: 'completed',
|
|
166
|
+
};
|
|
167
|
+
export const INVALID_ATTACHMENT = Object.assign(Object.assign({}, DEFAULT_ATTACHMENT), { uploadState: 'something random' });
|
|
168
|
+
export const DEFAULT_RAW_COMMENT = {
|
|
169
|
+
id: DEFAULT_COMMENT_ID,
|
|
170
|
+
postedUid: DEFAULT_USER_ID,
|
|
171
|
+
content: DEFAULT_COMMENT_CONTENT,
|
|
172
|
+
fileAttachment: DEFAULT_ATTACHMENT,
|
|
173
|
+
uidsToNotify: null,
|
|
174
|
+
isDeleted: false,
|
|
175
|
+
postedAt: DEFAULT_DATE,
|
|
176
|
+
reactions: DEFAULT_COMMENT_REACTIONS,
|
|
177
|
+
itemId: DEFAULT_TASK_ID,
|
|
178
|
+
};
|
|
179
|
+
export const DEFAULT_COMMENT = Object.assign(Object.assign({}, DEFAULT_RAW_COMMENT), { taskId: DEFAULT_RAW_COMMENT.itemId, itemId: undefined });
|
|
180
|
+
export const INVALID_COMMENT = Object.assign(Object.assign({}, DEFAULT_RAW_COMMENT), { isDeleted: 'true' });
|
|
181
|
+
export const RAW_COMMENT_WITH_OPTIONALS_AS_NULL_TASK = Object.assign(Object.assign({}, DEFAULT_RAW_COMMENT), { fileAttachment: null, uidsToNotify: null, reactions: null });
|
|
182
|
+
export const COMMENT_WITH_OPTIONALS_AS_NULL_TASK = Object.assign(Object.assign({}, RAW_COMMENT_WITH_OPTIONALS_AS_NULL_TASK), { taskId: RAW_COMMENT_WITH_OPTIONALS_AS_NULL_TASK.itemId, itemId: undefined });
|
|
183
|
+
export const RAW_COMMENT_WITH_ATTACHMENT_WITH_OPTIONALS_AS_NULL = Object.assign(Object.assign({}, DEFAULT_RAW_COMMENT), { fileAttachment: {
|
|
184
|
+
resourceType: 'file',
|
|
185
|
+
fileName: null,
|
|
186
|
+
fileSize: null,
|
|
187
|
+
fileType: null,
|
|
188
|
+
fileDuration: null,
|
|
189
|
+
uploadState: null,
|
|
190
|
+
image: null,
|
|
191
|
+
imageWidth: null,
|
|
192
|
+
imageHeight: null,
|
|
193
|
+
url: null,
|
|
194
|
+
title: null,
|
|
195
|
+
} });
|
|
196
|
+
export const COMMENT_WITH_ATTACHMENT_WITH_OPTIONALS_AS_NULL = Object.assign(Object.assign({}, RAW_COMMENT_WITH_ATTACHMENT_WITH_OPTIONALS_AS_NULL), { taskId: RAW_COMMENT_WITH_ATTACHMENT_WITH_OPTIONALS_AS_NULL.itemId, itemId: undefined });
|
|
197
|
+
export const RAW_COMMENT_WITH_OPTIONALS_AS_NULL_PROJECT = Object.assign(Object.assign({}, DEFAULT_RAW_COMMENT), { itemId: undefined, projectId: DEFAULT_PROJECT_ID });
|
|
198
|
+
export const COMMENT_WITH_OPTIONALS_AS_NULL_PROJECT = Object.assign(Object.assign({}, RAW_COMMENT_WITH_OPTIONALS_AS_NULL_PROJECT), { taskId: undefined });
|