增加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 的文件夹
|
||||||
test/
|
test/
|
||||||
.cursor/
|
.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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
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 props = defineProps<{ df: any; record: Record<string, any>; canEdit: boolean; ctx: any }>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
// Label布局:上下结构(vertical) 或 左右结构(horizontal)
|
// Label布局:上下结构(vertical) 或 左右结构(horizontal)
|
||||||
const labelLayout = computed(() => props.df.label_layout || 'vertical')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<label class="field-label">
|
||||||
{{ ctx.t(df.label || df.fieldname) }}
|
{{ ctx.t(df.label || df.fieldname) }}
|
||||||
<span v-if="df.reqd" class="required">*</span>
|
<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) {
|
.field-wrapper :deep(.html-content) {
|
||||||
flex: 1;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user