Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.idea
.vscode
*.code-workspace
/vendor
node_modules
.DS_Store
Expand Down
4 changes: 2 additions & 2 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
'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.',
'email_connection_empty_description' => 'Notify your team, or send a confirmation to the person who submitted.',
'email_connection_empty_description' => 'Set up emails to be sent automatically when this form is submitted. Use them to notify users or team members.',
'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 @@ -329,7 +329,7 @@
'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.',
'webhook_connection_empty_description' => 'Notify other services whenever this form receives a submission.',
'webhook_connection_empty_description' => 'Each webhook is sent when this form is submitted. Notify other services or trigger automations with the submission data.',
'webhook_connection_payload_instructions' => 'Each submission is sent to the webhook URL as a JSON POST request.',
'webhook_connection_verify_ssl_instructions' => 'Only disable this if the receiving server uses a self-signed certificate.',
'width_x_height' => ':width × :height',
Expand Down
1 change: 1 addition & 0 deletions packages/cms/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const {
SortableList,
ConnectionRows,
ConnectionRules,
ConditionsCollapsedSummary,
conditionsSummary,
requireElevatedSession,
requireElevatedSessionIf,
Expand Down
1 change: 1 addition & 0 deletions resources/js/bootstrap/cms/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export { default as clone, deepClone } from '../../util/clone.js';
export { default as debounce } from '../../util/debounce.js';
export { default as ConnectionRows } from '../../components/forms/connections/ConnectionRows.vue';
export { default as ConnectionRules, conditionsSummary } from '../../components/forms/connections/ConnectionRules.vue';
export { default as ConditionsCollapsedSummary } from '../../components/forms/connections/ConditionsCollapsedSummary.vue';
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<script setup>
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { Badge, Icon, Subheading } from '@ui';
import FieldNumber from '@/components/forms/FieldNumber.vue';
import { categories, categoryColorClasses } from '@/components/forms/builder/categories';

const props = defineProps({
conditions: { type: Array, default: () => [] },
fallback: { type: String, default: null },
});

const suggestableFields = usePage().props.suggestableFields ?? [];

const operatorLabels = {
'': __('equals'),
equals: __('equals'),
not: __('does not equal'),
contains: __('contains'),
contains_any: __('contains any of'),
'==': __('equals'),
'!=': __('does not equal'),
'>': __('is greater than'),
'<': __('is less than'),
'>=': __('is at least'),
'<=': __('is at most'),
};

const getOperatorLabel = (operator) => operatorLabels[operator] || operator || __('equals');
const getFieldConfig = (handle) => suggestableFields.find((field) => field.handle === handle);
const getFieldDisplay = (handle) => __(getFieldConfig(handle)?.config?.display) || handle;
const getIconClass = (category) => {
const color = categories[category]?.color || 'gray';
return categoryColorClasses[color]?.icon || 'text-gray-600 dark:text-gray-400';
};

const filteredConditions = computed(() => (props.conditions ?? []).filter((condition) => condition.field));

const firstFieldConfig = computed(() => {
const firstCondition = filteredConditions.value[0];
if (!firstCondition?.field) return null;

const field = getFieldConfig(firstCondition.field);

return {
handle: firstCondition.field,
display: __(field?.config?.display) || firstCondition.field,
icon: field?.icon || 'generic-field',
iconClass: getIconClass(field?.category),
};
});

const previewParts = computed(() => {
if (filteredConditions.value.length === 0) return null;

const parts = [];

filteredConditions.value.forEach((condition, index) => {
if (index === 0) {
parts.push({ type: 'operator', text: getOperatorLabel(condition.operator) });

if (condition.value !== null && condition.value !== undefined && condition.value !== '') {
const displayValue = Array.isArray(condition.value)
? condition.value.join(', ')
: String(condition.value);
parts.push({ type: 'value', text: displayValue });
}

return;
}

parts.push({ type: 'join', text: condition.join === 'or' ? __('or') : __('and') });
parts.push({ type: 'field-plain', text: getFieldDisplay(condition.field) });
parts.push({ type: 'operator', text: getOperatorLabel(condition.operator) });

if (condition.value !== null && condition.value !== undefined && condition.value !== '') {
const displayValue = Array.isArray(condition.value)
? condition.value.join(', ')
: String(condition.value);
parts.push({ type: 'value', text: displayValue });
}
});

return parts.length ? parts : null;
});

const collapsedSummary = computed(() => {
if (filteredConditions.value.length === 0) {
return props.fallback || __('Always');
}

if (!previewParts.value) return __('Configure conditions');

return null;
});
</script>

<template>
<Badge v-if="filteredConditions.length" pill size="sm" color="white" class="font-medium text-gray-800 dark:text-gray-200">
{{ __('If') }}
</Badge>
<Badge v-if="firstFieldConfig" pill color="white" class="ps-1.5 py-1 text-gray-950 gap-1">
<FieldNumber :field-key="firstFieldConfig.handle" class="me-0.5" />
<Icon
:name="firstFieldConfig.icon"
class="size-3.5 me-1 rounded-sm opacity-100!"
:class="firstFieldConfig.iconClass"
aria-hidden="true"
/>
<span class="st-text-trim-cap">{{ firstFieldConfig.display }}</span>
</Badge>
<Subheading class="overflow-hidden text-ellipsis whitespace-nowrap text-xs flex items-center gap-1">
<template v-if="collapsedSummary">
<span class="lowercase">{{ collapsedSummary }}</span>
</template>
<template v-else-if="previewParts">
<template v-for="(part, index) in previewParts" :key="index">
<Badge
v-if="part.type === 'operator'"
class="inline-block px-1 py-1.5 font-medium st-text-trim-ex-alphabetic lowercase bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"
>
{{ part.text }}
</Badge>
<span v-else-if="part.type === 'value'" class="font-mono text-gray-900 dark:text-gray-100">{{ part.text }}</span>
<Badge
v-else-if="part.type === 'join'"
class="inline-block px-1 py-1.5 font-medium st-text-trim-ex-alphabetic lowercase bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"
>
{{ part.text }}
</Badge>
<span v-else-if="part.type === 'field-plain'" class="text-gray-700 dark:text-gray-300">{{ part.text }}</span>
</template>
</template>
</Subheading>
</template>
4 changes: 2 additions & 2 deletions resources/js/components/forms/connections/ConnectionRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ function toggleCollapsedState() {
:class="{ 'border-red-500': hasError }"
>
<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="group/header animate-border-color flex items-center show-focus-within rounded-[calc(var(--radius-lg)-1px)] px-1.5 antialiased duration-200 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,
'bg-gray-100 dark:bg-gray-925 rounded-b-none': !collapsed,
}"
>
<DragHandle :class="handleClass" class="ms-1 cursor-grab [&_svg]:opacity-75 dark:[&_svg]:opacity-50" />
Expand Down
41 changes: 36 additions & 5 deletions resources/js/components/forms/connections/ConnectionRows.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, inject, ref, watch } from 'vue';
import { nanoid as uniqid } from 'nanoid';
import { Button, ConfirmationModal } from '@ui';
import { Button, ConfirmationModal, Description } from '@ui';
import { SortableList } from '@/components/sortable/Sortable.js';
import { deepClone } from '@/util/clone.js';
import { preferences } from '@api';
import LogicEmptyState from '@/components/forms/logic/LogicEmptyState.vue';
import ConnectionRow from './ConnectionRow.vue';
import { __ } from '@/bootstrap/globals';
Expand Down Expand Up @@ -44,7 +45,8 @@ const props = withDefaults(defineProps<{
const sortableItemClass = 'connection-row';
const sortableHandleClass = 'connection-row-handle';

const collapsed = ref<string[]>([]);
const userPreference = ref<'collapsed' | 'expanded'>(preferences.get('forms.connect.rows_view', 'collapsed'));
const collapsed = ref<string[]>(userPreference.value === 'collapsed' ? props.modelValue.map((row) => row.id) : []);
const confirmingRemoval = ref<string | null>(null);
const errorRowIds = ref<string[]>([]);

Expand Down Expand Up @@ -99,6 +101,21 @@ const collapse = (id: string): void => {

const expand = (id: string): void => (collapsed.value = collapsed.value.filter((rowId) => rowId !== id));

const expandAll = (): void => { collapsed.value = []; userPreference.value = 'expanded'; preferences.set('forms.connect.rows_view', 'expanded'); };
const collapseAll = (): void => { collapsed.value = props.modelValue.map((row) => row.id); userPreference.value = 'collapsed'; preferences.set('forms.connect.rows_view', 'collapsed'); };
const allCollapsed = computed(() => props.modelValue.length > 0 && collapsed.value.length === props.modelValue.length);

const connectionRowsApi = inject('connectionRowsApi', null);

watch([allCollapsed, () => props.modelValue.length], ([collapsed, count]) => {
if (connectionRowsApi) {
connectionRowsApi.expandAll = expandAll;
connectionRowsApi.collapseAll = collapseAll;
connectionRowsApi.allCollapsed = collapsed;
connectionRowsApi.count = count;
}
}, { immediate: true });

const errorIndex = (row: Row): number => errorRowIds.value.indexOf(row.id);

const hasError = (row: Row): boolean => {
Expand All @@ -119,6 +136,17 @@ const rowErrors = (row: Row) => {
}, {});
};

watch(
() => props.modelValue.map((row) => row.id),
(newIds, oldIds) => {
const added = newIds.filter((id) => !oldIds?.includes(id));

if (userPreference.value !== 'expanded' && added.length) {
collapsed.value = [...collapsed.value, ...added];
}
},
);

watch(
() => props.errors,
() => (errorRowIds.value = props.modelValue.map((row) => row.id)),
Expand All @@ -127,11 +155,14 @@ watch(
</script>

<template>
<LogicEmptyState v-if="modelValue.length === 0" :heading="emptyHeading" :description="emptyDescription">
<Description v-if="emptyDescription" :text="emptyDescription" class="mb-4" />

<div v-if="modelValue.length === 0">
<Button size="sm" :text="addLabel" icon="plus" @click="add" />
</LogicEmptyState>
</div>

<template v-else>

<SortableList
vertical
constrain-dimensions
Expand Down
15 changes: 10 additions & 5 deletions resources/js/components/forms/connections/EmailConnection.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { Badge, Icon, PublishContainer, PublishFields, PublishFieldsProvider, Subheading } from '@ui';
import { Badge, Icon, Label, PublishContainer, PublishFields, PublishFieldsProvider } from '@ui';
import ConnectionRows from './ConnectionRows.vue';
import ConnectionRules, { conditionsSummary } from './ConnectionRules.vue';
import ConnectionRules from './ConnectionRules.vue';
import ConditionsCollapsedSummary from './ConditionsCollapsedSummary.vue';

defineEmits(['update:modelValue']);

Expand All @@ -29,6 +30,8 @@ const recipients = (to: string[] | string): string =>
</script>

<template>
<Label :text="__('Emails')" class="mb-2" />

<ConnectionRows
:model-value="modelValue"
:errors
Expand All @@ -45,9 +48,11 @@ const recipients = (to: string[] | string): string =>
<Icon name="mail-sign-at" class="size-3.5 me-1 opacity-100! text-blue-600 dark:text-blue-400" aria-hidden="true" />
{{ email.to?.length ? __('Message sent to :email', { email: recipients(email.to) }) : __('New Email') }}
</Badge>
<Subheading v-show="collapsed" class="overflow-hidden text-ellipsis whitespace-nowrap gap-1.5!">
<span class="truncate">{{ conditionsSummary(email.conditions) ?? email.subject }}</span>
</Subheading>
<ConditionsCollapsedSummary
v-show="collapsed"
:conditions="email.conditions"
:fallback="email.subject"
/>
</template>

<template #default="{ item: email, errors }">
Expand Down
52 changes: 27 additions & 25 deletions resources/js/components/forms/connections/WebhookConnection.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Badge, Button, Field, Icon, Label, PublishContainer, PublishFields, PublishFieldsProvider, Subheading } from '@ui';
import { Badge, Button, Field, Icon, Label, PublishContainer, PublishFields, PublishFieldsProvider } from '@ui';
import ConnectionRows from './ConnectionRows.vue';
import ConnectionRules, { conditionsSummary } from './ConnectionRules.vue';
import ConnectionRules from './ConnectionRules.vue';
import ConditionsCollapsedSummary from './ConditionsCollapsedSummary.vue';

defineEmits(['update:modelValue']);

Expand All @@ -20,25 +21,6 @@ const showExamplePayload = ref<boolean>(props.modelValue.length === 0);
</script>

<template>
<Field
class="mb-8"
:label="__('Example Payload')"
:instructions="__('statamic::messages.webhook_connection_payload_instructions')"
>
<template #actions>
<Button
variant="subtle"
size="xs"
:icon-append="showExamplePayload ? 'chevron-up' : 'chevron-down'"
:text="showExamplePayload ? __('Hide') : __('Show')"
:aria-expanded="showExamplePayload"
@click="showExamplePayload = !showExamplePayload"
/>
</template>

<pre v-show="showExamplePayload" class="overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs text-gray-800 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300"><code>{{ examplePayload }}</code></pre>
</Field>

<Label v-if="modelValue.length" :text="__('Webhooks')" />

<ConnectionRows
Expand All @@ -54,12 +36,13 @@ const showExamplePayload = ref<boolean>(props.modelValue.length === 0);
>
<template #header="{ item: webhook, collapsed }">
<Badge size="lg" pill color="white" class="px-3 text-gray-950 gap-1">
<Icon name="globe-arrow" class="size-3.5 me-1 opacity-100! text-teal-600 dark:text-teal-400" aria-hidden="true" />
<Icon name="globe-setting" class="size-3.5 me-1 opacity-100! text-purple-600 dark:text-purple-400" aria-hidden="true" />
{{ webhook.url || __('New Webhook') }}
</Badge>
<Subheading v-show="collapsed" class="overflow-hidden text-ellipsis whitespace-nowrap gap-1.5!">
<span class="truncate">{{ conditionsSummary(webhook.conditions) }}</span>
</Subheading>
<ConditionsCollapsedSummary
v-show="collapsed"
:conditions="webhook.conditions"
/>
</template>

<template #default="{ item: webhook, errors }">
Expand Down Expand Up @@ -87,4 +70,23 @@ const showExamplePayload = ref<boolean>(props.modelValue.length === 0);
</ConnectionRules>
</template>
</ConnectionRows>

<Field
class="mt-8"
:label="__('Example Payload')"
:instructions="__('statamic::messages.webhook_connection_payload_instructions')"
>
<template #actions>
<Button
variant="subtle"
size="xs"
:icon-append="showExamplePayload ? 'chevron-up' : 'chevron-down'"
:text="showExamplePayload ? __('Hide') : __('Show')"
:aria-expanded="showExamplePayload"
@click="showExamplePayload = !showExamplePayload"
/>
</template>

<pre v-show="showExamplePayload" class="overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs text-gray-800 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300"><code>{{ examplePayload }}</code></pre>
</Field>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Input/GroupPrepend.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const props = defineProps({
]"
data-ui-input-group-prepend
>
<span v-if="text" class="text-gray-500 dark:text-gray-400">{{ text }}</span>
<span v-if="text" class="text-gray-600 dark:text-gray-300">{{ text }}</span>
<slot v-else />
</div>
</template>
Loading
Loading