@xelqun/forms 0.3.1 → 0.5.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.
package/README.md CHANGED
@@ -41,7 +41,8 @@ class OrderForm extends Form
41
41
  - **Secure by default.** Forms rebuild from an encrypted token, and every endpoint re-runs `authorize()` and validation.
42
42
  - **Repeaters and uploads included.** Nested rows map to validation errors, and files upload as soon as they're picked.
43
43
  - **Every field you'd expect.** Select with relationships, date and time pickers, tags, sliders, colors, a block `Builder`, and rich text, Markdown and code editors.
44
- - **Wizards and actions.** Multi-step forms check each step on the server, and buttons can run PHP through a modal with its own fields.
44
+ - **Wizards and actions.** Multi-step forms check each step on the server, and buttons can run PHP through a modal with its own fields. Group them in a menu with `ActionGroup`.
45
+ - **Notifications.** Send one from an action, a hook or a controller with `Notification::make()`, and it reaches the page by itself. Add buttons with `NotificationAction`.
45
46
 
46
47
  ## Installation
47
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,12 @@
6
6
  @source "../js";
7
7
 
8
8
  @theme inline {
9
+ --animate-xq-fade-in: xq-fade-in 150ms ease-out;
10
+ --animate-xq-fade-out: xq-fade-out 100ms ease-in forwards;
11
+ --animate-xq-toast-in: xq-toast-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
12
+ --animate-xq-toast-out: xq-toast-out 150ms ease-in forwards;
13
+ --animate-xq-dialog-in: xq-dialog-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
14
+ --animate-xq-dialog-out: xq-dialog-out 100ms ease-in forwards;
9
15
  --color-xq-background: var(--xq-background);
10
16
  --color-xq-foreground: var(--xq-foreground);
11
17
  --color-xq-card: var(--xq-card);
@@ -281,3 +287,43 @@
281
287
  max-width: 100%;
282
288
  height: auto;
283
289
  }
290
+
291
+ @keyframes xq-fade-in {
292
+ from {
293
+ opacity: 0;
294
+ }
295
+ }
296
+
297
+ @keyframes xq-fade-out {
298
+ to {
299
+ opacity: 0;
300
+ }
301
+ }
302
+
303
+ @keyframes xq-toast-in {
304
+ from {
305
+ opacity: 0;
306
+ translate: 0 -0.5rem;
307
+ }
308
+ }
309
+
310
+ @keyframes xq-toast-out {
311
+ to {
312
+ opacity: 0;
313
+ translate: 1rem 0;
314
+ }
315
+ }
316
+
317
+ @keyframes xq-dialog-in {
318
+ from {
319
+ opacity: 0;
320
+ scale: 0.96;
321
+ }
322
+ }
323
+
324
+ @keyframes xq-dialog-out {
325
+ to {
326
+ opacity: 0;
327
+ scale: 0.98;
328
+ }
329
+ }
@@ -8,50 +8,57 @@ export type ActionRunner = Pick<FormStore, 'mountAction' | 'runAction' | 'proces
8
8
  </script>
9
9
 
10
10
  <script setup lang="ts">
11
- import { Link } from '@inertiajs/vue3';
11
+ import { Link, router } from '@inertiajs/vue3';
12
12
  import { LoaderCircle } from 'lucide-vue-next';
13
13
  import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
14
14
  import { useFormStore } from '../composables/context';
15
- import { createFormStore } from '../core/store';
15
+ import { createFormStore, LOADING_INDICATOR_DELAY } from '../core/store';
16
+ import { cn, isOwnUrl } from '../lib/utils';
16
17
  import type { SchemaNode } from '../schema/types';
18
+ import { actionVariants } from '../ui/classes';
17
19
  import XqButton from '../ui/XqButton.vue';
18
20
  import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
19
21
  import XqDialog from '../ui/XqDialog.vue';
22
+ import XqDialogFooter from '../ui/XqDialogFooter.vue';
20
23
  import XqIcon from '../ui/XqIcon.vue';
21
24
  import XqForm from './XqForm.vue';
22
25
 
23
- const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
26
+ const props = defineProps<{
27
+ node: SchemaNode;
28
+ runner?: ActionRunner;
29
+ /** Keep the dialogs and leave the button out, for a menu that has its own. */
30
+ headless?: boolean;
31
+ }>();
32
+ const emit = defineEmits<{ spinner: [showsSpinner: boolean] }>();
24
33
  const store = props.runner ?? useFormStore();
25
34
  const options = computed(() => props.node.props ?? {});
26
35
  const running = ref(false);
36
+ const showsSpinner = ref(false);
27
37
  const confirming = ref(false);
28
38
  const modalOpen = ref(false);
29
39
  const modal = shallowRef<FormStore | null>(null);
30
40
 
31
- const variants: Record<string, 'default' | 'destructive' | 'outline'> = {
32
- primary: 'default',
33
- danger: 'destructive',
34
- gray: 'outline',
35
- };
36
41
  const variant = computed(() =>
37
42
  options.value.style === 'link'
38
43
  ? 'link'
39
44
  : options.value.style === 'icon'
40
45
  ? 'ghost'
41
- : (variants[options.value.color] ?? 'outline'),
46
+ : (actionVariants[options.value.color] ?? 'outline'),
42
47
  );
43
48
 
44
- // Inertia can only visit its own app, and a new tab isn't a visit.
45
- const isVisit = computed(() => {
46
- if (options.value.openInNewTab) {
47
- return false;
48
- }
49
+ // A new tab isn't a visit.
50
+ const isVisit = computed(() => !options.value.openInNewTab && isOwnUrl(options.value.url));
51
+
52
+ let spinnerTimer: ReturnType<typeof setTimeout> | undefined;
49
53
 
50
- const url: string = options.value.url;
54
+ // A quick action changes nothing on screen until it's done, so it can't flicker.
55
+ watch(running, (isRunning) => {
56
+ clearTimeout(spinnerTimer);
57
+ showsSpinner.value = false;
51
58
 
52
- return typeof window === 'undefined'
53
- ? url.startsWith('/')
54
- : new URL(url, window.location.href).origin === window.location.origin;
59
+ if (isRunning) {
60
+ spinnerTimer = setTimeout(() => (showsSpinner.value = true), LOADING_INDICATOR_DELAY);
61
+ }
55
62
  });
56
63
 
57
64
  async function run(): Promise<void> {
@@ -62,14 +69,29 @@ async function run(): Promise<void> {
62
69
 
63
70
  running.value = true;
64
71
 
65
- if (await store.runAction(props.node, modal.value ?? undefined)) {
72
+ const result = await store.runAction(props.node, modal.value ?? undefined);
73
+
74
+ if (result) {
66
75
  modalOpen.value = false;
67
76
  }
68
77
 
78
+ if (result?.redirect) {
79
+ if (isOwnUrl(result.redirect)) {
80
+ router.visit(result.redirect);
81
+ } else {
82
+ window.location.assign(result.redirect);
83
+ }
84
+ }
85
+
69
86
  running.value = false;
70
87
  }
71
88
 
72
89
  async function open(): Promise<void> {
90
+ // Disabling the button would dim it and drop keyboard focus, so a second click lands here.
91
+ if (running.value) {
92
+ return;
93
+ }
94
+
73
95
  if (!options.value.hasForm) {
74
96
  if (options.value.confirmation) {
75
97
  confirming.value = true;
@@ -103,51 +125,74 @@ watch(modalOpen, (isOpen) => {
103
125
  }
104
126
  });
105
127
 
106
- onScopeDispose(closeModal);
128
+ watch(showsSpinner, (shows) => emit('spinner', shows));
129
+
130
+ defineExpose({ open });
131
+
132
+ onScopeDispose(() => {
133
+ clearTimeout(spinnerTimer);
134
+ closeModal();
135
+ });
107
136
  </script>
108
137
 
109
138
  <template>
110
139
  <div class="inline-flex" data-xq-action>
111
- <XqButton
112
- v-if="options.url"
113
- as-child
114
- :variant="variant"
115
- :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
116
- :title="options.tooltip"
117
- :aria-label="options.style === 'icon' ? options.label : undefined"
118
- >
119
- <component
120
- :is="isVisit ? Link : 'a'"
121
- :href="options.url"
122
- :target="options.openInNewTab ? '_blank' : undefined"
123
- :rel="options.openInNewTab ? 'noopener noreferrer' : undefined"
140
+ <template v-if="!headless">
141
+ <XqButton
142
+ v-if="options.url"
143
+ as-child
144
+ :variant="variant"
145
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
146
+ :title="options.tooltip"
147
+ :aria-label="options.style === 'icon' ? options.label : undefined"
124
148
  >
125
- <XqIcon v-if="options.icon" :name="options.icon" />
126
- <template v-if="options.style !== 'icon'">{{ options.label }}</template>
127
- </component>
128
- </XqButton>
129
- <XqButton
130
- v-else
131
- type="button"
132
- :variant="variant"
133
- :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
134
- :disabled="!!options.disabled || running || store.processing.value"
135
- :title="options.tooltip"
136
- :aria-label="options.style === 'icon' ? options.label : undefined"
137
- @click="open"
138
- >
139
- <LoaderCircle v-if="running" class="motion-safe:animate-spin" aria-hidden="true" />
140
- <XqIcon v-else-if="options.icon" :name="options.icon" />
141
- <template v-if="options.style !== 'icon'">{{ options.label }}</template>
142
- </XqButton>
149
+ <component
150
+ :is="isVisit ? Link : 'a'"
151
+ :href="options.url"
152
+ :target="options.openInNewTab ? '_blank' : undefined"
153
+ :rel="options.openInNewTab ? 'noopener noreferrer' : undefined"
154
+ >
155
+ <XqIcon v-if="options.icon" :name="options.icon" />
156
+ <template v-if="options.style !== 'icon'">{{ options.label }}</template>
157
+ </component>
158
+ </XqButton>
159
+ <XqButton
160
+ v-else
161
+ type="button"
162
+ :variant="variant"
163
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
164
+ :class="showsSpinner && 'pointer-events-none'"
165
+ :disabled="!!options.disabled || store.processing.value"
166
+ :title="options.tooltip"
167
+ :aria-label="options.style === 'icon' ? options.label : undefined"
168
+ :aria-busy="running || undefined"
169
+ @click="open"
170
+ >
171
+ <span :class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')">
172
+ <XqIcon v-if="options.icon" :name="options.icon" />
173
+ <template v-if="options.style !== 'icon'">{{ options.label }}</template>
174
+ </span>
175
+ <LoaderCircle
176
+ v-if="showsSpinner"
177
+ class="motion-safe:animate-spin absolute"
178
+ aria-hidden="true"
179
+ />
180
+ </XqButton>
181
+ </template>
143
182
  <XqConfirmDialog
144
183
  v-if="options.confirmation && !options.hasForm"
145
184
  v-model:open="confirming"
146
185
  :title="options.heading"
147
186
  :message="options.description ?? store.t('Are you sure?')"
187
+ :icon="options.modalIcon"
188
+ :icon-color="options.modalIconColor ?? options.color"
189
+ :alignment="options.alignment"
190
+ :footer-alignment="options.footerAlignment"
148
191
  :confirm-label="options.submitLabel ?? store.t('Confirm')"
149
192
  :cancel-label="options.cancelLabel"
150
- :confirm-variant="variants[options.color] === 'destructive' ? 'destructive' : 'default'"
193
+ :confirm-variant="
194
+ actionVariants[options.color] === 'destructive' ? 'destructive' : 'default'
195
+ "
151
196
  @confirm="run"
152
197
  />
153
198
  <XqDialog
@@ -156,10 +201,15 @@ onScopeDispose(closeModal);
156
201
  :title="options.heading"
157
202
  :description="options.description"
158
203
  :width="options.width"
204
+ :icon="options.modalIcon"
205
+ :icon-color="options.modalIconColor"
206
+ :alignment="options.alignment"
207
+ :close-button="options.closeButton !== false"
208
+ :close-on-click-away="options.closeOnClickAway !== false"
159
209
  >
160
210
  <XqForm :form="modal" @submit="run">
161
211
  <template #actions>
162
- <div class="gap-2 flex justify-end">
212
+ <XqDialogFooter sticky :alignment="options.footerAlignment">
163
213
  <XqButton
164
214
  type="button"
165
215
  variant="outline"
@@ -168,15 +218,22 @@ onScopeDispose(closeModal);
168
218
  >
169
219
  {{ options.cancelLabel ?? store.t('Cancel') }}
170
220
  </XqButton>
171
- <XqButton type="submit" size="sm" :disabled="running">
221
+ <XqButton
222
+ type="submit"
223
+ size="sm"
224
+ :class="showsSpinner && 'pointer-events-none'"
225
+ :aria-busy="running || undefined"
226
+ >
227
+ <span :class="showsSpinner && 'invisible'">
228
+ {{ options.submitLabel ?? store.t('Save') }}
229
+ </span>
172
230
  <LoaderCircle
173
- v-if="running"
174
- class="motion-safe:animate-spin"
231
+ v-if="showsSpinner"
232
+ class="motion-safe:animate-spin absolute"
175
233
  aria-hidden="true"
176
234
  />
177
- {{ options.submitLabel ?? store.t('Save') }}
178
235
  </XqButton>
179
- </div>
236
+ </XqDialogFooter>
180
237
  </template>
181
238
  </XqForm>
182
239
  </XqDialog>
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import { Link } from '@inertiajs/vue3';
3
+ import { LoaderCircle } from 'lucide-vue-next';
4
+ import {
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuPortal,
8
+ DropdownMenuRoot,
9
+ DropdownMenuTrigger,
10
+ } from 'reka-ui';
11
+ import { computed, ref } from 'vue';
12
+ import type { ComponentPublicInstance } from 'vue';
13
+ import { useFormStore } from '../composables/context';
14
+ import { cn, isOwnUrl } from '../lib/utils';
15
+ import type { SchemaNode } from '../schema/types';
16
+ import { actionVariants, optionClass, popoverClass } from '../ui/classes';
17
+ import XqButton from '../ui/XqButton.vue';
18
+ import XqIcon from '../ui/XqIcon.vue';
19
+ import XqAction from './XqAction.vue';
20
+ import type { ActionRunner } from './XqAction.vue';
21
+
22
+ /**
23
+ * A menu of actions. Closing a menu destroys what's in it, so each item only
24
+ * asks an XqAction outside the menu to open, and the dialogs live there.
25
+ */
26
+ const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
27
+ const form = props.runner ? null : useFormStore();
28
+ const store = props.runner ?? form!;
29
+ const options = computed(() => props.node.props ?? {});
30
+ const items = computed(() =>
31
+ (props.node.children ?? []).filter((item) => form?.isVisible(item) ?? !item.hidden),
32
+ );
33
+ const headlessActions = computed(() => items.value.filter((item) => !item.props?.url));
34
+ const hasLabel = computed(() => !!options.value.label);
35
+ const showsSpinner = ref(false);
36
+ const actions = new Map<string, { open: () => Promise<void> }>();
37
+ let selected: string | null = null;
38
+
39
+ function remember(key: string, action: Element | ComponentPublicInstance | null): void {
40
+ if (action) {
41
+ actions.set(key, action as unknown as { open: () => Promise<void> });
42
+ } else {
43
+ actions.delete(key);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Waits for the menu to give focus back to its button, so that a dialog, which
49
+ * returns focus to wherever it found it, comes back to the button too.
50
+ */
51
+ function openSelected(): void {
52
+ if (selected) {
53
+ void actions.get(selected)?.open();
54
+ selected = null;
55
+ }
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <div class="inline-flex" data-xq-action-group>
61
+ <DropdownMenuRoot v-if="items.length > 0" :modal="false">
62
+ <DropdownMenuTrigger as-child>
63
+ <XqButton
64
+ type="button"
65
+ :variant="hasLabel ? (actionVariants[options.color] ?? 'outline') : 'ghost'"
66
+ :size="hasLabel ? 'sm' : 'icon-sm'"
67
+ :class="showsSpinner && 'pointer-events-none'"
68
+ :disabled="store.processing.value"
69
+ :title="options.tooltip"
70
+ :aria-label="hasLabel ? undefined : store.t('Actions')"
71
+ :aria-busy="showsSpinner || undefined"
72
+ >
73
+ <span
74
+ :class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')"
75
+ >
76
+ <XqIcon :name="options.icon ?? 'ellipsis-vertical'" />
77
+ <template v-if="hasLabel">{{ options.label }}</template>
78
+ </span>
79
+ <LoaderCircle
80
+ v-if="showsSpinner"
81
+ class="motion-safe:animate-spin absolute"
82
+ aria-hidden="true"
83
+ />
84
+ </XqButton>
85
+ </DropdownMenuTrigger>
86
+ <DropdownMenuPortal>
87
+ <DropdownMenuContent
88
+ align="end"
89
+ :side-offset="4"
90
+ :class="cn(popoverClass, 'p-1 min-w-40')"
91
+ @close-auto-focus="openSelected"
92
+ >
93
+ <template v-for="item in items" :key="item.key">
94
+ <DropdownMenuItem
95
+ v-if="item.props?.url"
96
+ as-child
97
+ :class="cn(optionClass, 'pr-2')"
98
+ >
99
+ <component
100
+ :is="
101
+ !item.props.openInNewTab && isOwnUrl(item.props.url)
102
+ ? Link
103
+ : 'a'
104
+ "
105
+ :href="item.props.url"
106
+ :target="item.props.openInNewTab ? '_blank' : undefined"
107
+ :rel="item.props.openInNewTab ? 'noopener noreferrer' : undefined"
108
+ >
109
+ <XqIcon v-if="item.props.icon" :name="item.props.icon" />
110
+ {{ item.props.label }}
111
+ </component>
112
+ </DropdownMenuItem>
113
+ <DropdownMenuItem
114
+ v-else
115
+ :disabled="!!item.props?.disabled"
116
+ :class="
117
+ cn(
118
+ optionClass,
119
+ 'pr-2',
120
+ item.props?.color === 'danger' &&
121
+ 'text-xq-destructive data-[highlighted]:text-xq-destructive',
122
+ )
123
+ "
124
+ @select="selected = item.key"
125
+ >
126
+ <XqIcon v-if="item.props?.icon" :name="item.props.icon" />
127
+ {{ item.props?.label }}
128
+ </DropdownMenuItem>
129
+ </template>
130
+ </DropdownMenuContent>
131
+ </DropdownMenuPortal>
132
+ </DropdownMenuRoot>
133
+ <div hidden>
134
+ <XqAction
135
+ v-for="item in headlessActions"
136
+ :key="item.key"
137
+ :ref="(action) => remember(item.key, action)"
138
+ :node="item"
139
+ :runner="store"
140
+ headless
141
+ @spinner="showsSpinner = $event"
142
+ />
143
+ </div>
144
+ </div>
145
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import { router } from '@inertiajs/vue3';
3
+ import { CircleAlert } from 'lucide-vue-next';
4
+ import { computed, onScopeDispose, shallowRef, watch } from 'vue';
5
+ import { createFormStore } from '../core/store';
6
+ import type { FormSchema } from '../schema/types';
7
+ import XqAction from './XqAction.vue';
8
+ import XqActionGroup from './XqActionGroup.vue';
9
+ import type { ActionRunner } from './XqAction.vue';
10
+
11
+ /**
12
+ * The buttons of an ActionSet, with no form around them.
13
+ */
14
+ const props = defineProps<{ actions: FormSchema }>();
15
+ const store = shallowRef(createFormStore(props.actions));
16
+ const visibleActions = computed(() => props.actions.components.filter((node) => !node.hidden));
17
+
18
+ /**
19
+ * Resolves once the page has the new data, so the button stays busy until then.
20
+ *
21
+ * shortcut: a global "before" listener that cancels this leaves the button busy.
22
+ * Forms' own unsaved changes alert ignores reloads, so only an app's own could.
23
+ */
24
+ function reloadPage(): Promise<void> {
25
+ return new Promise((resolve) => router.reload({ onFinish: () => resolve() }));
26
+ }
27
+
28
+ // One runner for good: a new one, or a new key, would rebuild the buttons and lose focus.
29
+ const runner: ActionRunner = {
30
+ get processing() {
31
+ return store.value.processing;
32
+ },
33
+ t: (...args) => store.value.t(...args),
34
+ mountAction: (node) => store.value.mountAction(node),
35
+ async runAction(node, modal) {
36
+ const result = await store.value.runAction(node, modal);
37
+
38
+ if (result && !result.redirect) {
39
+ await reloadPage();
40
+ }
41
+
42
+ return result;
43
+ },
44
+ };
45
+
46
+ // With no fields to land on, a failure has to show here.
47
+ const failure = computed(() => {
48
+ const { errors, fatalError, liveError, t } = store.value;
49
+
50
+ return (
51
+ fatalError.value ??
52
+ (liveError.value ? t('That action could not run.') : Object.values(errors)[0])
53
+ );
54
+ });
55
+
56
+ watch(
57
+ () => props.actions,
58
+ (next) => {
59
+ store.value.dispose();
60
+ store.value = createFormStore(next);
61
+ },
62
+ );
63
+
64
+ onScopeDispose(() => store.value.dispose());
65
+ </script>
66
+
67
+ <template>
68
+ <div class="gap-2 flex flex-wrap items-center" data-xq-actions>
69
+ <component
70
+ :is="node.type === 'action-group' ? XqActionGroup : XqAction"
71
+ v-for="node in visibleActions"
72
+ :key="node.key"
73
+ :node="node"
74
+ :runner="runner"
75
+ />
76
+ <p
77
+ v-if="failure"
78
+ role="alert"
79
+ class="gap-2 text-sm flex basis-full items-start text-xq-destructive"
80
+ >
81
+ <CircleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
82
+ {{ failure }}
83
+ </p>
84
+ </div>
85
+ </template>
@@ -135,6 +135,7 @@ function submit(): void {
135
135
  </XqButton>
136
136
  </div>
137
137
  <slot
138
+ v-if="!store.isFormDisabled"
138
139
  name="actions"
139
140
  :store="store"
140
141
  :processing="processing"
@@ -1,6 +1,7 @@
1
1
  import { toComponent } from '../composables/registry';
2
2
  import type { ComponentRegistry, ComponentResolver } from '../composables/registry';
3
3
  import XqAction from './XqAction.vue';
4
+ import XqActionGroup from './XqActionGroup.vue';
4
5
  import XqCheckboxField from './fields/XqCheckboxField.vue';
5
6
  import XqRadioField from './fields/XqRadioField.vue';
6
7
  import XqRepeaterField from './fields/XqRepeaterField.vue';
@@ -55,6 +56,7 @@ export function defaultComponents(): Record<string, ComponentResolver> {
55
56
  fieldset: XqFieldset,
56
57
  tabs: XqTabsLayout,
57
58
  action: XqAction,
59
+ 'action-group': XqActionGroup,
58
60
  actions: XqActions,
59
61
  wizard: XqWizard,
60
62
  block: XqBlock,
@@ -0,0 +1,64 @@
1
+ import { router } from '@inertiajs/vue3';
2
+ import { createToastManager } from 'reka-ui';
3
+ import { isOwnUrl } from '../lib/utils';
4
+ import type { ActionResult, XqNotification, XqNotificationAction } from '../schema/types';
5
+ import { HttpError, postJson } from './http';
6
+
7
+ /** Where the server puts notifications for the next page. See NotificationQueue. */
8
+ export const FLASH_KEY = 'xelqunNotifications';
9
+
10
+ export const notificationManager =
11
+ createToastManager<Pick<XqNotification, 'icon' | 'closeLabel' | 'actions'>>();
12
+
13
+ /**
14
+ * Show a notification.
15
+ */
16
+ export function notify(notification: XqNotification): void {
17
+ const { title, body, status, icon, duration, persistent, actions, closeLabel } = notification;
18
+
19
+ notificationManager.add({
20
+ title: title ?? undefined,
21
+ description: body ?? undefined,
22
+ status: status ?? undefined,
23
+ type: status === 'danger' || status === 'warning' ? 'foreground' : 'background',
24
+ duration: persistent ? Number.POSITIVE_INFINITY : (duration ?? undefined),
25
+ data: { icon, closeLabel, actions },
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Press a button that runs an action, then do what any action does afterwards.
31
+ * Says whether it worked, and a failure shows as a notification of its own.
32
+ */
33
+ export async function runNotificationAction(action: XqNotificationAction): Promise<boolean> {
34
+ const { endpoint, token, action: key } = action.run!;
35
+
36
+ try {
37
+ const result = await postJson<ActionResult>(endpoint, {
38
+ token,
39
+ action: key,
40
+ phase: 'run',
41
+ state: { data: {} },
42
+ deps: {},
43
+ });
44
+
45
+ result.notifications?.forEach(notify);
46
+
47
+ if (!result.redirect) {
48
+ router.reload();
49
+ } else if (isOwnUrl(result.redirect)) {
50
+ router.visit(result.redirect);
51
+ } else {
52
+ window.location.assign(result.redirect);
53
+ }
54
+
55
+ return true;
56
+ } catch (failure) {
57
+ const [message] =
58
+ failure instanceof HttpError ? Object.values(failure.validationErrors).flat() : [];
59
+
60
+ notify({ title: message ?? action.failureMessage, status: 'danger' });
61
+
62
+ return false;
63
+ }
64
+ }