@startsimpli/integrations 0.2.25 → 0.2.27
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__/corpus-document-lookup.test.tsx +319 -0
- package/src/__tests__/corpus-planning.test.tsx +556 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/LibraryPage.tsx +50 -5
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- package/src/components/pages/work-items.tsx +256 -0
- package/src/corpus/client.ts +423 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPENING ONE RECORD (bd startsim-4s4um RED / startsim-2lt8y GREEN).
|
|
3
|
+
*
|
|
4
|
+
* REPORTED, with a live URL:
|
|
5
|
+
* `https://tandem.ai.startsimpli.com/library?doc=5ab48b58-8573-4114-b408-215dd0eaf5d7`
|
|
6
|
+
* renders no document.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THE TENANT ACTUALLY DOES, read out of
|
|
9
|
+
* `start-simpli-api/tenant-starter/apps/api/{views,filters}.py` @ origin/main
|
|
10
|
+
* rather than assumed:
|
|
11
|
+
*
|
|
12
|
+
* - `/api/v1/entities/<id>/` is a real DRF detail route. It answers the ONE
|
|
13
|
+
* row, or 404 when the row is absent for this reader — the read gate makes a
|
|
14
|
+
* forbidden row ABSENT rather than refused, deliberately.
|
|
15
|
+
* - `/api/v1/entities/` applies `?type=` plus a fixed vocabulary
|
|
16
|
+
* (`search`, `source`, `party`, `occurred_after`, `occurred_before`,
|
|
17
|
+
* `related_*`, `order`) and `attr.*`. `_note_unknown` sweeps EVERY OTHER
|
|
18
|
+
* KEY into `ignored_filters` and returns the page unnarrowed. `id` is not in
|
|
19
|
+
* that vocabulary, which is why `?id=<uuid>` comes back with count 180.
|
|
20
|
+
*
|
|
21
|
+
* SO THE FAKE BELOW IGNORES UNKNOWN PARAMETERS RATHER THAN ERRORING ON THEM,
|
|
22
|
+
* and that is the whole point of writing a new one instead of reaching for
|
|
23
|
+
* `createFixtureCorpusHttp`: that fixture applies `?search=` when a backend is
|
|
24
|
+
* named and never reports `ignored_filters`, so a lookup expressed as a filter
|
|
25
|
+
* would come back plausibly narrowed there. A fake that answers an unsupported
|
|
26
|
+
* filter with `[]` — or with a filtered page — makes this suite green while the
|
|
27
|
+
* deployed Library stays blank, which is the exact trap this bead names.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS NOT THE BUG, established before any of this was written. The DEPLOYED
|
|
30
|
+
* bundle on tandem.ai.startsimpli.com already asks the detail route:
|
|
31
|
+
*
|
|
32
|
+
* getDocument(e){let[a,r]=await Promise.all([
|
|
33
|
+
* t.get(`${m}/${encodeURIComponent(e)}`),f()]);...}
|
|
34
|
+
*
|
|
35
|
+
* and that slash-less path resolves — `curl` returns 401 with
|
|
36
|
+
* `allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS`, the detail route's own verb
|
|
37
|
+
* set, not a redirect and not the list's. The request shape is right. What is
|
|
38
|
+
* missing is everything that happens when it does not come back: the client
|
|
39
|
+
* accepts whatever it is handed without checking it is the record it asked for,
|
|
40
|
+
* and the composer turns every failure into an empty screen.
|
|
41
|
+
*/
|
|
42
|
+
import { describe, expect, it } from 'vitest';
|
|
43
|
+
import { render, screen, within } from '@testing-library/react';
|
|
44
|
+
|
|
45
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
46
|
+
import { createCorpusApi } from '../corpus/client';
|
|
47
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
48
|
+
import type { TenantEntity } from '../corpus/entities';
|
|
49
|
+
import type { CorpusScope } from '../corpus/types';
|
|
50
|
+
|
|
51
|
+
const SCOPE: CorpusScope = { orgId: 'org_tandem' };
|
|
52
|
+
|
|
53
|
+
/** The record the user deep-linked, live, when they reported this. */
|
|
54
|
+
const OPENED = '5ab48b58-8573-4114-b408-215dd0eaf5d7';
|
|
55
|
+
/** A well-formed id for a row that is simply not there. */
|
|
56
|
+
const ABSENT = '00000000-0000-4000-8000-000000000000';
|
|
57
|
+
|
|
58
|
+
function document(id: string, title: string, index: number): TenantEntity {
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
entity_type: 'source_document',
|
|
62
|
+
external_id: `granola:${id}`,
|
|
63
|
+
name: title,
|
|
64
|
+
org_id: SCOPE.orgId,
|
|
65
|
+
created_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T10:00:00Z`,
|
|
66
|
+
data: {
|
|
67
|
+
source: 'granola',
|
|
68
|
+
title,
|
|
69
|
+
summary: `Summary of ${title}.`,
|
|
70
|
+
occurred_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T09:00:00Z`,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A corpus the size of the reported one, with the deep-linked record buried
|
|
77
|
+
* where the composer's on-page shortcut cannot reach it.
|
|
78
|
+
*
|
|
79
|
+
* THE POSITION MATTERS. The record is 100 rows in, so page one does not contain
|
|
80
|
+
* it and `documents.find(...)` misses — which is precisely the state the
|
|
81
|
+
* reported URL lands in and the only state where the lookup is exercised at all.
|
|
82
|
+
*/
|
|
83
|
+
const CORPUS: TenantEntity[] = Array.from({ length: 180 }, (_, index) =>
|
|
84
|
+
index === 100
|
|
85
|
+
? document(OPENED, 'Tandem <> Obwb quarterly review', index)
|
|
86
|
+
: document(`doc-${index}`, `Record ${index}`, index),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Parameters this tenant recognises on `/entities/`. Everything else is ignored. */
|
|
90
|
+
const HONOURED = new Set([
|
|
91
|
+
'search', 'source', 'party', 'occurred_after', 'occurred_before',
|
|
92
|
+
'related_to', 'related_via', 'related_direction', 'related_depth', 'order',
|
|
93
|
+
]);
|
|
94
|
+
const RESERVED = new Set(['page', 'page_size', 'format', 'type']);
|
|
95
|
+
|
|
96
|
+
interface Recorded {
|
|
97
|
+
path: string;
|
|
98
|
+
params: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The tenant, as the tenant is.
|
|
103
|
+
*
|
|
104
|
+
* `detailStatus` stands in for the read gate and the session: the tenant answers
|
|
105
|
+
* an absent row with 404 and an expired bearer with 401, and those are different
|
|
106
|
+
* facts that must not collapse into the same screen.
|
|
107
|
+
*/
|
|
108
|
+
function tenantHttp(options: {
|
|
109
|
+
entities?: TenantEntity[];
|
|
110
|
+
pageSize?: number;
|
|
111
|
+
detailStatus?: number;
|
|
112
|
+
} = {}) {
|
|
113
|
+
const rows = options.entities ?? CORPUS;
|
|
114
|
+
const pageSize = options.pageSize ?? 20;
|
|
115
|
+
const requests: Recorded[] = [];
|
|
116
|
+
|
|
117
|
+
const http: CorpusHttp = {
|
|
118
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
119
|
+
const params = init?.params ?? {};
|
|
120
|
+
requests.push({ path, params });
|
|
121
|
+
|
|
122
|
+
if (path.includes('relationships')) {
|
|
123
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
124
|
+
}
|
|
125
|
+
if (path.includes('connections')) {
|
|
126
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
127
|
+
}
|
|
128
|
+
if (!path.includes('entities')) {
|
|
129
|
+
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// BOTH SPELLINGS OF THE DETAIL ROUTE, because both were measured to
|
|
133
|
+
// resolve on the live tenant. A fake that insisted on the trailing slash
|
|
134
|
+
// would manufacture evidence for a path change the wire does not ask for.
|
|
135
|
+
const detail = /entities\/([^/?]+)\/?$/.exec(path)?.[1];
|
|
136
|
+
if (detail) {
|
|
137
|
+
if (options.detailStatus) {
|
|
138
|
+
throw Object.assign(new Error(`${path} responded ${options.detailStatus}`), {
|
|
139
|
+
status: options.detailStatus,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const found = rows.find((row) => String(row.id) === decodeURIComponent(detail));
|
|
143
|
+
// ABSENT, NOT REFUSED — `readable_types_q` / `readable_rows_q` narrow the
|
|
144
|
+
// queryset, so a row this reader may not see 404s exactly like one that
|
|
145
|
+
// was never written.
|
|
146
|
+
if (!found) throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
147
|
+
return found as T;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The LIST. `?type=` narrows; every other key is swept into
|
|
151
|
+
// `ignored_filters` and the page comes back exactly as wide as it was.
|
|
152
|
+
const type = params.type ? String(params.type) : '';
|
|
153
|
+
const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
|
|
154
|
+
const size = Number(params.page_size ?? pageSize) || pageSize;
|
|
155
|
+
const page = Number(params.page ?? 1) || 1;
|
|
156
|
+
const start = (page - 1) * size;
|
|
157
|
+
const applied: string[] = [];
|
|
158
|
+
const ignored: string[] = [];
|
|
159
|
+
for (const key of Object.keys(params)) {
|
|
160
|
+
if (RESERVED.has(key)) continue;
|
|
161
|
+
(HONOURED.has(key) ? applied : ignored).push(key);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
count: matching.length,
|
|
165
|
+
next: start + size < matching.length ? `?page=${page + 1}` : null,
|
|
166
|
+
previous: page > 1 ? `?page=${page - 1}` : null,
|
|
167
|
+
results: matching.slice(start, start + size),
|
|
168
|
+
applied_filters: applied,
|
|
169
|
+
ignored_filters: ignored,
|
|
170
|
+
} as T;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { http, requests };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The tenant as it answers a lookup that was expressed as a FILTER.
|
|
179
|
+
*
|
|
180
|
+
* `GET /api/v1/entities/?id=5ab48b58-…` was checked against prod for this epic
|
|
181
|
+
* and returned 200 with count 180 — the whole corpus, unnarrowed, because `id`
|
|
182
|
+
* is not a filter this API has. Any client that reaches the entity list instead
|
|
183
|
+
* of the detail route gets THIS: a page envelope where it expected a row.
|
|
184
|
+
*
|
|
185
|
+
* The contract under test is that such a response is a FAILURE, not a document.
|
|
186
|
+
* Mapping it produces a record with no id and no title that renders as a blank
|
|
187
|
+
* panel — an arbitrary answer wearing a real record's clothes, which is worse
|
|
188
|
+
* than an error because nobody can tell it is wrong.
|
|
189
|
+
*/
|
|
190
|
+
function pageEnvelopeHttp() {
|
|
191
|
+
const { requests } = tenantHttp();
|
|
192
|
+
const http: CorpusHttp = {
|
|
193
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
194
|
+
requests.push({ path, params: init?.params ?? {} });
|
|
195
|
+
if (path.includes('entities')) {
|
|
196
|
+
return {
|
|
197
|
+
count: CORPUS.length,
|
|
198
|
+
next: '?page=2',
|
|
199
|
+
previous: null,
|
|
200
|
+
results: CORPUS.slice(0, 20),
|
|
201
|
+
ignored_filters: ['id'],
|
|
202
|
+
} as T;
|
|
203
|
+
}
|
|
204
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
return { http, requests };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function corpusOver(http: CorpusHttp) {
|
|
211
|
+
return createCorpusApi({ http, scope: SCOPE, supportedFilters: [] });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// the client
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('a document lookup returns THAT document', () => {
|
|
219
|
+
it('answers with the record that was asked for, not the first of many', async () => {
|
|
220
|
+
const api = corpusOver(tenantHttp().http);
|
|
221
|
+
const found = await api.getDocument(OPENED);
|
|
222
|
+
expect(found.id).toBe(OPENED);
|
|
223
|
+
expect(found.title).toBe('Tandem <> Obwb quarterly review');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('asks the DETAIL route, and never expresses the id as a filter', async () => {
|
|
227
|
+
// The distinction the whole bead turns on. `?id=` is swept into
|
|
228
|
+
// `ignored_filters` by this API, so a lookup spelled that way is handed the
|
|
229
|
+
// unfiltered corpus and has to guess.
|
|
230
|
+
const { http, requests } = tenantHttp();
|
|
231
|
+
await corpusOver(http).getDocument(OPENED);
|
|
232
|
+
|
|
233
|
+
const lookup = requests.find((request) => request.path.includes(OPENED));
|
|
234
|
+
expect(lookup, 'the id belongs in the PATH').toBeTruthy();
|
|
235
|
+
expect(
|
|
236
|
+
requests.every((request) => !('id' in request.params)),
|
|
237
|
+
'no request may carry the id as a query parameter',
|
|
238
|
+
).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('refuses a page envelope instead of fabricating a record from it', async () => {
|
|
242
|
+
// A response that is a LIST is not the record, whatever produced it — an
|
|
243
|
+
// ignored `?id=`, a proxy that dropped the path segment, a deployment whose
|
|
244
|
+
// detail route is a list. Mapping it yields id `undefined` and the title
|
|
245
|
+
// "Untitled unknown record": a blank panel that claims to be the document.
|
|
246
|
+
const api = corpusOver(pageEnvelopeHttp().http);
|
|
247
|
+
await expect(api.getDocument(OPENED)).rejects.toMatchObject({ status: 404 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('surfaces a record that is genuinely absent as not-found', async () => {
|
|
251
|
+
const api = corpusOver(tenantHttp().http);
|
|
252
|
+
await expect(api.getDocument(ABSENT)).rejects.toMatchObject({ status: 404 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('never answers one id with a different record', async () => {
|
|
256
|
+
const api = corpusOver(tenantHttp().http);
|
|
257
|
+
const found = await api.getDocument('doc-3');
|
|
258
|
+
expect(found.id).toBe('doc-3');
|
|
259
|
+
await expect(api.getDocument(ABSENT)).rejects.toBeTruthy();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// the screen
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
describe('the Library opens the record it was deep-linked to', () => {
|
|
268
|
+
it('renders a record that is not on the first page', async () => {
|
|
269
|
+
const api = corpusOver(tenantHttp().http);
|
|
270
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
await screen.findByRole('region', { name: 'Record detail' }),
|
|
274
|
+
).toHaveTextContent('Tandem <> Obwb quarterly review');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('SAYS SO when the record cannot be found, instead of rendering nothing', async () => {
|
|
278
|
+
// THE REPORTED SYMPTOM. `?doc=<id>` in the address bar, a list on screen,
|
|
279
|
+
// and no record and no explanation — the composer swallowed the failure into
|
|
280
|
+
// `setSelected(null)`. A screen that asked for a record and shows neither the
|
|
281
|
+
// record nor a reason is the one outcome the user cannot act on.
|
|
282
|
+
const api = corpusOver(tenantHttp().http);
|
|
283
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
|
|
284
|
+
|
|
285
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
286
|
+
expect(notice).toHaveTextContent(/could not be found/i);
|
|
287
|
+
expect(notice).toHaveTextContent(new RegExp(ABSENT));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('calls an expired session an expired session, not a missing record', async () => {
|
|
291
|
+
// "Sign in again" and "that record is gone" send the reader to different
|
|
292
|
+
// places, and only one of them is true at a time.
|
|
293
|
+
const api = corpusOver(tenantHttp({ detailStatus: 401 }).http);
|
|
294
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
295
|
+
|
|
296
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
297
|
+
expect(notice).toHaveTextContent(/session has expired/i);
|
|
298
|
+
expect(notice).not.toHaveTextContent(/could not be found/i);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('calls a refusal a refusal', async () => {
|
|
302
|
+
const api = corpusOver(tenantHttp({ detailStatus: 403 }).http);
|
|
303
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
304
|
+
|
|
305
|
+
expect(await screen.findByRole('region', { name: 'Record' })).toHaveTextContent(
|
|
306
|
+
/do not have access/i,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('lets the reader dismiss the notice and get back to the list', async () => {
|
|
311
|
+
// Without this the `?doc=` in the URL is a dead end: the panel cannot be
|
|
312
|
+
// closed because there is no panel, only a message.
|
|
313
|
+
const api = corpusOver(tenantHttp().http);
|
|
314
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
|
|
315
|
+
|
|
316
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
317
|
+
expect(within(notice).getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
318
|
+
});
|
|
319
|
+
});
|