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:
co-authored by
Claude Opus 5
parent
8ad91a6c28
commit
ad56c7f513
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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}.`}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user