Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions forms_pro/forms_pro/doctype/form_field/form_field.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Fieldtype",
"options": "Attach\nData\nNumber\nEmail\nDate\nDate Time\nDate Range\nTime Picker\nPassword\nSelect\nSwitch\nTextarea\nText Editor\nLink\nCheckbox\nRating\nPhone",
"options": "Attach\nData\nNumber\nEmail\nDate\nDate Time\nDate Range\nTime Picker\nPassword\nSelect\nSwitch\nTextarea\nText Editor\nLink\nCheckbox\nRating\nPhone\nTable",
"reqd": 1
},
{
Expand Down Expand Up @@ -81,7 +81,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-15 00:41:11.397823",
"modified": "2026-02-11 14:39:48.593350",
"modified_by": "Administrator",
"module": "Forms Pro",
"name": "Form Field",
Expand Down
1 change: 1 addition & 0 deletions forms_pro/forms_pro/doctype/form_field/form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class FormField(Document):
"Checkbox",
"Rating",
"Phone",
"Table",
]
hidden: DF.Check
label: DF.Data
Expand Down
2 changes: 1 addition & 1 deletion forms_pro/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

# include js, css files in header of desk.html
# app_include_css = "/assets/forms_pro/css/forms_pro.css"
# app_include_js = "/assets/forms_pro/js/forms_pro.js"
app_include_js = "/assets/forms_pro/js/forms_pro_desk.js"

# include js, css files in header of web template
# web_include_css = "/assets/forms_pro/css/forms_pro.css"
Expand Down
21 changes: 21 additions & 0 deletions forms_pro/public/js/forms_pro_desk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Forms Pro: fix opening Form doctype documents from list view.
// The doctype name "Form" conflicts with the router's "form" view, so path-based
// navigation (/desk/form/docname) is misinterpreted. We patch set_route so that
// when called with a path like "/desk/form/<name>" we use ["Form", "Form", name].
(function () {
if (typeof frappe === "undefined") return;

const original_set_route = frappe.set_route;
frappe.set_route = function () {
const args = Array.from(arguments);
if (args.length === 1 && typeof args[0] === "string" && args[0].includes("/")) {
// Path string: e.g. "/desk/form/n8tqgeco1u" or "form/n8tqgeco1u"
// Only match form/<docname> (one segment), not form/view/list etc.
const m = args[0].match(/(?:^\/?(?:desk\/|app\/)?)?form\/([^/#?]+)(?=[#?]|$)/);
if (m) {
return original_set_route.apply(frappe.router, ["Form", "Form", m[1]]);
}
}
return original_set_route.apply(frappe.router, args);
};
})();
14 changes: 14 additions & 0 deletions frontend/src/components/RenderField.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
<script setup lang="ts">
import type { SelectOption } from "@/utils/selectOptions";
import { useFieldOptions } from "@/utils/selectOptions";
import { FormFields, formFields, FormFieldType } from "@/utils/form_fields";
import type { PropType } from "vue";
import { computed } from "vue";

const props = defineProps({
field: {
type: Object,
required: true,
},
/** Optional pre-loaded options for Select/Link fields. When not provided, options are loaded via useFieldOptions. */
options: {
type: Array as PropType<string[] | SelectOption[] | null>,
required: false,
default: undefined,
},
});

const value = defineModel();
const fieldRef = computed(() => props.field);
const { options: loadedOptions } = useFieldOptions(fieldRef);
const resolvedOptions = computed(() => props.options ?? loadedOptions.value);

const getComponent = computed(() => {
return formFields.find(
(field: FormFields) => field.name === props.field.fieldtype
Expand All @@ -21,6 +34,7 @@ const getComponent = computed(() => {
v-model="value"
:is="getComponent.component"
:field="props.field"
:options="resolvedOptions"
v-bind="getComponent.props"
/>
</template>
80 changes: 25 additions & 55 deletions frontend/src/components/builder/FieldRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from "vue";
import { computed } from "vue";
import { Asterisk } from "lucide-vue-next";
import RenderField from "../RenderField.vue";
import { createResource } from "frappe-ui";
import Table from "@/components/fields/Table.vue";
import { useFieldOptions } from "@/utils/selectOptions";

const props = defineProps({
field: {
Expand Down Expand Up @@ -48,59 +49,7 @@ const getClasses = computed(() => {
}
});

type SelectOption = {
label: string;
value: string;
};

// Reactive ref to store options
const selectOptions = ref<string[] | SelectOption[] | null>(null);

const getOptions = async () => {
if (!fieldData.value.options) {
return "";
}

if (fieldData.value.fieldtype === "Select") {
return fieldData.value.options.split("\n");
}

if (fieldData.value.fieldtype === "Link") {
const _options = createResource({
url: "forms_pro.api.form.get_link_field_options",
makeParams: () => {
return {
doctype: fieldData.value.options,
filters: {},
page_length: 999,
};
},
});
await _options.fetch();
return _options.data;
}

return fieldData.value.options;
};

// Load options when component mounts or field data changes
const loadOptions = async () => {
selectOptions.value = await getOptions();
};

// Watch for changes to field type or options
watch(
() => [fieldData.value.fieldtype, fieldData.value.options],
() => {
loadOptions();
},
{ immediate: true }
);

// Also load on mount
onMounted(() => {
loadOptions();
});
const { options: selectOptions } = useFieldOptions(fieldData);
</script>
<template>
<div :class="getClasses" v-if="fieldData.fieldtype == 'Switch'">
Expand Down Expand Up @@ -200,6 +149,27 @@ onMounted(() => {
{{ fieldData.description }}
</small>
</div>
<div v-else-if="fieldData.fieldtype == 'Table'" class="w-full space-y-4">
<div class="flex gap-2 items-start">
<input
v-if="inEditMode"
placeholder="Label"
type="text"
v-model="fieldData.label"
class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1"
/>
<label class="text-base" v-else>{{ fieldData.label }}</label>
<Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" />
</div>
<small class="text-gray-500">
{{ fieldData.description }}
</small>
<Table
v-model="modelValue as undefined"
:in-edit-mode="inEditMode"
:doctype="fieldData.options"
/>
</div>
<div v-else :class="getClasses">
<div class="flex gap-2 items-start">
<input
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/components/fields/Table.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useCall, Button } from "frappe-ui";
import { computed } from "vue";
import FieldRenderer from "../builder/FieldRenderer.vue";
import { mapDoctypeFieldForForm } from "@/utils/form_fields";

type Row = {
[key: string]: any;
};

const rows = defineModel<Row[]>({ default: [] });

type props = {
inEditMode: boolean;
doctype: string;
};

const props = defineProps<props>();

const columnResource = useCall({
url: "forms_pro.api.form.get_doctype_fields",
baseUrl: "/api/v2/method/",
params: {
doctype: props.doctype,
},
});

const columns = computed(() => {
const data = columnResource.data;
if (!Array.isArray(data)) {
return [];
}
return data.map((column: any) => ({
label: column.label,
key: column.fieldname,
...column,
fieldtype: mapDoctypeFieldForForm(column.fieldtype) ?? "Data",
}));
});

function addRow() {
const newRow = columns.value.reduce((acc, column) => {
acc[column.key] = null;
return acc;
}, {} as Row);
rows.value = [...(rows.value ?? []), newRow];
}

function removeRow(index: number) {
rows.value = rows.value?.filter((_, i) => i !== index) ?? [];
}

function updateCell(rowIndex: number, key: string, value: unknown) {
const list = rows.value ?? [];
if (rowIndex < 0 || rowIndex >= list.length) return;
rows.value = list.map((row, i) => (i === rowIndex ? { ...row, [key]: value } : row));
}
</script>
<template>
<div class="flex flex-col gap-5 p-4 bg-surface-gray-1 border rounded">
<div v-if="!rows.length" class="flex flex-col gap-2 items-center justify-center">
<h5 class="text-base font-medium text-ink-gray-6">No rows added</h5>
<p class="text-sm text-ink-gray-5">Add an item to get started</p>
</div>
<div
v-for="(_, index) in rows"
:key="index"
class="border-b border-gray-400 pb-4 border p-4 rounded bg-surface-white relative"
>
<Button
icon="x"
variant="outline"
class="absolute -top-2 -right-2 w-4 h-4"
@click="removeRow(index)"
/>
<FieldRenderer
v-for="column in columns"
:key="column.key"
:model-value="rows[index][column.key]"
@update:model-value="updateCell(index, column.key, $event)"
:field="column"
:in-edit-mode="false"
/>
</div>
</div>
<Button v-if="!inEditMode" @click="addRow">Add Row</Button>
</template>
1 change: 1 addition & 0 deletions frontend/src/types/formfield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export enum FormFieldTypes {
Link = "Link",
Checkbox = "Checkbox",
Rating = "Rating",
Table = "Table",
}

export type FormField = {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/utils/form_fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { Component } from "vue";
import Attachment from "@/components/fields/Attachment.vue";
import Phone from "@/components/fields/Phone.vue";
import Table from "@/components/fields/Table.vue";

export type FormFieldType = {
component: Component;
Expand Down Expand Up @@ -144,6 +145,18 @@ export const PhoneField: FormFieldType = {
},
};

export const TableField: FormFieldType = {
component: Table,
props: {
options: {
emptyState: {
title: "This is a table field",
description: "Use this field to input a list of items.",
},
},
},
};

export const formFields: FormFields[] = [
{ name: "Attach", ...AttachmentField },
{ name: "Data", ...DataField },
Expand All @@ -162,6 +175,7 @@ export const formFields: FormFields[] = [
{ name: "Text Editor", ...TextEditorField },
{ name: "Checkbox", ...CheckboxField },
{ name: "Phone", ...PhoneField },
{ name: "Table", ...TableField },
];

export const mapDoctypeFieldForForm = (fieldtype: string): string => {
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/utils/selectOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createResource } from "frappe-ui";
import type { Ref, ComputedRef } from "vue";
import { ref, watch } from "vue";

export type SelectOption = {
label: string;
value: string;
};

export type FieldForOptions = {
fieldtype?: string;
options?: string;
[key: string]: unknown;
};

/**
* Resolves options for Select and Link form fields.
* Returns string[] for Select (newline-separated options), API result for Link, or raw options otherwise.
*/
export async function getFieldOptions(
field: FieldForOptions
): Promise<string[] | SelectOption[] | string | ""> {
if (!field?.options) {
return "";
}

if (field.fieldtype === "Select") {
return field.options.split("\n");
}

if (field.fieldtype === "Link") {
const resource = createResource({
url: "forms_pro.api.form.get_link_field_options",
makeParams: () => ({
doctype: field.options,
filters: {},
page_length: 999,
}),
});
await resource.fetch();
return resource.data as string[] | SelectOption[];
}

return field.options;
}

/**
* Composable to load and reactively track options for a Select/Link field.
* Call load() or rely on automatic load on mount and when field type/options change.
*/
export function useFieldOptions(
field: Ref<FieldForOptions> | ComputedRef<FieldForOptions>
) {
const options = ref<string[] | SelectOption[] | null>(null);

const load = async () => {
const result = await getFieldOptions(field.value);
options.value =
result === "" || result === undefined
? null
: (result as string[] | SelectOption[]);
};

watch(
() => [field.value.fieldtype, field.value.options],
() => load(),
{ immediate: true }
);

return { options, load };
}
Loading
Loading