@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 4.5.1-next.4

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 (73) hide show
  1. package/README.md +133 -4
  2. package/app/api/automations.ts +74 -0
  3. package/app/api/index.ts +1 -0
  4. package/app/api/messageGroups.ts +5 -1
  5. package/app/app.config.ts +10 -0
  6. package/app/components/Automations/ActionCard.vue +174 -0
  7. package/app/components/Automations/ActionEditor.vue +398 -0
  8. package/app/components/Automations/Actions.vue +148 -0
  9. package/app/components/Automations/CloneModal.vue +78 -0
  10. package/app/components/Automations/Conditions.vue +155 -0
  11. package/app/components/Automations/Create.vue +114 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +80 -0
  14. package/app/components/Automations/Edit.vue +145 -0
  15. package/app/components/Automations/Form.vue +282 -0
  16. package/app/components/Automations/List.vue +370 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +54 -0
  19. package/app/components/Automations/Unavailable.vue +71 -0
  20. package/app/components/Automations/UnavailableBadge.vue +35 -0
  21. package/app/components/Common/MessageGroupSelect.vue +30 -1
  22. package/app/components/Composer/EmailComposer.vue +5 -1
  23. package/app/components/Composer/WabaSelectModal.vue +3 -1
  24. package/app/components/Composer/WhatsappComposer.vue +5 -1
  25. package/app/components/Filters/DateInput.vue +57 -0
  26. package/app/components/Filters/Editor.vue +163 -0
  27. package/app/components/Filters/Row.vue +119 -0
  28. package/app/components/Filters/Summary.vue +215 -0
  29. package/app/components/Filters/UnavailableRow.vue +74 -0
  30. package/app/components/Filters/ValueInput.vue +215 -0
  31. package/app/components/Layout/BasePage.vue +2 -0
  32. package/app/components/TemplateList/BaseTable.vue +1 -22
  33. package/app/composables/apiErrorMessages.ts +1 -0
  34. package/app/composables/automationsContext.ts +42 -0
  35. package/app/composables/composerContext.ts +3 -3
  36. package/app/composables/useAutomationClone.ts +33 -0
  37. package/app/composables/useAutomationEditorTrip.ts +77 -0
  38. package/app/composables/useAutomationSaveProblems.ts +82 -0
  39. package/app/composables/useAutomationUnavailable.ts +98 -0
  40. package/app/composables/useAutomationsGate.ts +68 -0
  41. package/app/composables/useBuiltInOptionLists.ts +44 -0
  42. package/app/composables/useEditorTripBack.ts +15 -0
  43. package/app/composables/useMessageGroupWaba.ts +7 -10
  44. package/app/composables/useTemplateOperations.ts +8 -3
  45. package/app/middleware/automations-enabled.ts +12 -0
  46. package/app/pages/automations/[id].vue +87 -0
  47. package/app/pages/automations/index.vue +28 -0
  48. package/app/pages/automations/new.vue +53 -0
  49. package/app/pages/template/email/[id].vue +5 -0
  50. package/app/pages/template/email/create.vue +5 -0
  51. package/app/pages/template/whatsapp/[id].vue +5 -0
  52. package/app/pages/template/whatsapp/create.vue +5 -0
  53. package/app/queries/automations.ts +206 -0
  54. package/app/queries/index.ts +1 -0
  55. package/app/queries/messageGroups.ts +1 -3
  56. package/app/stores/appContext.ts +1 -1
  57. package/app/stores/editorTrip.ts +67 -0
  58. package/app/types/app-config.d.ts +3 -0
  59. package/app/types/tracking.ts +33 -0
  60. package/app/utils/automations/form.ts +389 -0
  61. package/app/utils/automations/metadata.ts +71 -0
  62. package/app/utils/automations/unavailable.ts +90 -0
  63. package/app/utils/filters/data/countries.json +998 -0
  64. package/app/utils/filters/data/languages.json +754 -0
  65. package/app/utils/filters/rows.ts +367 -0
  66. package/app/utils/testIds.const.ts +107 -1
  67. package/i18n/locales/de.ts +269 -1
  68. package/i18n/locales/en.ts +255 -1
  69. package/i18n/locales/es.ts +258 -1
  70. package/i18n/locales/fr.ts +264 -1
  71. package/i18n/locales/it.ts +260 -1
  72. package/package.json +4 -4
  73. package/app.config.ts +0 -5
@@ -0,0 +1,367 @@
1
+ import { CONDITION_OPERATORS_BY_FIELD_TYPE } from '@dev.smartpricing/message-composer-utils/types'
2
+ import type {
3
+ Condition,
4
+ ConditionFieldType,
5
+ ConditionOperator,
6
+ FilterField,
7
+ FilterOptionValue,
8
+ FilterUnavailableReason,
9
+ FiltersReport,
10
+ } from '@dev.smartpricing/message-composer-utils/types'
11
+
12
+ /**
13
+ * The filter components' own model (ADR 0018): a list of Conditions seen as
14
+ * rows, one per field. Internal: "between" as one row, prices in units and
15
+ * the rows themselves never leave the components.
16
+ */
17
+
18
+ /**
19
+ * The operators of a row: the engine's, plus "between", a pair of Conditions.
20
+ * There is no "not between": the engine holds all the Conditions of a list
21
+ * together, and no pair of them says "outside this range".
22
+ */
23
+ export type FilterRowOperator = ConditionOperator | 'between'
24
+
25
+ /** Select: one option. Multiselect: a list. Number, price: a number. Date: `YYYY-MM-DD`. Text: a string. */
26
+ export type FilterRowValue = FilterOptionValue | FilterOptionValue[] | undefined
27
+
28
+ /** A row with no field picked yet. Never emitted, and not half-filled either. */
29
+ export interface FilterBlankRow {
30
+ kind: 'blank'
31
+ id: number
32
+ }
33
+
34
+ export interface FilterEditableRow {
35
+ kind: 'editable'
36
+ id: number
37
+ field: string
38
+ type: ConditionFieldType
39
+ operator: FilterRowOperator
40
+ /** The value, or the lower bound of "between". A price is in units, not cents. */
41
+ value: FilterRowValue
42
+ /** The upper bound of "between". */
43
+ to: FilterRowValue
44
+ /**
45
+ * The Conditions the row was read from, until the user edits it: an
46
+ * untouched row is emitted exactly as it came in.
47
+ */
48
+ original?: Condition[]
49
+ }
50
+
51
+ /** A Condition the editor cannot edit: shown locked, emitted as it is. */
52
+ export interface FilterUnavailableRow {
53
+ kind: 'unavailable'
54
+ id: number
55
+ reason: FilterUnavailableReason
56
+ condition: Condition
57
+ }
58
+
59
+ export type FilterRow = FilterBlankRow | FilterEditableRow | FilterUnavailableRow
60
+
61
+ const ORDERED_TYPES: readonly ConditionFieldType[] = ['number', 'price', 'date']
62
+
63
+ function isOrdered(type: ConditionFieldType): boolean {
64
+ return ORDERED_TYPES.includes(type)
65
+ }
66
+
67
+ /** The operators a row on a field of this type offers, in display order. */
68
+ export function filterRowOperators(type: ConditionFieldType): FilterRowOperator[] {
69
+ const operators: FilterRowOperator[] = [...CONDITION_OPERATORS_BY_FIELD_TYPE[type]]
70
+ if (!isOrdered(type)) return operators
71
+ // "Is set" stays last.
72
+ operators.splice(operators.indexOf('exists'), 0, 'between')
73
+ return operators
74
+ }
75
+
76
+ export function isFilterRange(operator: FilterRowOperator): boolean {
77
+ return operator === 'between'
78
+ }
79
+
80
+ function isOptionValue(value: unknown): value is FilterOptionValue {
81
+ return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))
82
+ }
83
+
84
+ function isIsoDate(value: unknown): value is string {
85
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
86
+ const date = new Date(`${value}T00:00:00Z`)
87
+ // "2026-02-31" parses, as the 3rd of March.
88
+ return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value)
89
+ }
90
+
91
+ /** The row value of a stored one, or `undefined` when it does not fit the type. */
92
+ function readValue(type: ConditionFieldType, value: unknown): FilterRowValue {
93
+ switch (type) {
94
+ case 'select':
95
+ return isOptionValue(value) ? value : undefined
96
+ case 'multiselect':
97
+ return Array.isArray(value) && value.every(isOptionValue) ? [...value] : undefined
98
+ case 'number':
99
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
100
+ case 'price':
101
+ return typeof value === 'number' && Number.isFinite(value) ? value / 100 : undefined
102
+ case 'date':
103
+ return isIsoDate(value) ? value : undefined
104
+ case 'text':
105
+ return typeof value === 'string' ? value : undefined
106
+ }
107
+ }
108
+
109
+ function writeValue(type: ConditionFieldType, value: FilterRowValue): unknown {
110
+ return type === 'price' ? Math.round((value as number) * 100) : value
111
+ }
112
+
113
+ function isComplete(type: ConditionFieldType, value: FilterRowValue): boolean {
114
+ switch (type) {
115
+ case 'select':
116
+ return isOptionValue(value) && value !== ''
117
+ case 'multiselect':
118
+ return Array.isArray(value) && value.length > 0
119
+ case 'number':
120
+ case 'price':
121
+ return typeof value === 'number' && Number.isFinite(value)
122
+ case 'date':
123
+ return isIsoDate(value)
124
+ case 'text':
125
+ return typeof value === 'string' && value !== ''
126
+ }
127
+ }
128
+
129
+ /** Numbers compare as numbers, ISO dates as strings. */
130
+ function isAtMost(a: FilterRowValue, b: FilterRowValue): boolean {
131
+ return (a as number) <= (b as number)
132
+ }
133
+
134
+ type RowContent = Pick<FilterEditableRow, 'operator' | 'value' | 'to'>
135
+
136
+ function readSingle(field: FilterField, condition: Condition): RowContent | undefined {
137
+ const operators: readonly ConditionOperator[] = CONDITION_OPERATORS_BY_FIELD_TYPE[field.type]
138
+ if (!operators.includes(condition.operator)) return undefined
139
+ if (condition.operator === 'exists') {
140
+ return { operator: 'exists', value: undefined, to: undefined }
141
+ }
142
+
143
+ const value = readValue(field.type, condition.value)
144
+ if (value === undefined) return undefined
145
+ return { operator: condition.operator, value, to: undefined }
146
+ }
147
+
148
+ /**
149
+ * "Between" is `gte` the lower bound and `lte` the upper one. A `gte` above
150
+ * its `lte` is no range: the two are read one by one.
151
+ */
152
+ function readPair(
153
+ field: FilterField,
154
+ first: Condition | undefined,
155
+ second: Condition | undefined,
156
+ ): RowContent | undefined {
157
+ if (!first || !second || !isOrdered(field.type)) return undefined
158
+
159
+ const gte = [first, second].find((condition) => condition.operator === 'gte')
160
+ const lte = [first, second].find((condition) => condition.operator === 'lte')
161
+ if (!gte || !lte) return undefined
162
+
163
+ const from = readValue(field.type, gte.value)
164
+ const until = readValue(field.type, lte.value)
165
+ if (from === undefined || until === undefined) return undefined
166
+
167
+ return isAtMost(from, until) ? { operator: 'between', value: from, to: until } : undefined
168
+ }
169
+
170
+ /**
171
+ * The rows of a list of Conditions, in the order the Conditions are in. No
172
+ * Condition is left out: one the editor cannot edit is an unavailable row.
173
+ * A field gets one editable row, from its first two Conditions when they are
174
+ * a "between", else from the first one that can be edited.
175
+ */
176
+ export function conditionsToFilterRows(
177
+ conditions: readonly Condition[],
178
+ fields: readonly FilterField[],
179
+ nextId: () => number,
180
+ ): FilterRow[] {
181
+ const fieldsByKey = new Map(fields.map((field) => [field.key, field]))
182
+
183
+ const indexesByField = new Map<string, number[]>()
184
+ conditions.forEach((condition, index) => {
185
+ const indexes = indexesByField.get(condition.field) ?? []
186
+ indexes.push(index)
187
+ indexesByField.set(condition.field, indexes)
188
+ })
189
+
190
+ // By the index of the Condition a row starts at; `null` for the second of a pair.
191
+ const editable = new Map<number, (RowContent & { original: Condition[] }) | null>()
192
+ for (const [key, indexes] of indexesByField) {
193
+ const field = fieldsByKey.get(key)
194
+ if (!field) continue
195
+
196
+ const [first, second] = indexes as [number, number | undefined]
197
+ const pair = readPair(
198
+ field,
199
+ conditions[first],
200
+ second === undefined ? undefined : conditions[second],
201
+ )
202
+ if (pair && second !== undefined) {
203
+ editable.set(first, { ...pair, original: [conditions[first]!, conditions[second]!] })
204
+ editable.set(second, null)
205
+ continue
206
+ }
207
+
208
+ for (const index of indexes) {
209
+ const single = readSingle(field, conditions[index]!)
210
+ if (!single) continue
211
+ editable.set(index, { ...single, original: [conditions[index]!] })
212
+ break
213
+ }
214
+ }
215
+
216
+ const rows: FilterRow[] = []
217
+ conditions.forEach((condition, index) => {
218
+ const content = editable.get(index)
219
+ if (content === null) return
220
+
221
+ const field = fieldsByKey.get(condition.field)
222
+ if (content && field) {
223
+ rows.push({ kind: 'editable', id: nextId(), field: field.key, type: field.type, ...content })
224
+ return
225
+ }
226
+ rows.push({
227
+ kind: 'unavailable',
228
+ id: nextId(),
229
+ reason: field ? 'not_editable' : 'unknown_field',
230
+ condition,
231
+ })
232
+ })
233
+ return rows
234
+ }
235
+
236
+ /** The Conditions of an editable row; `null` while it is half-filled. */
237
+ export function filterRowToConditions(row: FilterEditableRow): Condition[] | null {
238
+ if (row.original) return row.original
239
+
240
+ const { field, type, operator } = row
241
+ if (operator === 'exists') return [{ field, operator }]
242
+
243
+ if (!isFilterRange(operator)) {
244
+ if (!isComplete(type, row.value)) return null
245
+ return [{ field, operator: operator as ConditionOperator, value: writeValue(type, row.value) }]
246
+ }
247
+
248
+ if (!isComplete(type, row.value) || !isComplete(type, row.to)) return null
249
+ // Whichever way round the user typed them.
250
+ const [lower, upper] = isAtMost(row.value, row.to) ? [row.value, row.to] : [row.to, row.value]
251
+ return [
252
+ { field, operator: 'gte', value: writeValue(type, lower) },
253
+ { field, operator: 'lte', value: writeValue(type, upper) },
254
+ ]
255
+ }
256
+
257
+ /** What the rows are worth as Conditions. Blank and half-filled rows are left out. */
258
+ export function filterRowsToConditions(rows: readonly FilterRow[]): Condition[] {
259
+ return rows.flatMap((row) => {
260
+ if (row.kind === 'unavailable') return [row.condition]
261
+ if (row.kind === 'editable') return filterRowToConditions(row) ?? []
262
+ return []
263
+ })
264
+ }
265
+
266
+ /** The option values a row holds that its field's `options` do not list. */
267
+ export function missingFilterOptions(
268
+ row: FilterEditableRow,
269
+ field: FilterField | undefined,
270
+ ): FilterOptionValue[] {
271
+ if (row.operator === 'exists') return []
272
+ if (row.type !== 'select' && row.type !== 'multiselect') return []
273
+
274
+ const known = new Set((field?.options ?? []).map((option) => option.value))
275
+ const selected = Array.isArray(row.value) ? row.value : [row.value]
276
+ return selected.filter(
277
+ (value): value is FilterOptionValue => value !== undefined && value !== '' && !known.has(value),
278
+ )
279
+ }
280
+
281
+ export function reportFilterRows(
282
+ rows: readonly FilterRow[],
283
+ fields: readonly FilterField[],
284
+ ): FiltersReport {
285
+ const fieldsByKey = new Map(fields.map((field) => [field.key, field]))
286
+ const report: FiltersReport = {
287
+ unknownFields: [],
288
+ unavailable: [],
289
+ unavailableOptions: [],
290
+ halfFilled: 0,
291
+ }
292
+
293
+ for (const row of rows) {
294
+ if (row.kind === 'unavailable') {
295
+ report.unavailable.push({ condition: row.condition, reason: row.reason })
296
+ const { field } = row.condition
297
+ if (row.reason === 'unknown_field' && !report.unknownFields.includes(field)) {
298
+ report.unknownFields.push(field)
299
+ }
300
+ } else if (row.kind === 'editable') {
301
+ if (filterRowToConditions(row) === null) report.halfFilled += 1
302
+ for (const value of missingFilterOptions(row, fieldsByKey.get(row.field))) {
303
+ report.unavailableOptions.push({ field: row.field, value })
304
+ }
305
+ }
306
+ }
307
+ return report
308
+ }
309
+
310
+ /** A row on a field the user just picked: its first operator, no value. */
311
+ export function newFilterRow(id: number, field: FilterField): FilterEditableRow {
312
+ return {
313
+ kind: 'editable',
314
+ id,
315
+ field: field.key,
316
+ type: field.type,
317
+ operator: filterRowOperators(field.type)[0]!,
318
+ value: field.type === 'multiselect' ? [] : undefined,
319
+ to: undefined,
320
+ }
321
+ }
322
+
323
+ /** The row under another operator: a value is kept wherever it still means something. */
324
+ export function withFilterOperator(
325
+ row: FilterEditableRow,
326
+ operator: FilterRowOperator,
327
+ ): FilterEditableRow {
328
+ return {
329
+ ...row,
330
+ operator,
331
+ to: isFilterRange(operator) ? row.to : undefined,
332
+ original: undefined,
333
+ }
334
+ }
335
+
336
+ /** The locale key of an operator's label, under `filters.operators`. */
337
+ export const FILTER_OPERATOR_LABEL_KEYS: Record<FilterRowOperator, string> = {
338
+ eq: 'is',
339
+ neq: 'is_not',
340
+ gt: 'is_more',
341
+ gte: 'is_at_least',
342
+ lt: 'is_less',
343
+ lte: 'is_at_most',
344
+ between: 'is_between',
345
+ in: 'in',
346
+ not_in: 'not_in',
347
+ contains: 'contains',
348
+ exclude: 'does_not_contain',
349
+ exists: 'is_set',
350
+ }
351
+
352
+ /** A stored value the components cannot label, as it is. */
353
+ export function rawConditionValue(value: unknown): string {
354
+ if (value === undefined) return ''
355
+ return typeof value === 'string' ? value : JSON.stringify(value)
356
+ }
357
+
358
+ /** The currency to show prices in: the caller's, or EUR when `Intl` does not take it. */
359
+ export function filterCurrency(currency: string | undefined): string {
360
+ if (!currency) return 'EUR'
361
+ try {
362
+ new Intl.NumberFormat('en', { style: 'currency', currency })
363
+ return currency
364
+ } catch {
365
+ return 'EUR'
366
+ }
367
+ }
@@ -519,7 +519,6 @@ export const templateListTableTestIds = {
519
519
  paginationSummary: 'template-list-table-pagination-summary',
520
520
  card: 'template-list-table-card',
521
521
  table: 'template-list-table-table',
522
- loading: 'template-list-table-loading',
523
522
  rowName: 'template-list-table-row-name',
524
523
  rowInternalBadge: 'template-list-table-row-internal-badge',
525
524
  rowCategoryBadge: 'template-list-table-row-category-badge',
@@ -785,3 +784,110 @@ export const messageSendFlowTestIds = {
785
784
  cancel: 'message-send-flow-cancel',
786
785
  confirm: 'message-send-flow-confirm',
787
786
  } as const
787
+
788
+ /**
789
+ * The Automation pages. The values are the ones Smartconnect's own pages used
790
+ * (`automationsTestIds` in its `testIds.const.ts`): host e2e suites target them.
791
+ */
792
+ export const automationsTestIds = {
793
+ // List page
794
+ page: 'automations-page',
795
+ newButton: 'automations-new-button',
796
+
797
+ // List
798
+ searchInput: 'automations-search-input',
799
+ filterTrigger: 'automations-filter-trigger',
800
+ filterStatus: 'automations-filter-status',
801
+ filterChannel: 'automations-filter-channel',
802
+ table: 'automations-table',
803
+ rowActionsButton: 'automations-row-actions-button',
804
+ editAction: 'automations-edit-action',
805
+ duplicateAction: 'automations-duplicate-action',
806
+ deleteAction: 'automations-delete-action',
807
+ channelsCell: 'automations-channels-cell',
808
+ statusCell: 'automations-status-cell',
809
+
810
+ // Delete modal
811
+ deleteModal: 'automations-delete-modal',
812
+ deleteModalCloseButton: 'automations-delete-modal-close-button',
813
+ deleteModalCancel: 'automations-delete-modal-cancel',
814
+ deleteModalConfirm: 'automations-delete-modal-confirm',
815
+ deleteModalMessages: 'automations-delete-modal-messages',
816
+
817
+ // New page
818
+ newPage: 'automations-new-page',
819
+ newPageCancelButton: 'automations-new-page-cancel-button',
820
+ newPageCreateButton: 'automations-new-page-create-button',
821
+
822
+ // Edit page
823
+ editPage: 'automations-edit-page',
824
+ editPageCancelButton: 'automations-edit-page-cancel-button',
825
+ editPageCreateButton: 'automations-edit-page-create-button',
826
+
827
+ // Form
828
+ form: 'automations-form',
829
+ errorAlert: 'automations-error-alert',
830
+ nameInput: 'automations-name-input',
831
+ triggerSelect: 'automations-trigger-select',
832
+
833
+ // Send to (audience). The rows of the slideover are `audience-filter-*`.
834
+ sendToSummary: 'automations-send-to-summary',
835
+ addFiltersButton: 'automations-add-filters-button',
836
+ editFiltersButton: 'automations-edit-filters-button',
837
+ filtersSlideover: 'automations-filters-slideover',
838
+ filtersSlideoverCloseButton: 'automations-filters-slideover-close-button',
839
+ filtersSlideoverCancel: 'automations-filters-slideover-cancel',
840
+ filtersSlideoverSave: 'automations-filters-slideover-save',
841
+
842
+ // Actions list
843
+ actionsEmpty: 'automations-actions-empty',
844
+ addChannelButton: 'automations-add-channel-button',
845
+
846
+ // Action card (suffixed with `-${action.key}`, which is also on `data-testvalue`)
847
+ actionCard: 'automations-action-card',
848
+ actionEnabledSwitch: 'automations-action-enabled-switch',
849
+ actionDeleteButton: 'automations-action-delete-button',
850
+ actionPreviewButton: 'automations-action-preview-button',
851
+ actionEditButton: 'automations-action-edit-button',
852
+
853
+ // Message slideover. The message picker is `messageGroupSelectTestIds.root`.
854
+ channelSlideover: 'automations-channel-slideover',
855
+ channelSlideoverCloseButton: 'automations-channel-slideover-close-button',
856
+ channelTypeSelect: 'automations-channel-type-select',
857
+ channelDaysInput: 'automations-channel-days-input',
858
+ channelDateRefSelect: 'automations-channel-date-ref-select',
859
+ channelSendingTimeSelect: 'automations-channel-sending-time-select',
860
+ channelCreateNewButton: 'automations-channel-create-new-button',
861
+ channelEditTemplateButton: 'automations-channel-edit-template-button',
862
+ channelPreview: 'automations-channel-preview',
863
+ channelAdvancedCollapse: 'automations-channel-advanced-collapse',
864
+ channelAdvancedCollapseButton: 'automations-channel-advanced-collapse-button',
865
+ channelRecurrenceSelect: 'automations-channel-recurrence-select',
866
+ channelStrictDelaySwitch: 'automations-channel-strict-delay-switch',
867
+ channelPreventDuplicatesSwitch: 'automations-channel-prevent-duplicates-switch',
868
+ channelSlideoverCancel: 'automations-channel-slideover-cancel',
869
+ channelSlideoverSave: 'automations-channel-slideover-save',
870
+
871
+ // Preview slideover (action card)
872
+ previewSlideover: 'automations-preview-slideover',
873
+ previewSlideoverCloseButton: 'automations-preview-slideover-close-button',
874
+
875
+ // System Automation
876
+ lockedBadge: 'automations-locked-badge',
877
+ lockedAlert: 'automations-locked-alert',
878
+ lockedDuplicateButton: 'automations-locked-duplicate-button',
879
+
880
+ // Clone modal
881
+ cloneModal: 'automations-clone-modal',
882
+ cloneModalNameInput: 'automations-clone-modal-name-input',
883
+ cloneModalConfirm: 'automations-clone-modal-confirm',
884
+ cloneModalCancel: 'automations-clone-modal-cancel',
885
+ } as const
886
+
887
+ /**
888
+ * The `content` of a UModal or a USlideover that puts a test id on the dialog:
889
+ * the two render no attribute of their own, and `content` types no `data-*`.
890
+ */
891
+ export function dialogTestId(testId: string): object {
892
+ return { 'data-testid': testId }
893
+ }