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
+1
View File
@@ -32,6 +32,7 @@ React aplikaci ze slozky `dist/public`.
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury |
| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
+203
View File
@@ -0,0 +1,203 @@
# 13 - Transformace dat
Naprogramovano. Souvisi se skripty ([11](11-skripty-konektoru.md))
a se sluzbami ([12](12-sluzby-a-konektory.md)).
## K cemu to je
Prijde objednavka z e-shopu a do iDokladu je potreba uplne jiny objekt. Jmena
poli nesedi, castky jsou textem, polozky maji jinou strukturu. Mezi tim musi byt
krok, ktery rekne, co se kam prepise a jak se to po ceste prevede.
```
Nova objednavka (e-shop) -> objekt "order"
Transformace dat -> objekt "result" ve tvaru dokladu
Vystavit fakturu z objektu -> iDoklad
```
## Kroky si predavaji i cele struktury
Dosud si kroky predavaly jen jednotlive hodnoty. Pribyly dva typy parametru:
| Typ | Co to je | Do sablony | Podminka |
| -------- | --------------------------- | ---------- | ----------------- |
| `object` | cely objekt | ne | je / neni prazdny |
| `list` | seznam | ne | je / neni prazdny |
**Do sablony se nedosazuji.** `{{order}}` v textu by znamenalo vlozit do vety
kus JSONu, coz nikdo nechce. Struktura se predava **jako celek** dalsimu kroku,
proto je u ni v builderu vyber a ne textove pole.
Nad strukturou ma z podminek smysl jen "prisla / neprisla". Porovnavat dva
objekty by znamenalo urcit, co je "stejny", a to zavisi na pripadu.
**Strop na velikost.** Struktura nad `SCRIPT_MAX_VALUE_BYTES` (vychozi 256 kB)
neprojde kontrolou. Radek s vystupem kroku je nejrychleji rostouci tabulka
v systemu (viz [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md)), takze
hranice patri do kontroly parametru, ne az do uklidu databaze.
## Dva rezimy
Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`.
Volba je o tom, cehoz je vic:
| Rezim | Kdy | Skript |
| ------------------------- | -------------------------------------- | ----------------------- |
| **Pravidla** (pole na pole) | hlavni prace je v prevodech hodnot | `transform.map-fields` |
| **Sablona JSON** | hlavni prace je ve tvaru struktury | `transform.to-json` |
### Rezim 1: pravidla
Jedno pravidlo je "vezmi tuhle cestu, projed prevody, uloz sem".
```json
[
{ "to": "partnerId", "from": "customer.idokladId", "required": true },
{ "to": "description", "from": "number", "transforms": [{ "op": "string" }] },
{ "to": "dateOfIssue", "from": "createdAt", "transforms": [{ "op": "date", "format": "day" }] },
{ "to": "variableSymbol", "from": "number", "omitIfEmpty": true },
{
"to": "items",
"from": "items",
"transforms": [
{
"op": "map",
"rules": [
{ "to": "name", "from": "title", "required": true },
{ "to": "amount", "from": "quantity", "transforms": [{ "op": "number" }] },
{ "to": "unitPrice", "from": "priceWithoutVat", "transforms": [{ "op": "number" }, { "op": "round", "decimals": 2 }] },
{ "to": "unit", "value": "ks" },
{ "to": "priceType", "value": 0 },
{ "to": "vatRateType", "value": 0 },
{ "to": "discountPercentage", "value": 0 },
{ "to": "isTaxMovement", "value": false }
]
}
]
}
]
```
Prevod `map` je to, bez ceho by priklad nesel dokoncit. Bez nej by slo prevest
hlavicku dokladu, ale ne seznam polozek, a doklad by byl na nulu.
| Klic | K cemu |
| ------------- | --------------------------------------------------------- |
| `to` | kam se ulozi. Tecka znamena zanoreni: `partner.id` |
| `from` | cesta ve zdroji. `items.0.name` i `items[0].name` |
| `value` | pevna hodnota, kdyz `from` chybi |
| `transforms` | prevody v uvedenem poradi |
| `fallback` | pouzije se, kdyz je vysledek prazdny |
| `omitIfEmpty` | prazdny vysledek se do vystupu vubec nezapise |
| `required` | prazdny vysledek je chyba |
`required` neni formalita. Doklad bez `partnerId` iDoklad odmitne, a je lepsi
to poznat na kroku transformace s nazvem pole, nez z odpovedi 400 od iDokladu.
### Rezim 2: sablona JSON
Napise se cely cilovy objekt a na mistech hodnot se odkaze pres `${cesta}`:
```json
{
"partnerId": "${customer.idokladId}",
"description": "Objednávka ${number}",
"dateOfIssue": "${createdAt}",
"items": [{ "name": "Zboží dle objednávky", "unitPrice": "${total}", "amount": 1 }]
}
```
Dve veci, ktere v tom rezimu rozhoduji:
**Marker je `${...}`, ne `{{...}}`.** Sablony kroku se dosazuji driv, nez se
krok spusti. `{{total}}` by tedy stihl vyhodnotit strom, zadny parametr toho
jmena by nenasel a dosadil by prazdno. Rozdilny marker to nemuze splest.
**Cely retezec zachova typ.** `"unitPrice": "${total}"` vyrobi **cislo**, ne
text. Bez toho by cizi sluzba dostala castku jako retezec a vetsina jich to
odmitne. Uvnitř delsiho textu (`"Objednávka ${number}"`) se naopak vklada
jako text, protoze jinak to nedava smysl.
## Prevody
| Prevod | Co dela |
| ----------------------------- | ---------------------------------------- |
| `trim`, `lower`, `upper` | uprava textu |
| `string`, `number`, `boolean` | zmena typu |
| `date` (`iso` / `day`) | datum, `day` je jen `YYYY-MM-DD` |
| `round` (`decimals`) | zaokrouhleni |
| `multiply`, `add` (`by`) | pocty, napriklad prevod na cenu s DPH |
| `default` (`value`) | vyplneni prazdne hodnoty |
| `replace` (`find`, `with`) | nahrazeni textu |
| `slice` (`start`, `end`) | cast textu nebo seznamu |
| `split`, `join` (`separator`) | text na seznam a zpatky |
| `sum` (`path`) | soucet pres seznam |
| `count` | pocet polozek |
| `map` (`rules`) | kazdou polozku seznamu podle vlastnich pravidel |
Sada je zamerne **uzavrena**. Volny vyraz by z transformace udelal dalsi jazyk
k ladeni a hlavne by to byl kod bez sandboxu na miste, kde ho nikdo neceka.
Kdo potrebuje vic, napise skript.
## Kontrola
| Kdy | Co se overi | Vysledek |
| ----------------- | ---------------------------------------------------- | --------- |
| Pri psani | JSON se parsuje, chyba se ukaze hned pod polem | jen v UI |
| Pri ulozeni stromu| JSON a tvar pravidel (`to`, `from`/`value`, `op`) | nedodelek |
| Pri behu | typy, `required`, prazdne hodnoty, hloubka zanoreni | chyba behu|
Rozbite pravidlo je **nedodelek**, ne chyba ukladani. Rozdelana prace se
nezahazuje, jen automatizace nepujde zapnout. Stejny rezim jako u ostatnich
nedodelku, viz [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
Neznamy prevod je pri behu chyba, ne preskoceni. Tise ho ignorovat by znamenalo
odeslat neprevedena data a divit se az u odberatele.
## Kde to je
```
src/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona
scripts/transform.map-fields.js rezim 1
scripts/transform.to-json.js rezim 2
scripts/idoklad.create-invoice-from-object.js druha polovina prikladu
web/src/components/dashboard/flow/MappingEditor.tsx klikaci editor pravidel
web/src/components/dashboard/flow/StepInputs.tsx pole typu mapping, json a object
```
Engine je na serveru a skripty ho dostanou na `ctx.util` jako `get`, `applyRules`
a `fillJson`. Nemuze byt v souboru skriptu, protoze skripty nic neimportuji -
a nema tam byt, protoze by ho jinak mel kazdy skript ve vlastni verzi.
## Cely priklad
```
Spoustec: Nova objednavka (e-shop)
vystupy: orderNumber, orderTotal, customerEmail, order (objekt), items (seznam)
Krok 1: Premapovat pole
Zdrojova data: order
Pravidla: viz vyse
vystup: result (objekt), fieldCount
Krok 2: Vystavit fakturu z objektu (iDoklad)
Telo dokladu: result
vystupy: invoiceId, documentNumber, totalWithVat, itemCount, invoice
Krok 3: Odeslat fakturu e-mailem (iDoklad)
ID faktury: {{invoiceId}}
E-mail: {{customerEmail}}
```
Krok 2 doplni z `GET /issued-invoices/default` povinna pole, ktera z objednavky
nikdy neprijdou (`documentSerialNumber`, `isEet`, `isIncomeTax`). Objekt
z transformace ho prepise jen tam, kde neco rika.
## Co chybi
| Chybi | Poznamka |
| --------------------------- | ------------------------------------------------------- |
| Nahled transformace | pravidla jde zkusit jen pres test skriptu |
| Vnorena pravidla klikanim | prevod `map` se upravuje v rezimu JSON |
| Napoveda cest ze skutecnych dat | nabizeji se jmena parametru, ne cesty uvnitr objektu |
| Vykonavani ze stromu | runner je hotovy, runtime automatizaci ne |
+38
View File
@@ -2,6 +2,44 @@
Nejnovejsi nahore.
## 2026-08-12 - transformace dat a oprava konektoru
Transformace dat popsana v [13-transformace-dat.md](13-transformace-dat.md).
### Pridano
- 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.
- `src/scripts/mapping.ts`: cesty, prevody, pravidla a sablona JSON.
Skripty ho dostanou na `ctx.util` jako `get`, `applyRules` a `fillJson`.
- Dva rezimy transformace: `transform.map-fields` (pole na pole s prevody)
a `transform.to-json` (sablona cileveho objektu s `${cesta}`).
V sablone je marker `${...}` zamerne jiny nez `{{...}}`: sablony kroku se
dosazuji driv, nez krok bezi, a stihly by ji prepsat.
- Prevod `map` pro seznamy. Bez nej by slo prevest hlavicku dokladu, ale ne
polozky objednavky, a doklad by byl na nulu.
- `idoklad.create-invoice-from-object`: druha polovina prikladu, bere hotove
telo dokladu z transformace.
- Spoustec e-shopu predava celou objednavku jako objekt a polozky jako seznam.
- Klikaci editor pravidel v builderu vcetne rezimu JSON pro vnorena pravidla.
- 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, 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.
## 2026-08-12 - sluzby a konektory
Rozdeleni na sluzbu a konektor. Popis
@@ -0,0 +1,88 @@
/**
* iDoklad: vystaveni faktury z hotoveho objektu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-invoices/default, POST /issued-invoices
*
* Druha polovina prikladu z transformace: prijde objednavka z e-shopu,
* `transform.map-fields` z ni udela telo dokladu a tenhle krok ho odesle.
*
* Rozdil proti `idoklad.create-issued-invoice`: ta akce ma pole na kliknuti
* a umi jednu polozku. Tahle bere cely objekt, takze zvladne vic polozek
* a cokoliv dalsiho, co iDoklad prijima. Cenou je, ze tvar tela musi znat ten,
* kdo stavi transformaci.
*
* Vzor z `/default` se poradi i tady: nese povinna pole, ktera nikdo vyplnovat
* nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`). Objekt z transformace
* ho jen prepise tam, kde neco rika.
*/
export const manifest = {
id: 'idoklad.create-invoice-from-object',
name: 'Vystavit fakturu z objektu',
description:
'Odešle do iDokladu připravené tělo dokladu. Chybějící povinná pole se ' +
'doplní z předvyplněného vzoru iDokladu.',
timeoutMs: 25000,
inputs: [
{
id: 'payload',
label: 'Tělo dokladu',
type: 'object',
required: true,
hint: 'Objekt z transformace dat. Klíče podle iDokladu, například partnerId, items.',
},
{
id: 'requireItems',
label: 'Vyžadovat aspoň jednu položku',
type: 'boolean',
required: false,
default: true,
hint: 'Doklad bez položek iDoklad přijme, ale bude na nulu.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'itemCount', label: 'Počet položek', type: 'number', required: true },
{ id: 'invoice', label: 'Celý doklad', type: 'object', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, need } = ctx.util;
const payload = inputs.payload;
const items = Array.isArray(payload.items) ? payload.items : [];
if ((inputs.requireItems ?? true) && items.length === 0) {
ctx.fail('Tělo dokladu neobsahuje žádné položky. Zkontrolujte pravidla transformace.');
}
if (num(pick(payload, 'partnerId')) === null) {
ctx.fail('Tělo dokladu neobsahuje partnerId, iDoklad by doklad nepřijal.');
}
// Vzor nese povinna pole, ktera z objednavky nikdy neprijdou.
const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body);
if (!defaults || typeof defaults !== 'object') {
ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.');
}
const body = { ...defaults, ...payload };
const response = await ctx.http.post('/issued-invoices', body);
const invoice = unwrap(response.body);
ctx.log(`Doklad vystaven z objektu, položek: ${items.length}.`);
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
itemCount: items.length,
// Cely doklad dal, aby na nej sel navazat dalsi krok bez druheho volani.
invoice: invoice && typeof invoice === 'object' ? invoice : {},
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Transformace dat, rezim 1: pole na pole.
*
* Prijde objekt z jedne sluzby a vypadne objekt pro druhou. Kazde pravidlo rika
* "vezmi tuhle cestu ve zdroji, projed temito prevody a uloz to sem".
*
* Typicky pripad: objednavka ze Shoptetu a doklad pro iDoklad maji uplne jina
* jmena poli. Mezi nimi je tenhle krok.
*
* Rezim 2 je `transform.to-json` - tam se napise cely cilovy objekt a dosadi se
* do nej hodnoty. Volba mezi rezimy je o tom, cehoz je vic: kdyz se prevadi
* hodnoty, jsou lepsi pravidla. Kdyz se sklada tvar, je lepsi sablona.
*
* Sluzba je obecna, takze skript nic nevola a nepotrebuje konektor.
*/
export const manifest = {
id: 'transform.map-fields',
name: 'Přemapovat pole',
description:
'Převede objekt na jiný objekt podle pravidel. U každého cílového pole se ' +
'určí cesta ve zdroji a případné převody.',
inputs: [
{
id: 'source',
label: 'Zdrojová data',
type: 'object',
required: true,
hint: 'Objekt z předchozího kroku, například objednávka.',
},
{
id: 'rules',
label: 'Pravidla',
type: 'list',
required: true,
control: 'mapping',
hint: 'Seznam pravidel. V builderu se klikají, pod tím je vidět JSON.',
},
],
outputs: [
{ id: 'result', label: 'Výsledek', type: 'object', required: true },
{ id: 'fieldCount', label: 'Počet vyplněných polí', type: 'number', required: true },
],
};
export async function run(inputs, ctx) {
const rules = inputs.rules;
if (!Array.isArray(rules) || rules.length === 0) {
ctx.fail('Nejsou zadaná žádná pravidla, transformace by nic nevytvořila.');
}
// Mapovaci engine je na kontextu, ne v tomhle souboru. Pouziva ho i rezim 2
// a mel by byt na jednom miste, ne ve dvou skriptech.
const result = ctx.util.applyRules(inputs.source, rules);
const fieldCount = Object.keys(result).length;
ctx.log(`Přemapováno ${fieldCount} polí podle ${rules.length} pravidel.`);
return { result, fieldCount };
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Transformace dat, rezim 2: sablona JSON.
*
* Napise se cely cilovy objekt tak, jak ma vypadat, a na mistech hodnot se
* odkaze na zdroj pres `${cesta}`. Hodi se, kdyz je hlavni prace ve **tvaru**
* cilove struktury - zanoreni, seznamy, pevna pole.
*
* Rezim 1 je `transform.map-fields`. Ten je lepsi, kdyz je hlavni prace
* v prevodech hodnot.
*
* Proc `${cesta}` a ne `{{cesta}}`: sablony kroku se dosazuji driv, nez se krok
* spusti. `{{order.total}}` by tedy stihl vyhodnotit strom, zadny parametr toho
* jmena by nenasel a dosadil by prazdno. Jiny marker to nemuze splest.
*
* Kdyz je hodnota **cely** retezec, zachova se puvodni typ:
* `"total": "${order.total}"` vyrobi cislo, ne text. Bez toho by cizi sluzba
* dostala castku jako retezec a vetsina jich to odmitne.
*/
export const manifest = {
id: 'transform.to-json',
name: 'Poskládat objekt ze šablony',
description:
'Vezme šablonu JSON a dosadí do ní hodnoty ze zdroje přes ${cesta}. ' +
'Hodí se, když je potřeba postavit strukturu se zanořením a seznamy.',
inputs: [
{
id: 'source',
label: 'Zdrojová data',
type: 'object',
required: true,
hint: 'Objekt z předchozího kroku.',
},
{
id: 'template',
label: 'Šablona JSON',
type: 'string',
required: true,
multiline: true,
control: 'json',
hint: 'Cílový objekt. Hodnoty ze zdroje se vkládají jako ${cesta.k.poli}.',
},
],
outputs: [
{ id: 'result', label: 'Výsledek', type: 'object', required: true },
],
};
export async function run(inputs, ctx) {
let template;
try {
template = JSON.parse(String(inputs.template));
} catch (err) {
// Rozbita sablona je chyba nastaveni, opakovat ji nema smysl.
ctx.fail(`Šablona není platný JSON: ${err instanceof Error ? err.message : String(err)}`);
}
if (template === null || typeof template !== 'object' || Array.isArray(template)) {
ctx.fail('Šablona musí být objekt, tedy začínat složenou závorkou.');
}
const result = ctx.util.fillJson(template, inputs.source);
ctx.log(`Poskládán objekt s ${Object.keys(result).length} polemi.`);
return { result };
}
+6
View File
@@ -94,6 +94,12 @@ export const config = {
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Strop na jednu strukturu (parametr typu object nebo list).
* Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu, takze
* hranice patri do kontroly parametru, ne az do uklidu databaze.
*/
scriptMaxValueBytes: positiveNumber(process.env.SCRIPT_MAX_VALUE_BYTES, 256_000),
/**
* Povoli skriptum volat na localhost a do privatnich rozsahu IP.
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
+21 -2
View File
@@ -5,7 +5,12 @@
* Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md).
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
/**
* `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli
* transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole.
* Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku.
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
export type ConditionOperator =
| 'eq'
@@ -21,7 +26,17 @@ export type ConditionOperator =
| 'isTrue'
| 'isFalse';
export const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
export const fieldTypes: FieldType[] = [
'string',
'number',
'boolean',
'date',
'object',
'list',
];
/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */
export const declarableFieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
/** Ktere operatory maji smysl pro ktery typ. */
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
@@ -29,6 +44,10 @@ 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. Porovnavat dva
// objekty by znamenalo urcit, co je "stejny", a to zalezi na pripadu.
object: ['isEmpty', 'isNotEmpty'],
list: ['isEmpty', 'isNotEmpty'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
+26 -3
View File
@@ -104,8 +104,20 @@ export interface ProvidedField {
export interface OperationField {
id: string;
label: string;
/** text = jeden radek, longtext = vice radku, choice = vyber z `options` */
kind: 'text' | 'longtext' | 'choice';
/**
* Jak se pole vykresli v builderu.
*
* `text` jeden radek
* `longtext` vice radku
* `choice` vyber z `options`
* `json` JSON, kontroluje se uz pri ulozeni stromu
* `mapping` pravidla transformace, klikatelny editor nad JSONem
* `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku
*
* Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil
* `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva.
*/
kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
@@ -738,7 +750,18 @@ export const services: Service[] = [
{
id: 'order-created',
name: 'Nová objednávka',
description: 'Spustí se při vytvoření objednávky v e-shopu.',
description:
'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' +
'dál jako objekt, takže se dá přemapovat na doklad.',
providedFields: [
{ id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true },
{ id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true },
{ id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false },
// Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku
// jako celek - typicky do transformace dat.
{ id: 'eshop.order', name: 'order', type: 'object', required: true },
{ id: 'eshop.items', name: 'items', type: 'list', required: true },
],
},
{
id: 'order-status-changed',
+28 -1
View File
@@ -53,6 +53,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
@@ -434,7 +435,10 @@ const fieldSchema = z.object({
.min(1, 'Parametr musí mít název.')
// Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu.
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'),
type: z.enum(['string', 'number', 'boolean', 'date']),
// `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields.
// Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony
// dosadit nejde - predava se jen jako celek dalsimu kroku.
type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']),
required: z.boolean(),
});
@@ -544,6 +548,29 @@ function validateFlowReferences(
}
}
// Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep
// v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen
// automatizace nepujde zapnout. Bez teto kontroly by se to poznalo
// az z padleho behu.
for (const input of action.inputs ?? []) {
if (input.kind !== 'json' && input.kind !== 'mapping') continue;
const raw = (step.inputs ?? {})[input.id];
if (raw === undefined || raw.trim() === '') continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const detail = err instanceof Error ? err.message : 'neplatný JSON';
issues.push(`Pole „${input.label}" není platný JSON: ${detail}`);
continue;
}
if (input.kind === 'mapping') {
issues.push(...validateRules(parsed, `pole „${input.label}"`));
}
}
// Vybrany konektor musi patrit te same firme a te same sluzbe.
// Cizi konektor je rozbity strom, ne nedodelek.
if (step.connectorId) {
+15 -1
View File
@@ -59,12 +59,26 @@ export function parseManifest(raw: unknown, expectedId: string): ParseResult {
/**
* Nastavitelne pole akce pro builder.
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
*
* Poradi rozhodovani: vyslovne urceny `control` vyhrava, pak vyber z hodnot,
* pak typ struktury, pak vic radku. Vyslovne urceni je prvni zamerne - jen
* autor skriptu vi, ze `rules` je mapovani a ne obycejny seznam.
*/
function toOperationField(field: ScriptField): OperationField {
const kind: OperationField['kind'] = field.control
? field.control
: field.options
? 'choice'
: field.type === 'object' || field.type === 'list'
? 'object'
: field.multiline
? 'longtext'
: 'text';
return {
id: field.id,
label: field.label,
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
kind,
required: field.required,
...(field.options ? { options: field.options } : {}),
...(field.hint ? { hint: field.hint } : {}),
+353
View File
@@ -0,0 +1,353 @@
/**
* Transformace dat: prevod jedne struktury na druhou.
*
* Je to odpoved na typickou vec v automatizaci: prijde objednavka z e-shopu
* a do iDokladu potrebuju uplne jiny objekt. Mezi tim musi byt krok, ktery
* rekne, co se kam prepise a jak se to po cest prevede.
*
* Dva rezimy, oba stoji na tomhle souboru:
* - **pravidla** (`applyRules`): pole na pole, klikatelne, s prevody,
* - **sablona JSON** (`fillJson`): cilovy objekt se napise a do nej se dosadi
* `${cesta}` ze zdroje.
*
* Engine je tady na serveru a skripty ho dostanou na `ctx.util`. Nemuze byt
* v souboru skriptu, protoze skripty nic neimportuji - a nema tam byt, protoze
* by ho pak kazdy skript mel ve vlastni verzi.
*/
import { ScriptError, type ScriptJson } from './types.js';
/** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */
const MAX_DEPTH = 20;
// ---------------------------------------------------------------------- cesty
/**
* Hodnota na ceste. Prijima `customer.email`, `items.0.name` i `items[0].name`,
* protoze lidi pisou oboji a hadat, ktere je spravne, nema smysl.
*
* `undefined` znamena "na te ceste nic neni". Zamerne se nerozlisuje od `null`
* na vyssi urovni - pro dosazeni do vystupu je to totez.
*/
export function getPath(source: unknown, path: string): unknown {
if (path.trim() === '') return source;
const parts = path
.replace(/\[(\d+)\]/g, '.$1')
.split('.')
.filter((part) => part !== '');
let current: unknown = source;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (Array.isArray(current)) {
const index = Number(part);
if (!Number.isInteger(index)) return undefined;
current = current[index];
continue;
}
if (typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
/** Zapise hodnotu na cestu a cestou vytvori chybejici objekty. */
function setPath(target: Record<string, ScriptJson>, path: string, value: ScriptJson): void {
const parts = path.split('.').filter((part) => part !== '');
if (parts.length === 0) return;
let current: Record<string, ScriptJson> = target;
for (const part of parts.slice(0, -1)) {
const next = current[part];
if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) {
current[part] = {};
}
current = current[part] as Record<string, ScriptJson>;
}
current[parts[parts.length - 1]] = value;
}
// ------------------------------------------------------------------- prevody
/**
* Jeden prevod hodnoty. Zamerne mala uzavrena sada - pri volnem vyrazu by
* transformace prestala byt nastaveni a stala se dalsim jazykem k ladeni.
*/
export type TransformOp =
| { op: 'trim' }
| { op: 'lower' }
| { op: 'upper' }
| { op: 'string' }
| { op: 'number' }
| { op: 'boolean' }
| { op: 'date'; format?: 'iso' | 'day' }
| { op: 'round'; decimals?: number }
| { op: 'multiply'; by: number }
| { op: 'add'; by: number }
| { op: 'default'; value: ScriptJson }
| { op: 'replace'; find: string; with: string }
| { op: 'slice'; start?: number; end?: number }
| { op: 'split'; separator: string }
| { op: 'join'; separator?: string }
| { op: 'sum'; path?: string }
| { op: 'count' }
/** Nad seznamem objektu: kazdy prvek prevede podle vlastnich pravidel. */
| { op: 'map'; rules: MappingRule[] };
export interface MappingRule {
/** Kam se hodnota ulozi. Tecka znamena zanoreni: `partner.id`. */
to: string;
/** Cesta ve zdroji. Bez markeru, je to cesta, ne sablona. */
from?: string;
/** Pevna hodnota. Pouzije se, kdyz `from` chybi. */
value?: ScriptJson;
/** Prevody v uvedenem poradi. */
transforms?: TransformOp[];
/** Kdyz je vysledek prazdny, pouzije se tohle. */
fallback?: ScriptJson;
/** Prazdny vysledek se do vystupu vubec nezapise. */
omitIfEmpty?: boolean;
/** Prazdny vysledek je chyba. Pro pole, bez kterych cizi sluzba doklad neprijme. */
required?: boolean;
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return false;
}
function toNumber(value: unknown, label: string): number {
if (typeof value === 'number') return value;
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
if (!Number.isFinite(parsed)) {
throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`);
}
return parsed;
}
function applyOne(value: unknown, operation: TransformOp, label: string, depth: number): ScriptJson {
switch (operation.op) {
case 'trim':
return String(value ?? '').trim();
case 'lower':
return String(value ?? '').toLowerCase();
case 'upper':
return String(value ?? '').toUpperCase();
case 'string':
return value === undefined || value === null ? '' : String(value);
case 'number':
return toNumber(value, label);
case 'boolean': {
if (typeof value === 'boolean') return value;
const text = String(value ?? '').trim().toLowerCase();
return text === 'true' || text === '1' || text === 'yes' || text === 'ano';
}
case 'date': {
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? ''));
if (Number.isNaN(parsed)) {
throw new ScriptError('terminal', `${label}: "${String(value)}" není platné datum.`);
}
const iso = new Date(parsed).toISOString();
return operation.format === 'day' ? iso.slice(0, 10) : iso;
}
case 'round': {
const factor = 10 ** (operation.decimals ?? 2);
return Math.round(toNumber(value, label) * factor) / factor;
}
case 'multiply':
return toNumber(value, label) * operation.by;
case 'add':
return toNumber(value, label) + operation.by;
case 'default':
return isEmpty(value) ? operation.value : (value as ScriptJson);
case 'replace':
return String(value ?? '').split(operation.find).join(operation.with);
case 'slice': {
if (Array.isArray(value)) return value.slice(operation.start ?? 0, operation.end) as ScriptJson;
return String(value ?? '').slice(operation.start ?? 0, operation.end);
}
case 'split':
return String(value ?? '')
.split(operation.separator)
.map((part) => part.trim())
.filter((part) => part !== '');
case 'join':
return Array.isArray(value)
? value.map((item) => String(item ?? '')).join(operation.separator ?? ', ')
: String(value ?? '');
case 'sum': {
if (!Array.isArray(value)) return toNumber(value, label);
return value.reduce<number>((total, item) => {
const part = operation.path ? getPath(item, operation.path) : item;
return total + toNumber(part ?? 0, label);
}, 0);
}
case 'count':
return Array.isArray(value) ? value.length : isEmpty(value) ? 0 : 1;
case 'map': {
if (!Array.isArray(value)) {
throw new ScriptError('terminal', `${label}: prevod map potřebuje seznam.`);
}
return value.map((item) => applyRules(item, operation.rules, depth + 1));
}
default: {
// Neznamy prevod je chyba nastaveni, ne dat. Tise ho preskocit by znamenalo
// odeslat neprevedena data a divit se az u odberatele.
const unknown = operation as { op?: string };
throw new ScriptError('terminal', `Neznámý převod "${String(unknown.op)}" u ${label}.`);
}
}
}
// ------------------------------------------------------------------ pravidla
/** Prevede zdroj na novy objekt podle pravidel. */
export function applyRules(
source: unknown,
rules: MappingRule[],
depth = 0,
): Record<string, ScriptJson> {
if (depth > MAX_DEPTH) {
throw new ScriptError('terminal', 'Mapování je zanořené příliš hluboko.');
}
const result: Record<string, ScriptJson> = {};
for (const rule of rules) {
if (!rule.to || rule.to.trim() === '') {
throw new ScriptError('terminal', 'Pravidlo mapování musí mít vyplněné cílové pole.');
}
const label = `pole ${rule.to}`;
let value: unknown = rule.from !== undefined ? getPath(source, rule.from) : rule.value;
for (const operation of rule.transforms ?? []) {
value = applyOne(value, operation, label, depth);
}
if (isEmpty(value) && rule.fallback !== undefined) value = rule.fallback;
if (isEmpty(value)) {
if (rule.required) {
const where = rule.from ? ` (zdroj: ${rule.from})` : '';
throw new ScriptError('terminal', `${label} je povinné, ale nic do něj nedošlo${where}.`);
}
if (rule.omitIfEmpty) continue;
}
setPath(result, rule.to, (value ?? null) as ScriptJson);
}
return result;
}
// -------------------------------------------------------------- sablona JSON
/**
* Dosadi `${cesta}` ze zdroje do sablony.
*
* Marker je zamerne `${...}` a ne `{{...}}`. Sablony kroku se dosazuji driv,
* nez se krok spusti, takze `{{order.total}}` v sablone JSON by stihl vyhodnotit
* strom, nenasel by parametr toho jmena a dosadil prazdno. Rozdilny marker to
* nemuze splest.
*
* Kdyz je hodnota **cely** retezec, zachova se puvodni typ. `"${order.total}"`
* tedy vyrobi cislo, ne text - jinak by cizi sluzba dostala castku jako string.
*/
const wholeToken = /^\$\{([^}]+)\}$/;
const anyToken = /\$\{([^}]+)\}/g;
export function fillJson(template: ScriptJson, source: unknown, depth = 0): ScriptJson {
if (depth > MAX_DEPTH) {
throw new ScriptError('terminal', 'Šablona JSON je zanořená příliš hluboko.');
}
if (typeof template === 'string') {
const whole = wholeToken.exec(template);
if (whole) {
const value = getPath(source, whole[1]);
return (value ?? null) as ScriptJson;
}
return template.replace(anyToken, (_match, path: string) => {
const value = getPath(source, path);
if (value === undefined || value === null) return '';
return typeof value === 'object' ? JSON.stringify(value) : String(value);
});
}
if (Array.isArray(template)) {
return template.map((item) => fillJson(item, source, depth + 1));
}
if (template !== null && typeof template === 'object') {
const result: Record<string, ScriptJson> = {};
for (const [key, value] of Object.entries(template)) {
result[key] = fillJson(value, source, depth + 1);
}
return result;
}
return template;
}
// -------------------------------------------------------------------- kontrola
/**
* Overi tvar pravidel prisly z nastaveni kroku.
* Vraci popisy problemu, prazdne pole znamena v poradku.
*
* Kontroluje se pri ukladani stromu, aby se preklep v pravidlech nedozvedel
* uzivatel az z padleho behu.
*/
export function validateRules(value: unknown, path = 'pravidla'): string[] {
if (!Array.isArray(value)) return [`${path}: očekává se seznam pravidel.`];
const problems: string[] = [];
value.forEach((rule, index) => {
const where = `${path}[${index}]`;
if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) {
problems.push(`${where}: pravidlo musí být objekt.`);
return;
}
const record = rule as Record<string, unknown>;
if (typeof record.to !== 'string' || record.to.trim() === '') {
problems.push(`${where}: chybí cílové pole "to".`);
}
if (record.from === undefined && record.value === undefined) {
problems.push(`${where}: vyplňte "from" (cesta ve zdroji) nebo "value" (pevná hodnota).`);
}
if (record.transforms !== undefined) {
if (!Array.isArray(record.transforms)) {
problems.push(`${where}: "transforms" musí být seznam.`);
} else {
record.transforms.forEach((operation, opIndex) => {
if (operation === null || typeof operation !== 'object') {
problems.push(`${where}.transforms[${opIndex}]: převod musí být objekt.`);
return;
}
const op = (operation as { op?: unknown }).op;
if (typeof op !== 'string') {
problems.push(`${where}.transforms[${opIndex}]: chybí "op".`);
return;
}
if (op === 'map') {
problems.push(
...validateRules(
(operation as { rules?: unknown }).rules,
`${where}.transforms[${opIndex}].rules`,
),
);
}
});
}
}
});
return problems;
}
+49 -3
View File
@@ -13,16 +13,31 @@
*/
import { z } from 'zod';
import type { MappingRule } from './mapping.js';
// ------------------------------------------------------------------- hodnoty
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
export type ScriptValue = string | number | boolean | null;
/**
* Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem.
*
* Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou
* objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji,
* predavaji se jen jako celek dalsimu kroku.
*/
export type ScriptJson =
| string
| number
| boolean
| null
| ScriptJson[]
| { [key: string]: ScriptJson };
export type ScriptValue = ScriptJson;
export type ScriptValues = Record<string, ScriptValue>;
// -------------------------------------------------------------------- schema
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']);
/**
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
@@ -53,8 +68,24 @@ export const scriptFieldSchema = z
pattern: z.string().optional(),
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
multiline: z.boolean().optional(),
/**
* Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat.
*
* `mapping` pravidla transformace, klikatelny editor
* `json` JSON, kontroluje se uz pri ulozeni stromu
* `object` vyber parametru typu objekt nebo seznam z predchoziho kroku
*
* Explicitne, ne odvozene z nazvu pole. Hadat podle `id === 'rules'` by
* fungovalo do prvniho skriptu, ktery to pole pojmenuje jinak.
*/
control: z.enum(['mapping', 'json', 'object']).optional(),
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
/**
* Jen u `object` a `list`: strop na velikost v bajtech.
* Bez nej by jeden krok mohl protahnout logem megabajty.
*/
maxBytes: z.number().int().min(256).max(4_000_000).optional(),
})
.strict();
@@ -210,6 +241,21 @@ export interface ScriptUtil {
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
*/
need<T>(value: T | null | undefined, label: string): T;
/**
* Hodnota na ceste: `customer.email`, `items.0.name` i `items[0].name`.
* Chybejici cesta vraci `undefined`, ne vyjimku.
*/
get(source: unknown, path: string): unknown;
/**
* Prevede zdroj na novy objekt podle pravidel.
* Tohle je rezim "pole na pole" u transformace dat.
*/
applyRules(source: unknown, rules: MappingRule[]): Record<string, ScriptJson>;
/**
* Dosadi `${cesta}` ze zdroje do sablony JSON.
* Marker je zamerne jiny nez `{{...}}` u sablon kroku, duvod je v mapping.ts.
*/
fillJson(template: ScriptJson, source: unknown): ScriptJson;
}
export interface ScriptContext {
+15 -1
View File
@@ -7,6 +7,7 @@
* z nich by to resil spatne.
*/
import { applyRules, fillJson, getPath } from './mapping.js';
import { ScriptError, type ScriptUtil } from './types.js';
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
@@ -83,7 +84,20 @@ function need<T>(value: T | null | undefined, label: string): T {
return value;
}
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
export const scriptUtil: ScriptUtil = {
unwrap,
pick,
first,
text,
num,
bool,
date,
round,
need,
get: getPath,
applyRules: (source, rules) => applyRules(source, rules),
fillJson: (template, source) => fillJson(template, source),
};
// ------------------------------------------------------------------- redakce
+62 -2
View File
@@ -11,7 +11,8 @@
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
*/
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
import { config } from '../config.js';
import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js';
export type ValidationResult =
| { ok: true; values: ScriptValues }
@@ -25,6 +26,43 @@ function isMissing(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
/**
* Rozparsuje JSON, kdyz hodnota prisla jako text.
*
* Deje se to bezne: z nastaveni kroku i z testovaciho formulare prijde struktura
* jako retezec. Odmitnout ji by znamenalo, ze uzivatel musi resit, kde presne
* se to prevadi.
*/
function parseIfText(raw: unknown): unknown {
if (typeof raw !== 'string') return raw;
const text = raw.trim();
if (text === '') return raw;
if (!text.startsWith('{') && !text.startsWith('[')) return raw;
try {
return JSON.parse(text);
} catch {
return raw;
}
}
/**
* Strop na velikost struktury.
*
* Bez nej by jeden krok mohl protahnout logem a databazi megabajty. Radek
* `run_step` je nejrychleji rostouci tabulka v systemu, viz dokument 10.
*/
function sizeProblem(field: ScriptField, value: ScriptJson): string | null {
const limit = field.maxBytes ?? config.scriptMaxValueBytes;
let size: number;
try {
size = Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
} catch {
return 'strukturu nelze zapsat jako JSON (asi obsahuje cyklus).';
}
if (size > limit) return `je ${size} B, což je nad povolený strop ${limit} B.`;
return null;
}
/**
* Prevede jednu hodnotu na deklarovany typ.
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
@@ -70,6 +108,22 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er
return { value: new Date(parsed).toISOString() };
}
case 'object': {
const parsed = parseIfText(raw);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { error: 'Očekává se objekt.' };
}
const problem = sizeProblem(field, parsed as ScriptJson);
return problem ? { error: problem } : { value: parsed as ScriptJson };
}
case 'list': {
const parsed = parseIfText(raw);
if (!Array.isArray(parsed)) return { error: 'Očekává se seznam.' };
const problem = sizeProblem(field, parsed as ScriptJson);
return problem ? { error: problem } : { value: parsed as ScriptJson };
}
default: {
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
return { error: 'Neznámý typ parametru.' };
@@ -115,7 +169,13 @@ export function validateValues(
continue;
}
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
// Vyber ma smysl jen u skalarni hodnoty. U struktury by se porovnaval JSON.
if (
field.options &&
field.type !== 'object' &&
field.type !== 'list' &&
!field.options.some((option) => option.value === String(result.value))
) {
const allowed = field.options.map((option) => option.value).join(', ');
issues.push({
field: field.id,
@@ -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'