# 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.
>
> Kaynak: https://tecof-app-dev.vercel.app/app-forms · Bölüm: Uygulama geliştirme · Son güncelleme: 2018-10-20

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

```ts title="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`)

| Kural | Açı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:

```ts
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

```tsx title="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

```ts title="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](/app-data).

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

```ts
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`).
