Enhance Form Builder Components with Readonly State Support and New Modal Functionality

- Updated ComponentPreview.vue to include support for the 'searchSelect' and 'switch' components in readonly states, ensuring consistent behavior across form fields.
- Modified FormBuilderComponents.vue to set default readonly properties for various components, enhancing usability and preventing unintended edits.
- Enhanced FormBuilderFieldSettingsModal.vue to reflect the updated readonly logic for component types, improving user awareness of field capabilities.
- Introduced a new modal in manage.vue for copying workflow links, allowing users to select between direct and iframe link types with customizable options.
- Improved link generation logic to support iframe parameters, enhancing the flexibility of sharing workflows.
- Updated styles in SearchSelect.vue and Switch.vue to visually indicate readonly states, ensuring a consistent user experience across components.
This commit is contained in:
Afiq 2025-08-07 10:05:53 +08:00
parent f024cc91dd
commit f86fe87fc5
6 changed files with 360 additions and 29 deletions

View File

@ -27,7 +27,7 @@
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
:readonly="component.props.readonly || !isPreview"
:disabled="!isPreview || (component.props.readonly && ['select', 'checkbox', 'radio'].includes(component.type))"
:disabled="!isPreview || (component.props.readonly && ['select', 'searchSelect', 'checkbox', 'radio', 'switch'].includes(component.type))"
:options="component.props.options || undefined" :value="component.props.value || undefined"
:accept="component.props.accept || undefined" :max="component.props.max || undefined"
:mask="component.props.mask || undefined" :digits="component.props.digits || undefined"
@ -36,11 +36,12 @@
:classes="component.type === 'checkbox' ? {
wrapper: 'mb-1',
options: 'space-y-0.5'
} : {}" :class="{
} : { }" :class="{
'canvas-component': isPreview,
'readonly-select': component.props.readonly && component.type === 'select',
'readonly-select': component.props.readonly && (component.type === 'select' || component.type === 'searchSelect'),
'readonly-checkbox': component.props.readonly && component.type === 'checkbox',
'readonly-radio': component.props.readonly && component.type === 'radio'
'readonly-radio': component.props.readonly && component.type === 'radio',
'readonly-switch': component.props.readonly && component.type === 'switch'
}" />
<!-- Heading -->
@ -1590,29 +1591,33 @@ const getButtonSizeClass = (size) => {
pointer-events: none;
}
/* Readonly styles for select, checkbox, and radio components */
/* Readonly styles for select, checkbox, radio, and switch components */
:deep(.readonly-select),
:deep(.readonly-checkbox),
:deep(.readonly-radio) {
:deep(.readonly-radio),
:deep(.readonly-switch) {
cursor: default !important;
}
:deep(.readonly-select select),
:deep(.readonly-select .formkit-inner),
:deep(.readonly-checkbox input[type="checkbox"]),
:deep(.readonly-radio input[type="radio"]) {
:deep(.readonly-radio input[type="radio"]),
:deep(.readonly-switch input[type="checkbox"]) {
pointer-events: none !important;
opacity: 0.8 !important;
background-color: #f3f4f6 !important;
}
:deep(.readonly-checkbox) .formkit-options,
:deep(.readonly-radio) .formkit-options {
:deep(.readonly-radio) .formkit-options,
:deep(.readonly-switch) .formkit-wrapper {
pointer-events: none !important;
}
:deep(.readonly-checkbox) .formkit-wrapper,
:deep(.readonly-radio) .formkit-wrapper {
:deep(.readonly-radio) .formkit-wrapper,
:deep(.readonly-switch) .formkit-wrapper {
cursor: not-allowed !important;
}

View File

@ -136,6 +136,7 @@ const availableComponents = [
placeholder: 'Enter text...',
help: '',
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -156,6 +157,7 @@ const availableComponents = [
placeholder: 'Enter text...',
help: '',
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -176,6 +178,7 @@ const availableComponents = [
placeholder: '0',
help: '',
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -196,6 +199,7 @@ const availableComponents = [
placeholder: 'email@example.com',
help: '',
validation: 'email',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -216,6 +220,7 @@ const availableComponents = [
placeholder: 'Enter password...',
help: '',
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -235,7 +240,8 @@ const availableComponents = [
type: 'url',
placeholder: 'https://example.com',
help: '',
validation: 'url'
validation: 'url',
readonly: false
}
},
{
@ -248,7 +254,8 @@ const availableComponents = [
type: 'tel',
placeholder: '+1 (555) 123-4567',
help: '',
validation: ''
validation: '',
readonly: false
}
},
{
@ -262,7 +269,8 @@ const availableComponents = [
placeholder: 'Enter value...',
help: 'Input will be formatted according to the mask',
mask: '###-###-####',
validation: ''
validation: '',
readonly: false
}
},
{
@ -295,6 +303,7 @@ const availableComponents = [
{ label: 'Option 3', value: 'option_3' }
],
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -320,6 +329,7 @@ const availableComponents = [
{ label: 'Option 3', value: 'option_3' }
],
validation: '',
readonly: false,
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
@ -345,7 +355,8 @@ const availableComponents = [
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' }
],
validation: ''
validation: '',
readonly: false
}
},
{
@ -362,7 +373,8 @@ const availableComponents = [
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' }
],
validation: ''
validation: '',
readonly: false
}
},
{
@ -377,7 +389,8 @@ const availableComponents = [
name: 'switch_field',
help: 'Toggle this option on or off',
value: false,
validation: ''
validation: '',
readonly: false
}
},

View File

@ -2223,7 +2223,7 @@ const showField = (fieldName) => {
rows: ['textarea'],
options: ['select', 'searchSelect', 'checkbox', 'radio'],
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
readonly: ['text', 'number', 'email', 'textarea', 'mask', 'url', 'tel']
readonly: ['text', 'number', 'email', 'password', 'textarea', 'mask', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch']
}
return fieldConfig[fieldName]?.includes(props.component.type) || false

View File

@ -5,14 +5,14 @@
@input="handleChange"
:options="context.options || []"
:placeholder="context.placeholder || 'Search and select an option'"
:disabled="context.disabled"
:searchable="true"
:disabled="context.disabled || context.readonly"
:searchable="!context.readonly"
:clearable="false"
label="label"
:reduce="option => option.value"
:class="[
'vue-select-wrapper',
{ 'vue-select-disabled': context.disabled }
{ 'vue-select-disabled': context.disabled || context.readonly }
]"
/>
@ -21,7 +21,7 @@
:value="_value"
:name="context.node.name"
:id="context.id"
:disabled="context.disabled"
:disabled="context.disabled || context.readonly"
:required="context.attrs.required"
class="hidden-select"
tabindex="-1"
@ -60,6 +60,10 @@ const _value = computed({
// Handle value changes from vue3-select-component
const handleChange = (value) => {
// Don't allow changes if readonly
if (props.context.readonly) {
return
}
props.context.node.input(value)
}
</script>
@ -81,4 +85,20 @@ const handleChange = (value) => {
white-space: nowrap;
border-width: 0;
}
/* Readonly styles for SearchSelect */
.vue-select-disabled {
opacity: 0.8;
background-color: #f3f4f6;
cursor: not-allowed;
}
.vue-select-disabled .vs__dropdown-toggle {
background-color: #f3f4f6 !important;
cursor: not-allowed !important;
}
.vue-select-disabled .vs__actions {
pointer-events: none !important;
}
</style>

View File

@ -4,6 +4,10 @@ const props = defineProps({
});
function handleChange(event) {
// Don't allow changes if readonly
if (props.context.readonly) {
return;
}
props.context.node.input(event.target.checked);
}
</script>
@ -16,11 +20,11 @@ function handleChange(event) {
:name="context.name"
type="checkbox"
:checked="context.value"
:disabled="context.disabled"
:disabled="context.disabled || context.readonly"
@change="handleChange"
class="switch-input"
/>
<label :for="context.id" class="switch-label">
<label :for="context.id" class="switch-label" :class="{ 'cursor-not-allowed': context.readonly }">
<span class="switch-track">
<span class="switch-thumb"></span>
</span>
@ -91,4 +95,23 @@ function handleChange(event) {
.switch-input:checked + .switch-label .switch-thumb {
transform: translateX(1.25rem) translateZ(0);
}
/* Readonly styles for Switch */
.switch-input:disabled + .switch-label .switch-track {
@apply opacity-50 cursor-not-allowed;
}
.switch-input:disabled + .switch-label {
@apply cursor-not-allowed;
}
/* Additional readonly styling */
.switch-label.cursor-not-allowed {
cursor: not-allowed !important;
}
.switch-label.cursor-not-allowed .switch-track {
opacity: 0.8;
background-color: #f3f4f6;
}
</style>

View File

@ -29,6 +29,16 @@ const sortBy = ref('processCreatedDate');
const sortOrder = ref('desc');
const currentView = ref('dashboard'); // 'dashboard', 'list', 'analytics'
// Copy link modal state
const showCopyLinkModal = ref(false);
const selectedProcess = ref(null);
const copyLinkType = ref('direct'); // 'direct' or 'iframe'
const iframeOptions = ref({
debug: false,
hideComplete: true,
theme: 'light'
});
// Pagination state
const currentPage = ref(1);
const itemsPerPage = ref(20); // Default to 20 items per page
@ -451,16 +461,119 @@ onUnmounted(() => {
clearTimeout(searchTimeout);
});
// Copy workflow run link to clipboard
const copyWorkflowLink = async (processId) => {
// Enhanced copy workflow link functionality
const openCopyLinkModal = (process) => {
selectedProcess.value = process;
showCopyLinkModal.value = true;
};
const copyWorkflowLink = async (processId, linkType = 'direct', options = {}) => {
try {
const link = `${window.location.origin}/workflow/${processId}`;
await navigator.clipboard.writeText(link);
toast.success('Run link copied to clipboard!');
// Check if we're on the client side
if (process.client) {
let link = `${baseUrl.value}/workflow/${processId}`;
if (linkType === 'iframe') {
const params = new URLSearchParams();
if (options.debug !== undefined) params.append('debug', options.debug);
if (options.hideComplete !== undefined) params.append('hideComplete', options.hideComplete);
if (options.theme) params.append('theme', options.theme);
if (params.toString()) {
link += `?${params.toString()}`;
}
}
await navigator.clipboard.writeText(link);
toast.success(`${linkType === 'iframe' ? 'Iframe link' : 'Direct link'} copied to clipboard!`);
}
} catch (err) {
toast.error('Failed to copy link');
}
};
const openWorkflowLink = (processId, linkType = 'direct', options = {}) => {
// Check if we're on the client side
if (process.client) {
let link = `${baseUrl.value}/workflow/${processId}`;
if (linkType === 'iframe') {
const params = new URLSearchParams();
if (options.debug !== undefined) params.append('debug', options.debug);
if (options.hideComplete !== undefined) params.append('hideComplete', options.hideComplete);
if (options.theme) params.append('theme', options.theme);
if (params.toString()) {
link += `?${params.toString()}`;
}
}
window.open(link, '_blank');
}
};
const closeCopyLinkModal = () => {
showCopyLinkModal.value = false;
selectedProcess.value = null;
copyLinkType.value = 'direct';
iframeOptions.value = {
debug: false,
hideComplete: true,
theme: 'light'
};
};
const handleCopyLink = async () => {
if (!selectedProcess.value) return;
await copyWorkflowLink(selectedProcess.value.id, copyLinkType.value, copyLinkType.value === 'iframe' ? iframeOptions.value : {});
closeCopyLinkModal();
};
const handleOpenLink = () => {
if (!selectedProcess.value) return;
openWorkflowLink(selectedProcess.value.id, copyLinkType.value, copyLinkType.value === 'iframe' ? iframeOptions.value : {});
closeCopyLinkModal();
};
// Computed properties for link generation
const baseUrl = computed(() => {
return process.client ? window.location.origin : '';
});
const generatedLink = computed(() => {
if (!selectedProcess.value || !baseUrl.value) return '';
if (copyLinkType.value === 'iframe') {
const params = new URLSearchParams();
if (iframeOptions.value.debug !== undefined) params.append('debug', iframeOptions.value.debug);
if (iframeOptions.value.hideComplete !== undefined) params.append('hideComplete', iframeOptions.value.hideComplete);
if (iframeOptions.value.theme) params.append('theme', iframeOptions.value.theme);
return `${baseUrl.value}/workflow/${selectedProcess.value.id}${params.toString() ? `?${params.toString()}` : ''}`;
}
return `${baseUrl.value}/workflow/${selectedProcess.value.id}`;
});
const generateIframeCode = () => {
if (!selectedProcess.value || !baseUrl.value) return '';
const params = new URLSearchParams();
if (iframeOptions.value.debug !== undefined) params.append('debug', iframeOptions.value.debug);
if (iframeOptions.value.hideComplete !== undefined) params.append('hideComplete', iframeOptions.value.hideComplete);
if (iframeOptions.value.theme) params.append('theme', iframeOptions.value.theme);
const url = `${baseUrl.value}/workflow/${selectedProcess.value.id}${params.toString() ? `?${params.toString()}` : ''}`;
return `<iframe
src="${url}"
width="100%"
height="600px"
style="border: none; border-radius: 8px;">
</iframe>`;
};
</script>
<template>
@ -782,9 +895,9 @@ const copyWorkflowLink = async (processId) => {
<!-- Copy Run Link Button (for published processes) -->
<button
v-if="process.status === 'published'"
@click="copyWorkflowLink(process.id)"
@click="openCopyLinkModal(process)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Copy Run Link"
title="Copy or Open Run Link"
:disabled="loading"
>
<Icon name="material-symbols:link" class="text-lg" />
@ -1051,6 +1164,163 @@ const copyWorkflowLink = async (processId) => {
</div>
</template>
</RsModal>
<!-- Copy Link Modal -->
<RsModal v-model="showCopyLinkModal" title="Share Workflow Link" size="lg" position="center">
<div class="p-6">
<!-- Process Info -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<Icon name="material-symbols:link" class="text-blue-600 w-6 h-6" />
<div>
<h3 class="font-semibold text-gray-900">{{ selectedProcess?.name }}</h3>
<p class="text-sm text-gray-600">{{ selectedProcess?.description || 'No description available' }}</p>
</div>
</div>
</div>
<!-- Link Type Selection -->
<div class="mb-6">
<h4 class="font-medium text-gray-900 mb-3">Link Type</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
@click="copyLinkType = 'direct'"
:class="[
'p-4 border-2 rounded-lg cursor-pointer transition-colors',
copyLinkType === 'direct'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
]"
>
<div class="flex items-center gap-3">
<div :class="[
'w-4 h-4 rounded-full border-2',
copyLinkType === 'direct'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
]">
<Icon v-if="copyLinkType === 'direct'" name="material-symbols:check" class="w-3 h-3 text-white" />
</div>
<div>
<h5 class="font-medium text-gray-900">Direct Link</h5>
<p class="text-sm text-gray-600">Direct URL to the workflow</p>
</div>
</div>
</div>
<div
@click="copyLinkType = 'iframe'"
:class="[
'p-4 border-2 rounded-lg cursor-pointer transition-colors',
copyLinkType === 'iframe'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
]"
>
<div class="flex items-center gap-3">
<div :class="[
'w-4 h-4 rounded-full border-2',
copyLinkType === 'iframe'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
]">
<Icon v-if="copyLinkType === 'iframe'" name="material-symbols:check" class="w-3 h-3 text-white" />
</div>
<div>
<h5 class="font-medium text-gray-900">Iframe Integration</h5>
<p class="text-sm text-gray-600">Embed in external applications</p>
</div>
</div>
</div>
</div>
</div>
<!-- Iframe Options -->
<div v-if="copyLinkType === 'iframe'" class="mb-6">
<h4 class="font-medium text-gray-900 mb-3">Iframe Options</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">Hide Debug Information</label>
<p class="text-xs text-gray-500">Remove debug UI elements</p>
</div>
<FormKit
v-model="iframeOptions.debug"
type="checkbox"
:classes="{
wrapper: 'flex items-center',
input: 'w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500'
}"
/>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">Hide Completion Message</label>
<p class="text-xs text-gray-500">Auto-advance after completion</p>
</div>
<FormKit
v-model="iframeOptions.hideComplete"
type="checkbox"
:classes="{
wrapper: 'flex items-center',
input: 'w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500'
}"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700">Theme</label>
<FormKit
v-model="iframeOptions.theme"
type="select"
:options="[
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' }
]"
:classes="{
outer: 'mt-1',
input: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
}"
/>
</div>
</div>
</div>
<!-- Generated Link Preview -->
<div class="mb-6">
<h4 class="font-medium text-gray-900 mb-3">Generated Link</h4>
<div class="bg-gray-50 p-3 rounded-lg">
<code class="text-sm text-gray-800 break-all">
{{ generatedLink }}
</code>
</div>
</div>
<!-- Iframe Code Preview -->
<div v-if="copyLinkType === 'iframe'" class="mb-6">
<h4 class="font-medium text-gray-900 mb-3">Iframe Code</h4>
<div class="bg-gray-50 p-3 rounded-lg">
<pre class="text-sm text-gray-800 overflow-x-auto"><code>{{ generateIframeCode() }}</code></pre>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-between items-center">
<div class="flex gap-2">
<RsButton @click="handleOpenLink" variant="secondary" :disabled="!selectedProcess">
<Icon name="material-symbols:open-in-new" class="mr-2" />
Open Link
</RsButton>
<RsButton @click="handleCopyLink" variant="primary" :disabled="!selectedProcess">
<Icon name="material-symbols:content-copy" class="mr-2" />
Copy Link
</RsButton>
</div>
</div>
</template>
</RsModal>
</div>
</template>