@open-mercato/core 0.6.5-develop.4588.1.ecaa16cfc0 → 0.6.5-develop.4620.1.c20bc7e4bb
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/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/crudFormFields.js +33 -0
- package/dist/helpers/integration/crudFormFields.js.map +7 -0
- package/dist/helpers/integration/crudFormPersistence.js +105 -0
- package/dist/helpers/integration/crudFormPersistence.js.map +7 -0
- package/dist/helpers/integration/undoHarness.js +81 -0
- package/dist/helpers/integration/undoHarness.js.map +7 -0
- package/dist/modules/customers/components/formConfig.js +22 -7
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +17 -4
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/dictionaries/commands/entry-operations.js +8 -0
- package/dist/modules/dictionaries/commands/entry-operations.js.map +2 -2
- package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js +6 -2
- package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js.map +2 -2
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js +9 -1
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crudFormFields.ts +48 -0
- package/src/helpers/integration/crudFormPersistence.ts +166 -0
- package/src/helpers/integration/undoHarness.ts +111 -0
- package/src/modules/customers/components/formConfig.tsx +59 -25
- package/src/modules/customers/data/validators.ts +25 -9
- package/src/modules/dictionaries/commands/entry-operations.ts +19 -0
- package/src/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.tsx +23 -2
- package/src/modules/translations/api/[entityType]/[entityId]/route.ts +9 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure, runner-agnostic helpers for the CrudForm field-persistence sweep (umbrella #2466).
|
|
5
|
+
*
|
|
6
|
+
* Kept free of any `@playwright/test` import so this logic is unit-testable under jest.
|
|
7
|
+
* The Playwright harness (`crudFormPersistence.ts`) re-exports everything here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CRUDFORM_EXTENSION_TESTS_DISABLED_ENV = 'OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED';
|
|
11
|
+
|
|
12
|
+
export type CrudRecord = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads the sweep disable flag. Default `false` so the sweep runs unless explicitly turned off
|
|
16
|
+
* via `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED=1` (or `true`/`yes`/`on`).
|
|
17
|
+
*/
|
|
18
|
+
export function crudFormExtensionTestsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
19
|
+
return parseBooleanWithDefault(env[CRUDFORM_EXTENSION_TESTS_DISABLED_ENV], false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a custom-field value from a CRUD response record, tolerating every shape the
|
|
24
|
+
* platform emits: bare keys under `customValues`, top-level `cf_<name>` / `cf:<name>`, or a
|
|
25
|
+
* `customFields` definition array carrying `value`. Returns `undefined` when absent.
|
|
26
|
+
*/
|
|
27
|
+
export function getCustomFieldValue(record: CrudRecord, fieldName: string): unknown {
|
|
28
|
+
const customValues = record.customValues;
|
|
29
|
+
if (customValues && typeof customValues === 'object' && fieldName in (customValues as CrudRecord)) {
|
|
30
|
+
return (customValues as CrudRecord)[fieldName];
|
|
31
|
+
}
|
|
32
|
+
const prefixedUnderscore = record[`cf_${fieldName}`];
|
|
33
|
+
if (prefixedUnderscore !== undefined) return prefixedUnderscore;
|
|
34
|
+
const prefixedColon = record[`cf:${fieldName}`];
|
|
35
|
+
if (prefixedColon !== undefined) return prefixedColon;
|
|
36
|
+
const customFields = record.customFields;
|
|
37
|
+
if (Array.isArray(customFields)) {
|
|
38
|
+
const match = customFields.find((entry) => {
|
|
39
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
40
|
+
const candidate = entry as CrudRecord;
|
|
41
|
+
return candidate.key === fieldName || candidate.id === fieldName || candidate.name === fieldName;
|
|
42
|
+
});
|
|
43
|
+
if (match && typeof match === 'object') {
|
|
44
|
+
return (match as CrudRecord).value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test';
|
|
2
|
+
import { apiRequest } from './api';
|
|
3
|
+
import { expectId, readJsonSafe } from './generalFixtures';
|
|
4
|
+
import {
|
|
5
|
+
CRUDFORM_EXTENSION_TESTS_DISABLED_ENV,
|
|
6
|
+
crudFormExtensionTestsDisabled,
|
|
7
|
+
getCustomFieldValue,
|
|
8
|
+
type CrudRecord,
|
|
9
|
+
} from './crudFormFields';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Playwright harness for the CrudForm field-persistence sweep (umbrella #2466).
|
|
13
|
+
*
|
|
14
|
+
* Every spec proves a CrudForm surface saves AND reloads every field type — scalars,
|
|
15
|
+
* dictionary references, multiselect/array values, and **custom fields** — on both create
|
|
16
|
+
* and update.
|
|
17
|
+
*
|
|
18
|
+
* The whole sweep is gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED`
|
|
19
|
+
* (default `false` → tests run). When set truthy the specs `test.skip()` themselves, so the
|
|
20
|
+
* sweep can be disabled wholesale without deleting any spec.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
CRUDFORM_EXTENSION_TESTS_DISABLED_ENV,
|
|
25
|
+
crudFormExtensionTestsDisabled,
|
|
26
|
+
getCustomFieldValue,
|
|
27
|
+
type CrudRecord,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Call inside `test.beforeAll` (or a test body) to skip the spec when the sweep is disabled.
|
|
32
|
+
* Uses Playwright's `test.skip(condition, reason)` so the spec is reported as skipped, not failed.
|
|
33
|
+
*/
|
|
34
|
+
export function skipIfCrudFormExtensionTestsDisabled(): void {
|
|
35
|
+
test.skip(
|
|
36
|
+
crudFormExtensionTestsDisabled(),
|
|
37
|
+
`${CRUDFORM_EXTENSION_TESTS_DISABLED_ENV} is set — CrudForm field-persistence sweep skipped`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Asserts each expected scalar field round-tripped (deep equality so arrays/objects work). */
|
|
42
|
+
export function assertScalarFieldsPersisted(record: CrudRecord, expected: CrudRecord, label = 'record'): void {
|
|
43
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
44
|
+
expect(record[key], `${label}.${key} should persist`).toEqual(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Asserts each expected custom field round-tripped, regardless of response shape. */
|
|
49
|
+
export function assertCustomFieldsPersisted(record: CrudRecord, expected: CrudRecord, label = 'record'): void {
|
|
50
|
+
for (const [name, value] of Object.entries(expected)) {
|
|
51
|
+
expect(getCustomFieldValue(record, name), `${label} custom field "${name}" should persist`).toEqual(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function safeText(response: APIResponse): Promise<string> {
|
|
56
|
+
try {
|
|
57
|
+
return (await response.text()).slice(0, 500);
|
|
58
|
+
} catch {
|
|
59
|
+
return '<unreadable body>';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type CrudFormExpectation = {
|
|
64
|
+
scalars?: CrudRecord;
|
|
65
|
+
customFields?: CrudRecord;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CrudFormRoundTripConfig = {
|
|
69
|
+
request: APIRequestContext;
|
|
70
|
+
token: string;
|
|
71
|
+
/** Collection route handling POST/PUT/DELETE and a `?id=` list GET, e.g. `/api/currencies/currencies`. */
|
|
72
|
+
collectionPath: string;
|
|
73
|
+
create: { payload: CrudRecord; expectedStatus?: number };
|
|
74
|
+
/** Build the PUT body from the created id. MUST include the id the route expects. */
|
|
75
|
+
update: { payload: (id: string) => CrudRecord; expectedStatus?: number };
|
|
76
|
+
expectAfterCreate: CrudFormExpectation;
|
|
77
|
+
expectAfterUpdate: CrudFormExpectation;
|
|
78
|
+
/** Override id extraction from the create response (default: `id` ?? `entityId`). */
|
|
79
|
+
idFromCreate?: (body: CrudRecord) => string;
|
|
80
|
+
/** Override read-back (default: list `?id=` and match on `id`). Useful for detail-GET routes. */
|
|
81
|
+
readById?: (id: string) => Promise<CrudRecord | null>;
|
|
82
|
+
/** Delete the fixture in `finally` (default true). */
|
|
83
|
+
cleanup?: boolean;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async function defaultReadById(
|
|
87
|
+
request: APIRequestContext,
|
|
88
|
+
token: string,
|
|
89
|
+
collectionPath: string,
|
|
90
|
+
id: string,
|
|
91
|
+
): Promise<CrudRecord | null> {
|
|
92
|
+
const separator = collectionPath.includes('?') ? '&' : '?';
|
|
93
|
+
const response = await apiRequest(
|
|
94
|
+
request,
|
|
95
|
+
'GET',
|
|
96
|
+
`${collectionPath}${separator}id=${encodeURIComponent(id)}&page=1&pageSize=100`,
|
|
97
|
+
{ token },
|
|
98
|
+
);
|
|
99
|
+
expect(response.status(), `read-back ${collectionPath} failed: ${response.status()}`).toBe(200);
|
|
100
|
+
const body = await readJsonSafe<{ items?: CrudRecord[] } & CrudRecord>(response);
|
|
101
|
+
if (Array.isArray(body?.items)) {
|
|
102
|
+
return body!.items.find((item) => item.id === id) ?? null;
|
|
103
|
+
}
|
|
104
|
+
// Detail routes may return the record directly.
|
|
105
|
+
return body && body.id === id ? (body as CrudRecord) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Runs the canonical create → read-back → assert → update → read-back → assert → delete
|
|
110
|
+
* cycle for a makeCrud collection route and asserts every declared field persisted.
|
|
111
|
+
*/
|
|
112
|
+
export async function runCrudFormRoundTrip(config: CrudFormRoundTripConfig): Promise<void> {
|
|
113
|
+
const { request, token, collectionPath } = config;
|
|
114
|
+
const read = config.readById ?? ((id: string) => defaultReadById(request, token, collectionPath, id));
|
|
115
|
+
let id: string | null = null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const createResponse = await apiRequest(request, 'POST', collectionPath, {
|
|
119
|
+
token,
|
|
120
|
+
data: config.create.payload,
|
|
121
|
+
});
|
|
122
|
+
expect(
|
|
123
|
+
createResponse.status(),
|
|
124
|
+
`create ${collectionPath} failed (${createResponse.status()}): ${await safeText(createResponse)}`,
|
|
125
|
+
).toBe(config.create.expectedStatus ?? 201);
|
|
126
|
+
const createBody = (await readJsonSafe<CrudRecord>(createResponse)) ?? {};
|
|
127
|
+
const rawId = config.idFromCreate
|
|
128
|
+
? config.idFromCreate(createBody)
|
|
129
|
+
: (createBody.id ?? createBody.entityId);
|
|
130
|
+
id = expectId(rawId, `create response should include an id (${collectionPath})`);
|
|
131
|
+
|
|
132
|
+
const afterCreate = await read(id);
|
|
133
|
+
expect(afterCreate, `created record ${id} should be readable from ${collectionPath}`).toBeTruthy();
|
|
134
|
+
if (config.expectAfterCreate.scalars) {
|
|
135
|
+
assertScalarFieldsPersisted(afterCreate as CrudRecord, config.expectAfterCreate.scalars, 'after-create');
|
|
136
|
+
}
|
|
137
|
+
if (config.expectAfterCreate.customFields) {
|
|
138
|
+
assertCustomFieldsPersisted(afterCreate as CrudRecord, config.expectAfterCreate.customFields, 'after-create');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const updateResponse = await apiRequest(request, 'PUT', collectionPath, {
|
|
142
|
+
token,
|
|
143
|
+
data: config.update.payload(id),
|
|
144
|
+
});
|
|
145
|
+
expect(
|
|
146
|
+
updateResponse.status(),
|
|
147
|
+
`update ${collectionPath} failed (${updateResponse.status()}): ${await safeText(updateResponse)}`,
|
|
148
|
+
).toBe(config.update.expectedStatus ?? 200);
|
|
149
|
+
|
|
150
|
+
const afterUpdate = await read(id);
|
|
151
|
+
expect(afterUpdate, `updated record ${id} should be readable from ${collectionPath}`).toBeTruthy();
|
|
152
|
+
if (config.expectAfterUpdate.scalars) {
|
|
153
|
+
assertScalarFieldsPersisted(afterUpdate as CrudRecord, config.expectAfterUpdate.scalars, 'after-update');
|
|
154
|
+
}
|
|
155
|
+
if (config.expectAfterUpdate.customFields) {
|
|
156
|
+
assertCustomFieldsPersisted(afterUpdate as CrudRecord, config.expectAfterUpdate.customFields, 'after-update');
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
if (id && config.cleanup !== false) {
|
|
160
|
+
const separator = collectionPath.includes('?') ? '&' : '?';
|
|
161
|
+
await apiRequest(request, 'DELETE', `${collectionPath}${separator}id=${encodeURIComponent(id)}`, {
|
|
162
|
+
token,
|
|
163
|
+
}).catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type APIRequestContext, type APIResponse, expect } from '@playwright/test'
|
|
2
|
+
import { apiRequest } from './api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared harness for verifying Undo/Redo correctness against the real command bus.
|
|
6
|
+
*
|
|
7
|
+
* Every mutating Open Mercato API response carries the operation metadata in the
|
|
8
|
+
* `x-om-operation` header (`omop:<urlencoded JSON>`) containing the `undoToken` and the
|
|
9
|
+
* audit log `id` (used as `logId` for redo). These helpers extract that envelope and drive
|
|
10
|
+
* the real undo/redo endpoints so tests can assert full state restoration per TC-UNDO-001.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const HEADER_PREFIX = 'omop:'
|
|
14
|
+
const UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'
|
|
15
|
+
const REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'
|
|
16
|
+
const ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'
|
|
17
|
+
|
|
18
|
+
export type Operation = {
|
|
19
|
+
logId: string
|
|
20
|
+
undoToken: string
|
|
21
|
+
commandId: string
|
|
22
|
+
resourceKind: string | null
|
|
23
|
+
resourceId: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */
|
|
27
|
+
export function extractOperation(response: APIResponse): Operation | null {
|
|
28
|
+
const header = response.headers()['x-om-operation']
|
|
29
|
+
if (!header || typeof header !== 'string') return null
|
|
30
|
+
const trimmed = header.startsWith(HEADER_PREFIX) ? header.slice(HEADER_PREFIX.length) : header
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(decodeURIComponent(trimmed)) as Record<string, unknown>
|
|
33
|
+
if (typeof parsed.id !== 'string' || typeof parsed.commandId !== 'string') return null
|
|
34
|
+
if (typeof parsed.undoToken !== 'string' || !parsed.undoToken) return null
|
|
35
|
+
return {
|
|
36
|
+
logId: parsed.id,
|
|
37
|
+
undoToken: parsed.undoToken,
|
|
38
|
+
commandId: parsed.commandId,
|
|
39
|
+
resourceKind: (parsed.resourceKind as string) ?? null,
|
|
40
|
+
resourceId: (parsed.resourceId as string) ?? null,
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Like extractOperation but fails the test if no undo token was issued. */
|
|
48
|
+
export function expectOperation(response: APIResponse, context: string): Operation {
|
|
49
|
+
const op = extractOperation(response)
|
|
50
|
+
expect(op, `Expected an undo token (x-om-operation header) for ${context}, got none`).toBeTruthy()
|
|
51
|
+
return op as Operation
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function undoByToken(request: APIRequestContext, token: string, undoToken: string): Promise<APIResponse> {
|
|
55
|
+
return apiRequest(request, 'POST', UNDO_PATH, { token, data: { undoToken } })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function redoByLogId(request: APIRequestContext, token: string, logId: string): Promise<APIResponse> {
|
|
59
|
+
return apiRequest(request, 'POST', REDO_PATH, { token, data: { logId } })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Undo and assert success; returns the resolved logId. */
|
|
63
|
+
export async function undoOk(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<string> {
|
|
64
|
+
const res = await undoByToken(request, token, undoToken)
|
|
65
|
+
const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string } | null
|
|
66
|
+
expect(res.ok(), `Undo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()
|
|
67
|
+
expect(body?.ok, `Undo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()
|
|
68
|
+
return body?.logId as string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Redo and assert success; returns the new operation (new undoToken + logId). */
|
|
72
|
+
export async function redoOk(request: APIRequestContext, token: string, logId: string, context: string): Promise<{ logId: string; undoToken: string | null }> {
|
|
73
|
+
const res = await redoByLogId(request, token, logId)
|
|
74
|
+
const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string; undoToken?: string } | null
|
|
75
|
+
expect(res.ok(), `Redo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()
|
|
76
|
+
expect(body?.ok, `Redo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()
|
|
77
|
+
return { logId: body?.logId as string, undoToken: body?.undoToken ?? null }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Assert that undoing an already-consumed token is rejected (token consumption / no double-undo). */
|
|
81
|
+
export async function expectTokenConsumed(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<void> {
|
|
82
|
+
const res = await undoByToken(request, token, undoToken)
|
|
83
|
+
expect(res.ok(), `Expected double-undo to be rejected for ${context}, but it succeeded`).toBeFalsy()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Fetch undoable actions list (for Version History assertions). */
|
|
87
|
+
export async function listUndoable(request: APIRequestContext, token: string, params: Record<string, string> = {}): Promise<unknown> {
|
|
88
|
+
const qs = new URLSearchParams({ undoableOnly: 'true', ...params }).toString()
|
|
89
|
+
const res = await apiRequest(request, 'GET', `${ACTIONS_PATH}?${qs}`, { token })
|
|
90
|
+
return res.json().catch(() => null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Deep-equality assertion for a selected set of fields between two entity snapshots.
|
|
95
|
+
* Reports the first mismatching field with context for clear bug triage.
|
|
96
|
+
*/
|
|
97
|
+
export function assertFieldsEqual(
|
|
98
|
+
actual: Record<string, unknown> | null | undefined,
|
|
99
|
+
expected: Record<string, unknown> | null | undefined,
|
|
100
|
+
fields: string[],
|
|
101
|
+
context: string,
|
|
102
|
+
): void {
|
|
103
|
+
expect(actual, `${context}: actual entity missing`).toBeTruthy()
|
|
104
|
+
expect(expected, `${context}: expected entity missing`).toBeTruthy()
|
|
105
|
+
for (const field of fields) {
|
|
106
|
+
expect(
|
|
107
|
+
JSON.stringify((actual as Record<string, unknown>)[field]),
|
|
108
|
+
`${context}: field "${field}" not restored (expected ${JSON.stringify((expected as Record<string, unknown>)[field])}, got ${JSON.stringify((actual as Record<string, unknown>)[field])})`,
|
|
109
|
+
).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -1427,15 +1427,20 @@ export function buildCompanyPayload(
|
|
|
1427
1427
|
// Edit-mode types
|
|
1428
1428
|
// ---------------------------------------------------------------------------
|
|
1429
1429
|
|
|
1430
|
-
|
|
1430
|
+
// URL/email fields are clearable on edit: blanking a previously-set value transmits null,
|
|
1431
|
+
// so the edit-form value types widen to `string | null` to match the edit-schema output. See #2526.
|
|
1432
|
+
export type CompanyEditFormValues = Omit<CompanyFormValues, 'addresses' | 'primaryEmail' | 'websiteUrl'> & {
|
|
1431
1433
|
id: string
|
|
1434
|
+
primaryEmail?: string | null
|
|
1435
|
+
websiteUrl?: string | null
|
|
1432
1436
|
}
|
|
1433
1437
|
|
|
1434
|
-
export type PersonEditFormValues = Omit<PersonFormValues, 'addresses'> & {
|
|
1438
|
+
export type PersonEditFormValues = Omit<PersonFormValues, 'addresses' | 'primaryEmail'> & {
|
|
1435
1439
|
id: string
|
|
1436
1440
|
department?: string
|
|
1437
|
-
|
|
1438
|
-
|
|
1441
|
+
primaryEmail?: string | null
|
|
1442
|
+
linkedInUrl?: string | null
|
|
1443
|
+
twitterUrl?: string | null
|
|
1439
1444
|
}
|
|
1440
1445
|
|
|
1441
1446
|
// ---------------------------------------------------------------------------
|
|
@@ -1451,31 +1456,44 @@ const optionalString = () =>
|
|
|
1451
1456
|
.transform((val) => (val === '' ? undefined : val))
|
|
1452
1457
|
.optional()
|
|
1453
1458
|
|
|
1459
|
+
// Edit-mode URL/email fields map to nullable columns and must be clearable: blanking a
|
|
1460
|
+
// previously-set value transforms '' → null so the payload builder can transmit an explicit
|
|
1461
|
+
// clear (omitting the key can never remove an existing value). Create-mode schemas keep the
|
|
1462
|
+
// '' → undefined transform. See #2526.
|
|
1463
|
+
const clearableUrlField = () =>
|
|
1464
|
+
z
|
|
1465
|
+
.string()
|
|
1466
|
+
.trim()
|
|
1467
|
+
.url()
|
|
1468
|
+
.optional()
|
|
1469
|
+
.or(z.literal(''))
|
|
1470
|
+
.transform((val) => (val === '' ? null : val))
|
|
1471
|
+
.optional()
|
|
1472
|
+
|
|
1473
|
+
const clearableEmailField = () =>
|
|
1474
|
+
z
|
|
1475
|
+
.string()
|
|
1476
|
+
.trim()
|
|
1477
|
+
.email()
|
|
1478
|
+
.optional()
|
|
1479
|
+
.or(z.literal(''))
|
|
1480
|
+
.transform((val) => (val === '' ? null : val))
|
|
1481
|
+
.optional()
|
|
1482
|
+
|
|
1454
1483
|
export const createCompanyEditSchema = () =>
|
|
1455
1484
|
createCompanyFormSchema().extend({
|
|
1456
1485
|
id: z.string().uuid(),
|
|
1486
|
+
primaryEmail: clearableEmailField(),
|
|
1487
|
+
websiteUrl: clearableUrlField(),
|
|
1457
1488
|
})
|
|
1458
1489
|
|
|
1459
1490
|
export const createPersonEditSchema = () =>
|
|
1460
1491
|
createPersonFormSchema().extend({
|
|
1461
1492
|
id: z.string().uuid(),
|
|
1462
1493
|
department: optionalString(),
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
.url()
|
|
1467
|
-
.optional()
|
|
1468
|
-
.or(z.literal(''))
|
|
1469
|
-
.transform((val) => (val === '' ? undefined : val))
|
|
1470
|
-
.optional(),
|
|
1471
|
-
twitterUrl: z
|
|
1472
|
-
.string()
|
|
1473
|
-
.trim()
|
|
1474
|
-
.url()
|
|
1475
|
-
.optional()
|
|
1476
|
-
.or(z.literal(''))
|
|
1477
|
-
.transform((val) => (val === '' ? undefined : val))
|
|
1478
|
-
.optional(),
|
|
1494
|
+
primaryEmail: clearableEmailField(),
|
|
1495
|
+
linkedInUrl: clearableUrlField(),
|
|
1496
|
+
twitterUrl: clearableUrlField(),
|
|
1479
1497
|
})
|
|
1480
1498
|
|
|
1481
1499
|
// ---------------------------------------------------------------------------
|
|
@@ -1755,9 +1773,27 @@ export const createPersonPersonalDataGroups = (
|
|
|
1755
1773
|
// Edit-mode payload builders
|
|
1756
1774
|
// ---------------------------------------------------------------------------
|
|
1757
1775
|
|
|
1776
|
+
// On edit, optional URL/email fields that map to nullable columns must transmit an explicit
|
|
1777
|
+
// `null` when the user blanks a previously-set value — omitting the key can never clear it.
|
|
1778
|
+
// The base create-mode builders omit blanks (correct for create), so the edit builders
|
|
1779
|
+
// override these clearable fields here. See #2526.
|
|
1780
|
+
const assignClearable = (payload: Record<string, unknown>, key: string, raw: unknown): void => {
|
|
1781
|
+
if (raw === null) {
|
|
1782
|
+
payload[key] = null
|
|
1783
|
+
return
|
|
1784
|
+
}
|
|
1785
|
+
if (typeof raw !== 'string') return
|
|
1786
|
+
const trimmed = raw.trim()
|
|
1787
|
+
payload[key] = trimmed.length ? trimmed : null
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1758
1790
|
export function buildCompanyEditPayload(values: CompanyEditFormValues, organizationId?: string | null): Record<string, unknown> {
|
|
1759
1791
|
const payload = buildCompanyPayload(values, organizationId)
|
|
1760
1792
|
payload.id = values.id
|
|
1793
|
+
|
|
1794
|
+
assignClearable(payload, 'primaryEmail', values.primaryEmail)
|
|
1795
|
+
assignClearable(payload, 'websiteUrl', values.websiteUrl)
|
|
1796
|
+
|
|
1761
1797
|
return payload
|
|
1762
1798
|
}
|
|
1763
1799
|
|
|
@@ -1768,11 +1804,9 @@ export function buildPersonEditPayload(values: PersonEditFormValues, organizatio
|
|
|
1768
1804
|
const department = typeof values.department === 'string' ? values.department.trim() : ''
|
|
1769
1805
|
if (department.length) payload.department = department
|
|
1770
1806
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
const twitterUrl = typeof values.twitterUrl === 'string' ? values.twitterUrl.trim() : ''
|
|
1775
|
-
if (twitterUrl.length) payload.twitterUrl = twitterUrl
|
|
1807
|
+
assignClearable(payload, 'primaryEmail', values.primaryEmail)
|
|
1808
|
+
assignClearable(payload, 'linkedInUrl', values.linkedInUrl)
|
|
1809
|
+
assignClearable(payload, 'twitterUrl', values.twitterUrl)
|
|
1776
1810
|
|
|
1777
1811
|
return payload
|
|
1778
1812
|
}
|
|
@@ -14,6 +14,27 @@ const phoneSchema = z.string().trim().max(50).refine((val) => {
|
|
|
14
14
|
return isValidPhoneNumber(val)
|
|
15
15
|
}, { message: CUSTOMER_PHONE_INVALID_MESSAGE_KEY }).optional()
|
|
16
16
|
|
|
17
|
+
// Optional URL/email fields map to nullable DB columns. Treat both '' and null as an
|
|
18
|
+
// explicit "clear this value" signal (both coerce to null) so a previously-set value can
|
|
19
|
+
// be removed via update; without this `''` fails `.url()/.email()` and `null` fails the
|
|
20
|
+
// string type, leaving the columns effectively write-once-non-empty. The command layer
|
|
21
|
+
// already persists null. See #2526.
|
|
22
|
+
const emptyStringToNull = (value: unknown): unknown => {
|
|
23
|
+
if (typeof value !== 'string') return value
|
|
24
|
+
const trimmed = value.trim()
|
|
25
|
+
return trimmed.length ? trimmed : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const clearableEmailSchema = z.preprocess(
|
|
29
|
+
emptyStringToNull,
|
|
30
|
+
z.string().email().max(320).nullable().optional(),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const clearableUrlSchema = z.preprocess(
|
|
34
|
+
emptyStringToNull,
|
|
35
|
+
z.string().url().max(300).nullable().optional(),
|
|
36
|
+
)
|
|
37
|
+
|
|
17
38
|
const interactionPhoneNumberSchema = z.string().trim().max(50).optional().nullable()
|
|
18
39
|
|
|
19
40
|
const scopedSchema = z.object({
|
|
@@ -42,12 +63,7 @@ const baseEntitySchema = {
|
|
|
42
63
|
displayName: displayNameSchema,
|
|
43
64
|
description: z.string().trim().max(4000).optional(),
|
|
44
65
|
ownerUserId: uuid().optional(),
|
|
45
|
-
primaryEmail:
|
|
46
|
-
.string()
|
|
47
|
-
.trim()
|
|
48
|
-
.email()
|
|
49
|
-
.max(320)
|
|
50
|
-
.optional(),
|
|
66
|
+
primaryEmail: clearableEmailSchema,
|
|
51
67
|
primaryPhone: phoneSchema,
|
|
52
68
|
status: z.string().trim().max(100).optional(),
|
|
53
69
|
lifecycleStage: z.string().trim().max(100).optional(),
|
|
@@ -65,8 +81,8 @@ const personDetailsSchema = {
|
|
|
65
81
|
department: z.string().trim().max(150).optional(),
|
|
66
82
|
seniority: z.string().trim().max(100).optional(),
|
|
67
83
|
timezone: z.string().trim().max(120).optional(),
|
|
68
|
-
linkedInUrl:
|
|
69
|
-
twitterUrl:
|
|
84
|
+
linkedInUrl: clearableUrlSchema,
|
|
85
|
+
twitterUrl: clearableUrlSchema,
|
|
70
86
|
companyEntityId: uuid().nullable().optional(),
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -77,7 +93,7 @@ const companyDetailsSchema = {
|
|
|
77
93
|
legalName: z.string().trim().max(200).optional(),
|
|
78
94
|
brandName: z.string().trim().max(200).optional(),
|
|
79
95
|
domain: z.string().trim().max(200).optional(),
|
|
80
|
-
websiteUrl:
|
|
96
|
+
websiteUrl: clearableUrlSchema,
|
|
81
97
|
industry: z.string().trim().max(150).optional(),
|
|
82
98
|
sizeBucket: z.string().trim().max(100).optional(),
|
|
83
99
|
annualRevenue: z.coerce.number().min(0).optional(),
|
|
@@ -325,6 +325,14 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
|
|
|
325
325
|
)
|
|
326
326
|
const clearedIds: string[] = []
|
|
327
327
|
|
|
328
|
+
// The partial unique index `dictionary_entries_one_default_per_dict`
|
|
329
|
+
// (dictionary_id, organization_id, tenant_id) WHERE is_default = true forbids
|
|
330
|
+
// two default entries in the same dictionary. PostgreSQL checks partial unique
|
|
331
|
+
// indexes per-statement (no deferral) and MikroORM does not guarantee that the
|
|
332
|
+
// is_default=false UPDATEs run before the is_default=true UPDATE within a single
|
|
333
|
+
// flush, so clearing and setting in one flush races to a 23505. Clear the prior
|
|
334
|
+
// default(s) in their own flush first, then set the new default in a second
|
|
335
|
+
// flush, so Postgres never observes two default rows at once.
|
|
328
336
|
await withAtomicFlush(em, [
|
|
329
337
|
() => {
|
|
330
338
|
for (const entry of existingDefaults) {
|
|
@@ -333,6 +341,10 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
|
|
|
333
341
|
entry.updatedAt = new Date()
|
|
334
342
|
clearedIds.push(entry.id)
|
|
335
343
|
}
|
|
344
|
+
},
|
|
345
|
+
], { transaction: true })
|
|
346
|
+
await withAtomicFlush(em, [
|
|
347
|
+
() => {
|
|
336
348
|
targetEntry.isDefault = true
|
|
337
349
|
targetEntry.updatedAt = new Date()
|
|
338
350
|
},
|
|
@@ -421,12 +433,19 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
|
|
|
421
433
|
)
|
|
422
434
|
: []
|
|
423
435
|
|
|
436
|
+
// Same partial-unique-index ordering constraint as `execute`: clear the
|
|
437
|
+
// current default in its own flush before restoring the previous default(s),
|
|
438
|
+
// so Postgres never sees two is_default=true rows in the dictionary at once.
|
|
424
439
|
await withAtomicFlush(em, [
|
|
425
440
|
() => {
|
|
426
441
|
if (newDefault) {
|
|
427
442
|
newDefault.isDefault = false
|
|
428
443
|
newDefault.updatedAt = new Date()
|
|
429
444
|
}
|
|
445
|
+
},
|
|
446
|
+
], { transaction: true })
|
|
447
|
+
await withAtomicFlush(em, [
|
|
448
|
+
() => {
|
|
430
449
|
for (const entry of previousDefaults) {
|
|
431
450
|
entry.isDefault = true
|
|
432
451
|
entry.updatedAt = new Date()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import { Page, PageBody } from "@open-mercato/ui/backend/Page";
|
|
3
3
|
import { CrudForm } from "@open-mercato/ui/backend/CrudForm";
|
|
4
|
+
import { ErrorMessage, LoadingMessage } from "@open-mercato/ui/backend/detail";
|
|
4
5
|
import { E } from "#generated/entities.ids.generated";
|
|
5
6
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
7
|
import * as React from 'react'
|
|
@@ -15,7 +16,7 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
|
|
|
15
16
|
const fields = createFieldDefinitions(t);
|
|
16
17
|
const formGroups = createFormGroups(t);
|
|
17
18
|
|
|
18
|
-
const { data: featureToggleItem, isLoading } = useFeatureToggleItem(id)
|
|
19
|
+
const { data: featureToggleItem, isLoading, isError } = useFeatureToggleItem(id)
|
|
19
20
|
|
|
20
21
|
// Derive the form's initial values synchronously from the loaded record. Using
|
|
21
22
|
// a useState+useEffect here caused #2452: `isLoading` flips to false one render
|
|
@@ -36,6 +37,26 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
|
|
|
36
37
|
}
|
|
37
38
|
}, [featureToggleItem, id])
|
|
38
39
|
|
|
40
|
+
// Gate the form on load. Mounting CrudForm with placeholder `{}` before the
|
|
41
|
+
// record arrives makes the required `type` Select mount controlled with `''`,
|
|
42
|
+
// then flip empty→value asynchronously — a transition the Radix trigger fails
|
|
43
|
+
// to re-derive, leaving Type blank and blocking save. Rendering CrudForm only
|
|
44
|
+
// once the record is present mirrors the synchronous create flow, so the
|
|
45
|
+
// Select mounts a single time with the stored value already set.
|
|
46
|
+
if (!initialValues) {
|
|
47
|
+
return (
|
|
48
|
+
<Page>
|
|
49
|
+
<PageBody>
|
|
50
|
+
{isError && !isLoading ? (
|
|
51
|
+
<ErrorMessage label={t('feature_toggles.form.errors.load', 'Failed to load feature toggle')} />
|
|
52
|
+
) : (
|
|
53
|
+
<LoadingMessage label={t('feature_toggles.form.loading', 'Loading feature toggles')} />
|
|
54
|
+
)}
|
|
55
|
+
</PageBody>
|
|
56
|
+
</Page>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
return (
|
|
40
61
|
<Page>
|
|
41
62
|
<PageBody>
|
|
@@ -46,7 +67,7 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
|
|
|
46
67
|
versionHistory={{ resourceKind: 'feature_toggles.global', resourceId: id ? String(id) : '' }}
|
|
47
68
|
fields={fields}
|
|
48
69
|
entityId={E.feature_toggles.feature_toggle}
|
|
49
|
-
initialValues={initialValues
|
|
70
|
+
initialValues={initialValues}
|
|
50
71
|
optimisticLockUpdatedAt={featureToggleItem?.updatedAt ?? null}
|
|
51
72
|
isLoading={isLoading}
|
|
52
73
|
groups={formGroups}
|
|
@@ -69,7 +69,15 @@ export async function PUT(req: Request, ctx: { params?: { entityType?: string; e
|
|
|
69
69
|
entityId: ctx.params?.entityId,
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
const
|
|
72
|
+
const rawText = await req.text()
|
|
73
|
+
let rawBody: unknown = {}
|
|
74
|
+
if (rawText.trim().length > 0) {
|
|
75
|
+
try {
|
|
76
|
+
rawBody = JSON.parse(rawText)
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
73
81
|
const translations = translationBodySchema.parse(rawBody)
|
|
74
82
|
|
|
75
83
|
const commandBus = context.container.resolve('commandBus') as CommandBus
|