@visns-studio/visns-components 6.24.3 → 6.25.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/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +220 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- package/src/index.js +32 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a grid's columns become which part of a stacked phone row.
|
|
3
|
+
*
|
|
4
|
+
* No React and no CSS import, on the `utils/displayValue.js` precedent: this
|
|
5
|
+
* is the only new *logic* in the card treatment, it decides what 37 grid
|
|
6
|
+
* configs across three applications look like on a phone, and it is testable
|
|
7
|
+
* under `node --test` rather than by squinting at a rendered tree.
|
|
8
|
+
*
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* WHAT THIS IS NOT
|
|
11
|
+
*
|
|
12
|
+
* It is not a second column pipeline. `deriveCardLayout` picks columns out of
|
|
13
|
+
* the array the view config already declares and hands them back; the DataGrid
|
|
14
|
+
* then calls the descriptor each of them was ALREADY built into, so every value
|
|
15
|
+
* on a card is formatted by the same renderer that formats it in the table.
|
|
16
|
+
* There is no per-type formatting in this file and there must never be one —
|
|
17
|
+
* that is how a currency starts rendering two different ways on two screens.
|
|
18
|
+
*
|
|
19
|
+
* The vocabulary is `title / subtitle / meta / badge / reference`, which is a
|
|
20
|
+
* card's vocabulary, but the rendered thing is a compact three-or-four line
|
|
21
|
+
* row. See the doctrine in `omnia-global-app`'s CustomersPage.jsx: "ROWS, NOT
|
|
22
|
+
* CARDS. … a card per client would be four times the height for the same four
|
|
23
|
+
* facts."
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The synthetic column's name.
|
|
28
|
+
*
|
|
29
|
+
* Exported because `DataGrid`'s row-click handler has to recognise it: the
|
|
30
|
+
* "ignore the last column" rule is positional, and in card mode the card is
|
|
31
|
+
* the last column.
|
|
32
|
+
*/
|
|
33
|
+
export const CARD_COLUMN_NAME = '__card';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How many lines sit under the title, channels and meta together.
|
|
37
|
+
*
|
|
38
|
+
* Three at default density, four when the app is running `data-density="large"`
|
|
39
|
+
* — the roomier setting exists because somebody cannot read the tight one, so
|
|
40
|
+
* it buys a line rather than spending its extra height on padding alone.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_BODY_LINES = { default: 3, large: 4 };
|
|
43
|
+
|
|
44
|
+
/** Column types that cannot be drawn on one clipped line of a fixed-height row. */
|
|
45
|
+
const EXCLUDED_TYPES = new Set([
|
|
46
|
+
'placeholder',
|
|
47
|
+
'input_text',
|
|
48
|
+
'gallery',
|
|
49
|
+
'image',
|
|
50
|
+
'icons',
|
|
51
|
+
'colour',
|
|
52
|
+
'color',
|
|
53
|
+
'json',
|
|
54
|
+
'timer',
|
|
55
|
+
'total',
|
|
56
|
+
'stageCounter',
|
|
57
|
+
'richtext',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Types that can carry a record's identity. `undefined` counts — most configs omit it. */
|
|
61
|
+
const TEXTISH_TYPES = new Set(['text', 'relation', 'option', 'url']);
|
|
62
|
+
|
|
63
|
+
const BADGE_TYPES = new Set(['option', 'boolean', 'stage']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keys that name a record's state rather than a fact about it.
|
|
67
|
+
*
|
|
68
|
+
* The vocabulary exists because TYPE IS NOT ENOUGH IN EITHER DIRECTION: deals
|
|
69
|
+
* declare `stage` as `text`, and the tickets tabs declare `['status']` and
|
|
70
|
+
* `['priority']` as relations. Both are the chip a reader looks for first.
|
|
71
|
+
*/
|
|
72
|
+
const BADGE_KEYS =
|
|
73
|
+
/^(status|stage|state|priority|primary|active|disabled|is_default|invite_status)$/i;
|
|
74
|
+
|
|
75
|
+
/** Keys that ARE the record's name. */
|
|
76
|
+
const IDENTITY_KEYS =
|
|
77
|
+
/^(name|label|subject|title|display_name|full_name|pattern)$/i;
|
|
78
|
+
|
|
79
|
+
const IDENTITY_SUFFIX = /_(name|label|title)$/i;
|
|
80
|
+
|
|
81
|
+
/** …except when the suffix belongs to a PERSON, who is not this record's name. */
|
|
82
|
+
const ATTRIBUTED_PREFIX = /^(owner|created_by|updated_by|assigned)/i;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keys that describe a person's part rather than identify them.
|
|
86
|
+
*
|
|
87
|
+
* This is what makes a contact's card read "Sarah Whitcombe / IT Manager"
|
|
88
|
+
* rather than putting the job title down among the meta. `title` is in BOTH
|
|
89
|
+
* this list and IDENTITY_KEYS, and the order of the steps is what resolves it:
|
|
90
|
+
* a view whose only identity is `title` titles on it (step 3b), while contacts
|
|
91
|
+
* take the name pair first (step 3a) and `title` is still in the pool when the
|
|
92
|
+
* subtitle is chosen.
|
|
93
|
+
*/
|
|
94
|
+
const DESCRIPTOR_KEYS =
|
|
95
|
+
/^(title|job_title|jobtitle|position|role|department)$/i;
|
|
96
|
+
|
|
97
|
+
const FIRST_NAME_KEYS = /^(firstname|first_name|given_name)$/i;
|
|
98
|
+
const LAST_NAME_KEYS = /^(surname|lastname|last_name|family_name)$/i;
|
|
99
|
+
|
|
100
|
+
const EMAIL_KEYS = /(^|_)e?mail$/i;
|
|
101
|
+
const TEL_KEYS =
|
|
102
|
+
/^(mobile|phone|telephone|work_phone|home_phone|fax|contact_number)$/i;
|
|
103
|
+
|
|
104
|
+
/** The slots a per-column `"card"` value may name. */
|
|
105
|
+
const CARD_SLOTS = new Set([
|
|
106
|
+
'title',
|
|
107
|
+
'subtitle',
|
|
108
|
+
'meta',
|
|
109
|
+
'badge',
|
|
110
|
+
'reference',
|
|
111
|
+
'hidden',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The field a column is really about.
|
|
116
|
+
*
|
|
117
|
+
* Column ids in this library come in three spellings and all three appear in
|
|
118
|
+
* the same config file: a plain key, a dotted path into a loaded relation, and
|
|
119
|
+
* an ARRAY naming a relation chain (`['task','ticket']` is the ticket a task
|
|
120
|
+
* belongs to). The last segment is the field in every case.
|
|
121
|
+
*
|
|
122
|
+
* @param {object|string|Array} column a column definition, or a bare id
|
|
123
|
+
* @returns {string} `''` when there is nothing to read
|
|
124
|
+
*/
|
|
125
|
+
export const fieldKey = (column) => {
|
|
126
|
+
const id =
|
|
127
|
+
column && typeof column === 'object' && !Array.isArray(column)
|
|
128
|
+
? column.id
|
|
129
|
+
: column;
|
|
130
|
+
|
|
131
|
+
if (Array.isArray(id)) {
|
|
132
|
+
const last = id[id.length - 1];
|
|
133
|
+
|
|
134
|
+
return last === null || last === undefined ? '' : String(last);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (id === null || id === undefined) {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const text = String(id);
|
|
142
|
+
const dot = text.lastIndexOf('.');
|
|
143
|
+
|
|
144
|
+
return dot === -1 ? text : text.slice(dot + 1);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is this key a machine's handle for the record rather than its name?
|
|
149
|
+
*
|
|
150
|
+
* `code` is deliberately NOT on the list. A product code is what a person says
|
|
151
|
+
* out loud when they ring up about one, and demoting it to a small grey chip
|
|
152
|
+
* would take the only readable thing off a products card.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} key
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export const isIdLike = (key) => {
|
|
158
|
+
const text = String(key ?? '');
|
|
159
|
+
|
|
160
|
+
return /^(id|ref|reference)$/i.test(text) || /_(id|no)$/i.test(text);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which contact channel a column carries, if any.
|
|
165
|
+
*
|
|
166
|
+
* THERE IS NO `email` COLUMN TYPE in this library — every address in every
|
|
167
|
+
* config is `type: "text"` with `id: "email"` — so the key has to be sniffed.
|
|
168
|
+
* The anchor is the whole point of the card treatment on a phone, and a
|
|
169
|
+
* `mailto:` on a column that merely mentions mail (`emailed_at`) is worse than
|
|
170
|
+
* none, so the pattern is anchored at both ends.
|
|
171
|
+
*
|
|
172
|
+
* `type: "mobile"` appears once in these configs (the client page's contacts
|
|
173
|
+
* tab), is not in the DataGrid's `renderColumn` switch and falls through to the
|
|
174
|
+
* text default. It is a phone here regardless of what the table does with it.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} column
|
|
177
|
+
* @returns {'email'|'tel'|null}
|
|
178
|
+
*/
|
|
179
|
+
export const channelOf = (column) => {
|
|
180
|
+
if (!column) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const key = fieldKey(column);
|
|
185
|
+
|
|
186
|
+
if (column.type === 'email' || EMAIL_KEYS.test(key)) {
|
|
187
|
+
return 'email';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (column.type === 'phone' || column.type === 'mobile' || TEL_KEYS.test(key)) {
|
|
191
|
+
return 'tel';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const isTextish = (column) =>
|
|
198
|
+
column.type === undefined ||
|
|
199
|
+
column.type === null ||
|
|
200
|
+
TEXTISH_TYPES.has(column.type);
|
|
201
|
+
|
|
202
|
+
const hasUsableLink = (column) =>
|
|
203
|
+
Boolean(column?.link && typeof column.link === 'object' && column.link.url);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Does `candidate` — a string, a dotted path or an array — name this column?
|
|
207
|
+
*
|
|
208
|
+
* Both sides are reduced with `fieldKey`, so `"customer"`, `["customer"]` and
|
|
209
|
+
* `"deal.customer"` all find the same column. A config author should not have
|
|
210
|
+
* to know which of the three spellings the columns array happens to use.
|
|
211
|
+
*/
|
|
212
|
+
const namesColumn = (candidate, column) => {
|
|
213
|
+
const wanted = fieldKey(candidate);
|
|
214
|
+
|
|
215
|
+
return wanted !== '' && wanted === fieldKey(column);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The mapping the card renders from.
|
|
220
|
+
*
|
|
221
|
+
* @typedef {object} CardMapping
|
|
222
|
+
* @property {object|null} reference small mono chip before the title
|
|
223
|
+
* @property {object|Array|null} title a column, or a `[first, last]` name pair
|
|
224
|
+
* @property {object|null} subtitle
|
|
225
|
+
* @property {object|null} badge
|
|
226
|
+
* @property {object[]} meta
|
|
227
|
+
* @property {object[]} channels rendered as `tel:` / `mailto:` anchors
|
|
228
|
+
* @property {object[]} actionColumns `type: 'action'` columns, folded into the rail
|
|
229
|
+
* @property {object[]} dropped past the line budget — recorded, not rendered
|
|
230
|
+
* @property {object|null} link the link the whole row follows
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Pick the parts of a card out of a view's declared columns.
|
|
235
|
+
*
|
|
236
|
+
* Returns `null` for "there is nothing here worth stacking" — no columns, an
|
|
237
|
+
* explicit `cardLayout: false`, or no column that could be a title. The caller
|
|
238
|
+
* treats `null` as "keep the scrolling grid exactly as it is today", which is
|
|
239
|
+
* why giving up is a first-class answer rather than a fallback that guesses.
|
|
240
|
+
*
|
|
241
|
+
* @param {object[]} columns the view config's `columns` array
|
|
242
|
+
* @param {object} [options]
|
|
243
|
+
* @param {object|false} [options.overrides] a `tableSetting.cardLayout` value
|
|
244
|
+
* @param {number} [options.maxLines] body-line budget; see MAX_BODY_LINES
|
|
245
|
+
* @param {(...args: any[]) => void} [options.log] where an unusable override is reported
|
|
246
|
+
* @returns {CardMapping|null}
|
|
247
|
+
*/
|
|
248
|
+
export const deriveCardLayout = (columns, options = {}) => {
|
|
249
|
+
const { overrides, maxLines, log } = options;
|
|
250
|
+
|
|
251
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// `"cardLayout": false` is a view saying "not on this grid" and is the
|
|
256
|
+
// highest-precedence answer there is.
|
|
257
|
+
if (overrides === false) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const note = typeof log === 'function' ? log : () => {};
|
|
262
|
+
const budget =
|
|
263
|
+
Number.isFinite(maxLines) && maxLines > 0
|
|
264
|
+
? maxLines
|
|
265
|
+
: MAX_BODY_LINES.default;
|
|
266
|
+
|
|
267
|
+
/* ------------------------------------------------------------------
|
|
268
|
+
Step 1 — the pool.
|
|
269
|
+
------------------------------------------------------------------ */
|
|
270
|
+
const actionColumns = [];
|
|
271
|
+
const explicit = { title: null, subtitle: null, badge: null, reference: null };
|
|
272
|
+
const explicitMeta = [];
|
|
273
|
+
let pool = [];
|
|
274
|
+
|
|
275
|
+
columns.forEach((column) => {
|
|
276
|
+
if (!column || typeof column !== 'object') {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (column.visible === false) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (column.type === 'action') {
|
|
285
|
+
actionColumns.push(column);
|
|
286
|
+
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (EXCLUDED_TYPES.has(column.type)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const slot = CARD_SLOTS.has(column.card) ? column.card : null;
|
|
295
|
+
|
|
296
|
+
if (slot === 'hidden') {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (slot === 'meta') {
|
|
301
|
+
explicitMeta.push(column);
|
|
302
|
+
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (slot && explicit[slot] === null) {
|
|
307
|
+
explicit[slot] = column;
|
|
308
|
+
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
pool.push(column);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const take = (column) => {
|
|
316
|
+
pool = pool.filter((entry) => entry !== column);
|
|
317
|
+
|
|
318
|
+
return column;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------------------
|
|
322
|
+
Step 0 — `tableSetting.cardLayout`, which short-circuits the slots it
|
|
323
|
+
names. Values are column ids; one that names no column is IGNORED with
|
|
324
|
+
a note rather than thrown, because a config typo must not take a phone's
|
|
325
|
+
whole grid down.
|
|
326
|
+
------------------------------------------------------------------ */
|
|
327
|
+
const overridden = { ...explicit };
|
|
328
|
+
const overriddenMeta = [...explicitMeta];
|
|
329
|
+
// Whether a `cardLayout.meta` LIST was supplied. A per-column
|
|
330
|
+
// `card: "meta"` mark is a hint about one column and stays additive; a
|
|
331
|
+
// list is a statement about the whole body. See where it is spent below.
|
|
332
|
+
let metaIsClosed = false;
|
|
333
|
+
let overriddenLink = null;
|
|
334
|
+
|
|
335
|
+
if (overrides && typeof overrides === 'object') {
|
|
336
|
+
['title', 'subtitle', 'badge', 'reference'].forEach((slot) => {
|
|
337
|
+
if (overrides[slot] === undefined || overrides[slot] === null) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const found = pool.find((column) =>
|
|
342
|
+
namesColumn(overrides[slot], column)
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
if (!found) {
|
|
346
|
+
note(
|
|
347
|
+
`[cardLayout] "${slot}" names no column in this view:`,
|
|
348
|
+
overrides[slot]
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
overridden[slot] = take(found);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (Array.isArray(overrides.meta)) {
|
|
358
|
+
metaIsClosed = true;
|
|
359
|
+
|
|
360
|
+
overrides.meta.forEach((candidate) => {
|
|
361
|
+
const found = pool.find((column) =>
|
|
362
|
+
namesColumn(candidate, column)
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (!found) {
|
|
366
|
+
note('[cardLayout] "meta" names no column in this view:', candidate);
|
|
367
|
+
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
overriddenMeta.push(take(found));
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (overrides.link && typeof overrides.link === 'object') {
|
|
376
|
+
overriddenLink = overrides.link;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ------------------------------------------------------------------
|
|
381
|
+
Step 2 — the reference chip.
|
|
382
|
+
|
|
383
|
+
Only among the first two columns a view declares. Every grid that leads
|
|
384
|
+
with a record number means it as a heading; a `customer_id` buried at
|
|
385
|
+
position six is a foreign key, and putting one in a mono chip at the top
|
|
386
|
+
of a card says something about the record that is not true.
|
|
387
|
+
------------------------------------------------------------------ */
|
|
388
|
+
let reference = overridden.reference;
|
|
389
|
+
|
|
390
|
+
if (!reference) {
|
|
391
|
+
const candidate = pool
|
|
392
|
+
.slice(0, 2)
|
|
393
|
+
.find((column) => isIdLike(fieldKey(column)));
|
|
394
|
+
|
|
395
|
+
if (candidate) {
|
|
396
|
+
reference = take(candidate);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ------------------------------------------------------------------
|
|
401
|
+
Step 3 — the title, first match wins.
|
|
402
|
+
------------------------------------------------------------------ */
|
|
403
|
+
let title = overridden.title;
|
|
404
|
+
|
|
405
|
+
if (!title) {
|
|
406
|
+
// 3a — a name pair. Two columns, one line: "Sarah Whitcombe", not
|
|
407
|
+
// "Sarah" with "Whitcombe" underneath it.
|
|
408
|
+
const first = pool.find((column) => FIRST_NAME_KEYS.test(fieldKey(column)));
|
|
409
|
+
const last = pool.find((column) => LAST_NAME_KEYS.test(fieldKey(column)));
|
|
410
|
+
|
|
411
|
+
if (first && last) {
|
|
412
|
+
title = [take(first), take(last)];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!title) {
|
|
417
|
+
// 3b — an exact identity key. THIS STEP EXISTS FOR /projects AND
|
|
418
|
+
// /opportunities, both of which declare the client relation FIRST: a
|
|
419
|
+
// "first substantive column" rule titles every one of their rows with
|
|
420
|
+
// the customer's name and hides the thing the row is about.
|
|
421
|
+
const candidate = pool.find(
|
|
422
|
+
(column) => isTextish(column) && IDENTITY_KEYS.test(fieldKey(column))
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
if (candidate) {
|
|
426
|
+
title = take(candidate);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!title) {
|
|
431
|
+
// 3c — an identity suffix (`site_name`, `product_label`), but not one
|
|
432
|
+
// that names a person who touched the record.
|
|
433
|
+
const candidate = pool.find((column) => {
|
|
434
|
+
const key = fieldKey(column);
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
isTextish(column) &&
|
|
438
|
+
IDENTITY_SUFFIX.test(key) &&
|
|
439
|
+
!ATTRIBUTED_PREFIX.test(key)
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (candidate) {
|
|
444
|
+
title = take(candidate);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!title) {
|
|
449
|
+
// 3d — the first substantive text or relation. /ssa lands here and
|
|
450
|
+
// titles on the client, which is what its rows are read as.
|
|
451
|
+
const candidate = pool.find((column) => {
|
|
452
|
+
const key = fieldKey(column);
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
(column.type === 'text' ||
|
|
456
|
+
column.type === 'relation' ||
|
|
457
|
+
column.type === 'relationArray' ||
|
|
458
|
+
column.type === undefined ||
|
|
459
|
+
column.type === null) &&
|
|
460
|
+
!isIdLike(key) &&
|
|
461
|
+
!BADGE_KEYS.test(key)
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (candidate) {
|
|
466
|
+
title = take(candidate);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 3e — give up. A view of nothing but a rich-text note has no line worth
|
|
471
|
+
// making the heading, and a card whose title is a date is worse than the
|
|
472
|
+
// table it replaced.
|
|
473
|
+
if (!title) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* ------------------------------------------------------------------
|
|
478
|
+
Step 4 — the badge (at most one).
|
|
479
|
+
------------------------------------------------------------------ */
|
|
480
|
+
let badge = overridden.badge;
|
|
481
|
+
|
|
482
|
+
if (!badge) {
|
|
483
|
+
const candidate = pool.find(
|
|
484
|
+
(column) =>
|
|
485
|
+
BADGE_TYPES.has(column.type) || BADGE_KEYS.test(fieldKey(column))
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
if (candidate) {
|
|
489
|
+
badge = take(candidate);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* ------------------------------------------------------------------
|
|
494
|
+
Step 5 — the subtitle (at most one).
|
|
495
|
+
|
|
496
|
+
A relation, or a column whose key describes the record's part. Declared
|
|
497
|
+
order decides between them, so the client page's contacts tab reads
|
|
498
|
+
"Sarah Whitcombe / Head Office" (the site relation is declared first)
|
|
499
|
+
and the address book reads "Sarah Whitcombe / IT Manager".
|
|
500
|
+
------------------------------------------------------------------ */
|
|
501
|
+
let subtitle = overridden.subtitle;
|
|
502
|
+
|
|
503
|
+
if (!subtitle) {
|
|
504
|
+
const candidate = pool.find(
|
|
505
|
+
(column) =>
|
|
506
|
+
column.type === 'relation' ||
|
|
507
|
+
column.type === 'relationArray' ||
|
|
508
|
+
(isTextish(column) && DESCRIPTOR_KEYS.test(fieldKey(column)))
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
if (candidate) {
|
|
512
|
+
subtitle = take(candidate);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* ------------------------------------------------------------------
|
|
517
|
+
Steps 6 and 7 — the body lines, which channels and meta SHARE.
|
|
518
|
+
|
|
519
|
+
The order is: one channel of each kind, then meta, then any further
|
|
520
|
+
channels. That last clause is the rule that is not obvious and is the
|
|
521
|
+
one contacts turns on — a contact has an email, a mobile and a work
|
|
522
|
+
number, and the second phone number is the least useful of the four
|
|
523
|
+
remaining facts. One number to press is the point; two is a list.
|
|
524
|
+
|
|
525
|
+
Everything past the budget is recorded in `dropped` rather than silently
|
|
526
|
+
lost, so a view that is throwing something away can be found by reading
|
|
527
|
+
the mapping instead of by counting lines on a phone.
|
|
528
|
+
------------------------------------------------------------------ */
|
|
529
|
+
const primaryChannels = [];
|
|
530
|
+
const extraChannels = [];
|
|
531
|
+
const metaCandidates = [];
|
|
532
|
+
const kindsTaken = new Set();
|
|
533
|
+
|
|
534
|
+
pool.forEach((column) => {
|
|
535
|
+
const kind = channelOf(column);
|
|
536
|
+
|
|
537
|
+
if (!kind) {
|
|
538
|
+
metaCandidates.push(column);
|
|
539
|
+
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (kindsTaken.has(kind)) {
|
|
544
|
+
extraChannels.push(column);
|
|
545
|
+
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
kindsTaken.add(kind);
|
|
550
|
+
primaryChannels.push(column);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// A `cardLayout.meta` LIST CLOSES THE META. It is an instruction, not a
|
|
554
|
+
// suggestion, and it does two things a per-column `card: "meta"` hint does
|
|
555
|
+
// not: it raises the budget rather than being trimmed by it (a view asking
|
|
556
|
+
// for four lines gets a taller row, which is honest; one asking for four
|
|
557
|
+
// and given three has been overruled without being told), and it stops the
|
|
558
|
+
// heuristic topping the list up. Branding profiles is what the second half
|
|
559
|
+
// is for — naming `created_at` and being handed two colour swatches
|
|
560
|
+
// alongside it is not what "the meta is created_at" means.
|
|
561
|
+
//
|
|
562
|
+
// Channels survive it, because the click-to-call line is the whole point
|
|
563
|
+
// of the treatment and no config author naming a meta list is asking for
|
|
564
|
+
// it to go.
|
|
565
|
+
const ordered = metaIsClosed
|
|
566
|
+
? [...overriddenMeta, ...primaryChannels]
|
|
567
|
+
: [
|
|
568
|
+
...overriddenMeta,
|
|
569
|
+
...primaryChannels,
|
|
570
|
+
...metaCandidates,
|
|
571
|
+
...extraChannels,
|
|
572
|
+
];
|
|
573
|
+
const lines = Math.max(
|
|
574
|
+
budget,
|
|
575
|
+
overriddenMeta.length + (metaIsClosed ? primaryChannels.length : 0)
|
|
576
|
+
);
|
|
577
|
+
const kept = ordered.slice(0, lines);
|
|
578
|
+
const dropped = [
|
|
579
|
+
...ordered.slice(lines),
|
|
580
|
+
...(metaIsClosed ? [...metaCandidates, ...extraChannels] : []),
|
|
581
|
+
];
|
|
582
|
+
|
|
583
|
+
const channels = kept.filter((column) => channelOf(column) !== null);
|
|
584
|
+
const meta = kept.filter((column) => channelOf(column) === null);
|
|
585
|
+
|
|
586
|
+
/* ------------------------------------------------------------------
|
|
587
|
+
Step 8 — the link the whole row follows.
|
|
588
|
+
|
|
589
|
+
ONLY THE THREE COLUMNS THAT IDENTIFY THE RECORD are considered: the
|
|
590
|
+
title, the reference, the subtitle. "The first column carrying a link"
|
|
591
|
+
is the obvious wider rule and it is wrong — on /products and
|
|
592
|
+
/systemRequests the only column with a link is a FILE column whose href
|
|
593
|
+
is `/ajax/file/download/`, so the whole row would become a download.
|
|
594
|
+
That link belongs to that cell in the table and it still belongs to that
|
|
595
|
+
cell here; it is not a statement about where the record lives.
|
|
596
|
+
|
|
597
|
+
`null` is a legitimate answer: `onRowClick` then falls through to the
|
|
598
|
+
update modal, which is exactly what a tap on those rows does today.
|
|
599
|
+
------------------------------------------------------------------ */
|
|
600
|
+
const titleColumn = Array.isArray(title) ? title[0] : title;
|
|
601
|
+
const link =
|
|
602
|
+
overriddenLink ??
|
|
603
|
+
[titleColumn, reference, subtitle].find(hasUsableLink)?.link ??
|
|
604
|
+
null;
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
reference: reference ?? null,
|
|
608
|
+
title,
|
|
609
|
+
subtitle: subtitle ?? null,
|
|
610
|
+
badge: badge ?? null,
|
|
611
|
+
meta,
|
|
612
|
+
channels,
|
|
613
|
+
actionColumns,
|
|
614
|
+
dropped,
|
|
615
|
+
link,
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The px geometry of a card row, per density.
|
|
621
|
+
*
|
|
622
|
+
* Not in `DENSITY_GRID`: those numbers are every existing deployment's table
|
|
623
|
+
* layout and `tests/density.test.mjs` pins them literally. These are new and
|
|
624
|
+
* belong to the one thing that reads them.
|
|
625
|
+
*/
|
|
626
|
+
export const CARD_METRICS = {
|
|
627
|
+
default: { padY: 10, title: 22, line: 18, gap: 2, railMin: 44 },
|
|
628
|
+
large: { padY: 12, title: 26, line: 21, gap: 3, railMin: 52 },
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* How tall every row of this grid is, in card mode.
|
|
633
|
+
*
|
|
634
|
+
* PER VIEW, NEVER PER ROW, and never measured. The virtual list positions rows
|
|
635
|
+
* absolutely from a height it is given, and `utils/useDensity.js` already
|
|
636
|
+
* records why measurement is the wrong tool on precisely this platform:
|
|
637
|
+
* "measurement is unreliable in a background-throttled tab and clips action
|
|
638
|
+
* buttons". A blank field therefore collapses its line's CONTENT and leaves
|
|
639
|
+
* the row exactly as tall as its neighbours, which is also what stops a list
|
|
640
|
+
* of cards looking like a ransom note.
|
|
641
|
+
*
|
|
642
|
+
* @param {CardMapping} mapping
|
|
643
|
+
* @param {object} [options]
|
|
644
|
+
* @param {'default'|'large'} [options.density]
|
|
645
|
+
* @param {boolean} [options.hasActions] whether the rail draws any control
|
|
646
|
+
* @returns {number} px
|
|
647
|
+
*/
|
|
648
|
+
export const cardRowHeight = (mapping, { density, hasActions } = {}) => {
|
|
649
|
+
const metrics = CARD_METRICS[density] || CARD_METRICS.default;
|
|
650
|
+
const { padY, title, line, gap, railMin } = metrics;
|
|
651
|
+
|
|
652
|
+
if (!mapping) {
|
|
653
|
+
return 2 * padY + Math.max(title, hasActions ? railMin : 0);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const bodyLines =
|
|
657
|
+
(mapping.subtitle ? 1 : 0) +
|
|
658
|
+
(mapping.channels?.length ?? 0) +
|
|
659
|
+
(mapping.meta?.length ?? 0);
|
|
660
|
+
|
|
661
|
+
const stack = title + bodyLines * (line + gap);
|
|
662
|
+
|
|
663
|
+
return 2 * padY + Math.max(stack, hasActions ? railMin : 0);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
export default deriveCardLayout;
|