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
9 changes: 3 additions & 6 deletions frontend/e2e/helpers/form-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,9 @@ export class FormBuilderPage {
}

async addField(fieldType: string) {
// Each card shows the field type name as visible text
const card = this.sidebar()
.getByText(fieldType, { exact: true })
.locator("..");
await card.hover();
await card.getByRole("button").click();
await this.sidebar()
.getByRole("button", { name: fieldType, exact: true })
.click();
}

// The canvas shows "Click on fields to add them…" when empty
Expand Down
87 changes: 87 additions & 0 deletions frontend/e2e/specs/add-fields-palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test, expect } from "../fixtures/test-data.fixture";
import { FormBuilderPage } from "../helpers/form-builder";

test.describe("Add Fields palette", () => {
test("renders palette items as buttons without live input previews", async ({
page,
createForm,
}) => {
const formId = await createForm();
const builder = new FormBuilderPage(page);
await builder.goto(formId);

const sidebar = page.locator(
'[data-form-builder-component="form-builder-sidebar"]'
);

// Palette items are buttons, named after the fieldtype
await expect(
sidebar.getByRole("button", { name: "Data", exact: true })
).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Email", exact: true })
).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Phone", exact: true })
).toBeVisible();

// No autofillable inputs inside palette buttons (regression: previously
// each palette card mounted a live FormControl, which triggered browser
// autofill on type=email / type=tel / type=password).
const emailBtn = sidebar.getByRole("button", {
name: "Email",
exact: true,
});
await expect(emailBtn.locator("input")).toHaveCount(0);

const phoneBtn = sidebar.getByRole("button", {
name: "Phone",
exact: true,
});
await expect(phoneBtn.locator("input")).toHaveCount(0);
});

test("search filters palette items", async ({ page, createForm }) => {
const formId = await createForm();
const builder = new FormBuilderPage(page);
await builder.goto(formId);

const sidebar = page.locator(
'[data-form-builder-component="form-builder-sidebar"]'
);

await expect(
sidebar.getByRole("button", { name: "Data", exact: true })
).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Phone", exact: true })
).toBeVisible();

await sidebar.getByPlaceholder("Search Fields").fill("date");

// "Date", "Date Time", "Date Range" remain (case-insensitive substring)
await expect(
sidebar.getByRole("button", { name: "Date", exact: true })
).toBeVisible();
// Unrelated types are filtered out
await expect(
sidebar.getByRole("button", { name: "Phone", exact: true })
).toHaveCount(0);
await expect(
sidebar.getByRole("button", { name: "Data", exact: true })
).toHaveCount(0);
Comment on lines +60 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add assertions for Date Time and Date Range.

The comment on line 62 states that "Date", "Date Time", and "Date Range" should remain after filtering for "date", but only the "Date" button is verified. Add:

await expect(
  sidebar.getByRole("button", { name: "Date Time", exact: true })
).toBeVisible();
await expect(
  sidebar.getByRole("button", { name: "Date Range", exact: true })
).toBeVisible();

This ensures the filter correctly matches all date-related field types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/specs/add-fields-palette.spec.ts` around lines 60 - 72, Add
missing assertions verifying that "Date Time" and "Date Range" remain visible
after filtering; locate the block where the test fills the search input via
sidebar.getByPlaceholder("Search Fields").fill("date") and currently only
asserts sidebar.getByRole("button", { name: "Date", exact: true
}).toBeVisible(), then add two additional expectations using
sidebar.getByRole("button", { name: "Date Time", exact: true }) and
sidebar.getByRole("button", { name: "Date Range", exact: true }) and assert they
are visible so all three date-related buttons are covered.

});

test("clicking a palette item adds the field to the canvas", async ({
page,
createForm,
}) => {
const formId = await createForm();
const builder = new FormBuilderPage(page);
await builder.goto(formId);

await expect(builder.canvasEmptyState()).toBeVisible();
await builder.addField("Email");
await expect(builder.canvasEmptyState()).not.toBeVisible();
});
});
86 changes: 69 additions & 17 deletions frontend/src/components/FormBuilderSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,56 +1,108 @@
<script setup lang="ts">
import { Settings, Plus, StretchHorizontal } from "@lucide/vue";
import { ref } from "vue";
import { computed, ref } from "vue";
import { Tooltip } from "frappe-ui";
import AddFieldsSection from "@/components/builder/sidebar/AddFieldsSection.vue";
import SettingsSection from "@/components/builder/sidebar/SettingsSection.vue";
import DocTypeFieldsSection from "@/components/builder/sidebar/DoctypeFieldsSection.vue";

const sidebarSections = ref([
const sidebarSections = [
{
id: 0,
id: "settings",
label: "Settings",
icon: Settings,
section: SettingsSection,
},
{
id: 1,
id: "add-fields",
label: "Add Fields",
icon: Plus,
section: AddFieldsSection,
},
{
id: 2,
id: "doctype-fields",
label: "DocType Fields",
icon: StretchHorizontal,
section: DocTypeFieldsSection,
},
]);
];

const activeSection = ref(sidebarSections.value[1]);
const activeSection = ref(
sidebarSections.find((s) => s.id === "add-fields") ?? sidebarSections[0]
);

const tabId = (id: string) => `form-builder-tab-${id}`;
const panelId = (id: string) => `form-builder-panel-${id}`;
const activeTabId = computed(() => tabId(activeSection.value.id));
const activePanelId = computed(() => panelId(activeSection.value.id));
</script>
<template>
<div
class="form-builder-sidebar bg-primary h-[calc(100vh-3rem)] w-72 border-r sticky top-0 overflow-y-auto flex"
class="form-builder-sidebar bg-surface-white h-[calc(100dvh-3rem)] w-72 border-r border-outline-gray-1 sticky top-0 overflow-y-auto flex"
data-form-builder-component="form-builder-sidebar"
>
<div class="h-full bg-inherit flex flex-col gap-2 p-2 border-r">
<div
role="tablist"
aria-label="Form builder sections"
aria-orientation="vertical"
class="h-full bg-inherit flex flex-col gap-2 p-2 border-r border-outline-gray-1"
>
<Tooltip
v-for="section in sidebarSections"
:key="section.id"
:text="section.label"
placement="right"
>
<Button
size="md"
@click="activeSection = section"
:variant="activeSection === section ? 'subtle' : 'ghost'"
:icon="section.icon"
/>
<div class="relative">
<span
v-if="activeSection.id === section.id"
aria-hidden="true"
class="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-full bg-surface-gray-7"
/>
<Button
size="md"
role="tab"
:id="tabId(section.id)"
:aria-label="section.label"
:aria-selected="activeSection.id === section.id"
:aria-controls="panelId(section.id)"
@click="activeSection = section"
:variant="activeSection.id === section.id ? 'subtle' : 'ghost'"
:icon="section.icon"
/>
</div>
</Tooltip>
</div>
<div class="flex flex-col gap-4 w-full p-4 overflow-x-hidden">
<component :is="activeSection.section" />
<div
role="tabpanel"
:id="activePanelId"
:aria-labelledby="activeTabId"
tabindex="0"
class="flex flex-col gap-4 w-full p-4 overflow-x-hidden focus:outline-none"
>
<Transition name="section-fade" mode="out-in">
<component :is="activeSection.section" :key="activeSection.id" />
</Transition>
</div>
</div>
</template>

<style scoped>
.section-fade-enter-active {
transition: opacity 120ms ease-out;
}
.section-fade-leave-active {
transition: opacity 120ms ease-in;
}
.section-fade-enter-from,
.section-fade-leave-to {
opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
.section-fade-enter-active,
.section-fade-leave-active {
transition: none;
}
}
</style>
45 changes: 21 additions & 24 deletions frontend/src/components/builder/sidebar/AddFieldsSection.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { formFields, FormFields } from "@/utils/form_fields";
import { Fieldtype } from "@/types/formfield";
import { FormControl, Button } from "frappe-ui";
import { formFields, type FormFields } from "@/utils/form_fields";
import { FormControl } from "frappe-ui";
import { useEditForm } from "@/stores/editForm";
import RenderField from "@/components/RenderField.vue";
import type { Component } from "vue";

const search = ref("");
const componentMap = formFields.reduce((acc: Record<string, Component>, field: FormFields) => {
acc[field.name] = field.component;
return acc;
}, {});

const filteredComponents = computed(() => {
return Object.keys(componentMap).filter((component) =>
component.toLowerCase().includes(search.value.toLowerCase())
);
const filteredFields = computed(() => {
const q = search.value.toLowerCase();
return formFields.filter((field: FormFields) => field.name.toLowerCase().includes(q));
});

const editFormStore = useEditForm();
Expand All @@ -31,19 +23,24 @@ const editFormStore = useEditForm();
variant="outline"
placeholder="Search Fields"
/>
<div v-for="component in filteredComponents" :key="component">
<div
class="p-2 bg-gray-50 w-full rounded flex flex-col gap-2 border border-gray-200 hover:border-gray-400 transition-all relative group"
<p v-if="!filteredFields.length" class="text-sm text-gray-500 px-1 py-2">
No fields match "{{ search }}"
</p>
<div v-else class="flex flex-col gap-2">
<button
v-for="field in filteredFields"
:key="field.name"
type="button"
class="flex w-full items-center gap-2 px-2.5 py-2 bg-surface-gray-1 rounded border border-outline-gray-1 hover:border-outline-gray-2 hover:bg-surface-gray-2 active:scale-[0.98] active:bg-surface-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-1 transition-all duration-150 text-left"
@click="editFormStore.addField(field.name)"
>
<div class="text-sm">{{ component }}</div>
<RenderField class="pointer-events-none" :field="{ fieldtype: component }" />
<Button
class="absolute top-4 -right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
variant="outline"
icon="plus"
@click="editFormStore.addField(component as Fieldtype)"
<component
:is="field.icon"
class="w-4 h-4 text-gray-600 shrink-0"
aria-hidden="true"
/>
</div>
<span class="text-sm truncate">{{ field.name }}</span>
</button>
</div>
</div>
</div>
Expand Down
Loading
Loading