Release Fixture Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
import { useMemo, useState, type ChangeEvent } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
generateFixtures,
|
||||
modelJson,
|
||||
parseFixtureModel,
|
||||
serializeDataset,
|
||||
validateModel,
|
||||
type Distribution,
|
||||
type FixtureField,
|
||||
type FixtureModel,
|
||||
type FixtureType,
|
||||
type GeneratedDataset,
|
||||
type ModelFormat,
|
||||
type OutputFormat,
|
||||
} from "../fixture/model";
|
||||
|
||||
const SAMPLE_SQL = `CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email VARCHAR(80) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(48) NOT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
ordered_at TIMESTAMP NOT NULL
|
||||
);`;
|
||||
|
||||
const initialParsed = parseFixtureModel(SAMPLE_SQL, "sql");
|
||||
const initialDataset = generateFixtures(initialParsed.model, {
|
||||
seed: "fixture-tools-demo",
|
||||
rows: 8,
|
||||
boundaryPercent: 10,
|
||||
invalidPercent: 0,
|
||||
});
|
||||
|
||||
type View = "model" | "data" | "violations";
|
||||
const TYPES: FixtureType[] = [
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"datetime",
|
||||
"email",
|
||||
"uuid",
|
||||
"enum",
|
||||
"foreignKey",
|
||||
];
|
||||
const DISTRIBUTIONS: Distribution[] = [
|
||||
"uniform",
|
||||
"sequential",
|
||||
"normal",
|
||||
"constant",
|
||||
];
|
||||
|
||||
function download(text: string, filename: string, mediaType: string) {
|
||||
triggerBlobDownload(new Blob([text], { type: mediaType }), filename);
|
||||
}
|
||||
|
||||
function replaceField(
|
||||
model: FixtureModel,
|
||||
tableIndex: number,
|
||||
fieldIndex: number,
|
||||
changes: Partial<FixtureField>,
|
||||
): FixtureModel {
|
||||
return {
|
||||
tables: model.tables.map((table, currentTable) =>
|
||||
currentTable === tableIndex
|
||||
? {
|
||||
...table,
|
||||
fields: table.fields.map((field, currentField) =>
|
||||
currentField === fieldIndex ? { ...field, ...changes } : field,
|
||||
),
|
||||
}
|
||||
: table,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function FieldEditor({
|
||||
field,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
field: FixtureField;
|
||||
onChange: (changes: Partial<FixtureField>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`${field.name} field name`}
|
||||
value={field.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${field.name} type`}
|
||||
value={field.type}
|
||||
onChange={(event) => {
|
||||
const type = event.target.value as FixtureType;
|
||||
onChange({
|
||||
type,
|
||||
reference:
|
||||
type === "foreignKey" ? (field.reference ?? "") : undefined,
|
||||
values: type === "enum" ? (field.values ?? []) : undefined,
|
||||
constant: undefined,
|
||||
distribution:
|
||||
type === "foreignKey" || field.distribution === "constant"
|
||||
? "uniform"
|
||||
: field.distribution,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{TYPES.map((type) => (
|
||||
<option key={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${field.name} distribution`}
|
||||
value={field.distribution}
|
||||
disabled={field.type === "foreignKey"}
|
||||
onChange={(event) => {
|
||||
const distribution = event.target.value as Distribution;
|
||||
onChange({
|
||||
distribution,
|
||||
...(distribution === "constant" ? {} : { constant: undefined }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{DISTRIBUTIONS.map((distribution) => (
|
||||
<option key={distribution}>{distribution}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="flag-cell">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(event) => onChange({ required: event.target.checked })}
|
||||
/>
|
||||
required
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.unique}
|
||||
onChange={(event) => onChange({ unique: event.target.checked })}
|
||||
/>
|
||||
unique
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
{field.type === "foreignKey" ? (
|
||||
<input
|
||||
aria-label={`${field.name} reference`}
|
||||
placeholder="table.field"
|
||||
value={field.reference ?? ""}
|
||||
onChange={(event) => onChange({ reference: event.target.value })}
|
||||
/>
|
||||
) : field.type === "enum" ? (
|
||||
<span className="range-inputs">
|
||||
<input
|
||||
aria-label={`${field.name} enum values`}
|
||||
placeholder="draft, active, closed"
|
||||
value={(field.values ?? []).join(", ")}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
values: event.target.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{field.distribution === "constant" ? (
|
||||
<select
|
||||
aria-label={`${field.name} constant`}
|
||||
value={(field.values ?? []).findIndex(
|
||||
(value) => value === field.constant,
|
||||
)}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
constant:
|
||||
field.values?.[Number(event.target.value)] ?? undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="-1">Choose constant</option>
|
||||
{(field.values ?? []).map((value, index) => (
|
||||
<option value={index} key={`${String(value)}-${index}`}>
|
||||
{String(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</span>
|
||||
) : field.distribution === "constant" ? (
|
||||
field.type === "boolean" ? (
|
||||
<select
|
||||
aria-label={`${field.name} constant`}
|
||||
value={
|
||||
typeof field.constant === "boolean"
|
||||
? String(field.constant)
|
||||
: ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
constant:
|
||||
event.target.value === ""
|
||||
? undefined
|
||||
: event.target.value === "true",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Choose constant</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
aria-label={`${field.name} constant`}
|
||||
type={
|
||||
field.type === "integer" || field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: "text"
|
||||
}
|
||||
step={field.type === "integer" ? "1" : undefined}
|
||||
placeholder="constant value"
|
||||
value={field.constant == null ? "" : String(field.constant)}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
constant:
|
||||
event.target.value === ""
|
||||
? undefined
|
||||
: field.type === "integer" || field.type === "number"
|
||||
? Number(event.target.value)
|
||||
: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : field.type === "integer" || field.type === "number" ? (
|
||||
<span className="range-inputs">
|
||||
<input
|
||||
aria-label={`${field.name} minimum`}
|
||||
type="number"
|
||||
placeholder="min"
|
||||
value={field.minimum ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
minimum: event.target.value
|
||||
? Number(event.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
aria-label={`${field.name} maximum`}
|
||||
type="number"
|
||||
placeholder="max"
|
||||
value={field.maximum ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
maximum: event.target.value
|
||||
? Number(event.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="range-inputs">
|
||||
<input
|
||||
aria-label={`${field.name} minimum length`}
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="min length"
|
||||
value={field.minLength ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
minLength: event.target.value
|
||||
? Number(event.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
aria-label={`${field.name} maximum length`}
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="max length"
|
||||
value={field.maxLength ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
maxLength: event.target.value
|
||||
? Number(event.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button className="icon-button danger" type="button" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState(SAMPLE_SQL);
|
||||
const [sourceName, setSourceName] = useState("shop.sql");
|
||||
const [inputFormat, setInputFormat] = useState<ModelFormat>("sql");
|
||||
const [model, setModel] = useState<FixtureModel>(initialParsed.model);
|
||||
const [notices, setNotices] = useState(initialParsed.notices);
|
||||
const [dataset, setDataset] = useState<GeneratedDataset>(initialDataset);
|
||||
const [seed, setSeed] = useState("fixture-tools-demo");
|
||||
const [rows, setRows] = useState(8);
|
||||
const [boundaryPercent, setBoundaryPercent] = useState(10);
|
||||
const [invalidPercent, setInvalidPercent] = useState(0);
|
||||
const [selectedTable, setSelectedTable] = useState(model.tables[0]!.name);
|
||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>("json");
|
||||
const [view, setView] = useState<View>("data");
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const tableIndex = Math.max(
|
||||
0,
|
||||
model.tables.findIndex((table) => table.name === selectedTable),
|
||||
);
|
||||
const table = model.tables[tableIndex]!;
|
||||
const records = dataset.tables[selectedTable] ?? [];
|
||||
const output = useMemo(() => {
|
||||
try {
|
||||
return { text: serializeDataset(dataset, outputFormat, selectedTable) };
|
||||
} catch (reason) {
|
||||
return {
|
||||
text: "",
|
||||
error: reason instanceof Error ? reason.message : "Output failed.",
|
||||
};
|
||||
}
|
||||
}, [dataset, outputFormat, selectedTable]);
|
||||
|
||||
const parseSource = () => {
|
||||
try {
|
||||
const parsed = parseFixtureModel(source, inputFormat);
|
||||
setModel(parsed.model);
|
||||
setNotices(parsed.notices);
|
||||
setSelectedTable(parsed.model.tables[0]!.name);
|
||||
setError(undefined);
|
||||
setView("model");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "The model could not be parsed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
try {
|
||||
const validated = validateModel(structuredClone(model));
|
||||
const next = generateFixtures(validated, {
|
||||
seed,
|
||||
rows,
|
||||
boundaryPercent,
|
||||
invalidPercent,
|
||||
});
|
||||
setModel(validated);
|
||||
setDataset(next);
|
||||
setError(undefined);
|
||||
setView("data");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Generation failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const openFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setError("Model file exceeds 2 MiB.");
|
||||
return;
|
||||
}
|
||||
const extension = file.name.split(".").at(-1)?.toLowerCase();
|
||||
const format: ModelFormat = file.name
|
||||
.toLowerCase()
|
||||
.endsWith(".fixture.json")
|
||||
? "fixture-model"
|
||||
: extension === "sql"
|
||||
? "sql"
|
||||
: extension === "xsd"
|
||||
? "xsd"
|
||||
: extension === "csv"
|
||||
? "csv"
|
||||
: "json-schema";
|
||||
setSource(await file.text());
|
||||
setSourceName(file.name);
|
||||
setInputFormat(format);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const updateCurrentField = (
|
||||
fieldIndex: number,
|
||||
changes: Partial<FixtureField>,
|
||||
) =>
|
||||
setModel((current) =>
|
||||
replaceField(current, tableIndex, fieldIndex, changes),
|
||||
);
|
||||
|
||||
const extension =
|
||||
outputFormat === "json"
|
||||
? "json"
|
||||
: outputFormat === "ndjson"
|
||||
? "ndjson"
|
||||
: outputFormat === "xml"
|
||||
? "xml"
|
||||
: outputFormat === "sql"
|
||||
? "sql"
|
||||
: "csv";
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Deterministic synthetic-data laboratory</p>
|
||||
<h1>Fixture Tools</h1>
|
||||
<p>
|
||||
Turn local schemas into replayable, privacy-safe datasets—with
|
||||
foreign keys, boundary values and intentionally invalid cases kept
|
||||
explicit.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Seeded · local only</span>
|
||||
</section>
|
||||
|
||||
<section className="panel source-panel" aria-labelledby="source-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2 id="source-title">Model source</h2>
|
||||
<p>
|
||||
{sourceName} · the last valid model remains active after an error
|
||||
</p>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open model
|
||||
<input
|
||||
data-testid="model-file-input"
|
||||
type="file"
|
||||
accept=".json,.schema.json,.sql,.xsd,.csv,application/json,text/csv,text/xml"
|
||||
onChange={(event) => void openFile(event)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="source-grid">
|
||||
<label>
|
||||
Source kind
|
||||
<select
|
||||
aria-label="Source kind"
|
||||
value={inputFormat}
|
||||
onChange={(event) =>
|
||||
setInputFormat(event.target.value as ModelFormat)
|
||||
}
|
||||
>
|
||||
<option value="fixture-model">Fixture model JSON</option>
|
||||
<option value="json-schema">JSON Schema</option>
|
||||
<option value="sql">SQL DDL</option>
|
||||
<option value="xsd">XSD</option>
|
||||
<option value="csv">CSV headings</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={parseSource}
|
||||
>
|
||||
Import model
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="Fixture model source"
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="error" role="alert">
|
||||
{error} Existing model and generated data were retained.
|
||||
</p>
|
||||
) : (
|
||||
<p className="success" role="status">
|
||||
{model.tables.length} table{model.tables.length === 1 ? "" : "s"} ·{" "}
|
||||
{model.tables.reduce((sum, item) => sum + item.fields.length, 0)}{" "}
|
||||
fields · {dataset.violations.length} intentional violations
|
||||
</p>
|
||||
)}
|
||||
{notices.slice(0, 8).map((notice) => (
|
||||
<p className="warning" key={notice}>
|
||||
{notice}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="panel generator-panel"
|
||||
aria-labelledby="generator-title"
|
||||
>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2 id="generator-title">Generation recipe</h2>
|
||||
<p>Same model, seed and settings produce the same records.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
modelJson(model),
|
||||
"fixture-model.json",
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Export model
|
||||
</button>
|
||||
</div>
|
||||
<div className="option-grid">
|
||||
<label>
|
||||
Seed
|
||||
<input
|
||||
value={seed}
|
||||
maxLength={256}
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rows per table
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={rows}
|
||||
onChange={(event) => setRows(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Boundary values (%)
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={boundaryPercent}
|
||||
onChange={(event) =>
|
||||
setBoundaryPercent(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Intentional invalids (%)
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={invalidPercent}
|
||||
onChange={(event) =>
|
||||
setInvalidPercent(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="button" onClick={generate}>
|
||||
Generate fixtures
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="tabs" aria-label="Fixture workspaces">
|
||||
{(["model", "data", "violations"] as View[]).map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
aria-pressed={view === name}
|
||||
onClick={() => setView(name)}
|
||||
>
|
||||
{name === "violations"
|
||||
? `Invalid cases (${dataset.violations.length})`
|
||||
: name[0]!.toUpperCase() + name.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{view === "model" ? (
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Editable field model</h2>
|
||||
<p>
|
||||
Imported constructs are normalized into this inspectable model.
|
||||
</p>
|
||||
</div>
|
||||
<label>
|
||||
Table
|
||||
<select
|
||||
aria-label="Model table"
|
||||
value={selectedTable}
|
||||
onChange={(event) => setSelectedTable(event.target.value)}
|
||||
>
|
||||
{model.tables.map((item) => (
|
||||
<option key={item.name}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="field-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Distribution</th>
|
||||
<th>Rules</th>
|
||||
<th>Bounds / values / reference</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.fields.map((field, fieldIndex) => (
|
||||
<FieldEditor
|
||||
key={`${field.name}-${fieldIndex}`}
|
||||
field={field}
|
||||
onChange={(changes) =>
|
||||
updateCurrentField(fieldIndex, changes)
|
||||
}
|
||||
onRemove={() =>
|
||||
setModel((current) => ({
|
||||
tables: current.tables.map((item, currentIndex) =>
|
||||
currentIndex === tableIndex
|
||||
? {
|
||||
...item,
|
||||
fields: item.fields.filter(
|
||||
(_field, index) => index !== fieldIndex,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="top-gap"
|
||||
onClick={() =>
|
||||
setModel((current) => ({
|
||||
tables: current.tables.map((item, currentIndex) =>
|
||||
currentIndex === tableIndex
|
||||
? {
|
||||
...item,
|
||||
fields: [
|
||||
...item.fields,
|
||||
{
|
||||
name: `field_${item.fields.length + 1}`,
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: false,
|
||||
distribution: "sequential",
|
||||
},
|
||||
],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add field
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view === "data" ? (
|
||||
<section className="workspace-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Generated records</h2>
|
||||
<p>
|
||||
Showing at most 100 of {records.length.toLocaleString()} rows.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Data table"
|
||||
value={selectedTable}
|
||||
onChange={(event) => setSelectedTable(event.target.value)}
|
||||
>
|
||||
{Object.keys(dataset.tables).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table-scroll records" tabIndex={0}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{Object.keys(records[0] ?? {}).map((name) => (
|
||||
<th key={name}>{name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.slice(0, 100).map((record, index) => (
|
||||
<tr key={index}>
|
||||
<th>{index + 1}</th>
|
||||
{Object.keys(record).map((name) => (
|
||||
<td key={name}>{String(record[name] ?? "null")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
<article className="panel export-panel">
|
||||
<div className="panel-heading">
|
||||
<h2>Export</h2>
|
||||
<label>
|
||||
Format
|
||||
<select
|
||||
aria-label="Output format"
|
||||
value={outputFormat}
|
||||
onChange={(event) =>
|
||||
setOutputFormat(event.target.value as OutputFormat)
|
||||
}
|
||||
>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV (selected table)</option>
|
||||
<option value="ndjson">NDJSON</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="sql">SQL INSERT</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{output.error ? <p className="error">{output.error}</p> : null}
|
||||
<textarea
|
||||
aria-label="Generated output"
|
||||
readOnly
|
||||
rows={24}
|
||||
value={output.text}
|
||||
/>
|
||||
<button
|
||||
className="primary-button top-gap"
|
||||
type="button"
|
||||
disabled={!output.text}
|
||||
onClick={() =>
|
||||
download(
|
||||
output.text,
|
||||
`fixtures.${extension}`,
|
||||
outputFormat === "json" || outputFormat === "ndjson"
|
||||
? "application/json"
|
||||
: outputFormat === "xml"
|
||||
? "application/xml"
|
||||
: "text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download {extension.toUpperCase()}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view === "violations" ? (
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Intentional invalid cases</h2>
|
||||
<p>
|
||||
Every mutation requested by the invalid percentage is recorded;
|
||||
zero means all generated values satisfy the focused model.
|
||||
</p>
|
||||
</div>
|
||||
<span className="count-pill">{dataset.violations.length}</span>
|
||||
</div>
|
||||
{dataset.violations.length ? (
|
||||
<ul className="violation-list">
|
||||
{dataset.violations.slice(0, 1_000).map((violation, index) => (
|
||||
<li
|
||||
key={`${violation.table}-${violation.row}-${violation.field}-${index}`}
|
||||
>
|
||||
<code>
|
||||
{violation.table}[{violation.row}].{violation.field}
|
||||
</code>
|
||||
<strong>{violation.rule}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="empty">No invalid cases were requested.</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user