增加form_builder组件,实现可视化拖拽编排pagetype字段
This commit is contained in:
parent
e10a42dbad
commit
3e08ae95c3
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@ frontend/.env.production
|
||||
# 忽略名为 test 的文件夹
|
||||
test/
|
||||
.cursor/
|
||||
.qoder/
|
||||
|
||||
|
||||
# 忽略所有 文件夹
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
176
apps/jingrow/frontend/src/core/features/form_builder/index.js
Normal file
176
apps/jingrow/frontend/src/core/features/form_builder/index.js
Normal 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,
|
||||
};
|
||||
464
apps/jingrow/frontend/src/core/features/form_builder/store.js
Normal file
464
apps/jingrow/frontend/src/core/features/form_builder/store.js
Normal 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,
|
||||
};
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Form Builder Styles Index
|
||||
* Import this file to include all form builder styles
|
||||
*/
|
||||
@import './form-builder.css';
|
||||
330
apps/jingrow/frontend/src/core/features/form_builder/utils.js
Normal file
330
apps/jingrow/frontend/src/core/features/form_builder/utils.js
Normal 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);
|
||||
}
|
||||
@ -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 []
|
||||
|
||||
// 从record中获取fields数据
|
||||
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) {
|
||||
// 更新record中的fields字段
|
||||
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>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user