@sanvika/forms
v0.6.0
Published
Sanvika ecosystem forms SDK — universal field validation, schema engine, RHF-based components, JSON transform recipes, draft auto-save, SDK-bundled Cloudinary upload + async SSR server component, cloud-stored form schemas via forms.sanvikaproduction.com
Downloads
1,796
Maintainers
Readme
@sanvika/forms
Universal forms SDK for the Sanvika ecosystem — one tiny package that powers
form rendering, validation, RHF wiring, JSON transform recipes, and pulls
cloud-stored schemas from forms.sanvikaproduction.com.
v0.4.0 — semver minor. All v0.3.x exports preserved.Why?
Across 50+ Sanvika projects (freeadpostkaro, highdealshop, matrimony,
gaming, …) the same forms problem appears: per-project field definitions,
custom validation rules, copy-pasted RHF wiring, hand-rolled "Other" handlers,
inconsistent styles, no central place to tweak a dropdown label without a
deploy.
@sanvika/forms ships one component + one cloud — every consumer project
collapses to roughly:
import { SanvikaForm } from "@sanvika/forms";
<SanvikaForm
formId="fapk:car:car"
onSubmit={async (data) => apiClient.post("/ads/car", data)}
/>Schema, validation, RHF, render, transform, image upload, pending-submission (guest → login → resume) — all handled.
Install
pnpm add @sanvika/forms
# Peers (already in any React + RHF project):
pnpm add react react-dom react-hook-formOptional peers (auto-detected at runtime — install only what you need):
| Peer | What it unlocks |
|---|---|
| react-i18next | labelKey resolution (field.labelKey → translated label) |
| @sanvika/auth | user, isAuthenticated, automatic pending-submission resume |
| @sanvika/cloudinary | image upload via the images field type |
Quickstart — cloud-backed form
"use client";
import { SanvikaForm } from "@sanvika/forms";
import apiClient from "@/lib/apiClient";
import { useRouter } from "next/navigation";
export default function PostCarPage() {
const router = useRouter();
return (
<SanvikaForm
formId="fapk:car:car"
onSubmit={async (data) => apiClient.post("/ads/car", data)}
onSubmitSuccess={(res) => router.push(`/ad/${res.id}`)}
requireAuthForSubmit
/>
);
}You also need a thin Next.js API proxy in your project to add the
x-client-secret server-side:
// app/api/sanvika-forms/schema/route.js
import { NextResponse } from "next/server";
export async function GET(req) {
const { searchParams } = new URL(req.url);
const formId = searchParams.get("formId");
const locale = searchParams.get("locale") || "en";
const res = await fetch(
`${process.env.FORMS_URL}/api/v1/forms/${encodeURIComponent(formId)}?locale=${locale}`,
{ headers: { "x-client-secret": process.env.FORMS_CLIENT_SECRET } },
);
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}Quickstart — inline schema (no cloud)
import { SanvikaForm } from "@sanvika/forms";
const schema = {
title: "Contact us",
fields: [
{ name: "name", type: "text", label: "Name", required: true },
{ name: "email", type: "email", label: "Email", required: true },
{ name: "subject", type: "select", label: "Subject", options: ["General", "Support", "Other"] },
{ name: "customSubject", type: "text", label: "Custom subject",
visibleIf: { subject: "Other" }, required: true },
{ name: "message", type: "textarea", label: "Message", required: true, rows: 6 },
],
transformRecipe: {
steps: [
{ type: "resolveOther", field: "subject", otherField: "customSubject", otherValue: "Other" },
{ type: "trim", fields: ["name", "email", "message"] },
],
},
};
<SanvikaForm schema={schema} onSubmit={async (data) => fetch("/api/contact", { method: "POST", body: JSON.stringify(data) })} />Field schema reference
type Field = {
name: string; // form key (required)
type: string; // text | number | email | phone | password | url
// textarea | select | multiselect | radio
// checkbox | checkbox-group | features
// date | datetime | time | range | color
// file | images | hidden | custom
label?: string; // display label (fallback)
labelKey?: string; // i18n key — resolved via react-i18next.t()
placeholder?: string;
placeholderKey?: string;
required?: boolean;
helpText?: string;
options?: Array<string | { value, label }>; // inline (select/radio/checkbox-group)
optionsRef?: string; // cloud lookup — e.g. "fapk:car:brands" or "fapk:car:models:{brand}"
dependsOn?: string | string[]; // RHF watch keys for re-eval
visibleIf?: VisibleIf; // see below
defaultValue?: any;
// Type-specific:
min?: number; max?: number; step?: number; // number/range/date
rows?: number; // textarea
multiple?: boolean; accept?: string; // file
disabled?: boolean; autoComplete?: string;
};
type VisibleIf =
| { [field: string]: any } // equality / value-in-array
| { [field: string]: OperatorObj } // { eq, ne, gt, gte, lt, lte, in, nin, truthy, regex }
| { $and: VisibleIf[] }
| { $or: VisibleIf[] }
| ((formValues) => boolean); // function (last resort)Transform recipes
Store project-specific submit transforms as JSON in the form schema — keeps the
consumer codebase free of transformSubmitData files.
Built-in step types:
| Step | Action |
|---|---|
| coerceNumber | data[f] = Number(data[f]) for each fields[] |
| coerceBoolean | strings/numbers → boolean |
| coerceString | values → String |
| resolveOther | If field === "Other" and customField set, replace + delete custom |
| mapBooleansToFeatures | Move all boolean keys into a features: {} object |
| rename | { from, to } |
| pick | Keep only listed keys |
| omit | Delete listed keys |
| set | Force a field to a value |
| defaultIfEmpty | Set value if currently empty |
| trim / lowercase / uppercase | String ops |
| concat | Join multiple field values with literals |
| splitCSV / joinCSV | Array ↔ string |
| pruneEmpty | Delete null/undefined/"" keys |
Custom steps:
import { registerRecipeStep } from "@sanvika/forms";
registerRecipeStep("computeAge", (data, step) => {
if (data[step.dobField]) {
data[step.target] = Math.floor((Date.now() - new Date(data[step.dobField])) / 31557600000);
}
return data;
});Hooks (advanced / custom rendering)
import { useSanvikaForm, FormProvider, FormField } from "@sanvika/forms";
const form = useSanvikaForm({
fields,
transformRecipe,
onSubmit: async (data) => {/* ... */},
});
return (
<FormProvider {...form}>
<form onSubmit={form.onSubmit}>
{fields.map((f) => <FormField key={f.name} field={f} validationRules={form.validationSchema} />)}
<button disabled={form.isSubmitting}>Submit</button>
</form>
</FormProvider>
);Server-side (forms.sanvikaproduction.com client)
import { SanvikaForms, getFormSchema, saveFormSchema } from "@sanvika/forms/server";
// In a Next.js API route
const r = await getFormSchema("fapk:car:car");
return Response.json(r);Migration from v0.3.x
Zero breaking changes. Old code keeps working:
// All these still work — v0.3.x exports preserved 1:1
import { fieldValidation, getValidationSchemaSmart, showValidationErrors } from "@sanvika/forms";New stuff is additive — opt in when ready.
License: UNLICENSED (Sanvika internal)
