@object-ui/data-objectstack 16.1.0 → 17.1.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 +513 -1
- package/README.md +3 -3
- package/dist/index.cjs +411 -85
- package/dist/index.d.cts +243 -8
- package/dist/index.d.ts +243 -8
- package/dist/index.js +407 -85
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- 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 +726 -116
- 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/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
package/src/userState.test.ts
CHANGED
|
@@ -144,7 +144,6 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
144
144
|
user_id: 'u1',
|
|
145
145
|
key: 'ui.favorites',
|
|
146
146
|
value: [{ id: 'a' }],
|
|
147
|
-
updated_at: expect.any(String),
|
|
148
147
|
});
|
|
149
148
|
});
|
|
150
149
|
|
|
@@ -165,11 +164,35 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
165
164
|
|
|
166
165
|
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-99', {
|
|
167
166
|
value: [{ id: 'x' }],
|
|
168
|
-
updated_at: expect.any(String),
|
|
169
167
|
});
|
|
170
168
|
expect(ds.create).not.toHaveBeenCalled();
|
|
171
169
|
});
|
|
172
170
|
|
|
171
|
+
// #3794 — `updated_at` is server-managed. A caller-supplied value is
|
|
172
|
+
// stripped (framework #2948) and reported back as a dropped field, which
|
|
173
|
+
// the console turns into a "Some fields were not saved" toast. Stamping it
|
|
174
|
+
// here fired that warning on every recents/favorites write, about a field
|
|
175
|
+
// no user ever touched — noise on the exact surface that is supposed to
|
|
176
|
+
// tell a user their edit did not land.
|
|
177
|
+
it('never sends the server-managed updated_at column', async () => {
|
|
178
|
+
const ds = mockDataSource({
|
|
179
|
+
find: vi.fn().mockResolvedValue({ data: [] }) as any,
|
|
180
|
+
create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
|
|
181
|
+
});
|
|
182
|
+
const adapter = createObjectStackUserStateAdapter({
|
|
183
|
+
dataSource: ds,
|
|
184
|
+
userId: 'u',
|
|
185
|
+
key: 'ui.recent',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await adapter.save([{ id: 'a' } as any]);
|
|
189
|
+
await adapter.save([{ id: 'b' } as any]);
|
|
190
|
+
|
|
191
|
+
for (const call of [...(ds.create as any).mock.calls, ...(ds.update as any).mock.calls]) {
|
|
192
|
+
expect(call[call.length - 1]).not.toHaveProperty('updated_at');
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
173
196
|
it('uses the cached row id from a previous load on subsequent saves', async () => {
|
|
174
197
|
const ds = mockDataSource({
|
|
175
198
|
find: vi.fn().mockResolvedValue({
|
|
@@ -242,7 +265,6 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
242
265
|
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
243
266
|
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
|
|
244
267
|
value: [{ id: 'q' }],
|
|
245
|
-
updated_at: expect.any(String),
|
|
246
268
|
});
|
|
247
269
|
});
|
|
248
270
|
|
|
@@ -270,7 +292,6 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
270
292
|
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
271
293
|
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
|
|
272
294
|
value: [{ id: 'a' }, { id: 'b' }],
|
|
273
|
-
updated_at: expect.any(String),
|
|
274
295
|
});
|
|
275
296
|
});
|
|
276
297
|
|
package/src/userState.ts
CHANGED
|
@@ -159,13 +159,18 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
159
159
|
// (a concurrent writer created the row, or the backend dropped our find
|
|
160
160
|
// predicate), recover by re-finding and updating rather than surfacing the
|
|
161
161
|
// failed insert.
|
|
162
|
+
//
|
|
163
|
+
// Deliberately does NOT stamp `updated_at`: the column is server-managed, and
|
|
164
|
+
// a non-system caller's write to it is stripped (framework #2948) and reported
|
|
165
|
+
// back as a dropped field — which the console surfaces as a "Some fields were
|
|
166
|
+
// not saved" toast (#3431). Sending it made every recents/favorites write pop
|
|
167
|
+
// a scary warning about a field the user never touched, drowning the real
|
|
168
|
+
// signal the toast exists for (#3794). The server stamps it either way.
|
|
162
169
|
const upsert = async (items: T[]): Promise<void> => {
|
|
163
|
-
const now = new Date().toISOString();
|
|
164
|
-
|
|
165
170
|
// Fast path: we already know the row id from a previous load/save.
|
|
166
171
|
if (cachedRowId !== null) {
|
|
167
172
|
try {
|
|
168
|
-
await dataSource.update(resource, cachedRowId, { value: items
|
|
173
|
+
await dataSource.update(resource, cachedRowId, { value: items });
|
|
169
174
|
return;
|
|
170
175
|
} catch (updateError) {
|
|
171
176
|
// Row may have been deleted server-side — fall through to find/insert.
|
|
@@ -177,7 +182,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
177
182
|
const existing = await findExisting();
|
|
178
183
|
if (existing && existing.id !== undefined && existing.id !== null) {
|
|
179
184
|
cachedRowId = existing.id;
|
|
180
|
-
await dataSource.update(resource, existing.id, { value: items
|
|
185
|
+
await dataSource.update(resource, existing.id, { value: items });
|
|
181
186
|
return;
|
|
182
187
|
}
|
|
183
188
|
|
|
@@ -186,7 +191,6 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
186
191
|
user_id: userId,
|
|
187
192
|
key,
|
|
188
193
|
value: items,
|
|
189
|
-
updated_at: now,
|
|
190
194
|
});
|
|
191
195
|
const newId = (created as UserPreferenceRecord | undefined)?.id;
|
|
192
196
|
if (newId !== undefined && newId !== null) cachedRowId = newId;
|
|
@@ -196,7 +200,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
196
200
|
const recovered = await findExisting();
|
|
197
201
|
if (recovered && recovered.id !== undefined && recovered.id !== null) {
|
|
198
202
|
cachedRowId = recovered.id;
|
|
199
|
-
await dataSource.update(resource, recovered.id, { value: items
|
|
203
|
+
await dataSource.update(resource, recovered.id, { value: items });
|
|
200
204
|
return;
|
|
201
205
|
}
|
|
202
206
|
throw createError;
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { normaliseClientError } from './index';
|
|
12
|
+
import { ValidationError } from './errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The server has always sent per-field rejection detail; the adapter used to
|
|
16
|
+
* hand callers the raw client error, so `fields[]` had no typed home and every
|
|
17
|
+
* form fell back to one undirected toast.
|
|
18
|
+
*/
|
|
19
|
+
describe('normaliseClientError — VALIDATION_FAILED', () => {
|
|
20
|
+
// `@objectstack/rest` serves a thrown validation failure as 400 with `fields[]`
|
|
21
|
+
// verbatim; `@objectstack/client` sets `error.details` to the parsed body's
|
|
22
|
+
// `details` OR — as here, since the envelope has no such key — the whole body.
|
|
23
|
+
const upstream = () =>
|
|
24
|
+
Object.assign(new Error('Name is required; Stage is not a valid option'), {
|
|
25
|
+
code: 'VALIDATION_FAILED',
|
|
26
|
+
httpStatus: 400,
|
|
27
|
+
details: {
|
|
28
|
+
error: 'Name is required; Stage is not a valid option',
|
|
29
|
+
code: 'VALIDATION_FAILED',
|
|
30
|
+
fields: [
|
|
31
|
+
{ field: 'name', code: 'required', message: 'Name is required' },
|
|
32
|
+
{ field: 'stage', code: 'invalid_option', message: 'Stage is not a valid option' },
|
|
33
|
+
],
|
|
34
|
+
object: 'crm_opportunity',
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('wraps it into a typed ValidationError', () => {
|
|
39
|
+
const normalised = normaliseClientError(upstream());
|
|
40
|
+
expect(normalised).toBeInstanceOf(ValidationError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('carries every field entry through', () => {
|
|
44
|
+
const normalised = normaliseClientError(upstream()) as ValidationError;
|
|
45
|
+
expect(normalised.validationErrors).toEqual([
|
|
46
|
+
{ field: 'name', message: 'Name is required' },
|
|
47
|
+
{ field: 'stage', message: 'Stage is not a valid option' },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps the server message verbatim — it names every field', () => {
|
|
52
|
+
const normalised = normaliseClientError(upstream()) as ValidationError;
|
|
53
|
+
expect(normalised.message).toBe('Name is required; Stage is not a valid option');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('exposes the first offending field on `.field`', () => {
|
|
57
|
+
const normalised = normaliseClientError(upstream()) as ValidationError;
|
|
58
|
+
expect(normalised.field).toBe('name');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('falls back to the machine code when an entry has no message', () => {
|
|
62
|
+
const err = Object.assign(new Error('Validation failed'), {
|
|
63
|
+
code: 'VALIDATION_FAILED',
|
|
64
|
+
details: { fields: [{ field: 'name', code: 'required' }] },
|
|
65
|
+
});
|
|
66
|
+
const normalised = normaliseClientError(err) as ValidationError;
|
|
67
|
+
expect(normalised.validationErrors).toEqual([{ field: 'name', message: 'required' }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('drops entries with no field rather than inventing one', () => {
|
|
71
|
+
const err = Object.assign(new Error('Validation failed'), {
|
|
72
|
+
code: 'VALIDATION_FAILED',
|
|
73
|
+
details: { fields: [{ message: 'something is wrong' }] },
|
|
74
|
+
});
|
|
75
|
+
const normalised = normaliseClientError(err) as ValidationError;
|
|
76
|
+
expect(normalised.validationErrors).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('still wraps when the error carries `fields` directly (hand-rolled shape)', () => {
|
|
80
|
+
// The server duck-types on `code`/`name`, so a hook throwing the bare shape
|
|
81
|
+
// is served identically — the client must not be pickier than the server.
|
|
82
|
+
const err = Object.assign(new Error('nope'), {
|
|
83
|
+
name: 'ValidationError',
|
|
84
|
+
fields: [{ field: 'amount', message: 'Amount must be positive' }],
|
|
85
|
+
});
|
|
86
|
+
const normalised = normaliseClientError(err) as ValidationError;
|
|
87
|
+
expect(normalised.validationErrors).toEqual([{ field: 'amount', message: 'Amount must be positive' }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('leaves an unrelated 400 untouched', () => {
|
|
91
|
+
const err = Object.assign(new Error('bad request'), { code: 'BAD_REQUEST', httpStatus: 400 });
|
|
92
|
+
expect(normaliseClientError(err)).toBe(err);
|
|
93
|
+
});
|
|
94
|
+
});
|