增加form_builder组件,实现可视化拖拽编排pagetype字段

This commit is contained in:
jingrow 2026-01-24 03:59:11 +08:00
parent e10a42dbad
commit 3e08ae95c3
34 changed files with 5749 additions and 1 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ frontend/.env.production
# 忽略名为 test 的文件夹
test/
.cursor/
.qoder/
# 忽略所有 文件夹

View File

@ -0,0 +1,503 @@
<script setup>
import Sidebar from "./components/Sidebar.vue";
import Tabs from "./components/Tabs.vue";
import { computed, onMounted, watch, ref, provide, getCurrentInstance } from "vue";
import { useFormBuilderStore } from "./store";
import { onClickOutside } from "@vueuse/core";
import { NMessageProvider, NDialogProvider } from 'naive-ui';
import { registerGlobalComponents } from "./globals";
// Register control components globally
const app = getCurrentInstance()?.appContext.app;
if (app) {
registerGlobalComponents(app);
}
// Props
const props = defineProps({
fields: {
type: Array,
default: () => []
},
pagetype: {
type: String,
default: ""
},
readOnly: {
type: Boolean,
default: false
},
isCustomizeForm: {
type: Boolean,
default: false
}
});
// Emits
const emit = defineEmits(['update:fields', 'save', 'dirty-change']);
let store = useFormBuilderStore();
// Provide store to all children
provide('formBuilderStore', store);
let shouldRender = computed(() => {
return Object.keys(store.form.layout).length !== 0 &&
store.form.layout.tabs &&
store.form.layout.tabs.length > 0;
});
let container = ref(null);
let dialogContainer = ref(null);
let messageContainer = ref(null);
let sidebarWidth = ref(300);
let isResizing = ref(false);
let startX = ref(0);
let startWidth = ref(300);
const minSidebarWidth = 260;
const maxSidebarWidth = 500;
onClickOutside(container, () => (store.form.selected_field = null), {
ignore: [".combo-box-options", ".dropdown-options", ".n-popover"],
});
watch(
() => store.form.layout,
() => {
store.dirty = true;
emit('dirty-change', true);
},
{ deep: true }
);
// Watch for dirty state changes
watch(
() => store.dirty,
(newVal) => {
emit('dirty-change', newVal);
}
);
// Initialize the store on mount
onMounted(() => {
dialogContainer.value = document.body;
messageContainer.value = document.body;
store.initialize(props.fields, {
pagetype: props.pagetype,
readOnly: props.readOnly,
isCustomizeForm: props.isCustomizeForm,
});
});
// Watch for prop changes
watch(
() => props.fields,
(newFields) => {
store.initialize(newFields, {
pagetype: props.pagetype,
readOnly: props.readOnly,
isCustomizeForm: props.isCustomizeForm,
});
},
{ deep: true }
);
// Expose methods for parent components
function getFields() {
return store.getUpdatedFields();
}
function validate() {
const fields = store.getUpdatedFields();
return store.validateFields(fields, false);
}
function save() {
const errorMessage = validate();
if (errorMessage) {
return { success: false, message: errorMessage };
}
const fields = store.getUpdatedFields();
emit('update:fields', fields);
emit('save', fields);
store.dirty = false;
return { success: true, fields };
}
// Resize handle functions
function startResize(event) {
event.preventDefault();
event.stopPropagation();
startX.value = event.clientX;
startWidth.value = sidebarWidth.value;
isResizing.value = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResize);
}
function handleMouseMove(event) {
if (!isResizing.value) return;
const deltaX = startX.value - event.clientX;
const newWidth = startWidth.value + deltaX;
if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) {
sidebarWidth.value = newWidth;
}
}
function stopResize() {
isResizing.value = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResize);
}
defineExpose({
getFields,
validate,
save,
store
});
</script>
<template>
<n-message-provider :to="messageContainer">
<n-dialog-provider :to="dialogContainer">
<div
v-if="shouldRender"
ref="container"
class="form-builder-container"
:class="{ resizing: isResizing }"
@click="store.form.selected_field = null"
>
<div class="form-container">
<div class="form-main" :class="[store.preview ? 'preview' : '']">
<Tabs />
</div>
</div>
<div class="resize-handle" @mousedown="startResize"></div>
<div class="form-controls" @click.stop>
<div class="form-sidebar" :style="{ width: sidebarWidth + 'px' }">
<Sidebar />
</div>
</div>
</div>
<div v-else class="form-builder-loading">
<span>Loading form builder...</span>
</div>
<div id="autocomplete-area" />
</n-dialog-provider>
</n-message-provider>
</template>
<style lang="scss" scoped>
.form-builder-loading {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
color: var(--text-muted, #6c757d);
}
.form-builder-container {
display: flex;
min-height: 600px;
height: 100%;
overflow: hidden;
&.resizing {
user-select: none;
cursor: col-resize;
}
.form-controls {
position: relative;
flex-shrink: 0;
}
.form-container {
flex: 1;
background-color: #f8f9fa;
overflow: hidden;
display: flex;
flex-direction: column;
}
.form-sidebar {
border-left: 1px solid #e5e7eb;
border-radius: 8px;
background-color: #fff;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.form-main {
border-radius: 8px;
border: 1px solid #e5e7eb;
background-color: #fff;
margin: 5px;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.resize-handle {
width: 4px;
background-color: transparent;
cursor: col-resize;
position: relative;
transition: all 0.2s ease;
flex-shrink: 0;
z-index: 10;
&:hover {
background-color: rgba(59, 130, 246, 0.1);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 24px;
background: linear-gradient(180deg,
rgba(156, 163, 175, 0.3) 0%,
rgba(156, 163, 175, 0.6) 50%,
rgba(156, 163, 175, 0.3) 100%);
border-radius: 0.5px;
transition: all 0.2s ease;
}
&:hover::after {
background: linear-gradient(180deg,
rgba(59, 130, 246, 0.4) 0%,
rgba(59, 130, 246, 0.8) 50%,
rgba(59, 130, 246, 0.4) 100%);
width: 2px;
height: 32px;
}
&:active {
background-color: rgba(59, 130, 246, 0.15);
}
}
.form-sidebar,
.form-main {
:deep(.section-columns.has-one-column .field) {
input.form-control,
.signature-field {
width: calc(50% - 19px);
}
.select-input {
width: calc(50% - 19px);
input.form-control {
width: 100%;
}
}
}
:deep(.column-container .field.sortable-chosen) {
background-color: #f3f4f6;
border-radius: 4px;
border: 1px solid transparent;
padding: 0.3rem;
font-size: 14px;
cursor: pointer;
&:not(.hovered) {
position: relative;
background-color: transparent;
height: 60px;
.drop-it-here {
display: flex;
justify-content: center;
&::after {
content: "Drop it here";
top: 31%;
position: absolute;
padding: 2px 10px;
color: #374151;
background-color: #9ca3af;
border-radius: 9999px;
z-index: 1;
}
&::before {
content: "";
top: 47%;
position: absolute;
width: 97%;
height: 4px;
background-color: #9ca3af;
border-radius: 9999px;
}
}
}
&:not(:first-child) {
margin-top: 0.4rem;
}
}
:deep(.field) {
.label {
margin-bottom: 0.3rem;
}
.editable {
input,
textarea,
select,
.ace_editor,
.ace_gutter,
.ace_content,
.signature-field,
.missing-image,
.ql-editor {
background-color: #fff;
cursor: pointer;
}
.input-text {
background-color: inherit;
}
}
.description,
.time-zone {
font-size: 12px;
color: #6b7280;
}
}
:deep([data-is-user-generated="1"]) {
background-color: #fef3c7;
}
}
:deep(.preview) {
.tab,
.column,
.field {
background-color: #fff;
}
.column,
.field {
border: none;
padding: 0;
}
.form-section {
padding: 5px;
.section-header {
&.has-label {
padding: 10px 15px;
margin-bottom: 8px;
}
&.collapsed {
margin-bottom: 0;
}
}
.section-description {
padding-left: 15px;
}
.section-columns {
margin-top: 8px;
&.has-one-column .field {
input.form-control,
.signature-field {
width: calc(50% - 15px);
}
.select-input {
width: calc(50% - 15px);
input.form-control {
width: 100%;
}
}
}
.section-columns-container {
.column {
padding-left: 15px;
padding-right: 15px;
margin: 0;
.column-header {
padding-left: 0;
}
.column-description {
margin-left: 0;
}
.field {
margin: 0;
margin-bottom: 1rem;
.field-controls {
margin-bottom: 5px;
}
}
.add-new-field-btn {
display: none;
}
}
}
}
}
.selected,
.hovered {
border-color: transparent;
}
input,
textarea,
select,
.ace_editor,
.ace_gutter,
.ace_content,
.signature-field,
.missing-image,
.ql-editor {
background-color: #f9fafb !important;
}
input[type="checkbox"] {
background-color: #fff !important;
}
}
.form-main > :deep(div:first-child:not(.tab-header)) {
max-height: calc(100vh - 175px);
}
}
</style>

View File

@ -0,0 +1,160 @@
<template>
<div class="add-field-wrapper">
<button
ref="addFieldBtnRef"
class="add-field-btn btn btn-xs btn-icon"
:title="tooltip"
@click.stop="toggleFieldtypeOptions"
>
<slot>
{{ t("Add field") }}
</slot>
</button>
<Teleport to="body">
<div
v-show="show"
ref="autocompleteRef"
class="autocomplete-dropdown"
:style="dropdownStyle"
>
<Autocomplete
v-model:show="show"
:value="autocompleteValue"
:options="fields"
@change="addNewField"
:placeholder="t('Search fieldtypes...')"
/>
</div>
</Teleport>
</div>
</template>
<script setup>
import Autocomplete from "./Autocomplete.vue";
import { useFormBuilderStore, ALL_FIELDTYPES, LAYOUT_FIELDS } from "../store";
import { cloneField, inList } from "../utils";
import { computed, nextTick, ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
import { t } from '@/shared/i18n';
const store = useFormBuilderStore();
const props = defineProps({
column: {
type: Object,
default: null,
},
field: {
type: Object,
default: null,
},
tooltip: {
type: String,
default: "Add field",
},
});
const emit = defineEmits(["update:modelValue"]);
const selected = computed(() => {
let fieldname = props.field ? props.field.df.name : props.column.df.name;
return store.selected(fieldname);
});
const show = ref(false);
const autocompleteValue = ref("");
const dropdownStyle = ref({});
const fields = computed(() => {
let fieldList = ALL_FIELDTYPES
.filter((df) => {
if (inList(LAYOUT_FIELDS, df)) {
return false;
}
return true;
})
.map((df) => {
let out = { label: df, value: df };
return out;
});
return [...fieldList];
});
const addFieldBtnRef = ref(null);
const autocompleteRef = ref(null);
onClickOutside(addFieldBtnRef, () => (show.value = false), { ignore: [autocompleteRef] });
function updatePosition() {
if (!addFieldBtnRef.value || !show.value) return;
const rect = addFieldBtnRef.value.getBoundingClientRect();
dropdownStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
minWidth: '200px',
zIndex: 1000,
};
}
function toggleFieldtypeOptions() {
show.value = !show.value;
autocompleteValue.value = "";
nextTick(() => updatePosition());
}
function addNewField(field) {
const fieldtype = field?.value;
if (!fieldtype) return;
let newField = {
df: store.getDf(fieldtype),
table_columns: [],
};
let clonedField = cloneField(newField);
// insert new field after current field
let index = 0;
if (props.field) {
index = props.column.fields.indexOf(props.field);
}
props.column.fields.splice(index + 1, 0, clonedField);
store.form.selected_field = clonedField.df;
show.value = false;
}
watch(selected, (val) => {
if (!val) show.value = false;
});
defineExpose({ open: toggleFieldtypeOptions });
</script>
<style lang="scss" scoped>
.add-field-wrapper {
display: inline-block;
}
.add-field-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #f9fafb;
}
}
.autocomplete-dropdown {
z-index: 1000;
}
</style>

View File

@ -0,0 +1,182 @@
<template>
<div class="autocomplete-wrapper">
<div class="combo-box-options">
<div class="search-box">
<input
ref="search"
class="search-input form-control"
type="text"
@input="(e) => (query = e.target.value)"
:value="query"
:placeholder="props.placeholder"
autocomplete="off"
@click.stop
/>
<button class="clear-button btn btn-sm" @click="clearSearch">
<Icon icon="mdi:close" class="icon-sm" />
</button>
</div>
<div class="combo-box-items">
<div
v-for="(field, i) in filteredOptions"
:key="i"
:class="['combo-box-option', activeIndex === i ? 'active' : '']"
@click="selectOption(field)"
@mouseenter="activeIndex = i"
>
{{ field.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, nextTick } from "vue";
import { Icon } from '@iconify/vue';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: "",
},
modelValue: {
type: String,
default: "",
},
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "update:show", "change"]);
const query = ref("");
const search = ref(null);
const activeIndex = ref(0);
const filteredOptions = computed(() => {
return query.value
? props.options.filter((option) => {
return option.label.toLowerCase().includes(query.value.toLowerCase());
})
: props.options;
});
function selectOption(option) {
query.value = "";
emit("change", option);
emit("update:show", false);
}
function clearSearch() {
query.value = "";
search.value?.focus();
}
watch(() => props.show, (val) => {
if (val) {
nextTick(() => {
search.value?.focus();
});
}
});
</script>
<style lang="scss" scoped>
.autocomplete-wrapper {
z-index: 100;
}
.combo-box-options {
width: 100%;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 0;
border: 1px solid #e5e7eb;
}
.combo-box-option {
font-size: 13px;
text-align: left;
border-radius: 4px;
padding: 6px 10px;
width: 100%;
cursor: pointer;
&:hover,
&.active {
background-color: #f3f4f6;
}
}
.combo-box-items {
max-height: 200px;
padding: 5px;
padding-top: 0px;
overflow-y: auto;
}
.search-box {
position: relative;
padding: 6px;
.clear-button {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
padding: 4px;
border: none;
background: transparent;
&:hover {
background-color: #f3f4f6;
border-radius: 4px;
}
}
.search-input {
width: 100%;
height: 32px;
padding: 6px 30px 6px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
&:focus {
outline: none;
border-color: #3b82f6;
}
}
}
.icon-sm {
width: 14px;
height: 14px;
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
</style>

View File

@ -0,0 +1,185 @@
<template>
<div
:class="['column', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="column.df.fieldname"
@click.stop="store.form.selected_field = column.df"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div
v-if="column.df.label"
class="column-header"
:hidden="!column.df.label && store.readOnly"
>
<div class="column-label">
<span>{{ column.df.label }}</span>
</div>
</div>
<div v-if="column.df.description" class="column-description">
{{ column.df.description }}
</div>
<draggable
class="column-container"
v-model="column.fields"
group="fields"
:delay="isTouchScreenDevice() ? 200 : 0"
:animation="200"
:easing="store.getAnimation"
item-key="id"
:disabled="store.readOnly"
>
<template #item="{ element }">
<Field
:column="column"
:field="element"
:data-is-user-generated="store.isUserGeneratedField(element)"
/>
</template>
</draggable>
<div class="empty-column" :hidden="store.readOnly">
<AddFieldButton :column="column" />
</div>
<div v-if="column.fields.length" class="add-new-field-btn">
<AddFieldButton :field="column.fields[column.fields.length - 1]" :column="column" />
</div>
</div>
</template>
<script setup>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import AddFieldButton from "./AddFieldButton.vue";
import { computed, ref } from "vue";
import { useFormBuilderStore } from "../store";
import { isTouchScreenDevice } from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["section", "column"]);
const store = useFormBuilderStore();
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.notUsingInput) {
// Column delete handled by section
}
});
const hovered = ref(false);
const selected = computed(() => store.selected(props.column.df.name));
</script>
<style lang="scss" scoped>
.column {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
background-color: #f9fafb;
border-radius: 8px;
border: 1px dashed #9ca3af;
padding: 0.5rem;
margin-left: 4px;
margin-right: 4px;
&.hovered,
&.selected {
border-color: #3b82f6;
border-style: solid;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 0.5rem;
padding-left: 0.3rem;
.column-label {
:deep(span) {
font-weight: 600;
color: #111827;
}
}
.column-actions {
display: flex;
justify-content: flex-end;
.btn.btn-icon {
padding: 2px;
box-shadow: none;
opacity: 0;
&:hover {
opacity: 1;
background-color: #fff;
}
}
}
}
.column-description {
margin-bottom: 10px;
margin-left: 0.3rem;
font-size: 12px;
color: #6b7280;
}
&:first-child {
margin-left: 0px;
}
&:last-child {
margin-right: 0px;
}
.column-container {
min-height: 2rem;
border-radius: 8px;
z-index: 1;
&:empty {
flex: 1;
& + .empty-column {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
position: absolute;
top: 0;
bottom: 0;
left: 0;
gap: 5px;
width: 100%;
padding: 15px;
button {
background-color: #f9fafb;
z-index: 2;
&:hover {
background-color: #e5e7eb;
}
}
}
}
& + .empty-column {
display: none;
}
}
.add-new-field-btn {
padding: 10px 6px 5px;
button {
background-color: #fff;
&:hover {
background-color: #e5e7eb;
}
}
}
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<div class="dropdown-wrapper">
<button
ref="dropdownBtnRef"
class="dropdown-btn btn btn-xs btn-icon"
@click.stop="toggleOptions"
>
<slot>
<Icon icon="mdi:dots-horizontal" class="icon-sm" />
</slot>
</button>
<Teleport to="body">
<div
v-show="show"
ref="dropdownRef"
class="dropdown-menu"
:style="dropdownStyle"
>
<div class="dropdown-options">
<div v-for="group in groups" :key="group.key" class="groups">
<div v-if="group.group" class="group-title">
{{ group.group }}
</div>
<div
class="dropdown-option"
v-for="item in group.items"
:key="item.label"
:title="item.tooltip"
>
<button class="dropdown-item" @click.stop="action(item.onClick)">
{{ item.label }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { nextTick, ref, computed, onMounted, onUnmounted } from "vue";
import { onClickOutside } from "@vueuse/core";
import { Icon } from '@iconify/vue';
const props = defineProps({
options: {
type: Array,
required: true,
},
placement: {
type: String,
default: "bottom-end",
},
});
const show = ref(false);
const dropdownBtnRef = ref(null);
const dropdownRef = ref(null);
const dropdownStyle = ref({});
onClickOutside(dropdownBtnRef, () => (show.value = false), { ignore: [dropdownRef] });
const groups = computed(() => {
let _groups = props.options[0]?.group ? props.options : [{ group: "", items: props.options }];
return _groups.map((group, i) => {
return {
key: i,
group: group.group,
items: group.items,
};
});
});
function updatePosition() {
if (!dropdownBtnRef.value || !show.value) return;
const rect = dropdownBtnRef.value.getBoundingClientRect();
dropdownStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: props.placement === 'bottom-end' ? 'auto' : `${rect.left}px`,
right: props.placement === 'bottom-end' ? `${window.innerWidth - rect.right}px` : 'auto',
zIndex: 1000,
};
}
function toggleOptions() {
show.value = !show.value;
nextTick(() => updatePosition());
}
function action(clickEvent) {
clickEvent && clickEvent();
show.value = false;
}
</script>
<style lang="scss" scoped>
.dropdown-wrapper {
position: relative;
}
.groups {
padding: 5px;
.group-title {
display: flex;
align-items: center;
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #9ca3af;
}
}
.dropdown-btn {
padding: 2px;
border: none;
background: transparent;
cursor: pointer;
&:hover {
background-color: #f3f4f6;
border-radius: 4px;
}
}
.dropdown-menu {
min-width: 150px;
}
.dropdown-options {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 4px;
border: 1px solid #e5e7eb;
}
.dropdown-option {
.dropdown-item {
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
border-radius: 4px;
color: #374151;
&:hover {
background-color: #f3f4f6;
}
}
}
.icon-sm {
width: 14px;
height: 14px;
}
</style>

View File

@ -0,0 +1,99 @@
<script setup>
import { ref, nextTick, computed } from "vue";
import { useFormBuilderStore } from "../store";
import { t } from '@/shared/i18n';
let store = useFormBuilderStore();
const props = defineProps({
text: {
type: String,
},
placeholder: {
default: "No Label",
},
emptyLabel: {
default: "No Label",
},
});
let editing = ref(false);
let inputText = ref(null);
let hiddenText = ref(null);
let hiddenPlaceholder = ref(null);
let hiddenSpanWidth = computed(() => {
if (hiddenText.value && props.text) {
return hiddenText.value.offsetWidth + 15 + "px";
} else if (!props.text) {
return hiddenPlaceholder.value?.offsetWidth + 15 + "px" || "100px";
}
return "40px";
});
function focusOnLabel() {
if (!store.readOnly) {
editing.value = true;
nextTick(() => inputText.value?.focus());
}
}
defineExpose({ focusOnLabel });
</script>
<template>
<div @dblclick="focusOnLabel" :title="t('Double click to edit label')">
<input
v-if="editing"
class="input-text"
ref="inputText"
:disabled="store.readOnly"
type="text"
:placeholder="placeholder"
:value="text"
:style="{ width: hiddenSpanWidth }"
@input="(event) => $emit('update:modelValue', event.target.value)"
@keydown.enter="editing = false"
@blur="editing = false"
@click.stop
/>
<span v-else-if="text" v-html="text"></span>
<i v-else class="text-muted">
{{ emptyLabel }}
</i>
<span class="hidden-span" ref="hiddenText" v-html="text"></span>
<span class="hidden-span" ref="hiddenPlaceholder">{{ placeholder }}</span>
</div>
</template>
<style lang="scss" scoped>
.input-text {
border: none;
min-width: 50px;
padding: 0px !important;
background-color: transparent;
font-size: inherit;
font-weight: inherit;
color: inherit;
&:focus {
outline: none;
background-color: inherit;
}
&::placeholder {
font-style: italic;
font-weight: normal;
}
}
.hidden-span {
visibility: hidden;
position: absolute;
width: max-content;
}
.text-muted {
color: #9ca3af;
}
</style>

View File

@ -0,0 +1,226 @@
<script setup>
import EditableInput from "./EditableInput.vue";
import { useFormBuilderStore } from "../store";
import { moveChildrenToParent, cloneField } from "../utils";
import { ref, computed, onMounted } from "vue";
import AddFieldButton from "./AddFieldButton.vue";
import { useMagicKeys, whenever } from "@vueuse/core";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
const props = defineProps(["column", "field"]);
const store = useFormBuilderStore();
const addFieldRef = ref(null);
// cmd/ctrl + shift + n to open the add field autocomplete
const { ctrl_shift_n, Backspace } = useMagicKeys();
whenever(ctrl_shift_n, (value) => {
if (value && selected.value) {
addFieldRef.value?.open();
}
});
// delete/backspace to delete the field
whenever(Backspace, (value) => {
if (value && selected.value && store.notUsingInput) {
removeField();
}
});
const labelInput = ref(null);
const hovered = ref(false);
const selected = computed(() => store.selected(props.field.df.name));
const component = computed(() => {
return props.field.df.fieldtype.replaceAll(" ", "") + "Control";
});
function removeField() {
if (store.isCustomizeForm && props.field.df.is_custom_field === 0) {
alert(t("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
}
let index = props.column.fields.indexOf(props.field);
props.column.fields.splice(index, 1);
store.form.selected_field = null;
}
function moveFieldsToColumn() {
let currentSection = store.currentTab?.sections?.find((section) =>
section.columns.find((column) => column === props.column)
);
if (currentSection) {
moveChildrenToParent(props, "column", "field", currentSection, store.getDf);
}
}
function duplicateField() {
let duplicatedField = cloneField(props.field);
if (store.isCustomizeForm) {
duplicatedField.df.is_custom_field = 1;
}
if (duplicatedField.df.label) {
duplicatedField.df.label = duplicatedField.df.label + " Copy";
}
duplicatedField.df.fieldname = "";
duplicatedField.df.__islocal = 1;
duplicatedField.df.__unsaved = 1;
delete duplicatedField.df.creation;
delete duplicatedField.df.modified;
delete duplicatedField.df.modified_by;
let index = props.column.fields.indexOf(props.field);
props.column.fields.splice(index + 1, 0, duplicatedField);
store.form.selected_field = duplicatedField.df;
}
onMounted(() => selected.value && labelInput.value?.focusOnLabel?.());
</script>
<template>
<div
:class="['field', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="field.df.fieldname"
@click.stop="store.form.selected_field = field.df"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<component
:is="component"
:df="field.df"
:data-fieldname="field.df.fieldname"
:data-fieldtype="field.df.fieldtype"
>
<template #label>
<div class="field-label">
<EditableInput
ref="labelInput"
:text="field.df.label"
:placeholder="t('Label')"
:emptyLabel="`${t('No Label')} (${field.df.fieldtype})`"
v-model="field.df.label"
/>
<div class="reqd-asterisk" v-if="field.df.reqd">*</div>
<div
class="help-icon"
v-if="field.df.documentation_url"
>
<Icon icon="mdi:help-circle-outline" class="icon-sm" />
</div>
</div>
</template>
<template #actions>
<div class="field-actions" :hidden="store.readOnly">
<AddFieldButton ref="addFieldRef" :column="column" :field="field">
<Icon icon="mdi:plus" class="icon-sm" />
</AddFieldButton>
<button
v-if="column.fields.indexOf(field)"
class="btn btn-xs btn-icon"
:title="t('Move the current field and the following fields to a new column')"
@click="moveFieldsToColumn"
>
<Icon icon="mdi:arrow-right-box" class="icon-sm" />
</button>
<button
class="btn btn-xs btn-icon"
:title="t('Duplicate field')"
@click.stop="duplicateField"
>
<Icon icon="mdi:content-copy" class="icon-sm" />
</button>
<button
class="btn btn-xs btn-icon"
:title="t('Remove field')"
@click.stop="removeField"
>
<Icon icon="mdi:close" class="icon-sm" />
</button>
</div>
</template>
</component>
</div>
</template>
<style lang="scss" scoped>
.field {
text-align: left;
width: 100%;
background-color: #f3f4f6;
border-radius: 4px;
border: 1px solid transparent;
padding: 0.3rem;
font-size: 14px;
&:not(:first-child) {
margin-top: 0.4rem;
}
&.hovered,
&.selected {
border-color: #3b82f6;
.btn.btn-icon {
opacity: 1 !important;
}
}
:deep(.form-control:read-only:focus) {
box-shadow: none;
}
:deep(.field-controls) {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
.field-label {
display: flex;
align-items: center;
.reqd-asterisk {
margin-left: 3px;
color: #ef4444;
}
.help-icon {
margin-left: 3px;
color: #6b7280;
cursor: pointer;
}
}
.field-actions {
flex: none;
display: flex;
gap: 2px;
.btn.btn-icon {
opacity: 0;
box-shadow: none;
padding: 2px;
border: none;
background: transparent;
cursor: pointer;
&:hover {
background-color: #fff;
}
}
}
}
}
.btn-filter-applied {
background-color: #d1d5db !important;
&:hover {
background-color: #9ca3af !important;
}
}
.icon-sm {
width: 14px;
height: 14px;
}
</style>

View File

@ -0,0 +1,171 @@
<script setup>
import SearchBox from "./SearchBox.vue";
import { evaluateDependsOnValue, inList } from "../utils";
import { ref, computed } from "vue";
import { useFormBuilderStore, LAYOUT_FIELDS, NO_VALUE_TYPES } from "../store";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
let store = useFormBuilderStore();
let searchText = ref("");
let pagefieldDf = computed(() => {
let fields = store.getPagefields.map(df => ({...df})).filter((df) => {
if (inList(LAYOUT_FIELDS, df.fieldtype) || df.hidden) {
return false;
}
if (
df.depends_on &&
!evaluateDependsOnValue(df.depends_on, store.form.selected_field)
) {
return false;
}
if (
inList(["fetch_from", "fetch_if_empty"], df.fieldname) &&
inList(NO_VALUE_TYPES, store.form.selected_field.fieldtype)
) {
return false;
}
if (df.fieldname === "reqd" && store.form.selected_field.fieldtype === "Check") {
return false;
}
if (df.fieldname === "options") {
df.fieldtype = "Small Text";
df.options = "";
if (inList(["Table", "Link"], store.form.selected_field.fieldtype)) {
df.fieldtype = "Data";
}
}
// show link_filters pagefield only when link field is selected
if (df.fieldname === "link_filters" && store.form.selected_field.fieldtype !== "Link") {
return false;
}
if (searchText.value) {
if (
df.label.toLowerCase().includes(searchText.value.toLowerCase()) ||
df.fieldname.toLowerCase().includes(searchText.value.toLowerCase())
) {
return true;
}
return false;
}
return true;
});
return [...fields];
});
function getControlComponent(fieldtype) {
return fieldtype.replaceAll(' ', '') + 'Control';
}
</script>
<template>
<div class="field-properties">
<div class="header">
<SearchBox class="flex-1" v-model="searchText" />
<button
class="close-btn btn btn-xs"
:title="t('Close properties')"
@click="store.form.selected_field = null"
>
<Icon icon="mdi:close" class="icon-sm" />
</button>
</div>
<div class="control-data">
<div v-if="store.form.selected_field">
<div class="field" v-for="(df, i) in pagefieldDf" :key="i">
<component
:is="getControlComponent(df.fieldtype)"
:df="df"
:read_only="store.readOnly"
:value="store.form.selected_field[df.fieldname]"
v-model="store.form.selected_field[df.fieldname]"
:data-fieldname="df.fieldname"
:data-fieldtype="df.fieldtype"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.field-properties {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
padding: 8px;
border-bottom: 1px solid #e5e7eb;
gap: 8px;
.close-btn {
padding: 4px;
border: none;
background: transparent;
cursor: pointer;
&:hover {
background-color: #f3f4f6;
border-radius: 4px;
}
}
}
.control-data {
flex: 1;
overflow-y: auto;
padding: 12px;
.field {
margin-bottom: 1rem;
:deep(.form-control:disabled) {
color: #9ca3af;
background-color: #f9fafb;
cursor: default;
}
}
}
.icon-sm {
width: 14px;
height: 14px;
}
.flex-1 {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #f9fafb;
}
&.btn-xs {
padding: 4px;
font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,73 @@
<script setup>
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
</script>
<template>
<div class="search-box">
<input
class="search-input form-control"
type="text"
:placeholder="t('Search properties...')"
@input="(event) => $emit('update:modelValue', event.target.value)"
/>
<span class="search-icon">
<Icon icon="mdi:magnify" class="icon-sm" />
</span>
</div>
</template>
<style lang="scss" scoped>
.search-box {
display: flex;
position: relative;
background-color: #fff;
width: 100%;
.search-input {
padding-left: 30px;
width: 100%;
height: 32px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.search-icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
}
.icon-sm {
width: 16px;
height: 16px;
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
</style>

View File

@ -0,0 +1,370 @@
<template>
<div
class="form-section-container"
:style="{ borderBottom: props.section.df.hide_border ? 'none' : '' }"
>
<div
:class="[
'form-section',
hovered ? 'hovered' : '',
store.selected(section.df.name) ? 'selected' : '',
]"
:title="section.df.fieldname"
@click.stop="selectSection"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div
:class="[
'section-header',
section.df.label || section.df.collapsible ? 'has-label' : '',
collapsed ? 'collapsed' : '',
]"
:hidden="!section.df.label && store.readOnly"
>
<div class="section-label">
<EditableInput
:text="section.df.label"
:placeholder="t('Section Title')"
v-model="section.df.label"
/>
<div
v-if="section.df.collapsible"
class="collapse-indicator"
>
<Icon :icon="collapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" class="icon-sm" />
</div>
</div>
<Dropdown v-if="!store.readOnly" :options="options" @click.stop />
</div>
<div v-if="section.df.description" class="section-description">
{{ section.df.description }}
</div>
<div
class="section-columns"
:class="{
hidden: section.df.collapsible && collapsed,
'has-one-column': section.columns.length === 1,
}"
>
<draggable
class="section-columns-container"
v-model="section.columns"
group="columns"
item-key="id"
:delay="isTouchScreenDevice() ? 200 : 0"
:animation="200"
:easing="store.getAnimation"
:disabled="store.readOnly"
>
<template #item="{ element }">
<Column
:section="section"
:column="element"
:data-is-user-generated="store.isUserGeneratedField(element)"
/>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script setup>
import draggable from "vuedraggable";
import Column from "./Column.vue";
import EditableInput from "./EditableInput.vue";
import Dropdown from "./Dropdown.vue";
import { ref, computed } from "vue";
import { useFormBuilderStore } from "../store";
import {
sectionBoilerplate,
moveChildrenToParent,
confirmDialog,
isTouchScreenDevice,
} from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
const props = defineProps(["tab", "section"]);
const store = useFormBuilderStore();
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.notUsingInput) {
removeSection();
}
});
const hovered = ref(false);
const collapsed = ref(false);
const selected = computed(() => store.selected(props.section.df.name));
const column = computed(() => props.section.columns[props.section.columns.length - 1]);
// section
function addSectionBelow() {
let index = props.tab.sections.indexOf(props.section);
props.tab.sections.splice(index + 1, 0, sectionBoilerplate(store.getDf));
}
function isSectionEmpty() {
return !props.section.columns.some(
(column) => (store.isCustomizeForm && !column.df.is_custom_field) || column.fields.length
);
}
function removeSection() {
if (store.isCustomizeForm && props.section.df.is_custom_field === 0) {
alert(t("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.hasStandardField(props.section)) {
deleteSection();
} else if (isSectionEmpty()) {
deleteSection(true);
} else {
confirmDialog(
t("Delete Section"),
t("Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section."),
() => deleteSection(),
t("Delete section"),
() => deleteSection(true),
t("Delete entire section with fields")
);
}
}
function deleteSection(withChildren) {
let sections = props.tab.sections;
let index = sections.indexOf(props.section);
if (!withChildren) {
if (index > 0) {
let prevSection = sections[index - 1];
if (!isSectionEmpty()) {
prevSection.columns = [...prevSection.columns, ...props.section.columns];
}
} else if (index === 0 && !isSectionEmpty()) {
sections.unshift({
df: store.getDf("Section Break"),
columns: props.section.columns,
is_first: true,
});
index++;
}
}
sections.splice(index, 1);
store.form.selected_field = null;
}
function selectSection() {
if (props.section.df.collapsible) {
collapsed.value = !collapsed.value;
}
store.form.selected_field = props.section.df;
}
function moveSectionsToTab() {
let newTab = moveChildrenToParent(props, "tab", "section", store.form.layout, store.getDf);
store.form.active_tab = newTab;
}
// column
function addColumn() {
props.section.columns.push({
fields: [],
df: store.getDf("Column Break"),
});
}
function removeColumn() {
if (store.isCustomizeForm && column.value.df.is_custom_field === 0) {
alert(t("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (column.value.fields.length === 0 || store.hasStandardField(column.value)) {
deleteColumn();
} else {
confirmDialog(
t("Delete Column"),
t("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column."),
() => deleteColumn(),
t("Delete column"),
() => deleteColumn(true),
t("Delete entire column with fields")
);
}
}
function deleteColumn(withChildren) {
let columns = props.section.columns;
let index = columns.length - 1;
if (withChildren && index === 0 && columns.length === 1) {
if (column.value.fields.length === 0) {
alert(t("Section must have at least one column"));
throw "section must have at least one column";
}
columns.unshift({
df: store.getDf("Column Break"),
fields: [],
is_first: true,
});
index++;
}
if (!withChildren) {
if (index > 0) {
let prevColumn = columns[index - 1];
prevColumn.fields = [...prevColumn.fields, ...column.value.fields];
} else {
if (column.value.fields.length === 0) {
let nextColumn = columns[index + 1];
if (nextColumn) {
nextColumn.is_first = true;
} else {
alert(t("Section must have at least one column"));
throw "section must have at least one column";
}
} else {
columns.unshift({
df: store.getDf("Column Break"),
fields: column.value.fields,
is_first: true,
});
index++;
}
}
}
columns.splice(index, 1);
store.form.selected_field = null;
}
const options = computed(() => {
let groups = [
{
group: t("Section"),
items: [
{ label: t("Add section below"), onClick: addSectionBelow },
{ label: t("Remove section"), onClick: removeSection },
],
},
{
group: t("Column"),
items: [{ label: t("Add column"), onClick: addColumn }],
},
];
if (props.section.columns.length > 1) {
groups[1].items.push({
label: t("Remove column"),
tooltip: t("Remove last column"),
onClick: removeColumn,
});
} else if (props.section.columns[0].fields.length) {
groups[1].items.push({
label: t("Empty column"),
tooltip: t("Remove all fields in the column"),
onClick: () => deleteColumn(true),
});
}
if (props.tab.sections.indexOf(props.section) > 0) {
groups[0].items.push({
label: t("Move sections to new tab"),
tooltip: t("Move current and all subsequent sections to a new tab"),
onClick: moveSectionsToTab,
});
}
return groups;
});
</script>
<style lang="scss" scoped>
.form-section-container {
border-bottom: 1px solid #e5e7eb;
background-color: #fff;
&:last-child {
border-bottom: none;
}
.form-section {
background-color: inherit;
border: 1px solid transparent;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
&.hovered,
&.selected {
border-color: #3b82f6;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
&.collapsed {
padding-bottom: 0;
}
&.has-label {
display: flex;
}
.section-label {
display: flex;
align-items: center;
:deep(span) {
font-weight: 600;
color: #111827;
}
.collapse-indicator {
margin-left: 7px;
}
}
.section-actions {
display: flex;
gap: 4px;
align-items: center;
}
}
.section-description {
margin-bottom: 10px;
font-size: 12px;
color: #6b7280;
}
.section-columns-container {
display: flex;
min-height: 2rem;
border-radius: 8px;
}
}
}
.icon-sm {
width: 14px;
height: 14px;
}
.hidden {
display: none;
}
</style>

View File

@ -0,0 +1,137 @@
<script setup>
import FieldProperties from "./FieldProperties.vue";
import { useFormBuilderStore } from "../store";
import { ref } from "vue";
import { t } from '@/shared/i18n';
let store = useFormBuilderStore();
</script>
<template>
<div class="sidebar-container">
<FieldProperties v-if="store.form.selected_field" />
<div class="default-state" v-else>
<div
class="actions"
v-if="store.form.layout.tabs.length === 1 && !store.readOnly && !store.pg?.istable"
>
<button
class="new-tab-btn btn btn-default btn-xs"
:title="t('Add new tab')"
@click="store.addNewTab"
>
{{ t("Add tab") }}
</button>
</div>
<div class="empty-state">
<div>{{ t("Select a field to edit its properties.") }}</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.sidebar-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
background-color: #fff;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
.tab {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 32px;
background-color: #f3f4f6;
border-radius: 6px;
border: 1px solid #d1d5db;
cursor: pointer;
&:not(:first-child) {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&.active {
background-color: #fff;
}
}
}
.tab-content {
display: none;
&.active {
display: block;
}
}
.default-state {
display: flex;
flex-direction: column;
height: 100%;
.actions {
padding: 8px;
display: flex;
justify-content: flex-end;
border-bottom: 1px solid #e5e7eb;
}
.empty-state {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #9ca3af;
padding: 20px;
}
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #f9fafb;
}
&.btn-xs {
padding: 4px 8px;
font-size: 12px;
}
&.btn-default {
background-color: #f3f4f6;
border-color: #e5e7eb;
&:hover {
background-color: #e5e7eb;
}
}
}
</style>

View File

@ -0,0 +1,373 @@
<script setup>
import Section from "./Section.vue";
import EditableInput from "./EditableInput.vue";
import { useFormBuilderStore } from "../store";
import { sectionBoilerplate, confirmDialog, isTouchScreenDevice } from "../utils";
import draggable from "vuedraggable";
import { ref, computed, inject } from "vue";
import { useMagicKeys, whenever } from "@vueuse/core";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
const store = useFormBuilderStore();
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.notUsingInput) {
removeTab(store.currentTab, "", true);
}
});
const dragged = ref(false);
const selected = computed(() => store.selected(store.currentTab?.df?.name));
const hasTabs = computed(() => store.form.layout.tabs.length > 1);
// Initialize active tab
if (store.form.layout.tabs.length > 0 && !store.form.active_tab) {
store.form.active_tab = store.form.layout.tabs[0].df.name;
}
function activateTab(tab) {
store.activateTab(tab);
}
function dragOver(tab) {
!dragged.value &&
setTimeout(() => {
store.form.active_tab = tab.df.name;
}, 500);
}
function addNewTab() {
store.addNewTab();
}
function addNewSection() {
let section = sectionBoilerplate(store.getDf);
store.currentTab.sections.push(section);
store.form.selected_field = section.df;
}
function isTabEmpty(tab) {
return !tab.sections.some((section) => section.columns.some((column) => column.fields.length));
}
function removeTab(tab, event, force = false) {
if (!event?.currentTarget?.offsetParent && !force) return;
if (store.isCustomizeForm && store.currentTab.df.is_custom_field === 0) {
alert(t("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.hasStandardField(store.currentTab)) {
deleteTab(tab);
} else if (isTabEmpty(tab)) {
deleteTab(tab, true);
} else {
confirmDialog(
t("Delete Tab"),
t("Are you sure you want to delete the tab? All the sections along with fields in the tab will be moved to the previous tab."),
() => deleteTab(tab),
t("Delete tab"),
() => deleteTab(tab, true),
t("Delete entire tab with fields")
);
}
}
function deleteTab(tab, withChildren) {
let tabs = store.form.layout.tabs;
let index = tabs.indexOf(tab);
if (!withChildren) {
if (index > 0) {
let prevTab = tabs[index - 1];
if (!isTabEmpty(tab)) {
prevTab.sections = [...prevTab.sections, ...tab.sections];
}
} else {
tabs.unshift({
df: store.getDf("Tab Break", "", t("Details")),
sections: tab.sections,
is_first: true,
});
index++;
}
}
tabs.splice(index, 1);
let prevTabIndex = index === 0 ? 0 : index - 1;
store.form.active_tab = tabs[prevTabIndex].df.name;
store.form.selected_field = null;
}
</script>
<template>
<div class="tab-header" v-if="store.form.layout.tabs.length > 1">
<draggable
v-show="hasTabs"
class="tabs"
v-model="store.form.layout.tabs"
group="tabs"
:delay="isTouchScreenDevice() ? 200 : 0"
:animation="200"
:easing="store.getAnimation"
item-key="id"
:disabled="store.readOnly"
>
<template #item="{ element }">
<div
:class="['tab', store.form.active_tab === element.df.name ? 'active' : '']"
:title="element.df.fieldname"
:data-is-user-generated="store.isUserGeneratedField(element)"
@click.stop="activateTab(element)"
@dragstart="dragged = true"
@dragend="dragged = false"
@dragover="dragOver(element)"
>
<EditableInput
:text="element.df.label"
:placeholder="t('Tab Label')"
v-model="element.df.label"
/>
<button
class="remove-tab-btn btn btn-xs"
:title="t('Remove tab')"
@click.stop="removeTab(element, $event)"
:hidden="store.readOnly"
>
<Icon icon="mdi:close" class="icon-sm" />
</button>
</div>
</template>
</draggable>
<div class="tab-actions" :hidden="store.readOnly">
<button
class="new-tab-btn btn btn-xs"
:class="{ 'no-tabs': !hasTabs }"
:title="t('Add new tab')"
@click="addNewTab"
>
<div class="add-btn-text">
{{ t("Add tab") }}
</div>
</button>
</div>
</div>
<div class="tab-contents">
<div
class="tab-content"
v-for="(tab, i) in store.form.layout.tabs"
:key="i"
:class="[store.form.active_tab === tab.df.name ? 'active' : '']"
>
<draggable
class="tab-content-container"
v-model="tab.sections"
group="sections"
:delay="isTouchScreenDevice() ? 200 : 0"
:animation="200"
:easing="store.getAnimation"
item-key="id"
:disabled="store.readOnly"
>
<template #item="{ element }">
<Section
:tab="tab"
:section="element"
:data-is-user-generated="store.isUserGeneratedField(element)"
/>
</template>
</draggable>
<div class="empty-tab" :hidden="store.readOnly">
<div v-if="hasTabs">{{ t("Drag & Drop a section here from another tab") }}</div>
<div v-if="hasTabs">{{ t("OR") }}</div>
<button class="btn btn-default btn-sm" @click="addNewSection">
{{ t("Add a new section") }}
</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.tab-header {
display: flex;
justify-content: space-between;
min-height: 42px;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
padding-left: 8px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
.tabs {
display: flex;
flex: 1;
overflow-x: auto;
width: 0px;
}
.tab-actions {
margin-right: 20px;
.btn {
background-color: #f3f4f6;
padding: 2px;
margin-left: 4px;
box-shadow: none;
.add-btn-text {
padding: 4px 8px;
}
&:hover {
background-color: #e5e7eb;
}
}
.no-tabs {
opacity: 1;
margin-left: 15px;
}
}
.tab {
display: flex;
align-items: center;
position: relative;
padding: 10px 18px 10px 15px;
color: #6b7280;
min-width: max-content;
cursor: pointer;
&::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0 12px;
width: auto;
border-bottom: 1px solid transparent;
}
&:hover::before {
border-color: #d1d5db;
}
&.active {
font-weight: 600;
color: #111827;
&::before {
border-color: #3b82f6;
}
}
&:hover .remove-tab-btn {
display: block;
}
.remove-tab-btn {
position: absolute;
right: -2px;
display: none;
padding: 2px;
}
}
}
.tab-contents {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
border-radius: 8px;
min-height: 70px;
.tab-content {
display: none;
position: relative;
&.active {
display: flex;
}
.tab-content-container {
flex: 1;
min-height: 4rem;
border-radius: 8px;
z-index: 1;
&:empty {
height: 7rem;
margin: 1rem;
& + .empty-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
bottom: 0;
gap: 5px;
width: 100%;
padding: 15px;
button {
z-index: 2;
}
}
}
& + .empty-tab {
display: none;
}
}
}
}
.icon-sm {
width: 14px;
height: 14px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #f9fafb;
}
&.btn-xs {
padding: 2px 4px;
font-size: 11px;
}
&.btn-sm {
padding: 4px 8px;
font-size: 13px;
}
&.btn-default {
background-color: #f3f4f6;
border-color: #e5e7eb;
&:hover {
background-color: #e5e7eb;
}
}
}
</style>

View File

@ -0,0 +1,49 @@
<!-- Used as Attach & Attach Image Control -->
<script setup>
import { t } from '@/shared/i18n';
const props = defineProps(["df"]);
</script>
<template>
<div class="control editable">
<!-- label -->
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<!-- attach button -->
<button class="btn btn-sm btn-default">{{ t("Attach") }}</button>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #f3f4f6;
cursor: pointer;
font-size: 13px;
&:hover {
background-color: #e5e7eb;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,56 @@
<!-- Used as Button & Heading Control -->
<script setup>
const props = defineProps(["df", "value"]);
</script>
<template>
<div class="control form-builder-control editable" :data-fieldtype="df.fieldtype">
<!-- label -->
<div class="field-controls">
<h4 v-if="df.fieldtype === 'Heading'">
<slot name="label" />
</h4>
<button v-else class="btn btn-xs btn-default">
<slot name="label" />
</button>
<slot name="actions" />
</div>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description" />
</div>
</template>
<style lang="scss" scoped>
h4 {
margin-bottom: 0px;
font-size: 16px;
font-weight: 600;
color: #111827;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #f3f4f6;
cursor: pointer;
font-size: 13px;
&:hover {
background-color: #e5e7eb;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,87 @@
<script setup>
import { useSlots } from "vue";
const props = defineProps(["df", "value", "read_only"]);
let slots = useSlots();
</script>
<template>
<div class="control form-builder-control checkbox" :class="{ editable: slots.label }">
<!-- checkbox -->
<label v-if="slots.label" class="field-controls">
<div class="checkbox">
<input type="checkbox" disabled />
<slot name="label" />
</div>
<slot name="actions" />
</label>
<label v-else class="checkbox-label">
<input
type="checkbox"
:checked="value"
:disabled="read_only"
@change="(event) => $emit('update:modelValue', event.target.checked)"
/>
<span class="label-area" :class="{ reqd: df.reqd }">{{ df.label }}</span>
</label>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.control.checkbox {
label,
input {
margin-bottom: 0 !important;
cursor: pointer;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.label-area {
font-size: 14px;
color: #374151;
&.reqd::after {
content: " *";
color: #ef4444;
}
}
}
label .checkbox {
display: flex;
align-items: center;
input {
background-color: #fff;
box-shadow: none;
border: 1px solid #9ca3af;
pointer-events: none;
width: 16px;
height: 16px;
margin-right: 8px;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,95 @@
<!-- Used as Code, HTML Editor, Markdown Editor & JSON Control -->
<script setup>
import { computed, onMounted, ref, useSlots } from "vue";
const props = defineProps(["df", "read_only", "modelValue"]);
let emit = defineEmits(["update:modelValue"]);
let slots = useSlots();
let codeValue = ref(props.modelValue || '');
function handleInput(event) {
emit("update:modelValue", event.target.value);
}
</script>
<template>
<div class="control" :class="{ editable: slots.label }">
<div v-if="slots.label" class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div v-else class="control-label label">{{ df.label }}</div>
<div class="code-editor">
<textarea
class="code-textarea form-control"
:style="{ maxHeight: df.max_height ?? '300px' }"
:value="modelValue"
:disabled="read_only || df.read_only || !!slots.label"
:readonly="!!slots.label"
@input="handleInput"
spellcheck="false"
/>
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.control {
.control-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.code-editor {
.code-textarea {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
min-height: 150px;
resize: vertical;
white-space: pre;
overflow-x: auto;
}
}
.form-control {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #f8fafc;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&:disabled, &:read-only {
background-color: #f1f5f9;
color: #64748b;
cursor: not-allowed;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,133 @@
<!-- Used as Autocomplete, Barcode, Color, Currency, Data, Date, Duration, Link, Dynamic Link, Float, Int, Password, Percent, Time, Read Only, HTML Control -->
<script setup>
import { ref, useSlots } from "vue";
import { t } from '@/shared/i18n';
const props = defineProps(["df", "value", "read_only"]);
let slots = useSlots();
let timeZone = ref("");
let placeholder = ref("");
if (props.df.fieldtype === "Datetime") {
timeZone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
if (props.df.fieldtype === "Color") {
placeholder.value = t("Choose a color");
}
if (props.df.fieldtype === "Icon") {
placeholder.value = t("Choose an icon");
}
</script>
<template>
<div class="control form-builder-control" :class="{ editable: slots.label }">
<!-- label -->
<div v-if="slots.label" class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div v-else class="control-label label" :class="{ reqd: df.reqd }">{{ df.label }}</div>
<!-- data input -->
<input
v-if="slots.label"
class="form-control"
type="text"
:style="{ height: df.fieldtype == 'Table MultiSelect' ? '42px' : '' }"
:placeholder="placeholder"
readonly
/>
<input
v-else
class="form-control"
type="text"
:value="value"
:disabled="read_only || df.read_only"
@input="(event) => $emit('update:modelValue', event.target.value)"
/>
<input
v-if="slots.label && df.fieldtype === 'Barcode'"
class="mt-2 form-control"
type="text"
:style="{ height: '110px' }"
readonly
/>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description" />
<!-- timezone for datetime field -->
<div
v-if="timeZone"
:class="['time-zone', !df.description ? 'mt-2' : '']"
v-html="timeZone"
/>
<!-- color selector icon -->
<div class="selected-color no-value" />
</div>
</template>
<style lang="scss" scoped>
.control {
&.form-builder-control {
.control-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
&.reqd::after {
content: " *";
color: #ef4444;
}
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
.time-zone {
font-size: 12px;
color: #6b7280;
}
}
}
.selected-color {
background-color: transparent;
top: 30px !important;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,54 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
const props = defineProps(["df"]);
</script>
<template>
<div class="control editable">
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div class="map-placeholder">
<Icon icon="mdi:map-marker" class="icon-lg" />
<span>{{ t("Geolocation Field") }}</span>
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.map-placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 200px;
background-color: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 6px;
color: #9ca3af;
gap: 8px;
span {
font-size: 13px;
}
}
.icon-lg {
width: 32px;
height: 32px;
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,45 @@
<script setup>
import { Icon } from '@iconify/vue';
const props = defineProps(["df"]);
</script>
<template>
<div class="control editable">
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div class="missing-image">
<Icon icon="mdi:image-off-outline" class="icon-lg" />
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.missing-image {
display: flex;
justify-content: center;
align-items: center;
height: 150px;
background-color: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 6px;
color: #9ca3af;
}
.icon-lg {
width: 48px;
height: 48px;
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,89 @@
<!-- Used as Link Control -->
<script setup>
import { onMounted, ref, useSlots, computed, watch } from "vue";
const props = defineProps(["args", "df", "read_only", "modelValue"]);
let emit = defineEmits(["update:modelValue"]);
let slots = useSlots();
function handleInput(event) {
emit("update:modelValue", event.target.value);
}
</script>
<template>
<div
v-if="slots.label"
class="control form-builder-control"
:data-fieldtype="df.fieldtype"
:class="{ editable: slots.label }"
>
<!-- label -->
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<!-- link input -->
<input class="form-control" type="text" readonly />
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description" />
</div>
<div v-else class="control">
<div class="control-label label">{{ df.label }}</div>
<input
class="form-control"
type="text"
:value="modelValue"
:disabled="read_only || df.read_only"
@input="handleInput"
/>
<div v-if="df.description" class="mt-2 description" v-html="df.description" />
</div>
</template>
<style lang="scss" scoped>
.control {
.control-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,51 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { Icon } from '@iconify/vue';
const props = defineProps(["df"]);
// Generate stars based on options (default 5)
const starCount = computed(() => {
return parseInt(props.df.options) || 5;
});
</script>
<template>
<div class="control editable">
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div class="rating">
<Icon
v-for="i in starCount"
:key="i"
icon="mdi:star"
class="star"
/>
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.rating {
display: flex;
gap: 4px;
.star {
width: 20px;
height: 20px;
color: #fbbf24;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,144 @@
<script setup>
import { useSlots, onMounted, ref, computed, watch } from "vue";
import { Icon } from '@iconify/vue';
import { t } from '@/shared/i18n';
const props = defineProps(["df", "read_only", "modelValue", "no_label"]);
let emit = defineEmits(["update:modelValue"]);
let slots = useSlots();
let select = ref(null);
function getOptions() {
let options = props.df.options;
if (typeof options === "string") {
options = options.split("\n") || "";
options = options.map((opt) => {
return { label: opt, value: opt };
});
}
if (options?.length && typeof options[0] === "string") {
options = options.map((opt) => {
return { label: opt, value: opt };
});
}
if (props.df.sort_options) {
options.sort((a, b) => a.label.localeCompare(b.label));
}
return options || [];
}
let options = computed(() => getOptions());
function handleChange(event) {
emit("update:modelValue", event.target.value);
}
</script>
<template>
<div v-if="slots.label" class="control form-builder-control" :class="{ editable: slots.label }">
<!-- label -->
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<!-- select input -->
<div class="select-input">
<input class="form-control" readonly />
<Icon icon="mdi:chevron-down" class="select-icon" />
</div>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
<div v-else class="control">
<div v-if="!no_label" class="control-label label">{{ df.label }}</div>
<div class="select-input">
<select
class="form-control"
:value="modelValue"
@change="handleChange"
:disabled="read_only || df.read_only"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<Icon icon="mdi:chevron-down" class="select-icon" />
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.control {
.control-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
padding-right: 30px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
appearance: none;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
}
.editable {
.select-icon {
top: 8px !important;
}
}
.select-input {
position: relative;
.select-icon {
position: absolute;
pointer-events: none;
top: 50%;
transform: translateY(-50%);
right: 10px;
color: #6b7280;
width: 16px;
height: 16px;
}
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,42 @@
<script setup>
const props = defineProps(["df"]);
</script>
<template>
<div class="control editable">
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div class="signature-field"></div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.signature-field {
border-radius: 6px;
height: 200px;
display: flex;
justify-content: center;
background-color: #f9fafb;
border: 1px dashed #d1d5db;
&::before {
content: "";
background-color: #9ca3af;
width: 70%;
height: 2px;
margin-top: 150px;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,95 @@
<script setup>
import { t } from '@/shared/i18n';
const props = defineProps(["df"]);
</script>
<template>
<div class="control editable">
<!-- label -->
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<!-- table grid -->
<div
v-if="df.fieldtype === 'Table'"
class="table-controls"
>
<div class="table-column" style="width: 10%">
<div class="table-field ellipsis">
{{ t("No.") }}
</div>
</div>
<div class="table-column" style="width: 90%">
<div class="table-field ellipsis">
{{ df.options || t("Child Table") }}
</div>
</div>
</div>
<div class="grid-empty">
{{ t("No Data") }}
</div>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.grid-empty {
background-color: #fff;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border: 1px solid #e5e7eb;
border-top: none;
padding: 40px;
text-align: center;
color: #9ca3af;
}
.table-controls {
display: flex;
.table-column {
position: relative;
.table-field {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-left: none;
padding: 8px 10px;
user-select: none;
white-space: nowrap;
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: #374151;
}
&:first-child .table-field {
border-top-left-radius: 6px;
border-left: 1px solid #e5e7eb;
}
&:last-child .table-field {
border-top-right-radius: 6px;
}
}
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
}
.description {
font-size: 12px;
color: #6b7280;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,93 @@
<!-- Used as Text, Small Text & Long Text Control -->
<script setup>
import { useSlots, computed } from "vue";
const props = defineProps(["df", "value", "read_only", "modelValue"]);
let emit = defineEmits(["update:modelValue"]);
let slots = useSlots();
let height = computed(() => {
if (props.df.fieldtype === "Small Text") {
return "150px";
}
return "300px";
});
</script>
<template>
<div class="control" :class="{ editable: slots.label }">
<!-- label -->
<div v-if="slots.label" class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div v-else class="control-label label">{{ df.label }}</div>
<!-- textarea input -->
<textarea
v-if="slots.label"
:style="{ height: height, maxHeight: df.max_height ?? '' }"
class="form-control"
type="text"
readonly
/>
<textarea
v-else
:style="{ height: height, maxHeight: df.max_height ?? '' }"
class="form-control"
type="text"
:value="value"
:disabled="read_only || df.read_only"
@input="(event) => $emit('update:modelValue', event.target.value)"
/>
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.control {
.control-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
color: #374151;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #d1d5db;
border-radius: 6px;
resize: vertical;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
}
.description {
font-size: 12px;
color: #6b7280;
}
}
.mt-2 {
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,90 @@
<script setup>
import { computed } from "vue";
const props = defineProps(["df"]);
// Note: In jlocal, we create a static preview of the text editor
// The actual rich text editing will be done in the form view
const editorPlaceholder = computed(() => {
return props.df.default || 'Enter rich text here...';
});
</script>
<template>
<div class="control editable">
<div class="field-controls">
<slot name="label" />
<slot name="actions" />
</div>
<div class="text-editor-preview">
<div class="editor-toolbar">
<div class="toolbar-group">
<span class="toolbar-btn" title="Bold">B</span>
<span class="toolbar-btn" title="Italic">I</span>
<span class="toolbar-btn" title="Underline">U</span>
</div>
<div class="toolbar-group">
<span class="toolbar-btn" title="List"></span>
<span class="toolbar-btn" title="Link">🔗</span>
</div>
</div>
<div class="editor-content">
<p class="placeholder-text">{{ editorPlaceholder }}</p>
</div>
</div>
<div v-if="df.description" class="mt-2 description" v-html="df.description"></div>
</div>
</template>
<style lang="scss" scoped>
.text-editor-preview {
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 4px;
background: #fff;
overflow: hidden;
.editor-toolbar {
display: flex;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
background: #fafafa;
pointer-events: none;
.toolbar-group {
display: flex;
gap: 4px;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
font-size: 12px;
font-weight: 600;
color: #666;
border: 1px solid #ddd;
border-radius: 3px;
background: #fff;
}
}
.editor-content {
min-height: 120px;
padding: 12px;
cursor: pointer;
.placeholder-text {
color: #999;
margin: 0;
}
}
}
.description {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
</style>

View File

@ -0,0 +1,77 @@
import AttachControl from "./components/controls/AttachControl.vue";
import ButtonControl from "./components/controls/ButtonControl.vue";
import CheckControl from "./components/controls/CheckControl.vue";
import CodeControl from "./components/controls/CodeControl.vue";
import DataControl from "./components/controls/DataControl.vue";
import GeolocationControl from "./components/controls/GeolocationControl.vue";
import ImageControl from "./components/controls/ImageControl.vue";
import LinkControl from "./components/controls/LinkControl.vue";
import RatingControl from "./components/controls/RatingControl.vue";
import SelectControl from "./components/controls/SelectControl.vue";
import SignatureControl from "./components/controls/SignatureControl.vue";
import TableControl from "./components/controls/TableControl.vue";
import TextControl from "./components/controls/TextControl.vue";
import TextEditorControl from "./components/controls/TextEditorControl.vue";
export function registerGlobalComponents(app) {
app.component("AttachControl", AttachControl)
.component("AttachImageControl", AttachControl)
.component("AutocompleteControl", DataControl)
.component("BarcodeControl", DataControl)
.component("ButtonControl", ButtonControl)
.component("CheckControl", CheckControl)
.component("CodeControl", CodeControl)
.component("ColorControl", DataControl)
.component("CurrencyControl", DataControl)
.component("DataControl", DataControl)
.component("DateControl", DataControl)
.component("DatetimeControl", DataControl)
.component("DurationControl", DataControl)
.component("DynamicLinkControl", DataControl)
.component("FloatControl", DataControl)
.component("GeolocationControl", GeolocationControl)
.component("HeadingControl", ButtonControl)
.component("HTMLControl", DataControl)
.component("HTMLEditorControl", CodeControl)
.component("IconControl", DataControl)
.component("ImageControl", ImageControl)
.component("IntControl", DataControl)
.component("JSONControl", CodeControl)
.component("LinkControl", LinkControl)
.component("LongTextControl", TextControl)
.component("MarkdownEditorControl", CodeControl)
.component("PasswordControl", DataControl)
.component("PercentControl", DataControl)
.component("PhoneControl", DataControl)
.component("ReadOnlyControl", DataControl)
.component("RatingControl", RatingControl)
.component("SelectControl", SelectControl)
.component("SignatureControl", SignatureControl)
.component("SmallTextControl", TextControl)
.component("TableControl", TableControl)
.component("TableMultiSelectControl", DataControl)
.component("TextControl", TextControl)
.component("TextEditorControl", TextEditorControl)
.component("TimeControl", DataControl);
}
export const controls = {
AttachControl,
ButtonControl,
CheckControl,
CodeControl,
DataControl,
GeolocationControl,
ImageControl,
LinkControl,
RatingControl,
SelectControl,
SignatureControl,
TableControl,
TextEditorControl,
};
export default {
registerGlobalComponents,
controls,
};

View File

@ -0,0 +1,176 @@
/**
* Form Builder Bundle Entry Point for jlocal
*
* This provides a clean API for using the FormBuilder component
* in jlocal's Vue application context.
*/
import { createApp, h, ref } from "vue";
import { createPinia } from "pinia";
import FormBuilderComponent from "./FormBuilder.vue";
import { registerGlobalComponents } from "./globals.js";
import { useFormBuilderStore, ALL_FIELDTYPES, LAYOUT_FIELDS, NO_VALUE_TYPES, DEFAULT_PAGEFIELD_PROPERTIES } from "./store";
import * as utils from "./utils.js";
/**
* Create and mount a FormBuilder instance
* @param {Object} options - Configuration options
* @param {HTMLElement|string} options.container - DOM element or selector to mount to
* @param {Array} options.fields - Initial field definitions
* @param {string} options.pagetype - The pagetype name
* @param {boolean} [options.readOnly=false] - Whether the form is read-only
* @param {boolean} [options.isCustomizeForm=false] - Whether this is customize form mode
* @param {Function} [options.onSave] - Callback when save is triggered
* @param {Function} [options.onDirtyChange] - Callback when dirty state changes
* @returns {Object} FormBuilder instance with control methods
*/
export function createFormBuilder(options) {
const {
container,
fields = [],
pagetype = "",
readOnly = false,
isCustomizeForm = false,
onSave,
onDirtyChange,
} = options;
// Resolve container element
const el = typeof container === "string"
? document.querySelector(container)
: container;
if (!el) {
throw new Error("FormBuilder: Invalid container element");
}
// Create pinia instance
const pinia = createPinia();
// Track current fields
const currentFields = ref([...fields]);
// Create Vue app
const app = createApp({
setup() {
return () => h(FormBuilderComponent, {
fields: currentFields.value,
pagetype,
readOnly,
isCustomizeForm,
"onUpdate:fields": (newFields) => {
currentFields.value = newFields;
},
onSave: () => {
onSave?.(currentFields.value);
},
onDirtyChange: (isDirty) => {
onDirtyChange?.(isDirty);
},
ref: "formBuilder",
});
},
});
app.use(pinia);
registerGlobalComponents(app);
// Mount the app
const instance = app.mount(el);
// Get store reference
const store = useFormBuilderStore();
// Return control API
return {
/**
* Get the current field definitions
* @returns {Array} Current fields
*/
getFields() {
return store.getUpdatedFields();
},
/**
* Set new fields
* @param {Array} newFields - New field definitions
*/
setFields(newFields) {
currentFields.value = [...newFields];
store.initialize({ fields: newFields, pagetype });
},
/**
* Validate the current form configuration
* @returns {boolean} Whether validation passed
*/
validate() {
return store.validateFields();
},
/**
* Check if there are unsaved changes
* @returns {boolean} Dirty state
*/
isDirty() {
return store.dirty;
},
/**
* Reset dirty state
*/
resetDirty() {
store.dirty = false;
},
/**
* Set read-only mode
* @param {boolean} value - Read-only state
*/
setReadOnly(value) {
store.read_only = value;
},
/**
* Toggle preview mode
*/
togglePreview() {
store.preview = !store.preview;
store.read_only = store.preview;
},
/**
* Get the Pinia store instance
* @returns {Object} Store instance
*/
getStore() {
return store;
},
/**
* Unmount and cleanup
*/
destroy() {
app.unmount();
},
};
}
// Export components and utilities for direct usage
export { FormBuilderComponent as FormBuilder };
export { useFormBuilderStore };
export { registerGlobalComponents };
export { ALL_FIELDTYPES, LAYOUT_FIELDS, NO_VALUE_TYPES, DEFAULT_PAGEFIELD_PROPERTIES };
export { utils };
// Default export
export default {
createFormBuilder,
FormBuilder: FormBuilderComponent,
useFormBuilderStore,
registerGlobalComponents,
ALL_FIELDTYPES,
LAYOUT_FIELDS,
NO_VALUE_TYPES,
DEFAULT_PAGEFIELD_PROPERTIES,
utils,
};

View File

@ -0,0 +1,464 @@
import { defineStore } from "pinia";
import {
createLayout,
scrubFieldNames,
sectionBoilerplate,
} from "./utils";
import { computed, nextTick, ref } from "vue";
import { useDebouncedRefHistory, onKeyDown, useActiveElement } from "@vueuse/core";
import { t } from '@/shared/i18n';
// Field types supported by the form builder
export const ALL_FIELDTYPES = [
"Autocomplete",
"Attach",
"Attach Image",
"Barcode",
"Button",
"Check",
"Code",
"Color",
"Currency",
"Data",
"Date",
"Datetime",
"Duration",
"Dynamic Link",
"Float",
"Geolocation",
"Heading",
"HTML",
"HTML Editor",
"Icon",
"Image",
"Int",
"JSON",
"Link",
"Long Text",
"Markdown Editor",
"Password",
"Percent",
"Phone",
"Rating",
"Read Only",
"Select",
"Signature",
"Small Text",
"Table",
"Table MultiSelect",
"Text",
"Text Editor",
"Time",
];
// Layout fields (not actual data fields)
export const LAYOUT_FIELDS = ["Tab Break", "Section Break", "Column Break"];
// Fields that don't hold values
export const NO_VALUE_TYPES = [
"Button",
"Column Break",
"Heading",
"HTML",
"Image",
"Section Break",
"Tab Break",
];
// Table field types
export const TABLE_FIELDS = ["Table", "Table MultiSelect"];
// Restricted field names
export const RESTRICTED_FIELDS = [
"name",
"creation",
"modified",
"modified_by",
"owner",
"docstatus",
"idx",
"parent",
"parenttype",
"parentfield",
];
// Default PageField properties for the properties panel
export const DEFAULT_PAGEFIELD_PROPERTIES = [
{ fieldname: "label", label: t("Label"), fieldtype: "Data" },
{ fieldname: "fieldname", label: t("Fieldname"), fieldtype: "Data", read_only: 1 },
{ fieldname: "fieldtype", label: t("Field Type"), fieldtype: "Select", options: ALL_FIELDTYPES.join("\n") },
{ fieldname: "options", label: t("Options"), fieldtype: "Small Text" },
{ fieldname: "reqd", label: t("Mandatory"), fieldtype: "Check" },
{ fieldname: "default", label: t("Default"), fieldtype: "Data" },
{ fieldname: "read_only", label: t("Read Only"), fieldtype: "Check" },
{ fieldname: "hidden", label: t("Hidden"), fieldtype: "Check" },
{ fieldname: "unique", label: t("Unique"), fieldtype: "Check" },
{ fieldname: "in_list_view", label: t("In List View"), fieldtype: "Check" },
{ fieldname: "in_standard_filter", label: t("In Standard Filter"), fieldtype: "Check" },
{ fieldname: "in_global_search", label: t("In Global Search"), fieldtype: "Check" },
{ fieldname: "bold", label: t("Bold"), fieldtype: "Check" },
{ fieldname: "collapsible", label: t("Collapsible"), fieldtype: "Check", depends_on: "eval:doc.fieldtype=='Section Break'" },
{ fieldname: "collapsible_depends_on", label: t("Collapsible Depends On"), fieldtype: "Data", depends_on: "eval:doc.collapsible" },
{ fieldname: "depends_on", label: t("Depends On"), fieldtype: "Data" },
{ fieldname: "mandatory_depends_on", label: t("Mandatory Depends On"), fieldtype: "Data" },
{ fieldname: "read_only_depends_on", label: t("Read Only Depends On"), fieldtype: "Data" },
{ fieldname: "description", label: t("Description"), fieldtype: "Small Text" },
{ fieldname: "allow_on_submit", label: t("Allow on Submit"), fieldtype: "Check" },
{ fieldname: "translatable", label: t("Translatable"), fieldtype: "Check" },
{ fieldname: "fetch_from", label: t("Fetch From"), fieldtype: "Data" },
{ fieldname: "fetch_if_empty", label: t("Fetch If Empty"), fieldtype: "Check" },
{ fieldname: "ignore_user_permissions", label: t("Ignore User Permissions"), fieldtype: "Check" },
{ fieldname: "ignore_xss_filter", label: t("Ignore XSS Filter"), fieldtype: "Check" },
{ fieldname: "print_hide", label: t("Print Hide"), fieldtype: "Check" },
{ fieldname: "print_hide_if_no_value", label: t("Print Hide If No Value"), fieldtype: "Check" },
{ fieldname: "report_hide", label: t("Report Hide"), fieldtype: "Check" },
{ fieldname: "permlevel", label: t("Permission Level"), fieldtype: "Int" },
{ fieldname: "width", label: t("Width"), fieldtype: "Data" },
{ fieldname: "columns", label: t("Columns"), fieldtype: "Int" },
{ fieldname: "precision", label: t("Precision"), fieldtype: "Data" },
{ fieldname: "length", label: t("Length"), fieldtype: "Int" },
{ fieldname: "non_negative", label: t("Non Negative"), fieldtype: "Check" },
{ fieldname: "hide_border", label: t("Hide Border"), fieldtype: "Check" },
{ fieldname: "hide_days", label: t("Hide Days"), fieldtype: "Check" },
{ fieldname: "hide_seconds", label: t("Hide Seconds"), fieldtype: "Check" },
{ fieldname: "max_height", label: t("Max Height"), fieldtype: "Data" },
{ fieldname: "link_filters", label: t("Link Filters"), fieldtype: "Code", depends_on: "eval:doc.fieldtype=='Link'" },
];
export const useFormBuilderStore = defineStore("form-builder-store", () => {
let pagetype = ref("");
let pg = ref(null);
let pagefields = ref([...DEFAULT_PAGEFIELD_PROPERTIES]);
let form = ref({
layout: { tabs: [] },
active_tab: null,
selected_field: null,
});
let dirty = ref(false);
let readOnly = ref(false);
let isCustomizeForm = ref(false);
let preview = ref(false);
let drag = ref(false);
let getAnimation = "cubic-bezier(0.34, 1.56, 0.64, 1)";
let refHistory = ref(null);
// Getters
let getPagefields = computed(() => {
return pagefields.value;
});
let currentTab = computed(() => {
if (!form.value.layout.tabs || form.value.layout.tabs.length === 0) {
return null;
}
return form.value.layout.tabs.find((tab) => tab.df.name === form.value.active_tab);
});
const activeElement = useActiveElement();
const notUsingInput = computed(
() =>
activeElement.value?.readOnly ||
activeElement.value?.disabled ||
(activeElement.value?.tagName !== "INPUT" &&
activeElement.value?.tagName !== "TEXTAREA")
);
// Actions
function selected(name) {
return form.value.selected_field?.name === name;
}
function getDf(fieldtype, fieldname = "", label = "") {
let df = {
name: getRandomId(8),
fieldtype: fieldtype,
fieldname: fieldname,
label: label,
__islocal: 1,
__unsaved: 1,
};
return df;
}
function getRandomId(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function hasStandardField(field) {
if (!isCustomizeForm.value) return false;
if (!field.df.is_custom_field) return true;
let children = {
"Tab Break": "sections",
"Section Break": "columns",
"Column Break": "fields",
}[field.df.fieldtype];
if (!children) return false;
return field[children]?.some((child) => {
if (!child.df.is_custom_field) return true;
return hasStandardField(child);
});
}
function isUserGeneratedField(field) {
return field.df.is_custom_field && !field.df.is_system_generated ? 1 : 0;
}
function initialize(fields = [], options = {}) {
pg.value = options.pg || null;
pagetype.value = options.pagetype || "";
readOnly.value = options.readOnly || false;
isCustomizeForm.value = options.isCustomizeForm || false;
form.value.layout = createLayout(fields);
if (form.value.layout.tabs.length > 0) {
form.value.active_tab = form.value.layout.tabs[0].df.name;
}
form.value.selected_field = null;
nextTick(() => {
dirty.value = false;
preview.value = false;
});
setupUndoRedo();
}
let undoRedoKeyboardEvent = onKeyDown(true, (e) => {
if (!refHistory.value) return;
if ((e.ctrlKey || e.metaKey)) {
if (e.key === "z" && !e.shiftKey && refHistory.value.canUndo) {
refHistory.value.undo();
} else if (e.key === "z" && e.shiftKey && refHistory.value.canRedo) {
refHistory.value.redo();
}
}
});
function setupUndoRedo() {
refHistory.value = useDebouncedRefHistory(form, { deep: true, debounce: 100 });
undoRedoKeyboardEvent;
}
function validateFields(fields, isTable) {
fields = scrubFieldNames(fields);
let errorMessage = "";
let hasFields = fields.some((df) => {
return !LAYOUT_FIELDS.includes(df.fieldtype);
});
if (!hasFields) {
errorMessage = t("PageType must have at least one field");
}
let notAllowedInListView = ["Attach Image", ...NO_VALUE_TYPES];
if (isTable) {
notAllowedInListView = notAllowedInListView.filter((f) => f !== "Button");
}
function getFieldData(df) {
let fieldname = `<b>${df.label} (${df.fieldname})</b>`;
if (!df.label) {
fieldname = `<b>${df.fieldname}</b>`;
}
let fieldtype = `<b>${df.fieldtype}</b>`;
return [fieldname, fieldtype];
}
fields.forEach((df) => {
// check if fieldname already exists
let duplicate = fields.filter((f) => f.fieldname === df.fieldname);
if (duplicate.length > 1) {
errorMessage = t("Fieldname {0} appears multiple times").replace("{0}", getFieldData(df)[0]);
}
// Link & Table fields should always have options set
if (["Link", ...TABLE_FIELDS].includes(df.fieldtype) && !df.options) {
errorMessage = t("Options is required for field {0} of type {1}")
.replace("{0}", getFieldData(df)[0])
.replace("{1}", getFieldData(df)[1]);
}
// Do not allow if field is hidden & required but doesn't have default value
if (df.hidden && df.reqd && !df.default) {
errorMessage = t("{0} cannot be hidden and mandatory without any default value")
.replace("{0}", getFieldData(df)[0]);
}
// In List View is not allowed for some fieldtypes
if (df.in_list_view && notAllowedInListView.includes(df.fieldtype)) {
errorMessage = t("'In List View' is not allowed for field {0} of type {1}")
.replace("{0}", getFieldData(df)[0])
.replace("{1}", getFieldData(df)[1]);
}
// In Global Search is not allowed for no_value_type fields
if (df.in_global_search && NO_VALUE_TYPES.includes(df.fieldtype)) {
errorMessage = t("'In Global Search' is not allowed for field {0} of type {1}")
.replace("{0}", getFieldData(df)[0])
.replace("{1}", getFieldData(df)[1]);
}
if (df.link_filters === "") {
delete df.link_filters;
}
// check if link_filters format is correct
if (df.link_filters) {
try {
JSON.parse(df.link_filters);
} catch (e) {
errorMessage = t("Invalid Filter Format for field {0} of type {1}")
.replace("{0}", getFieldData(df)[0])
.replace("{1}", getFieldData(df)[1]);
}
}
});
return errorMessage;
}
function getUpdatedFields() {
let fields = [];
let idx = 0;
let newFieldName = "new-pagefield-";
let layoutFields = JSON.parse(JSON.stringify(form.value.layout.tabs));
layoutFields.forEach((tab, i) => {
if (
(i === 0 && isDfUpdated(tab.df, getDf("Tab Break", "", t("Details")))) ||
i > 0
) {
idx++;
tab.df.idx = idx;
if (tab.df.__unsaved && tab.df.__islocal) {
tab.df.name = newFieldName + idx;
}
fields.push(tab.df);
}
tab.sections.forEach((section, j) => {
let fieldsCopy = JSON.parse(JSON.stringify(fields));
let oldIdx = idx;
section.has_fields = false;
if ((j === 0 && isDfUpdated(section.df, getDf("Section Break"))) || j > 0) {
idx++;
section.df.idx = idx;
if (section.df.__unsaved && section.df.__islocal) {
section.df.name = newFieldName + idx;
}
fields.push(section.df);
}
section.columns.forEach((column, k) => {
if (
(k === 0 && isDfUpdated(column.df, getDf("Column Break"))) ||
k > 0 ||
column.fields.length === 0
) {
idx++;
column.df.idx = idx;
if (column.df.__unsaved && column.df.__islocal) {
column.df.name = newFieldName + idx;
}
fields.push(column.df);
}
column.fields.forEach((field) => {
idx++;
field.df.idx = idx;
if (field.df.__unsaved && field.df.__islocal) {
field.df.name = newFieldName + idx;
}
fields.push(field.df);
section.has_fields = true;
});
});
if (!section.has_fields) {
fields = fieldsCopy || [];
idx = oldIdx;
}
});
});
return fields;
}
function isDfUpdated(df, newDf) {
let dfCopy = JSON.parse(JSON.stringify(df));
let newDfCopy = JSON.parse(JSON.stringify(newDf));
delete dfCopy.name;
delete newDfCopy.name;
return JSON.stringify(dfCopy) !== JSON.stringify(newDfCopy);
}
function getLayout() {
return createLayout(pg.value?.fields || []);
}
// Tab actions
function addNewTab() {
let tab = {
df: getDf("Tab Break", "", t("Tab") + " " + (form.value.layout.tabs.length + 1)),
sections: [sectionBoilerplate(getDf)],
};
form.value.layout.tabs.push(tab);
activateTab(tab);
}
function activateTab(tab) {
form.value.active_tab = tab.df.name;
form.value.selected_field = tab.df;
nextTick(() => {
const activeTabElement = document.querySelector(".tabs .tab.active");
if (activeTabElement) {
activeTabElement.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}
});
}
return {
pagetype,
pg,
form,
dirty,
readOnly,
isCustomizeForm,
preview,
drag,
getAnimation,
getPagefields,
currentTab,
notUsingInput,
selected,
getDf,
getRandomId,
hasStandardField,
isUserGeneratedField,
initialize,
validateFields,
getUpdatedFields,
isDfUpdated,
getLayout,
addNewTab,
activateTab,
};
});

View File

@ -0,0 +1,845 @@
/**
* Form Builder Styles
*
* Main stylesheet for the Form Builder component.
* Uses CSS custom properties for theming compatibility with naive-ui.
*/
/* CSS Custom Properties (defaults for light theme) */
:root {
--fb-bg-color: #f5f5f5;
--fb-card-bg: #ffffff;
--fb-border-color: #e0e0e6;
--fb-text-color: #333639;
--fb-text-muted: #999;
--fb-primary-color: #18a058;
--fb-primary-hover: #36ad6a;
--fb-warning-color: #f0a020;
--fb-error-color: #d03050;
--fb-highlight-color: rgba(24, 160, 88, 0.1);
--fb-selected-border: #18a058;
--fb-hover-bg: rgba(24, 160, 88, 0.08);
--fb-drag-placeholder-bg: #f0f9f4;
--fb-radius-sm: 3px;
--fb-radius: 6px;
--fb-radius-lg: 8px;
--fb-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--fb-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--fb-transition: all 0.2s ease;
}
/* Base Form Builder Container */
.form-builder-container {
display: flex;
min-height: 100%;
background: var(--fb-bg-color);
font-size: 14px;
line-height: 1.5;
}
.form-builder-container.resizing {
user-select: none;
cursor: col-resize;
}
/* Form Main Area */
.form-container {
flex: 1;
overflow: hidden;
}
.form-main {
background: var(--fb-card-bg);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius);
margin: 8px;
overflow: hidden;
}
/* Sidebar */
.form-controls {
position: relative;
}
.form-sidebar {
border-left: 1px solid var(--fb-border-color);
background: var(--fb-card-bg);
overflow-y: auto;
max-height: calc(100vh - 120px);
}
/* Resize Handle */
.resize-handle {
width: 4px;
position: absolute;
left: 0;
top: 0;
bottom: 0;
cursor: col-resize;
z-index: 10;
transition: background-color 0.15s;
}
.resize-handle:hover,
.resize-handle.active {
background-color: var(--fb-primary-color);
}
/* Tab Styles */
.tab-header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: var(--fb-bg-color);
border-bottom: 1px solid var(--fb-border-color);
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--fb-radius);
cursor: pointer;
transition: var(--fb-transition);
white-space: nowrap;
user-select: none;
}
.tab:hover {
background: var(--fb-hover-bg);
}
.tab.active {
background: var(--fb-card-bg);
box-shadow: var(--fb-shadow-sm);
}
.tab.selected {
border: 2px solid var(--fb-selected-border);
}
.tab-label {
font-weight: 500;
color: var(--fb-text-color);
}
/* Section Styles */
.form-section {
padding: 12px;
border-bottom: 1px solid var(--fb-border-color);
}
.form-section:last-child {
border-bottom: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
border-radius: var(--fb-radius-sm);
transition: var(--fb-transition);
}
.section-header:hover {
background: var(--fb-hover-bg);
}
.section-header.has-label {
background: var(--fb-bg-color);
margin-bottom: 8px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--fb-text-color);
}
.section-description {
padding: 0 12px;
font-size: 13px;
color: var(--fb-text-muted);
margin-bottom: 8px;
}
.section-columns {
margin-top: 8px;
}
.section-columns-container {
display: flex;
gap: 12px;
}
/* Column Styles */
.column {
flex: 1;
padding: 8px;
border: 1px dashed var(--fb-border-color);
border-radius: var(--fb-radius-sm);
min-height: 80px;
transition: var(--fb-transition);
}
.column:hover {
border-color: var(--fb-primary-color);
border-style: dashed;
}
.column.selected {
border: 2px solid var(--fb-selected-border);
background: var(--fb-highlight-color);
}
.column-header {
padding: 4px 8px;
margin-bottom: 8px;
font-weight: 500;
color: var(--fb-text-muted);
font-size: 12px;
}
.column-container {
min-height: 60px;
}
/* Field Styles */
.field {
padding: 8px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--fb-radius-sm);
cursor: pointer;
transition: var(--fb-transition);
}
.field:last-child {
margin-bottom: 0;
}
.field:hover {
background: var(--fb-hover-bg);
border-color: var(--fb-border-color);
}
.field.selected {
border: 2px solid var(--fb-selected-border);
background: var(--fb-highlight-color);
}
.field.hovered {
background: var(--fb-hover-bg);
}
.field-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.field-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.field:hover .field-actions,
.field.selected .field-actions {
opacity: 1;
}
.field-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--fb-radius-sm);
background: transparent;
color: var(--fb-text-muted);
cursor: pointer;
transition: var(--fb-transition);
}
.field-action-btn:hover {
background: var(--fb-bg-color);
color: var(--fb-text-color);
}
.field-action-btn.danger:hover {
background: rgba(208, 48, 80, 0.1);
color: var(--fb-error-color);
}
/* Label Styles */
.label {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.label-text {
font-weight: 500;
color: var(--fb-text-color);
font-size: 13px;
}
.reqd-indicator {
color: var(--fb-error-color);
font-weight: bold;
}
/* Control Styles */
.control {
width: 100%;
}
.control.editable input,
.control.editable textarea,
.control.editable select,
.control.editable .input-field {
background: var(--fb-card-bg);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
padding: 6px 10px;
font-size: 13px;
color: var(--fb-text-color);
cursor: pointer;
transition: var(--fb-transition);
}
.control.editable input:focus,
.control.editable textarea:focus,
.control.editable select:focus {
outline: none;
border-color: var(--fb-primary-color);
box-shadow: 0 0 0 2px var(--fb-highlight-color);
}
.control .description {
margin-top: 4px;
font-size: 12px;
color: var(--fb-text-muted);
}
/* Input Fields */
input.form-control {
width: 100%;
height: 32px;
background: var(--fb-card-bg);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
padding: 0 10px;
font-size: 13px;
color: var(--fb-text-color);
}
textarea.form-control {
width: 100%;
min-height: 80px;
resize: vertical;
}
/* Select Input */
.select-input {
position: relative;
display: flex;
align-items: center;
}
.select-input input {
padding-right: 30px;
}
.select-input .icon {
position: absolute;
right: 8px;
color: var(--fb-text-muted);
pointer-events: none;
}
/* Checkbox/Radio Styles */
.checkbox-control {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.checkbox-control input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--fb-primary-color);
}
/* Button Control */
.btn-control {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--fb-radius-sm);
font-weight: 500;
cursor: pointer;
transition: var(--fb-transition);
}
.btn-control.btn-primary {
background: var(--fb-primary-color);
color: white;
border: none;
}
.btn-control.btn-default {
background: var(--fb-card-bg);
color: var(--fb-text-color);
border: 1px solid var(--fb-border-color);
}
/* Table Control */
.table-preview {
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
overflow: hidden;
}
.table-preview .table-header {
display: flex;
background: var(--fb-bg-color);
border-bottom: 1px solid var(--fb-border-color);
padding: 8px 12px;
font-weight: 500;
font-size: 12px;
}
.table-preview .table-row {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--fb-border-color);
}
.table-preview .table-row:last-child {
border-bottom: none;
}
/* Rating Control */
.rating-stars {
display: flex;
gap: 4px;
}
.rating-star {
color: var(--fb-border-color);
font-size: 18px;
}
.rating-star.filled {
color: var(--fb-warning-color);
}
/* Signature Control */
.signature-field {
width: 200px;
height: 60px;
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
background: var(--fb-card-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--fb-text-muted);
font-size: 12px;
}
/* Image Control */
.missing-image {
width: 100px;
height: 100px;
border: 2px dashed var(--fb-border-color);
border-radius: var(--fb-radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--fb-text-muted);
gap: 4px;
}
/* Code Control */
.code-preview {
background: #1e1e1e;
border-radius: var(--fb-radius-sm);
padding: 12px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
color: #d4d4d4;
min-height: 100px;
}
/* Link Control */
.link-preview {
display: flex;
flex-direction: column;
gap: 4px;
}
.link-input-group {
display: flex;
gap: 8px;
}
.link-input-group input {
flex: 1;
}
.link-input-group .search-btn {
padding: 6px 12px;
background: var(--fb-bg-color);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
color: var(--fb-text-muted);
cursor: pointer;
}
/* Add Field Button */
.add-new-field-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 12px;
border: 2px dashed var(--fb-border-color);
border-radius: var(--fb-radius-sm);
background: transparent;
color: var(--fb-text-muted);
font-size: 13px;
cursor: pointer;
transition: var(--fb-transition);
}
.add-new-field-btn:hover {
border-color: var(--fb-primary-color);
color: var(--fb-primary-color);
background: var(--fb-highlight-color);
}
/* Dropdown Menu */
.dropdown-menu {
position: fixed;
min-width: 160px;
background: var(--fb-card-bg);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius);
box-shadow: var(--fb-shadow);
padding: 4px;
z-index: 1000;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--fb-radius-sm);
color: var(--fb-text-color);
font-size: 13px;
cursor: pointer;
transition: var(--fb-transition);
}
.dropdown-item:hover {
background: var(--fb-hover-bg);
}
.dropdown-item.danger {
color: var(--fb-error-color);
}
.dropdown-item.danger:hover {
background: rgba(208, 48, 80, 0.1);
}
.dropdown-divider {
height: 1px;
background: var(--fb-border-color);
margin: 4px 0;
}
/* Autocomplete/Combobox */
.combo-box-options {
position: fixed;
min-width: 200px;
max-height: 300px;
overflow-y: auto;
background: var(--fb-card-bg);
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius);
box-shadow: var(--fb-shadow);
z-index: 1001;
}
.combo-box-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: var(--fb-transition);
}
.combo-box-option:hover,
.combo-box-option.active {
background: var(--fb-hover-bg);
}
.combo-box-option.selected {
background: var(--fb-highlight-color);
color: var(--fb-primary-color);
}
/* Editable Input */
.editable-input {
display: flex;
align-items: center;
}
.editable-input .edit-icon {
opacity: 0;
margin-left: 4px;
cursor: pointer;
transition: opacity 0.15s;
}
.editable-input:hover .edit-icon {
opacity: 1;
}
.editable-input input {
border: none;
border-bottom: 2px solid var(--fb-primary-color);
background: transparent;
padding: 2px 4px;
font-size: inherit;
font-weight: inherit;
color: inherit;
outline: none;
}
/* Sidebar Styles */
.sidebar-container {
width: 280px;
padding: 16px;
}
.sidebar-header {
margin-bottom: 16px;
}
.sidebar-section {
margin-bottom: 16px;
}
.sidebar-section-title {
font-weight: 600;
font-size: 12px;
color: var(--fb-text-muted);
text-transform: uppercase;
margin-bottom: 8px;
}
/* Property Group */
.property-group {
margin-bottom: 12px;
}
.property-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--fb-text-muted);
margin-bottom: 4px;
}
.property-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius-sm);
font-size: 13px;
transition: var(--fb-transition);
}
.property-input:focus {
outline: none;
border-color: var(--fb-primary-color);
box-shadow: 0 0 0 2px var(--fb-highlight-color);
}
/* Search Box */
.search-box {
position: relative;
}
.search-box input {
width: 100%;
height: 36px;
padding: 0 12px 0 36px;
border: 1px solid var(--fb-border-color);
border-radius: var(--fb-radius);
font-size: 13px;
transition: var(--fb-transition);
}
.search-box input:focus {
outline: none;
border-color: var(--fb-primary-color);
box-shadow: 0 0 0 2px var(--fb-highlight-color);
}
.search-box .search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--fb-text-muted);
}
/* Drag & Drop Styles */
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
opacity: 1;
box-shadow: var(--fb-shadow);
}
.sortable-chosen {
background: var(--fb-drag-placeholder-bg);
}
.drop-placeholder {
position: relative;
height: 60px;
border: 2px dashed var(--fb-primary-color);
border-radius: var(--fb-radius-sm);
background: var(--fb-highlight-color);
display: flex;
align-items: center;
justify-content: center;
}
.drop-placeholder::after {
content: "Drop here";
color: var(--fb-primary-color);
font-weight: 500;
}
/* User Generated Field Indicator */
[data-is-user-generated="1"] {
background-color: rgba(240, 160, 32, 0.15);
}
/* Preview Mode */
.preview .tab,
.preview .column,
.preview .field {
background: var(--fb-card-bg);
}
.preview .column,
.preview .field {
border: none;
padding: 0;
}
.preview .add-new-field-btn,
.preview .field-actions,
.preview .resize-handle {
display: none;
}
.preview .field-controls {
margin-bottom: 4px;
}
/* Read Only Mode Indicator */
.read-only-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--fb-warning-color);
color: white;
border-radius: var(--fb-radius-sm);
font-size: 12px;
font-weight: 500;
}
/* Scrollbar Styles */
.form-builder-container ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.form-builder-container ::-webkit-scrollbar-track {
background: transparent;
}
.form-builder-container ::-webkit-scrollbar-thumb {
background: var(--fb-border-color);
border-radius: 3px;
}
.form-builder-container ::-webkit-scrollbar-thumb:hover {
background: var(--fb-text-muted);
}
/* Utility Classes */
.mt-2 {
margin-top: 8px;
}
.mt-4 {
margin-top: 16px;
}
.mb-2 {
margin-bottom: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.gap-2 {
gap: 8px;
}
.text-muted {
color: var(--fb-text-muted);
}
.text-sm {
font-size: 12px;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,5 @@
/**
* Form Builder Styles Index
* Import this file to include all form builder styles
*/
@import './form-builder.css';

View File

@ -0,0 +1,330 @@
import { t } from '@/shared/i18n';
import { LAYOUT_FIELDS, RESTRICTED_FIELDS } from './store';
/**
* Create a layout structure from a flat list of fields
*/
export function createLayout(fields) {
let layout = {
tabs: [],
};
let tab = null;
let section = null;
let column = null;
function getRandomId(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function createDf(fieldtype, fieldname = "", label = "") {
return {
name: getRandomId(8),
fieldtype: fieldtype,
fieldname: fieldname,
label: label,
__islocal: 1,
__unsaved: 1,
};
}
function setTab(df) {
tab = getNewTab(df);
column = null;
section = null;
layout.tabs.push(tab);
}
function setSection(df) {
if (!tab) setTab();
section = getNewSection(df);
column = null;
tab.sections.push(section);
}
function setColumn(df) {
if (!section) setSection();
column = getNewColumn(df);
section.columns.push(column);
}
function getNewTab(df) {
let _tab = {};
_tab.df = df || createDf("Tab Break", "", t("Details"));
_tab.sections = [];
_tab.is_first = !df;
return _tab;
}
function getNewSection(df) {
let _section = {};
_section.df = df || createDf("Section Break");
_section.columns = [];
_section.is_first = !df;
return _section;
}
function getNewColumn(df) {
let _column = {};
_column.df = df || createDf("Column Break");
_column.fields = [];
_column.is_first = !df;
return _column;
}
for (let df of fields) {
if (df.fieldname) {
// make a copy to avoid mutation bugs
df = JSON.parse(JSON.stringify(df));
}
if (df.fieldtype === "Tab Break") {
setTab(df);
} else if (df.fieldtype === "Section Break") {
setSection(df);
} else if (df.fieldtype === "Column Break") {
setColumn(df);
} else {
if (!column) setColumn();
let field = { df: df };
if (df.fieldtype === "Table") {
field.table_columns = getTableColumns(df);
}
column.fields.push(field);
section.has_fields = true;
}
}
// remove empty sections
for (let tab of layout.tabs) {
for (let i = tab.sections.length - 1; i >= 0; --i) {
let section = tab.sections[i];
if (!section.has_fields) {
tab.sections.splice(tab.sections.indexOf(section), 1);
}
}
}
// Ensure at least one tab with one section and one column
if (layout.tabs.length === 0) {
layout.tabs.push({
df: createDf("Tab Break", "", t("Details")),
sections: [sectionBoilerplate(createDf)],
is_first: true,
});
}
return layout;
}
/**
* Get table columns for a Table field
*/
export function getTableColumns(df, childPagetype) {
let tableColumns = [];
// For now, return a simple structure
// This can be enhanced later to fetch actual child doctype fields
tableColumns.push([
{
label: t("No."),
},
1,
]);
return tableColumns;
}
/**
* Evaluate depends_on expression
*/
export function evaluateDependsOnValue(expression, pg) {
if (!pg) return false;
let out = null;
if (typeof expression === "boolean") {
out = expression;
} else if (typeof expression === "function") {
out = expression(pg);
} else if (expression.substr(0, 5) === "eval:") {
try {
const doc = pg;
// Simple evaluation for common patterns
out = eval(expression.substr(5));
} catch (e) {
console.warn('Invalid "depends_on" expression:', expression);
out = false;
}
} else {
const value = pg[expression];
if (Array.isArray(value)) {
out = !!value.length;
} else {
out = !!value;
}
}
return out;
}
/**
* Create a section boilerplate with one column
*/
export function sectionBoilerplate(getDf) {
return {
df: getDf("Section Break"),
columns: [
{
df: getDf("Column Break"),
fields: [],
},
],
};
}
/**
* Move children to a new parent element
*/
export function moveChildrenToParent(props, parent, child, currentContainer, getDf) {
let children = props[parent][child + "s"];
let index = children.indexOf(props[child]);
if (index > 0) {
const name = parent.charAt(0).toUpperCase() + parent.slice(1);
// move current children and children after that to a new parent
let newParent = {
df: getDf(name + " Break"),
[child + "s"]: children.splice(index),
};
// add new parent after current parent
let parents = currentContainer[parent + "s"];
let parentIndex = parents.indexOf(props[parent]);
parents.splice(parentIndex + 1, 0, newParent);
// remove current child and after that
children.splice(index + 1);
return newParent.df.name;
}
}
/**
* Scrub field names - generate fieldnames from labels if missing
*/
export function scrubFieldNames(fields) {
function getRandomStr(length = 4) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
fields.forEach((d) => {
if (d.fieldtype) {
if (!d.fieldname) {
if (d.label) {
d.fieldname = d.label.trim().toLowerCase().replaceAll(" ", "_");
if (d.fieldname.endsWith("?")) {
d.fieldname = d.fieldname.slice(0, -1);
}
if (RESTRICTED_FIELDS.includes(d.fieldname)) {
d.fieldname = d.fieldname + "1";
}
if (d.fieldtype === "Section Break") {
d.fieldname = d.fieldname + "_section";
} else if (d.fieldtype === "Column Break") {
d.fieldname = d.fieldname + "_column";
} else if (d.fieldtype === "Tab Break") {
d.fieldname = d.fieldname + "_tab";
}
} else {
d.fieldname =
d.fieldtype.toLowerCase().replaceAll(" ", "_") +
"_" +
getRandomStr(4);
}
} else {
if (RESTRICTED_FIELDS.includes(d.fieldname)) {
throw new Error(t("Fieldname {0} is restricted").replace("{0}", d.fieldname));
}
}
let regex = new RegExp(/['",./%@()<>{}]/g);
d.fieldname = d.fieldname.replace(regex, "");
// fieldnames should be lowercase
d.fieldname = d.fieldname.toLowerCase();
}
// unique is automatically an index
if (d.unique) {
d.search_index = 0;
}
});
return fields;
}
/**
* Clone a field with a new unique name
*/
export function cloneField(field) {
function getRandomId(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
let clonedField = JSON.parse(JSON.stringify(field));
clonedField.df.name = getRandomId(8);
return clonedField;
}
/**
* Show a confirmation dialog
*/
export function confirmDialog(
title,
message,
primaryAction,
primaryActionLabel,
secondaryAction,
secondaryActionLabel
) {
// This will be implemented with naive-ui dialog
// For now, use browser confirm
if (window.confirm(`${title}\n\n${message}`)) {
primaryAction && primaryAction();
} else {
secondaryAction && secondaryAction();
}
}
/**
* Check if the device has a touch screen
*/
export function isTouchScreenDevice() {
return "ontouchstart" in document.documentElement;
}
/**
* Check if a value is in a list
*/
export function inList(list, value) {
return list && list.includes(value);
}

View File

@ -1,14 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import FormBuilderComponent from '@/core/features/form_builder/FormBuilder.vue'
import { useMessage } from 'naive-ui'
const props = defineProps<{ df: any; record: Record<string, any>; canEdit: boolean; ctx: any }>()
const message = useMessage()
// Label(vertical) (horizontal)
const labelLayout = computed(() => props.df.label_layout || 'vertical')
// form_builder
const isFormBuilder = computed(() => props.df.fieldname === 'form_builder')
// Form builder configuration
const pagetypeName = computed(() => props.record?.name || '')
const fields = computed(() => {
if (!isFormBuilder.value) return []
// recordfields
try {
const fieldsData = props.record?.fields
if (typeof fieldsData === 'string') {
return JSON.parse(fieldsData)
}
return Array.isArray(fieldsData) ? fieldsData : []
} catch (e) {
console.error('Failed to parse fields:', e)
return []
}
})
// Handle field updates from form builder
function handleFieldsUpdate(newFields: any[]) {
if (props.canEdit && isFormBuilder.value) {
// recordfields
try {
const fieldsJson = JSON.stringify(newFields)
if (props.record) {
props.record.fields = fieldsJson
}
} catch (e) {
console.error('Failed to update fields:', e)
}
}
}
// Handle save event
function handleSave(newFields: any[]) {
handleFieldsUpdate(newFields)
if (props.ctx?.t) {
message.success(props.ctx.t('Fields updated'))
}
}
// Handle dirty state change
function handleDirtyChange(isDirty: boolean) {
console.log('Form builder dirty:', isDirty)
}
</script>
<template>
<div :class="['field-wrapper', `layout-${labelLayout}`]">
<!-- 如果是form_builder字段渲染Form Builder组件 -->
<div v-if="isFormBuilder" class="form-builder-field">
<FormBuilderComponent
:fields="fields"
:pagetype="pagetypeName"
:readOnly="!canEdit"
:isCustomizeForm="false"
@update:fields="handleFieldsUpdate"
@save="handleSave"
@dirty-change="handleDirtyChange"
/>
</div>
<!-- 否则渲染普通HTML内容 -->
<div v-else :class="['field-wrapper', `layout-${labelLayout}`]">
<label class="field-label">
{{ ctx.t(df.label || df.fieldname) }}
<span v-if="df.reqd" class="required">*</span>
@ -21,6 +87,23 @@ const labelLayout = computed(() => props.df.label_layout || 'vertical')
.field-wrapper :deep(.html-content) {
flex: 1;
}
.form-builder-field {
min-height: 600px;
max-height: calc(100vh - 300px);
width: 100%;
margin: 16px 0;
overflow: auto;
border: 1px solid #e0e0e6;
border-radius: 6px;
background: #fafafa;
}
/* 确保FormBuilder组件内部也支持滚动 */
.form-builder-field :deep(.form-builder-container) {
height: 100%;
overflow: visible;
}
</style>