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: 9 additions & 0 deletions .changeset/column-span-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@formrelay/core": patch
"@formrelay/vue": patch
"@formrelay/nuxt": patch
---

Add column span support for form field layout. The form schema now exposes `columns` (number of grid columns) and each field has a `columnSpan` property controlling its width.

New `<FormRelayGrid>` component in `@formrelay/vue` and `@formrelay/nuxt` handles CSS grid layout automatically — consumers just define their field template via the `#field` scoped slot.
5 changes: 5 additions & 0 deletions packages/core/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const RAW_SCHEMA_RESPONSE = {
id: "01abc",
name: "Contact Form",
is_active: true,
columns: 2,
fields: [
{
name: "email",
Expand All @@ -30,6 +31,7 @@ const RAW_SCHEMA_RESPONSE = {
options: null,
help_text: "Your email",
order: 0,
column_span: 2,
},
{
name: "subject",
Expand All @@ -43,6 +45,7 @@ const RAW_SCHEMA_RESPONSE = {
],
help_text: null,
order: 1,
column_span: 1,
},
],
validation_schema: {
Expand Down Expand Up @@ -98,6 +101,7 @@ describe("createSchemaFetcher", () => {
expect(schema.id).toBe("01abc");
expect(schema.name).toBe("Contact Form");
expect(schema.isActive).toBe(true);
expect(schema.columns).toBe(2);
expect(schema.honeypotField).toBe("_hp_phone");
expect(schema.submitUrl).toBe("https://formrelay.app/api/v1/form/01abc");
expect(schema.botProtection).toEqual({
Expand All @@ -113,6 +117,7 @@ describe("createSchemaFetcher", () => {
options: null,
helpText: "Your email",
order: 0,
columnSpan: 2,
});
expect(schema.fields[1]!.options).toEqual([
{ label: "General", value: "general" },
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface RawSchemaResponse {
id: string;
name: string;
is_active: boolean;
columns: number;
fields: RawFormField[];
validation_schema: Record<string, unknown>;
honeypot_field: string | null;
Expand All @@ -31,6 +32,7 @@ interface RawFormField {
options: { label: string; value: string }[] | null;
help_text: string | null;
order: number;
column_span: number;
}

export function createSchemaFetcher(
Expand Down Expand Up @@ -91,6 +93,7 @@ function transformSchema(raw: RawSchemaResponse["data"]): FormSchema {
id: raw.id,
name: raw.name,
isActive: raw.is_active,
columns: raw.columns,
fields: raw.fields.map(transformField),
validationSchema: raw.validation_schema,
honeypotField: raw.honeypot_field,
Expand All @@ -114,5 +117,6 @@ function transformField(raw: RawFormField): FormField {
options: raw.options,
helpText: raw.help_text,
order: raw.order,
columnSpan: raw.column_span,
};
}
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface FormSchema {
id: string;
name: string;
isActive: boolean;
columns: number;
fields: FormField[];
validationSchema: JsonSchema;
honeypotField: string | null;
Expand All @@ -20,6 +21,7 @@ export interface FormField {
options: FieldOption[] | null;
helpText: string | null;
order: number;
columnSpan: number;
}

export interface FieldOption {
Expand Down
5 changes: 5 additions & 0 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,10 @@ export default defineNuxtModule<ModuleOptions>({
name: "FormRelay",
filePath: resolver.resolve("./runtime/components/FormRelay"),
});

addComponent({
name: "FormRelayGrid",
filePath: resolver.resolve("./runtime/components/FormRelayGrid"),
});
},
});
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/components/FormRelayGrid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FormRelayGrid as default } from "@formrelay/vue";
3 changes: 3 additions & 0 deletions packages/vue/src/components/FormRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mockSchema = {
id: "01abc",
name: "Test Form",
isActive: true,
columns: 2,
fields: [
{
name: "email",
Expand All @@ -18,6 +19,7 @@ const mockSchema = {
options: null,
helpText: null,
order: 0,
columnSpan: 2,
},
],
validationSchema: { type: "object" },
Expand Down Expand Up @@ -55,6 +57,7 @@ describe("FormRelay", () => {
await nextTick();

expect(slotProps).toBeDefined();
expect(slotProps.columns).toBe(2);
expect(slotProps.fields).toBeDefined();
expect(slotProps.values).toBeDefined();
expect(slotProps.errors).toBeDefined();
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/components/FormRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default defineComponent({

return slots.default({
schema: state.schema.value,
columns: state.columns.value,
fields: state.fields.value,
schemaLoading: state.schemaLoading.value,
schemaError: state.schemaError.value,
Expand Down
155 changes: 155 additions & 0 deletions packages/vue/src/components/FormRelayGrid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { mount } from "@vue/test-utils";
import { h } from "vue";
import FormRelayGrid from "./FormRelayGrid";
import type { FormField } from "@formrelay/core";

const mockFields: FormField[] = [
{
name: "first_name",
label: "First Name",
type: "text",
isRequired: true,
htmlInputType: "text",
options: null,
helpText: null,
order: 0,
columnSpan: 1,
},
{
name: "last_name",
label: "Last Name",
type: "text",
isRequired: true,
htmlInputType: "text",
options: null,
helpText: null,
order: 1,
columnSpan: 1,
},
{
name: "email",
label: "Email",
type: "email",
isRequired: true,
htmlInputType: "email",
options: null,
helpText: null,
order: 2,
columnSpan: 2,
},
];

describe("FormRelayGrid", () => {
test("renders a grid container with correct columns", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 2 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

const container = wrapper.element as HTMLElement;
expect(container.style.display).toBe("grid");
expect(container.style.gridTemplateColumns).toBe("repeat(2, 1fr)");
});

test("applies columnSpan to each field wrapper", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 2 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

const children = wrapper.element.children;
expect(children).toHaveLength(3);
expect((children[0] as HTMLElement).style.gridColumn).toBe("span 1");
expect((children[1] as HTMLElement).style.gridColumn).toBe("span 1");
expect((children[2] as HTMLElement).style.gridColumn).toBe("span 2");
});

test("passes field to the slot", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 2 },
slots: {
field: ({ field }: { field: FormField }) =>
h("label", { "data-field": field.name }, field.label),
},
});

const labels = wrapper.findAll("label");
expect(labels).toHaveLength(3);
expect(labels[0]!.attributes("data-field")).toBe("first_name");
expect(labels[1]!.attributes("data-field")).toBe("last_name");
expect(labels[2]!.attributes("data-field")).toBe("email");
});

test("renders nothing when no field slot is provided", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 2 },
});

expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE);
});

test("uses custom tag when provided", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 2, tag: "fieldset" },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

expect(wrapper.element.tagName).toBe("FIELDSET");
});

test("adapts to different column counts", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: mockFields, columns: 3 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

expect((wrapper.element as HTMLElement).style.gridTemplateColumns).toBe("repeat(3, 1fr)");
});

test("clamps columnSpan to columns when it exceeds the grid", () => {
const fields: FormField[] = [{ ...mockFields[0]!, columnSpan: 5 }];
const wrapper = mount(FormRelayGrid, {
props: { fields, columns: 2 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

expect((wrapper.element.children[0] as HTMLElement).style.gridColumn).toBe("span 2");
});

test("clamps columnSpan to minimum of 1", () => {
const fields: FormField[] = [{ ...mockFields[0]!, columnSpan: 0 }];
const wrapper = mount(FormRelayGrid, {
props: { fields, columns: 2 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

expect((wrapper.element.children[0] as HTMLElement).style.gridColumn).toBe("span 1");
});

test("renders empty grid container when fields array is empty", () => {
const wrapper = mount(FormRelayGrid, {
props: { fields: [], columns: 2 },
slots: {
field: ({ field }: { field: FormField }) => h("input", { name: field.name }),
},
});

const el = wrapper.element as HTMLElement;
expect(el.style.display).toBe("grid");
expect(el.children).toHaveLength(0);
});
});
44 changes: 44 additions & 0 deletions packages/vue/src/components/FormRelayGrid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineComponent, h, type PropType } from "vue";
import type { FormField } from "@formrelay/core";

export default defineComponent({
name: "FormRelayGrid",
props: {
columns: {
type: Number,
default: 2,
validator: (v: number) => Number.isInteger(v) && v >= 1,
},
fields: { type: Array as PropType<FormField[]>, required: true },
tag: { type: String, default: "div" },
},
setup(props, { slots }) {
return () => {
if (!slots.field) return null;

const children = props.fields.map((field) => {
const span = Math.min(Math.max(1, field.columnSpan), props.columns);

return h(
"div",
{
key: field.name,
style: { gridColumn: `span ${span}` },
},
slots.field!({ field }),
);
});

return h(
props.tag,
{
style: {
display: "grid",
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
},
},
children,
);
};
},
});
13 changes: 9 additions & 4 deletions packages/vue/src/composables/useFormRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockFields = [
options: null,
helpText: null,
order: 0,
columnSpan: 2,
},
{
name: "name",
Expand All @@ -24,13 +25,15 @@ const mockFields = [
options: null,
helpText: null,
order: 1,
columnSpan: 1,
},
];

const mockSchema = {
id: "01abc",
name: "Test Form",
isActive: true,
columns: 2,
fields: mockFields,
validationSchema: { type: "object" },
honeypotField: "_hp_phone",
Expand Down Expand Up @@ -120,10 +123,11 @@ describe("useFormRelay", () => {
test("fetches schema on init and populates state", async () => {
mockGetSchema.mockResolvedValueOnce(mockSchemaWithBot);

const { schema, fields, schemaLoading, botProtection, validationSchema } = useFormRelay({
formId: "01abc",
publicKey: "pk_fr_test",
});
const { schema, columns, fields, schemaLoading, botProtection, validationSchema } =
useFormRelay({
formId: "01abc",
publicKey: "pk_fr_test",
});

expect(schemaLoading.value).toBe(true);

Expand All @@ -132,6 +136,7 @@ describe("useFormRelay", () => {

expect(schemaLoading.value).toBe(false);
expect(schema.value).toEqual(mockSchemaWithBot);
expect(columns.value).toBe(2);
expect(fields.value).toHaveLength(2);
expect(botProtection.value).toEqual({
type: "turnstile",
Expand Down
Loading
Loading