@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
@@ -68,6 +68,10 @@ import {
68
68
  isBlankValue,
69
69
  overviewFieldLabel,
70
70
  } from '../utils/displayValue';
71
+ import {
72
+ isHeaderPlacement,
73
+ resolveEditPlacement,
74
+ } from '../utils/editPlacement';
71
75
  import OcrTemplateEnhanced from './OcrTemplateEnhanced';
72
76
 
73
77
  import 'react-big-calendar/lib/css/react-big-calendar.css';
@@ -239,6 +243,19 @@ function GenericDetail({
239
243
  urlParam = 'dataId',
240
244
  userProfile,
241
245
  actions = null,
246
+ /**
247
+ * Where the record's action cluster sits — `'floating'` (the default, the
248
+ * bottom-right bar this library has always drawn) or `'header'` (inline on
249
+ * the right of the title row).
250
+ *
251
+ * This is an OVERRIDE, not the primary switch: the value normally comes
252
+ * from the view config as `setting.editPlacement`, so a page mounted by a
253
+ * bare `<GenericDetail setting={…} />` — which is most of them — can ask
254
+ * for it without a host editing JSX. See `utils/editPlacement.js` for the
255
+ * whole argument. Given here it wins, because a host that has already
256
+ * wrapped this component for one page knows something the config does not.
257
+ */
258
+ editPlacement = null,
242
259
  /**
243
260
  * Host-supplied components for `type: "custom"` tabs, keyed by the tab's
244
261
  * `renderer`.
@@ -264,6 +281,15 @@ function GenericDetail({
264
281
 
265
282
  const { filters, page, stages, tabs } = setting;
266
283
 
284
+ /**
285
+ * `'floating'` unless this page's config (or its host) asked otherwise.
286
+ * Resolved once per render, from one implementation, so the three places
287
+ * that draw a cluster and the title row that may receive it cannot
288
+ * disagree about where the buttons are meant to be.
289
+ */
290
+ const actionPlacement = resolveEditPlacement(setting, editPlacement);
291
+ const drawActionsInHeader = isHeaderPlacement(actionPlacement);
292
+
267
293
  /** Fileupload states */
268
294
  const [files, setFiles] = useState([]);
269
295
 
@@ -3101,192 +3127,10 @@ function GenericDetail({
3101
3127
  </div>
3102
3128
  )}
3103
3129
 
3104
- {activeTabConfig.form && (
3105
- <div className={styles.polActions}>
3106
- {/* Render the "Edit" button if activeTabConfig.form exists */}
3107
- {!inPlaceEditing ? (
3108
- <button
3109
- className={styles.btn}
3110
- title={
3111
- activeTabConfig.form
3112
- .editLabel || 'Edit'
3113
- }
3114
- aria-label={
3115
- activeTabConfig.form
3116
- .editLabel || 'Edit'
3117
- }
3118
- onClick={() => {
3119
- if (
3120
- activeTabConfig.form
3121
- .inPlaceEdit
3122
- ) {
3123
- startInPlaceEditing(
3124
- activeTabConfig.id
3125
- );
3126
- } else {
3127
- modalOpen(
3128
- 'update',
3129
- routeParams[
3130
- urlParam
3131
- ]
3132
- );
3133
- }
3134
- }}
3135
- >
3136
- <Pencil
3137
- size={18}
3138
- strokeWidth={2.25}
3139
- />
3140
- <span>
3141
- {activeTabConfig.form
3142
- .editLabel ||
3143
- 'Edit'}
3144
- </span>
3145
- </button>
3146
- ) : (
3147
- editingTabId ===
3148
- activeTabConfig.id && (
3149
- <>
3150
- {/* Cancel is the way back out of editing, not a
3151
- destructive act — it was painted in error red,
3152
- the loudest thing on the page, next to the
3153
- Save it competes with. Ghost secondary. */}
3154
- <button
3155
- className={
3156
- styles.btnSecondary
3157
- }
3158
- onClick={
3159
- cancelInPlaceEditing
3160
- }
3161
- >
3162
- Cancel
3163
- </button>
3164
- <button
3165
- className={
3166
- styles.btn
3167
- }
3168
- onClick={() => {
3169
- // Find the form's save button and click it
3170
- const saveButton =
3171
- document.querySelector(
3172
- `.inPlaceEdit .${styles.saveBtn}`
3173
- );
3174
- if (
3175
- saveButton
3176
- ) {
3177
- saveButton.click();
3178
- } else {
3179
- // Fallback to any button with data-type="save"
3180
- const fallbackButton =
3181
- document.querySelector(
3182
- `.inPlaceEdit button[data-type="save"]`
3183
- );
3184
- if (
3185
- fallbackButton
3186
- ) {
3187
- fallbackButton.click();
3188
- }
3189
- }
3190
-
3191
- // Reset the editing state
3192
- setInPlaceEditing(
3193
- false
3194
- );
3195
- setEditingTabId(
3196
- null
3197
- );
3198
-
3199
- // Reload data
3200
- handleReload();
3201
- }}
3202
- >
3203
- Save
3204
- </button>
3205
- </>
3206
- )
3207
- )}
3208
-
3209
- {/* Check if sections contain "ocrTemplate" type and render the "Save" button under the same polActions */}
3210
- {activeTabConfig.sections &&
3211
- activeTabConfig.sections
3212
- .length > 0 &&
3213
- activeTabConfig.sections.map(
3214
- (section, sectionIndex) =>
3215
- section.content &&
3216
- section.content.some(
3217
- (item) =>
3218
- item.type ===
3219
- 'ocrTemplate'
3220
- ) && (
3221
- <button
3222
- key={`save-button-${sectionIndex}`}
3223
- className={
3224
- styles.btn
3225
- }
3226
- onClick={() => {
3227
- // Handle the specific "ocrTemplate" item
3228
- const ocrItem =
3229
- section.content.find(
3230
- (
3231
- item
3232
- ) =>
3233
- item.type ===
3234
- 'ocrTemplate'
3235
- );
3236
- handleOcrTemplateSave(
3237
- ocrItem.id
3238
- ); // Now you can safely use ocrItem.id
3239
- }}
3240
- >
3241
- Save
3242
- </button>
3243
- )
3244
- )}
3245
-
3246
- {/* Check if export exists and render the "Export" button under the same polActions */}
3247
- {activeTabConfig.export && (
3248
- <button
3249
- className={styles.btn}
3250
- onClick={() =>
3251
- handleExport(
3252
- activeTabConfig.export
3253
- )
3254
- }
3255
- >
3256
- {activeTabConfig.export
3257
- .label || 'Export'}
3258
- </button>
3259
- )}
3260
-
3261
- {activeTabConfig.customAction && (
3262
- <button
3263
- className={styles.btn}
3264
- onClick={() =>
3265
- handleCustomAction(
3266
- activeTabConfig.customAction
3267
- )
3268
- }
3269
- >
3270
- {
3271
- activeTabConfig
3272
- .customAction.label
3273
- }
3274
- </button>
3275
- )}
3276
-
3277
- {activeTabConfig.customActions && Array.isArray(activeTabConfig.customActions) && activeTabConfig.customActions.map((customAction, index) => (
3278
- <button
3279
- key={index}
3280
- className={styles.btn}
3281
- onClick={() =>
3282
- handleCustomAction(customAction)
3283
- }
3284
- >
3285
- {getActionLabel(customAction)}
3286
- </button>
3287
- ))}
3288
- </div>
3289
- )}
3130
+ {!drawActionsInHeader &&
3131
+ actionCluster(
3132
+ renderOverviewActions(activeTabConfig)
3133
+ )}
3290
3134
  </>
3291
3135
  );
3292
3136
  case 'qrCode':
@@ -3297,12 +3141,14 @@ function GenericDetail({
3297
3141
  />
3298
3142
  );
3299
3143
  case 'table':
3300
- if (
3301
- config.ajaxSetting &&
3302
- config.form &&
3303
- config.columns &&
3304
- config.settings
3305
- ) {
3144
+ // ajaxSetting and columns are what a grid IS;
3145
+ // form (create/edit) and settings (row actions)
3146
+ // are what a grid can DO, and a read-only tab
3147
+ // legitimately has neither. Requiring all four
3148
+ // rendered an empty strip with no fetch and no
3149
+ // error — a config mistake that looked like a
3150
+ // data problem, twice, in the field.
3151
+ if (config.ajaxSetting && config.columns) {
3306
3152
  const processUrlParams = (obj) => {
3307
3153
  if (Array.isArray(obj)) {
3308
3154
  return obj.map(processUrlParams);
@@ -3554,72 +3400,15 @@ function GenericDetail({
3554
3400
  </div>
3555
3401
  </div>
3556
3402
 
3557
- {activeTabConfig.functions
3558
- ?.checkboxUpdate && (
3559
- <div className={styles.polActions}>
3560
- <button
3561
- className={styles.btn}
3562
- onClick={
3563
- handleCheckboxUpdate
3564
- }
3565
- >
3566
- {activeTabConfig.functions
3567
- .checkboxUpdate.label ||
3568
- 'Update'}
3569
- </button>
3570
- </div>
3571
- )}
3572
-
3573
- {(activeTabConfig.functions
3574
- ?.checkboxDelete ||
3575
- activeTabConfig.customAction ||
3576
- (activeTabConfig.customActions && activeTabConfig.customActions.length > 0)) && (
3577
- <div className={styles.polActions}>
3578
- {activeTabConfig.functions
3579
- ?.checkboxDelete && (
3580
- <button
3581
- className={styles.btn}
3582
- onClick={
3583
- handleCheckboxDelete
3584
- }
3585
- >
3586
- {activeTabConfig
3587
- .functions
3588
- .checkboxDelete
3589
- .label || 'Delete'}
3590
- </button>
3591
- )}
3592
-
3593
- {activeTabConfig.customAction && (
3594
- <button
3595
- className={styles.btn}
3596
- onClick={() =>
3597
- handleCustomAction(
3598
- activeTabConfig.customAction
3599
- )
3600
- }
3601
- >
3602
- {
3603
- activeTabConfig
3604
- .customAction
3605
- .label
3606
- }
3607
- </button>
3608
- )}
3403
+ {!drawActionsInHeader &&
3404
+ actionCluster(
3405
+ renderTableUpdateAction(activeTabConfig)
3406
+ )}
3609
3407
 
3610
- {activeTabConfig.customActions && Array.isArray(activeTabConfig.customActions) && activeTabConfig.customActions.map((customAction, index) => (
3611
- <button
3612
- key={index}
3613
- className={styles.btn}
3614
- onClick={() =>
3615
- handleCustomAction(customAction)
3616
- }
3617
- >
3618
- {getActionLabel(customAction)}
3619
- </button>
3620
- ))}
3621
- </div>
3622
- )}
3408
+ {!drawActionsInHeader &&
3409
+ actionCluster(
3410
+ renderTableBulkActions(activeTabConfig)
3411
+ )}
3623
3412
  </>
3624
3413
  );
3625
3414
  } else {
@@ -3846,7 +3635,16 @@ function GenericDetail({
3846
3635
  ),
3847
3636
  };
3848
3637
 
3849
- const updatedForm = {
3638
+ // A table tab is allowed to have NO form — a
3639
+ // read-only listing whose rows link elsewhere
3640
+ // (a client's Projects tab is one). This
3641
+ // branch assumed every filtered table carried
3642
+ // form.fields and crashed the whole page on
3643
+ // the first one that did not.
3644
+ const updatedForm = !activeTabConfig.form
3645
+ ?.fields
3646
+ ? activeTabConfig.form
3647
+ : {
3850
3648
  ...activeTabConfig.form,
3851
3649
  fields: activeTabConfig.form.fields.map(
3852
3650
  (field) => {
@@ -4023,6 +3821,328 @@ function GenericDetail({
4023
3821
  setEditingTabId(null);
4024
3822
  };
4025
3823
 
3824
+ /* ---------------------------------------------------------------------
3825
+ The action clusters, and why they are functions rather than JSX in
3826
+ place.
3827
+
3828
+ `editPlacement` draws these EITHER in the title row or pinned
3829
+ bottom-right, and a placement switch is the classic way to end up with
3830
+ two copies of a button. The Edit button's own logic is
3831
+ `inPlaceEditing`, `editingTabId`, `startInPlaceEditing`, and a Save that
3832
+ reaches into the DOM for `.inPlaceEdit button[data-type="save"]` — copy
3833
+ that into a second branch and one of the two silently stops saving the
3834
+ first time anything about in-place editing changes, on whichever
3835
+ placement nobody was looking at.
3836
+
3837
+ So each cluster is built exactly once, here, and the render decides
3838
+ only WHERE to hang it. `actionCluster` is the one wrapper: it is the
3839
+ same `<div>` in both placements, carrying a modifier class in the
3840
+ header one — the floating variant keeps its pill, its backdrop and its
3841
+ `:has(> :nth-child(2))` rules untouched.
3842
+
3843
+ They take the tab config as an argument rather than closing over the
3844
+ state copy, because the title row is rendered BEFORE `renderContent()`
3845
+ walks the tabs and would otherwise be a render behind. Same reason the
3846
+ parameter is named `activeTabConfig`: the bodies are the original JSX,
3847
+ moved and not rewritten.
3848
+ --------------------------------------------------------------------- */
3849
+
3850
+ /** The one wrapper. Returns nothing at all when there is nothing to draw. */
3851
+ const actionCluster = (children) =>
3852
+ children ? (
3853
+ <div
3854
+ className={
3855
+ drawActionsInHeader
3856
+ ? `${styles.polActions} ${styles.polActionsHeader}`
3857
+ : styles.polActions
3858
+ }
3859
+ >
3860
+ {children}
3861
+ </div>
3862
+ ) : null;
3863
+
3864
+ /**
3865
+ * An overview tab's Edit — or, once editing, its Cancel and Save — plus
3866
+ * the OCR save, the export and any custom actions the tab declares.
3867
+ */
3868
+ const renderOverviewActions = (activeTabConfig) => {
3869
+ if (!activeTabConfig?.form) {
3870
+ return null;
3871
+ }
3872
+
3873
+ return (
3874
+ <>
3875
+ {/* Render the "Edit" button if activeTabConfig.form exists */}
3876
+ {!inPlaceEditing ? (
3877
+ <button
3878
+ className={styles.btn}
3879
+ title={
3880
+ activeTabConfig.form
3881
+ .editLabel || 'Edit'
3882
+ }
3883
+ aria-label={
3884
+ activeTabConfig.form
3885
+ .editLabel || 'Edit'
3886
+ }
3887
+ onClick={() => {
3888
+ if (
3889
+ activeTabConfig.form
3890
+ .inPlaceEdit
3891
+ ) {
3892
+ startInPlaceEditing(
3893
+ activeTabConfig.id
3894
+ );
3895
+ } else {
3896
+ modalOpen(
3897
+ 'update',
3898
+ routeParams[
3899
+ urlParam
3900
+ ]
3901
+ );
3902
+ }
3903
+ }}
3904
+ >
3905
+ <Pencil
3906
+ size={18}
3907
+ strokeWidth={2.25}
3908
+ />
3909
+ <span>
3910
+ {activeTabConfig.form
3911
+ .editLabel ||
3912
+ 'Edit'}
3913
+ </span>
3914
+ </button>
3915
+ ) : (
3916
+ editingTabId ===
3917
+ activeTabConfig.id && (
3918
+ <>
3919
+ {/* Cancel is the way back out of editing, not a
3920
+ destructive act — it was painted in error red,
3921
+ the loudest thing on the page, next to the
3922
+ Save it competes with. Ghost secondary. */}
3923
+ <button
3924
+ className={
3925
+ styles.btnSecondary
3926
+ }
3927
+ onClick={
3928
+ cancelInPlaceEditing
3929
+ }
3930
+ >
3931
+ Cancel
3932
+ </button>
3933
+ <button
3934
+ className={
3935
+ styles.btn
3936
+ }
3937
+ onClick={() => {
3938
+ // Find the form's save button and click it
3939
+ const saveButton =
3940
+ document.querySelector(
3941
+ `.inPlaceEdit .${styles.saveBtn}`
3942
+ );
3943
+ if (
3944
+ saveButton
3945
+ ) {
3946
+ saveButton.click();
3947
+ } else {
3948
+ // Fallback to any button with data-type="save"
3949
+ const fallbackButton =
3950
+ document.querySelector(
3951
+ `.inPlaceEdit button[data-type="save"]`
3952
+ );
3953
+ if (
3954
+ fallbackButton
3955
+ ) {
3956
+ fallbackButton.click();
3957
+ }
3958
+ }
3959
+
3960
+ // Reset the editing state
3961
+ setInPlaceEditing(
3962
+ false
3963
+ );
3964
+ setEditingTabId(
3965
+ null
3966
+ );
3967
+
3968
+ // Reload data
3969
+ handleReload();
3970
+ }}
3971
+ >
3972
+ Save
3973
+ </button>
3974
+ </>
3975
+ )
3976
+ )}
3977
+
3978
+ {/* Check if sections contain "ocrTemplate" type and render the "Save" button under the same polActions */}
3979
+ {activeTabConfig.sections &&
3980
+ activeTabConfig.sections
3981
+ .length > 0 &&
3982
+ activeTabConfig.sections.map(
3983
+ (section, sectionIndex) =>
3984
+ section.content &&
3985
+ section.content.some(
3986
+ (item) =>
3987
+ item.type ===
3988
+ 'ocrTemplate'
3989
+ ) && (
3990
+ <button
3991
+ key={`save-button-${sectionIndex}`}
3992
+ className={
3993
+ styles.btn
3994
+ }
3995
+ onClick={() => {
3996
+ // Handle the specific "ocrTemplate" item
3997
+ const ocrItem =
3998
+ section.content.find(
3999
+ (
4000
+ item
4001
+ ) =>
4002
+ item.type ===
4003
+ 'ocrTemplate'
4004
+ );
4005
+ handleOcrTemplateSave(
4006
+ ocrItem.id
4007
+ ); // Now you can safely use ocrItem.id
4008
+ }}
4009
+ >
4010
+ Save
4011
+ </button>
4012
+ )
4013
+ )}
4014
+
4015
+ {/* Check if export exists and render the "Export" button under the same polActions */}
4016
+ {activeTabConfig.export && (
4017
+ <button
4018
+ className={styles.btn}
4019
+ onClick={() =>
4020
+ handleExport(
4021
+ activeTabConfig.export
4022
+ )
4023
+ }
4024
+ >
4025
+ {activeTabConfig.export
4026
+ .label || 'Export'}
4027
+ </button>
4028
+ )}
4029
+
4030
+ {activeTabConfig.customAction && (
4031
+ <button
4032
+ className={styles.btn}
4033
+ onClick={() =>
4034
+ handleCustomAction(
4035
+ activeTabConfig.customAction
4036
+ )
4037
+ }
4038
+ >
4039
+ {
4040
+ activeTabConfig
4041
+ .customAction.label
4042
+ }
4043
+ </button>
4044
+ )}
4045
+
4046
+ {activeTabConfig.customActions && Array.isArray(activeTabConfig.customActions) && activeTabConfig.customActions.map((customAction, index) => (
4047
+ <button
4048
+ key={index}
4049
+ className={styles.btn}
4050
+ onClick={() =>
4051
+ handleCustomAction(customAction)
4052
+ }
4053
+ >
4054
+ {getActionLabel(customAction)}
4055
+ </button>
4056
+ ))}
4057
+ </>
4058
+ );
4059
+ };
4060
+
4061
+ /** A table tab's "update the ticked rows" button. */
4062
+ const renderTableUpdateAction = (activeTabConfig) => {
4063
+ if (!activeTabConfig?.functions?.checkboxUpdate) {
4064
+ return null;
4065
+ }
4066
+
4067
+ return (
4068
+ <>
4069
+ <button
4070
+ className={styles.btn}
4071
+ onClick={
4072
+ handleCheckboxUpdate
4073
+ }
4074
+ >
4075
+ {activeTabConfig.functions
4076
+ .checkboxUpdate.label ||
4077
+ 'Update'}
4078
+ </button>
4079
+ </>
4080
+ );
4081
+ };
4082
+
4083
+ /** A table tab's bulk delete, and the custom actions beside it. */
4084
+ const renderTableBulkActions = (activeTabConfig) => {
4085
+ if (
4086
+ !(
4087
+ activeTabConfig?.functions?.checkboxDelete ||
4088
+ activeTabConfig?.customAction ||
4089
+ (activeTabConfig?.customActions &&
4090
+ activeTabConfig.customActions.length > 0)
4091
+ )
4092
+ ) {
4093
+ return null;
4094
+ }
4095
+
4096
+ return (
4097
+ <>
4098
+ {activeTabConfig.functions
4099
+ ?.checkboxDelete && (
4100
+ <button
4101
+ className={styles.btn}
4102
+ onClick={
4103
+ handleCheckboxDelete
4104
+ }
4105
+ >
4106
+ {activeTabConfig
4107
+ .functions
4108
+ .checkboxDelete
4109
+ .label || 'Delete'}
4110
+ </button>
4111
+ )}
4112
+
4113
+ {activeTabConfig.customAction && (
4114
+ <button
4115
+ className={styles.btn}
4116
+ onClick={() =>
4117
+ handleCustomAction(
4118
+ activeTabConfig.customAction
4119
+ )
4120
+ }
4121
+ >
4122
+ {
4123
+ activeTabConfig
4124
+ .customAction
4125
+ .label
4126
+ }
4127
+ </button>
4128
+ )}
4129
+
4130
+ {activeTabConfig.customActions && Array.isArray(activeTabConfig.customActions) && activeTabConfig.customActions.map((customAction, index) => (
4131
+ <button
4132
+ key={index}
4133
+ className={styles.btn}
4134
+ onClick={() =>
4135
+ handleCustomAction(customAction)
4136
+ }
4137
+ >
4138
+ {getActionLabel(customAction)}
4139
+ </button>
4140
+ ))}
4141
+ </>
4142
+ );
4143
+ };
4144
+
4145
+
4026
4146
  /**
4027
4147
  * Detail tabs are a horizontal strip above the content panel.
4028
4148
  *
@@ -4046,6 +4166,68 @@ function GenericDetail({
4046
4166
  */
4047
4167
  const tabsUnderlined = isFlatFilterList(filters);
4048
4168
 
4169
+ /**
4170
+ * The active tab's action cluster, when this page asked for it in the
4171
+ * title row. `null` on the floating default, so the title row is
4172
+ * byte-identical for every host that did not opt in.
4173
+ *
4174
+ * The active tab is worked out here rather than read off the
4175
+ * `activeTabConfig` STATE, and that is not a preference: the state is set
4176
+ * by an effect, so it is a render behind, and a header that lags one tab
4177
+ * behind the panel under it draws the previous tab's Edit button. The two
4178
+ * lines below are the same lookup `renderContent()` makes, in the same
4179
+ * order, on the same `subnav` — which also runs during THIS render, but
4180
+ * later in the tree than the title row.
4181
+ *
4182
+ * The tab TYPE gate mirrors the switch exactly, and deliberately so:
4183
+ * placement moves a cluster, it must never add one. An overview tab draws
4184
+ * the Edit cluster; a table tab draws its bulk actions, and only once its
4185
+ * grid config has arrived, which is the same `config.ajaxSetting &&
4186
+ * config.columns` test that decides whether the table case renders at all.
4187
+ * Every other tab type has never had a cluster and still does not.
4188
+ */
4189
+ const headerActions = (() => {
4190
+ if (!drawActionsInHeader || !subnav || subnav.length === 0) {
4191
+ return null;
4192
+ }
4193
+
4194
+ const activeTab = subnav.find((s) => s.show === true);
4195
+ const activeTabConfig = activeTab
4196
+ ? (tabs || []).find((t) => t.id === activeTab.id)
4197
+ : null;
4198
+
4199
+ if (!activeTabConfig || !activeTabConfig.type) {
4200
+ return null;
4201
+ }
4202
+
4203
+ if (activeTabConfig.type === 'overview') {
4204
+ return actionCluster(renderOverviewActions(activeTabConfig));
4205
+ }
4206
+
4207
+ if (
4208
+ activeTabConfig.type === 'table' &&
4209
+ config.ajaxSetting &&
4210
+ config.columns
4211
+ ) {
4212
+ const update = renderTableUpdateAction(activeTabConfig);
4213
+ const bulk = renderTableBulkActions(activeTabConfig);
4214
+
4215
+ /* One cluster, not two. Pinned bottom-right these are separate
4216
+ bars stacked at the same coordinates; on a single row they are
4217
+ simply buttons beside each other. */
4218
+ return update || bulk
4219
+ ? actionCluster(
4220
+ <>
4221
+ {update}
4222
+ {bulk}
4223
+ </>
4224
+ )
4225
+ : null;
4226
+ }
4227
+
4228
+ return null;
4229
+ })();
4230
+
4049
4231
  return (
4050
4232
  <>
4051
4233
  <div className={styles.grid}>
@@ -4058,8 +4240,9 @@ function GenericDetail({
4058
4240
  }`}
4059
4241
  >
4060
4242
  <Breadcrumb data={data} page={page} />
4061
- {(actions || total > 0) && (
4243
+ {(actions || total > 0 || headerActions) && (
4062
4244
  <div
4245
+ className={styles.titleActions}
4063
4246
  style={{
4064
4247
  display: 'flex',
4065
4248
  alignItems: 'center',
@@ -4074,6 +4257,10 @@ function GenericDetail({
4074
4257
  </div>
4075
4258
  )}
4076
4259
  {actions}
4260
+ {/* Last, so a host's own `actions` keep the
4261
+ position they have always had and the
4262
+ record's Edit sits outermost right. */}
4263
+ {headerActions}
4077
4264
  </div>
4078
4265
  )}
4079
4266
  </div>