Transformace dat, oprava ukladani udaju konektoru

Transformace dat ve dvou rezimech plus oprava chyby, kvuli ktere se neukladaly
pristupove udaje konektoru. Popis v documentation/13-transformace-dat.md.

Kroky si predavaji i cele struktury:
- FieldType ma object a list. Do sablony se nedosazuji, predavaji se jako celek
  dalsimu kroku - proto je u nich v builderu vyber a ne textove pole. Z podminek
  nad nimi ma smysl jen "prisla / neprisla"
- strop na velikost struktury (SCRIPT_MAX_VALUE_BYTES, vychozi 256 kB). Radek
  s vystupem kroku je nejrychleji rostouci tabulka v systemu

Dva rezimy transformace, oba nad enginem v src/scripts/mapping.ts:
- transform.map-fields: pole na pole s prevody, klikatelne
- transform.to-json: sablona cileveho objektu s ${cesta}

Marker ${...} je zamerne jiny nez {{...}}. Sablony kroku se dosazuji driv, nez
krok bezi, takze {{total}} by strom stihl vyhodnotit, nenasel by parametr toho
jmena a dosadil by prazdno. Cely retezec navic zachova typ, takze
"unitPrice": "${total}" vyrobi cislo - jinak by cizi sluzba dostala castku jako
text a odmitla ji.

Prevod map pro seznamy je to, bez ceho by priklad nesel dokoncit. Bez nej jde
prevest hlavicku dokladu, ale ne polozky objednavky, a doklad by byl na nulu.

Dal pridano:
- idoklad.create-invoice-from-object: druha polovina prikladu, bere hotove telo
  dokladu z transformace a doplni povinna pole ze vzoru iDokladu
- spoustec e-shopu predava celou objednavku jako objekt a polozky jako seznam
- klikaci editor pravidel vcetne rezimu JSON pro vnorena pravidla u map
- kontrola JSONu a tvaru pravidel uz pri ulozeni stromu. Preklep je nedodelek,
  ne chyba ukladani - rozdelana prace se nezahazuje

Opraveno: konektor neukladal pristupove udaje. Server byl v poradku, overeno
volanim POST i PATCH. Chyba byla v prohlizeci: u pole type="password" prohlizec
ignoruje autocomplete="off" a dosazuje ulozene prihlaseni. Uzivatel pak videl
jednu hodnotu, React drzel jinou, a ulozilo se to, co drzel React, tedy nic.
Resi to autocomplete="new-password", jmena poli, ktera nepripominaji heslo,
a prepinac zobrazeni, aby slo overit, co je opravdu zapsane.

Zakladani a uprava konektoru se presunuly do dialogu, na strance jsou jen male
karty. Formulare rozlozene po strance byly u vic konektoru neprehledne.

Overeno: npm run typecheck prochazi na serveru i webu, node --check na skriptech.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 14:32:39 +02:00
co-authored by Claude Opus 5
parent 8ad91a6c28
commit ad56c7f513
20 changed files with 2063 additions and 297 deletions
@@ -0,0 +1,447 @@
import { ArrowRight, Braces, Plus, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
/**
* Editor pravidel transformace dat.
*
* Jedno pravidlo rika "vezmi tuhle cestu ve zdroji, projed temito prevody
* a uloz to sem". Vysledkem je JSON, ktery se uklada do nastaveni kroku -
* hodnota kroku zustala retezec, takze se model stromu nemenil.
*
* Editor umi i rezim JSON. Neni to jen pro znale: vnorena pravidla u prevodu
* `map` se v radkovem editoru kreslit nedaji rozumne, a zakazat je kvuli tomu
* by znamenalo, ze seznam polozek objednavky nejde prevest vubec.
*
* Popis prevodu je v documentation/13-transformace-dat.md.
*/
interface TransformOp {
op: string;
[key: string]: unknown;
}
interface MappingRule {
to?: string;
from?: string;
value?: unknown;
transforms?: TransformOp[];
fallback?: unknown;
omitIfEmpty?: boolean;
required?: boolean;
}
/** Jeden parametr prevodu. Podle nej se vykresli policko. */
interface OpParam {
key: string;
label: string;
kind: 'text' | 'number' | 'choice';
options?: string[];
placeholder?: string;
}
/**
* Popis prevodu na jednom miste. UI se z toho odvozuje, aby se pri pridani
* prevodu nemusel upravovat vykreslovaci kod.
* Musi odpovidat `TransformOp` v `src/scripts/mapping.ts`.
*/
const opCatalog: Array<{ op: string; label: string; params?: OpParam[] }> = [
{ op: 'trim', label: 'Odstranit mezery' },
{ op: 'lower', label: 'Na malá písmena' },
{ op: 'upper', label: 'Na velká písmena' },
{ op: 'string', label: 'Převést na text' },
{ op: 'number', label: 'Převést na číslo' },
{ op: 'boolean', label: 'Převést na ano/ne' },
{
op: 'date',
label: 'Převést na datum',
params: [{ key: 'format', label: 'Tvar', kind: 'choice', options: ['iso', 'day'] }],
},
{
op: 'round',
label: 'Zaokrouhlit',
params: [{ key: 'decimals', label: 'Desetinná místa', kind: 'number', placeholder: '2' }],
},
{
op: 'multiply',
label: 'Vynásobit',
params: [{ key: 'by', label: 'Čím', kind: 'number', placeholder: '1.21' }],
},
{
op: 'add',
label: 'Přičíst',
params: [{ key: 'by', label: 'Kolik', kind: 'number', placeholder: '0' }],
},
{
op: 'default',
label: 'Když je prázdné, použít',
params: [{ key: 'value', label: 'Hodnota', kind: 'text' }],
},
{
op: 'replace',
label: 'Nahradit text',
params: [
{ key: 'find', label: 'Najít', kind: 'text' },
{ key: 'with', label: 'Čím', kind: 'text' },
],
},
{
op: 'slice',
label: 'Vzít část',
params: [
{ key: 'start', label: 'Od', kind: 'number', placeholder: '0' },
{ key: 'end', label: 'Do', kind: 'number' },
],
},
{
op: 'split',
label: 'Rozdělit na seznam',
params: [{ key: 'separator', label: 'Oddělovač', kind: 'text', placeholder: ',' }],
},
{
op: 'join',
label: 'Spojit seznam do textu',
params: [{ key: 'separator', label: 'Oddělovač', kind: 'text', placeholder: ', ' }],
},
{
op: 'sum',
label: 'Sečíst seznam',
params: [{ key: 'path', label: 'Cesta v položce', kind: 'text', placeholder: 'total' }],
},
{ op: 'count', label: 'Počet položek' },
];
function opLabel(op: string): string {
return opCatalog.find((item) => item.op === op)?.label ?? op;
}
/** Bezpecne rozparsuje ulozenou hodnotu. Rozbity JSON znamena prazdna pravidla. */
function parseRules(raw: string): { rules: MappingRule[]; broken: boolean } {
const text = raw.trim();
if (text === '') return { rules: [], broken: false };
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) return { rules: [], broken: true };
return { rules: parsed as MappingRule[], broken: false };
} catch {
return { rules: [], broken: true };
}
}
export function MappingEditor({
value,
onChange,
/** Parametry viditelne v tomhle kroku. Nabizeji se jako zdroj. */
sourceHints,
}: {
value: string;
onChange: (value: string) => void;
sourceHints: string[];
}) {
const { rules, broken } = useMemo(() => parseRules(value), [value]);
const [rawMode, setRawMode] = useState(broken);
function write(next: MappingRule[]) {
onChange(JSON.stringify(next, null, 2));
}
function updateRule(index: number, patch: Partial<MappingRule>) {
write(rules.map((rule, at) => (at === index ? { ...rule, ...patch } : rule)));
}
function removeRule(index: number) {
write(rules.filter((_rule, at) => at !== index));
}
function addRule() {
write([...rules, { to: '', from: '' }]);
}
if (rawMode) {
return (
<div className="mt-1 space-y-2">
<textarea
value={value}
rows={10}
onChange={(event) => onChange(event.target.value)}
spellCheck={false}
placeholder='[{ "to": "partnerId", "from": "customer.id" }]'
className={cn(
'w-full resize-y rounded-lg border bg-ink-950/70 px-3 py-2 font-mono text-xs text-white/85 focus:outline-none',
broken ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
{broken && <p className="text-xs text-warn-400">Tohle není platný JSON se seznamem pravidel.</p>}
<button
type="button"
onClick={() => setRawMode(false)}
disabled={broken}
className="text-xs text-brand-300 hover:text-brand-200 disabled:text-white/25"
>
Zpět na klikání
</button>
</div>
);
}
return (
<div className="mt-1 space-y-2">
{rules.length === 0 && (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2 text-xs text-white/35">
Žádné pravidlo. Bez pravidel by transformace nic nevytvořila.
</p>
)}
{rules.map((rule, index) => (
<RuleRow
key={index}
rule={rule}
sourceHints={sourceHints}
onChange={(patch) => updateRule(index, patch)}
onRemove={() => removeRule(index)}
/>
))}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={addRule}
className="flex items-center gap-1.5 rounded-lg border border-brand-400/40 px-2.5 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
>
<Plus className="size-3" />
Přidat pravidlo
</button>
<button
type="button"
onClick={() => setRawMode(true)}
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white"
>
<Braces className="size-3" />
Upravit jako JSON
</button>
</div>
</div>
);
}
function RuleRow({
rule,
sourceHints,
onChange,
onRemove,
}: {
rule: MappingRule;
sourceHints: string[];
onChange: (patch: Partial<MappingRule>) => void;
onRemove: () => void;
}) {
const fixed = rule.from === undefined;
const [adding, setAdding] = useState('');
const inputClass =
'w-full rounded-md border border-ink-600/70 bg-ink-900/70 px-2 py-1 text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
function addOp(op: string) {
if (op === '') return;
onChange({ transforms: [...(rule.transforms ?? []), { op }] });
setAdding('');
}
function updateOp(index: number, patch: Record<string, unknown>) {
onChange({
transforms: (rule.transforms ?? []).map((operation, at) =>
at === index ? { ...operation, ...patch } : operation,
),
});
}
function removeOp(index: number) {
onChange({ transforms: (rule.transforms ?? []).filter((_op, at) => at !== index) });
}
return (
<div className="rounded-lg border border-ink-600/50 bg-ink-900/40 p-2.5">
<div className="flex flex-wrap items-center gap-2">
{/* Zdroj: cesta ve vstupu, nebo pevna hodnota. */}
<select
value={fixed ? 'value' : 'from'}
onChange={(event) =>
event.target.value === 'from'
? onChange({ from: '', value: undefined })
: onChange({ from: undefined, value: '' })
}
className={cn(inputClass, 'w-auto')}
>
<option value="from" className="bg-ink-850">
cesta
</option>
<option value="value" className="bg-ink-850">
pevná hodnota
</option>
</select>
{fixed ? (
<input
value={String(rule.value ?? '')}
onChange={(event) => onChange({ value: event.target.value })}
placeholder="hodnota"
className={cn(inputClass, 'flex-1 min-w-32')}
/>
) : (
<input
value={rule.from ?? ''}
onChange={(event) => onChange({ from: event.target.value })}
placeholder="customer.email"
list="mapping-source-hints"
className={cn(inputClass, 'flex-1 min-w-32 font-mono')}
/>
)}
<ArrowRight className="size-3 shrink-0 text-white/30" />
<input
value={rule.to ?? ''}
onChange={(event) => onChange({ to: event.target.value })}
placeholder="partnerId"
className={cn(
inputClass,
'flex-1 min-w-32 font-mono',
(rule.to ?? '').trim() === '' && 'border-warn-400/60',
)}
/>
<button
type="button"
onClick={onRemove}
aria-label="Odebrat pravidlo"
className="grid size-6 shrink-0 place-items-center rounded text-white/35 transition-colors hover:bg-danger-500/10 hover:text-danger-400"
>
<Trash2 className="size-3" />
</button>
</div>
<datalist id="mapping-source-hints">
{sourceHints.map((hint) => (
<option key={hint} value={hint} />
))}
</datalist>
{(rule.transforms ?? []).length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-ink-600/40 pt-2">
{(rule.transforms ?? []).map((operation, index) => (
<OpRow
key={index}
operation={operation}
onChange={(patch) => updateOp(index, patch)}
onRemove={() => removeOp(index)}
/>
))}
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<select
value={adding}
onChange={(event) => addOp(event.target.value)}
className={cn(inputClass, 'w-auto')}
>
<option value="" className="bg-ink-850">
Přidat převod
</option>
{opCatalog.map((item) => (
<option key={item.op} value={item.op} className="bg-ink-850">
{item.label}
</option>
))}
</select>
<label className="flex items-center gap-1.5 text-xs text-white/45">
<input
type="checkbox"
checked={rule.required === true}
onChange={(event) => onChange({ required: event.target.checked || undefined })}
className="size-3"
/>
povinné
</label>
<label className="flex items-center gap-1.5 text-xs text-white/45">
<input
type="checkbox"
checked={rule.omitIfEmpty === true}
onChange={(event) => onChange({ omitIfEmpty: event.target.checked || undefined })}
className="size-3"
/>
vynechat když prázdné
</label>
</div>
</div>
);
}
function OpRow({
operation,
onChange,
onRemove,
}: {
operation: TransformOp;
onChange: (patch: Record<string, unknown>) => void;
onRemove: () => void;
}) {
const meta = opCatalog.find((item) => item.op === operation.op);
const inputClass =
'rounded-md border border-ink-600/70 bg-ink-900/70 px-2 py-0.5 text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
return (
<div className="flex flex-wrap items-center gap-2">
<Badge tone={meta ? 'neutral' : 'warn'}>{opLabel(operation.op)}</Badge>
{(meta?.params ?? []).map((param) =>
param.kind === 'choice' ? (
<select
key={param.key}
value={String(operation[param.key] ?? '')}
onChange={(event) => onChange({ [param.key]: event.target.value })}
className={cn(inputClass, 'w-auto')}
>
{(param.options ?? []).map((option) => (
<option key={option} value={option} className="bg-ink-850">
{option}
</option>
))}
</select>
) : (
<input
key={param.key}
type={param.kind === 'number' ? 'number' : 'text'}
value={String(operation[param.key] ?? '')}
placeholder={param.placeholder ?? param.label}
onChange={(event) =>
onChange({
[param.key]:
param.kind === 'number'
? event.target.value === ''
? undefined
: Number(event.target.value)
: event.target.value,
})
}
title={param.label}
className={cn(inputClass, 'w-24')}
/>
),
)}
{!meta && <span className="text-xs text-warn-400">neznámý převod</span>}
<button
type="button"
onClick={onRemove}
aria-label="Odebrat převod"
className="grid size-5 place-items-center rounded text-white/30 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3" />
</button>
</div>
);
}
@@ -1,10 +1,28 @@
import { AlertTriangle, Braces } from 'lucide-react';
import { useRef, useState } from 'react';
import { MappingEditor } from '@/components/dashboard/flow/MappingEditor';
import { Badge } from '@/components/ui/Badge';
import { brokenReferences } from '@/lib/flow';
import { cn } from '@/lib/cn';
import type { OperationField, TriggerField } from '@/types/dashboard';
/** Parametry, ktere nesou celou strukturu. Jen ty jde predat do pole typu objekt. */
function structuredFields(available: TriggerField[]): TriggerField[] {
return available.filter((field) => field.type === 'object' || field.type === 'list');
}
/** Hlaseni o rozbitem JSONu. Prazdny retezec znamena v poradku. */
function jsonProblem(value: string): string | null {
const text = value.trim();
if (text === '') return null;
try {
JSON.parse(text);
return null;
} catch (err) {
return err instanceof Error ? err.message : 'Neplatný JSON.';
}
}
/**
* Nastaveni akce: co se ma kam ulozit.
*
@@ -71,7 +89,12 @@ export function StepInputs({
{fields.map((field) => {
const value = values[field.id] ?? '';
const missing = field.required && value.trim().length === 0;
const broken = field.kind === 'choice' ? [] : brokenReferences(value, available);
const plainTemplate =
field.kind !== 'choice' &&
field.kind !== 'mapping' &&
field.kind !== 'json' &&
field.kind !== 'object';
const broken = plainTemplate ? brokenReferences(value, available) : [];
return (
<div key={field.id}>
@@ -84,7 +107,31 @@ export function StepInputs({
{missing && <Badge tone="warn">Chybí</Badge>}
</label>
{field.kind === 'choice' ? (
{field.kind === 'mapping' ? (
<MappingEditor
value={value}
onChange={(next) => set(field.id, next)}
// Cesty se pisou do zdrojoveho objektu, takze napoveda jsou
// jmena strukturovanych parametru viditelnych v tomhle kroku.
sourceHints={structuredFields(available).map((item) => item.name)}
/>
) : field.kind === 'json' ? (
<JsonField
id={`input-${field.id}`}
value={value}
missing={missing}
onChange={(next) => set(field.id, next)}
hint={field.hint}
/>
) : field.kind === 'object' ? (
<ObjectField
id={`input-${field.id}`}
value={value}
missing={missing}
options={structuredFields(available)}
onChange={(next) => set(field.id, next)}
/>
) : field.kind === 'choice' ? (
<select
id={`input-${field.id}`}
value={value}
@@ -216,3 +263,104 @@ function ParameterChips({
</div>
);
}
/**
* Pole s JSONem. Chyba se hlasi hned pri psani, ne az pri ulozeni - preklep
* v zavorce je jinak videt teprve z padleho behu.
*/
function JsonField({
id,
value,
missing,
onChange,
hint,
}: {
id: string;
value: string;
missing: boolean;
onChange: (value: string) => void;
hint?: string;
}) {
const problem = jsonProblem(value);
return (
<div className="mt-1">
<textarea
id={id}
value={value}
rows={8}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
placeholder={hint ?? '{ "pole": "${cesta.ve.zdroji}" }'}
className={cn(
'w-full resize-y rounded-lg border bg-ink-950/70 px-3 py-2 font-mono text-xs text-white/85 focus:outline-none',
missing || problem ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
{problem && (
<p className="mt-1 flex items-start gap-1.5 text-xs text-warn-400">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
Neplatný JSON: {problem}
</p>
)}
<p className="mt-1 text-xs text-white/35">
Hodnoty ze zdroje se vkládají jako <code>{'${cesta}'}</code>. Jiný zápis než{' '}
<code>{'{{parametr}}'}</code> je záměr: šablony kroku se dosazují dřív, než krok
běží, a stihly by tenhle text přepsat.
</p>
</div>
);
}
/**
* Vyber strukturovaneho parametru z predchozich kroku.
*
* Do objektu se nedosazuje sablonou, predava se cely. Proto vyber a ne textove
* pole - psat `{{order}}` rucne by vypadalo stejne jako u skalaru, ale znamenalo
* by neco jineho.
*/
function ObjectField({
id,
value,
missing,
options,
onChange,
}: {
id: string;
value: string;
missing: boolean;
options: TriggerField[];
onChange: (value: string) => void;
}) {
if (options.length === 0) {
return (
<p className="mt-1 rounded-lg border border-dashed border-warn-400/50 px-3 py-2 text-xs text-warn-400">
Sem zatím nedoteče žádný objekt ani seznam. Potřebujete krok, který strukturu
vrátí - třeba spouštěč e-shopu nebo jinou transformaci.
</p>
);
}
return (
<select
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
'mt-1 w-full rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:outline-none',
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
>
{value === '' && (
<option value="" className="bg-ink-850">
Vyberte objekt
</option>
)}
{options.map((option) => (
<option key={option.id} value={`{{${option.name}}}`} className="bg-ink-850">
{option.name} ({option.type === 'list' ? 'seznam' : 'objekt'})
</option>
))}
</select>
);
}
+5
View File
@@ -63,6 +63,9 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
boolean: ['isTrue', 'isFalse'],
date: ['eq', 'gt', 'lt'],
// Nad strukturou ma smysl jen to, jestli vubec neco prisla.
object: ['isEmpty', 'isNotEmpty'],
list: ['isEmpty', 'isNotEmpty'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
@@ -113,6 +116,8 @@ export const fieldTypeLabels: Record<FieldType, string> = {
number: 'číslo',
boolean: 'ano/ne',
date: 'datum',
object: 'objekt',
list: 'seznam',
};
/** Lidsky citelny zapis podminky, napr. „score je větší nebo rovno 15". */
+410 -280
View File
@@ -1,10 +1,21 @@
import { AlertTriangle, CheckCircle2, Plus, Star, Trash2, Wifi } from 'lucide-react';
import {
AlertTriangle,
CheckCircle2,
Eye,
EyeOff,
Pencil,
Plus,
Star,
Trash2,
Wifi,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
@@ -20,9 +31,12 @@ import type {
/**
* Konektory: napojeni teto firmy na sluzby.
*
* Pristupove udaje se zadavaji tady, ne v prostredi aplikace - kazda firma ma
* svoje. Tajne hodnoty server nikdy nevraci, takze u vyplneneho pole je videt
* jen to, ze vyplnene je. Viz documentation/12-sluzby-a-konektory.md.
* Na strance jsou jen male karty, zakladani a uprava jsou v dialogu. Formulare
* rozlozene po strance byly neprehledne - u pet konektoru je na obrazovce pet
* sad poli a nikdo nepozna, ke kteremu co patri.
*
* Pristupove udaje se zadavaji tady, ne v prostredi aplikace. Kazda firma ma
* svoje. Viz documentation/12-sluzby-a-konektory.md.
*/
export default function Connectors() {
usePageMeta({ title: 'Konektory - portál Automia' });
@@ -34,7 +48,11 @@ export default function Connectors() {
const [connectors, setConnectors] = useState<Connector[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [creatingFor, setCreatingFor] = useState<string | null>(preselected);
/** Co je otevrene v dialogu. null = zavreno. */
const [editor, setEditor] = useState<{ mode: 'create'; serviceId: string } | { mode: 'edit'; connector: Connector } | null>(
preselected ? { mode: 'create', serviceId: preselected } : null,
);
const load = useCallback(() => {
setLoading(true);
@@ -70,8 +88,8 @@ export default function Connectors() {
[services],
);
function closeForm() {
setCreatingFor(null);
function closeEditor() {
setEditor(null);
if (preselected) setParams({});
}
@@ -81,16 +99,12 @@ export default function Connectors() {
<div>
<h1 className="text-2xl font-bold text-white">Konektory</h1>
<p className="mt-1 max-w-2xl text-sm text-white/50">
Napojení této firmy na jednotlivé služby. Přístupové údaje zadáváte tady a
zůstávají na serveru, prohlížeč je nikdy nedostane zpátky.
Napojení této firmy na jednotlivé služby. Přístupové údaje zůstávají na
serveru, prohlížeč je nikdy nedostane zpátky.
</p>
</div>
{connectable.length > 0 && (
<Button
size="sm"
onClick={() => setCreatingFor(connectable[0].id)}
disabled={creatingFor !== null}
>
<Button size="sm" onClick={() => setEditor({ mode: 'create', serviceId: connectable[0].id })}>
<Plus className="size-4" />
Nový konektor
</Button>
@@ -98,192 +112,60 @@ export default function Connectors() {
</header>
<DataState loading={loading} error={error} onRetry={load}>
<div className="space-y-5">
{creatingFor && (
<CreateForm
services={connectable}
initialServiceId={creatingFor}
onCancel={closeForm}
onCreated={() => {
closeForm();
load();
}}
/>
)}
{(connectors?.length ?? 0) === 0 && !creatingFor ? (
<p className="py-10 text-center text-sm text-white/45">
Zatím žádný konektor. Vyberte službu na stránce Služby, nebo použijte
tlačítko Nový konektor.
</p>
) : (
<div className="space-y-4">
{(connectors ?? []).map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
service={servicesById.get(connector.serviceId)}
onChanged={load}
/>
))}
</div>
)}
</div>
</DataState>
</div>
);
}
// ------------------------------------------------------------------ zakladani
function CreateForm({
services,
initialServiceId,
onCancel,
onCreated,
}: {
services: Service[];
initialServiceId: string;
onCancel: () => void;
onCreated: () => void;
}) {
const [serviceId, setServiceId] = useState(
services.some((service) => service.id === initialServiceId)
? initialServiceId
: (services[0]?.id ?? ''),
);
const service = services.find((item) => item.id === serviceId);
const [name, setName] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Nazev se predvyplni podle sluzby, at ho nikdo nemusi vymyslet.
useEffect(() => {
setName(service ? service.name : '');
setValues({});
}, [service]);
async function handleCreate() {
if (!service) return;
setSaving(true);
setError(null);
try {
await apiFetch('/api/dashboard/connectors', {
method: 'POST',
body: { serviceId: service.id, name: name.trim() || service.name, values },
});
onCreated();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Konektor se nepodařilo založit.');
} finally {
setSaving(false);
}
}
return (
<div className="glass rounded-card border border-brand-400/30 p-5">
<p className="mb-4 text-xs font-semibold tracking-wide text-white/45 uppercase">
Nový konektor
</p>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Služba">
<select
value={serviceId}
onChange={(event) => setServiceId(event.target.value)}
className={inputClass}
>
{services.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
{(connectors?.length ?? 0) === 0 ? (
<p className="py-10 text-center text-sm text-white/45">
Zatím žádný konektor. Vyberte službu na stránce Služby, nebo použijte
tlačítko Nový konektor.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{(connectors ?? []).map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
service={servicesById.get(connector.serviceId)}
onEdit={() => setEditor({ mode: 'edit', connector })}
onChanged={load}
/>
))}
</select>
</Field>
</div>
)}
</DataState>
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
<input
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
/>
</Field>
</div>
{service && service.credentials.length > 0 && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{service.credentials.map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={false}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
)}
{service && service.credentials.length === 0 && (
<p className="mt-4 text-sm text-white/45">
U služby {service.name} ještě nejsou popsané přístupové údaje. Konektor půjde
založit, ale volat se bude umět až spolu se službou.
</p>
)}
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<div className="mt-5 flex gap-3">
<Button size="sm" onClick={handleCreate} disabled={saving || !service}>
{saving ? 'Zakládám...' : 'Založit'}
</Button>
<Button size="sm" variant="secondary" onClick={onCancel} disabled={saving}>
Zrušit
</Button>
</div>
<ConnectorEditor
editor={editor}
services={connectable}
servicesById={servicesById}
onClose={closeEditor}
onSaved={() => {
closeEditor();
load();
}}
/>
</div>
);
}
// --------------------------------------------------------------------- detail
// ----------------------------------------------------------------- mala karta
function ConnectorCard({
connector,
service,
onEdit,
onChanged,
}: {
connector: Connector;
service: Service | undefined;
onEdit: () => void;
onChanged: () => void;
}) {
const Icon = serviceIcon(service?.icon ?? 'Plug');
const [values, setValues] = useState<Record<string, string>>({});
const [name, setName] = useState(connector.name);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [busy, setBusy] = useState(false);
const [test, setTest] = useState<ConnectorTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const dirty = Object.keys(values).length > 0 || name !== connector.name;
async function patch(body: Record<string, unknown>) {
setSaving(true);
setError(null);
try {
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'PATCH', body });
setValues({});
setTest(null);
onChanged();
} catch (err: unknown) {
setError(err instanceof ApiError ? err.message : 'Uložení selhalo.');
} finally {
setSaving(false);
}
}
async function handleTest() {
setTesting(true);
setBusy(true);
setError(null);
try {
setTest(
@@ -295,140 +177,88 @@ function ConnectorCard({
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ověření se nepodařilo odeslat.');
} finally {
setTesting(false);
setBusy(false);
}
}
async function handleDelete() {
setSaving(true);
setBusy(true);
try {
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'DELETE' });
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
setSaving(false);
setBusy(false);
}
}
return (
<article className="glass rounded-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
{connector.name}
{connector.isDefault && (
<Star className="size-3.5 text-brand-300" aria-label="Výchozí konektor" />
)}
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
</h2>
<p className="mt-0.5 text-sm text-white/45">
{service?.name ?? connector.serviceId}
{connector.baseUrl ? ` - ${connector.baseUrl}` : ''}
</p>
</div>
<article className="glass flex h-full flex-col rounded-card p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="flex flex-wrap items-center gap-1.5 truncate font-semibold text-white">
{connector.name}
{connector.isDefault && (
<Star className="size-3 shrink-0 text-brand-300" aria-label="Výchozí konektor" />
)}
</h2>
<p className="truncate text-xs text-white/40">{service?.name ?? connector.serviceId}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge connector={connector} />
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
</div>
{connector.missing.length > 0 && service && (
<p className="mt-3 text-sm text-warn-400">
Chybí vyplnit:{' '}
<p className="mt-2 text-xs text-warn-400">
Chybí:{' '}
{connector.missing
.map((id) => service.credentials.find((field) => field.id === id)?.label ?? id)
.join(', ')}
. Dokud to nevyplníte, kroky s touhle službou nepoběží.
</p>
)}
{connector.lastError && (
<p className="mt-3 flex items-start gap-2 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{connector.lastError}
</p>
{connector.lastError && !test && (
<p className="mt-2 line-clamp-3 text-xs text-danger-400">{connector.lastError}</p>
)}
<div className="mt-4 grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<Field label="Název">
<input
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
/>
</Field>
{(service?.credentials ?? []).map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={connector.filled.includes(field.id)}
existing={connector.config[field.id]}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
{test && (
<p
className={cn(
'mt-3 flex items-start gap-2 rounded-lg p-3 text-sm',
test.ok
? 'border border-ok-500/40 bg-ok-500/8 text-ok-400'
: 'border border-danger-500/40 bg-danger-500/8 text-danger-400',
'mt-2 flex items-start gap-1.5 text-xs',
test.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{test.ok ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
<CheckCircle2 className="mt-0.5 size-3 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
)}
<span>
{test.message}
<span className="mt-0.5 block text-xs text-white/40">Ověřeno: {test.checked}</span>
<span className="block text-white/35">Ověřeno: {test.checked}</span>
</span>
</p>
)}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button
size="sm"
onClick={() => patch({ name: name.trim(), values })}
disabled={!dirty || saving}
>
{saving ? 'Ukládám...' : 'Uložit'}
{error && <p className="mt-2 text-xs text-danger-400">{error}</p>}
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-4">
<Button size="sm" variant="secondary" onClick={onEdit} disabled={busy}>
<Pencil className="size-3.5" />
Upravit
</Button>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={testing || saving}>
<Wifi className="size-4" />
{testing ? 'Ověřuji...' : 'Ověřit napojení'}
<Button size="sm" variant="ghost" onClick={handleTest} disabled={busy}>
<Wifi className="size-3.5" />
{busy ? 'Ověřuji...' : 'Ověřit'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => patch({ enabled: !connector.enabled })}
disabled={saving}
>
{connector.enabled ? 'Vypnout' : 'Zapnout'}
</Button>
{!connector.isDefault && (
<Button
size="sm"
variant="ghost"
onClick={() => patch({ isDefault: true })}
disabled={saving}
>
<Star className="size-4" />
Nastavit jako výchozí
</Button>
)}
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
<Trash2 className="size-4" />
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={busy}>
<Trash2 className="size-3.5" />
Smazat
</Button>
</div>
@@ -443,6 +273,277 @@ function StatusBadge({ connector }: { connector: Connector }) {
return <Badge tone="neutral">Neověřeno</Badge>;
}
// --------------------------------------------------------------------- dialog
type EditorState =
| { mode: 'create'; serviceId: string }
| { mode: 'edit'; connector: Connector }
| null;
function ConnectorEditor({
editor,
services,
servicesById,
onClose,
onSaved,
}: {
editor: EditorState;
services: Service[];
servicesById: Map<string, Service>;
onClose: () => void;
onSaved: () => void;
}) {
const creating = editor?.mode === 'create';
const existing = editor?.mode === 'edit' ? editor.connector : null;
const [serviceId, setServiceId] = useState('');
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
const service = servicesById.get(serviceId) ?? services.find((item) => item.id === serviceId);
// Pri otevreni dialogu se formular naplni podle toho, co se upravuje.
// Zavislost je na `editor`, ne na `service` - jinak by prepnuti sluzby
// v zakladani smazalo uz vyplnena pole.
useEffect(() => {
if (!editor) return;
if (editor.mode === 'create') {
setServiceId(editor.serviceId);
setName(servicesById.get(editor.serviceId)?.name ?? '');
setBaseUrl('');
} else {
setServiceId(editor.connector.serviceId);
setName(editor.connector.name);
setBaseUrl(editor.connector.baseUrl ?? '');
}
setValues({});
setError(null);
setIssues([]);
}, [editor, servicesById]);
async function handleSave() {
setSaving(true);
setError(null);
setIssues([]);
// Posilaji se jen vyplnena pole. Prazdne u tajneho pole znamena
// "nech jak je", ne "smaz" - proto se prazdne klice zahazuji.
const changed = Object.fromEntries(
Object.entries(values).filter(([, value]) => value.trim() !== ''),
);
try {
if (creating) {
await apiFetch('/api/dashboard/connectors', {
method: 'POST',
body: {
serviceId,
name: name.trim() || (service?.name ?? serviceId),
baseUrl: baseUrl.trim() === '' ? null : baseUrl.trim(),
values: changed,
},
});
} else if (existing) {
await apiFetch(`/api/dashboard/connectors/${existing.id}`, {
method: 'PATCH',
body: {
name: name.trim(),
baseUrl: baseUrl.trim() === '' ? null : baseUrl.trim(),
values: changed,
},
});
}
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
async function toggleEnabled() {
if (!existing) return;
setSaving(true);
try {
await apiFetch(`/api/dashboard/connectors/${existing.id}`, {
method: 'PATCH',
body: { enabled: !existing.enabled },
});
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
setSaving(false);
}
}
async function makeDefault() {
if (!existing) return;
setSaving(true);
try {
await apiFetch(`/api/dashboard/connectors/${existing.id}`, {
method: 'PATCH',
body: { isDefault: true },
});
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
setSaving(false);
}
}
return (
<Modal
open={editor !== null}
onClose={onClose}
title={creating ? 'Nový konektor' : 'Upravit konektor'}
description={
creating
? 'Vyberte službu a vyplňte přístupové údaje. Zůstanou na serveru.'
: 'Prázdné pole u tajného údaje znamená, že se nezmění.'
}
>
{/*
Vlastni <form> s vypnutym doplnovanim. Bez toho prohlizec do poli
typu password dosazuje ulozene prihlaseni, uzivatel vidi jednu hodnotu
a React drzi jinou - a ulozi se to, co drzi React, tedy nic.
*/}
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
className="space-y-4 p-5"
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Služba">
{creating ? (
<select
value={serviceId}
onChange={(event) => {
setServiceId(event.target.value);
setName(servicesById.get(event.target.value)?.name ?? '');
setValues({});
}}
className={inputClass}
>
{services.map((item) => (
<option key={item.id} value={item.id} className="bg-ink-850">
{item.name}
</option>
))}
</select>
) : (
<p className="rounded-lg border border-ink-600/50 bg-ink-950/40 px-3 py-2 text-sm text-white/60">
{service?.name ?? serviceId}
</p>
)}
</Field>
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
<input
name="connector-label"
autoComplete="off"
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
/>
</Field>
</div>
{service && service.credentials.length > 0 ? (
<div className="grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
{service.credentials.map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={existing?.filled.includes(field.id) ?? false}
existing={existing?.config[field.id]}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
) : (
<p className="border-t border-ink-600/50 pt-4 text-sm text-white/45">
U služby {service?.name ?? serviceId} ještě nejsou popsané přístupové údaje.
Konektor půjde založit, ale volat se bude umět až spolu se službou.
</p>
)}
<Field
label="Vlastní adresa služby"
hint="Nechte prázdné, pokud nemáte vlastní instanci."
>
<input
name="connector-endpoint"
autoComplete="off"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="Výchozí podle služby"
className={inputClass}
/>
</Field>
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{error}</p>
{issues.length > 0 && (
<ul className="mt-1.5 ml-4 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving || !service}>
{saving ? 'Ukládám...' : creating ? 'Založit' : 'Uložit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
{existing && (
<>
<span className="flex-1" />
{!existing.isDefault && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={makeDefault}
disabled={saving}
>
<Star className="size-3.5" />
Nastavit jako výchozí
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={toggleEnabled}
disabled={saving}
>
{existing.enabled ? 'Vypnout' : 'Zapnout'}
</Button>
</>
)}
</div>
</form>
</Modal>
);
}
// ------------------------------------------------------------------ formulare
const inputClass =
@@ -469,8 +570,15 @@ function Field({
/**
* Pole pristupoveho udaje.
*
* U tajneho pole server hodnotu nevraci, takze se ukazuje jen to, ze vyplnene
* je. Prazdne pole hodnotu nemeni - ulozeni formulare tedy nic nepresepe.
* Tri veci, ktere tady nejsou kosmetika:
*
* 1. `autoComplete="new-password"` u tajneho pole. Prohlizec `off` u hesel
* ignoruje a dosazuje ulozene prihlaseni. Vznikne tim stav, kdy uzivatel
* vidi jednu hodnotu a React drzi jinou, a ulozi se ta z Reactu, tedy nic.
* 2. `name`, ktere nepripomina heslo ani uzivatele. Podle nej se prohlizec
* rozhoduje, co doplnit.
* 3. Prepinac zobrazeni. Uzivatel musi mit jak overit, ze zapsal to, co chtel -
* bez toho se spatne zadany klic pozna az z neuspesneho overeni.
*/
function CredentialInput({
field,
@@ -485,6 +593,8 @@ function CredentialInput({
existing?: string;
onChange: (value: string) => void;
}) {
const [visible, setVisible] = useState(false);
const placeholder = field.secret
? filled
? 'Vyplněno, prázdné pole hodnotu nezmění'
@@ -499,14 +609,34 @@ function CredentialInput({
{field.secret && <Badge tone="neutral">tajné</Badge>}
{filled && <Badge tone="ok">vyplněno</Badge>}
</span>
<input
type={field.secret ? 'password' : 'text'}
autoComplete="off"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={inputClass}
/>
<div className="relative">
<input
// Jmeno zamerne nepripomina heslo, jinak do nej prohlizec doplnuje.
name={`svc-${field.id}`}
type={field.secret && !visible ? 'password' : 'text'}
autoComplete={field.secret ? 'new-password' : 'off'}
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={cn(inputClass, field.secret && 'pr-10')}
/>
{field.secret && (
<button
type="button"
onClick={() => setVisible((previous) => !previous)}
className="absolute top-1/2 right-2 grid size-7 -translate-y-1/2 place-items-center rounded text-white/40 transition-colors hover:text-white"
aria-label={visible ? 'Skrýt hodnotu' : 'Zobrazit hodnotu'}
tabIndex={-1}
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
)}
</div>
<span className="mt-1 block text-xs text-white/35">
{field.hint ??
`Posílá se jako ${field.target === 'header' ? 'hlavička' : 'nastavení'} ${field.name}.`}
+14 -2
View File
@@ -223,7 +223,11 @@ export interface ServiceOperation {
export interface OperationField {
id: string;
label: string;
kind: 'text' | 'longtext' | 'choice';
/**
* `json` a `mapping` nesou strukturu zapsanou jako JSON v retezci.
* `object` je odkaz na parametr typu objekt nebo seznam.
*/
kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
@@ -307,7 +311,15 @@ export interface ServiceCatalog {
// ------------------------------------------------- parametry a podminky
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
/**
* `object` a `list` jsou cele struktury, ne jednotlive hodnoty. Do sablony se
* nedosazuji, predavaji se jen jako celek dalsimu kroku.
* POZOR: stejna tabulka je na serveru v src/data/conditions.ts.
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */
export const declarableFieldTypes = ['string', 'number', 'boolean', 'date'] as const;
export type ConditionOperator =
| 'eq'