src/lib/ui/components/field-editor-modal.tsx

Total Symbols
11
Lines of Code
344
Avg Complexity
1.5
Symbol Types
4

File Relationships

graph LR GeneratorOption["GeneratorOption"] update["update"] resolveParamDefs["resolveParamDefs"] buildInitialParams["buildInitialParams"] GeneratorOption -->|calls| update GeneratorOption -->|calls| resolveParamDefs GeneratorOption -->|calls| buildInitialParams click GeneratorOption "../symbols/46d00f7f04960150.html" click update "../symbols/aa07d92961244cc7.html" click resolveParamDefs "../symbols/5f4f43ba2cfd6219.html" click buildInitialParams "../symbols/b6ce5c6111d112b3.html"

Symbols by Kind

function 5
interface 3
method 2
type 1

All Symbols

Name Kind Visibility Status Lines Signature
GeneratorOption type exported- 30-30 type GeneratorOption
FieldEditorSavePayload interface exported- 32-38 interface FieldEditorSavePayload
FieldEditorModalProps interface exported- 40-47 interface FieldEditorModalProps
resolveParamDefs function exported- 54-60 resolveParamDefs( gen: GeneratorOption, ): : readonly GeneratorParamDef[]
buildInitialParams function exported- 63-73 buildInitialParams( defs: readonly GeneratorParamDef[], existing: GeneratorParams, ): : GeneratorParams
GeneratorParamsSectionProps interface exported- 77-81 interface GeneratorParamsSectionProps
update function - 90-92 update(key: string, value: unknown)
handleSave function - 229-231 handleSave()
handleRedetect function - 233-241 handleRedetect()
t method - 298-298 t("editorFixedValuePlaceholder")
setFixedValue method - 301-301 setFixedValue((e.target as HTMLInputElement).value)

Full Source

/**
 * FieldEditorModal — Preact modal for editing a field's fill rule in DevTools.
 *
 * Allows the user to:
 * - Override the detected field type
 * - Choose a generator ("auto", "ai", "tensorflow", or a specific FieldType)
 * - Set a fixed value
 * - Set a custom AI prompt
 * - Configure generator params (min, max, formatted, etc.) dynamically
 * - Save or delete the override rule
 */

import { h } from "preact";
import { useState, useMemo } from "preact/hooks";
import type { DetectedFieldSummary, FieldType, GeneratorParams } from "@/types";
import {
  getGeneratorParamDefs,
  getGeneratorKey,
} from "@/types/field-type-definitions";
import { t } from "@/lib/i18n";
import { SearchableSelectPreact } from "@/lib/ui/searchable-select-preact";
import {
  buildFieldTypeSelectEntries,
  buildGeneratorSelectEntries,
} from "@/lib/ui/select-builders";
import type { GeneratorParamDef } from "@/types/field-type-definitions";

// ── Types ─────────────────────────────────────────────────────────────────────

export type GeneratorOption = "auto" | "ai" | "tensorflow" | FieldType;

export interface FieldEditorSavePayload {
  fieldType: FieldType;
  generator: GeneratorOption;
  fixedValue: string;
  aiPrompt: string;
  generatorParams: GeneratorParams;
}

export interface FieldEditorModalProps {
  field: DetectedFieldSummary;
  existingRule: FieldEditorSavePayload | null;
  onSave: (payload: FieldEditorSavePayload) => void;
  onDelete: () => void;
  onClose: () => void;
  onRedetect?: () => Promise<void>;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

const SPECIAL_GENERATORS = new Set(["auto", "ai", "tensorflow"]);

/** Returns the param defs for a given generator option. */
export function resolveParamDefs(
  gen: GeneratorOption,
): readonly GeneratorParamDef[] {
  if (SPECIAL_GENERATORS.has(gen)) return [];
  const key = getGeneratorKey(gen as FieldType) ?? gen;
  return getGeneratorParamDefs(key);
}

/** Builds initial params from param defs, merging existing values. */
export function buildInitialParams(
  defs: readonly GeneratorParamDef[],
  existing: GeneratorParams,
): GeneratorParams {
  const result: Record<string, unknown> = {};
  for (const def of defs) {
    const k = def.key as string;
    result[k] = k in existing ? existing[k] : def.defaultValue;
  }
  return result as GeneratorParams;
}

// ── Sub-Component: Generator Params ──────────────────────────────────────────

export interface GeneratorParamsSectionProps {
  defs: readonly GeneratorParamDef[];
  params: GeneratorParams;
  onChange: (params: GeneratorParams) => void;
}

export function GeneratorParamsSection({
  defs,
  params,
  onChange,
}: GeneratorParamsSectionProps) {
  if (defs.length === 0) return null;

  function update(key: string, value: unknown) {
    onChange({ ...params, [key]: value } as GeneratorParams);
  }

  return (
    <div class="editor-params-section">
      <span class="editor-params-title">{t("paramSectionTitle")}</span>

      {defs.map((def) => {
        const k = def.key as string;
        const currentValue =
          k in params ? params[k as keyof GeneratorParams] : def.defaultValue;

        if (def.type === "boolean") {
          return (
            <label key={k} class="editor-row editor-row--checkbox">
              <input
                type="checkbox"
                checked={!!currentValue}
                onChange={(e) =>
                  update(k, (e.target as HTMLInputElement).checked)
                }
              />
              <span class="editor-label">
                {t(def.labelKey as Parameters<typeof t>[0])}
              </span>
            </label>
          );
        }

        if (def.type === "number") {
          return (
            <label key={k} class="editor-row">
              <span class="editor-label">
                {t(def.labelKey as Parameters<typeof t>[0])}:
              </span>
              <input
                class="editor-input editor-input--number"
                type="number"
                value={currentValue as number}
                min={def.min}
                max={def.max}
                step={def.step ?? 1}
                onInput={(e) =>
                  update(k, Number((e.target as HTMLInputElement).value))
                }
              />
            </label>
          );
        }

        if (def.type === "text") {
          return (
            <label key={k} class="editor-row">
              <span class="editor-label">
                {t(def.labelKey as Parameters<typeof t>[0])}:
              </span>
              <input
                class="editor-input"
                type="text"
                value={currentValue as string}
                placeholder={
                  def.placeholder
                    ? t(def.placeholder as Parameters<typeof t>[0])
                    : undefined
                }
                onInput={(e) => update(k, (e.target as HTMLInputElement).value)}
              />
            </label>
          );
        }

        if (def.type === "select" && def.selectOptions) {
          const nativeEntries = def.selectOptions.map((o) => ({
            value: o.value,
            label: t(o.labelKey as Parameters<typeof t>[0]),
          }));
          return (
            <label key={k} class="editor-row">
              <span class="editor-label">
                {t(def.labelKey as Parameters<typeof t>[0])}:
              </span>
              <SearchableSelectPreact
                entries={nativeEntries}
                value={currentValue as string}
                onChange={(v) => update(k, v)}
                placeholder={t(def.labelKey as Parameters<typeof t>[0])}
                className="editor-select"
              />
            </label>
          );
        }

        return null;
      })}
    </div>
  );
}

// ── Component ─────────────────────────────────────────────────────────────────

export function FieldEditorModal({
  field,
  existingRule,
  onSave,
  onDelete,
  onClose,
  onRedetect,
}: FieldEditorModalProps) {
  const displayLabel = field.label || field.name || field.id || field.selector;

  const [fieldType, setFieldType] = useState<FieldType>(
    existingRule?.fieldType ?? field.fieldType,
  );
  const [generator, setGenerator] = useState<GeneratorOption>(
    existingRule?.generator ?? "auto",
  );
  const [fixedValue, setFixedValue] = useState(existingRule?.fixedValue ?? "");
  const [aiPrompt, setAiPrompt] = useState(existingRule?.aiPrompt ?? "");

  const paramDefs = useMemo(() => resolveParamDefs(generator), [generator]);

  const [generatorParams, setGeneratorParams] = useState<GeneratorParams>(() =>
    buildInitialParams(paramDefs, existingRule?.generatorParams ?? {}),
  );

  const [isRedetecting, setIsRedetecting] = useState(false);

  // When generator changes, rebuild params with defaults (preserving compatible keys)
  function handleGeneratorChange(value: string) {
    const newGen = value as GeneratorOption;
    const newDefs = resolveParamDefs(newGen);
    setGenerator(newGen);
    setGeneratorParams(buildInitialParams(newDefs, generatorParams));
  }

  const fieldTypeEntries = useMemo(() => buildFieldTypeSelectEntries(), []);
  const generatorEntries = useMemo(() => buildGeneratorSelectEntries(), []);

  function handleSave() {
    onSave({ fieldType, generator, fixedValue, aiPrompt, generatorParams });
  }

  async function handleRedetect() {
    if (!onRedetect || isRedetecting) return;
    setIsRedetecting(true);
    try {
      await onRedetect();
    } finally {
      setIsRedetecting(false);
    }
  }

  return (
    <div class="modal-overlay" onClick={onClose}>
      <div class="modal-box" onClick={(e) => e.stopPropagation()}>
        <div class="modal-header">
          <span class="modal-title">✏️ {t("editFieldRule")}</span>
          <button class="modal-close" onClick={onClose}>
            ✕
          </button>
        </div>

        <div class="modal-body">
          <div class="editor-field-info">
            <span class="editor-label">{t("columnLabel")}:</span>
            <span class="editor-value">{displayLabel}</span>
          </div>
          <div class="editor-field-info">
            <span class="editor-label">{t("columnIdName")}:</span>
            <span class="editor-value editor-mono">{field.selector}</span>
          </div>

          <hr class="modal-divider" />

          <div class="editor-row">
            <span class="editor-label">{t("columnType")}:</span>
            <SearchableSelectPreact
              entries={fieldTypeEntries}
              value={fieldType}
              onChange={(v) => setFieldType(v as FieldType)}
              placeholder={t("columnType")}
              className="editor-select"
            />
          </div>

          <div class="editor-row">
            <span class="editor-label">{t("editorGenerator")}:</span>
            <SearchableSelectPreact
              entries={generatorEntries}
              value={generator}
              onChange={handleGeneratorChange}
              placeholder={t("editorGenerator")}
              className="editor-select"
            />
          </div>

          <GeneratorParamsSection
            defs={paramDefs}
            params={generatorParams}
            onChange={setGeneratorParams}
          />

          <label class="editor-row">
            <span class="editor-label">{t("editorFixedValue")}:</span>
            <input
              class="editor-input"
              type="text"
              placeholder={t("editorFixedValuePlaceholder")}
              value={fixedValue}
              onInput={(e) =>
                setFixedValue((e.target as HTMLInputElement).value)
              }
            />
          </label>

          <label class="editor-row">
            <span class="editor-label">{t("editorAiPrompt")}:</span>
            <input
              class="editor-input"
              type="text"
              placeholder={t("editorAiPromptPlaceholder")}
              value={aiPrompt}
              onInput={(e) => setAiPrompt((e.target as HTMLInputElement).value)}
            />
          </label>
        </div>

        <div class="modal-footer">
          {onRedetect && (
            <button
              class="btn btn-secondary"
              onClick={handleRedetect}
              disabled={isRedetecting}
            >
              🔍 {isRedetecting ? t("editorRedetecting") : t("editorRedetect")}
            </button>
          )}
          {existingRule && (
            <button class="btn btn-danger" onClick={onDelete}>
              🗑️ {t("editorDeleteRule")}
            </button>
          )}
          <button class="btn" onClick={onClose}>
            {t("editorCancel")}
          </button>
          <button class="btn btn-primary" onClick={handleSave}>
            💾 {t("editorSave")}
          </button>
        </div>
      </div>
    </div>
  );
}