Skip to content
Merged
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
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"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

v-model="modelValue as undefined" silences a legitimate type mismatch.

defineModel() in FieldRenderer is untyped (unknown), while Table.vue declares defineModel<Row[]>(). Cast-to-undefined is a no-op at runtime but hides the real fix: either type modelValue as Row[] in this component or use a properly typed intermediate.

♻️ Proposed fix
-const modelValue = defineModel();
+const modelValue = defineModel<Row[]>();

Then in the template:

-<Table v-model="modelValue as undefined" .../>
+<Table v-model="modelValue" .../>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/builder/FieldRenderer.vue` at line 168,
FieldRenderer.vue is silencing a type mismatch by using v-model="modelValue as
undefined"; instead give the component a proper typed model value: update the
defineModel usage in FieldRenderer (replace the untyped defineModel/unknown
modelValue) to the correct generic type (e.g., defineModel<Row[]>() or declare a
properly typed prop/computed wrapper) so v-model binds a typed modelValue rather
than casting to undefined; ensure the symbol names involved are
FieldRenderer.vue, defineModel, modelValue and that Table.vue's
defineModel<Row[]>() type is respected by this component (or introduce a typed
intermediate like modelValueTyped: Ref<Row[]> and use
v-model="modelValueTyped").

:in-edit-mode="inEditMode"
:doctype="fieldData.options"
/>
Comment on lines +164 to +171
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Two small issues in the Table branch.

  1. Missing description guard (line 164): The <small> for fieldData.description renders an empty element when description is absent, unlike the Checkbox branch (line 99) which has v-if="fieldData.description".

  2. Undefined doctype (line 170): If the form field was saved without setting options (the child doctype), fieldData.options is undefined, and Table.vue will fire an API call for doctype=undefined.

♻️ Proposed fix
-        <small class="text-gray-500">
-            {{ fieldData.description }}
-        </small>
+        <small v-if="fieldData.description" class="text-gray-500">
+            {{ fieldData.description }}
+        </small>
         <Table
             v-model="modelValue as undefined"
             :in-edit-mode="inEditMode"
-            :doctype="fieldData.options"
+            :doctype="fieldData.options || ''"
         />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<small class="text-gray-500">
{{ fieldData.description }}
</small>
<Table
v-model="modelValue as undefined"
:in-edit-mode="inEditMode"
:doctype="fieldData.options"
/>
<small v-if="fieldData.description" class="text-gray-500">
{{ fieldData.description }}
</small>
<Table
v-model="modelValue as undefined"
:in-edit-mode="inEditMode"
:doctype="fieldData.options || ''"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/builder/FieldRenderer.vue` around lines 164 - 171,
The <small> for fieldData.description should be conditional like the Checkbox
branch and the Table prop :doctype must never be undefined; update
FieldRenderer.vue to render the description only when fieldData.description is
truthy (use the same v-if guard as the Checkbox branch) and pass a safe doctype
to the Table component (e.g., compute a fallback or pass null/empty string when
fieldData.options is falsy) so Table does not receive doctype=undefined; ensure
references are to fieldData.description and the Table component props (v-model,
:in-edit-mode, :doctype) so you modify the correct elements.

</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];
}
Comment on lines +41 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

addRow when columns haven't loaded yet produces empty, keyless rows.

If a user clicks "Add Row" before the columnResource finishes, columns.value is [], so newRow is {} — a row with no keys. Those rows then render zero FieldRenderer instances but still appear in the list. Guard the action:

♻️ Proposed fix
 function addRow() {
+    if (!columns.value.length) return;
     const newRow = columns.value.reduce((acc, column) => {
         acc[column.key] = null;
         return acc;
     }, {} as Row);
     rows.value = [...(rows.value ?? []), newRow];
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function addRow() {
const newRow = columns.value.reduce((acc, column) => {
acc[column.key] = null;
return acc;
}, {} as Row);
rows.value = [...(rows.value ?? []), newRow];
}
function addRow() {
if (!columns.value.length) return;
const newRow = columns.value.reduce((acc, column) => {
acc[column.key] = null;
return acc;
}, {} as Row);
rows.value = [...(rows.value ?? []), newRow];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/fields/Table.vue` around lines 39 - 45, addRow
currently builds a newRow from columns.value which can be empty if
columnResource hasn't loaded, producing keyless rows; update addRow to
early-return (or disable action) when columns.value is empty (e.g., if
(!columns.value || columns.value.length === 0) return) so it never appends an
empty object to rows.value, ensuring newRow creation uses actual column keys;
reference the addRow function, columns.value, rows.value and the Row type (and
FieldRenderer rendering) when applying the guard.


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[]);
};
Comment on lines +56 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-array result (raw string) is incorrectly cast to string[] | SelectOption[].

When fieldtype is neither "Select" nor "Link" but options is non-empty (e.g. a "Data" field with options: "Email"), getFieldOptions returns the raw string "Email". The guard result === "" is false, so useFieldOptions assigns "Email" as string[] | SelectOption[]. Any downstream component that iterates options.value would receive individual characters.

Restrict the assignment to actual array results:

♻️ Proposed fix
   const load = async () => {
     const result = await getFieldOptions(field.value);
     options.value =
-      result === "" || result === undefined
+      !result || !Array.isArray(result)
         ? null
         : (result as string[] | SelectOption[]);
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/utils/selectOptions.ts` around lines 56 - 62, In load() inside
selectOptions.ts, avoid casting non-array results from
getFieldOptions(field.value) into string[] | SelectOption[]; change the
assignment to only set options.value when Array.isArray(result) (and not empty
string/undefined), otherwise set options.value to null—i.e. use
Array.isArray(result) to guard the assignment in the load function so downstream
code that iterates options.value always receives an actual array.


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

return { options, load };
}