@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.
- package/README.md +133 -4
- package/app/api/automations.ts +74 -0
- package/app/api/index.ts +1 -0
- package/app/api/messageGroups.ts +5 -1
- package/app/app.config.ts +10 -0
- package/app/components/Automations/ActionCard.vue +174 -0
- package/app/components/Automations/ActionEditor.vue +398 -0
- package/app/components/Automations/Actions.vue +148 -0
- package/app/components/Automations/CloneModal.vue +78 -0
- package/app/components/Automations/Conditions.vue +155 -0
- package/app/components/Automations/Create.vue +114 -0
- package/app/components/Automations/Data.vue +46 -0
- package/app/components/Automations/DeleteModal.vue +80 -0
- package/app/components/Automations/Edit.vue +145 -0
- package/app/components/Automations/Form.vue +282 -0
- package/app/components/Automations/List.vue +370 -0
- package/app/components/Automations/PageGate.vue +50 -0
- package/app/components/Automations/SaveProblems.vue +54 -0
- package/app/components/Automations/Unavailable.vue +71 -0
- package/app/components/Automations/UnavailableBadge.vue +35 -0
- package/app/components/Common/MessageGroupSelect.vue +30 -1
- package/app/components/Composer/EmailComposer.vue +5 -1
- package/app/components/Composer/WabaSelectModal.vue +3 -1
- package/app/components/Composer/WhatsappComposer.vue +5 -1
- package/app/components/Filters/DateInput.vue +57 -0
- package/app/components/Filters/Editor.vue +163 -0
- package/app/components/Filters/Row.vue +119 -0
- package/app/components/Filters/Summary.vue +215 -0
- package/app/components/Filters/UnavailableRow.vue +74 -0
- package/app/components/Filters/ValueInput.vue +215 -0
- package/app/components/Layout/BasePage.vue +2 -0
- package/app/components/TemplateList/BaseTable.vue +1 -22
- package/app/composables/apiErrorMessages.ts +1 -0
- package/app/composables/automationsContext.ts +42 -0
- package/app/composables/composerContext.ts +3 -3
- package/app/composables/useAutomationClone.ts +33 -0
- package/app/composables/useAutomationEditorTrip.ts +77 -0
- package/app/composables/useAutomationSaveProblems.ts +82 -0
- package/app/composables/useAutomationUnavailable.ts +98 -0
- package/app/composables/useAutomationsGate.ts +68 -0
- package/app/composables/useBuiltInOptionLists.ts +44 -0
- package/app/composables/useEditorTripBack.ts +15 -0
- package/app/composables/useMessageGroupWaba.ts +7 -10
- package/app/composables/useTemplateOperations.ts +8 -3
- package/app/middleware/automations-enabled.ts +12 -0
- package/app/pages/automations/[id].vue +87 -0
- package/app/pages/automations/index.vue +28 -0
- package/app/pages/automations/new.vue +53 -0
- package/app/pages/template/email/[id].vue +5 -0
- package/app/pages/template/email/create.vue +5 -0
- package/app/pages/template/whatsapp/[id].vue +5 -0
- package/app/pages/template/whatsapp/create.vue +5 -0
- package/app/queries/automations.ts +206 -0
- package/app/queries/index.ts +1 -0
- package/app/queries/messageGroups.ts +1 -3
- package/app/stores/appContext.ts +1 -1
- package/app/stores/editorTrip.ts +67 -0
- package/app/types/app-config.d.ts +3 -0
- package/app/types/tracking.ts +33 -0
- package/app/utils/automations/form.ts +389 -0
- package/app/utils/automations/metadata.ts +71 -0
- package/app/utils/automations/unavailable.ts +90 -0
- package/app/utils/filters/data/countries.json +998 -0
- package/app/utils/filters/data/languages.json +754 -0
- package/app/utils/filters/rows.ts +367 -0
- package/app/utils/testIds.const.ts +107 -1
- package/i18n/locales/de.ts +269 -1
- package/i18n/locales/en.ts +255 -1
- package/i18n/locales/es.ts +258 -1
- package/i18n/locales/fr.ts +264 -1
- package/i18n/locales/it.ts +260 -1
- package/package.json +4 -4
- package/app.config.ts +0 -5
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import { EDITOR_ROUTES } from '~/stores/editorTrip'
|
|
3
|
+
import {
|
|
4
|
+
cloneFormData,
|
|
5
|
+
type AutomationFormAction,
|
|
6
|
+
type AutomationFormState,
|
|
7
|
+
} from '~/utils/automations/form'
|
|
8
|
+
|
|
9
|
+
/** The Action in the Action editor. */
|
|
10
|
+
export interface OpenAutomationAction {
|
|
11
|
+
action: AutomationFormAction
|
|
12
|
+
/** Whether the form does not hold it yet. */
|
|
13
|
+
isNew: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A request to leave for the Message editor: a new Message Group, or the Action's own. */
|
|
17
|
+
export interface AutomationMessageRequest extends OpenAutomationAction {
|
|
18
|
+
target: 'create' | 'edit'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What an Automation page wants back from an editor trip. */
|
|
22
|
+
interface AutomationTripKept {
|
|
23
|
+
/** The unsaved Automation. */
|
|
24
|
+
form: AutomationFormState
|
|
25
|
+
/** The Automation as the Automation Engine holds it; none on the create page. */
|
|
26
|
+
stored?: Automation
|
|
27
|
+
openAction: OpenAutomationAction
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The Automation side of an editor trip: leaving for the Message editor with
|
|
32
|
+
* the unsaved Automation kept, and getting it back on return with the Action
|
|
33
|
+
* reopened and the Message Group just saved selected in it.
|
|
34
|
+
*
|
|
35
|
+
* Called once, in the setup of the component that holds the form: a trip that
|
|
36
|
+
* started on `origin` is consumed there.
|
|
37
|
+
*/
|
|
38
|
+
export function useAutomationEditorTrip(
|
|
39
|
+
origin: 'automation-create' | 'automation-edit',
|
|
40
|
+
automationId?: string,
|
|
41
|
+
) {
|
|
42
|
+
const { t } = useI18n()
|
|
43
|
+
const toast = useToast()
|
|
44
|
+
const editorTrip = useEditorTripStore()
|
|
45
|
+
|
|
46
|
+
const trip = editorTrip.consume<AutomationTripKept>(origin)
|
|
47
|
+
// The edit page of another Automation is not where the trip started.
|
|
48
|
+
const returned = trip && trip.kept.stored?.id === automationId ? trip : undefined
|
|
49
|
+
|
|
50
|
+
let restored: AutomationTripKept | undefined
|
|
51
|
+
if (returned) {
|
|
52
|
+
const { kept, result } = returned
|
|
53
|
+
// Only the Message Group changes: an Action's status is the user's to set.
|
|
54
|
+
const action = result
|
|
55
|
+
? { ...kept.openAction.action, messageGroupId: result.id, messageGroupName: result.name }
|
|
56
|
+
: kept.openAction.action
|
|
57
|
+
restored = { ...kept, openAction: { ...kept.openAction, action } }
|
|
58
|
+
toast.add({ title: t('automations.trip.kept'), color: 'info' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Leaves for the Message editor, keeping the unsaved Automation and the open Action. */
|
|
62
|
+
function leave(
|
|
63
|
+
unsaved: { form: AutomationFormState; stored?: Automation },
|
|
64
|
+
{ action, isNew, target }: AutomationMessageRequest,
|
|
65
|
+
) {
|
|
66
|
+
editorTrip.start<AutomationTripKept>(
|
|
67
|
+
{ routeName: origin, backLabel: t('automations.trip.back') },
|
|
68
|
+
{ ...cloneFormData(unsaved), openAction: { action: cloneFormData(action), isNew } },
|
|
69
|
+
)
|
|
70
|
+
const name = EDITOR_ROUTES[action.channel][target]
|
|
71
|
+
return navigateTo(
|
|
72
|
+
target === 'edit' ? { name, params: { id: action.messageGroupId } } : { name },
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { restored, leave }
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutomationCatalog,
|
|
3
|
+
AutomationSaveProblem,
|
|
4
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
5
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
6
|
+
import type { AutomationFormState } from '~/utils/automations/form'
|
|
7
|
+
|
|
8
|
+
/** One line of the alert: what is wrong and, when it has one, the place to jump to. */
|
|
9
|
+
export interface AutomationProblemLine {
|
|
10
|
+
text: string
|
|
11
|
+
/** The id of the element the line is about. */
|
|
12
|
+
target?: string
|
|
13
|
+
actionKey?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a refused save (422 `AUTOMATION_ACTIONS_INVALID`) says at the top of
|
|
18
|
+
* the Automation form, on the create page and on the edit page alike.
|
|
19
|
+
* `AutomationsSaveProblems` renders the lines.
|
|
20
|
+
*/
|
|
21
|
+
export function useAutomationSaveProblems(catalog: MaybeRefOrGetter<AutomationCatalog>) {
|
|
22
|
+
const { t, te } = useI18n()
|
|
23
|
+
|
|
24
|
+
const lines = ref<AutomationProblemLine[]>([])
|
|
25
|
+
const invalidActionKeys = computed(() =>
|
|
26
|
+
lines.value.flatMap(({ actionKey }) => (actionKey ? [actionKey] : [])),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The line of one problem. A problem points at an Action by its position
|
|
31
|
+
* in what was submitted; `undefined` for a problem this version does not
|
|
32
|
+
* know.
|
|
33
|
+
*/
|
|
34
|
+
function toLine(
|
|
35
|
+
problem: AutomationSaveProblem,
|
|
36
|
+
sent: AutomationFormState,
|
|
37
|
+
): AutomationProblemLine | undefined {
|
|
38
|
+
if (problem.target === 'trigger') {
|
|
39
|
+
return {
|
|
40
|
+
text: t('automations.problems.trigger_not_in_catalog'),
|
|
41
|
+
target: 'automation-trigger',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (problem.target === 'condition') {
|
|
46
|
+
const key = `automations.problems.condition_${problem.reason}`
|
|
47
|
+
const label =
|
|
48
|
+
toValue(catalog)
|
|
49
|
+
.triggers.find(({ type }) => type === sent.triggerType)
|
|
50
|
+
?.condition_fields.find((field) => field.key === problem.field)?.label ?? problem.field
|
|
51
|
+
return te(key)
|
|
52
|
+
? { text: t(key, { field: label }), target: 'automation-conditions' }
|
|
53
|
+
: undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (problem.target === 'action') {
|
|
57
|
+
const key = `automations.problems.action_${problem.reason}`
|
|
58
|
+
const action = sent.actions[problem.position]
|
|
59
|
+
if (!action || !te(key)) return undefined
|
|
60
|
+
return {
|
|
61
|
+
text: t(key, { name: action.messageGroupName }),
|
|
62
|
+
target: `automation-action-${action.key}`,
|
|
63
|
+
actionKey: action.key,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `sent` is the form as it was submitted: the problems point into it. */
|
|
71
|
+
function show(problems: AutomationSaveProblem[], sent: AutomationFormState) {
|
|
72
|
+
const known = problems.flatMap((problem) => toLine(problem, sent) ?? [])
|
|
73
|
+
// Refused for reasons this version cannot name: still say it was refused.
|
|
74
|
+
lines.value = known.length > 0 ? known : [{ text: t('automations.problems.unknown') }]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clear() {
|
|
78
|
+
lines.value = []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { lines, invalidActionKeys, show, clear }
|
|
82
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { FilterField, FiltersReport } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import type { AutomationFormAction } from '~/utils/automations/form'
|
|
3
|
+
import {
|
|
4
|
+
actionUnavailableKinds,
|
|
5
|
+
unavailableBlocks,
|
|
6
|
+
type ActionAvailability,
|
|
7
|
+
type AutomationUnavailableItem,
|
|
8
|
+
type AutomationUnavailableKind,
|
|
9
|
+
} from '~/utils/automations/unavailable'
|
|
10
|
+
|
|
11
|
+
export const AUTOMATION_TRIGGER_ELEMENT_ID = 'automation-trigger'
|
|
12
|
+
export const AUTOMATION_CONDITIONS_ELEMENT_ID = 'automation-conditions'
|
|
13
|
+
|
|
14
|
+
export function automationActionElementId(actionKey: string): string {
|
|
15
|
+
return `automation-action-${actionKey}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The unavailable things of an Automation as the form says them: one item
|
|
20
|
+
* each, with where it is, its sentence and whether it blocks the save.
|
|
21
|
+
* `AutomationsUnavailable` lists them, `AutomationsUnavailableBadge` marks
|
|
22
|
+
* one in place.
|
|
23
|
+
*/
|
|
24
|
+
export function useAutomationUnavailable() {
|
|
25
|
+
const { t } = useI18n()
|
|
26
|
+
|
|
27
|
+
function item(
|
|
28
|
+
kind: AutomationUnavailableKind,
|
|
29
|
+
rest: Omit<AutomationUnavailableItem, 'kind' | 'blocks' | 'text'>,
|
|
30
|
+
params: Record<string, string> = {},
|
|
31
|
+
): AutomationUnavailableItem {
|
|
32
|
+
return {
|
|
33
|
+
kind,
|
|
34
|
+
blocks: unavailableBlocks(kind),
|
|
35
|
+
text: t(`automations.unavailable.${kind}`, params),
|
|
36
|
+
...rest,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A Trigger the Automation Catalog no longer lists. */
|
|
41
|
+
function triggerItem(): AutomationUnavailableItem {
|
|
42
|
+
return item('trigger', {
|
|
43
|
+
key: 'trigger',
|
|
44
|
+
where: t('automations.form.trigger'),
|
|
45
|
+
target: AUTOMATION_TRIGGER_ELEMENT_ID,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the filter components report on a list of Conditions (ADR 0018). */
|
|
50
|
+
function conditionItems(
|
|
51
|
+
report: FiltersReport | undefined,
|
|
52
|
+
fields: readonly FilterField[],
|
|
53
|
+
): AutomationUnavailableItem[] {
|
|
54
|
+
if (!report) return []
|
|
55
|
+
|
|
56
|
+
const where = (field: string) =>
|
|
57
|
+
t('automations.unavailable.where.filter', {
|
|
58
|
+
field: fields.find(({ key }) => key === field)?.label ?? field,
|
|
59
|
+
})
|
|
60
|
+
const place = { target: AUTOMATION_CONDITIONS_ELEMENT_ID, inFilters: true }
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
...report.unavailable.map(({ condition, reason }, index) =>
|
|
64
|
+
item(reason === 'unknown_field' ? 'condition_unknown_field' : 'condition_not_editable', {
|
|
65
|
+
key: `condition-${index}`,
|
|
66
|
+
where: where(condition.field),
|
|
67
|
+
...place,
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
...report.unavailableOptions.map(({ field }, index) =>
|
|
71
|
+
item('option', { key: `option-${index}`, where: where(field), ...place }),
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function actionItems(
|
|
77
|
+
action: AutomationFormAction,
|
|
78
|
+
availability: ActionAvailability,
|
|
79
|
+
): AutomationUnavailableItem[] {
|
|
80
|
+
const channel = t(`automations.channels.${action.channel}`)
|
|
81
|
+
|
|
82
|
+
return actionUnavailableKinds(action, availability).map((kind) =>
|
|
83
|
+
item(
|
|
84
|
+
kind,
|
|
85
|
+
{
|
|
86
|
+
key: `${action.key}-${kind}`,
|
|
87
|
+
where: t('automations.unavailable.where.message', {
|
|
88
|
+
name: action.messageGroupName || channel,
|
|
89
|
+
}),
|
|
90
|
+
target: automationActionElementId(action.key),
|
|
91
|
+
},
|
|
92
|
+
{ channel },
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { triggerItem, conditionItems, actionItems }
|
|
98
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AutomationCatalog } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import type { AutomationsContext } from './automationsContext'
|
|
3
|
+
|
|
4
|
+
export type AutomationsGate =
|
|
5
|
+
| { state: 'ready'; catalog: AutomationCatalog; context: AutomationsContext }
|
|
6
|
+
| { state: 'loading' }
|
|
7
|
+
| { state: 'error'; title: string; description: string; retry?: () => void }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What an Automation page needs before it can show anything: the Host's
|
|
11
|
+
* Automations context, an Organization and an Automation Catalog for the
|
|
12
|
+
* Host's Product. Checked by the page, not by the layout, so a Host that
|
|
13
|
+
* replaces the layout keeps it (ADR 0019). `AutomationsPageGate` renders it.
|
|
14
|
+
*/
|
|
15
|
+
export function useAutomationsGate() {
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const context = useAutomationsContext()
|
|
18
|
+
const organization = useEffectiveOrganizationStore()
|
|
19
|
+
const catalogQuery = useAutomationCatalogQuery()
|
|
20
|
+
const { product } = storeToRefs(useAppContextStore())
|
|
21
|
+
|
|
22
|
+
return computed<AutomationsGate>(() => {
|
|
23
|
+
if (!context) {
|
|
24
|
+
return {
|
|
25
|
+
state: 'error',
|
|
26
|
+
title: t('automations.gate.no_context_title'),
|
|
27
|
+
description: t('automations.gate.no_context_description'),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (organization.status === 'resolving') return { state: 'loading' }
|
|
32
|
+
if (organization.status === 'failed') {
|
|
33
|
+
return {
|
|
34
|
+
state: 'error',
|
|
35
|
+
title: t('common.organization.lookup_failed_title'),
|
|
36
|
+
description: t('common.organization.lookup_failed_description'),
|
|
37
|
+
retry: () => void organization.retry(),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (organization.status === 'none') {
|
|
41
|
+
return {
|
|
42
|
+
state: 'error',
|
|
43
|
+
title: t('automations.gate.no_organization_title'),
|
|
44
|
+
description: t('automations.gate.no_organization_description'),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const response = catalogQuery.data.value
|
|
49
|
+
if (response?.catalog) return { state: 'ready', catalog: response.catalog, context }
|
|
50
|
+
// Answered with no catalog, or nothing to ask for: no Product, no request.
|
|
51
|
+
if (response || !product.value) {
|
|
52
|
+
return {
|
|
53
|
+
state: 'error',
|
|
54
|
+
title: t('automations.gate.no_catalog_title'),
|
|
55
|
+
description: t('automations.gate.no_catalog_description'),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (catalogQuery.status.value === 'error') {
|
|
59
|
+
return {
|
|
60
|
+
state: 'error',
|
|
61
|
+
title: t('automations.gate.catalog_failed_title'),
|
|
62
|
+
description: t('automations.gate.catalog_failed_description'),
|
|
63
|
+
retry: () => void catalogQuery.refetch(),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { state: 'loading' }
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FilterOption } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import countries from '../utils/filters/data/countries.json'
|
|
3
|
+
import languages from '../utils/filters/data/languages.json'
|
|
4
|
+
|
|
5
|
+
/** The name of a code in a language, or the English one where `Intl` has none. */
|
|
6
|
+
function toOptions(
|
|
7
|
+
entries: { code: string; name: string }[],
|
|
8
|
+
type: 'region' | 'language',
|
|
9
|
+
locale: string,
|
|
10
|
+
): FilterOption[] {
|
|
11
|
+
let names: Intl.DisplayNames | undefined
|
|
12
|
+
try {
|
|
13
|
+
names = new Intl.DisplayNames([locale], { type, fallback: 'none' })
|
|
14
|
+
} catch {
|
|
15
|
+
// A locale `Intl` does not know: the English names.
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const options = entries.map(({ code, name }) => {
|
|
19
|
+
let label: string | undefined
|
|
20
|
+
try {
|
|
21
|
+
label = names?.of(type === 'region' ? code.toUpperCase() : code)
|
|
22
|
+
} catch {
|
|
23
|
+
// A code `Intl` rejects keeps its English name.
|
|
24
|
+
}
|
|
25
|
+
return { value: code, label: label ?? name }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return options.sort((a, b) => a.label.localeCompare(b.label, locale))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Public (ADR 0018): the sets built into the layer, as option lists in the
|
|
33
|
+
* current language, sorted by label. The values are lowercase ISO codes
|
|
34
|
+
* (`it`, `de`), the ones Smartconnect stores. For any caller of the filter
|
|
35
|
+
* components, and for a Host to pass on in `defineAutomationsContext`.
|
|
36
|
+
*/
|
|
37
|
+
export function useBuiltInOptionLists() {
|
|
38
|
+
const { locale } = useI18n()
|
|
39
|
+
|
|
40
|
+
const countryOptions = computed(() => toOptions(countries, 'region', locale.value))
|
|
41
|
+
const languageOptions = computed(() => toOptions(languages, 'language', locale.value))
|
|
42
|
+
|
|
43
|
+
return { countries: countryOptions, languages: languageOptions }
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ButtonProps } from '#ui/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The back button of an editor page while an editor trip exists; none
|
|
5
|
+
* otherwise. It does what Cancel does, so it is busy whenever Cancel is.
|
|
6
|
+
*/
|
|
7
|
+
export function useEditorTripBack(busy: MaybeRefOrGetter<boolean | undefined>) {
|
|
8
|
+
const editorTrip = useEditorTripStore()
|
|
9
|
+
|
|
10
|
+
return computed<ButtonProps | undefined>(() =>
|
|
11
|
+
editorTrip.backLabel === undefined
|
|
12
|
+
? undefined
|
|
13
|
+
: { label: editorTrip.backLabel, loading: Boolean(toValue(busy)) },
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -43,10 +43,9 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The WABA to name in a submission.
|
|
47
|
-
* Group whose Organization has several WABAs
|
|
48
|
-
*
|
|
49
|
-
* case none is named and the backend resolves it. A lookup that has not
|
|
46
|
+
* The WABA to name in a submission. Asks the user only at the first
|
|
47
|
+
* submission of a Message Group whose Organization has several WABAs;
|
|
48
|
+
* otherwise names none and the backend resolves it. A lookup that has not
|
|
50
49
|
* answered yet is waited for; one that failed leaves it to the backend.
|
|
51
50
|
* Returns null when the user gives up.
|
|
52
51
|
*/
|
|
@@ -56,12 +55,10 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
|
|
|
56
55
|
const candidates = wabas.value
|
|
57
56
|
if (!candidates || candidates.length < 2) return {}
|
|
58
57
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const chosen = await modal.open({ wabas: candidates }).result
|
|
58
|
+
const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
|
|
59
|
+
? metaWabaId.value
|
|
60
|
+
: undefined
|
|
61
|
+
const chosen = await modal.open({ wabas: candidates, preselected }).result
|
|
65
62
|
|
|
66
63
|
return chosen ? { wabaId: chosen } : null
|
|
67
64
|
}
|
|
@@ -21,7 +21,8 @@ interface TemplateData<T = Message | PostEntity<Message>> {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
interface UseTemplateOperationsOptions {
|
|
24
|
-
|
|
24
|
+
/** Called with the Message Group that was saved, before anything navigates. */
|
|
25
|
+
onSuccess?: (saved?: { id: string; name: string }) => void
|
|
25
26
|
onError?: (error: Error) => void
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -67,7 +68,11 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
saved.value = true
|
|
70
|
-
options.onSuccess?.(
|
|
71
|
+
options.onSuccess?.(
|
|
72
|
+
createdMessageGroup?.id
|
|
73
|
+
? { id: String(createdMessageGroup.id), name: createdMessageGroup.name }
|
|
74
|
+
: undefined,
|
|
75
|
+
)
|
|
71
76
|
return createdMessageGroup
|
|
72
77
|
} catch (error) {
|
|
73
78
|
options.onError?.(error as Error)
|
|
@@ -101,7 +106,7 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
saved.value = true
|
|
104
|
-
options.onSuccess?.()
|
|
109
|
+
options.onSuccess?.({ id, name: data.messageGroup.name })
|
|
105
110
|
} catch (error) {
|
|
106
111
|
options.onError?.(error as Error)
|
|
107
112
|
throw error
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Automation pages are off until a Host sets `composer.automations`
|
|
3
|
+
* (ADR 0019): their routes always exist, and answer 404 while it is off.
|
|
4
|
+
*/
|
|
5
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
6
|
+
if (useAppConfig().composer?.automations) return
|
|
7
|
+
|
|
8
|
+
// Fatal, or a navigation inside the app would be cancelled without a word.
|
|
9
|
+
return abortNavigation(
|
|
10
|
+
createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }),
|
|
11
|
+
)
|
|
12
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({
|
|
3
|
+
name: 'automation-edit',
|
|
4
|
+
middleware: 'automations-enabled',
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const gate = useAutomationsGate()
|
|
10
|
+
const { goBackOrFallback } = useGoBack()
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
data: automation,
|
|
14
|
+
error,
|
|
15
|
+
status,
|
|
16
|
+
refetch,
|
|
17
|
+
} = useAutomationQuery(() => String(route.params.id))
|
|
18
|
+
const notFound = computed(() => (error.value as { status?: number } | null)?.status === 404)
|
|
19
|
+
|
|
20
|
+
const edit = useTemplateRef('edit')
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<LayoutBasePage :title="t('automations.editTitle')" :test-id="automationsTestIds.editPage">
|
|
25
|
+
<template #actions>
|
|
26
|
+
<UButton
|
|
27
|
+
variant="ghost"
|
|
28
|
+
size="lg"
|
|
29
|
+
:label="t('common.actions.cancel')"
|
|
30
|
+
:data-testid="automationsTestIds.editPageCancelButton"
|
|
31
|
+
@click="goBackOrFallback({ name: 'automations-list' })"
|
|
32
|
+
/>
|
|
33
|
+
<UButton
|
|
34
|
+
v-if="edit"
|
|
35
|
+
size="lg"
|
|
36
|
+
:loading="edit.saving"
|
|
37
|
+
:label="t('common.actions.save')"
|
|
38
|
+
:data-testid="automationsTestIds.editPageCreateButton"
|
|
39
|
+
@click="edit.submit()"
|
|
40
|
+
/>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
|
|
44
|
+
<AutomationsEdit
|
|
45
|
+
v-if="automation"
|
|
46
|
+
ref="edit"
|
|
47
|
+
:key="automation.id"
|
|
48
|
+
:catalog="catalog"
|
|
49
|
+
:automation="automation"
|
|
50
|
+
:context="context"
|
|
51
|
+
/>
|
|
52
|
+
<UAlert
|
|
53
|
+
v-else-if="status === 'error'"
|
|
54
|
+
color="error"
|
|
55
|
+
variant="subtle"
|
|
56
|
+
icon="i-lucide-alert-triangle"
|
|
57
|
+
:title="
|
|
58
|
+
notFound ? t('automations.gate.not_found_title') : t('automations.gate.load_failed_title')
|
|
59
|
+
"
|
|
60
|
+
:description="
|
|
61
|
+
notFound
|
|
62
|
+
? t('automations.gate.not_found_description')
|
|
63
|
+
: t('automations.gate.load_failed_description')
|
|
64
|
+
"
|
|
65
|
+
:actions="
|
|
66
|
+
notFound
|
|
67
|
+
? undefined
|
|
68
|
+
: [
|
|
69
|
+
{
|
|
70
|
+
label: t('common.organization.retry'),
|
|
71
|
+
color: 'error',
|
|
72
|
+
variant: 'outline',
|
|
73
|
+
onClick: () => void refetch(),
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
"
|
|
77
|
+
/>
|
|
78
|
+
<div
|
|
79
|
+
v-else
|
|
80
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
|
|
81
|
+
role="status"
|
|
82
|
+
>
|
|
83
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
|
|
84
|
+
</div>
|
|
85
|
+
</AutomationsPageGate>
|
|
86
|
+
</LayoutBasePage>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({
|
|
3
|
+
name: 'automations-list',
|
|
4
|
+
middleware: 'automations-enabled',
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const gate = useAutomationsGate()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<LayoutBasePage :title="t('automations.title')" :test-id="automationsTestIds.page">
|
|
13
|
+
<template #actions>
|
|
14
|
+
<CommonOrganizationSelect />
|
|
15
|
+
<UButton
|
|
16
|
+
v-if="gate.state === 'ready'"
|
|
17
|
+
icon="ph:plus-bold"
|
|
18
|
+
:label="t('automations.new')"
|
|
19
|
+
:to="{ name: 'automation-create' }"
|
|
20
|
+
:data-testid="automationsTestIds.newButton"
|
|
21
|
+
/>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<AutomationsPageGate v-slot="{ catalog }" :gate="gate">
|
|
25
|
+
<AutomationsList :catalog="catalog" />
|
|
26
|
+
</AutomationsPageGate>
|
|
27
|
+
</LayoutBasePage>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({
|
|
3
|
+
name: 'automation-create',
|
|
4
|
+
middleware: 'automations-enabled',
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const gate = useAutomationsGate()
|
|
9
|
+
const { goBackOrFallback } = useGoBack()
|
|
10
|
+
|
|
11
|
+
const create = useTemplateRef('create')
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<LayoutBasePage :title="t('automations.newTitle')" :test-id="automationsTestIds.newPage">
|
|
16
|
+
<template #actions>
|
|
17
|
+
<UButton
|
|
18
|
+
variant="ghost"
|
|
19
|
+
size="lg"
|
|
20
|
+
:label="t('common.actions.cancel')"
|
|
21
|
+
:data-testid="automationsTestIds.newPageCancelButton"
|
|
22
|
+
@click="goBackOrFallback({ name: 'automations-list' })"
|
|
23
|
+
/>
|
|
24
|
+
<UButton
|
|
25
|
+
v-if="create"
|
|
26
|
+
size="lg"
|
|
27
|
+
:loading="create.saving"
|
|
28
|
+
:label="t('automations.form.create')"
|
|
29
|
+
:data-testid="automationsTestIds.newPageCreateButton"
|
|
30
|
+
@click="create.submit()"
|
|
31
|
+
/>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
|
|
35
|
+
<AutomationsCreate
|
|
36
|
+
v-if="catalog.triggers[0]"
|
|
37
|
+
ref="create"
|
|
38
|
+
:catalog="catalog"
|
|
39
|
+
:initial-trigger="catalog.triggers[0]"
|
|
40
|
+
:context="context"
|
|
41
|
+
/>
|
|
42
|
+
<!-- A catalog with no Trigger has nothing to build an Automation from. -->
|
|
43
|
+
<UAlert
|
|
44
|
+
v-else
|
|
45
|
+
color="error"
|
|
46
|
+
variant="subtle"
|
|
47
|
+
icon="i-lucide-alert-triangle"
|
|
48
|
+
:title="t('automations.gate.no_catalog_title')"
|
|
49
|
+
:description="t('automations.gate.no_catalog_description')"
|
|
50
|
+
/>
|
|
51
|
+
</AutomationsPageGate>
|
|
52
|
+
</LayoutBasePage>
|
|
53
|
+
</template>
|
|
@@ -8,6 +8,9 @@ definePageMeta({
|
|
|
8
8
|
const route = useRoute()
|
|
9
9
|
const composerRef = useTemplateRef('composerRef')
|
|
10
10
|
const store = useEmailComposerStore()
|
|
11
|
+
const tripBack = useEditorTripBack(
|
|
12
|
+
() => composerRef.value?.saving || composerRef.value?.isTranslating,
|
|
13
|
+
)
|
|
11
14
|
</script>
|
|
12
15
|
|
|
13
16
|
<template>
|
|
@@ -25,6 +28,8 @@ const store = useEmailComposerStore()
|
|
|
25
28
|
]"
|
|
26
29
|
:active-tab="store.activeTab"
|
|
27
30
|
:test-id="templateEmailEditTestIds.navbar"
|
|
31
|
+
:back="tripBack"
|
|
32
|
+
@back="composerRef?.handleCancel()"
|
|
28
33
|
@tab-change="store.activeTab = $event as 'content' | 'translations'"
|
|
29
34
|
>
|
|
30
35
|
<template #actions>
|
|
@@ -7,6 +7,9 @@ definePageMeta({
|
|
|
7
7
|
|
|
8
8
|
const composerRef = useTemplateRef('composerRef')
|
|
9
9
|
const store = useEmailComposerStore()
|
|
10
|
+
const tripBack = useEditorTripBack(
|
|
11
|
+
() => composerRef.value?.saving || composerRef.value?.isTranslating,
|
|
12
|
+
)
|
|
10
13
|
</script>
|
|
11
14
|
|
|
12
15
|
<template>
|
|
@@ -24,6 +27,8 @@ const store = useEmailComposerStore()
|
|
|
24
27
|
]"
|
|
25
28
|
:active-tab="store.activeTab"
|
|
26
29
|
:test-id="templateEmailCreateTestIds.navbar"
|
|
30
|
+
:back="tripBack"
|
|
31
|
+
@back="composerRef?.handleCancel()"
|
|
27
32
|
@tab-change="store.activeTab = $event as 'content' | 'translations'"
|
|
28
33
|
>
|
|
29
34
|
<template #actions>
|