Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- Improved the styling of control panel tables and lightswitches. ([#19766](https://github.com/craftcms/cms/pull/19766))
- Improved disabled secondary form actions with explanatory tooltips.
- The “Assets” system settings nav item is now listed under “Content”, and “Globals”, “Categories”, and “Tags” are now listed under a new “Deprecated” heading, if those concepts are supported for the project. ([#19670](https://github.com/craftcms/cms/pull/19670))
- Added reusable server-built form modals and the `formModal` element action behavior. ([#19768](https://github.com/craftcms/cms/pull/19768))
- Added the `<craft-timeline-item>` web component. ([#19629](https://github.com/craftcms/cms/pull/19629))
- Added the `placeholder` property to `<craft-thumbnail>`, for an image to show while the thumbnail loads. ([#19750](https://github.com/craftcms/cms/pull/19750))
- Added the `--c-thumbnail-image-radius` custom property to `<craft-thumbnail>`, and its other custom properties can now be set on an ancestor. ([#19758](https://github.com/craftcms/cms/pull/19758))
Expand Down
18 changes: 18 additions & 0 deletions resources/js/modules/elements/composables/useElementActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {openSlideout, type SlideoutController} from '@/common/slideouts';
import type {ActionItem} from '@/common/types';
import {ElementDeletionManager} from '@/modules/element-deletion-manager';
import type {FormProperties, FormValues} from '@/modules/forms/types';
import {openFormModal} from '@/modules/forms/open-form-modal';
import {
openImageEditorDialog,
type ImageEditorSettings,
Expand Down Expand Up @@ -52,6 +53,13 @@ export type ElementActionBehavior =
url: string;
entryTypeFromField?: boolean;
}
/** Loads and submits a server-built form, then reloads the page. */
| {
type: 'formModal';
modalUrl: string;
actionUrl: string;
params?: FormValues;
}
// The asset behaviors below all hand off to a modal or uploader, and reload
// the page afterwards rather than patching the file's details into it.
| {
Expand Down Expand Up @@ -200,6 +208,16 @@ export function createElementActionMenu({
return;
}

case 'formModal':
void openFormModal({
modalUrl: behavior.modalUrl,
actionUrl: behavior.actionUrl,
params: behavior.params,
onSubmitted: () => router.reload(),
});

return;

case 'previewFile':
new Craft.PreviewFileModal(behavior.assetId, behavior.settings ?? {});

Expand Down
111 changes: 111 additions & 0 deletions resources/js/modules/forms/FormModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script setup lang="ts">
import {actionClient, t} from '@craftcms/ui';
import {onMounted, shallowRef, useTemplateRef} from 'vue';
import ModalForm from '@/common/components/ModalForm.vue';
import FormRenderer from './FormRenderer.vue';
import type {FormPayload, FormValues} from './types';

export interface FormModalResponse {
form: FormPayload;
title?: string | null;
submitLabel?: string | null;
}

const props = withDefaults(
defineProps<{
modalUrl: string;
actionUrl: string;
params?: FormValues;
/** Used when the server doesn't send a title. */
title?: string;
/** Used when the server doesn't send a submit label. */
submitLabel?: string;
width?: string;
}>(),
{
params: () => ({}),
title: undefined,
submitLabel: () => t('Save'),
width: 'md',
}
);

const emit = defineEmits<{
(event: 'close'): void;
(event: 'submitted', data: Record<string, unknown>): void;
}>();

const modal = shallowRef<FormModalResponse | null>(null);
const errors = shallowRef<FormPayload['errors']>([]);
const loading = shallowRef(false);
const renderer =
useTemplateRef<InstanceType<typeof FormRenderer>>('renderer');

onMounted(async () => {
try {
const {data} = await actionClient.get<FormModalResponse>(props.modalUrl, {
params: props.params,
});
errors.value = data.form.errors ?? [];
modal.value = data;
} catch (error: any) {
Craft.cp?.displayError?.(
error?.response?.data?.message ?? t('A server error occurred.')
);
emit('close');
}
});

async function submit(): Promise<void> {
if (!renderer.value || loading.value || !renderer.value.canSubmit()) {
return;
}

loading.value = true;
errors.value = [];

try {
const {data} = await actionClient.post<Record<string, unknown>>(
props.actionUrl,
{...renderer.value.currentValues(), ...props.params}
);

if (typeof data?.message === 'string') {
Craft.cp?.displayNotice?.(data.message);
}

emit('submitted', data ?? {});
} catch (error: any) {
const responseErrors: Record<string, string | string[]> =
error?.response?.data?.errors ?? {};
errors.value = Object.entries(responseErrors).map(([path, messages]) => ({
path: path.split('.'),
messages: Array.isArray(messages) ? messages : [messages],
}));
Craft.cp?.displayError?.(
error?.response?.data?.message ?? t('A server error occurred.')
);
} finally {
loading.value = false;
}
}
</script>

<template>
<ModalForm
:is-active="modal !== null"
:title="modal?.title ?? title"
:submit-label="modal?.submitLabel ?? submitLabel"
:loading="loading"
:width="width"
@close="emit('close')"
@submit="submit"
>
<FormRenderer
v-if="modal"
ref="renderer"
:payload="modal.form"
:errors="errors"
/>
</ModalForm>
</template>
60 changes: 60 additions & 0 deletions resources/js/modules/forms/open-form-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type {App} from 'vue';
import type {FormValues} from './types';

export interface FormModalOptions {
modalUrl: string;
actionUrl: string;
params?: FormValues;
title?: string;
submitLabel?: string;
width?: string;
onSubmitted?: (data: Record<string, unknown>) => void;
}

/**
* Opens a server-built form in a detached Vue app with registered CP controls.
* The app, host, and registry installation are removed on close or submission.
*/
export async function openFormModal({
onSubmitted,
...props
}: FormModalOptions): Promise<void> {
const [{createApp, h}, {default: FormModal}, {cpComponentRegistry}] =
await Promise.all([
import('vue'),
import('./FormModal.vue'),
import('@/bootstrap/components'),
]);

const host = document.createElement('div');
document.body.append(host);

let closed = false;
const close = () => {
if (closed) {
return;
}

closed = true;
app.unmount();
host.remove();
};

const app: App = createApp({
render: () =>
h(FormModal, {
...props,
onClose: close,
onSubmitted: (data: Record<string, unknown>) => {
close();
onSubmitted?.(data);
},
}),
});

cpComponentRegistry.install(app);
app.onUnmount(() => cpComponentRegistry.uninstall(app));
app.config.compilerOptions.isCustomElement = (tag: string) =>
tag.includes('-');
app.mount(host);
}
Loading