@visns-studio/visns-components 6.24.4 → 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.
Files changed (43) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/columns/ColumnRenderers.jsx +3 -46
  8. package/src/components/columns/StackedRow.jsx +186 -0
  9. package/src/components/controls/DataGridSearch.jsx +110 -2
  10. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  11. package/src/components/generic/GenericAuth.jsx +50 -18
  12. package/src/components/generic/GenericDashboard.jsx +20 -1
  13. package/src/components/generic/GenericDetail.jsx +446 -259
  14. package/src/components/mapboxSearchBox.js +640 -0
  15. package/src/components/navBadges.js +63 -1
  16. package/src/components/navDrawer.js +147 -0
  17. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  18. package/src/components/sms/smsHelpers.js +15 -0
  19. package/src/components/styles/ClientAuth.module.scss +39 -0
  20. package/src/components/styles/DataGrid.module.scss +158 -5
  21. package/src/components/styles/Field.module.scss +52 -1
  22. package/src/components/styles/Form.module.scss +82 -0
  23. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  24. package/src/components/styles/GenericDashboard.module.scss +50 -0
  25. package/src/components/styles/GenericDetail.module.scss +63 -1
  26. package/src/components/styles/GenericDynamic.module.scss +23 -0
  27. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  28. package/src/components/styles/GenericIndex.module.scss +6 -1
  29. package/src/components/styles/Navigation.module.scss +460 -7
  30. package/src/components/styles/Sms.module.scss +92 -0
  31. package/src/components/styles/StackedRow.module.scss +182 -0
  32. package/src/components/styles/TicketConversation.module.scss +76 -0
  33. package/src/components/styles/Vault.module.scss +192 -0
  34. package/src/components/styles/density.css +10 -0
  35. package/src/components/styles/global-datagrid.css +163 -0
  36. package/src/components/styles/global.css +20 -0
  37. package/src/components/tickets/TicketConversation.jsx +13 -8
  38. package/src/components/utils/ConfirmDialog.js +22 -3
  39. package/src/components/utils/cardLayout.js +666 -0
  40. package/src/components/utils/contactChannels.js +130 -0
  41. package/src/components/utils/editPlacement.js +95 -0
  42. package/src/components/utils/useDensity.js +303 -7
  43. package/src/index.js +32 -0
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.24.4",
96
+ "version": "6.25.0",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -104,7 +104,9 @@
104
104
  "build": "vite build",
105
105
  "test": "node --test tests/*.test.mjs",
106
106
  "dev:vault": "vite --config dev/vite.config.js",
107
- "dev:sms": "FIXTURE=sms vite --config dev/vite.config.js"
107
+ "dev:sms": "FIXTURE=sms vite --config dev/vite.config.js",
108
+ "dev:grid": "FIXTURE=grid vite --config dev/vite.config.js",
109
+ "check:grid": "node dev/checkGridFixture.mjs"
108
110
  },
109
111
  "repository": {
110
112
  "type": "git",
@@ -3,6 +3,17 @@ import AwesomeDebouncePromise from 'awesome-debounce-promise';
3
3
  import { ToggleLeft, ToggleRight, Search } from 'lucide-react';
4
4
  import styles from './styles/Autocomplete.module.scss';
5
5
  import fetchUtil from '../utils/fetchUtil';
6
+ import {
7
+ DEFAULT_TYPES,
8
+ adaptSuggestions,
9
+ adaptToLegacyFeature,
10
+ buildRetrieveUrl,
11
+ buildSuggestUrl,
12
+ createSessionTokenManager,
13
+ featureFromRetrieve,
14
+ isTypesParamRejection,
15
+ warnOnce,
16
+ } from './mapboxSearchBox';
6
17
 
7
18
  // Create the debounced function outside the component to prevent recreation
8
19
  const createDebouncedSearch = (fn) =>
@@ -31,66 +42,87 @@ const VisnsAutocomplete = memo((props) => {
31
42
  const inputRef = useRef(null);
32
43
  const resultsRef = useRef(null);
33
44
 
34
- // Memoize URL building logic to avoid recalculations
35
- const buildSearchUrl = useCallback(
36
- (value) => {
37
- if (!value || !api) return null;
38
-
39
- // The search text is a PATH segment, so it has to be escaped: an
40
- // unencoded "5/12 Smith St" split the path and 404'd, which is a
41
- // shape half of Australian addresses take.
42
- let url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
43
- value
44
- )}.json?types=address,poi&access_token=${api}`;
45
-
46
- if (country) {
47
- url += `&country=${country}`;
48
- }
45
+ /*
46
+ * The Search Box session, held for the life of the mounted field.
47
+ *
48
+ * One token covers every keystroke of a search and the retrieve that ends
49
+ * it — that pairing is what Mapbox bills as a single session. See
50
+ * mapboxSearchBox.js; the manager is a ref because a token that changed
51
+ * with the render cycle would be a token per keystroke.
52
+ */
53
+ const sessionRef = useRef(null);
49
54
 
50
- if (mapbox) {
51
- if (mapbox.proximity) {
52
- url += `&proximity=${mapbox.proximity}`;
53
- }
55
+ if (!sessionRef.current) {
56
+ sessionRef.current = createSessionTokenManager();
57
+ }
54
58
 
55
- if (mapbox.bbox) {
56
- url += `&bbox=${mapbox.bbox}`;
57
- }
58
- }
59
+ /*
60
+ * The `types` filter, held so a refusal is only paid for once.
61
+ *
62
+ * `types=address,poi` is what the documentation says and what live calls
63
+ * accept. If a future Mapbox disagrees, the first refusal drops the filter
64
+ * for the rest of the page rather than erroring on every keystroke.
65
+ */
66
+ const typesRef = useRef(DEFAULT_TYPES);
59
67
 
60
- return url;
61
- },
62
- [api, country, mapbox]
63
- );
68
+ /*
69
+ * Read the two biasing values off the prop as primitives.
70
+ *
71
+ * `mapbox` is usually rebuilt by the parent on every render, so depending
72
+ * on the object identity made `performSearch` a new function each time —
73
+ * and the "autocomplete switched back on" effect below keys on exactly
74
+ * that, so it would re-fire on every render of the parent form.
75
+ */
76
+ const proximity = mapbox?.proximity;
77
+ const bbox = mapbox?.bbox;
64
78
 
65
79
  const performSearch = useCallback(
66
80
  (id, value) => {
67
- if (id <= 0 || !value) {
81
+ if (id <= 0 || !value || !api) {
68
82
  setResults([]);
69
83
  setIsLoading(false);
70
84
  return;
71
85
  }
72
86
 
73
- const url = buildSearchUrl(value);
74
- if (!url) return;
75
-
76
- fetchUtil
77
- .get(url)
78
- .then((response) => {
79
- // Ensure we're accessing the features array from the response
80
- // Check if response.data is a string (sometimes happens with certain APIs)
81
- let parsedData = response.data;
82
- if (typeof response.data === 'string') {
83
- try {
84
- parsedData = JSON.parse(response.data);
85
- } catch (e) {
86
- console.error('Error parsing response data:', e);
87
- }
88
- }
87
+ const request = (types) => {
88
+ const url = buildSuggestUrl({
89
+ query: value,
90
+ accessToken: api,
91
+ // `current()` — NOT `consume()`. Every keystroke of this
92
+ // search rides the same token; only the retrieve spends it.
93
+ sessionToken: sessionRef.current.current(),
94
+ country,
95
+ proximity,
96
+ bbox,
97
+ types,
98
+ });
89
99
 
90
- const features = parsedData?.features || [];
100
+ if (!url) {
101
+ setIsLoading(false);
102
+ return Promise.resolve();
103
+ }
91
104
 
92
- setResults(features);
105
+ return fetchUtil.get(url).then((response) => {
106
+ setResults(adaptSuggestions(response.data));
93
107
  setIsLoading(false);
108
+ });
109
+ };
110
+
111
+ request(typesRef.current)
112
+ .catch((error) => {
113
+ if (typesRef.current && isTypesParamRejection(error)) {
114
+ warnOnce(
115
+ 'searchbox-types',
116
+ 'Mapbox refused the Search Box `types` filter; retrying without it.',
117
+ error?.response?.data
118
+ );
119
+
120
+ typesRef.current = '';
121
+
122
+ return request('');
123
+ }
124
+
125
+ throw error;
94
126
  })
95
127
  .catch((error) => {
96
128
  console.error('Error fetching address suggestions:', error);
@@ -98,7 +130,7 @@ const VisnsAutocomplete = memo((props) => {
98
130
  setIsLoading(false);
99
131
  });
100
132
  },
101
- [buildSearchUrl]
133
+ [api, bbox, country, proximity]
102
134
  );
103
135
 
104
136
  // Create the debounced search function using our memoized performSearch
@@ -126,36 +158,15 @@ const VisnsAutocomplete = memo((props) => {
126
158
  [field, overwrite, performSearchDebounced, setFormData]
127
159
  );
128
160
 
129
- const handleItemClicked = useCallback(
161
+ /**
162
+ * Hand a finished pick — already adapted to the legacy v5 feature shape —
163
+ * to whoever owns this field's value.
164
+ */
165
+ const applyFeature = useCallback(
130
166
  (place) => {
131
- // Use place_name as the primary display value as it contains the full formatted address
132
- const displayValue = place.place_name || '';
133
-
134
- // If place_name is not available, construct from components
135
- const fallbackValue = (() => {
136
- const address = place.address || '';
137
- const text = place.text || '';
138
- const context = place.context
139
- ? place.context
140
- .filter((ctx) => ctx.text)
141
- .map((ctx) => ctx.text)
142
- .join(', ')
143
- : '';
144
-
145
- if (address && text) {
146
- return context
147
- ? `${address} ${text}, ${context}`
148
- : `${address} ${text}`;
149
- } else if (text) {
150
- return context ? `${text}, ${context}` : text;
151
- }
152
- return '';
153
- })();
154
-
155
- const finalValue = displayValue || fallbackValue;
156
-
157
- setResults([]);
158
- setIsFocused(false);
167
+ if (!place) {
168
+ return;
169
+ }
159
170
 
160
171
  /*
161
172
  * A form that handles the pick owns this field's value.
@@ -179,10 +190,14 @@ const VisnsAutocomplete = memo((props) => {
179
190
  return;
180
191
  }
181
192
 
193
+ // Standalone use (no form to parse the place): the field keeps
194
+ // the full formatted address, which is the only useful value. The
195
+ // adapter always builds a `place_name`, from the record's own
196
+ // `full_address` or from its name and locality.
197
+ const finalValue = place.place_name || '';
198
+
182
199
  setSearch(finalValue);
183
200
 
184
- // Standalone use (no form to parse the place): the field keeps
185
- // the full formatted address, which is the only useful value.
186
201
  setFormData((prevState) => ({
187
202
  ...prevState,
188
203
  [field]: finalValue,
@@ -191,10 +206,89 @@ const VisnsAutocomplete = memo((props) => {
191
206
  [field, onSelect, setFormData]
192
207
  );
193
208
 
209
+ /*
210
+ * A click on a row is the second half of the Search Box exchange: the
211
+ * suggestion carries a `mapbox_id` and nothing else useful, so the full
212
+ * record — coordinates, context, the whole formatted address — is fetched
213
+ * by `retrieve` on the same session token, then adapted back to the v5
214
+ * shape the forms read.
215
+ *
216
+ * IT FAILS SOFT, ALWAYS. A retrieve that 500s, times out or comes back
217
+ * empty must not cost the user their click and must never throw into the
218
+ * form: a suggestion already carries the name, the street line and the
219
+ * same context object, so it adapts into the same feature minus the
220
+ * coordinates. That is worth far more than an empty address field, and
221
+ * nothing in this library reads the coordinates.
222
+ */
223
+ const handleItemClicked = useCallback(
224
+ (row) => {
225
+ setResults([]);
226
+ setIsFocused(false);
227
+
228
+ const fromSuggestion = () =>
229
+ adaptToLegacyFeature(row?.suggestion || row);
230
+
231
+ if (!row?.mapbox_id || !api) {
232
+ // Shouldn't happen — suggest returns an id on every row.
233
+ warnOnce(
234
+ 'searchbox-no-id',
235
+ 'Mapbox suggestion arrived without a mapbox_id; filling from the suggestion alone.',
236
+ row
237
+ );
238
+
239
+ sessionRef.current.reset();
240
+ applyFeature(fromSuggestion());
241
+ return;
242
+ }
243
+
244
+ const url = buildRetrieveUrl({
245
+ mapboxId: row.mapbox_id,
246
+ accessToken: api,
247
+ // Spends the token: this pick closes the session, and the next
248
+ // thing typed opens a new one.
249
+ sessionToken: sessionRef.current.consume(),
250
+ });
251
+
252
+ fetchUtil
253
+ .get(url)
254
+ .then((response) => {
255
+ const place = featureFromRetrieve(response.data);
256
+
257
+ if (!place) {
258
+ warnOnce(
259
+ 'searchbox-retrieve-empty',
260
+ 'Mapbox retrieve returned no feature; filling from the suggestion instead.',
261
+ response?.data
262
+ );
263
+
264
+ applyFeature(fromSuggestion());
265
+ return;
266
+ }
267
+
268
+ applyFeature(place);
269
+ })
270
+ .catch((error) => {
271
+ warnOnce(
272
+ 'searchbox-retrieve-failed',
273
+ 'Mapbox retrieve failed; filling from the suggestion instead.',
274
+ error
275
+ );
276
+
277
+ applyFeature(fromSuggestion());
278
+ });
279
+ },
280
+ [api, applyFeature]
281
+ );
282
+
194
283
  const handleOverwrite = useCallback(() => {
195
284
  setOverwrite((prevState) => {
196
285
  if (!prevState) {
197
286
  setResults([]);
287
+
288
+ // Switching autocomplete off abandons the session rather than
289
+ // ending it. Keeping the token would bill whatever is typed
290
+ // after it is switched back on against this search.
291
+ sessionRef.current.reset();
198
292
  }
199
293
  return !prevState;
200
294
  });
@@ -331,34 +425,34 @@ const VisnsAutocomplete = memo((props) => {
331
425
  Loading...
332
426
  </li>
333
427
  ) : Array.isArray(results) && results.length > 0 ? (
334
- results.map((place) => (
428
+ /*
429
+ * Suggest hands back the row already split in two:
430
+ * `name` — the business or the street line — over
431
+ * `place_formatted`, the locality it sits in. So
432
+ * "Epiq" reads above "Perth 6000, Australia" rather
433
+ * than the v5 rows' number/street pair stitched back
434
+ * together from a context array. Same two elements,
435
+ * same classes.
436
+ */
437
+ results.map((row) => (
335
438
  <li
336
- key={place.id}
439
+ key={row.id}
337
440
  className={styles['AutocompletePlace-items']}
338
- onClick={() => handleItemClicked(place)}
441
+ onClick={() => handleItemClicked(row)}
339
442
  >
340
443
  <div
341
444
  className={
342
445
  styles['AutocompletePlace-itemMain']
343
446
  }
344
447
  >
345
- {place.address && (
346
- <span
347
- className={
348
- styles[
349
- 'AutocompletePlace-itemAddress'
350
- ]
351
- }
352
- >
353
- {place.address}
354
- </span>
355
- )}
356
448
  <span
357
449
  className={
358
- styles['AutocompletePlace-itemText']
450
+ styles[
451
+ 'AutocompletePlace-itemAddress'
452
+ ]
359
453
  }
360
454
  >
361
- {place.text}
455
+ {row.name}
362
456
  </span>
363
457
  </div>
364
458
  <div
@@ -368,31 +462,7 @@ const VisnsAutocomplete = memo((props) => {
368
462
  ]
369
463
  }
370
464
  >
371
- {place.context &&
372
- place.context
373
- .filter((ctx) => ctx.text)
374
- .map((ctx, index) => (
375
- <span key={ctx.id || index}>
376
- {ctx.text}
377
- </span>
378
- ))
379
- .reduce(
380
- (prev, curr, i) => [
381
- prev,
382
- <span
383
- key={`sep-${i}`}
384
- className={
385
- styles[
386
- 'AutocompletePlace-itemSeparator'
387
- ]
388
- }
389
- >
390
- ,{' '}
391
- </span>,
392
- curr,
393
- ],
394
- []
395
- )}
465
+ {row.place_formatted}
396
466
  </div>
397
467
  </li>
398
468
  ))