Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
24d5d7e
add `unique_instances` toggle
duncanmcclean Aug 18, 2026
d4b66cf
support `entry` key on submissions
duncanmcclean Aug 18, 2026
1894815
output hidden `_entry` field and refactor access feature to allow ove…
duncanmcclean Aug 18, 2026
112ab59
add entry column to submissions listing
duncanmcclean Aug 18, 2026
8114902
add entry filter to submissions listing
duncanmcclean Aug 18, 2026
f953e13
mock `statamic/forms-pro` check in tests
duncanmcclean Aug 19, 2026
bade644
show entry on submission show page
duncanmcclean Aug 22, 2026
5d9d5d3
add tests for the hidden `_entry` input
duncanmcclean Aug 24, 2026
d5a05fd
extract form config fields into a `ConfigFields` class
duncanmcclean Aug 24, 2026
abeb794
extract `SubmissionPublishForm` component
duncanmcclean Aug 24, 2026
1a11b18
add "view submissions" and "configure" options to the form fieldtype
duncanmcclean Aug 24, 2026
e6a3e7a
show the form's values as placeholders in the configure stack
duncanmcclean Aug 24, 2026
d51b14b
validate the form fieldtype against its handles
duncanmcclean Aug 24, 2026
2bf9c63
fall back to origin values when resolving entry overrides
duncanmcclean Aug 24, 2026
e903a5c
reject submissions from entries that don't use the form
duncanmcclean Aug 24, 2026
1a29b8c
resolve entry filter options from the store index
duncanmcclean Aug 24, 2026
53daf0a
support form fields nested in groups, grids, replicators and bard
duncanmcclean Aug 24, 2026
0c84f91
gate unique instance entries behind forms pro
duncanmcclean Aug 24, 2026
67698a2
use sync state for entry config overrides
duncanmcclean Aug 24, 2026
43d9ac4
don't translate the entry validation message
duncanmcclean Aug 24, 2026
7419a5d
Merge branch 'forms-2' into forms-2-unique-instances
duncanmcclean Aug 24, 2026
1f4ba35
Merge branch 'forms-2' into forms-2-unique-instances
duncanmcclean Aug 24, 2026
af20a26
drop the unused values from the configure stack payload
duncanmcclean Aug 24, 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
3 changes: 3 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
'form_configure_mailer_instructions' => 'Choose the mailer for sending this email. 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_unique_instances_instructions' => 'Treat each entry using this form as its own instance. Submissions will be attached to the entry they were submitted from.',
'form_configure_title_instructions' => 'Use a call to action, such as \'Contact Us\'.',
'form_configure_close_date_instructions' => 'The form will stop accepting submissions after this date. Leave blank to never close.',
'form_configure_submission_limit_instructions' => 'The maximum number of submissions to accept. Leave blank for no limit.',
Expand All @@ -148,6 +149,8 @@
'form_configure_require_login_instructions' => 'Only allow logged in users to submit this form.',
'form_configure_require_login_message_instructions' => 'Shown when a logged out visitor tries to submit the form. Leave blank to use the default message.',
'form_closed_message' => 'This form is no longer accepting submissions.',
'form_fieldtype_configure_instructions' => 'Edit a field to override the form\'s setting for this entry. Synced fields keep following the form.',
'form_fieldtype_sync_confirmation' => 'Are you sure? This override will be removed and the field will follow the form\'s setting again.',
'form_require_login_message' => 'You must be logged in to submit this form.',
'form_create_description' => 'Get started by creating your first form.',
'form_builder' => 'Form Builder',
Expand Down
6 changes: 6 additions & 0 deletions resources/js/bootstrap/fieldtypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import Grid from '../components/fieldtypes/grid/Grid.vue';
import GridIndex from '../components/fieldtypes/grid/GridIndex.vue';
import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue';
import FormBannerFieldtype from '../components/fieldtypes/FormBannerFieldtype.vue';
import FormFieldtype from '../components/fieldtypes/FormFieldtype.vue';
import FormIndexFieldtype from '../components/fieldtypes/FormIndexFieldtype.vue';
import FormRelatedItem from '../components/fieldtypes/FormRelatedItem.vue';
import FormHeadingFieldtype from '../components/fieldtypes/FormHeadingFieldtype.vue';
import FormParagraphFieldtype from '@/components/fieldtypes/FormParagraphFieldtype.vue';
import FormUploadFieldtype from '@/components/fieldtypes/FormUploadFieldtype.vue';
Expand Down Expand Up @@ -114,6 +117,9 @@ export default function registerFieldtypes(app) {
app.component('grid-fieldtype', Grid);
app.component('grid-fieldtype-index', GridIndex);
app.component('group-fieldtype', GroupFieldtype);
app.component('form-fieldtype', FormFieldtype);
app.component('form-fieldtype-index', FormIndexFieldtype);
app.component('form-related-item', FormRelatedItem);
app.component('form_banner-fieldtype', FormBannerFieldtype);
app.component('form_heading-fieldtype', FormHeadingFieldtype);
app.component('form_paragraph-fieldtype', FormParagraphFieldtype);
Expand Down
185 changes: 185 additions & 0 deletions resources/js/components/fieldtypes/FormFieldtype.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<div>
<RelationshipFieldtype
v-bind="$props"
:value="form"
@update:value="formUpdated"
@update:meta="$emit('update:meta', $event)"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>

<Stack v-if="submissionsMeta" v-model:open="viewingSubmissions" :title="__('Submissions')">
<SubmissionListing
:form="submissionsMeta.form"
:action-url="submissionsMeta.actionUrl"
:filters="submissionsMeta.filters"
view-in-stack
sort-column="datestamp"
sort-direction="desc"
@view="viewingSubmission = $event"
/>

<InlineSubmissionForm
v-if="viewingSubmission"
:url="viewingSubmission.url"
@closed="viewingSubmission = null"
/>
</Stack>

<Stack v-if="configurationMeta" v-model:open="configuringForm" size="half" :title="__('Configure')">
<p
class="mb-6 text-sm text-gray-600 dark:text-gray-300"
v-text="__('messages.form_fieldtype_configure_instructions')"
/>

<PublishContainer
v-model="configOverrides"
v-model:modified-fields="modifiedOverrides"
:blueprint="configurationMeta.blueprint"
:meta="configurationMeta.meta"
:origin-values="configurationMeta.originValues"
:origin-meta="configurationMeta.originMeta"
:sync-field-confirmation-text="__('messages.form_fieldtype_sync_confirmation')"
:track-dirty-state="false"
as-config
>
<PublishTabs />
</PublishContainer>

<template #footer-end>
<Button variant="ghost" :text="__('Cancel')" @click="configuringForm = false" />
<Button variant="primary" :text="__('Apply')" @click="applyConfigure" />
</template>
</Stack>
</div>
</template>

<script>
import clone from '@/util/clone.js';
import Fieldtype from './Fieldtype.vue';
import RelationshipFieldtype from './relationship/RelationshipFieldtype.vue';
import InlineSubmissionForm from '@/components/forms/InlineSubmissionForm.vue';
import SubmissionListing from '@/components/forms/SubmissionListing.vue';
import { Button, PublishContainer, PublishTabs, Stack } from '@/components/ui';

export default {
mixins: [Fieldtype],

components: {
RelationshipFieldtype,
InlineSubmissionForm,
SubmissionListing,
PublishContainer,
PublishTabs,
Button,
Stack,
},

provide() {
return {
formFieldtypeItem: {
hasSubmissions: () => !!this.submissionsMeta,
hasConfigure: () => !!this.configurationMeta,
viewSubmissions: () => (this.viewingSubmissions = true),
configure: () => (this.configuringForm = true),
},
};
},

data() {
return {
viewingSubmissions: false,
viewingSubmission: null,
configuringForm: false,
configOverrides: {},
modifiedOverrides: [],
};
},

computed: {
configurable() {
return this.meta.configurable;
},

form() {
return (this.configurable ? this.value?.form : this.value) ?? [];
},

submissionsMeta() {
const submissions = this.meta.submissions;

if (!submissions || !this.form.includes(submissions.form)) return null;

return submissions;
},

configurationMeta() {
const configuration = this.meta.configuration;

if (!configuration || !this.form.includes(configuration.form)) return null;

return configuration;
},

replicatorPreview() {
if (!this.showFieldPreviews) return;

return this.form.map((id) => {
const item = this.meta.data.find((d) => d.id === id);
return item ? item.title : id;
});
},

internalFieldActions() {
return [
{
title: __('Unlink All'),
dangerous: true,
run: this.unlinkAll,
visible: this.form.length > 0,
},
];
},
},

watch: {
viewingSubmissions(open) {
if (!open) this.viewingSubmission = null;
},

configuringForm(open) {
if (!open) return;

const config = clone(this.value?.config ?? {});

this.configOverrides = { ...clone(this.configurationMeta.originValues), ...config };
this.modifiedOverrides = Object.keys(config);
},
},

methods: {
formUpdated(ids) {
if (!this.configurable) return this.update(ids);

const config = ids[0] === this.form[0] ? this.value?.config : {};

this.update({ form: ids, config: config ?? {} });
},

unlinkAll() {
this.formUpdated([]);
this.updateMeta({ ...this.meta, data: [] });
},

applyConfigure() {
const config = Object.fromEntries(
Object.entries(this.configOverrides).filter(([handle]) => this.modifiedOverrides.includes(handle)),
);

this.update({ form: this.form, config });
this.configuringForm = false;
},
},
};
</script>
22 changes: 22 additions & 0 deletions resources/js/components/fieldtypes/FormIndexFieldtype.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup>
import { Badge } from '@ui';

defineProps({
handle: { type: String, required: true },
value: { required: true },
values: { required: true },
});
</script>

<template>
<div class="flex flex-wrap items-center gap-2">
<template v-for="item in value" :key="item.id">
<Badge size="sm" :href="item.edit_url">
<span v-text="item.title" />
</Badge>
<Badge v-if="item.submissions_url" size="sm" icon="eye" :href="item.submissions_url">
<span v-text="__('View Submissions')" />
</Badge>
</template>
</div>
</template>
81 changes: 81 additions & 0 deletions resources/js/components/fieldtypes/FormRelatedItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div
class="shadow-ui-sm relative z-(--z-index-above) flex w-full h-full items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 [&:has(.cursor-grab)]:px-1.5 py-1.5 mb-1.5 last:mb-0 text-base dark:border-gray-700 dark:with-contrast:border-gray-500 dark:bg-gray-900"
>
<ui-icon name="handles" class="item-move sortable-handle size-4 cursor-grab text-gray-300 dark:text-gray-700" v-if="sortable" />
<div class="flex flex-1 items-center line-clamp-1 text-sm text-gray-600 dark:text-gray-300">
<div
v-if="item.invalid"
v-tooltip.top="__('messages.relationship_item_unavailable')"
v-text="__(item.title)"
class="line-clamp-1 text-sm text-gray-500 dark:text-gray-400"
/>

<div v-else v-text="__(item.title)" />

<div class="flex flex-1 items-center justify-end">
<Button
v-if="formItem.hasSubmissions()"
size="xs"
variant="ghost"
icon="eye"
:text="__('View Submissions')"
@click="formItem.viewSubmissions()"
/>

<div class="flex items-center" v-if="!readOnly">
<Dropdown>
<template #trigger>
<Button icon="dots" variant="ghost" size="xs" v-bind="$attrs" :aria-label="__('Open dropdown menu')" />
</template>
<DropdownMenu>
<DropdownItem
v-if="formItem.hasConfigure()"
:text="__('Configure')"
@click="formItem.configure()"
/>
<DropdownItem
:text="__('Unlink')"
variant="destructive"
@click="$emit('removed')"
/>
</DropdownMenu>
</Dropdown>
</div>
</div>
</div>
</div>
</template>

<script>
import { Button, Dropdown, DropdownMenu, DropdownItem } from '@/components/ui';

export default {
components: {
Button,
DropdownItem,
DropdownMenu,
Dropdown,
},

inject: {
formItem: {
from: 'formFieldtypeItem',
},
},

props: {
item: Object,
config: Object,
statusIcon: Boolean,
editable: Boolean,
sortable: Boolean,
readOnly: Boolean,
formComponent: String,
formComponentProps: Object,
formStackSize: String,
},

emits: ['removed'],
};
</script>
43 changes: 43 additions & 0 deletions resources/js/components/forms/InlineSubmissionForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { Icon, Stack } from '@ui';
import SubmissionPublishForm from '@/components/forms/SubmissionPublishForm.vue';

const props = defineProps({
url: { type: String, required: true },
});

const emit = defineEmits(['closed']);

const loading = ref(true);
const submission = ref(null);

axios
.get(props.url, { headers: { Accept: 'application/json' } })
.then((response) => {
submission.value = response.data;
loading.value = false;
})
.catch((error) => {
Statamic.$toast.error(error.response?.data?.message || __('Something went wrong'));
emit('closed');
});
</script>

<template>
<Stack open @closed="emit('closed')">
<div v-if="loading" class="absolute inset-0 z-200 flex items-center justify-center text-center">
<Icon name="loading" />
</div>

<SubmissionPublishForm
v-else
:status="submission.status"
:date="submission.date"
:blueprint="submission.blueprint"
:values="submission.values"
:meta="submission.meta"
/>
</Stack>
</template>
Loading
Loading