@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.4.1-next.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 (35) hide show
  1. package/README.md +184 -48
  2. package/app/api/client.ts +12 -0
  3. package/app/api/compilation.ts +13 -3
  4. package/app/api/dynamicValues.ts +5 -2
  5. package/app/api/messageGroups.ts +23 -12
  6. package/app/components/Common/AppContextErrors.vue +30 -0
  7. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  8. package/app/components/Common/MessageGroupSelect.vue +153 -26
  9. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  10. package/app/components/Composer/MessageLibrary.vue +17 -7
  11. package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
  12. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  13. package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
  14. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  15. package/app/components/TemplateList/BaseTable.vue +156 -108
  16. package/app/components/Whatsapp/Preview.vue +12 -4
  17. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  18. package/app/composables/composerContext.ts +4 -2
  19. package/app/composables/useMessageGroupList.ts +186 -0
  20. package/app/composables/useMessageSendFlow.ts +246 -0
  21. package/app/composables/usePagedMessageGroups.ts +66 -0
  22. package/app/composables/useTemplateOperations.ts +13 -3
  23. package/app/layouts/default.vue +1 -0
  24. package/app/queries/compilation.ts +1 -1
  25. package/app/queries/dynamicValues.ts +8 -2
  26. package/app/queries/messageGroups.ts +29 -16
  27. package/app/stores/appContext.ts +12 -6
  28. package/app/types/tracking.ts +17 -0
  29. package/app/utils/testIds.const.ts +29 -0
  30. package/i18n/locales/de.ts +56 -2
  31. package/i18n/locales/en.ts +53 -2
  32. package/i18n/locales/es.ts +54 -2
  33. package/i18n/locales/fr.ts +53 -2
  34. package/i18n/locales/it.ts +55 -2
  35. package/package.json +2 -2
package/README.md CHANGED
@@ -1,75 +1,211 @@
1
- # Nuxt Minimal Starter
1
+ # @dev.smartpricing/message-composer-layer
2
2
 
3
- Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
3
+ The Message Composer as a [Nuxt layer](https://nuxt.com/docs/getting-started/layers). A host app extends it and gets the composer pages (message library, editor, preview, brands, images) plus embeddable components such as the **Message Send Flow**.
4
+
5
+ The layer is published on the private Smartpricing npm registry together with `@dev.smartpricing/message-composer-utils` (shared types and helpers, subpath exports `/types` and `/utils`). Both packages share one version number.
6
+
7
+ ## Host setup
8
+
9
+ 1. Install the layer and its peer:
10
+
11
+ ```bash
12
+ pnpm add @dev.smartpricing/message-composer-layer
13
+ pnpm add -D nuxt-ui-layer@github:smartpricing/smartness-nuxt-ui#v1.14.4 # peer, >= 1.3.0
14
+ ```
15
+
16
+ `nuxt-ui-layer` is extended by `@dev.smartpricing/platform-layer`, which this layer extends, so the host does not list it in `extends`.
17
+
18
+ 2. Extend it and set the API keys. `BACKEND_BASE_URL`, `PLATFORM_URL` and `CSRF_COOKIE_NAME` are declared by `@dev.smartpricing/platform-layer`, which this layer extends; set them once for both.
19
+
20
+ ```ts
21
+ // nuxt.config.ts
22
+ export default defineNuxtConfig({
23
+ extends: ['@dev.smartpricing/message-composer-layer'],
24
+ runtimeConfig: {
25
+ public: {
26
+ COMPOSER_API_URL: '', // NUXT_PUBLIC_COMPOSER_API_URL
27
+ BACKEND_BASE_URL: '', // NUXT_PUBLIC_BACKEND_BASE_URL
28
+ PLATFORM_URL: '', // NUXT_PUBLIC_PLATFORM_URL
29
+ CSRF_COOKIE_NAME: '', // NUXT_PUBLIC_CSRF_COOKIE_NAME: smt_csrf | smt_csrf_stage | smt_csrf_dev
30
+ },
31
+ },
32
+ })
33
+ ```
34
+
35
+ 3. Provide the composer context before any layer component renders (`app.vue` or a plugin). `product` is required; everything else has a default.
36
+
37
+ ```vue
38
+ <script setup lang="ts">
39
+ defineComposerContext({
40
+ product: ref('chat'), // 'connect' | 'chat' | 'pms' | 'pricing'
41
+ organization_id: useAuth().organizationId,
42
+ channels: ref(['whatsapp']),
43
+ mainLanguage: ref('it'),
44
+ metaWabaId: useAuth().wabaId, // required for the whatsapp channel
45
+ metaAccessToken: useAuth().metaToken, // required for the whatsapp channel
46
+ })
47
+
48
+ useNuxtApp().hook('composer:auth-error', () => {
49
+ // 401 from the composer API: the session is gone, decide what to do
50
+ })
51
+ </script>
52
+ ```
53
+
54
+ Auth is cookie-based through platform-layer. The full `ComposerContext` interface lives in `app/composables/composerContext.ts`; the design and the parent/layer contract are in [`docs/nuxt-layer-design.md`](../../docs/nuxt-layer-design.md).
55
+
56
+ 4. Tracking is opt-in: set `composer.tracking: true` in the host's `app.config.ts` to have the layer post its events to `POST /tracking/event`.
57
+
58
+ The layer's pages are mounted at root (`/`, `/template/...`, `/preview/...`, `/brands`, `/images`). Remap them with `pages:extend` if the host already owns those routes.
59
+
60
+ ## Message Send Flow
61
+
62
+ `MessageSendFlow` walks an operator from "which Message" to "here is what to send", in two steps inside a container the host owns (Smartchat: a modal):
63
+
64
+ 1. **Message**: pick a **Sendable** Message Group (at least one approved Message; groups with none are greyed out with a reason) and one of its approved languages.
65
+ 2. **Parameters**: one required input per operator parameter of the chosen Message, prefilled from the host, editable. `brand_*` keys are hidden and filled server-side. **Confirm** calls `POST /message-groups/:id/compile` once (`strict: true`, `strict_language: true`) and emits the **Sendable Message** the backend built.
66
+
67
+ The preview follows every step in a right panel: the picked Message as the guest receives it (the WhatsApp bubble for `whatsapp_v1`, the compiled email for `email_v1`), with the typed values substituted live. Plan a container at least ~1100 px wide (Nuxt UI `max-w-6xl`); the panel is 400 px, the stepper 200 px.
68
+
69
+ Back and Next move between steps, Cancel is always available, Confirm only on Parameters. The action bar renders under the steps, or wherever `actionsTo` points (a modal footer). The component has no outer padding: the host's container pads it. The component owns no overlay and no send, and it keeps its state after `compiled`: closing the container is the host's decision. There is no reset API; remount it (`:key`) to start over.
70
+
71
+ The `/compile` contract (request, response with `message`, error codes) is documented in [`docs/compile-and-send.md`](../../docs/compile-and-send.md).
72
+
73
+ ### Mount
74
+
75
+ ```vue
76
+ <script setup lang="ts">
77
+ import type { SendableMessage } from '@dev.smartpricing/message-composer-utils/types'
78
+
79
+ const open = ref(true)
80
+ const groupId = ref<string>() // optional preselect, read back after a pick
81
+
82
+ function onCompiled(message: SendableMessage) {
83
+ open.value = false
84
+ // hand `message` to your send pipeline, see "Smartchat glue" below
85
+ }
86
+ </script>
87
+
88
+ <template>
89
+ <UModal v-model:open="open" title="New WhatsApp conversation">
90
+ <template #body>
91
+ <MessageSendFlow
92
+ v-model:message-group-id="groupId"
93
+ language="it"
94
+ :parameters="{ guest_name: 'Mario', guest_fullname: 'Mario Rossi' }"
95
+ actions-to="#send-flow-actions"
96
+ @compiled="onCompiled"
97
+ @cancel="open = false"
98
+ @error="(e) => console.warn(e.code, e.details)"
99
+ />
100
+ </template>
101
+ <template #footer>
102
+ <!-- Cancel, Back, Next and Confirm render here -->
103
+ <div id="send-flow-actions" class="w-full" />
104
+ </template>
105
+ </UModal>
106
+ </template>
107
+ ```
4
108
 
5
- ## Setup
109
+ ### Props
6
110
 
7
- Make sure to install dependencies:
111
+ | Prop | Type | Default | Description |
112
+ | ---------------- | ------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
113
+ | `messageGroupId` | `string` (`v-model`) | `undefined` | Two-way: preselect a group, read back what the operator picked. A preselected group that is not Sendable raises `NOT_SENDABLE` and leaves the picker usable. |
114
+ | `renderType` | `RenderType` | `'whatsapp_v1'` | Channel of the Messages offered: `whatsapp_v1` or `email_v1`. Picker, languages, parameters, preview and the Sendable Message follow it. |
115
+ | `language` | `string` | `undefined` | Preferred language (see the language rule). |
116
+ | `parameters` | `Record<string, string>` | `undefined` | Editable prefill of the parameter inputs. Keys the chosen Message does not need are ignored. |
117
+ | `brandId` | `string` | `undefined` | Brand override forwarded to `/compile` as `brand_id`; defaults to the group's brand. |
118
+ | `actionsTo` | `string \| HTMLElement` | `undefined` | Where the Cancel/Back/Next/Confirm bar renders, e.g. a `UModal` footer element. Unset, it renders under the steps. |
8
119
 
9
- ```bash
10
- # npm
11
- npm install
120
+ ### Emits
12
121
 
13
- # pnpm
14
- pnpm install
122
+ | Event | Payload | When |
123
+ | ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | `compiled` | `SendableMessage` | Confirm succeeded. The Sendable Message is the `message` field of the `/compile` response, untouched: `WhatsappSendableMessage` for `whatsapp_v1`, `EmailSendableMessage` for `email_v1`. |
125
+ | `cancel` | none | The operator pressed Cancel (on any step). |
126
+ | `error` | `{ code: MessageSendFlowErrorCode, message: string, details?: unknown }` | Every failure the inline alert shows, so the host can log or surface it. `message` is the layer's translated text, the same string the alert shows. |
15
127
 
16
- # yarn
17
- yarn install
128
+ Types: `SendableMessage` from `@dev.smartpricing/message-composer-utils/types`; `MessageSendFlowError`, `MessageSendFlowErrorCode` and `MessageSendFlowEmits` from the layer's `app/composables/useMessageSendFlow.ts`.
18
129
 
19
- # bun
20
- bun install
21
- ```
130
+ ### Error codes
22
131
 
23
- ## Development Server
132
+ | Code | Raised by | Meaning | `details` |
133
+ | ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
134
+ | `NOT_SENDABLE` | layer | The preselected or picked group has no approved Message. The picker stays usable; the alert clears once a Sendable group is picked. | none |
135
+ | `MISSING_PARAMETERS` | layer | Confirm pressed with an empty required parameter (checked client-side before calling `/compile`). | `{ missing: string[] }` |
136
+ | `MISSING_PARAMETERS` | backend | `/compile` reported a required key as absent. | the backend error body |
137
+ | `TEMPLATE_NOT_SUBMITTED` | backend | The WhatsApp template was never submitted to Meta. | the backend error body |
138
+ | `TEMPLATE_NOT_APPROVED` | backend | The Message is not approved by Meta. | the backend error body |
139
+ | `COMPILE_FAILED` | layer | Any other `/compile` failure, or a response without `message`. | the backend body or the error |
24
140
 
25
- Start the development server on `http://localhost:3000`:
141
+ A 401 is not an `error` emit: it reaches the host through the `composer:auth-error` Nuxt hook like everywhere else in the layer.
26
142
 
27
- ```bash
28
- # npm
29
- npm run dev
143
+ ### Language rule
30
144
 
31
- # pnpm
32
- pnpm dev
145
+ The language selected in step 1 is the `language` prop when that Message is approved, otherwise the first approved Message in the group's `messages[]` order. It is recomputed whenever the group or the `language` prop changes. Non-approved languages stay visible as disabled radio cards showing their status; only approved ones can be picked.
33
146
 
34
- # yarn
35
- yarn dev
147
+ ### Reused blocks and their new props
36
148
 
37
- # bun
38
- bun run dev
39
- ```
149
+ - `MessageGroupSelect` (auto-import `CommonMessageGroupSelect`) gained `eligibility?: 'active' | 'sendable'`. `active` (default, the composer's own picker) selects groups whose every Message is approved; `sendable` selects groups with at least one approved Message. The disabled tooltip and the "hide inactive" filter follow the mode. The send flow uses `sendable`. Details in [`docs/message-group-select-migration.md`](../../docs/message-group-select-migration.md).
150
+ - `WhatsappPreview` gained `parameters?: Record<string, string>`: non-empty values override the organization-level resolved values, keys not in the map keep the resolved mock, an empty string shows the raw `{{key}}` placeholder.
40
151
 
41
- ## Production
152
+ ### Exported blocks
42
153
 
43
- Build the application for production:
154
+ All of these are auto-imported in a host that extends the layer, so a different layout can be composed without forking:
44
155
 
45
- ```bash
46
- # npm
47
- npm run build
156
+ | Auto-import name | File | Role |
157
+ | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
158
+ | `MessageSendFlow` | `app/components/MessageSendFlow/MessageSendFlow.vue` | The composed two-step flow. |
159
+ | `MessageSendFlowLanguageSelect` | `app/components/MessageSendFlow/LanguageSelect.vue` | Radio cards of a group's languages; props `languages`, `v-model` code. |
160
+ | `MessageSendFlowParametersForm` | `app/components/MessageSendFlow/ParametersForm.vue` | One input per key; props `parameterKeys`, `placeholders`, `loading`, `v-model` values. |
161
+ | `CommonMessageGroupSelect` | `app/components/Common/MessageGroupSelect.vue` | The picker, with `eligibility="sendable"`. |
162
+ | `WhatsappPreview` | `app/components/Whatsapp/Preview.vue` | The bubble, with the `parameters` override. |
163
+ | `MessageSendFlowEmailPreview` | `app/components/MessageSendFlow/EmailPreview.vue` | The compiled email (subject, HTML) for the typed values; props `messageGroupId`, `language`, `parameters`, `brandId`. |
164
+
165
+ The data flow (group, language rule, parameters, one compile on Confirm, error mapping) is `useMessageSendFlow()` in `app/composables/useMessageSendFlow.ts`. Test ids for every control are in `messageSendFlowTestIds` (`app/utils/testIds.const.ts`).
166
+
167
+ ### Smartchat glue
48
168
 
49
- # pnpm
50
- pnpm build
169
+ The emitted Sendable Message is the content object Smartchat already sends, so the glue is a two-field pick:
51
170
 
52
- # yarn
53
- yarn build
171
+ ```ts
172
+ import type { SendableMessage } from '@dev.smartpricing/message-composer-utils/types'
54
173
 
55
- # bun
56
- bun run build
174
+ function onCompiled(message: SendableMessage) {
175
+ sendMessage({
176
+ contentType: message.type, // 'WhatsappTemplateMessage' | 'HtmlMessage'
177
+ content: message,
178
+ language:
179
+ message.type === 'WhatsappTemplateMessage' ? message.meta.data.language.code : language,
180
+ })
181
+ }
57
182
  ```
58
183
 
59
- Locally preview production build:
184
+ For WhatsApp, `message.meta.data` is the Meta template object (`name`, `language`, `components` with the filled parameters) and `message.meta.template` is the raw translated template stored at submission, the one Smartchat renders in the bubble. For email, `message` is the `HtmlMessage` content compile-and-send publishes (`subject`, `text` and `rawHtml` both the compiled HTML, `skipTranslation: true`); it carries no language, so the host keeps the one the operator picked from its own state.
60
185
 
61
- ```bash
62
- # npm
63
- npm run preview
186
+ ### Tracking
187
+
188
+ With tracking enabled the flow reports `send_flow.confirm` (`message_name`, `message_category`, `language`, `parameters_count`), `send_flow.cancel` (`step`: `message` | `parameters`) and `send_flow.error` (`code`).
189
+
190
+ ### Dev harness
191
+
192
+ `apps/frontend` (this monorepo's standalone host) has a permanent `/debug/send-flow` page that plays the host: preselect, preferred language, prefill JSON, brand id, inline or modal container, remount, and a log of every emit.
64
193
 
65
- # pnpm
66
- pnpm preview
194
+ ## Development
67
195
 
68
- # yarn
69
- yarn preview
196
+ Run from `apps/layer`:
70
197
 
71
- # bun
72
- bun run preview
198
+ ```bash
199
+ pnpm typecheck # nuxt typecheck
200
+ pnpm lint # eslint app/ --fix
201
+ pnpm test:e2e # playwright
202
+ pnpm translations:check # every locale has every key
73
203
  ```
74
204
 
75
- Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
205
+ There is no dev server in the layer itself: use `apps/frontend`, which extends `../layer`.
206
+
207
+ ## Release
208
+
209
+ 1. Bump `version` in `apps/layer/package.json` and `packages/message-composer-utils/package.json` together (semver: minor for a new component or prop, major for a change to the host contract).
210
+ 2. Build the tarball from `apps/layer` with `pnpm pack`; `build.sh` builds the Docker image of the standalone deployment, not the npm package.
211
+ 3. Publish both packages to the private registry.
package/app/api/client.ts CHANGED
@@ -24,6 +24,11 @@ export const apiClient = createApiClient({
24
24
  (useRuntimeConfig().public.BACKEND_BASE_URL as string) ||
25
25
  (useRuntimeConfig().public.PLATFORM_URL as string),
26
26
  platformURL: useRuntimeConfig().public.PLATFORM_URL as string,
27
+ // The composer backend answers a dead access token with @fastify/jwt's flat
28
+ // body (`code: FST_JWT_*` at top level), never the platform's nested
29
+ // `error.code: session.*` that the default predicate looks for. Refresh on
30
+ // any 401, as Connect's client does; the retry decides.
31
+ shouldRefresh: (status) => status === 401,
27
32
  onRefreshFailed: () => {
28
33
  useNuxtApp().callHook('composer:auth-error', { statusCode: 401 })
29
34
  },
@@ -45,6 +50,13 @@ export const apiClient = createApiClient({
45
50
  }
46
51
  },
47
52
  onResponseError({ response, options }) {
53
+ // A 401 that reaches here survived the refresh attempt (or its retry): the
54
+ // session is dead. platform-layer only calls onRefreshFailed for its own
55
+ // `session.*` codes, so tell the host here for the composer's `FST_JWT_*`.
56
+ if (response.status === 401) {
57
+ useNuxtApp().callHook('composer:auth-error', { statusCode: 401 })
58
+ return
59
+ }
48
60
  if ((options as { _skipErrorToast?: boolean })._skipErrorToast) return
49
61
 
50
62
  const t = useNuxtApp().$i18n.t
@@ -5,6 +5,7 @@ import type {
5
5
  GetResolvedParametersResponse,
6
6
  CompileMessageRequest,
7
7
  CompileEmailResponse,
8
+ CompileMessageResponse,
8
9
  PreviewMessageRequest,
9
10
  } from '@dev.smartpricing/message-composer-utils/types'
10
11
  import { apiClient } from './client'
@@ -26,11 +27,20 @@ export const compilationApi = {
26
27
  `/message-groups/${messageGroupId}/parameters/resolved?language=${language}`,
27
28
  ),
28
29
 
29
- compileEmail: (messageGroupId: string, data: CompileMessageRequest) =>
30
- apiClient<CompileEmailResponse>(`/message-groups/${messageGroupId}/compile`, {
30
+ /**
31
+ * WhatsApp responses carry `message`, the Sendable Message (docs/compile-and-send.md).
32
+ * `skipErrorToast` lets a caller with its own inline error surface silence the global toast.
33
+ */
34
+ compile: (
35
+ messageGroupId: string,
36
+ data: CompileMessageRequest,
37
+ options: { skipErrorToast?: boolean } = {},
38
+ ) =>
39
+ apiClient<CompileMessageResponse>(`/message-groups/${messageGroupId}/compile`, {
31
40
  method: 'POST',
32
41
  body: data,
33
- }),
42
+ _skipErrorToast: options.skipErrorToast,
43
+ } as Record<string, unknown>),
34
44
 
35
45
  previewEmail: (data: PreviewMessageRequest) =>
36
46
  apiClient<CompileEmailResponse>('/message-groups/preview', {
@@ -1,8 +1,11 @@
1
- import type { DynamicValuesResponse } from '@dev.smartpricing/message-composer-utils/types'
1
+ import type {
2
+ DynamicValuesResponse,
3
+ MessageGroupProduct,
4
+ } from '@dev.smartpricing/message-composer-utils/types'
2
5
  import { apiClient } from './client'
3
6
 
4
7
  export const dynamicValuesApi = {
5
- get: (product: string, language: string) => {
8
+ get: (product: MessageGroupProduct, language: string) => {
6
9
  const params = new URLSearchParams()
7
10
  params.set('product', product)
8
11
  params.set('language', language)
@@ -1,27 +1,38 @@
1
1
  import type {
2
2
  MessageGroupFromDb,
3
+ PaginatedResponse,
3
4
  MessageGroupWithMessages,
4
5
  MessageGroupWithMessageSummary,
5
6
  MessageGroupValidationResponse,
6
7
  PostEntity,
7
8
  MessageGroup,
8
- RenderType,
9
9
  DuplicateMessageGroupBody,
10
+ ListMessageGroupsQuery,
10
11
  } from '@dev.smartpricing/message-composer-utils/types'
11
12
  import { apiClient } from './client'
12
13
 
14
+ /**
15
+ * Query params of `GET /message-groups`, straight from the shared schema
16
+ * (ADR 0007): snake_case, as on the wire. `visibility` other than `public`
17
+ * is admin-only (403 otherwise); every field is optional here, the server
18
+ * applies the defaults.
19
+ */
20
+ export type ListMessageGroupsOptions = Partial<ListMessageGroupsQuery>
21
+
22
+ function listQueryString(options: ListMessageGroupsOptions = {}): string {
23
+ const params = new URLSearchParams()
24
+ for (const [name, value] of Object.entries(options)) {
25
+ if (value !== undefined && value !== '') params.set(name, String(value))
26
+ }
27
+ return params.size ? `?${params.toString()}` : ''
28
+ }
29
+
13
30
  export const messageGroupsApi = {
14
- getAll: (options?: { renderType?: RenderType; tags?: string[]; tagsMode?: 'and' | 'or' }) => {
15
- const params = new URLSearchParams()
16
- if (options?.renderType) params.set('render_type', options.renderType)
17
- if (options?.tags && options.tags.length > 0) {
18
- options.tags.forEach((tag) => params.append('tags', tag))
19
- }
20
- if (options?.tagsMode) params.set('tags_mode', options.tagsMode)
21
-
22
- const query = params.size ? `?${params.toString()}` : ''
23
- return apiClient<MessageGroupWithMessageSummary[]>(`/message-groups${query}`)
24
- },
31
+ /** One page of the server-side filtered list, in the Laravel envelope (ADR 0012). */
32
+ list: (options?: ListMessageGroupsOptions) =>
33
+ apiClient<PaginatedResponse<MessageGroupWithMessageSummary>>(
34
+ `/message-groups${listQueryString(options)}`,
35
+ ),
25
36
 
26
37
  getById: (id: string) => apiClient<MessageGroupWithMessages>(`/message-groups/${id}`),
27
38
 
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import { useAppContextStore } from '~/stores/appContext'
3
+
4
+ /**
5
+ * Host-integration problems reported by the app-context store (missing
6
+ * Product, missing Meta config, ...). Shown persistently: the host has to fix
7
+ * its context, the user cannot.
8
+ */
9
+ const { validationErrors } = storeToRefs(useAppContextStore())
10
+ const messages = computed(() => Object.values(validationErrors.value))
11
+ </script>
12
+
13
+ <template>
14
+ <UAlert
15
+ v-if="messages.length"
16
+ color="error"
17
+ variant="subtle"
18
+ icon="i-lucide-alert-triangle"
19
+ :title="$t('common.context_error_title')"
20
+ class="rounded-none"
21
+ >
22
+ <template #description>
23
+ <ul class="list-disc pl-4 space-y-1">
24
+ <li v-for="message in messages" :key="message">
25
+ {{ message }}
26
+ </li>
27
+ </ul>
28
+ </template>
29
+ </UAlert>
30
+ </template>
@@ -17,6 +17,8 @@ const props = withDefaults(
17
17
 
18
18
  const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
19
19
  const measuredHeight = ref(0)
20
+ // The first document comes from `srcdoc`; later ones are written in place (see the watch).
21
+ const initialHtml = props.html
20
22
 
21
23
  const heightStyle = computed(() => {
22
24
  if (!measuredHeight.value) return `${props.minHeight}px`
@@ -44,10 +46,22 @@ function onLoad() {
44
46
  observeContent()
45
47
  }
46
48
 
49
+ /**
50
+ * Replace the document in place instead of re-binding `srcdoc`: a `srcdoc`
51
+ * change is a navigation, which blanks the frame and drops the height for a
52
+ * few frames. Writing keeps the frame painted and the last measured height
53
+ * until the new content is measured.
54
+ */
47
55
  watch(
48
56
  () => props.html,
49
- () => {
50
- measuredHeight.value = 0
57
+ (html) => {
58
+ const doc = iframeRef.value?.contentDocument
59
+ if (!doc) return
60
+ doc.open()
61
+ doc.write(html)
62
+ doc.close()
63
+ fitToContent()
64
+ observeContent()
51
65
  },
52
66
  )
53
67
 
@@ -59,7 +73,7 @@ onBeforeUnmount(() => {
59
73
  <template>
60
74
  <iframe
61
75
  ref="iframe"
62
- :srcdoc="html"
76
+ :srcdoc="initialHtml"
63
77
  :title="title"
64
78
  sandbox="allow-same-origin"
65
79
  :style="{ height: heightStyle }"