fix: More features

- Show selection dialog if Print Format not selected
- Field component
- Common store
- Edit Field label inline
- Configure table columns
- Edit Custom HTML
- Preview
This commit is contained in:
Faris Ansari 2021-08-15 16:10:04 +05:30
parent f58254db78
commit f536a1ff91
10 changed files with 732 additions and 159 deletions

View file

@ -18,6 +18,26 @@ frappe.pages["print-format-builder-beta"].on_page_load = function(wrapper) {
print_format: route[1]
});
});
} else {
let d = new frappe.ui.Dialog({
title: __("Select Print Format to edit"),
fields: [
{
label: __("Print Format"),
fieldname: "print_format",
fieldtype: "Link",
options: "Print Format",
filters: {
print_format_builder_beta: 1
}
}
],
primary_action({ print_format }) {
if (!print_format) return;
frappe.set_route("print-format-builder-beta", print_format);
}
});
d.show();
}
}

View file

@ -0,0 +1,86 @@
<template>
<div>
<p class="text-muted">
{{ help_message }}
</p>
<div class="row font-weight-bold">
<div class="col-8">
{{ __("Column") }}
</div>
<div class="col-4">
{{ __("Width") }}
</div>
</div>
<draggable
:list="df.table_columns"
:animation="200"
:group="df.fieldname"
handle=".icon-drag"
>
<div
class="row mt-2 align-center column-row"
v-for="column in df.table_columns"
>
<div class="col-8">
<div class="column-label d-flex align-center">
<div class="icon-drag px-2 ml-n2">
<svg class="icon icon-xs">
<use xlink:href="#icon-drag"></use>
</svg>
</div>
<div class="ml-1" :class="{ 'text-danger': column.invalid_width }">
{{ column.label }}
</div>
</div>
</div>
<div class="col-4 d-flex align-items-center">
<input
type="number"
class="form-control text-right"
v-model.number="column.width"
min="1"
max="12"
/>
<button
class="btn btn-xs btn-icon ml-2"
@click="remove_column(column)"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-close"></use>
</svg>
</button>
</div>
</div>
</draggable>
</div>
</template>
<script>
import draggable from "vuedraggable";
export default {
name: "ConfigureColumns",
props: ["df"],
components: {
draggable
},
methods: {
remove_column(column) {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(_column => _column !== column)
);
}
},
computed: {
help_message() {
// prettier-ignore
return __("Drag columns to set order. The total width should be 12, columns marked in red will be removed.");
}
}
};
</script>
<style scoped>
.icon-drag {
cursor: grab;
}
</style>

View file

@ -0,0 +1,345 @@
<template>
<div class="field" :title="df.fieldname" @click="editing = true">
<div class="field-controls">
<div>
<div
class="custom-html"
v-if="df.fieldtype == 'HTML' && df.html"
v-html="df.html"
></div>
<input
v-else-if="editing && df.fieldtype != 'HTML'"
ref="label-input"
class="label-input"
type="text"
:placeholder="__('Label')"
v-model="df.label"
@keydown.enter="editing = false"
@blur="editing = false"
/>
<span v-else-if="df.label">{{ df.label }}</span>
<i class="text-muted" v-else>
{{ __("No Label") }} ({{ df.fieldname }})
</i>
</div>
<div class="field-actions">
<button
v-if="df.fieldtype == 'HTML'"
class="btn btn-xs btn-icon"
@click="edit_html"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-edit"></use>
</svg>
</button>
<button
v-if="df.fieldtype == 'Table'"
class="btn btn-xs"
@click="configure_columns"
>
Configure columns
</button>
<button class="btn btn-xs btn-icon" @click="$set(df, 'remove', true)">
<svg class="icon icon-sm">
<use xlink:href="#icon-close"></use>
</svg>
</button>
</div>
</div>
<div
v-if="df.fieldtype == 'Table'"
class="table-controls row no-gutters"
:style="{ opacity: 1 }"
>
<div
:class="`col-${tf.width} table-column`"
v-for="(tf, i) in df.table_columns"
:key="tf.fieldname"
>
<div class="table-field">
{{ tf.label }}
</div>
</div>
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import ConfigureColumnsVue from "./ConfigureColumns.vue";
import { storeMixin } from "./store";
export default {
name: "Field",
mixins: [storeMixin],
props: ["df"],
components: {
draggable
},
data() {
return {
editing: false
};
},
watch: {
editing(value) {
if (value) {
this.$nextTick(() => this.$refs["label-input"].focus());
}
},
"df.table_columns": {
deep: true,
handler() {
this.validate_table_columns();
}
}
},
methods: {
edit_html() {
let d = new frappe.ui.Dialog({
title: __("Edit HTML"),
fields: [
{
label: __("HTML"),
fieldname: "html",
fieldtype: "Code",
options: "HTML"
}
],
primary_action: ({ html }) => {
html = frappe.dom.remove_script_and_style(html);
this.$set(this.df, "html", html);
d.hide();
}
});
d.set_value("html", this.df.html);
d.show();
},
configure_columns() {
let dialog = new frappe.ui.Dialog({
title: __("Configure columns for {0}", [this.df.label]),
fields: [
{
fieldtype: "HTML",
fieldname: "columns_area"
},
{
label: "",
fieldtype: "Autocomplete",
placeholder: __("Add Column"),
fieldname: "add_column",
options: this.get_all_columns(),
onchange: () => {
let fieldname = dialog.get_value("add_column");
if (fieldname) {
let column = this.get_column_to_add(fieldname);
if (column) {
this.df.table_columns.push(column);
this.$set(this.df, "table_columns", this.df.table_columns);
dialog.set_value("add_column", "");
}
}
}
}
],
on_page_show: () => {
new Vue({
el: dialog.get_field("columns_area").$wrapper.get(0),
render: h =>
h(ConfigureColumnsVue, {
props: {
df: this.df
}
})
});
},
on_hide: () => {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(col => !col.invalid_width)
);
}
});
dialog.show();
},
get_all_columns() {
let meta = frappe.get_meta(this.df.options);
let more_columns = [
{
label: __("Sr No."),
value: "idx"
}
];
return more_columns.concat(
meta.fields
.map(tf => {
if (frappe.model.no_value_type.includes(tf.fieldtype)) {
return;
}
return {
label: tf.label,
value: tf.fieldname
};
})
.filter(Boolean)
);
},
get_column_to_add(fieldname) {
let standard_columns = {
idx: {
label: __("Sr No."),
fieldname: "Data",
fieldname: "idx",
width: 1
}
};
if (fieldname in standard_columns) {
return standard_columns[fieldname];
}
return {
...frappe.meta.get_docfield(this.df.options, fieldname),
width: 1
};
},
validate_table_columns() {
if (this.df.fieldtype != "Table") return;
let columns = this.df.table_columns;
let total_columns = 0;
for (let column of columns) {
if (!column.width) {
column.width = 1;
}
total_columns += column.width;
if (total_columns > 12) {
column.invalid_width = true;
} else {
column.invalid_width = false;
}
}
}
}
};
</script>
<style>
.field {
text-align: left;
width: 100%;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
}
.field-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
.field:not(:first-child) {
margin-top: 0.5rem;
}
.custom-html {
padding-right: var(--padding-xs);
word-break: break-all;
}
.label-input {
background-color: transparent;
border: none;
padding: 0;
}
.label-input:focus {
outline: none;
}
.field:focus-within {
border-style: solid;
border-color: var(--gray-600);
}
.field-actions {
flex: none;
}
.field-actions .btn {
opacity: 0;
}
.field-actions .btn-icon {
box-shadow: none;
}
.btn-icon {
padding: 2px;
}
.btn-icon:hover {
background-color: white;
}
.field:hover .btn {
opacity: 1;
}
.table-controls {
display: flex;
margin-top: 1rem;
}
.table-column {
position: relative;
}
.table-field {
text-align: left;
width: 100%;
background-color: white;
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
user-select: none;
white-space: nowrap;
}
.column-resize {
position: absolute;
right: 0;
top: 0;
width: 6px;
border-radius: 2px;
height: 80%;
background-color: var(--gray-600);
transform: translate(50%, 10%);
z-index: 999;
cursor: col-resize;
}
.column-resize-actions {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
padding-right: 0.25rem;
}
.column-resize-actions .btn-icon {
background: white;
}
.column-resize-actions .btn-icon:hover {
background: var(--bg-light-gray);
}
.columns-input {
padding: var(--padding-sm);
}
</style>

View file

@ -19,13 +19,14 @@
<script>
import draggable from "vuedraggable";
import PrintFormatSection from "./PrintFormatSection.vue";
import { storeMixin } from "./store";
export default {
name: "PrintFormat",
props: ["print_format", "meta", "layout"],
mixins: [storeMixin],
components: {
draggable,
PrintFormatSection,
PrintFormatSection
},
computed: {
rootStyles() {
@ -33,14 +34,14 @@ export default {
margin_top = 0,
margin_bottom = 0,
margin_left = 0,
margin_right = 0,
margin_right = 0
} = this.print_format;
return {
padding: `${margin_top}mm ${margin_right}mm ${margin_bottom}mm ${margin_left}mm`,
width: "210mm",
minHeight: "297mm",
minHeight: "297mm"
};
},
}
},
methods: {
add_section_above(section) {
@ -51,15 +52,15 @@ export default {
label: "",
columns: [
{ label: "", fields: [] },
{ label: "", fields: [] },
],
{ label: "", fields: [] }
]
});
}
sections.push(_section);
}
this.$set(this.layout, "sections", sections);
},
},
}
}
};
</script>
@ -70,4 +71,4 @@ export default {
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius);
}
</style>
</style>

View file

@ -1,14 +1,10 @@
<template>
<div class="layout-main-section row" v-if="print_format && meta && layout">
<div class="layout-main-section row" v-if="shouldRender">
<div class="col-3">
<PrintFormatControls
:print_format="print_format"
:meta="meta"
@update="update($event)"
/>
<PrintFormatControls />
</div>
<div class="print-format-container col-9">
<PrintFormat :print_format="print_format" :meta="meta" :layout="layout" />
<PrintFormat />
</div>
</div>
</template>
@ -16,87 +12,33 @@
<script>
import PrintFormat from "./PrintFormat.vue";
import PrintFormatControls from "./PrintFormatControls.vue";
import { create_default_layout } from "./utils";
import { getStore } from "./store";
export default {
name: "PrintFormatBuilder",
props: ["print_format_name"],
components: {
PrintFormat,
PrintFormatControls,
PrintFormatControls
},
data() {
provide() {
return {
print_format: null,
doctype: null,
meta: null,
layout: null,
$store: this.$store
};
},
mounted() {
this.fetch();
this.$store.fetch();
},
methods: {
fetch() {
frappe.dom.freeze(__("Loading..."));
frappe.model.clear_doc("Print Format", this.print_format_name);
frappe.model.with_doc("Print Format", this.print_format_name, () => {
this.print_format = frappe.get_doc(
"Print Format",
this.print_format_name
);
frappe.model.with_doctype(this.print_format.doc_type, () => {
this.meta = frappe.get_meta(this.print_format.doc_type);
this.layout = this.get_layout();
frappe.dom.unfreeze();
});
});
computed: {
$store() {
return getStore(this.print_format_name);
},
update({ fieldname, value }) {
this.$set(this.print_format, fieldname, value);
},
save_changes() {
frappe.dom.freeze();
this.layout.sections = this.layout.sections
.map((section) => {
section.columns = section.columns.map((column) => {
column.fields = column.fields.filter((df) => !df.remove);
return column;
});
return section.remove ? null : section;
})
.filter(Boolean);
this.print_format.format_data = JSON.stringify(this.layout);
frappe
.call("frappe.client.save", {
doc: this.print_format,
})
.then(() => {
this.fetch();
})
.always(() => {
frappe.dom.unfreeze();
});
},
reset_changes() {
this.fetch();
},
get_layout() {
if (this.print_format) {
if (!this.print_format.format_data) {
return create_default_layout(this.meta);
}
if (typeof this.print_format.format_data == "string") {
return JSON.parse(this.print_format.format_data);
}
return this.print_format.format_data;
}
return null;
},
},
shouldRender() {
return Boolean(
this.$store.print_format && this.$store.meta && this.$store.layout
);
}
}
};
</script>
@ -107,4 +49,4 @@ export default {
padding-top: 0.5rem;
padding-bottom: 4rem;
}
</style>
</style>

View file

@ -14,7 +14,7 @@
type="number"
class="form-control form-control-sm"
:value="print_format[df.fieldname]"
@change="(e) => update_margin(df.fieldname, e.target.value)"
@change="e => update_margin(df.fieldname, e.target.value)"
/>
</div>
</div>
@ -24,7 +24,7 @@
<div class="sidebar-menu">
<div class="sidebar-label">{{ __("Fields") }}</div>
<input
class="form-control form-control-sm mb-2"
class="mb-2 form-control form-control-sm"
type="text"
:placeholder="__('Search fields')"
v-model="search_text"
@ -34,8 +34,14 @@
:list="fields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="clone_field"
>
<div class="field" v-for="df in fields" :key="df.fieldname">
<div
class="field"
v-for="df in fields"
:key="df.fieldname"
:title="df.fieldname"
>
{{ df.label }}
</div>
</draggable>
@ -46,18 +52,19 @@
<script>
import draggable from "vuedraggable";
import { get_table_columns } from "./utils";
import { get_table_columns, pluck } from "./utils";
import { storeMixin } from "./store";
export default {
name: "PrintFormatControls",
props: ["print_format", "meta"],
mixins: [storeMixin],
data() {
return {
search_text: "",
search_text: ""
};
},
components: {
draggable,
draggable
},
methods: {
update_margin(fieldname, value) {
@ -67,6 +74,19 @@ export default {
}
this.$emit("update", { fieldname, value });
},
clone_field(df) {
let cloned = pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns"
]);
if (cloned.fieldname == "custom_html") {
cloned.fieldname += "_" + frappe.utils.get_random(8);
}
return cloned;
}
},
computed: {
margins() {
@ -74,12 +94,12 @@ export default {
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
{ label: __("Left"), fieldname: "margin_left" },
{ label: __("Right"), fieldname: "margin_right" },
{ label: __("Right"), fieldname: "margin_right" }
];
},
fields() {
let fields = this.meta.fields
.filter((df) => {
.filter(df => {
if (["Section Break", "Column Break"].includes(df.fieldtype)) {
return false;
}
@ -95,12 +115,12 @@ export default {
return true;
}
})
.map((df) => {
.map(df => {
let out = {
label: df.label,
fieldname: df.fieldname,
options: df.options,
reqd: df.reqd,
fieldtype: df.fieldtype,
options: df.options
};
if (df.fieldtype == "Table") {
out.table_columns = get_table_columns(df);
@ -110,15 +130,20 @@ export default {
return [
{
label: "Custom HTML",
label: __("Custom HTML"),
fieldname: "custom_html",
fieldtype: "HTML",
html: "",
html: ""
},
...fields,
{
label: __("ID (name)"),
fieldname: "name",
fieldtype: "Data"
},
...fields
];
},
},
}
}
};
</script>
@ -160,4 +185,4 @@ export default {
.sidebar-menu:last-child {
margin-bottom: 0;
}
</style>
</style>

View file

@ -49,23 +49,11 @@
group="fields"
:animation="150"
>
<button
class="field"
<Field
v-for="df in get_fields(column)"
:key="df.fieldname"
>
<div>
{{ df.label }}
</div>
<button
class="btn btn-xs btn-remove-field"
@click="$set(df, 'remove', true)"
>
<svg class="icon icon-sm">
<use xlink:href="#icon-close"></use>
</svg>
</button>
</button>
:df="df"
/>
</draggable>
</div>
</div>
@ -74,19 +62,23 @@
<script>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import { storeMixin } from "./store";
export default {
name: "PrintFormatSection",
mixins: [storeMixin],
props: ["section"],
components: {
draggable,
Field
},
methods: {
add_column() {
if (this.section.columns.length < 4) {
this.section.columns.push({
label: "",
fields: [],
fields: []
});
}
},
@ -103,9 +95,9 @@ export default {
this.$set(this.section, "columns", columns);
},
get_fields(column) {
return column.fields.filter((df) => !df.remove);
},
},
return column.fields.filter(df => !df.remove);
}
}
};
</script>
@ -166,38 +158,8 @@ export default {
padding-right: 8px;
}
.field {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 1px dashed var(--gray-400);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
}
.field:not(:first-child) {
margin-top: 0.5rem;
}
.btn-remove-field {
opacity: 0;
padding: 2px;
box-shadow: none;
}
.btn-remove-field:hover {
background-color: white;
}
.field:hover .btn-remove-field {
opacity: 1;
}
.drag-container {
height: 100%;
min-height: 2rem;
}
</style>
</style>

View file

@ -5,13 +5,24 @@ class PrintFormatBuilder {
this.$wrapper = $(wrapper);
this.page = page;
this.print_format = print_format;
this.page.clear_actions()
this.page.clear_custom_actions()
this.page.set_title(__("Editing {0}", [this.print_format]));
this.page.set_primary_action(__("Save changes"), () => {
this.$component.save_changes();
this.$component.$store.save_changes();
});
this.page.set_secondary_action(__("Reset changes"), () => {
this.$component.reset_changes();
this.$component.$store.reset_changes();
});
this.page.add_button(
__("Preview"),
() => {
this.preview();
},
{ icon: "small-file" }
);
let $vm = new Vue({
el: this.$wrapper.get(0),
@ -24,6 +35,46 @@ class PrintFormatBuilder {
});
this.$component = $vm.$children[0];
}
async preview() {
let doctype = this.$component.$store.print_format.doc_type;
let default_doc = await frappe.db.get_list(doctype, {
limit: 1
});
let d = new frappe.ui.Dialog({
title: __("Preview Print Format"),
fields: [
{
label: __("Type"),
fieldname: "type",
fieldtype: "Select",
options: ["PDF", "HTML"],
default: "PDF"
},
{
label: __("Select Document"),
fieldname: "docname",
fieldtype: "Link",
options: doctype,
reqd: 1,
default: default_doc.length > 0 ? default_doc[0].name : null
}
],
primary_action: ({ docname, type }) => {
let params = new URLSearchParams();
params.append("doctype", doctype);
params.append("name", docname);
params.append("print_format", this.print_format);
let url =
type == "PDF"
? `/api/method/frappe.utils.weasyprint.download_pdf`
: "/printpreview";
window.open(`${url}?${params.toString()}`, "_blank");
}
});
d.show();
}
}
frappe.provide("frappe.ui");

View file

@ -0,0 +1,125 @@
import { create_default_layout, pluck } from "./utils";
let stores = {};
export function getStore(print_format_name) {
if (stores[print_format_name]) {
return stores[print_format_name];
}
let options = {
data() {
return {
print_format_name,
print_format: null,
doctype: null,
meta: null,
layout: null
};
},
methods: {
fetch() {
frappe.model.clear_doc("Print Format", this.print_format_name);
frappe.model.with_doc(
"Print Format",
this.print_format_name,
() => {
this.print_format = frappe.get_doc(
"Print Format",
this.print_format_name
);
frappe.model.with_doctype(
this.print_format.doc_type,
() => {
this.meta = frappe.get_meta(
this.print_format.doc_type
);
this.layout = this.get_layout();
}
);
}
);
},
update({ fieldname, value }) {
this.$set(this.print_format, fieldname, value);
},
save_changes() {
frappe.dom.freeze(__("Saving..."));
this.layout.sections = this.layout.sections
.filter(section => !section.remove)
.map(section => {
section.columns = section.columns.map(column => {
column.fields = column.fields
.filter(df => !df.remove)
.map(df => {
if (df.table_columns) {
df.table_columns = df.table_columns.map(
tf => {
return pluck(tf, [
"label",
"fieldname",
"fieldtype",
"options",
"width"
]);
}
);
}
return pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns"
]);
});
return column;
});
return section;
});
this.print_format.format_data = JSON.stringify(this.layout);
frappe
.call("frappe.client.save", {
doc: this.print_format
})
.then(() => this.fetch())
.always(() => frappe.dom.unfreeze());
},
reset_changes() {
this.fetch();
},
get_layout() {
if (this.print_format) {
if (!this.print_format.format_data) {
return create_default_layout(this.meta);
}
if (typeof this.print_format.format_data == "string") {
return JSON.parse(this.print_format.format_data);
}
return this.print_format.format_data;
}
return null;
}
}
};
stores[print_format_name] = new Vue(options);
return stores[print_format_name];
}
export let storeMixin = {
inject: ["$store"],
computed: {
print_format() {
return this.$store.print_format;
},
layout() {
return this.$store.layout;
},
meta() {
return this.$store.meta;
}
}
};

View file

@ -59,6 +59,7 @@ export function create_default_layout(meta) {
let field = {
label: df.label,
fieldname: df.fieldname,
fieldtype: df.fieldtype,
options: df.options
};
@ -81,20 +82,35 @@ export function create_default_layout(meta) {
export function get_table_columns(df) {
let table_columns = [];
let table_fields = frappe.get_meta(df.options).fields;
let total_columns = 0;
for (let tf of table_fields) {
if (
!in_list(["Section Break", "Column Break"], tf.fieldtype) &&
!tf.print_hide &&
df.label
df.label &&
total_columns < 12
) {
let columns =
typeof tf.width == "number" && tf.width < 12 ? tf.width : 2;
table_columns.push({
label: tf.label,
fieldname: tf.fieldname,
fieldtype: tf.fieldtype,
options: tf.options,
width: tf.width || 0
width: columns
});
total_columns += columns;
}
}
return table_columns;
}
export function pluck(object, keys) {
let out = {};
for (let key of keys) {
if (key in object) {
out[key] = object[key];
}
}
return out;
}