Merge pull request #22968 from shariquerik/form-builder-ui-refactor

This commit is contained in:
Shariq Ansari 2023-11-01 19:14:56 +05:30 committed by GitHub
commit 45236a98d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 873 additions and 336 deletions

View file

@ -56,12 +56,18 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Table']").drag(first_field, {
target: { x: 100, y: 10 },
});
let first_field = first_column + " .field:first";
let last_field = first_column + " .field:last";
let add_new_field_btn = first_field + " .field-actions .add-field-btn";
// add new field
cy.get(add_new_field_btn).click();
// type table and press enter
cy.get(".combo-box-options:first .search-box > input").type("table{enter}");
// save
cy.click_doc_primary_button("Save");
@ -70,7 +76,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "Options is required");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input")
.click()
@ -78,13 +84,10 @@ context("Form Builder", () => {
cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 });
cy.wait("@search_link");
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(first_field)
.find(".table-controls .table-column")
.contains("Field")
.should("exist");
cy.get(first_field)
cy.get(last_field).find(".table-controls .table-column").contains("Field").should("exist");
cy.get(last_field)
.find(".table-controls .table-column")
.contains("Fieldtype")
.should("exist");
@ -98,7 +101,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In List View");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .field label .label-area").contains("In List View").click();
// validate In Global Search
@ -188,7 +191,7 @@ context("Form Builder", () => {
// add new column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:first").click();
cy.get(first_section).find(".column").should("have.length", 3);
cy.get(first_section).find(".column").should("have.length", 2);
});
it("Remove Tab/Section/Column", () => {
@ -197,7 +200,7 @@ context("Form Builder", () => {
// remove column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:last").click();
cy.get(first_section).find(".column").should("have.length", 2);
cy.get(first_section).find(".column").should("have.length", 1);
// remove section
cy.get(first_section).click(15, 10);
@ -205,7 +208,7 @@ context("Form Builder", () => {
cy.get(".tab-content.active .form-section-container").should("have.length", 1);
// remove tab
cy.get(".tab-header").realHover().find(".tab-actions .remove-tab-btn").click();
cy.get(".tab-header .tab:last").realHover().find(".remove-tab-btn").click();
cy.get(".tab-header .tabs .tab").should("have.length", 2);
});
@ -231,14 +234,20 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Data']").drag(first_field, {
target: { x: 100, y: 10 },
});
let first_field = first_column + " .field:first";
let last_field = first_column + " .field:last";
cy.get(first_field).click();
let add_new_field_btn = first_field + " .field-actions .add-field-btn";
// add new field
cy.get(add_new_field_btn).click();
// type data and press enter
cy.get(".combo-box-options:first .search-box > input").type("data{enter}");
cy.get(last_field).click();
// validate duplicate name
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input")
@ -251,7 +260,7 @@ context("Form Builder", () => {
cy.click_doc_primary_button("Save");
cy.get_open_dialog().find(".msgprint").should("contain", "appears multiple times");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(last_field).click();
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input").clear({
force: true,
});

View file

@ -2,6 +2,12 @@
// MIT License. See license.txt
frappe.ui.form.on("DocType", {
onload: function (frm) {
if (frm.is_new()) {
frappe.listview_settings["DocType"].new_doctype_dialog();
}
},
before_save: function (frm) {
let form_builder = frappe.form_builder;
if (form_builder?.store) {
@ -13,6 +19,7 @@ frappe.ui.form.on("DocType", {
}
}
},
after_save: function (frm) {
if (
frappe.form_builder &&
@ -22,6 +29,7 @@ frappe.ui.form.on("DocType", {
frappe.form_builder.store.fetch();
}
},
refresh: function (frm) {
frm.set_query("role", "permissions", function (doc) {
if (doc.custom && frappe.session.user != "Administrator") {
@ -119,6 +127,20 @@ frappe.ui.form.on("DocType", {
setup_default_views: (frm) => {
frappe.model.set_default_views_for_doctype(frm.doc.name, frm);
},
on_tab_change: (frm) => {
let current_tab = frm.get_active_tab().label;
if (current_tab === "Form") {
frm.footer.wrapper.hide();
frm.form_wrapper.find(".form-message").hide();
frm.form_wrapper.addClass("mb-1");
} else {
frm.footer.wrapper.show();
frm.form_wrapper.find(".form-message").show();
frm.form_wrapper.removeClass("mb-1");
}
},
});
frappe.ui.form.on("DocField", {

View file

@ -8,6 +8,9 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"form_builder_tab",
"form_builder",
"settings_tab",
"sb0",
"module",
"is_submittable",
@ -32,32 +35,6 @@
"column_break_15",
"description",
"documentation",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash",
"form_builder_tab",
"form_builder",
"fields_section",
"fields",
"settings_tab",
"form_settings_section",
"image_field",
"timeline_field",
@ -92,6 +69,29 @@
"email_append_to",
"sender_field",
"subject_field",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash",
"fields_section",
"fields",
"connections_tab"
],
"fields": [
@ -640,6 +640,7 @@
"label": "Settings"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "form_builder_tab",
"fieldtype": "Tab Break",
"label": "Form"
@ -742,7 +743,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2023-08-29 12:27:06.587523",
"modified": "2023-11-01 16:45:14.960949",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -0,0 +1,122 @@
frappe.listview_settings["DocType"] = {
primary_action: function () {
this.new_doctype_dialog();
},
new_doctype_dialog() {
let non_developer = frappe.session.user !== "Administrator" || !frappe.boot.developer_mode;
let fields = [
{
label: __("DocType Name"),
fieldname: "name",
fieldtype: "Data",
reqd: 1,
},
{ fieldtype: "Column Break" },
{
label: __("Module"),
fieldname: "module",
fieldtype: "Link",
options: "Module Def",
reqd: 1,
},
{ fieldtype: "Section Break" },
{
label: __("Is Submittable"),
fieldname: "is_submittable",
fieldtype: "Check",
description: __(
"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended."
),
depends_on: "eval:!doc.istable && !doc.issingle",
},
{
label: __("Is Child Table"),
fieldname: "istable",
fieldtype: "Check",
description: __("Child Tables are shown as a Grid in other DocTypes"),
depends_on: "eval:!doc.is_submittable && !doc.issingle",
},
{
label: __("Editable Grid"),
fieldname: "editable_grid",
fieldtype: "Check",
depends_on: "istable",
default: 1,
},
{
label: __("Is Single"),
fieldname: "issingle",
fieldtype: "Check",
description: __(
"Single Types have only one record no tables associated. Values are stored in tabSingles"
),
depends_on: "eval:!doc.istable && !doc.is_submittable",
},
{
label: "Is Tree",
fieldname: "is_tree",
fieldtype: "Check",
default: "0",
depends_on: "eval:!doc.istable",
description: "Tree structures are implemented using Nested Set",
},
{
label: __("Custom?"),
fieldname: "custom",
fieldtype: "Check",
default: non_developer,
read_only: non_developer,
},
];
if (!non_developer) {
fields.push({
label: "Is Virtual",
fieldname: "is_virtual",
fieldtype: "Check",
default: "0",
});
}
let new_d = new frappe.ui.Dialog({
title: __("Create New DocType"),
fields: fields,
primary_action_label: __("Create & Continue"),
primary_action(values) {
if (!values.istable) values.editable_grid = 0;
frappe.db
.insert({
doctype: "DocType",
...values,
permissions: [
{
create: 1,
delete: 1,
email: 1,
export: 1,
print: 1,
read: 1,
report: 1,
role: "System Manager",
share: 1,
write: 1,
},
],
fields: [{ fieldtype: "Section Break" }],
})
.then((doc) => {
frappe.set_route("Form", "DocType", doc.name);
});
},
secondary_action_label: __("Cancel"),
secondary_action() {
new_d.hide();
if (frappe.get_route()[0] === "Form") {
frappe.set_route("List", "DocType");
}
},
});
new_d.show();
},
};

View file

@ -180,6 +180,7 @@
"depends_on": "doc_type",
"fieldname": "fields_section_break",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Fields"
},
{
@ -393,7 +394,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-08-29 12:31:55.808848",
"modified": "2023-10-31 02:04:25.955931",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -30,22 +30,23 @@ onMounted(() => store.fetch());
class="form-builder-container"
@click="store.form.selected_field = null"
>
<div class="form-controls" @click.stop>
<div class="form-sidebar">
<Sidebar />
</div>
</div>
<div class="form-container">
<div class="form-main" :class="[store.preview ? 'preview' : '']">
<Tabs />
</div>
</div>
<div class="form-controls" @click.stop>
<div class="form-sidebar">
<Sidebar />
</div>
</div>
</div>
<div id="autocomplete-area" />
</template>
<style lang="scss" scoped>
.form-builder-container {
margin: -12px -20px -5px;
margin: -15px -20px -5px;
display: flex;
&.resizing {
@ -59,18 +60,19 @@ onMounted(() => store.fetch());
.form-container {
flex: 1;
background-color: var(--disabled-control-bg);
}
.form-sidebar {
border-right: 1px solid var(--border-color);
border-bottom-left-radius: var(--border-radius);
border-left: 1px solid var(--border-color);
border-bottom-right-radius: var(--border-radius);
}
.form-main {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
margin: 10px;
margin: 5px;
}
.form-sidebar,
@ -171,8 +173,6 @@ onMounted(() => store.fetch());
}
:deep(.preview) {
--field-placeholder-color: var(--fg-bg-color);
.tab,
.column,
.field {
@ -270,7 +270,7 @@ onMounted(() => store.fetch());
}
.form-main > :deep(div:first-child:not(.tab-header)) {
max-height: calc(100vh - 160px);
max-height: calc(100vh - 175px);
}
}
</style>

View file

@ -0,0 +1,135 @@
<template>
<button
ref="add_field_btn_ref"
class="add-field-btn btn btn-xs btn-icon"
:title="__('Add field')"
@click.stop="toggle_fieldtype_options"
>
<slot>
{{ __("Add a field") }}
</slot>
<Teleport to="#autocomplete-area">
<div class="autocomplete" ref="autocomplete_ref">
<div v-show="show">
<Autocomplete
v-model:show="show"
:value="autocomplete_value"
:options="fields"
@change="add_new_field"
placeholder="Search fieldtypes..."
/>
</div>
</div>
</Teleport>
</button>
</template>
<script setup>
import Autocomplete from "./Autocomplete.vue";
import { useStore } from "../store";
import { clone_field } from "../utils";
import { createPopper } from "@popperjs/core";
import { computed, nextTick, ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
const store = useStore();
const props = defineProps({
column: {
type: Object,
default: null,
},
field: {
type: Object,
default: null,
},
});
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 autocomplete_value = ref("");
const fields = computed(() => {
let fields = frappe.model.all_fieldtypes
.filter((df) => {
if (in_list(frappe.model.layout_fields, df)) {
return false;
}
return true;
})
.map((df) => {
let out = { label: df };
return out;
});
return [...fields];
});
const add_field_btn_ref = ref(null);
const autocomplete_ref = ref(null);
const popper = ref(null);
onClickOutside(add_field_btn_ref, () => (show.value = false), { ignore: [autocomplete_ref] });
function setupPopper() {
if (!popper.value) {
popper.value = createPopper(add_field_btn_ref.value, autocomplete_ref.value, {
placement: "bottom-start",
modifiers: [
{
name: "offset",
options: {
offset: [0, 4],
},
},
],
});
} else {
popper.value.update();
}
}
function toggle_fieldtype_options() {
show.value = !show.value;
autocomplete_value.value = "";
nextTick(() => setupPopper());
}
function add_new_field(field) {
fieldtype = field?.label;
if (!fieldtype) return;
let new_field = {
df: store.get_df(fieldtype),
table_columns: [],
};
let cloned_field = clone_field(new_field);
// 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, cloned_field);
store.form.selected_field = cloned_field.df;
show.value = false;
}
watch(selected, (val) => {
if (!val) show.value = false;
});
defineExpose({ open: toggle_fieldtype_options });
</script>
<style lang="scss" scoped>
.autocomplete {
z-index: 100;
}
</style>

View file

@ -0,0 +1,159 @@
<template>
<Combobox v-model="selectedValue" nullable>
<ComboboxOptions class="combo-box-options" static>
<div class="search-box">
<ComboboxInput
ref="search"
class="search-input form-control"
type="text"
@change="(e) => (query = e.target.value)"
:value="query"
:placeholder="props.placeholder"
autocomplete="off"
@click.stop
/>
<button class="clear-button btn btn-sm" @click="clear_search">
<div v-html="frappe.utils.icon('close', 'sm')" />
</button>
</div>
<div class="combo-box-items">
<ComboboxOption
as="template"
v-for="(field, i) in filteredOptions"
:key="i"
:value="field"
v-slot="{ active }"
>
<li :class="['combo-box-option', active ? 'active' : '']">
{{ field.label }}
</li>
</ComboboxOption>
</div>
</ComboboxOptions>
</Combobox>
</template>
<script setup>
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from "@headlessui/vue";
import { computed, ref, useAttrs, watch, nextTick } from "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 attrs = useAttrs();
const query = ref(null);
const search = ref(null);
const showOptions = computed({
get() {
return props.show;
},
set(val) {
emit("update:show", val);
},
});
const selectedValue = computed({
get() {
return attrs.value;
},
set(val) {
query.value = "";
if (val) {
showOptions.value = false;
}
emit("change", val);
},
});
const filteredOptions = computed(() => {
return query.value
? props.options.filter((option) => {
return option.label.toLowerCase().includes(query.value.toLowerCase());
})
: props.options;
});
function clear_search() {
selectedValue.value = "";
search.value.el.focus();
}
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus();
});
}
});
</script>
<style lang="scss" scoped>
.combo-box {
z-index: 100;
}
.combo-box-options {
width: 100%;
background-color: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-2xl);
padding: 0;
}
.combo-box-option {
font-size: small;
text-align: left;
border-radius: var(--border-radius-sm);
padding: 6px 10px;
width: 100%;
&:hover,
&.active {
background-color: var(--bg-light-gray);
}
}
.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: 0;
top: 0;
bottom: 0;
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.search-input {
width: 100%;
}
}
</style>

View file

@ -1,15 +1,26 @@
<script setup>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import AddFieldButton from "./AddFieldButton.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { computed, ref } from "vue";
import { useStore } from "../store";
import { move_children_to_parent, confirm_dialog } from "../utils";
import { move_children_to_parent, confirm_dialog, is_touch_screen_device } from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["section", "column"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_column();
}
});
const hovered = ref(false);
const selected = computed(() => store.selected(props.column.df.name));
function add_column() {
// insert new column after the current column
@ -29,7 +40,11 @@ function remove_column() {
} else {
confirm_dialog(
__("Delete Column", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.", null, "Confirmation dialog message"),
__(
"Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.",
null,
"Confirmation dialog message"
),
() => delete_column(),
__("Delete column", null, "Button text"),
() => delete_column(true),
@ -95,21 +110,14 @@ function move_columns_to_section() {
<template>
<div
:class="[
'column',
hovered ? 'hovered' : '',
store.selected(column.df.name) ? 'selected' : ''
]"
: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
:class="[
'column-header',
column.df.label ? 'has-label' : '',
]"
:class="['column-header', column.df.label ? 'has-label' : '']"
:hidden="!column.df.label && store.read_only"
>
<div class="column-label">
@ -120,6 +128,9 @@ function move_columns_to_section() {
/>
</div>
<div class="column-actions">
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
v-if="section.columns.indexOf(column)"
class="btn btn-xs btn-icon"
@ -128,9 +139,6 @@ function move_columns_to_section() {
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-icon"
:title="__('Remove Column')"
@ -145,9 +153,9 @@ function move_columns_to_section() {
</div>
<draggable
class="column-container"
:style="{ backgroundColor: column.fields.length ? '' : 'var(--field-placeholder-color)' }"
v-model="column.fields"
group="fields"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -161,11 +169,19 @@ function move_columns_to_section() {
/>
</template>
</draggable>
<div
class="empty-column"
:hidden="store.read_only"
:style="store.selected(column.df.name) ? { top: '35px' } : { top: 0 }"
>
<AddFieldButton :column="column" />
</div>
</div>
</template>
<style lang="scss" scoped>
.column {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
@ -250,6 +266,35 @@ function move_columns_to_section() {
flex: 1;
min-height: 2rem;
border-radius: var(--border-radius);
z-index: 1;
&:empty {
& + .empty-column {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
position: absolute;
left: 0;
bottom: 0;
gap: 5px;
width: 100%;
padding: 15px;
button {
background-color: var(--bg-color);
z-index: 2;
&:hover {
background-color: var(--btn-default-hover-bg);
}
}
}
}
& + .empty-column {
display: none;
}
}
}
</style>

View file

@ -5,13 +5,13 @@ let store = useStore();
const props = defineProps({
text: {
type: String
type: String,
},
placeholder: {
default: __("No Label")
default: __("No Label"),
},
empty_label: {
default: __("No Label")
default: __("No Label"),
},
});
@ -35,6 +35,8 @@ function focus_on_label() {
nextTick(() => input_text.value.focus());
}
}
defineExpose({ focus_on_label });
</script>
<template>
@ -48,12 +50,12 @@ function focus_on_label() {
:placeholder="placeholder"
:value="text"
:style="{ width: hidden_span_width }"
@input="event => $emit('update:modelValue', event.target.value)"
@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>
<span v-else-if="text" v-html="text"></span>
<i v-else class="text-muted">
{{ empty_label }}
</i>
@ -70,7 +72,6 @@ function focus_on_label() {
&:focus {
outline: none;
border-radius: var(--border-radius);
background-color: inherit;
}

View file

@ -1,14 +1,35 @@
<script setup>
import EditableInput from "./EditableInput.vue";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { move_children_to_parent, clone_field } from "../utils";
import { ref, computed, onMounted } from "vue";
import AddFieldButton from "./AddFieldButton.vue";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["column", "field"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
let component = computed(() => {
const add_field_ref = 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) {
add_field_ref.value.open();
}
});
// delete/backspace to delete the field
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_field();
}
});
const label_input = ref(null);
const hovered = ref(false);
const selected = computed(() => store.selected(props.field.df.name));
const component = computed(() => {
return props.field.df.fieldtype.replace(" ", "") + "Control";
});
@ -23,8 +44,8 @@ function remove_field() {
}
function move_fields_to_column() {
let current_section = store.current_tab.sections.find(section =>
section.columns.find(column => column == props.column)
let current_section = store.current_tab.sections.find((section) =>
section.columns.find((column) => column == props.column)
);
move_children_to_parent(props, "column", "field", current_section);
}
@ -53,15 +74,13 @@ function duplicate_field() {
props.column.fields.splice(index + 1, 0, duplicate_field);
store.form.selected_field = duplicate_field.df;
}
onMounted(() => selected.value && label_input.value.focus_on_label());
</script>
<template>
<div
:class="[
'field',
hovered ? 'hovered' : '',
store.selected(field.df.name) ? 'selected' : ''
]"
:class="['field', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="field.df.fieldname"
@click.stop="store.form.selected_field = field.df"
@mouseover.stop="hovered = true"
@ -76,24 +95,25 @@ function duplicate_field() {
<template #label>
<div class="field-label">
<EditableInput
ref="label_input"
:text="field.df.label"
:placeholder="__('Label')"
:empty_label="`${__('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" v-html="frappe.utils.icon('help', 'sm')"></div>
<div
class="help-icon"
v-if="field.df.documentation_url"
v-html="frappe.utils.icon('help', 'sm')"
></div>
</div>
</template>
<template #actions>
<div class="field-actions" :hidden="store.read_only">
<button
v-if="field.df.fieldtype == 'HTML'"
class="btn btn-xs btn-icon"
@click="edit_html"
>
<div v-html="frappe.utils.icon('edit', 'sm')"></div>
</button>
<AddFieldButton ref="add_field_ref" :column="column" :field="field">
<div v-html="frappe.utils.icon('add', 'sm')" />
</AddFieldButton>
<button
v-if="column.fields.indexOf(field)"
class="btn btn-xs btn-icon"
@ -104,10 +124,18 @@ function duplicate_field() {
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click.stop="duplicate_field">
<button
class="btn btn-xs btn-icon"
:title="__('Duplicate field')"
@click.stop="duplicate_field"
>
<div v-html="frappe.utils.icon('duplicate', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click.stop="remove_field">
<button
class="btn btn-xs btn-icon"
:title="__('Remove field')"
@click.stop="remove_field"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>

View file

@ -68,7 +68,16 @@ let docfield_df = computed(() => {
</script>
<template>
<SearchBox v-model="search_text" />
<div class="header">
<SearchBox class="flex-1" v-model="search_text" />
<button
class="close-btn btn btn-xs"
:title="__('Close properties')"
@click="store.form.selected_field = null"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
<div class="control-data">
<div v-if="store.form.selected_field">
<div class="field" v-for="(df, i) in docfield_df" :key="i">
@ -88,8 +97,17 @@ let docfield_df = computed(() => {
</template>
<style lang="scss" scoped>
.header {
display: flex;
padding: 5px;
border-bottom: 1px solid var(--border-color);
.close-btn {
margin-right: -5px;
}
}
.control-data {
height: calc(100vh - 150px);
height: calc(100vh - 202px);
overflow-y: auto;
padding: 8px;

View file

@ -1,93 +0,0 @@
<script setup>
import SearchBox from "./SearchBox.vue";
import draggable from "vuedraggable";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { clone_field } from "../utils";
let store = useStore();
let search_text = ref("");
let fields = computed(() => {
let fields = frappe.model.all_fieldtypes
.filter(df => {
if (in_list(frappe.model.layout_fields, df)) {
return false;
}
if (search_text.value) {
if (df.toLowerCase().includes(search_text.value.toLowerCase())) {
return true;
}
return false;
} else {
return true;
}
})
.map(df => {
let out = {
df: store.get_df(df),
table_columns: [],
};
return out;
});
return [...fields];
});
function on_drag_start(evt) {
$(evt.item).html('<div class="drop-it-here"></div>');
}
function on_drag_end(evt) {
let old_html = evt.clone.innerHTML;
$(evt.item).html(old_html);
}
</script>
<template>
<SearchBox v-model="search_text" />
<draggable
class="fields-container"
:list="fields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="clone_field"
item-key="id"
:remove-clone-on-hide="false"
@start="on_drag_start"
@end="on_drag_end"
>
<template #item="{ element }">
<div class="field" :title="element.df.fieldtype">
{{ element.df.fieldtype }}
</div>
</template>
</draggable>
</template>
<style lang="scss" scoped>
.fields-container {
height: calc(100vh - 133px);
overflow-y: auto;
display: grid;
gap: 8px;
padding: 8px;
grid-template-columns: 1fr 1fr;
grid-auto-rows: max-content;
.field {
display: block !important;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 0.5px solid var(--dark-border-color);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
cursor: pointer;
&.sortable-ghost {
position: absolute;
opacity: 0;
}
}
}
</style>

View file

@ -5,7 +5,7 @@
<input
class="search-input form-control"
type="text"
:placeholder="__('Search fields')"
:placeholder="__('Search properties...')"
@input="event => $emit('update:modelValue', event.target.value)"
/>
<span class="search-icon">
@ -18,9 +18,8 @@
.search-box {
display: flex;
position: relative;
padding: 0px 9px 9px;
background-color: var(--fg-color);
border-bottom: 1px solid var(--border-color);
width: 100%;
.search-input {
padding-left: 30px;
@ -28,7 +27,7 @@
.search-icon {
position: absolute;
left: 16px;
left: 7px;
top: 2px;
}
}

View file

@ -2,15 +2,25 @@
import draggable from "vuedraggable";
import Column from "./Column.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils";
import { section_boilerplate, move_children_to_parent, confirm_dialog, is_touch_screen_device } from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["tab", "section"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
let collapsed = ref(false);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_section();
}
});
const hovered = ref(false);
const collapsed = ref(false);
const selected = computed(() => store.selected(props.section.df.name));
function add_section_above() {
let index = props.tab.sections.indexOf(props.section);
@ -19,7 +29,7 @@ function add_section_above() {
function is_section_empty() {
return !props.section.columns.some(
column => (store.is_customize_form && !column.df.is_custom_field) || column.fields.length
(column) => (store.is_customize_form && !column.df.is_custom_field) || column.fields.length
);
}
@ -34,11 +44,15 @@ function remove_section() {
} else {
confirm_dialog(
__("Delete Section", null, "Title of confirmation dialog"),
__("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.", null, "Confirmation dialog message"),
__(
"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.",
null,
"Confirmation dialog message"
),
() => delete_section(),
__("Delete section", null, "Button text"),
() => delete_section(true),
__("Delete entire section with columns", null, "Button text")
__("Delete entire section with fields", null, "Button text")
);
}
}
@ -94,7 +108,7 @@ function move_sections_to_tab() {
:class="[
'form-section',
hovered ? 'hovered' : '',
store.selected(section.df.name) ? 'selected' : ''
store.selected(section.df.name) ? 'selected' : '',
]"
:title="section.df.fieldname"
@click.stop="select_section"
@ -105,7 +119,7 @@ function move_sections_to_tab() {
:class="[
'section-header',
section.df.label || section.df.collapsible ? 'has-label' : '',
collapsed ? 'collapsed' : ''
collapsed ? 'collapsed' : '',
]"
:hidden="!section.df.label && store.read_only"
>
@ -118,18 +132,10 @@ function move_sections_to_tab() {
<div
v-if="section.df.collapsible"
class="collapse-indicator"
v-html="frappe.utils.icon( collapsed ? 'down' : 'up-line', 'sm' )"
v-html="frappe.utils.icon(collapsed ? 'down' : 'up-line', 'sm')"
></div>
</div>
<div class="section-actions" :hidden="store.read_only">
<button
v-if="tab.sections.indexOf(section)"
class="btn btn-xs btn-section"
:title="__('Move the current section and the following sections to a new tab')"
@click="move_sections_to_tab"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-section"
:title="__('Add section above')"
@ -137,6 +143,16 @@ function move_sections_to_tab() {
>
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
v-if="tab.sections.indexOf(section)"
class="btn btn-xs btn-section"
:title="
__('Move the current section and the following sections to a new tab')
"
@click="move_sections_to_tab"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-section"
:title="__('Remove section')"
@ -146,22 +162,22 @@ function move_sections_to_tab() {
</button>
</div>
</div>
<div v-if="section.df.description" class="section-description">{{ section.df.description }}</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
'has-one-column': section.columns.length === 1,
}"
>
<draggable
class="section-columns-container"
:style="{
backgroundColor: section.columns.length ? null : 'var(--field-placeholder-color)'
}"
v-model="section.columns"
group="columns"
item-key="id"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
:disabled="store.read_only"

View file

@ -1,13 +1,10 @@
<script setup>
import FieldTypes from "./FieldTypes.vue";
import FieldProperties from "./FieldProperties.vue";
import { ref, watch } from "vue";
import { useStore } from "../store";
import { ref } from "vue";
let store = useStore();
let tab_titles = [__("Field Types"), __("Field Properties")];
let active_tab = ref(tab_titles[0]);
let sidebar_width = ref(272);
let sidebar_resizing = ref(false);
@ -23,7 +20,8 @@ function start_resize() {
function resize(e) {
sidebar_resizing.value = true;
$(".form-builder-container").addClass("resizing");
sidebar_width.value = e.clientX - 90;
let screen_width = e.view.innerWidth;
sidebar_width.value = screen_width - e.clientX - 90;
if (sidebar_width.value < 16 * 16) {
sidebar_width.value = 16 * 16;
@ -32,14 +30,6 @@ function resize(e) {
sidebar_width.value = 24 * 16;
}
}
watch(
() => store.form.selected_field,
value => {
active_tab.value = value ? tab_titles[1] : tab_titles[0];
},
{ deep: true }
);
</script>
<template>
@ -48,21 +38,20 @@ watch(
@mousedown="start_resize"
/>
<div class="sidebar-container" :style="{ width: `${sidebar_width}px` }">
<div class="tab-header">
<div
:class="['tab', active_tab == tab ? 'active' : '']"
v-for="(tab, i) in tab_titles"
:key="i"
@click="active_tab = tab"
>
{{ tab }}
<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.read_only">
<button
class="new-tab-btn btn btn-default btn-xs"
:title="__('Add new tab')"
@click="store.add_new_tab"
>
{{ __("Add tab") }}
</button>
</div>
<div class="empty-state">
<div>Select a field to edit its properties.</div>
</div>
</div>
<div :class="['tab-content', active_tab == tab_titles[0] ? 'active' : '']">
<FieldTypes />
</div>
<div :class="['tab-content', active_tab == tab_titles[1] ? 'active' : '']">
<FieldProperties />
</div>
</div>
</template>
@ -71,7 +60,7 @@ watch(
.sidebar-resizer {
position: absolute;
top: 0;
right: -6px;
left: -5px;
width: 5px;
height: 100%;
opacity: 0;
@ -80,10 +69,12 @@ watch(
z-index: 4;
cursor: col-resize;
&:hover, &.resizing {
&:hover,
&.resizing {
opacity: 1;
}
}
.tab-header {
display: flex;
justify-content: space-between;
@ -127,4 +118,25 @@ watch(
display: block;
}
}
.default-state {
display: flex;
flex-direction: column;
height: calc(100vh - 163px);
.actions {
padding: 5px;
display: flex;
justify-content: flex-end;
border-bottom: 1px solid var(--border-color);
}
.empty-state {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: var(--disabled-text-color);
}
}
</style>

View file

@ -1,29 +1,29 @@
<script setup>
import Section from "./Section.vue";
import EditableInput from "./EditableInput.vue";
import draggable from "vuedraggable";
import { useStore } from "../store";
import { section_boilerplate, confirm_dialog } from "../utils";
import { ref, computed, nextTick } from "vue";
import { section_boilerplate, confirm_dialog, is_touch_screen_device } from "../utils";
import draggable from "vuedraggable";
import { ref, computed } from "vue";
import { useMagicKeys, whenever } from "@vueuse/core";
let store = useStore();
const store = useStore();
let dragged = ref(false);
let has_tabs = computed(() => store.form.layout.tabs.length > 1);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_tab(store.current_tab, '', true);
}
});
const dragged = ref(false);
const selected = computed(() => store.selected(store.current_tab.df.name));
const has_tabs = computed(() => store.form.layout.tabs.length > 1);
store.form.active_tab = store.form.layout.tabs[0].df.name;
function activate_tab(tab) {
store.form.active_tab = tab.df.name;
store.form.selected_field = tab.df;
// scroll to active tab
nextTick(() => {
$(".tabs .tab.active")[0].scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
});
store.activate_tab(tab);
}
function drag_over(tab) {
@ -34,13 +34,7 @@ function drag_over(tab) {
}
function add_new_tab() {
let tab = {
df: store.get_df("Tab Break", "", "Tab " + (store.form.layout.tabs.length + 1)),
sections: [section_boilerplate()],
};
store.form.layout.tabs.push(tab);
activate_tab(tab);
store.add_new_tab();
}
function add_new_section() {
@ -49,49 +43,56 @@ function add_new_section() {
store.form.selected_field = section.df;
}
function is_current_tab_empty() {
function is_tab_empty(tab) {
// check if sections have columns and it contains fields
return !store.current_tab.sections.some(
section => section.columns.some(column => column.fields.length)
return !tab.sections.some((section) =>
section.columns.some((column) => column.fields.length)
);
}
function remove_tab() {
function remove_tab(tab, event, force=false) {
// is remove_tab_btn is not visible then return
if (!event?.currentTarget?.offsetParent && !force) return;
if (store.is_customize_form && store.current_tab.df.is_custom_field == 0) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.has_standard_field(store.current_tab)) {
delete_tab();
} else if (is_current_tab_empty()) {
delete_tab(true);
delete_tab(tab);
} else if (is_tab_empty(tab)) {
delete_tab(tab, true);
} else {
confirm_dialog(
__("Delete Tab", null, "Title of confirmation dialog"),
__("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.", null, "Confirmation dialog message"),
() => delete_tab(),
__(
"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.",
null,
"Confirmation dialog message"
),
() => delete_tab(tab),
__("Delete tab", null, "Button text"),
() => delete_tab(true),
__("Delete entire tab with sections", null, "Button text")
() => delete_tab(tab, true),
__("Delete entire tab with fields", null, "Button text")
);
}
}
function delete_tab(with_children) {
function delete_tab(tab, with_children) {
let tabs = store.form.layout.tabs;
let index = tabs.indexOf(store.current_tab);
let index = tabs.indexOf(tab);
if (!with_children) {
if (index > 0) {
let prev_tab = tabs[index - 1];
if (!is_current_tab_empty()) {
if (!is_tab_empty(tab)) {
// move all sections from current tab to previous tab
prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections];
prev_tab.sections = [...prev_tab.sections, ...tab.sections];
}
} else {
// create a new tab and push sections to it
tabs.unshift({
df: store.get_df("Tab Break", "", __("Details")),
sections: store.current_tab.sections,
sections: tab.sections,
is_first: true,
});
index++;
@ -109,12 +110,13 @@ function delete_tab(with_children) {
</script>
<template>
<div class="tab-header" v-if="!(store.form.layout.tabs.length == 1 && store.read_only)">
<div class="tab-header" v-if="store.form.layout.tabs.length > 1">
<draggable
v-show="has_tabs"
class="tabs"
v-model="store.form.layout.tabs"
group="tabs"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -135,6 +137,14 @@ function delete_tab(with_children) {
:placeholder="__('Tab Label')"
v-model="element.df.label"
/>
<button
class="remove-tab-btn btn btn-xs"
:title="__('Remove tab')"
@click.stop="remove_tab(element, $event)"
:hidden="store.read_only"
>
<div v-html="frappe.utils.icon('remove', 'xs')"></div>
</button>
</div>
</template>
</draggable>
@ -145,19 +155,10 @@ function delete_tab(with_children) {
:title="__('Add new tab')"
@click="add_new_tab"
>
<div v-if="has_tabs" v-html="frappe.utils.icon('add', 'sm')"></div>
<div class="add-btn-text" v-else>
{{ __("Add new tab") }}
<div class="add-btn-text">
{{ __("Add tab") }}
</div>
</button>
<button
v-if="has_tabs"
class="remove-tab-btn btn btn-xs"
:title="__('Remove selected tab')"
@click="remove_tab"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
</div>
@ -172,6 +173,7 @@ function delete_tab(with_children) {
class="tab-content-container"
v-model="tab.sections"
group="sections"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -186,8 +188,8 @@ function delete_tab(with_children) {
</template>
</draggable>
<div class="empty-tab" :hidden="store.read_only">
<div>{{ __("Drag & Drop a section here from another tab") }}</div>
<div>{{ __("OR") }}</div>
<div v-if="has_tabs">{{ __("Drag & Drop a section here from another tab") }}</div>
<div v-if="has_tabs">{{ __("OR") }}</div>
<button class="btn btn-default btn-sm" @click="add_new_section">
{{ __("Add a new section") }}
</button>
@ -200,7 +202,7 @@ function delete_tab(with_children) {
.tab-header {
display: flex;
justify-content: space-between;
min-height: 53px;
min-height: 42px;
align-items: center;
background-color: var(--fg-color);
border-bottom: 1px solid var(--border-color);
@ -208,12 +210,6 @@ function delete_tab(with_children) {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
&:hover {
.tab-actions .btn {
opacity: 1 !important;
}
}
.tabs {
display: flex;
flex: 1;
@ -225,7 +221,7 @@ function delete_tab(with_children) {
margin-right: 20px;
.btn {
opacity: 0;
background-color: var(--control-bg);
padding: 2px;
margin-left: 4px;
box-shadow: none;
@ -235,7 +231,7 @@ function delete_tab(with_children) {
}
&:hover {
background-color: var(--border-color);
background-color: var(--btn-default-hover-bg);
}
}
@ -246,8 +242,10 @@ function delete_tab(with_children) {
}
.tab {
display: flex;
align-items: center;
position: relative;
padding: var(--padding-md);
padding: 10px 18px 10px 15px;
color: var(--text-muted);
min-width: max-content;
cursor: pointer;
@ -275,11 +273,22 @@ function delete_tab(with_children) {
border-color: var(--primary);
}
}
&:hover .remove-tab-btn {
display: block;
}
.remove-tab-btn {
position: absolute;
right: -2px;
display: none;
padding: 2px;
}
}
}
.tab-contents {
max-height: calc(100vh - 110px);
max-height: calc(100vh - 217px);
overflow-y: auto;
overflow-x: hidden;
border-radius: var(--border-radius);
@ -290,13 +299,14 @@ function delete_tab(with_children) {
position: relative;
&.active {
display: block;
display: flex;
}
.tab-content-container {
flex: 1;
min-height: 4rem;
background-color: var(--field-placeholder-color);
border-radius: var(--border-radius);
z-index: 1;
&:empty {
height: 7rem;
@ -306,14 +316,16 @@ function delete_tab(with_children) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
bottom: 0;
gap: 5px;
width: 100%;
padding: 15px;
&button:hover {
background-color: var(--border-color);
button {
z-index: 2;
}
}
}

View file

@ -1,7 +1,12 @@
import { defineStore } from "pinia";
import { create_layout, scrub_field_names, load_doctype_model } from "./utils";
import {
create_layout,
scrub_field_names,
load_doctype_model,
section_boilerplate,
} from "./utils";
import { computed, nextTick, ref } from "vue";
import { useDebouncedRefHistory, onKeyDown } from "@vueuse/core";
import { useDebouncedRefHistory, onKeyDown, useActiveElement } from "@vueuse/core";
export const useStore = defineStore("form-builder-store", () => {
let doctype = ref("");
@ -31,6 +36,15 @@ export const useStore = defineStore("form-builder-store", () => {
return form.value.layout.tabs.find((tab) => tab.df.name == form.value.active_tab);
});
const active_element = useActiveElement();
const not_using_input = computed(
() =>
active_element.value?.readOnly ||
active_element.value?.disabled ||
(active_element.value?.tagName !== "INPUT" &&
active_element.value?.tagName !== "TEXTAREA")
);
// Actions
function selected(name) {
return form.value.selected_field?.name == name;
@ -297,6 +311,31 @@ export const useStore = defineStore("form-builder-store", () => {
return create_layout(doc.value.fields);
}
// Tab actions
function add_new_tab() {
let tab = {
df: get_df("Tab Break", "", "Tab " + (form.value.layout.tabs.length + 1)),
sections: [section_boilerplate()],
};
form.value.layout.tabs.push(tab);
activate_tab(tab);
}
function activate_tab(tab) {
form.value.active_tab = tab.df.name;
form.value.selected_field = tab.df;
// scroll to active tab
nextTick(() => {
$(".tabs .tab.active")[0]?.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
});
}
return {
doctype,
frm,
@ -310,6 +349,7 @@ export const useStore = defineStore("form-builder-store", () => {
get_animation,
get_docfields,
current_tab,
not_using_input,
selected,
get_df,
has_standard_field,
@ -320,5 +360,7 @@ export const useStore = defineStore("form-builder-store", () => {
get_updated_fields,
is_df_updated,
get_layout,
add_new_tab,
activate_tab,
};
});

View file

@ -242,10 +242,6 @@ export function section_boilerplate() {
df: store.get_df("Column Break"),
fields: [],
},
{
df: store.get_df("Column Break"),
fields: [],
},
],
};
}
@ -351,3 +347,7 @@ export function confirm_dialog(
d.show();
d.set_message(message);
}
export function is_touch_screen_device() {
return "ontouchstart" in document.documentElement;
}

View file

@ -2039,6 +2039,8 @@ frappe.ui.form.Form = class FrappeForm {
this.active_tab_map = {};
}
this.active_tab_map[this.docname] = tab;
this.script_manager.trigger("on_tab_change");
}
get_active_tab() {
return this.active_tab_map && this.active_tab_map[this.docname];

View file

@ -20,9 +20,10 @@
},
"homepage": "https://frappeframework.com",
"dependencies": {
"@popperjs/core": "^2.11.2",
"@editorjs/editorjs": "^2.26.3",
"@frappe/esbuild-plugin-postcss2": "^0.1.3",
"@headlessui/vue": "^1.7.16",
"@popperjs/core": "^2.11.2",
"@redis/client": "^1.5.8",
"@vue-flow/background": "^1.1.0",
"@vue-flow/core": "^1.16.2",

View file

@ -55,6 +55,11 @@
stylus "^0.x"
tmp "^0.2.1"
"@headlessui/vue@^1.7.16":
version "1.7.16"
resolved "https://registry.yarnpkg.com/@headlessui/vue/-/vue-1.7.16.tgz#bdc9d32d329248910325539b99e6abfce0c69f89"
integrity sha512-nKT+nf/q6x198SsyK54mSszaQl/z+QxtASmgMEJtpxSX2Q0OPJX0upS/9daDyiECpeAsvjkoOrm2O/6PyBQ+Qg==
"@jridgewell/sourcemap-codec@^1.4.13":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"