Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6217c48
extract VueComponent into Statamic\Support for reuse beyond widgets
duncanmcclean Jul 21, 2026
c36dab8
connections config
duncanmcclean Jul 21, 2026
c843711
concept of a "form connection"
duncanmcclean Jul 21, 2026
738f566
connections index
duncanmcclean Jul 21, 2026
6182915
connection show page
duncanmcclean Jul 21, 2026
56cc9bf
add webhooks connection
duncanmcclean Jul 21, 2026
ea95006
delete some components we don't need anymore
duncanmcclean Jul 21, 2026
d2cdcca
update webhook tests
duncanmcclean Jul 21, 2026
0715b38
Merge branch 'forms-2' into forms-2-connections
duncanmcclean Jul 22, 2026
4aba03f
ability to duplicate webhooks
duncanmcclean Jul 22, 2026
d1a071b
add `count()` method when registering connections
duncanmcclean Jul 22, 2026
46eeec7
move translation calls inside getter to avoid boot errors
duncanmcclean Jul 22, 2026
0ffbf29
extract list & logic into re-usable components
duncanmcclean Jul 22, 2026
e176c03
add email connection
duncanmcclean Jul 22, 2026
c4af17c
combine slots and avoid exporting internal component
duncanmcclean Jul 22, 2026
d3e0676
extract logic conditions summary
duncanmcclean Jul 22, 2026
5c45400
add "connections" column to forms index
duncanmcclean Jul 22, 2026
388c398
support conditions for webhooks
duncanmcclean Jul 22, 2026
30bb04b
create `ConnectionLogic` class to handle logic stuff on the backend
duncanmcclean Jul 22, 2026
07d7327
avoid triggering a save by hitting return
duncanmcclean Jul 22, 2026
62de534
avoid leaking submission data in webhook payload example
duncanmcclean Jul 22, 2026
d7d36c1
refactor how form connections are registered
duncanmcclean Jul 22, 2026
29d7e78
chain file upload & connection jobs together
duncanmcclean Jul 22, 2026
5c6b7e8
garbage collect form file uploads that aren't deleted by `DeleteTempo…
duncanmcclean Jul 22, 2026
1840e70
exclude informational and structural fieldtypes from email fields loop
duncanmcclean Jul 22, 2026
335e65b
make emails fields dropdowns so users can select fields, rather than …
duncanmcclean Jul 23, 2026
16c4271
only show "x" button when hovering over conditions
duncanmcclean Jul 23, 2026
1615514
add validation to email address fields
duncanmcclean Jul 23, 2026
807f565
make sender field clearable
duncanmcclean Jul 23, 2026
eea1cea
rename
duncanmcclean Jul 23, 2026
eb895f1
mark as deprecated
duncanmcclean Jul 23, 2026
46ff8ea
Merge branch 'forms-2' into forms-2-connections
duncanmcclean Jul 23, 2026
a31a7f3
formatting
duncanmcclean Jul 23, 2026
ad589c8
add missing function import
duncanmcclean Jul 23, 2026
5ce9ef6
Merge branch 'forms-2' into forms-2-connections
duncanmcclean Jul 23, 2026
55d6ae3
fix file upload garbage collection
duncanmcclean Jul 23, 2026
66848a3
don't split from field. it doesn't need it
duncanmcclean Jul 23, 2026
170a760
grab a fresh copy of the submission before sending emails
duncanmcclean Jul 23, 2026
df5f35b
fix failing tests
duncanmcclean Jul 27, 2026
7fba2a5
make phpstan happy
duncanmcclean Jul 27, 2026
1aa857a
Merge branch 'forms-2' into forms-2-connections
duncanmcclean Aug 5, 2026
e0b6204
connection rows should be expanded when you visit the page
duncanmcclean Aug 5, 2026
70f25e9
ensure all emails/webhook rows have ids
duncanmcclean Aug 5, 2026
9683c0a
rename
duncanmcclean Aug 5, 2026
82ddf76
typehints
duncanmcclean Aug 5, 2026
9bfed2c
wip
duncanmcclean Aug 5, 2026
7d1e005
be consistent. use a blueprint here too.
duncanmcclean Aug 5, 2026
d706918
wip
duncanmcclean Aug 5, 2026
78a0193
show confirmation modal before deleting emails/webhooks
duncanmcclean Aug 5, 2026
917c6ce
clean up field instructions
duncanmcclean Aug 5, 2026
36742d7
avoid tracking dirty state on the publish container. we're already tr…
duncanmcclean Aug 5, 2026
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
23 changes: 13 additions & 10 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
'elevated_session_verification_code_notification_body' => 'Copy and paste the code shown below to verify your identity. If you didn\'t request this code, you can ignore this email.',
'elevated_session_verification_code_notification_subject' => 'Your Verification Code',
'elevated_session_verification_code_sent' => 'Verification code has been sent to your email.',
'email_connection_delete_confirmation' => 'Are you sure you want to delete this email?',
'email_connection_description' => 'Send email notifications when submissions are received.',
'email_connection_test_sent' => 'Test email sent to :email.',
'email_utility_configuration_description' => 'Mail settings are configured in <code>:path</code>',
'email_utility_description' => 'Check email configuration settings and send test emails.',
'entry_count' => ':count entry|:count entries',
Expand Down Expand Up @@ -120,22 +123,17 @@
'forgot_password_enter_email' => 'Enter your email address to receive a password reset link.',
'forgot_password_sent' => 'If an account with that email address exists, a password reset link has been sent.',
'form_configure_blueprint_instructions' => 'Choose from existing blueprints or create a new one.',
'form_configure_email_attachments_instructions' => 'Attach uploaded assets to this email.',
'form_configure_email_bcc_instructions' => 'Email address of the BCC recipient(s) - comma separated.',
'form_configure_email_cc_instructions' => 'Email address of the CC recipient(s) - comma separated.',
'form_configure_email_attachments_instructions' => 'Attach files uploaded with the submission.',
'form_configure_email_from_instructions' => 'Leave blank to fall back to the site default.',
'form_configure_email_html_instructions' => 'The view for the HTML version of this email.',
'form_configure_email_instructions' => 'Configure emails to be sent when new form submissions are received.',
'form_configure_email_views_instructions' => 'Leave both blank to use the default layout.',
'form_configure_email_description' => 'Be sure your email settings are configured to send and receive notifications when forms are submitted.',
'form_configure_email_markdown_instructions' => 'Render the HTML version of this email using Markdown.',
'form_configure_email_reply_to_instructions' => 'Leave blank to fall back to sender.',
'form_configure_email_subject_instructions' => 'Email subject line.',
'form_configure_email_text_instructions' => 'The view for the text version of this email.',
'form_configure_email_to_instructions' => 'Email address of the recipient(s) - comma separated.',
'form_configure_email_reply_to_instructions' => 'Leave blank to fall back to the sender.',
'form_configure_email_to_instructions' => 'Choose a form field or enter an email address.',
'form_configure_handle_instructions' => 'Used to reference this form on the frontend. This cannot be easily changed later.',
'form_configure_honeypot_instructions' => 'Field name to use as a honeypot. Honeypots are special fields used to reduce bot spam.',
'form_configure_intro' => 'Forms collect information from visitors and can trigger events and send notifications when submissions are received.',
'form_configure_mailer_instructions' => 'Choose the mailer for sending this email. Leave blank to fall back to the default mailer.',
'form_configure_mailer_instructions' => 'Leave blank to fall back to the default mailer.',
'form_configure_generate_fake_submissions_instructions' => 'Allow generating fake submissions and workflow testing from the submissions screen.',
'form_configure_store_instructions' => 'Disable to stop storing submissions. Events and email notifications will still be sent.',
'form_configure_title_instructions' => 'Use a call to action, such as \'Contact Us\'.',
Expand Down Expand Up @@ -323,5 +321,10 @@
'user_wizard_invitation_subject' => 'Activate your new Statamic account on :site',
'user_wizard_roles_groups_intro' => 'Users can be assigned to roles that customize their permissions, access, and abilities throughout the Control Panel.',
'user_wizard_super_admin_instructions' => 'Super admins have complete control and access to everything in the control panel. Grant this role wisely.',
'webhook_connection_delete_confirmation' => 'Are you sure you want to delete this webhook?',
'webhook_connection_description' => 'Send submissions to an external URL as they are received.',
'webhook_connection_payload_instructions' => 'Each submission is sent to the webhook URL as a JSON POST request.',
'webhook_connection_test_sent' => 'Test request sent successfully.',
'webhook_connection_verify_ssl_instructions' => 'Only disable this if the receiving server uses a self-signed certificate.',
'width_x_height' => ':width × :height',
];
1 change: 1 addition & 0 deletions lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
'elevated_session_resend_code_unavailable' => 'Resend code is only available for verification code method.',
'elevated_session_verification_code' => 'The verification code is incorrect.',
'email_available' => 'A user with this email already exists.',
'email_field_reference' => "References a field that doesn't exist on this form.",
'fieldset_imported_recursively' => 'Fieldset :handle is being imported recursively.',
'one_site_without_origin' => 'At least one site must not have an origin.',
'options_require_keys' => 'All options must have keys.',
Expand Down
2 changes: 2 additions & 0 deletions packages/cms/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const {
RevisionPreview,
SaveButtonOptions,
SortableList,
ConnectionList,
ConnectionLogic,
requireElevatedSession,
requireElevatedSessionIf,
clone,
Expand Down
2 changes: 2 additions & 0 deletions resources/js/bootstrap/cms/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ export { default as resetValuesFromResponse } from '../../util/resetValuesFromRe
export { requireElevatedSession, requireElevatedSessionIf } from '../../components/elevated-sessions';
export { default as clone, deepClone } from '../../util/clone.js';
export { default as debounce } from '../../util/debounce.js';
export { default as ConnectionList } from '../../components/forms/connections/ConnectionList.vue';
export { default as ConnectionLogic } from '../../components/forms/connections/ConnectionLogic.vue';
4 changes: 4 additions & 0 deletions resources/js/bootstrap/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export default function registerGlobalComponents(app) {
app.component('form-widget', FormWidget);
app.component('updater-widget', UpdaterWidget);

// Form Connections
app.component('email-connection', defineAsyncComponent(() => import('../components/forms/connections/EmailConnection.vue')));
app.component('webhook-connection', defineAsyncComponent(() => import('../components/forms/connections/WebhookConnection.vue')));

// Reusable
app.component('file-icon', FileIcon);

Expand Down
112 changes: 112 additions & 0 deletions resources/js/components/forms/connections/ConnectionList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<script setup>
import { computed, ref } from 'vue';
import { Button, ConfirmationModal } from '@ui';
import { SortableList } from '@/components/sortable/Sortable.js';
import LogicEmptyState from '@/components/forms/logic/LogicEmptyState.vue';
import ConnectionListItem from './ConnectionListItem.vue';

const emit = defineEmits(['add', 'duplicate', 'remove', 'update:modelValue']);

const props = defineProps({
modelValue: { type: Array, default: () => [] },
addLabel: String,
emptyHeading: String,
emptyDescription: String,
deleteHeading: String,
deleteDescription: String,
hasError: { type: Function, default: () => false },
});

const sortableItemClass = 'connection-list-item';
const sortableHandleClass = 'connection-list-handle';

const items = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});

const collapsed = ref([]);

const collapse = (id) => {
if (!collapsed.value.includes(id)) {
collapsed.value.push(id);
}
};

const expand = (id) => (collapsed.value = collapsed.value.filter((itemId) => itemId !== id));

const confirmingRemoval = ref(null);

const remove = () => {
emit('remove', confirmingRemoval.value);
confirmingRemoval.value = null;
};
</script>

<template>
<LogicEmptyState v-if="items.length === 0" :heading="emptyHeading" :description="emptyDescription">
<Button size="sm" :text="addLabel" icon="plus" @click="emit('add')" />
</LogicEmptyState>

<template v-else>
<SortableList
v-model="items"
vertical
constrain-dimensions
:item-class="sortableItemClass"
:handle-class="sortableHandleClass"
>
<div class="relative space-y-6 mb-0" data-connection-list>
<div v-for="(item, index) in items" :key="item.id" :class="sortableItemClass">
<ConnectionListItem
:collapsed="collapsed.includes(item.id)"
:enabled="item.enabled !== false"
:has-error="hasError(index)"
:handle-class="sortableHandleClass"
@collapsed="collapse(item.id)"
@expanded="expand(item.id)"
@duplicated="emit('duplicate', item)"
@removed="confirmingRemoval = item"
@update:enabled="item.enabled = $event"
>
<template #header>
<slot name="header" :item="item" :index="index" :collapsed="collapsed.includes(item.id)" />
</template>

<slot :item="item" :index="index" />
</ConnectionListItem>
</div>
</div>
</SortableList>

<div class="inline-flex relative pt-6">
<div class="absolute inset-y-0 h-full left-3.5 border-l-1 border-gray-400 dark:border-gray-600 border-dashed z-0 dark:bg-gray-850" />
<Button size="sm" :text="addLabel" icon="plus" class="relative" @click="emit('add')" />
</div>
</template>

<ConfirmationModal
:open="confirmingRemoval !== null"
:title="deleteHeading"
:body-text="deleteDescription"
:button-text="__('Delete')"
danger
@update:open="confirmingRemoval = null"
@confirm="remove"
/>
</template>

<style scoped>
[data-connection-list]::before {
content: '';
position: absolute;
top: 1.5rem;
bottom: 0;
inset-inline-start: 0.875rem;
border-inline-start: 1px dashed var(--color-gray-400);
}

.dark [data-connection-list]::before {
border-inline-start-color: var(--color-gray-600);
}
</style>
77 changes: 77 additions & 0 deletions resources/js/components/forms/connections/ConnectionListItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script setup>
import { Button, DragHandle, Dropdown, DropdownItem, DropdownMenu, Switch } from '@ui';

const emit = defineEmits(['collapsed', 'expanded', 'duplicated', 'removed', 'update:enabled']);

const props = defineProps({
collapsed: Boolean,
enabled: Boolean,
hasError: Boolean,
handleClass: String,
});

function toggleCollapsedState() {
props.collapsed ? emit('expanded') : emit('collapsed');
}
</script>

<template>
<div
data-connection-config
class="@container relative w-full rounded-lg border border-gray-300 text-base dark:border-white/10 bg-white dark:bg-gray-900 dark:inset-shadow-2xs dark:inset-shadow-black shadow-ui-sm"
:class="{ 'border-red-500': hasError }"
:data-collapsed="collapsed ?? undefined"
:data-error="hasError ?? undefined"
>
<header
class="group/header animate-border-color flex items-center show-focus-within rounded-[calc(var(--radius-lg)-1px)] px-1.5 antialiased duration-200 dark:bg-gray-925 border-gray-300 dark:shadow-md"
:class="{
'bg-white dark:bg-gray-900': collapsed,
'bg-gray-200/50 dark:bg-gray-950/35 rounded-b-none': !collapsed,
}"
>
<DragHandle :class="handleClass" class="ms-1 cursor-grab [&_svg]:opacity-75 dark:[&_svg]:opacity-50" />
<button type="button" class="show-focus-within_target flex flex-1 items-center gap-1.75 p-2 py-1.75 min-w-0 focus:outline-none cursor-pointer" @click="toggleCollapsedState">
<slot name="header" />
</button>
<div class="flex items-center gap-2">
<Switch
size="xs"
:model-value="enabled"
@update:model-value="emit('update:enabled', $event)"
v-tooltip="enabled ? __('Enabled') : __('Disabled')"
/>
<Dropdown>
<template #trigger>
<Button icon="dots" variant="ghost" size="xs" class="me-2" :aria-label="__('Open row actions')" />
</template>
<DropdownMenu>
<DropdownItem
:text="__('Duplicate')"
icon="duplicate"
@click="emit('duplicated')"
/>
<DropdownItem
:text="__('Delete')"
icon="trash"
variant="destructive"
@click="emit('removed')"
/>
</DropdownMenu>
</Dropdown>
</div>
</header>

<div
v-show="!collapsed"
:class="{ 'contain-paint': collapsed, 'isolate': !collapsed }"
class="border-t border-t-gray-300! dark:border-t-white/10!"
>
<div :tabindex="collapsed ? -1 : undefined" :inert="collapsed">
<div class="p-4">
<slot />
</div>
</div>
</div>
</div>
</template>
Loading
Loading