İçeriğe geç
Docs

Formlar ve Şema

Uygulama formları — @tecof/app-ui/schema ile istemci ve sunucuda tek şema, v kuralları, koşullu zorunluluk, AppForm ve hazır alanlar, SecretField ile Vault sırları, validateOnServer ve 422 hata sözleşmesi.

Son güncelleme:

Uygulama formları tek şemayla iki tarafta doğrulanır: aynı model tarayıcıda alan hatalarını gösterir, sunucuda gövdeyi temizleyip doğrular. Şema sunucu-güvenli @tecof/app-ui/schema girişinden gelir (React/DOM yok), form bileşenleri istemci girişinden.

1. Şemayı tanımlayın#

lib/settings-model.ts
// "use client" YOK: bu dosya hem istemcide hem sunucuda içe aktarılır.
import { createModel } from "@tecof/app-ui/schema";

export type Settings = { companyName: string; email: string; vkn: string; mode: string; apiKey: string };

export const settingsModel = createModel<Settings>((v, ctx) => ({
  companyName: v.text({ required: true, max: 120 }),
  email: v.email({ required: true }),
  vkn: v.vknOrTckn(),
  mode: v.select(["draft", "live"], { required: true }),
  apiKey: v.secret({ required: !ctx.secrets.apiKey }), // kayıtlıysa boş = değiştirme
}));

Kurallar (v)#

KuralAçıklama
text({ required, min, max, pattern, trim })Metin; trim: false sunucuda kırpmaz (SMS/şablon gövdesi)
email()E-posta
httpsUrl({ hosts })Yalnız https, izin listeli host’lar
trMobile() · e164Phone()Türkiye cep telefonu · E.164
vkn() · tckn() · vknOrTckn()Vergi / kimlik numarası (sağlama hanesiyle)
iban()TR IBAN (mod-97)
number({ min, max, integer })Sayı
boolean({ mustBeTrue })Onay kutusu
select(values) · multiSelect(values, { min, max })Seçim
secret({ required, min, max })Vault’a gidecek sır
custom(check, message, { required, priority })Özel kural; check(value, formData)

Mesajlar tr/en hazırdır (createRules("en")); özel mesaj için required: "…".

Koşullu zorunluluk#

Her kural requiredIf: (form) => boolean alır; boş değerde de çalışır ve alanın zorunlu yıldızı güncel form değerine göre çıkar. Koşulu belirleyen alana .proxy(["bağımlı-alan"]) ekleyin ki değişince bağımlı alan yeniden denetlensin:

TypeScript
mode: v.select(["draft", "live"], { required: true }).proxy(["webhookUrl"]),
webhookUrl: v.httpsUrl({ hosts: ["hooks.ornek.com"], requiredIf: (d) => d.mode === "live" }),

Çapraz alan kuralı için v.custom((value: unknown, data?: Settings) => …, "mesaj", { priority: true }) kullanın. Ham schema-typed MixedType<A, B>() yazmayın (A değer, B form verisi tipidir ve kolay karışır); gerekiyorsa mixedOf<Değer, FormVerisi>().

2. İstemci formu#

app/settings/settings-form.tsx
"use client";
import { useState } from "react";
import { useSessionFetch } from "@tecof/app-bridge/react";
import { AppForm, FormActions, Section, SecretField, SelectField, TextField, useAppToast, type SecretState } from "@tecof/app-ui";
import { readFieldErrors } from "@tecof/app-ui/schema";
import { settingsModel, type Settings } from "@/lib/settings-model";

export function SettingsForm({ initial, apiKey }: { initial: Settings; apiKey: SecretState }) {
  const [value, setValue] = useState(initial);
  const [saved, setSaved] = useState(initial);
  const fetch = useSessionFetch(); // 60 sn'lik oturum token'ını ekler
  const toast = useAppToast();

  return (
    <AppForm
      model={settingsModel}
      modelContext={{ secrets: { apiKey: apiKey.set } }}
      formValue={value}
      initialValue={saved}
      onChange={setValue}
      onSubmit={async (data) => {
        const res = await fetch("/api/settings", {
          method: "PUT",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(data),
        });
        const json = await res.json().catch(() => ({}));
        if (!res.ok) return { errors: readFieldErrors(json), message: json.message };
        setSaved({ ...data, apiKey: "" });
        setValue({ ...data, apiKey: "" });
        toast.success("Ayarlar kaydedildi");
      }}
    >
      <Section title="Bağlantı" description="Sağlayıcı hesabınız" padded={false}>
        <TextField name="companyName" label="Firma adı" />
        <TextField name="email" label="E-posta" type="email" />
        <TextField name="vkn" label="VKN / TCKN" inputMode="numeric" />
        <SelectField name="mode" label="Mod" options={[{ label: "Taslak", value: "draft" }, { label: "Canlı", value: "live" }]} />
        <SecretField name="apiKey" label="API anahtarı" stored={apiKey} />
      </Section>
      <FormActions />
    </AppForm>
  );
}

Hazır alanlar: TextField, TextareaField, NumberField, SelectField (8+ seçenekte aranabilir), ToggleField, CheckboxGroupField, RadioField, PasswordField, SecretField ve genel Field (accepter={DatePicker} vb.). Girdi öznitelikleri (inputMode, autoComplete, maxLength, pattern, autoFocus, enterKeyHint, data-*) doğrudan <input>’a geçer. Zorunlu işareti şemadan gelir. Çekmece/modal içinde layout="stack".

  • AppForm gönderimde istemci doğrulaması yapar; hata varsa onCheckFail(errors, "client") çağrılır (odaklanmadan önce — katlanmış satırı ya da sekmeyi burada açın), uyarı toast’ı gösterilir ve ilk hatalı alana odaklanılır. Sunucu { errors } dönerse onCheckFail(errors, "server").
  • Form içinden durum: useAppForm() → { errors, saving, dirty, submit, reset, … }.
  • FormActions varsayılan olarak Kaydet + geri al’dır (mobilde değişiklik varken alta yapışır). Gönderme formları için submitLabel="Gönder" submittingLabel="Gönderiliyor…" submitIcon={<Send className="size-4" />} hideCancel; formun ortasında tek düğme için <SubmitButton>. Kendi gönder düğmenizi ya da onSubmitCapture geçici çözümünü yazmayın.

3. Sunucu ucu#

app/api/settings/route.ts
import { resolveLocale, validateOnServer } from "@tecof/app-ui/schema";
import { settingsModel } from "@/lib/settings-model";
import { tecof, withSession } from "@/lib/tecof";

export async function PUT(request: Request) {
  return withSession(request, async ({ installationId, locale }) => {
    const client = tecof.client(installationId);
    const hasKey = (await client.secrets.list()).some((s) => s.key === "apiKey");
    const r = validateOnServer(settingsModel, await request.json().catch(() => null), {
      locale: resolveLocale(locale),
      context: { secrets: { apiKey: hasKey } },
    });
    if (!r.ok) return r.response(); // 422 { error: "validation_failed", message, fields }
    const { apiKey, ...settings } = r.value;
    if (apiKey) await client.secrets.set("apiKey", apiKey);
    await client.settings.set(settings, { merge: true });
    return Response.json({ ok: true });
  });
}
  • r.value yalnız şema alanlarını içerir: fazlası atılır, metinler kırpılır (trim: false hariç), sayı ve boolean’lar çevrilir.
  • Mağaza ve kurulum kimliği yalnız doğrulanmış oturum token’ından alınır; gövde, başlık ya da sorgudan asla. withSession şablondaki yardımcıdır (tecof.sessionFromRequest(request) üzerine kurulu).

SecretField ve Vault#

Sunucu sırrı istemciye hiç göndermez; yalnız durumunu gönderir: { set, last4?, display?, updatedAt? } (display, sunucunun ürettiği maske; ör. "850*****03"). Boş gönderim mevcut sırrı korur; yeni değer client.secrets.set(key, value) ile Vault’a yazılır. Ayrıntı: Ayarlar, Vault ve metafield’lar.

Türkiye’ye özgü yardımcılar#

TypeScript
import { parseTrMobile, normalizeTrMobile, maskTrMobile, normalizeIban, isVkn, isTckn } from "@tecof/app-ui/schema";

parseTrMobile("+90 (0532) 111 22 33"); // { ok: true, national: "5321112233", e164: "+905321112233" }
parseTrMobile("0212 111 22 33");       // { ok: false, reason: "not-mobile" }
normalizeTrMobile("0532 111 22 33");   // "+905321112233"
maskTrMobile("05321112233");           // "0532 *** ** 33" — günlükte yalnız bu

parseTrMobile gerekçeli sonuç döner (missing · invalid · not-tr · not-mobile); harf içeren değeri reddeder, +90 / 0090 / 90 / 0 öneklerini, boşluk, tire, nokta ve parantezi kabul eder. Diğer saf yardımcılar: isVkn, isTckn, isTrIban, normalizeIban, isE164Phone, isHttpsUrl(url, hosts), maskSecret, formatDateTime, formatMoney.

Test#

Şemayı istemci ve sunucuda aynı mesajı verdiğinden emin olacak şekilde test edin; route handler’larda token yokken 401, alan hatasında 422 ve Vault’a sır yazımını doğrulayın. Uygulama şablonu testleri Node’un yerleşik test çalıştırıcısıyla koşar (node --test).