diff --git a/.gitignore b/.gitignore index 86b47e1..42b72c4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ frontend/.env.production # 忽略名为 test 的文件夹 test/ .cursor/ +.qoder/ # 忽略所有 文件夹 diff --git a/apps/jingrow/frontend/src/core/features/form_builder/FormBuilder.vue b/apps/jingrow/frontend/src/core/features/form_builder/FormBuilder.vue new file mode 100644 index 0000000..90b9606 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/FormBuilder.vue @@ -0,0 +1,503 @@ + + + + + + + + + + + + + + + + + + + + Loading form builder... + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/AddFieldButton.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/AddFieldButton.vue new file mode 100644 index 0000000..f1d3b5f --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/AddFieldButton.vue @@ -0,0 +1,160 @@ + + + + + {{ t("Add field") }} + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Autocomplete.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Autocomplete.vue new file mode 100644 index 0000000..d86ac74 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Autocomplete.vue @@ -0,0 +1,182 @@ + + + + + (query = e.target.value)" + :value="query" + :placeholder="props.placeholder" + autocomplete="off" + @click.stop + /> + + + + + + + {{ field.label }} + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Column.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Column.vue new file mode 100644 index 0000000..e432ee3 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Column.vue @@ -0,0 +1,185 @@ + + + + + {{ column.df.label }} + + + + {{ column.df.description }} + + + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Dropdown.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Dropdown.vue new file mode 100644 index 0000000..c7603ea --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Dropdown.vue @@ -0,0 +1,165 @@ + + + + + + + + + + + + + {{ group.group }} + + + + {{ item.label }} + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/EditableInput.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/EditableInput.vue new file mode 100644 index 0000000..8b4d5b8 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/EditableInput.vue @@ -0,0 +1,99 @@ + + + + + $emit('update:modelValue', event.target.value)" + @keydown.enter="editing = false" + @blur="editing = false" + @click.stop + /> + + + {{ emptyLabel }} + + + {{ placeholder }} + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Field.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Field.vue new file mode 100644 index 0000000..8f09a78 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Field.vue @@ -0,0 +1,226 @@ + + + + + + + + + * + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/FieldProperties.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/FieldProperties.vue new file mode 100644 index 0000000..28132cb --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/FieldProperties.vue @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/SearchBox.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/SearchBox.vue new file mode 100644 index 0000000..5c68a52 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/SearchBox.vue @@ -0,0 +1,73 @@ + + + + + $emit('update:modelValue', event.target.value)" + /> + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Section.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Section.vue new file mode 100644 index 0000000..eb3fa0f --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Section.vue @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + {{ section.df.description }} + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Sidebar.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Sidebar.vue new file mode 100644 index 0000000..54e0606 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Sidebar.vue @@ -0,0 +1,137 @@ + + + + + + + + + {{ t("Add tab") }} + + + + {{ t("Select a field to edit its properties.") }} + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/Tabs.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/Tabs.vue new file mode 100644 index 0000000..fd6b6ac --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/Tabs.vue @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + + + + + {{ t("Add tab") }} + + + + + + + + + + + + + + {{ t("Drag & Drop a section here from another tab") }} + {{ t("OR") }} + + {{ t("Add a new section") }} + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/AttachControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/AttachControl.vue new file mode 100644 index 0000000..cfd3785 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/AttachControl.vue @@ -0,0 +1,49 @@ + + + + + + + + + + + + + {{ t("Attach") }} + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ButtonControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ButtonControl.vue new file mode 100644 index 0000000..cdd1c7a --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ButtonControl.vue @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CheckControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CheckControl.vue new file mode 100644 index 0000000..ab78d5f --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CheckControl.vue @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + $emit('update:modelValue', event.target.checked)" + /> + {{ df.label }} + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CodeControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CodeControl.vue new file mode 100644 index 0000000..d2dfa21 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/CodeControl.vue @@ -0,0 +1,95 @@ + + + + + + + + + + {{ df.label }} + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/DataControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/DataControl.vue new file mode 100644 index 0000000..ad7a031 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/DataControl.vue @@ -0,0 +1,133 @@ + + + + + + + + + + + {{ df.label }} + + + + $emit('update:modelValue', event.target.value)" + /> + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/GeolocationControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/GeolocationControl.vue new file mode 100644 index 0000000..744ef9e --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/GeolocationControl.vue @@ -0,0 +1,54 @@ + + + + + + + + + + + {{ t("Geolocation Field") }} + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ImageControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ImageControl.vue new file mode 100644 index 0000000..e35576b --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/ImageControl.vue @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/LinkControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/LinkControl.vue new file mode 100644 index 0000000..52d1f89 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/LinkControl.vue @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + {{ df.label }} + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/RatingControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/RatingControl.vue new file mode 100644 index 0000000..7eb341a --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/RatingControl.vue @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SelectControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SelectControl.vue new file mode 100644 index 0000000..a6e46bf --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SelectControl.vue @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + {{ df.label }} + + + + {{ opt.label }} + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SignatureControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SignatureControl.vue new file mode 100644 index 0000000..a1f6c95 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/SignatureControl.vue @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TableControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TableControl.vue new file mode 100644 index 0000000..8f90086 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TableControl.vue @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + {{ t("No.") }} + + + + + {{ df.options || t("Child Table") }} + + + + + {{ t("No Data") }} + + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextControl.vue new file mode 100644 index 0000000..d22667b --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextControl.vue @@ -0,0 +1,93 @@ + + + + + + + + + + + {{ df.label }} + + + + $emit('update:modelValue', event.target.value)" + /> + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextEditorControl.vue b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextEditorControl.vue new file mode 100644 index 0000000..b4d9606 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/components/controls/TextEditorControl.vue @@ -0,0 +1,90 @@ + + + + + + + + + + + + B + I + U + + + ≡ + 🔗 + + + + {{ editorPlaceholder }} + + + + + + + diff --git a/apps/jingrow/frontend/src/core/features/form_builder/globals.js b/apps/jingrow/frontend/src/core/features/form_builder/globals.js new file mode 100644 index 0000000..df90736 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/globals.js @@ -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, +}; diff --git a/apps/jingrow/frontend/src/core/features/form_builder/index.js b/apps/jingrow/frontend/src/core/features/form_builder/index.js new file mode 100644 index 0000000..33f1113 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/index.js @@ -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, +}; diff --git a/apps/jingrow/frontend/src/core/features/form_builder/store.js b/apps/jingrow/frontend/src/core/features/form_builder/store.js new file mode 100644 index 0000000..b77c383 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/store.js @@ -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 = `${df.label} (${df.fieldname})`; + if (!df.label) { + fieldname = `${df.fieldname}`; + } + let fieldtype = `${df.fieldtype}`; + 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, + }; +}); diff --git a/apps/jingrow/frontend/src/core/features/form_builder/styles/form-builder.css b/apps/jingrow/frontend/src/core/features/form_builder/styles/form-builder.css new file mode 100644 index 0000000..cbe0dda --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/styles/form-builder.css @@ -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; +} diff --git a/apps/jingrow/frontend/src/core/features/form_builder/styles/index.css b/apps/jingrow/frontend/src/core/features/form_builder/styles/index.css new file mode 100644 index 0000000..0e626e3 --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/styles/index.css @@ -0,0 +1,5 @@ +/** + * Form Builder Styles Index + * Import this file to include all form builder styles + */ +@import './form-builder.css'; diff --git a/apps/jingrow/frontend/src/core/features/form_builder/utils.js b/apps/jingrow/frontend/src/core/features/form_builder/utils.js new file mode 100644 index 0000000..c931a3f --- /dev/null +++ b/apps/jingrow/frontend/src/core/features/form_builder/utils.js @@ -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); +} diff --git a/apps/jingrow/frontend/src/core/pagetype/form/controls/HTML.vue b/apps/jingrow/frontend/src/core/pagetype/form/controls/HTML.vue index aff016f..e1b9259 100644 --- a/apps/jingrow/frontend/src/core/pagetype/form/controls/HTML.vue +++ b/apps/jingrow/frontend/src/core/pagetype/form/controls/HTML.vue @@ -1,14 +1,80 @@ - + + + + + + + {{ ctx.t(df.label || df.fieldname) }} * @@ -21,6 +87,23 @@ const labelLayout = computed(() => props.df.label_layout || 'vertical') .field-wrapper :deep(.html-content) { flex: 1; } + +.form-builder-field { + min-height: 600px; + max-height: calc(100vh - 300px); + width: 100%; + margin: 16px 0; + overflow: auto; + border: 1px solid #e0e0e6; + border-radius: 6px; + background: #fafafa; +} + +/* 确保FormBuilder组件内部也支持滚动 */ +.form-builder-field :deep(.form-builder-container) { + height: 100%; + overflow: visible; +}
{{ editorPlaceholder }}