@startsimpli/integrations 0.2.24 → 0.2.26
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/package.json +1 -1
- package/src/__tests__/connections.test.tsx +131 -0
- package/src/__tests__/corpus-planning.test.tsx +556 -0
- package/src/api/connections.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/ConnectionsPage.tsx +139 -15
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- package/src/components/pages/types.ts +59 -0
- package/src/components/pages/work-items.tsx +256 -0
- package/src/corpus/client.ts +368 -1
- package/src/corpus/entities.ts +63 -1
- package/src/corpus/index.ts +26 -1
- package/src/corpus/planning.ts +323 -0
- package/src/corpus/types.ts +241 -3
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PLANNING TOOLSET (bd startsim-8l6ef).
|
|
3
|
+
*
|
|
4
|
+
* The screen makes one claim the tracker does not — "an extracted item and one
|
|
5
|
+
* you typed are the same list, and you can still tell them apart" — and every
|
|
6
|
+
* case below is a way that could be false on screen while the database is fine.
|
|
7
|
+
*
|
|
8
|
+
* Three of them are regression tests for bugs that were live in this branch and
|
|
9
|
+
* would each have shipped a silent wrong answer rather than an error:
|
|
10
|
+
*
|
|
11
|
+
* 1. a `Commitment.origin` field whose mapper wrote the key `origin` while the
|
|
12
|
+
* tenant indexes `_origin` — every filter over it returns an empty page;
|
|
13
|
+
* 2. an authored item with no `first_seen_at`/`last_seen_at`, which vanishes
|
|
14
|
+
* from three of the tracker's four tabs while sitting in the same table;
|
|
15
|
+
* 3. a `focus` filter the server answers as `related_to`, which — unreconciled
|
|
16
|
+
* — makes the screen announce that the workspace did not filter by focus
|
|
17
|
+
* while it verifiably did.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
21
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
22
|
+
|
|
23
|
+
import { PlanningPage } from '../components/pages/PlanningPage';
|
|
24
|
+
import { createPlanningApi } from '../corpus/client';
|
|
25
|
+
import { ORIGIN, ORIGIN_KEY, commitmentToEntity, toCommitment } from '../corpus/entities';
|
|
26
|
+
import {
|
|
27
|
+
authoredItemToEntity,
|
|
28
|
+
displayProgress,
|
|
29
|
+
focusProgress,
|
|
30
|
+
focusSlug,
|
|
31
|
+
focusToEntity,
|
|
32
|
+
isAuthored,
|
|
33
|
+
toFocus,
|
|
34
|
+
} from '../corpus/planning';
|
|
35
|
+
import type {
|
|
36
|
+
Commitment,
|
|
37
|
+
CorpusPage,
|
|
38
|
+
Focus,
|
|
39
|
+
FocusStanding,
|
|
40
|
+
PlanningApi,
|
|
41
|
+
} from '../corpus/types';
|
|
42
|
+
|
|
43
|
+
const NOW = Date.parse('2026-08-26T12:00:00Z');
|
|
44
|
+
const SCOPE = { orgId: 'tandem' };
|
|
45
|
+
|
|
46
|
+
function commitment(
|
|
47
|
+
overrides: Partial<Commitment> & { externalId: string; title: string },
|
|
48
|
+
): Commitment {
|
|
49
|
+
return {
|
|
50
|
+
id: overrides.externalId,
|
|
51
|
+
dedupeKey: overrides.externalId.replace('commit:', ''),
|
|
52
|
+
subtype: 'action',
|
|
53
|
+
detail: '',
|
|
54
|
+
status: 'open',
|
|
55
|
+
subjectKey: '',
|
|
56
|
+
actionKey: '',
|
|
57
|
+
mentions: [{
|
|
58
|
+
documentId: 'doc_1',
|
|
59
|
+
originKey: 'rules:x',
|
|
60
|
+
occurredAt: '2026-08-01T15:00:00Z',
|
|
61
|
+
quote: 'Jonathan to complete a filing before the next call.',
|
|
62
|
+
extractor: 'rules',
|
|
63
|
+
}],
|
|
64
|
+
firstSeenAt: '2026-08-01T15:00:00Z',
|
|
65
|
+
lastSeenAt: '2026-08-01T15:00:00Z',
|
|
66
|
+
extractorVersion: 'rules+model@1',
|
|
67
|
+
source: 'granola',
|
|
68
|
+
sourceLabel: 'Granola',
|
|
69
|
+
scope: SCOPE,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// the origin key
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
describe('_origin — the key the tenant actually indexes', () => {
|
|
79
|
+
it('writes the literal underscore key, not a field named origin', () => {
|
|
80
|
+
/**
|
|
81
|
+
* THE BUG THIS PINS. `camelToSnakeKey('origin')` is `origin`, a DIFFERENT
|
|
82
|
+
* `data` key from `_origin` — which is what three writers have used for
|
|
83
|
+
* months and what the tenant declares an AttributeDef for. A mapper that
|
|
84
|
+
* emitted `origin` would write to a key nothing indexes, and every
|
|
85
|
+
* `?attr._origin=` filter would answer 200/count:0 forever with no error.
|
|
86
|
+
*/
|
|
87
|
+
const payload = commitmentToEntity(
|
|
88
|
+
commitment({ externalId: 'item:a', title: 'Renew the lease', origin: ORIGIN.authored }),
|
|
89
|
+
);
|
|
90
|
+
expect(payload.data[ORIGIN_KEY]).toBe('human');
|
|
91
|
+
expect(ORIGIN_KEY).toBe('_origin');
|
|
92
|
+
expect(payload.data).not.toHaveProperty('origin');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not relabel an authored item as extracted when it passes back through the bulk upsert', () => {
|
|
96
|
+
// Otherwise a human's item would come back claiming a meeting said it — and
|
|
97
|
+
// it has no mention to contradict the claim.
|
|
98
|
+
const payload = commitmentToEntity(
|
|
99
|
+
commitment({ externalId: 'item:a', title: 'Renew the lease', origin: ORIGIN.authored }),
|
|
100
|
+
);
|
|
101
|
+
expect(payload.data[ORIGIN_KEY]).toBe(ORIGIN.authored);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still marks an extractor-written row as extracted', () => {
|
|
105
|
+
const payload = commitmentToEntity(
|
|
106
|
+
commitment({ externalId: 'commit:a', title: 'Chase the inventor' }),
|
|
107
|
+
);
|
|
108
|
+
expect(payload.data[ORIGIN_KEY]).toBe(ORIGIN.extracted);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reads BOTH spellings, because the browser client camelCases the key', () => {
|
|
112
|
+
/**
|
|
113
|
+
* `snakeToCamelKey('_origin')` is `Origin`, so a row read through
|
|
114
|
+
* @startsimpli/api in a browser arrives under that name while a plain
|
|
115
|
+
* server-side fetch sees `_origin`. Reading only one spelling is the bug
|
|
116
|
+
* that made the deployed Tracker report a filter gap that did not exist.
|
|
117
|
+
*/
|
|
118
|
+
const fromServer = toCommitment(
|
|
119
|
+
{ id: '1', external_id: 'item:a', name: 'x', data: { title: 'x', _origin: 'human' } },
|
|
120
|
+
SCOPE,
|
|
121
|
+
);
|
|
122
|
+
const fromBrowser = toCommitment(
|
|
123
|
+
{ id: '1', external_id: 'item:a', name: 'x', data: { title: 'x', Origin: 'human' } },
|
|
124
|
+
SCOPE,
|
|
125
|
+
);
|
|
126
|
+
expect(fromServer.origin).toBe('human');
|
|
127
|
+
expect(fromBrowser.origin).toBe('human');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// authored items
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe('an authored item', () => {
|
|
136
|
+
it('stamps both timestamps, or it disappears from three of four tabs', () => {
|
|
137
|
+
/**
|
|
138
|
+
* THE BUG THIS PINS, and it is invisible rather than loud. "Changed this
|
|
139
|
+
* week" filters on `last_seen_at`; the age line reads `first_seen_at`; the
|
|
140
|
+
* server-side `changedAfter` is a `attr.last_seen_at__gte` that a null never
|
|
141
|
+
* matches. An authored item with neither sits in the same table as the
|
|
142
|
+
* extracted ones and shows up in one view out of four — which makes the
|
|
143
|
+
* whole "one list" claim false on screen while it stays true in the DB.
|
|
144
|
+
*/
|
|
145
|
+
const payload = authoredItemToEntity({ title: 'Renew the office lease' }, { now: NOW });
|
|
146
|
+
expect(payload.data.first_seen_at).toBe(new Date(NOW).toISOString());
|
|
147
|
+
expect(payload.data.last_seen_at).toBe(new Date(NOW).toISOString());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('cannot collide with an extracted row', () => {
|
|
151
|
+
const payload = authoredItemToEntity({ title: 'x' }, { id: 'abc', now: NOW });
|
|
152
|
+
expect(payload.external_id).toBe('item:abc');
|
|
153
|
+
expect(payload.external_id.startsWith('commit:')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('writes an EMPTY mention list rather than omitting the key', () => {
|
|
157
|
+
// The row renderer branches on "no mentions"; "authored, so no meeting" and
|
|
158
|
+
// "the key is missing" must not be the same shape.
|
|
159
|
+
const payload = authoredItemToEntity({ title: 'x' }, { now: NOW });
|
|
160
|
+
expect(payload.data.mentions).toEqual([]);
|
|
161
|
+
expect(payload.data.mention_count).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('defaults to an action, and carries a requirement when asked', () => {
|
|
165
|
+
expect(authoredItemToEntity({ title: 'x' }, { now: NOW }).data.subtype).toBe('action');
|
|
166
|
+
// `requirement` costs nothing precisely because CommitmentSubtype is a
|
|
167
|
+
// string rather than an enum.
|
|
168
|
+
expect(
|
|
169
|
+
authoredItemToEntity({ title: 'x', subtype: 'requirement' }, { now: NOW }).data.subtype,
|
|
170
|
+
).toBe('requirement');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('is recognisable as authored from either the origin or the id', () => {
|
|
174
|
+
expect(isAuthored(commitment({ externalId: 'item:a', title: 'x' }))).toBe(true);
|
|
175
|
+
expect(isAuthored(commitment({
|
|
176
|
+
externalId: 'x', title: 'x', origin: ORIGIN.authored,
|
|
177
|
+
}))).toBe(true);
|
|
178
|
+
expect(isAuthored(commitment({ externalId: 'commit:a', title: 'x' }))).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('lands in the same list as an extracted item, and reads honestly', async () => {
|
|
182
|
+
// The placement argument, on screen. Both rows render; each says where it
|
|
183
|
+
// came from; neither claims a meeting it did not have.
|
|
184
|
+
render(
|
|
185
|
+
<PlanningPage
|
|
186
|
+
api={fakeApi({
|
|
187
|
+
items: [
|
|
188
|
+
commitment({ externalId: 'commit:a', title: 'Chase the inventor for 2148' }),
|
|
189
|
+
{
|
|
190
|
+
...commitment({ externalId: 'item:b', title: 'Renew the office lease' }),
|
|
191
|
+
mentions: [], origin: ORIGIN.authored, firstSeenAt: null, lastSeenAt: null,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
})}
|
|
195
|
+
scope={SCOPE}
|
|
196
|
+
now={NOW}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
expect(await screen.findByText('Chase the inventor for 2148')).toBeTruthy();
|
|
200
|
+
expect(screen.getByText('Renew the office lease')).toBeTruthy();
|
|
201
|
+
expect(screen.getByText('Added by hand')).toBeTruthy();
|
|
202
|
+
// ...and the extracted one still cites its meeting rather than being
|
|
203
|
+
// flattened into the same line.
|
|
204
|
+
expect(screen.getByText('From 1 meeting')).toBeTruthy();
|
|
205
|
+
// The bug this replaces: "From 0 meetings" over an empty disclosure.
|
|
206
|
+
expect(screen.queryByText('From 0 meetings')).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// focuses
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
describe('focus mapping', () => {
|
|
215
|
+
it('keeps an absent target absent rather than coercing it to zero', () => {
|
|
216
|
+
/**
|
|
217
|
+
* `target_value`'s absence is `Goal.is_measurable`'s switch. A 0 would turn
|
|
218
|
+
* every qualitative focus into a measurable one that is fully achieved.
|
|
219
|
+
*/
|
|
220
|
+
const focus = toFocus(
|
|
221
|
+
{ id: '1', external_id: 'focus:a', name: 'Ship v2', data: { title: 'Ship v2' } },
|
|
222
|
+
SCOPE,
|
|
223
|
+
);
|
|
224
|
+
expect(focus.targetValue).toBeUndefined();
|
|
225
|
+
expect(focus.currentValue).toBeUndefined();
|
|
226
|
+
expect(focusProgress(focus, null)).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('falls back to draft rather than active on an unrecognised status', () => {
|
|
230
|
+
// A row whose status did not survive a round trip must not present as work
|
|
231
|
+
// somebody committed to.
|
|
232
|
+
const focus = toFocus(
|
|
233
|
+
{ id: '1', external_id: 'focus:a', name: 'x', data: { title: 'x', status: 'nonsense' } },
|
|
234
|
+
SCOPE,
|
|
235
|
+
);
|
|
236
|
+
expect(focus.status).toBe('draft');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('round-trips the Goal vocabulary', () => {
|
|
240
|
+
const payload = focusToEntity({
|
|
241
|
+
title: 'Close the Series A', status: 'at_risk', category: 'financial',
|
|
242
|
+
timeFrame: 'Q3', year: 2026, targetValue: 2_000_000, currentValue: 500_000, unit: 'usd',
|
|
243
|
+
});
|
|
244
|
+
expect(payload.data).toMatchObject({
|
|
245
|
+
title: 'Close the Series A', status: 'at_risk', category: 'financial',
|
|
246
|
+
time_frame: 'Q3', year: 2026, target_value: 2_000_000, current_value: 500_000, unit: 'usd',
|
|
247
|
+
});
|
|
248
|
+
const back = toFocus({ id: '1', external_id: payload.external_id, name: payload.name, data: payload.data }, SCOPE);
|
|
249
|
+
expect(back.status).toBe('at_risk');
|
|
250
|
+
expect(back.timeFrame).toBe('Q3');
|
|
251
|
+
expect(back.targetValue).toBe(2_000_000);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('derives a slug once, so renaming does not fork the row', () => {
|
|
255
|
+
expect(focusSlug('Close the Series A!')).toBe('close-the-series-a');
|
|
256
|
+
expect(focusSlug(' ')).toBe('untitled');
|
|
257
|
+
expect(focusToEntity({ title: 'Close the Series A' }).external_id).toBe('focus:close-the-series-a');
|
|
258
|
+
// An explicit external_id wins, which is what an update relies on.
|
|
259
|
+
expect(focusToEntity({ title: 'Renamed' }, 'focus:close-the-series-a').external_id)
|
|
260
|
+
.toBe('focus:close-the-series-a');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('progress', () => {
|
|
265
|
+
const base = (over: Partial<Focus> = {}): Focus => ({
|
|
266
|
+
id: 'f1', externalId: 'focus:a', title: 'x', detail: '', status: 'active', scope: SCOPE, ...over,
|
|
267
|
+
});
|
|
268
|
+
const standing = (done: number, total: number): FocusStanding =>
|
|
269
|
+
({ focusId: 'f1', done, total, overdue: 0 });
|
|
270
|
+
|
|
271
|
+
it('measures against the target when there is one', () => {
|
|
272
|
+
expect(focusProgress(base({ targetValue: 100, currentValue: 25 }), null)).toBe(0.25);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('counts items when there is no target', () => {
|
|
276
|
+
expect(focusProgress(base(), standing(3, 12))).toBe(0.25);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('is null — not zero — when there is nothing to say', () => {
|
|
280
|
+
// 0% reads as "started and got nowhere" about work nobody has filed
|
|
281
|
+
// anything under yet.
|
|
282
|
+
expect(focusProgress(base(), standing(0, 0))).toBeNull();
|
|
283
|
+
expect(focusProgress(base(), null)).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('clamps rather than rendering over 100%', () => {
|
|
287
|
+
expect(focusProgress(base({ targetValue: 10, currentValue: 40 }), null)).toBe(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('prefers the STORED value, because that is what the server sorts by', () => {
|
|
291
|
+
// A card showing a number the list was not ordered by makes the sort look
|
|
292
|
+
// broken.
|
|
293
|
+
expect(displayProgress(base({ progress: 0.9 }), standing(1, 10))).toBe(0.9);
|
|
294
|
+
expect(displayProgress(base(), standing(1, 10))).toBe(0.1);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// the focus filter, end to end through the client
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
type Body = { data: Record<string, unknown> };
|
|
303
|
+
|
|
304
|
+
const FOCUS_ROW: Focus = {
|
|
305
|
+
id: 'f1', externalId: 'focus:a', title: 'Close the Series A', detail: '',
|
|
306
|
+
status: 'cancelled', scope: SCOPE,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** A client whose writes can be inspected. */
|
|
310
|
+
function writeSpy() {
|
|
311
|
+
const posted: unknown[] = [];
|
|
312
|
+
const http = {
|
|
313
|
+
get: vi.fn(async () => ({ count: 0, next: null, previous: null, results: [] })),
|
|
314
|
+
post: vi.fn(async (_path: string, body: unknown) => {
|
|
315
|
+
posted.push(body);
|
|
316
|
+
return { id: 'f1', external_id: 'focus:a', name: 'x', data: { title: 'x' } };
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
return { posted, api: createPlanningApi({ http: http as never, scope: SCOPE }) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
describe('filing is an edge, and the client has to say so', () => {
|
|
323
|
+
function httpSpy(applied: string[] = []) {
|
|
324
|
+
const calls: Array<{ path: string; params: Record<string, unknown> }> = [];
|
|
325
|
+
return {
|
|
326
|
+
calls,
|
|
327
|
+
http: {
|
|
328
|
+
get: vi.fn(async (path: string, options?: { params?: Record<string, unknown> }) => {
|
|
329
|
+
calls.push({ path, params: options?.params ?? {} });
|
|
330
|
+
return { count: 0, next: null, previous: null, results: [], applied_filters: applied };
|
|
331
|
+
}),
|
|
332
|
+
post: vi.fn(async () => ({ id: 'x', external_id: 'item:x', name: 'x', data: {} })),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
it('asks for the focus as an edge walk, in the direction that returns items', async () => {
|
|
338
|
+
const { calls, http } = httpSpy();
|
|
339
|
+
const api = createPlanningApi({ http: http as never, scope: SCOPE });
|
|
340
|
+
await api.listCommitments({ focus: 'focus-uuid' });
|
|
341
|
+
const params = calls.at(-1)!.params;
|
|
342
|
+
expect(params.related_to).toBe('focus-uuid');
|
|
343
|
+
expect(params.related_via).toBe('in_focus');
|
|
344
|
+
// `in` here returns an empty page rather than an error, so it is asserted.
|
|
345
|
+
expect(params.related_direction).toBe('out');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('counts a server-reported related_to as the focus filter being HONOURED', async () => {
|
|
349
|
+
/**
|
|
350
|
+
* THE BUG THIS PINS. The client asks for `focus`; the server confirms
|
|
351
|
+
* `related_to`. Unreconciled, the composer's banner tells the user this
|
|
352
|
+
* workspace's data API did not filter by focus — while the server
|
|
353
|
+
* verifiably did. Same lie, same cause, as reading one spelling of
|
|
354
|
+
* `applied_filters`.
|
|
355
|
+
*/
|
|
356
|
+
const { http } = httpSpy(['related_to', 'attr.status']);
|
|
357
|
+
const api = createPlanningApi({ http: http as never, scope: SCOPE });
|
|
358
|
+
const page = await api.listCommitments({ focus: 'focus-uuid', status: 'open' });
|
|
359
|
+
expect(page.appliedFilters).toContain('focus');
|
|
360
|
+
expect(page.appliedFilters).toContain('status');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('still reports a genuinely ignored filter', async () => {
|
|
364
|
+
const { http } = httpSpy(['attr.status']);
|
|
365
|
+
const api = createPlanningApi({ http: http as never, scope: SCOPE });
|
|
366
|
+
const page = await api.listCommitments({ focus: 'focus-uuid', status: 'open' });
|
|
367
|
+
expect(page.appliedFilters).not.toContain('focus');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('filters origin on the underscore attribute', async () => {
|
|
371
|
+
const { calls, http } = httpSpy();
|
|
372
|
+
const api = createPlanningApi({ http: http as never, scope: SCOPE });
|
|
373
|
+
await api.listCommitments({ origin: ORIGIN.authored });
|
|
374
|
+
expect(calls.at(-1)!.params['attr._origin']).toBe('human');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('counts a focus standing without pulling a single row body', async () => {
|
|
378
|
+
const { calls, http } = httpSpy();
|
|
379
|
+
const api = createPlanningApi({ http: http as never, scope: SCOPE });
|
|
380
|
+
await api.focusStanding!('focus-uuid', NOW);
|
|
381
|
+
const counting = calls.slice(-3);
|
|
382
|
+
expect(counting).toHaveLength(3);
|
|
383
|
+
for (const call of counting) expect(call.params.page_size).toBe(1);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('sends a snake_cased key when its camelCase draft field was patched', async () => {
|
|
387
|
+
// The map exists because the two names DIFFER. A guard that compared the
|
|
388
|
+
// wrong side would pass on `ownerName` — where both spellings coincide —
|
|
389
|
+
// and drop `time_frame` silently.
|
|
390
|
+
const { posted, api } = writeSpy();
|
|
391
|
+
await api.updateFocus!(FOCUS_ROW, { timeFrame: 'Q4' });
|
|
392
|
+
expect((posted.at(-1) as Body).data.time_frame).toBe('Q4');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('sends status when status IS what changed', async () => {
|
|
396
|
+
// The other half of the pair below. Together they distinguish a working
|
|
397
|
+
// filter from one that drops everything.
|
|
398
|
+
const { posted, api } = writeSpy();
|
|
399
|
+
await api.updateFocus!(FOCUS_ROW, { status: 'completed' });
|
|
400
|
+
expect((posted.at(-1) as Body).data.status).toBe('completed');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('never lets an edit relabel where the row came from', async () => {
|
|
404
|
+
// `focusToEntity` stamps `_origin` because a CREATE needs one. Merging that
|
|
405
|
+
// on every edit would rewrite provenance from a rename.
|
|
406
|
+
const { posted, api } = writeSpy();
|
|
407
|
+
await api.updateFocus!(FOCUS_ROW, { title: 'Renamed' });
|
|
408
|
+
expect((posted.at(-1) as Body).data).not.toHaveProperty('_origin');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('sends only the keys an edit actually changed', async () => {
|
|
412
|
+
/**
|
|
413
|
+
* The upsert shallow-merges, so every key in the body is a WRITE. An edit to
|
|
414
|
+
* an owner must not carry `focusToEntity`'s defaulted `status` — that would
|
|
415
|
+
* resurrect this cancelled focus from a rename, on live data, silently.
|
|
416
|
+
*/
|
|
417
|
+
const { posted, api } = writeSpy();
|
|
418
|
+
await api.updateFocus!(FOCUS_ROW, { ownerName: 'Rae' });
|
|
419
|
+
const body = posted.at(-1) as Body;
|
|
420
|
+
expect(body.data.owner_name).toBe('Rae');
|
|
421
|
+
expect(body.data).not.toHaveProperty('status');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('refuses to unfile when the client has no delete, rather than no-opping', async () => {
|
|
425
|
+
// A control that appears to unfile and does not is worse than one that is
|
|
426
|
+
// disabled — the row would sit under a focus it looks removed from.
|
|
427
|
+
const { api } = writeSpy();
|
|
428
|
+
const item = commitment({ externalId: 'item:a', title: 'x' });
|
|
429
|
+
await expect(api.fileCommitment!(item, null)).rejects.toThrow(/cannot unfile/);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('files through the edge endpoint when a focus IS named', async () => {
|
|
433
|
+
const { posted, api } = writeSpy();
|
|
434
|
+
const item = commitment({ externalId: 'item:a', title: 'x' });
|
|
435
|
+
await api.fileCommitment!(item, 'focus-uuid');
|
|
436
|
+
expect(posted.at(-1)).toMatchObject({
|
|
437
|
+
rel_type: 'in_focus', source: item.id, target: 'focus-uuid',
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// the screen
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
function fakeApi(options: {
|
|
447
|
+
items?: Commitment[];
|
|
448
|
+
focuses?: Focus[];
|
|
449
|
+
standings?: Record<string, FocusStanding>;
|
|
450
|
+
applied?: string[];
|
|
451
|
+
onList?: (query: unknown) => void;
|
|
452
|
+
} = {}): PlanningApi {
|
|
453
|
+
const page = <T,>(results: T[], applied: string[] = []): CorpusPage<T> =>
|
|
454
|
+
({ count: results.length, next: null, previous: null, results, appliedFilters: applied });
|
|
455
|
+
return {
|
|
456
|
+
listCommitments: async (query) => {
|
|
457
|
+
options.onList?.(query);
|
|
458
|
+
return page(options.items ?? [], options.applied ?? ['status']);
|
|
459
|
+
},
|
|
460
|
+
listFocuses: async () => page(options.focuses ?? []),
|
|
461
|
+
focusStanding: async (focusId) =>
|
|
462
|
+
options.standings?.[focusId] ?? { focusId, total: 0, done: 0, overdue: 0 },
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const FOCUS: Focus = {
|
|
467
|
+
id: 'f1', externalId: 'focus:series-a', title: 'Close the Series A',
|
|
468
|
+
detail: '', status: 'at_risk', category: 'financial', scope: SCOPE,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
describe('PlanningPage', () => {
|
|
472
|
+
it('says a focus has nothing filed under it, rather than "0 of 0 done"', async () => {
|
|
473
|
+
render(
|
|
474
|
+
<PlanningPage
|
|
475
|
+
api={fakeApi({ focuses: [FOCUS] })}
|
|
476
|
+
scope={SCOPE}
|
|
477
|
+
now={NOW}
|
|
478
|
+
/>,
|
|
479
|
+
);
|
|
480
|
+
expect(await screen.findByText('Close the Series A')).toBeTruthy();
|
|
481
|
+
await waitFor(() => expect(screen.getByText('Nothing filed under this yet')).toBeTruthy());
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('reports done over total from counts', async () => {
|
|
485
|
+
render(
|
|
486
|
+
<PlanningPage
|
|
487
|
+
api={fakeApi({
|
|
488
|
+
focuses: [FOCUS],
|
|
489
|
+
standings: { f1: { focusId: 'f1', total: 40, done: 12, overdue: 3 } },
|
|
490
|
+
})}
|
|
491
|
+
scope={SCOPE}
|
|
492
|
+
now={NOW}
|
|
493
|
+
/>,
|
|
494
|
+
);
|
|
495
|
+
await waitFor(() => expect(screen.getByText(/12 of 40 done/)).toBeTruthy());
|
|
496
|
+
expect(screen.getByText(/3 overdue/)).toBeTruthy();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('advises about an empty workspace only when it is really empty', async () => {
|
|
500
|
+
render(<PlanningPage api={fakeApi({ focuses: [] })} scope={SCOPE} now={NOW} />);
|
|
501
|
+
expect(await screen.findByText(/No focuses yet/)).toBeTruthy();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('stays silent about filtering the server actually did', async () => {
|
|
505
|
+
render(
|
|
506
|
+
<PlanningPage
|
|
507
|
+
api={fakeApi({ focuses: [FOCUS], items: [], applied: ['status', 'focus'] })}
|
|
508
|
+
scope={SCOPE}
|
|
509
|
+
now={NOW}
|
|
510
|
+
/>,
|
|
511
|
+
);
|
|
512
|
+
await screen.findByText('Close the Series A');
|
|
513
|
+
await waitFor(() =>
|
|
514
|
+
expect(screen.queryByText(/did not filter by/)).toBeNull());
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('confesses when the server ignored a filter it was given', async () => {
|
|
518
|
+
const api = fakeApi({ focuses: [FOCUS], items: [], applied: [] });
|
|
519
|
+
render(<PlanningPage api={api} scope={SCOPE} now={NOW} />);
|
|
520
|
+
await waitFor(() => expect(screen.getByText(/did not filter by status/)).toBeTruthy());
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('does not loop even when the app hands it a NEW api object every render', async () => {
|
|
524
|
+
/**
|
|
525
|
+
* The 917-requests-in-ten-minutes guard, carried over from the tracker. An
|
|
526
|
+
* api built inline in a parent's render is a new identity every pass; if it
|
|
527
|
+
* is an effect dependency, the load sets state, which renders, which
|
|
528
|
+
* rebuilds the api.
|
|
529
|
+
*/
|
|
530
|
+
let calls = 0;
|
|
531
|
+
function Host() {
|
|
532
|
+
const [, force] = React.useState(0);
|
|
533
|
+
React.useEffect(() => { const t = setTimeout(() => force(1), 10); return () => clearTimeout(t); }, []);
|
|
534
|
+
return (
|
|
535
|
+
<PlanningPage
|
|
536
|
+
api={fakeApi({ focuses: [FOCUS], onList: () => { calls += 1; } })}
|
|
537
|
+
scope={{ orgId: 'tandem' }}
|
|
538
|
+
now={NOW}
|
|
539
|
+
/>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
render(<Host />);
|
|
543
|
+
await screen.findByText('Close the Series A');
|
|
544
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
545
|
+
expect(calls).toBeLessThanOrEqual(2);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('hides authoring when the client cannot write', async () => {
|
|
549
|
+
// A control that appears to work and does not is worse than one that is
|
|
550
|
+
// absent — the read-only fork is a real deployment, not a degraded one.
|
|
551
|
+
render(<PlanningPage api={fakeApi({ focuses: [FOCUS] })} scope={SCOPE} now={NOW} />);
|
|
552
|
+
await screen.findByText('Close the Series A');
|
|
553
|
+
expect(screen.queryByText('New focus')).toBeNull();
|
|
554
|
+
expect(screen.queryByPlaceholderText('What needs doing?')).toBeNull();
|
|
555
|
+
});
|
|
556
|
+
});
|
package/src/api/connections.ts
CHANGED
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import type {
|
|
33
33
|
Connection,
|
|
34
|
+
ConnectionInstructions,
|
|
34
35
|
ConnectionPermissions,
|
|
35
36
|
ConnectionRun,
|
|
36
37
|
ConnectorCatalogEntry,
|
|
37
38
|
MemberConnectionsApi,
|
|
39
|
+
OAuthStart,
|
|
38
40
|
} from '../components/pages/types';
|
|
39
41
|
import { request } from './_fetch';
|
|
40
42
|
|
|
@@ -87,6 +89,50 @@ export const connectionsMe: MemberConnectionsApi = {
|
|
|
87
89
|
return request<Connection>(`${BASE}/`, { method: 'POST', body: input });
|
|
88
90
|
},
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* START a consent round trip on an OAuth connector (bd startsim-768w.22.25.21).
|
|
94
|
+
*
|
|
95
|
+
* ONE call, not create-then-initiate. The two halves have no independent
|
|
96
|
+
* meaning — a shell card with no consent URL is an orphan the member cannot
|
|
97
|
+
* act on, and a consent URL with no card has nowhere to put the token — so a
|
|
98
|
+
* refusal here leaves nothing behind.
|
|
99
|
+
*
|
|
100
|
+
* Takes `connector_key` rather than an id because the row does not exist yet;
|
|
101
|
+
* that is the whole bug this closes. The caller opens `authorization_url` and
|
|
102
|
+
* does nothing else: it is already complete, and the client has no `client_id`
|
|
103
|
+
* of its own to add to it.
|
|
104
|
+
*
|
|
105
|
+
* `return_to` is where the browser should land afterwards. It is VALIDATED
|
|
106
|
+
* server-side against this tenant's own recorded origins and folded into the
|
|
107
|
+
* signed state — the callback is an anonymous browser mid-redirect from the
|
|
108
|
+
* vendor and cannot be allowed to name its own destination.
|
|
109
|
+
*/
|
|
110
|
+
startOAuth(input: {
|
|
111
|
+
connector_key: string;
|
|
112
|
+
name?: string;
|
|
113
|
+
config?: Record<string, unknown>;
|
|
114
|
+
capabilities_enabled?: string[];
|
|
115
|
+
return_to?: string;
|
|
116
|
+
}): Promise<OAuthStart> {
|
|
117
|
+
return request<OAuthStart>(`${BASE}/oauth/start/`, { method: 'POST', body: input });
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* RE-consent an existing card whose grant the vendor stopped accepting. The
|
|
122
|
+
* first connect is `startOAuth`; this one takes an id because the card is
|
|
123
|
+
* already there and must not be duplicated.
|
|
124
|
+
*/
|
|
125
|
+
oauthInitiate(id: string, input?: { return_to?: string }): Promise<OAuthStart> {
|
|
126
|
+
return request<OAuthStart>(`${BASE}/${id}/oauth/initiate/`, {
|
|
127
|
+
method: 'POST', body: input ?? {},
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/** Vendor-side setup steps, as DATA written by the connector in Python. */
|
|
132
|
+
connectionInstructions(id: string): Promise<ConnectionInstructions> {
|
|
133
|
+
return request<ConnectionInstructions>(`${BASE}/${id}/instructions/`);
|
|
134
|
+
},
|
|
135
|
+
|
|
90
136
|
/**
|
|
91
137
|
* Test it now, against the STORED credential. The body is deliberately empty:
|
|
92
138
|
* a "test connection" button that accepts a pasted key is a second connect
|
package/src/components/index.ts
CHANGED
|
@@ -32,3 +32,20 @@ export type {
|
|
|
32
32
|
export * from './pages/LibraryPage';
|
|
33
33
|
export * from './pages/AskPage';
|
|
34
34
|
export * from './pages/TrackerPage';
|
|
35
|
+
|
|
36
|
+
// The planning toolset (bd startsim-8l6ef): focuses, and the items filed under
|
|
37
|
+
// them. `work-items` carries the pieces the tracker and the planner SHARE — the
|
|
38
|
+
// row, the honesty banner, the overdue/changed predicates — so two views over
|
|
39
|
+
// one list cannot drift into disagreeing about what they mean.
|
|
40
|
+
export * from './pages/PlanningPage';
|
|
41
|
+
export {
|
|
42
|
+
PRIORITY_LABELS,
|
|
43
|
+
UnfilteredNotice,
|
|
44
|
+
WORK_ITEM_DEFAULTS,
|
|
45
|
+
WorkItemRow,
|
|
46
|
+
changedSince,
|
|
47
|
+
compareForScreen,
|
|
48
|
+
isOverdue,
|
|
49
|
+
priorityLabel,
|
|
50
|
+
} from './pages/work-items';
|
|
51
|
+
export type { WorkItemSlot } from './pages/work-items';
|