@agent-native/core 0.124.0 → 0.124.2
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/assets/branding/favicon-base64.ts +5 -0
- package/corpus/core/src/cli/templates-meta.ts +1 -0
- package/corpus/core/src/server/email.ts +2 -5
- package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
- package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
- package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
- package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
- package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
- package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
- package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
- package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
- package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
- package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
- package/corpus/templates/crm/app/global.css +170 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
- package/dist/assets/branding/favicon-base64.d.ts +2 -0
- package/dist/assets/branding/favicon-base64.d.ts.map +1 -0
- package/dist/assets/branding/favicon-base64.js +5 -0
- package/dist/assets/branding/favicon-base64.js.map +1 -0
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +1 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/email.d.ts.map +1 -1
- package/dist/server/email.js +2 -2
- package/dist/server/email.js.map +1 -1
- package/package.json +1 -1
- package/src/assets/branding/favicon-base64.ts +5 -0
- package/src/cli/templates-meta.ts +1 -0
- package/src/server/email.ts +2 -5
|
@@ -4,17 +4,23 @@ import { and, eq } from "drizzle-orm";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
|
|
6
6
|
import { getDb, schema } from "../server/db/index.js";
|
|
7
|
+
import {
|
|
8
|
+
legacyValueTypeFor,
|
|
9
|
+
type CrmAttributeType,
|
|
10
|
+
} from "../shared/crm-attributes.js";
|
|
7
11
|
import { requireCrmScope } from "./_crm-action-utils.js";
|
|
8
12
|
import {
|
|
9
13
|
CrmListError,
|
|
10
14
|
crmApiSlug,
|
|
15
|
+
loadCrmObjectAttributes,
|
|
11
16
|
uniqueCrmListSlug,
|
|
17
|
+
type CrmObjectAttribute,
|
|
12
18
|
} from "./_crm-list-utils.js";
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
|
-
*
|
|
16
|
-
* with
|
|
17
|
-
*
|
|
21
|
+
* Stage options for a list whose parent object declares no status attribute to
|
|
22
|
+
* copy. A board with no options has no columns at all, and a status write with
|
|
23
|
+
* no options is a 422, so this is the floor rather than an empty stage.
|
|
18
24
|
*/
|
|
19
25
|
const DEFAULT_STAGE_OPTIONS = [
|
|
20
26
|
{ value: "new", title: "New" },
|
|
@@ -23,6 +29,30 @@ const DEFAULT_STAGE_OPTIONS = [
|
|
|
23
29
|
{ value: "lost", title: "Lost" },
|
|
24
30
|
] as const;
|
|
25
31
|
|
|
32
|
+
/** Currency config for a seeded amount when the parent object has no currency
|
|
33
|
+
* attribute to copy one from. Matches the native adapter's own default. */
|
|
34
|
+
const DEFAULT_CURRENCY_CONFIG = JSON.stringify({ currency: { code: "USD" } });
|
|
35
|
+
|
|
36
|
+
interface SeedOption {
|
|
37
|
+
value: string;
|
|
38
|
+
title: string;
|
|
39
|
+
color: string | null;
|
|
40
|
+
position: number;
|
|
41
|
+
targetDays: number | null;
|
|
42
|
+
celebrate: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ListAttributeSeed {
|
|
46
|
+
id: string;
|
|
47
|
+
apiSlug: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description: string;
|
|
50
|
+
attributeType: CrmAttributeType;
|
|
51
|
+
configJson: string;
|
|
52
|
+
position: number;
|
|
53
|
+
options: SeedOption[];
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
async function resolveConnectionId(
|
|
27
57
|
db: ReturnType<typeof getDb>,
|
|
28
58
|
connectionId: string | undefined,
|
|
@@ -59,9 +89,54 @@ async function resolveConnectionId(
|
|
|
59
89
|
return rows[0]!.id;
|
|
60
90
|
}
|
|
61
91
|
|
|
92
|
+
/** The live options of the object's status attribute, in their own order, so a
|
|
93
|
+
* seeded board's columns read like the object's stages instead of a generic
|
|
94
|
+
* vocabulary the records never use. */
|
|
95
|
+
async function loadSourceOptions(
|
|
96
|
+
db: ReturnType<typeof getDb>,
|
|
97
|
+
attributeId: string,
|
|
98
|
+
): Promise<SeedOption[]> {
|
|
99
|
+
const rows = await db
|
|
100
|
+
.select({
|
|
101
|
+
value: schema.crmAttributeOptions.value,
|
|
102
|
+
title: schema.crmAttributeOptions.title,
|
|
103
|
+
color: schema.crmAttributeOptions.color,
|
|
104
|
+
position: schema.crmAttributeOptions.position,
|
|
105
|
+
targetDays: schema.crmAttributeOptions.targetDays,
|
|
106
|
+
celebrate: schema.crmAttributeOptions.celebrate,
|
|
107
|
+
})
|
|
108
|
+
.from(schema.crmAttributeOptions)
|
|
109
|
+
.where(
|
|
110
|
+
and(
|
|
111
|
+
eq(schema.crmAttributeOptions.attributeId, attributeId),
|
|
112
|
+
eq(schema.crmAttributeOptions.archived, false),
|
|
113
|
+
accessFilter(
|
|
114
|
+
schema.crmAttributeOptions,
|
|
115
|
+
schema.crmAttributeOptionShares,
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
return rows.sort(
|
|
120
|
+
(a, b) => a.position - b.position || a.value.localeCompare(b.value),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Says what a seeded attribute is, in the one place a user sees it: the
|
|
125
|
+
* attribute's own description. The initial copy is stated as a copy so nobody
|
|
126
|
+
* reads a board as live-linked to its records. */
|
|
127
|
+
function seedDescription(
|
|
128
|
+
listName: string,
|
|
129
|
+
objectType: string,
|
|
130
|
+
source: CrmObjectAttribute | null,
|
|
131
|
+
): string {
|
|
132
|
+
return source
|
|
133
|
+
? `The list "${listName}" owns this value. Each entry starts from the ${objectType} record's ${source.label} when the record is added, and changes independently after that — editing it never writes the record.`
|
|
134
|
+
: `The list "${listName}" owns this value. It starts empty and is never written back to the ${objectType} record.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
62
137
|
export default defineAction({
|
|
63
138
|
description:
|
|
64
|
-
"Create a CRM list — a workflow overlay over one object type, with its own entry attributes. Lists and their entries are local-authoritative on every backend, including HubSpot and Salesforce, so a pipeline never needs a provider write.
|
|
139
|
+
"Create a CRM list — a workflow overlay over one object type, with its own entry attributes. Lists and their entries are local-authoritative on every backend, including HubSpot and Salesforce, so a pipeline never needs a provider write. Unless seedAttributes is false, the list is seeded with its own Stage (status, with the parent object's stage options when it has any), an amount (currency), and the parent object's date attribute, so a new board has columns and a summable total immediately. A seeded attribute is a COPY of the object's shape, not a link to it: each entry's first value is copied from the record once when the record is added and never re-synced.",
|
|
65
140
|
schema: z.object({
|
|
66
141
|
connectionId: z.string().trim().min(1).max(128).optional(),
|
|
67
142
|
name: z.string().trim().min(1).max(120),
|
|
@@ -75,7 +150,12 @@ export default defineAction({
|
|
|
75
150
|
),
|
|
76
151
|
description: z.string().trim().max(500).optional(),
|
|
77
152
|
position: z.number().int().min(0).max(100_000).optional(),
|
|
78
|
-
|
|
153
|
+
seedAttributes: z
|
|
154
|
+
.boolean()
|
|
155
|
+
.default(true)
|
|
156
|
+
.describe(
|
|
157
|
+
"Seed the list's own stage, amount, and date attributes from the parent object. False creates a list with no attributes at all — its board has no columns until you add a status attribute.",
|
|
158
|
+
),
|
|
79
159
|
}),
|
|
80
160
|
audit: {
|
|
81
161
|
target: (_args, result) => {
|
|
@@ -107,9 +187,94 @@ export default defineAction({
|
|
|
107
187
|
const listId = crypto.randomUUID();
|
|
108
188
|
const now = new Date().toISOString();
|
|
109
189
|
|
|
110
|
-
const
|
|
111
|
-
?
|
|
112
|
-
:
|
|
190
|
+
const sources = args.seedAttributes
|
|
191
|
+
? await loadCrmObjectAttributes(db, connectionId, args.parentObjectType)
|
|
192
|
+
: [];
|
|
193
|
+
const sourceOf = (type: CrmAttributeType): CrmObjectAttribute | null =>
|
|
194
|
+
sources.find((source) => source.attributeType === type) ?? null;
|
|
195
|
+
const statusSource = sourceOf("status");
|
|
196
|
+
const currencySource = sourceOf("currency");
|
|
197
|
+
const dateSource = sourceOf("date");
|
|
198
|
+
|
|
199
|
+
const stageOptions = statusSource
|
|
200
|
+
? await loadSourceOptions(db, statusSource.id)
|
|
201
|
+
: [];
|
|
202
|
+
|
|
203
|
+
const seeds: ListAttributeSeed[] = args.seedAttributes
|
|
204
|
+
? [
|
|
205
|
+
{
|
|
206
|
+
id: crypto.randomUUID(),
|
|
207
|
+
// The slug follows the object's attribute so an entry's first value
|
|
208
|
+
// has an unambiguous source; `stage` is the fallback name.
|
|
209
|
+
apiSlug: statusSource?.apiSlug ?? "stage",
|
|
210
|
+
// Qualified by the list: an opportunity's own Stage and this
|
|
211
|
+
// pipeline's Stage are different fields that a bare "Stage" on both
|
|
212
|
+
// makes indistinguishable in the record panel.
|
|
213
|
+
label: `${args.name} Stage`,
|
|
214
|
+
description: seedDescription(
|
|
215
|
+
args.name,
|
|
216
|
+
args.parentObjectType,
|
|
217
|
+
statusSource,
|
|
218
|
+
),
|
|
219
|
+
attributeType: "status",
|
|
220
|
+
configJson: statusSource?.configJson ?? "{}",
|
|
221
|
+
position: 0,
|
|
222
|
+
options: stageOptions.length
|
|
223
|
+
? stageOptions
|
|
224
|
+
: DEFAULT_STAGE_OPTIONS.map((option, index) => ({
|
|
225
|
+
value: option.value,
|
|
226
|
+
title: option.title,
|
|
227
|
+
color: null,
|
|
228
|
+
position: index,
|
|
229
|
+
targetDays: null,
|
|
230
|
+
celebrate: "celebrate" in option ? option.celebrate : false,
|
|
231
|
+
})),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: crypto.randomUUID(),
|
|
235
|
+
apiSlug: currencySource?.apiSlug ?? "amount",
|
|
236
|
+
label: currencySource?.label ?? "Amount",
|
|
237
|
+
description: seedDescription(
|
|
238
|
+
args.name,
|
|
239
|
+
args.parentObjectType,
|
|
240
|
+
currencySource,
|
|
241
|
+
),
|
|
242
|
+
attributeType: "currency",
|
|
243
|
+
configJson: currencySource?.configJson ?? DEFAULT_CURRENCY_CONFIG,
|
|
244
|
+
position: 1,
|
|
245
|
+
options: [],
|
|
246
|
+
},
|
|
247
|
+
...(dateSource
|
|
248
|
+
? [
|
|
249
|
+
{
|
|
250
|
+
id: crypto.randomUUID(),
|
|
251
|
+
apiSlug: dateSource.apiSlug,
|
|
252
|
+
label: dateSource.label,
|
|
253
|
+
description: seedDescription(
|
|
254
|
+
args.name,
|
|
255
|
+
args.parentObjectType,
|
|
256
|
+
dateSource,
|
|
257
|
+
),
|
|
258
|
+
attributeType: "date" as const,
|
|
259
|
+
configJson: dateSource.configJson,
|
|
260
|
+
position: 2,
|
|
261
|
+
options: [],
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
: []),
|
|
265
|
+
]
|
|
266
|
+
: [];
|
|
267
|
+
|
|
268
|
+
// `(connection_id, object_type, field_name)` is unique and `object_type`
|
|
269
|
+
// mirrors the list id here, so two seeds may never claim one slug.
|
|
270
|
+
const claimed = new Set<string>();
|
|
271
|
+
const attributes = seeds.filter((seed) => {
|
|
272
|
+
if (claimed.has(seed.apiSlug)) return false;
|
|
273
|
+
claimed.add(seed.apiSlug);
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
const stageAttributeId =
|
|
277
|
+
attributes.find((seed) => seed.attributeType === "status")?.id ?? null;
|
|
113
278
|
|
|
114
279
|
await db.transaction(async (tx) => {
|
|
115
280
|
await tx.insert(schema.crmLists).values({
|
|
@@ -128,52 +293,60 @@ export default defineAction({
|
|
|
128
293
|
updatedAt: now,
|
|
129
294
|
});
|
|
130
295
|
|
|
131
|
-
if (!
|
|
296
|
+
if (!attributes.length) return;
|
|
132
297
|
|
|
133
|
-
await tx.insert(schema.crmFieldPolicies).values(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
298
|
+
await tx.insert(schema.crmFieldPolicies).values(
|
|
299
|
+
attributes.map((seed) => ({
|
|
300
|
+
id: seed.id,
|
|
301
|
+
connectionId,
|
|
302
|
+
// `object_type` mirrors `target_id` for list attributes so the legacy
|
|
303
|
+
// (connection_id, object_type, field_name) unique index keeps working.
|
|
304
|
+
objectType: listId,
|
|
305
|
+
fieldName: seed.apiSlug,
|
|
306
|
+
label: seed.label,
|
|
307
|
+
description: seed.description,
|
|
308
|
+
valueType: legacyValueTypeFor(seed.attributeType, false),
|
|
309
|
+
storagePolicy: "local-authoritative" as const,
|
|
310
|
+
sensitive: false,
|
|
311
|
+
readable: true,
|
|
312
|
+
createable: true,
|
|
313
|
+
updateable: true,
|
|
314
|
+
required: false,
|
|
315
|
+
attributeType: seed.attributeType,
|
|
316
|
+
target: "list" as const,
|
|
317
|
+
targetId: listId,
|
|
318
|
+
apiSlug: seed.apiSlug,
|
|
319
|
+
configJson: seed.configJson,
|
|
320
|
+
multi: false,
|
|
321
|
+
authority: "local-authoritative" as const,
|
|
322
|
+
historyTracked: true,
|
|
323
|
+
uniqueValue: false,
|
|
324
|
+
archived: false,
|
|
325
|
+
position: seed.position,
|
|
326
|
+
...ownership,
|
|
327
|
+
createdAt: now,
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
})),
|
|
330
|
+
);
|
|
162
331
|
|
|
163
|
-
|
|
164
|
-
|
|
332
|
+
const options = attributes.flatMap((seed) =>
|
|
333
|
+
seed.options.map((option, index) => ({
|
|
165
334
|
id: crypto.randomUUID(),
|
|
166
|
-
attributeId:
|
|
335
|
+
attributeId: seed.id,
|
|
167
336
|
value: option.value,
|
|
168
337
|
title: option.title,
|
|
338
|
+
color: option.color,
|
|
169
339
|
position: index,
|
|
170
340
|
archived: false,
|
|
171
|
-
|
|
341
|
+
targetDays: option.targetDays,
|
|
342
|
+
celebrate: option.celebrate,
|
|
172
343
|
...ownership,
|
|
173
344
|
createdAt: now,
|
|
174
345
|
updatedAt: now,
|
|
175
346
|
})),
|
|
176
347
|
);
|
|
348
|
+
if (options.length)
|
|
349
|
+
await tx.insert(schema.crmAttributeOptions).values(options);
|
|
177
350
|
});
|
|
178
351
|
|
|
179
352
|
const [list] = await db
|
|
@@ -189,6 +362,19 @@ export default defineAction({
|
|
|
189
362
|
if (!list) {
|
|
190
363
|
throw new Error("CRM list could not be verified after creation.");
|
|
191
364
|
}
|
|
192
|
-
return {
|
|
365
|
+
return {
|
|
366
|
+
...list,
|
|
367
|
+
stageAttributeId,
|
|
368
|
+
seededAttributes: attributes.map((seed) => ({
|
|
369
|
+
id: seed.id,
|
|
370
|
+
apiSlug: seed.apiSlug,
|
|
371
|
+
label: seed.label,
|
|
372
|
+
attributeType: seed.attributeType,
|
|
373
|
+
/** The object attribute this one was copied from, if any. */
|
|
374
|
+
seededFrom:
|
|
375
|
+
sources.find((source) => source.apiSlug === seed.apiSlug)?.apiSlug ??
|
|
376
|
+
null,
|
|
377
|
+
})),
|
|
378
|
+
};
|
|
193
379
|
},
|
|
194
380
|
});
|
|
@@ -104,11 +104,19 @@ export default defineAction({
|
|
|
104
104
|
).id;
|
|
105
105
|
const objectType = kindToObjectType[args.kind];
|
|
106
106
|
const remoteId = crypto.randomUUID();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
// accounts and opportunities already carry a `name` attribute, so only
|
|
108
|
+
// people (which have no `name` field) need a separate stored `displayName`
|
|
109
|
+
// attribute. Minting both would leave two attributes for one value.
|
|
110
|
+
const fields =
|
|
111
|
+
args.kind === "person"
|
|
112
|
+
? {
|
|
113
|
+
...(args.fields as Record<string, CrmValue> | undefined),
|
|
114
|
+
displayName: args.displayName,
|
|
115
|
+
}
|
|
116
|
+
: {
|
|
117
|
+
...(args.fields as Record<string, CrmValue> | undefined),
|
|
118
|
+
name: args.displayName,
|
|
119
|
+
};
|
|
112
120
|
const idempotencyKey = await scopedCrmIdempotencyKey({
|
|
113
121
|
...ownership,
|
|
114
122
|
recordId: `${connectionId}:${objectType}`,
|
|
@@ -17,7 +17,7 @@ import { listEntryValuesSchema } from "./add-crm-record-to-list.js";
|
|
|
17
17
|
|
|
18
18
|
export default defineAction({
|
|
19
19
|
description:
|
|
20
|
-
"Set values on a CRM list entry's own attributes and/or reorder it. Moving an entry to another stage is exactly this call: the previous value's row is closed and a new one opened, so time-in-stage is derivable from the entry's own history. Entry attributes belong to the list, not to the record — this never writes to the record or to a provider.",
|
|
20
|
+
"Set values on a CRM list entry's own attributes and/or reorder it. Moving an entry to another stage is exactly this call: the previous value's row is closed and a new one opened, so time-in-stage is derivable from the entry's own history. Entry attributes belong to the list, not to the record — this never writes to the record or to a provider. An entry's values were copied from the record once, when it was added; they are not bound to it, so changing one here leaves the record's own value alone and a later record change does not reach the entry.",
|
|
21
21
|
schema: z.object({
|
|
22
22
|
entryId: z.string().trim().min(1).max(128),
|
|
23
23
|
values: listEntryValuesSchema.optional(),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
|
|
2
2
|
import { useT } from "@agent-native/core/client/i18n";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IconBuilding,
|
|
5
|
+
IconRoute,
|
|
6
|
+
IconSearch,
|
|
7
|
+
IconUser,
|
|
8
|
+
} from "@tabler/icons-react";
|
|
4
9
|
import {
|
|
5
10
|
useInfiniteQuery,
|
|
6
11
|
useQueries,
|
|
@@ -37,6 +42,17 @@ import {
|
|
|
37
42
|
import { Input } from "@/components/ui/input";
|
|
38
43
|
import type { CrmKind, CrmRecordSummary } from "@/lib/types";
|
|
39
44
|
|
|
45
|
+
/** The glyph the grid's icon column shows until a row is hovered, and the
|
|
46
|
+
* avatar shape that goes with it — round is a person, squircle is not. */
|
|
47
|
+
const KIND_ICONS: Record<
|
|
48
|
+
CrmKind,
|
|
49
|
+
React.ComponentType<{ className?: string }>
|
|
50
|
+
> = {
|
|
51
|
+
account: IconBuilding,
|
|
52
|
+
person: IconUser,
|
|
53
|
+
opportunity: IconRoute,
|
|
54
|
+
};
|
|
55
|
+
|
|
40
56
|
/** One `list-crm-record-values` request per chunk; the action caps at 200. */
|
|
41
57
|
const VALUES_CHUNK = 100;
|
|
42
58
|
/** Column resize fires per mouse move; only the settled layout is persisted. */
|
|
@@ -355,7 +371,9 @@ export function RecordGrid({ kind, emptyTitle }: RecordGridProps) {
|
|
|
355
371
|
// shell's <main> is a scroll container, not a flex column, so `flex-1` here
|
|
356
372
|
// would collapse. Switch to flex-1 if <main> ever becomes display:flex.
|
|
357
373
|
<div className="flex h-[calc(100vh-9.5rem)] min-h-[320px] flex-col">
|
|
358
|
-
|
|
374
|
+
{/* No bottom rule here: the grid header carries its own top border, and
|
|
375
|
+
two adjacent lines read as a heavy divider. */}
|
|
376
|
+
<div className="flex items-center gap-2 px-5 py-2.5">
|
|
359
377
|
<div className="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background px-3">
|
|
360
378
|
<IconSearch className="size-4 shrink-0 text-muted-foreground" />
|
|
361
379
|
<Input
|
|
@@ -381,6 +399,8 @@ export function RecordGrid({ kind, emptyTitle }: RecordGridProps) {
|
|
|
381
399
|
onRetry={() => void list.refetch()}
|
|
382
400
|
emptyTitle={emptyTitle}
|
|
383
401
|
nameLabel={t(`grid.name.${kind}`)}
|
|
402
|
+
objectIcon={KIND_ICONS[kind]}
|
|
403
|
+
avatarShape={kind === "person" ? "person" : "company"}
|
|
384
404
|
rowHref={(row) => `/records/${encodeURIComponent(row.id)}`}
|
|
385
405
|
onCommitCell={commitCell}
|
|
386
406
|
onAddToList={setAddToListIds}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The record page:
|
|
3
|
-
* memberships and
|
|
2
|
+
* The record page: TWO panes. A resizable left pane carrying the record's
|
|
3
|
+
* typed attributes and its list memberships, and a main pane carrying the tabs
|
|
4
|
+
* and the signals section.
|
|
5
|
+
*
|
|
6
|
+
* There is deliberately no third pane. Lists and signals used to sit in one,
|
|
7
|
+
* which cost the main pane ~350px on every screen and gave the page three
|
|
8
|
+
* competing reading orders. Lists are label/value rows, so they belong beside
|
|
9
|
+
* the attributes; signals are an evidence feed, so they belong in the main
|
|
10
|
+
* pane. The app shell's own left sidebar and right agent rail are untouched.
|
|
4
11
|
*
|
|
5
12
|
* The page reads two actions on purpose. `get-crm-record` is the one that
|
|
6
13
|
* verifies provider read-through permission for a mirrored record and carries
|
|
@@ -10,17 +17,25 @@
|
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
import { setClientAppState } from "@agent-native/core/client/application-state";
|
|
20
|
+
import { ExtensionSlot } from "@agent-native/core/client/extensions";
|
|
13
21
|
import {
|
|
14
22
|
useActionMutation,
|
|
15
23
|
useActionQuery,
|
|
16
24
|
} from "@agent-native/core/client/hooks";
|
|
17
25
|
import { useT } from "@agent-native/core/client/i18n";
|
|
18
26
|
import { useQueryClient } from "@tanstack/react-query";
|
|
19
|
-
import { useEffect, useState } from "react";
|
|
27
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
28
|
import { toast } from "sonner";
|
|
21
29
|
|
|
22
30
|
import { CrmSignalsPanel } from "@/components/crm/CrmSignalsPanel";
|
|
31
|
+
import { PANEL_SECTION_HEADING } from "@/components/crm/record/attribute-row";
|
|
23
32
|
import { AttributePanel } from "@/components/crm/record/AttributePanel";
|
|
33
|
+
import {
|
|
34
|
+
clampPanelPercent,
|
|
35
|
+
DEFAULT_RECORD_PANEL_PERCENT,
|
|
36
|
+
readPanelPercent,
|
|
37
|
+
writePanelPercent,
|
|
38
|
+
} from "@/components/crm/record/panel-resize";
|
|
24
39
|
import {
|
|
25
40
|
applyEntryValue,
|
|
26
41
|
applyFieldValue,
|
|
@@ -33,6 +48,10 @@ import {
|
|
|
33
48
|
import { RecordHeader } from "@/components/crm/record/RecordHeader";
|
|
34
49
|
import { RecordLists } from "@/components/crm/record/RecordLists";
|
|
35
50
|
import { RecordTabs } from "@/components/crm/record/RecordTabs";
|
|
51
|
+
import {
|
|
52
|
+
RECORD_MAIN_MIN_WIDTH,
|
|
53
|
+
RECORD_PANEL_MIN_WIDTH,
|
|
54
|
+
} from "@/components/crm/shared/ui-tokens";
|
|
36
55
|
import { LoadingRows, SetupEmptyState } from "@/components/crm/Surface";
|
|
37
56
|
import { TAB_ID } from "@/lib/tab-id";
|
|
38
57
|
import type { CrmRecordDetail } from "@/lib/types";
|
|
@@ -61,6 +80,29 @@ export function RecordWorkspace({
|
|
|
61
80
|
const [tab, setTab] = useState<RecordTab>("activity");
|
|
62
81
|
const queryClient = useQueryClient();
|
|
63
82
|
const recordId = record?.id;
|
|
83
|
+
const regionRef = useRef<HTMLDivElement>(null);
|
|
84
|
+
const [panelPercent, setPanelPercent] = useState(readPanelPercent);
|
|
85
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
86
|
+
|
|
87
|
+
function resizeTo(clientX: number) {
|
|
88
|
+
const region = regionRef.current;
|
|
89
|
+
if (!region) return;
|
|
90
|
+
const bounds = region.getBoundingClientRect();
|
|
91
|
+
if (bounds.width <= 0) return;
|
|
92
|
+
setPanelPercent(
|
|
93
|
+
clampPanelPercent(
|
|
94
|
+
((clientX - bounds.left) / bounds.width) * 100,
|
|
95
|
+
bounds.width,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nudgePanel(deltaPercent: number) {
|
|
101
|
+
const width = regionRef.current?.getBoundingClientRect().width ?? 0;
|
|
102
|
+
const next = clampPanelPercent(panelPercent + deltaPercent, width);
|
|
103
|
+
setPanelPercent(next);
|
|
104
|
+
writePanelPercent(next);
|
|
105
|
+
}
|
|
64
106
|
|
|
65
107
|
const pageParams = { recordId };
|
|
66
108
|
const pageQuery = useActionQuery<CrmRecordPage>(
|
|
@@ -179,27 +221,100 @@ export function RecordWorkspace({
|
|
|
179
221
|
recordUrlUnavailableReason={page?.recordUrlUnavailableReason ?? null}
|
|
180
222
|
actions={actions}
|
|
181
223
|
/>
|
|
182
|
-
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
183
|
-
{/*
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
224
|
+
<div ref={regionRef} className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
225
|
+
{/* The pane's floors live in CSS, not in the drag handler: a window
|
|
226
|
+
resize must not be able to strand the main pane under its minimum,
|
|
227
|
+
and only `min-width`/`max-width` hold at every width without a
|
|
228
|
+
resize observer. */}
|
|
229
|
+
<aside
|
|
230
|
+
style={
|
|
231
|
+
{
|
|
232
|
+
"--crm-record-panel": `${panelPercent}%`,
|
|
233
|
+
"--crm-record-panel-min": `${RECORD_PANEL_MIN_WIDTH}px`,
|
|
234
|
+
"--crm-record-panel-max": `min(60%, calc(100% - ${RECORD_MAIN_MIN_WIDTH}px))`,
|
|
235
|
+
} as React.CSSProperties
|
|
236
|
+
}
|
|
237
|
+
className="grid w-full content-start gap-5 border-hairline p-4 lg:w-[var(--crm-record-panel)] lg:min-w-[var(--crm-record-panel-min)] lg:max-w-[var(--crm-record-panel-max)] lg:shrink-0 lg:border-r"
|
|
238
|
+
>
|
|
239
|
+
{/* A failed panel load is a visible error, not an empty attribute
|
|
240
|
+
list: "this record has no attributes" and "we could not read them"
|
|
241
|
+
are different answers and must not render the same. */}
|
|
242
|
+
{pageQuery.error ? (
|
|
188
243
|
<p className="text-sm text-destructive">
|
|
189
244
|
{pageQuery.error instanceof Error
|
|
190
245
|
? pageQuery.error.message
|
|
191
246
|
: t("record.panelLoadFailed")}
|
|
192
247
|
</p>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
248
|
+
) : page ? (
|
|
249
|
+
<>
|
|
250
|
+
<AttributePanel page={page} onCommit={commitField} />
|
|
251
|
+
<RecordLists page={page} onEntryCommit={commitEntryValue} />
|
|
252
|
+
{/* Stable extension contract: changing this slot id or context is
|
|
253
|
+
a migration. */}
|
|
254
|
+
<ExtensionSlot
|
|
255
|
+
id="crm.record-sidebar.bottom"
|
|
256
|
+
context={{
|
|
257
|
+
recordId: page.record.id,
|
|
258
|
+
objectType: page.record.objectType,
|
|
259
|
+
kind: page.record.kind,
|
|
260
|
+
displayName: page.record.displayName,
|
|
261
|
+
provider: page.record.provider,
|
|
262
|
+
values: page.values,
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
) : (
|
|
198
267
|
<LoadingRows rows={6} />
|
|
199
|
-
|
|
200
|
-
|
|
268
|
+
)}
|
|
269
|
+
</aside>
|
|
270
|
+
|
|
271
|
+
<div
|
|
272
|
+
role="separator"
|
|
273
|
+
aria-orientation="vertical"
|
|
274
|
+
aria-label={t("record.resizePanel")}
|
|
275
|
+
aria-valuenow={Math.round(panelPercent)}
|
|
276
|
+
tabIndex={0}
|
|
277
|
+
data-resizing={isResizing ? "true" : undefined}
|
|
278
|
+
className="group/resize relative hidden w-1.5 shrink-0 cursor-col-resize touch-none lg:block"
|
|
279
|
+
onPointerDown={(event) => {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
282
|
+
setIsResizing(true);
|
|
283
|
+
}}
|
|
284
|
+
onPointerMove={(event) => {
|
|
285
|
+
if (!isResizing) return;
|
|
286
|
+
resizeTo(event.clientX);
|
|
287
|
+
}}
|
|
288
|
+
onPointerUp={(event) => {
|
|
289
|
+
if (!isResizing) return;
|
|
290
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
291
|
+
setIsResizing(false);
|
|
292
|
+
writePanelPercent(panelPercent);
|
|
293
|
+
}}
|
|
294
|
+
onDoubleClick={() => {
|
|
295
|
+
setPanelPercent(DEFAULT_RECORD_PANEL_PERCENT);
|
|
296
|
+
writePanelPercent(DEFAULT_RECORD_PANEL_PERCENT);
|
|
297
|
+
}}
|
|
298
|
+
onKeyDown={(event) => {
|
|
299
|
+
if (event.key === "ArrowLeft") nudgePanel(-2);
|
|
300
|
+
else if (event.key === "ArrowRight") nudgePanel(2);
|
|
301
|
+
else return;
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
{/* The 100ms delay before the handle appears is deliberate: without
|
|
306
|
+
it, crossing the divider on the way somewhere else flickers. */}
|
|
307
|
+
<span className="pointer-events-none absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-[3px] bg-primary opacity-0 transition-opacity delay-100 duration-100 group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100 group-data-[resizing=true]/resize:opacity-100" />
|
|
308
|
+
</div>
|
|
201
309
|
|
|
202
|
-
<div
|
|
310
|
+
<div
|
|
311
|
+
style={
|
|
312
|
+
{
|
|
313
|
+
"--crm-record-main-min": `${RECORD_MAIN_MIN_WIDTH}px`,
|
|
314
|
+
} as React.CSSProperties
|
|
315
|
+
}
|
|
316
|
+
className="min-w-0 flex-1 lg:min-w-[var(--crm-record-main-min)]"
|
|
317
|
+
>
|
|
203
318
|
<RecordTabs
|
|
204
319
|
record={record}
|
|
205
320
|
tab={tab}
|
|
@@ -207,23 +322,19 @@ export function RecordWorkspace({
|
|
|
207
322
|
onCompleteTask={onCompleteTask}
|
|
208
323
|
isCompletingTask={isCompletingTask}
|
|
209
324
|
/>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
) : null}
|
|
216
|
-
<section aria-labelledby="crm-record-signals">
|
|
217
|
-
<h2
|
|
218
|
-
id="crm-record-signals"
|
|
219
|
-
className="mb-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
|
|
220
|
-
>
|
|
325
|
+
<section
|
|
326
|
+
aria-labelledby="crm-record-signals"
|
|
327
|
+
className="border-t border-hairline p-5 sm:p-6"
|
|
328
|
+
>
|
|
329
|
+
<h2 id="crm-record-signals" className={PANEL_SECTION_HEADING}>
|
|
221
330
|
{t("record.signals")}
|
|
222
331
|
</h2>
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
332
|
+
<div className="mt-3 max-w-2xl">
|
|
333
|
+
<CrmSignalsPanel
|
|
334
|
+
recordId={record.id}
|
|
335
|
+
evidence={record.evidence ?? []}
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
227
338
|
</section>
|
|
228
339
|
</div>
|
|
229
340
|
</div>
|