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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 : {},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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