@object-ui/data-objectstack 16.1.0 → 17.2.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 +638 -1
- package/README.md +9 -4
- package/dist/index.cjs +417 -91
- package/dist/index.d.cts +294 -21
- package/dist/index.d.ts +294 -21
- package/dist/index.js +412 -90
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- package/src/cache/MetadataCache.ts +12 -3
- package/src/errors.test.ts +12 -12
- package/src/errors.ts +23 -5
- package/src/exportDownload.test.ts +42 -0
- package/src/filter-entry-translation.test.ts +360 -0
- package/src/filter-operator-ast-parity.test.ts +71 -0
- package/src/index.ts +736 -120
- package/src/metadata-client.ts +17 -3
- package/src/onMutation.test.ts +35 -35
- package/src/onWriteWarning.test.ts +172 -0
- package/src/orderby-serialization.test.ts +115 -0
- package/src/queryDataset.test.ts +50 -1
- package/src/searchAll.test.ts +120 -0
- package/src/security.ts +18 -5
- package/src/spec-symbol-batch6.test.ts +303 -0
- package/src/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.2.0",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@objectstack/client": "^
|
|
26
|
-
"@
|
|
27
|
-
"@object-ui/
|
|
25
|
+
"@objectstack/client": "^17.0.0-rc.1",
|
|
26
|
+
"@objectstack/spec": "^17.0.0-rc.1",
|
|
27
|
+
"@object-ui/core": "17.2.0",
|
|
28
|
+
"@object-ui/types": "17.2.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"tsup": "^8.5.1",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `aggregate()` against an analytics endpoint that cannot answer.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS. `@objectstack/client`'s `analytics.query()` returns
|
|
13
|
+
* `res.json()` WITHOUT checking `res.ok`, so a non-2xx never throws — the error
|
|
14
|
+
* body arrives where rows were expected. The adapter's row parser matched none
|
|
15
|
+
* of its shapes, produced `[]`, and returned it as a RESULT: the `catch` that
|
|
16
|
+
* promises a client-side fallback never ran, and a KPI on a deployment without
|
|
17
|
+
* `@objectstack/service-analytics` rendered a confident **zero**.
|
|
18
|
+
*
|
|
19
|
+
* That is the same lie framework#3891 removed from the server side (a degraded
|
|
20
|
+
* shim answering 200 with unscoped numbers), reproduced one layer up. These
|
|
21
|
+
* tests pin the three outcomes:
|
|
22
|
+
* - capability absent (404 / 501) → client-side fallback over a scoped find();
|
|
23
|
+
* - our body is off-contract (400) → THROWS, never silently re-answered;
|
|
24
|
+
* - a normal result → untouched.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
28
|
+
import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
|
|
29
|
+
|
|
30
|
+
const RECORDS = [
|
|
31
|
+
{ id: '1', stage: 'won', amount: 100 },
|
|
32
|
+
{ id: '2', stage: 'won', amount: 50 },
|
|
33
|
+
{ id: '3', stage: 'lost', amount: 20 },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A fetch mock answering discovery, the analytics query (with `analyticsBody`
|
|
38
|
+
* at `analyticsStatus`), and the `/data` read the fallback performs.
|
|
39
|
+
*/
|
|
40
|
+
function makeFetch(analyticsStatus: number, analyticsBody: unknown) {
|
|
41
|
+
const calls: string[] = [];
|
|
42
|
+
const fetchImpl = vi.fn(async (url: any, init?: any) => {
|
|
43
|
+
const u = String(url);
|
|
44
|
+
calls.push(`${init?.method ?? 'GET'} ${u}`);
|
|
45
|
+
if (u.includes('/api/v1/discovery')) {
|
|
46
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
|
|
47
|
+
}
|
|
48
|
+
if (u.includes('/api/v1/analytics/query')) {
|
|
49
|
+
return {
|
|
50
|
+
ok: analyticsStatus >= 200 && analyticsStatus < 300,
|
|
51
|
+
status: analyticsStatus,
|
|
52
|
+
statusText: 'x',
|
|
53
|
+
json: async () => analyticsBody,
|
|
54
|
+
} as any;
|
|
55
|
+
}
|
|
56
|
+
// The fallback's scoped read.
|
|
57
|
+
return {
|
|
58
|
+
ok: true, status: 200, statusText: 'OK',
|
|
59
|
+
json: async () => ({ success: true, data: { object: 'opportunity', records: RECORDS, total: RECORDS.length } }),
|
|
60
|
+
} as any;
|
|
61
|
+
});
|
|
62
|
+
return { fetchImpl, calls };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeAdapter(fetchImpl: any) {
|
|
66
|
+
return new ObjectStackAdapter({
|
|
67
|
+
baseUrl: 'http://localhost:3000',
|
|
68
|
+
autoReconnect: false,
|
|
69
|
+
fetch: fetchImpl as any,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SUM_BY_STAGE = { function: 'sum', field: 'amount', groupBy: 'stage' };
|
|
74
|
+
|
|
75
|
+
describe('aggregate() when the analytics capability is absent', () => {
|
|
76
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
77
|
+
|
|
78
|
+
it('404 (routes not mounted, framework#4019) falls back instead of returning an empty result', async () => {
|
|
79
|
+
const { fetchImpl, calls } = makeFetch(404, { error: 'Not found' });
|
|
80
|
+
const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
|
|
81
|
+
|
|
82
|
+
// Pre-fix this was `[]` — a chart reading "no data" on a populated table.
|
|
83
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
84
|
+
const won = rows.find((r: any) => r.stage === 'won');
|
|
85
|
+
expect(won?.amount).toBe(150);
|
|
86
|
+
// It really did fall back to a server-side read (not invent numbers).
|
|
87
|
+
expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('501 NOT_IMPLEMENTED (REST route, no service) falls back the same way', async () => {
|
|
91
|
+
const { fetchImpl } = makeFetch(501, {
|
|
92
|
+
code: 'NOT_IMPLEMENTED',
|
|
93
|
+
message: 'Analytics is not available on this deployment.',
|
|
94
|
+
});
|
|
95
|
+
const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
|
|
96
|
+
expect(rows.find((r: any) => r.stage === 'won')?.amount).toBe(150);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('warns ONCE per adapter, not once per widget', async () => {
|
|
100
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
101
|
+
try {
|
|
102
|
+
const { fetchImpl } = makeFetch(404, { error: 'Not found' });
|
|
103
|
+
const adapter = makeAdapter(fetchImpl);
|
|
104
|
+
await adapter.aggregate('opportunity', SUM_BY_STAGE);
|
|
105
|
+
await adapter.aggregate('opportunity', SUM_BY_STAGE);
|
|
106
|
+
await adapter.aggregate('opportunity', SUM_BY_STAGE);
|
|
107
|
+
|
|
108
|
+
const capabilityWarnings = warn.mock.calls.filter((c) =>
|
|
109
|
+
String(c[0]).includes('analytics capability unavailable'),
|
|
110
|
+
);
|
|
111
|
+
expect(capabilityWarnings).toHaveLength(1);
|
|
112
|
+
expect(String(capabilityWarnings[0][0])).toContain('@objectstack/service-analytics');
|
|
113
|
+
} finally {
|
|
114
|
+
warn.mockRestore();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('forwards the widget filter into the fallback (never aggregates the whole table)', async () => {
|
|
119
|
+
const { fetchImpl, calls } = makeFetch(404, { error: 'Not found' });
|
|
120
|
+
await makeAdapter(fetchImpl).aggregate('opportunity', { ...SUM_BY_STAGE, filter: { stage: 'won' } });
|
|
121
|
+
|
|
122
|
+
const dataCall = calls.find((c) => c.includes('/api/v1/data'));
|
|
123
|
+
expect(dataCall).toBeDefined();
|
|
124
|
+
expect(decodeURIComponent(dataCall!)).toContain('won');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('aggregate() when the server REJECTS our query body', () => {
|
|
129
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
130
|
+
|
|
131
|
+
// framework#4010 validates /analytics/query at the entry. A 400 means the
|
|
132
|
+
// adapter sent an off-contract body — answering it with numbers from the
|
|
133
|
+
// client-side path would bury our own bug behind plausible output, which is
|
|
134
|
+
// exactly the misdirection framework#3878 documented.
|
|
135
|
+
it('400 VALIDATION_FAILED throws — it must NOT be answered by the fallback', async () => {
|
|
136
|
+
const { fetchImpl, calls } = makeFetch(400, {
|
|
137
|
+
success: false,
|
|
138
|
+
error: {
|
|
139
|
+
code: 'VALIDATION_FAILED',
|
|
140
|
+
httpStatus: 400,
|
|
141
|
+
message: 'Invalid AnalyticsQuery body: measures: Invalid input',
|
|
142
|
+
details: { fields: [{ field: 'measures', code: 'invalid_type' }] },
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const err = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE).catch((e) => e);
|
|
147
|
+
|
|
148
|
+
expect(err).toBeInstanceOf(Error);
|
|
149
|
+
expect((err as { code?: string }).code).toBe('ANALYTICS_QUERY_REJECTED');
|
|
150
|
+
expect(String(err.message)).toContain('measures');
|
|
151
|
+
// No silent second answer from a different code path.
|
|
152
|
+
expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('aggregate() on a healthy analytics endpoint', () => {
|
|
157
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
158
|
+
|
|
159
|
+
it('returns the server rows untouched (no false capability detection)', async () => {
|
|
160
|
+
const { fetchImpl, calls } = makeFetch(200, {
|
|
161
|
+
rows: [{ stage: 'won', amount_sum: 150 }],
|
|
162
|
+
fields: [{ name: 'amount_sum', type: 'number' }],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
|
|
166
|
+
|
|
167
|
+
// Measure key mapped back to the caller's column (`amount`), rows from the server.
|
|
168
|
+
expect(rows).toEqual([{ stage: 'won', amount: 150 }]);
|
|
169
|
+
expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('a `{ success, data: { rows } }` envelope is still a result, not an error', async () => {
|
|
173
|
+
const { fetchImpl } = makeFetch(200, { success: true, data: { rows: [{ stage: 'won', amount_sum: 150 }] } });
|
|
174
|
+
const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
|
|
175
|
+
expect(rows).toEqual([{ stage: 'won', amount: 150 }]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -17,9 +17,18 @@ interface CachedSchema {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* Statistics reported by {@link MetadataCache.getStats} for monitoring.
|
|
21
|
+
*
|
|
22
|
+
* NOT the spec's `CacheStats` (`@objectstack/spec/contracts`), whose name this
|
|
23
|
+
* interface wore until objectui#3160 (objectstack#4115 ledger batch 6). That one
|
|
24
|
+
* describes the platform's `ICacheService` — a server-side KV cache measured by
|
|
25
|
+
* `keyCount` and `memoryUsage`. This one describes the browser-side LRU in front
|
|
26
|
+
* of `/api/v1/meta/*`: it is bounded (`size`/`maxSize`), it evicts, it coalesces
|
|
27
|
+
* concurrent fetches onto one in-flight promise, and it reports a `hitRate`.
|
|
28
|
+
* Neither type has a key the other has, so this is a name collision, not a
|
|
29
|
+
* dialect — deriving would have replaced every field.
|
|
21
30
|
*/
|
|
22
|
-
export interface
|
|
31
|
+
export interface MetadataCacheStats {
|
|
23
32
|
size: number;
|
|
24
33
|
maxSize: number;
|
|
25
34
|
hits: number;
|
|
@@ -218,7 +227,7 @@ export class MetadataCache {
|
|
|
218
227
|
*
|
|
219
228
|
* @returns Cache statistics including hit rate
|
|
220
229
|
*/
|
|
221
|
-
getStats():
|
|
230
|
+
getStats(): MetadataCacheStats {
|
|
222
231
|
const total = this.stats.hits + this.stats.misses;
|
|
223
232
|
const hitRate = total > 0 ? this.stats.hits / total : 0;
|
|
224
233
|
|
package/src/errors.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
BulkOperationError,
|
|
14
14
|
ConnectionError,
|
|
15
15
|
AuthenticationError,
|
|
16
|
-
|
|
16
|
+
DataApiValidationError,
|
|
17
17
|
createErrorFromResponse,
|
|
18
18
|
isObjectStackError,
|
|
19
19
|
isErrorType,
|
|
@@ -189,20 +189,20 @@ describe('Error Classes', () => {
|
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
describe('
|
|
192
|
+
describe('DataApiValidationError', () => {
|
|
193
193
|
it('should create validation error', () => {
|
|
194
|
-
const error = new
|
|
194
|
+
const error = new DataApiValidationError('Invalid input');
|
|
195
195
|
|
|
196
196
|
expect(error.message).toBe('Invalid input');
|
|
197
197
|
expect(error.code).toBe('VALIDATION_ERROR');
|
|
198
198
|
expect(error.statusCode).toBe(400);
|
|
199
199
|
expect(error.name).toBe('ValidationError');
|
|
200
200
|
expect(error).toBeInstanceOf(ObjectStackError);
|
|
201
|
-
expect(error).toBeInstanceOf(
|
|
201
|
+
expect(error).toBeInstanceOf(DataApiValidationError);
|
|
202
202
|
});
|
|
203
203
|
|
|
204
204
|
it('should include field information', () => {
|
|
205
|
-
const error = new
|
|
205
|
+
const error = new DataApiValidationError('Email is invalid', 'email');
|
|
206
206
|
|
|
207
207
|
expect(error.field).toBe('email');
|
|
208
208
|
expect(error.details).toHaveProperty('field', 'email');
|
|
@@ -214,7 +214,7 @@ describe('Error Classes', () => {
|
|
|
214
214
|
{ field: 'age', message: 'Must be a positive number' },
|
|
215
215
|
];
|
|
216
216
|
|
|
217
|
-
const error = new
|
|
217
|
+
const error = new DataApiValidationError(
|
|
218
218
|
'Validation failed',
|
|
219
219
|
undefined,
|
|
220
220
|
validationErrors
|
|
@@ -225,7 +225,7 @@ describe('Error Classes', () => {
|
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
it('should return empty array when no validation errors', () => {
|
|
228
|
-
const error = new
|
|
228
|
+
const error = new DataApiValidationError('Validation failed');
|
|
229
229
|
|
|
230
230
|
expect(error.getValidationErrors()).toEqual([]);
|
|
231
231
|
});
|
|
@@ -286,7 +286,7 @@ describe('Error Helpers', () => {
|
|
|
286
286
|
expect(error.code).toBe('NOT_FOUND');
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
it('should create
|
|
289
|
+
it('should create DataApiValidationError for 400 status', () => {
|
|
290
290
|
const response = {
|
|
291
291
|
status: 400,
|
|
292
292
|
message: 'Bad request',
|
|
@@ -299,9 +299,9 @@ describe('Error Helpers', () => {
|
|
|
299
299
|
|
|
300
300
|
const error = createErrorFromResponse(response);
|
|
301
301
|
|
|
302
|
-
expect(error).toBeInstanceOf(
|
|
302
|
+
expect(error).toBeInstanceOf(DataApiValidationError);
|
|
303
303
|
expect(error.statusCode).toBe(400);
|
|
304
|
-
expect((error as
|
|
304
|
+
expect((error as DataApiValidationError).validationErrors).toEqual([
|
|
305
305
|
{ field: 'email', message: 'Invalid email' },
|
|
306
306
|
]);
|
|
307
307
|
});
|
|
@@ -379,7 +379,7 @@ describe('Error Helpers', () => {
|
|
|
379
379
|
const bulkError = new BulkOperationError('create', 0, 1, []);
|
|
380
380
|
const connError = new ConnectionError('timeout');
|
|
381
381
|
const authError = new AuthenticationError();
|
|
382
|
-
const validError = new
|
|
382
|
+
const validError = new DataApiValidationError('invalid');
|
|
383
383
|
|
|
384
384
|
expect(isObjectStackError(metadataError)).toBe(true);
|
|
385
385
|
expect(isObjectStackError(bulkError)).toBe(true);
|
|
@@ -409,7 +409,7 @@ describe('Error Helpers', () => {
|
|
|
409
409
|
|
|
410
410
|
it('should return false for non-matching error type', () => {
|
|
411
411
|
const error = new MetadataNotFoundError('users');
|
|
412
|
-
expect(isErrorType(error,
|
|
412
|
+
expect(isErrorType(error, DataApiValidationError)).toBe(false);
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
it('should return true for base class check', () => {
|
package/src/errors.ts
CHANGED
|
@@ -159,12 +159,24 @@ export class AuthenticationError extends ObjectStackError {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* Error thrown when data
|
|
162
|
+
* Error thrown when the ObjectStack data API rejects a write as invalid.
|
|
163
|
+
*
|
|
164
|
+
* NOT the spec's `ValidationError` (`@objectstack/spec/kernel`), whose name this
|
|
165
|
+
* class wore until objectui#3160 (objectstack#4115 ledger batch 6). That one is
|
|
166
|
+
* a plain DATA SHAPE — `{ field, message, code? }`, one entry in a plugin
|
|
167
|
+
* manifest's validation report — and `@object-ui/types` re-exports it under that
|
|
168
|
+
* name. This is a runtime `Error` subclass carrying an HTTP status plus a list
|
|
169
|
+
* of such entries, so the two are not even the same KIND of thing.
|
|
170
|
+
*
|
|
171
|
+
* The name follows the convention registered on objectstack#4115 for this
|
|
172
|
+
* family — `<what was validated>Validation<Error|Result>`. `@object-ui/core`
|
|
173
|
+
* took `SchemaNodeValidationError` for its SDUI-tree walk; this one belongs to
|
|
174
|
+
* the data API.
|
|
163
175
|
*/
|
|
164
|
-
export class
|
|
176
|
+
export class DataApiValidationError extends ObjectStackError {
|
|
165
177
|
/**
|
|
166
|
-
* Create a new
|
|
167
|
-
*
|
|
178
|
+
* Create a new DataApiValidationError
|
|
179
|
+
*
|
|
168
180
|
* @param message - Human-readable error message
|
|
169
181
|
* @param field - The field that failed validation (optional)
|
|
170
182
|
* @param validationErrors - Array of validation error details
|
|
@@ -186,6 +198,12 @@ export class ValidationError extends ObjectStackError {
|
|
|
186
198
|
...details,
|
|
187
199
|
}
|
|
188
200
|
);
|
|
201
|
+
// The RUNTIME name stays `'ValidationError'` on purpose — it is the wire
|
|
202
|
+
// discriminator this adapter shares with `@objectstack/client` and with
|
|
203
|
+
// consumers that sniff `err.name` rather than `instanceof`
|
|
204
|
+
// (`normaliseClientError` here, `@object-ui/react`'s `error-message`).
|
|
205
|
+
// Renaming the TypeScript symbol is a source-level rename; renaming this
|
|
206
|
+
// string would be a behaviour change nobody asked for.
|
|
189
207
|
this.name = 'ValidationError';
|
|
190
208
|
}
|
|
191
209
|
|
|
@@ -232,7 +250,7 @@ export function createErrorFromResponse(response: Record<string, unknown>, conte
|
|
|
232
250
|
return new ObjectStackError(message, 'NOT_FOUND', 404, details);
|
|
233
251
|
|
|
234
252
|
case 400:
|
|
235
|
-
return new
|
|
253
|
+
return new DataApiValidationError(message, undefined, (response?.data as Record<string, unknown>)?.errors as Array<{ field: string; message: string }>, details);
|
|
236
254
|
|
|
237
255
|
case 503:
|
|
238
256
|
return new ConnectionError(message, (response?.config as Record<string, unknown>)?.url as string, details, 503);
|
|
@@ -97,3 +97,45 @@ describe('ObjectStackAdapter.exportDownload', () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `search` — the half of a list this request could not carry.
|
|
103
|
+
*
|
|
104
|
+
* The route mirrored `filter` and `orderby` only, so an export taken while a
|
|
105
|
+
* search was active downloaded the UNSEARCHED superset: more rows than the
|
|
106
|
+
* screen showed, in a file that looks authoritative. Server half:
|
|
107
|
+
* objectstack#4230.
|
|
108
|
+
*/
|
|
109
|
+
describe('ObjectStackAdapter.exportDownload — search', () => {
|
|
110
|
+
const paramsFor = async (request: Record<string, unknown>) => {
|
|
111
|
+
const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => csvResponse());
|
|
112
|
+
await makeDS(fetchImpl).exportDownload('task', request);
|
|
113
|
+
return new URL(fetchImpl.mock.calls[0][0]).searchParams;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it('sends the term as `search`', async () => {
|
|
117
|
+
expect((await paramsFor({ search: 'acme' })).get('search')).toBe('acme');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('sends `searchFields` as a comma list alongside the term', async () => {
|
|
121
|
+
const p = await paramsFor({ search: 'acme', searchFields: ['name', 'stage'] });
|
|
122
|
+
expect(p.get('searchFields')).toBe('name,stage');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('omits both when no term is given — `searchFields` alone means nothing', async () => {
|
|
126
|
+
const p = await paramsFor({ searchFields: ['name'] });
|
|
127
|
+
expect(p.get('search')).toBeNull();
|
|
128
|
+
expect(p.get('searchFields')).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('treats a whitespace-only term as absent, and trims a real one', async () => {
|
|
132
|
+
expect((await paramsFor({ search: ' ' })).get('search')).toBeNull();
|
|
133
|
+
expect((await paramsFor({ search: ' acme ' })).get('search')).toBe('acme');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('carries search and filter together — neither replaces the other', async () => {
|
|
137
|
+
const p = await paramsFor({ search: 'acme', filter: [['status', '=', 'open']] });
|
|
138
|
+
expect(p.get('search')).toBe('acme');
|
|
139
|
+
expect(JSON.parse(p.get('filter')!)).toEqual([['status', '=', 'open']]);
|
|
140
|
+
});
|
|
141
|
+
});
|