@tangle-network/blueprint-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/package.json +82 -0
- package/src/blueprints/registry.ts +107 -0
- package/src/components/forms/BlueprintJobForm.tsx +88 -0
- package/src/components/forms/FormField.tsx +206 -0
- package/src/components/forms/FormSummary.tsx +47 -0
- package/src/components/forms/JobExecutionDialog.tsx +203 -0
- package/src/components/layout/AppDocument.tsx +48 -0
- package/src/components/layout/AppFooter.tsx +38 -0
- package/src/components/layout/AppToaster.tsx +33 -0
- package/src/components/layout/ChainSwitcher.tsx +71 -0
- package/src/components/layout/ThemeToggle.tsx +18 -0
- package/src/components/layout/Web3Shell.tsx +28 -0
- package/src/components/motion/AnimatedPage.tsx +36 -0
- package/src/components/shared/Identicon.tsx +22 -0
- package/src/components/shared/TangleLogo.tsx +49 -0
- package/src/components/ui/badge.tsx +37 -0
- package/src/components/ui/button.tsx +61 -0
- package/src/components/ui/card.tsx +34 -0
- package/src/components/ui/dialog.tsx +59 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/select.tsx +80 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +49 -0
- package/src/components/ui/tabs.tsx +39 -0
- package/src/components/ui/textarea.tsx +23 -0
- package/src/components/ui/toggle.tsx +35 -0
- package/src/components.ts +51 -0
- package/src/contracts/abi.ts +259 -0
- package/src/contracts/chains.ts +100 -0
- package/src/contracts/generic-encoder.ts +69 -0
- package/src/contracts/publicClient.ts +55 -0
- package/src/env.d.ts +14 -0
- package/src/hooks/useAuthenticatedFetch.ts +57 -0
- package/src/hooks/useJobForm.ts +78 -0
- package/src/hooks/useJobPrice.ts +283 -0
- package/src/hooks/useOperators.ts +141 -0
- package/src/hooks/useProvisionProgress.ts +125 -0
- package/src/hooks/useQuotes.ts +261 -0
- package/src/hooks/useServiceValidation.ts +113 -0
- package/src/hooks/useSessionAuth.ts +103 -0
- package/src/hooks/useSubmitJob.ts +115 -0
- package/src/hooks/useThemeValue.ts +6 -0
- package/src/index.ts +79 -0
- package/src/preset.ts +61 -0
- package/src/stores/infra.ts +43 -0
- package/src/stores/persistedAtom.ts +67 -0
- package/src/stores/session.ts +64 -0
- package/src/stores/theme.ts +28 -0
- package/src/stores/txHistory.ts +47 -0
- package/src/utils/resolveOperatorRpc.ts +20 -0
- package/src/utils/web3.ts +21 -0
- package/src/utils.ts +6 -0
- package/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
<h1 align="center">@tangle-network/blueprint-ui</h1>
|
|
4
|
+
|
|
5
|
+
<p align="center"><em>Shared UI components, hooks, stores, and contract utilities for Tangle Blueprint applications.</em></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://discord.com/invite/cv8EfJu3Tn"><img src="https://img.shields.io/discord/833784453251596298?label=Discord" alt="Discord"></a>
|
|
9
|
+
<a href="https://t.me/tanglenet"><img src="https://img.shields.io/endpoint?color=neon&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Ftanglenet" alt="Telegram"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A TypeScript/React package that provides the building blocks for blueprint UIs on the Tangle Network. Designed to be consumed as a source dependency (no build step required) by apps using Vite or similar bundlers.
|
|
15
|
+
|
|
16
|
+
## What's Included
|
|
17
|
+
|
|
18
|
+
### Components
|
|
19
|
+
|
|
20
|
+
**UI Primitives** — Badge, Button, Card, Dialog, Input, Select, Separator, Skeleton, Table, Tabs, Textarea, Toggle
|
|
21
|
+
|
|
22
|
+
**Forms** — `FormField`, `BlueprintJobForm` (auto-generated from job definitions), `FormSummary`, `JobExecutionDialog`
|
|
23
|
+
|
|
24
|
+
**Layout / App Shell** — `AppDocument`, `AppFooter`, `AppToaster`, `Web3Shell`, `ChainSwitcher`, `ThemeToggle`
|
|
25
|
+
|
|
26
|
+
**Shared** — `Identicon` (blockie avatars), `TangleLogo`
|
|
27
|
+
|
|
28
|
+
**Motion** — `AnimatedPage`, `StaggerContainer`, `StaggerItem`
|
|
29
|
+
|
|
30
|
+
### Hooks
|
|
31
|
+
|
|
32
|
+
| Hook | Purpose |
|
|
33
|
+
|------|---------|
|
|
34
|
+
| `useSubmitJob` | Submit on-chain jobs with TX lifecycle tracking |
|
|
35
|
+
| `useJobForm` | Generic form state derived from `JobDefinition` |
|
|
36
|
+
| `useJobPrice` / `useJobPrices` | Fetch job pricing from operators |
|
|
37
|
+
| `useQuotes` | Operator quote aggregation with PoW challenge solving |
|
|
38
|
+
| `useOperators` | Discover active operators for a blueprint |
|
|
39
|
+
| `useServiceValidation` | Check service status and permissions |
|
|
40
|
+
| `useProvisionProgress` | Track provision lifecycle (events + polling) |
|
|
41
|
+
| `useSessionAuth` | PASETO session management |
|
|
42
|
+
| `useAuthenticatedFetch` | Fetch wrapper with automatic token refresh |
|
|
43
|
+
| `useThemeValue` | Resolve theme-dependent values |
|
|
44
|
+
|
|
45
|
+
### Stores (nanostores)
|
|
46
|
+
|
|
47
|
+
- **`infraStore`** — Blueprint ID, service ID, operator info
|
|
48
|
+
- **`sessionMapStore`** — PASETO sessions per operator
|
|
49
|
+
- **`txListStore`** — Transaction history tracking
|
|
50
|
+
- **`themeStore`** — Light/dark theme state
|
|
51
|
+
- **`persistedAtom`** — localStorage-backed atom with BigInt serialization
|
|
52
|
+
|
|
53
|
+
### Contract Utilities
|
|
54
|
+
|
|
55
|
+
- **ABI exports** — `tangleJobsAbi`, `tangleServicesAbi`, `tangleOperatorsAbi`
|
|
56
|
+
- **Chain configs** — Tangle Local, Testnet, Mainnet with RPC resolution
|
|
57
|
+
- **`publicClient`** — Singleton viem public client tied to selected chain
|
|
58
|
+
- **`encodeJobArgs`** — ABI-encode job arguments from form values using job field metadata
|
|
59
|
+
- **Web3 helpers** — `createTangleTransports`, `defaultConnectKitOptions`, `tangleWalletChains`, `resolveOperatorRpc`
|
|
60
|
+
|
|
61
|
+
## Package Boundaries
|
|
62
|
+
|
|
63
|
+
Use `@tangle-network/blueprint-ui` for:
|
|
64
|
+
- App-agnostic shell/layout primitives and design-system building blocks
|
|
65
|
+
- Shared chain/contract/provisioning hooks + stores
|
|
66
|
+
- Reusable cross-blueprint form and submission workflows
|
|
67
|
+
|
|
68
|
+
Keep in app-local code:
|
|
69
|
+
- Product-specific routes and copy
|
|
70
|
+
- Feature composition that is unique to a single app
|
|
71
|
+
|
|
72
|
+
### Blueprint Registry
|
|
73
|
+
|
|
74
|
+
- **`registerBlueprint`** / **`getBlueprint`** — Register and look up blueprint definitions
|
|
75
|
+
- **`JobDefinition`** — Declarative job schema with field types, ABI metadata, and categories
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# As a git dependency (recommended for Tangle apps)
|
|
82
|
+
pnpm add github:tangle-network/blueprint-ui
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Publishing
|
|
86
|
+
|
|
87
|
+
Automated npm publishing is configured via GitHub Actions with npm Trusted Publishing (OIDC):
|
|
88
|
+
- Workflow: `.github/workflows/publish-npm.yml`
|
|
89
|
+
- Triggers:
|
|
90
|
+
- GitHub Release published (`vX.Y.Z`)
|
|
91
|
+
- Manual `workflow_dispatch`
|
|
92
|
+
|
|
93
|
+
No long-lived npm token is required once trusted publishing is configured.
|
|
94
|
+
|
|
95
|
+
Release flow:
|
|
96
|
+
1. Bump `package.json` version.
|
|
97
|
+
2. Create and publish a GitHub release tagged `v<same-version>`.
|
|
98
|
+
3. Workflow typechecks and runs `npm publish --access public`.
|
|
99
|
+
|
|
100
|
+
Trusted publishing setup (one-time in npm):
|
|
101
|
+
1. Open npm package settings for `@tangle-network/blueprint-ui`.
|
|
102
|
+
2. Configure a trusted publisher:
|
|
103
|
+
- Provider: GitHub Actions
|
|
104
|
+
- Owner: `tangle-network`
|
|
105
|
+
- Repository: `blueprint-ui`
|
|
106
|
+
- Workflow file: `publish-npm.yml`
|
|
107
|
+
- Environment (if used): must match your workflow configuration
|
|
108
|
+
|
|
109
|
+
If npm does not allow configuring trusted publishing before first publish, do a one-time bootstrap publish with a short-lived token, then switch to trusted publishing and delete the token.
|
|
110
|
+
|
|
111
|
+
## Usage
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// Import hooks and utilities from the main entry
|
|
115
|
+
import { useSubmitJob, useJobForm, encodeJobArgs } from '@tangle-network/blueprint-ui';
|
|
116
|
+
|
|
117
|
+
// Import UI components from the /components entry
|
|
118
|
+
import { Button, Card, FormField } from '@tangle-network/blueprint-ui/components';
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The package uses source-level exports (`main` and `types` both point to `.ts` files). Your bundler must support TypeScript resolution — Vite works out of the box.
|
|
122
|
+
|
|
123
|
+
## Peer Dependencies
|
|
124
|
+
|
|
125
|
+
React 19, wagmi 3.x, viem 2.x, nanostores, Radix UI primitives, framer-motion, sonner, tailwind-merge, class-variance-authority. See `package.json` for the full list and version ranges.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Licensed under either of [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) or [MIT license](http://opensource.org/licenses/MIT), at your option.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Key Concepts
|
|
134
|
+
|
|
135
|
+
**Blueprint UI** is a TypeScript/React component library for building user interfaces that interact with Tangle Network blueprints. It provides pre-built components for operator management, service requests, job submission, and payment flows.
|
|
136
|
+
|
|
137
|
+
**Tangle Network** is a decentralized infrastructure protocol where operators stake economic collateral to run verifiable services called blueprints.
|
|
138
|
+
|
|
139
|
+
**x402** is an HTTP-native micropayment protocol that enables per-request payments for blueprint services, integrated into the UI through payment hooks and components.
|
|
140
|
+
|
|
141
|
+
**Blueprint** is a deployable service specification on Tangle that defines computation, verification, and payment in a single package.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Frequently Asked Questions
|
|
146
|
+
|
|
147
|
+
**What is @tangle-network/blueprint-ui?**
|
|
148
|
+
A React component library providing UI primitives, hooks, and contract utilities for building applications on Tangle Network.
|
|
149
|
+
|
|
150
|
+
**Do I need to build this package before using it?**
|
|
151
|
+
No. It is designed as a source dependency consumed directly by Vite or similar bundlers with no build step required.
|
|
152
|
+
|
|
153
|
+
**What framework does blueprint-ui support?**
|
|
154
|
+
React with TypeScript. Components use Radix UI primitives and Tailwind CSS for styling.
|
|
155
|
+
|
|
156
|
+
**How do I connect to Tangle contracts?**
|
|
157
|
+
Use the provided contract hooks and ABI utilities. The package includes typed bindings for Tangle's on-chain service registry, operator staking, and job submission.
|
|
158
|
+
|
|
159
|
+
**Can I use blueprint-ui for x402 payment flows?**
|
|
160
|
+
Yes. The hooks and utilities support x402 payment header construction for per-request micropayments to blueprint operators.
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tangle-network/blueprint-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared blueprint UI components, hooks, and contract utilities for Tangle Network apps",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/tangle-network/blueprint-ui.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/tangle-network/blueprint-ui",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/tangle-network/blueprint-ui/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"package.json",
|
|
21
|
+
"tsconfig.json"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./components": "./src/components.ts",
|
|
29
|
+
"./preset": "./src/preset.ts"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@tanstack/react-query": "^5.0.0",
|
|
36
|
+
"@nanostores/react": "^0.7.0",
|
|
37
|
+
"@radix-ui/react-dialog": "^1.1.0",
|
|
38
|
+
"@radix-ui/react-select": "^2.1.0",
|
|
39
|
+
"@radix-ui/react-separator": "^1.1.0",
|
|
40
|
+
"@radix-ui/react-slot": "^1.2.0",
|
|
41
|
+
"@radix-ui/react-tabs": "^1.1.0",
|
|
42
|
+
"@radix-ui/react-tooltip": "^1.2.0",
|
|
43
|
+
"blo": "^2.0.0",
|
|
44
|
+
"class-variance-authority": "^0.7.0",
|
|
45
|
+
"clsx": "^2.1.0",
|
|
46
|
+
"framer-motion": "^12.0.0",
|
|
47
|
+
"nanostores": "^0.10.0",
|
|
48
|
+
"react": "^19.0.0",
|
|
49
|
+
"react-dom": "^19.0.0",
|
|
50
|
+
"react-router": "^7.0.0",
|
|
51
|
+
"sonner": "^2.0.0",
|
|
52
|
+
"tailwind-merge": "^3.2.0",
|
|
53
|
+
"viem": "^2.31.0",
|
|
54
|
+
"wagmi": "^3.3.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@tanstack/react-query": "^5.90.16",
|
|
59
|
+
"@nanostores/react": "^1.0.0",
|
|
60
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
61
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
62
|
+
"@radix-ui/react-separator": "^1.1.8",
|
|
63
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
64
|
+
"@radix-ui/react-tabs": "^1.1.13",
|
|
65
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
66
|
+
"@types/react": "18.3.1",
|
|
67
|
+
"@types/react-dom": "18.3.1",
|
|
68
|
+
"blo": "^2.0.0",
|
|
69
|
+
"class-variance-authority": "^0.7.1",
|
|
70
|
+
"clsx": "^2.1.1",
|
|
71
|
+
"framer-motion": "^12.34.3",
|
|
72
|
+
"nanostores": "^1.1.0",
|
|
73
|
+
"react": "^19.2.4",
|
|
74
|
+
"react-dom": "^19.2.4",
|
|
75
|
+
"react-router": "^7.13.0",
|
|
76
|
+
"sonner": "^2.0.7",
|
|
77
|
+
"tailwind-merge": "^3.5.0",
|
|
78
|
+
"typescript": "^5.5.2",
|
|
79
|
+
"viem": "^2.46.2",
|
|
80
|
+
"wagmi": "^3.5.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Address } from 'viem';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blueprint Registry — defines the metadata layer for Tangle blueprints.
|
|
5
|
+
*
|
|
6
|
+
* Each blueprint exposes a set of jobs. The registry maps on-chain job IDs
|
|
7
|
+
* to human-readable metadata: labels, descriptions, categories, form fields,
|
|
8
|
+
* and pricing info. This enables the UI to render appropriate forms for each
|
|
9
|
+
* job without procedurally generating UI from raw ABI data.
|
|
10
|
+
*
|
|
11
|
+
* Third-party blueprints can register here to appear in the wizard.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Types ──
|
|
15
|
+
|
|
16
|
+
export type JobCategory = 'lifecycle' | 'execution' | 'batch' | 'workflow' | 'ssh' | 'management';
|
|
17
|
+
|
|
18
|
+
export interface JobFieldDef {
|
|
19
|
+
name: string;
|
|
20
|
+
label: string;
|
|
21
|
+
type: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'json' | 'combobox';
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
defaultValue?: string | number | boolean;
|
|
25
|
+
options?: { label: string; value: string }[];
|
|
26
|
+
helperText?: string;
|
|
27
|
+
/** Minimum allowed value for number fields */
|
|
28
|
+
min?: number;
|
|
29
|
+
/** Maximum allowed value for number fields */
|
|
30
|
+
max?: number;
|
|
31
|
+
/** Step increment for number fields */
|
|
32
|
+
step?: number;
|
|
33
|
+
/** Solidity ABI type for encoding (e.g. 'string', 'uint64', 'bool', 'uint8') */
|
|
34
|
+
abiType?: string;
|
|
35
|
+
/** ABI param name if different from `name` (e.g. 'agent_identifier' vs 'agentIdentifier') */
|
|
36
|
+
abiParam?: string;
|
|
37
|
+
/** Field is included in ABI encoding but never shown in form (e.g. sidecar_token) */
|
|
38
|
+
internal?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** ABI param injected from runtime context, not user input (e.g. sidecar_url, sandbox_id) */
|
|
42
|
+
export interface AbiContextParam {
|
|
43
|
+
abiName: string;
|
|
44
|
+
abiType: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JobDefinition {
|
|
48
|
+
id: number;
|
|
49
|
+
name: string;
|
|
50
|
+
label: string;
|
|
51
|
+
description: string;
|
|
52
|
+
category: JobCategory;
|
|
53
|
+
icon: string;
|
|
54
|
+
pricingMultiplier: number;
|
|
55
|
+
/** Fields the user needs to fill for this job */
|
|
56
|
+
fields: JobFieldDef[];
|
|
57
|
+
/** Whether this job requires an existing sandbox to target */
|
|
58
|
+
requiresSandbox: boolean;
|
|
59
|
+
/** Optional warning shown before submission */
|
|
60
|
+
warning?: string;
|
|
61
|
+
/** ABI params prepended from runtime context (e.g. sidecar_url for sandbox jobs) */
|
|
62
|
+
contextParams?: AbiContextParam[];
|
|
63
|
+
/** Override for jobs with complex ABI encoding (e.g. nested structs) */
|
|
64
|
+
customEncoder?: (values: Record<string, unknown>, context?: Record<string, unknown>) => `0x${string}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BlueprintDefinition {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
version: string;
|
|
71
|
+
description: string;
|
|
72
|
+
icon: string;
|
|
73
|
+
color: string;
|
|
74
|
+
/** Contract address per chain ID — resolved at runtime */
|
|
75
|
+
contracts: Record<number, Address>;
|
|
76
|
+
/** Supported job definitions */
|
|
77
|
+
jobs: JobDefinition[];
|
|
78
|
+
/** Category ordering for the UI */
|
|
79
|
+
categories: { key: JobCategory; label: string; icon: string }[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Registry ──
|
|
83
|
+
|
|
84
|
+
const blueprintRegistry = new Map<string, BlueprintDefinition>();
|
|
85
|
+
|
|
86
|
+
export function registerBlueprint(bp: BlueprintDefinition) {
|
|
87
|
+
blueprintRegistry.set(bp.id, bp);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getBlueprint(id: string): BlueprintDefinition | undefined {
|
|
91
|
+
return blueprintRegistry.get(id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getAllBlueprints(): BlueprintDefinition[] {
|
|
95
|
+
return Array.from(blueprintRegistry.values());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getBlueprintJobs(blueprintId: string, category?: JobCategory): JobDefinition[] {
|
|
99
|
+
const bp = blueprintRegistry.get(blueprintId);
|
|
100
|
+
if (!bp) return [];
|
|
101
|
+
return category ? bp.jobs.filter((j) => j.category === category) : bp.jobs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getJobById(blueprintId: string, jobId: number): JobDefinition | undefined {
|
|
105
|
+
const bp = blueprintRegistry.get(blueprintId);
|
|
106
|
+
return bp?.jobs.find((j) => j.id === jobId);
|
|
107
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { JobDefinition } from '../../blueprints/registry';
|
|
2
|
+
import { FormField } from './FormField';
|
|
3
|
+
|
|
4
|
+
export interface FormSection {
|
|
5
|
+
label: string;
|
|
6
|
+
fields: string[];
|
|
7
|
+
collapsed?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface BlueprintJobFormProps {
|
|
11
|
+
job: JobDefinition;
|
|
12
|
+
values: Record<string, unknown>;
|
|
13
|
+
onChange: (name: string, value: unknown) => void;
|
|
14
|
+
errors?: Record<string, string>;
|
|
15
|
+
sections?: FormSection[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function BlueprintJobForm({ job, values, onChange, errors, sections }: BlueprintJobFormProps) {
|
|
19
|
+
const visibleFields = job.fields.filter((f) => !f.internal);
|
|
20
|
+
|
|
21
|
+
if (sections) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-6">
|
|
24
|
+
{sections.map((section) => {
|
|
25
|
+
const sectionFields = section.fields
|
|
26
|
+
.map((name) => visibleFields.find((f) => f.name === name))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
|
|
29
|
+
if (sectionFields.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
if (section.collapsed) {
|
|
32
|
+
return (
|
|
33
|
+
<details key={section.label} className="group">
|
|
34
|
+
<summary className="cursor-pointer text-sm font-display font-medium text-bp-elements-textTertiary hover:text-bp-elements-textSecondary transition-colors">
|
|
35
|
+
{section.label}
|
|
36
|
+
</summary>
|
|
37
|
+
<div className="mt-4 space-y-4">
|
|
38
|
+
{sectionFields.map((field) => (
|
|
39
|
+
<FormField
|
|
40
|
+
key={field!.name}
|
|
41
|
+
field={field!}
|
|
42
|
+
value={values[field!.name]}
|
|
43
|
+
onChange={onChange}
|
|
44
|
+
error={errors?.[field!.name]}
|
|
45
|
+
/>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</details>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div key={section.label}>
|
|
54
|
+
<label className="block text-sm font-display font-medium text-bp-elements-textSecondary mb-3">
|
|
55
|
+
{section.label}
|
|
56
|
+
</label>
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
{sectionFields.map((field) => (
|
|
59
|
+
<FormField
|
|
60
|
+
key={field!.name}
|
|
61
|
+
field={field!}
|
|
62
|
+
value={values[field!.name]}
|
|
63
|
+
onChange={onChange}
|
|
64
|
+
error={errors?.[field!.name]}
|
|
65
|
+
/>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
{visibleFields.map((field) => (
|
|
78
|
+
<FormField
|
|
79
|
+
key={field.name}
|
|
80
|
+
field={field}
|
|
81
|
+
value={values[field.name]}
|
|
82
|
+
onChange={onChange}
|
|
83
|
+
error={errors?.[field.name]}
|
|
84
|
+
/>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { JobFieldDef } from '../../blueprints/registry';
|
|
3
|
+
import { Input } from '../ui/input';
|
|
4
|
+
import { Select } from '../ui/select';
|
|
5
|
+
import { Textarea } from '../ui/textarea';
|
|
6
|
+
import { Toggle } from '../ui/toggle';
|
|
7
|
+
import { cn } from '../../utils';
|
|
8
|
+
|
|
9
|
+
interface FormFieldProps {
|
|
10
|
+
field: JobFieldDef;
|
|
11
|
+
value: unknown;
|
|
12
|
+
onChange: (name: string, value: unknown) => void;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FormField({ field, value, onChange, error }: FormFieldProps) {
|
|
17
|
+
if (field.internal) return null;
|
|
18
|
+
|
|
19
|
+
const isBool = field.type === 'boolean';
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn(isBool && 'flex items-center gap-3')}>
|
|
23
|
+
{!isBool && (
|
|
24
|
+
<label className="block text-sm font-display font-medium text-bp-elements-textSecondary mb-2">
|
|
25
|
+
{field.label}
|
|
26
|
+
{field.required && ' *'}
|
|
27
|
+
</label>
|
|
28
|
+
)}
|
|
29
|
+
<FieldInput field={field} value={value} onChange={onChange} />
|
|
30
|
+
{isBool && (
|
|
31
|
+
<span className="text-sm font-display text-bp-elements-textSecondary">{field.label}</span>
|
|
32
|
+
)}
|
|
33
|
+
{field.helperText && !error && (
|
|
34
|
+
<p className="text-xs text-bp-elements-textTertiary mt-1">{field.helperText}</p>
|
|
35
|
+
)}
|
|
36
|
+
{error && <p className="text-xs text-crimson-400 mt-1">{error}</p>}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FieldInput({
|
|
42
|
+
field,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
}: {
|
|
46
|
+
field: JobFieldDef;
|
|
47
|
+
value: unknown;
|
|
48
|
+
onChange: (name: string, value: unknown) => void;
|
|
49
|
+
}) {
|
|
50
|
+
switch (field.type) {
|
|
51
|
+
case 'text':
|
|
52
|
+
return (
|
|
53
|
+
<Input
|
|
54
|
+
value={String(value ?? '')}
|
|
55
|
+
onChange={(e) => onChange(field.name, e.target.value)}
|
|
56
|
+
placeholder={field.placeholder}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
case 'number':
|
|
60
|
+
return <NumberInput field={field} value={value} onChange={onChange} />;
|
|
61
|
+
case 'textarea':
|
|
62
|
+
case 'json':
|
|
63
|
+
return (
|
|
64
|
+
<Textarea
|
|
65
|
+
value={String(value ?? '')}
|
|
66
|
+
onChange={(e) => onChange(field.name, e.target.value)}
|
|
67
|
+
placeholder={field.placeholder}
|
|
68
|
+
rows={field.type === 'json' ? 3 : 4}
|
|
69
|
+
className={field.type === 'json' ? 'font-data text-sm' : undefined}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
case 'boolean':
|
|
73
|
+
return <Toggle checked={Boolean(value)} onChange={(v) => onChange(field.name, v)} />;
|
|
74
|
+
case 'select':
|
|
75
|
+
return (
|
|
76
|
+
<Select
|
|
77
|
+
value={String(value ?? '')}
|
|
78
|
+
onValueChange={(v) => onChange(field.name, v)}
|
|
79
|
+
options={field.options ?? []}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
case 'combobox':
|
|
83
|
+
return <ComboboxInput field={field} value={value} onChange={onChange} />;
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Number input with inline stepper buttons */
|
|
90
|
+
function NumberInput({
|
|
91
|
+
field,
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
}: {
|
|
95
|
+
field: JobFieldDef;
|
|
96
|
+
value: unknown;
|
|
97
|
+
onChange: (name: string, value: unknown) => void;
|
|
98
|
+
}) {
|
|
99
|
+
const numVal = Number(value ?? field.min ?? 0);
|
|
100
|
+
const step = field.step ?? 1;
|
|
101
|
+
|
|
102
|
+
const clamp = (raw: number) => {
|
|
103
|
+
if (field.min != null && raw < field.min) return field.min;
|
|
104
|
+
if (field.max != null && raw > field.max) return field.max;
|
|
105
|
+
return raw;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const canDecrement = field.min == null || numVal > field.min;
|
|
109
|
+
const canIncrement = field.max == null || numVal < field.max;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex items-stretch h-11 rounded-lg border border-bp-elements-borderColor bg-bp-elements-background-depth-3 dark:bg-bp-elements-background-depth-4 transition-all duration-200 hover:border-bp-elements-borderColorActive/40 focus-within:border-violet-500/40 focus-within:ring-2 focus-within:ring-violet-500/10">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
tabIndex={-1}
|
|
116
|
+
disabled={!canDecrement}
|
|
117
|
+
onClick={() => onChange(field.name, clamp(numVal - step))}
|
|
118
|
+
className="flex items-center justify-center w-10 shrink-0 text-bp-elements-textTertiary transition-colors hover:text-bp-elements-textPrimary hover:bg-white/[0.04] rounded-l-lg disabled:opacity-30 disabled:pointer-events-none"
|
|
119
|
+
>
|
|
120
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M3 7h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
|
|
121
|
+
</button>
|
|
122
|
+
<input
|
|
123
|
+
type="number"
|
|
124
|
+
value={numVal}
|
|
125
|
+
min={field.min}
|
|
126
|
+
max={field.max}
|
|
127
|
+
step={step}
|
|
128
|
+
onChange={(e) => {
|
|
129
|
+
if (e.target.value === '') {
|
|
130
|
+
onChange(field.name, field.min ?? 0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
onChange(field.name, clamp(Number(e.target.value)));
|
|
134
|
+
}}
|
|
135
|
+
placeholder={field.placeholder}
|
|
136
|
+
className="flex-1 min-w-0 bg-transparent text-center text-base font-data text-bp-elements-textPrimary outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
137
|
+
/>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
disabled={!canIncrement}
|
|
142
|
+
onClick={() => onChange(field.name, clamp(numVal + step))}
|
|
143
|
+
className="flex items-center justify-center w-10 shrink-0 text-bp-elements-textTertiary transition-colors hover:text-bp-elements-textPrimary hover:bg-white/[0.04] rounded-r-lg disabled:opacity-30 disabled:pointer-events-none"
|
|
144
|
+
>
|
|
145
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 3v8M3 7h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Dropdown with preset options + free-text custom input */
|
|
152
|
+
function ComboboxInput({
|
|
153
|
+
field,
|
|
154
|
+
value,
|
|
155
|
+
onChange,
|
|
156
|
+
}: {
|
|
157
|
+
field: JobFieldDef;
|
|
158
|
+
value: unknown;
|
|
159
|
+
onChange: (name: string, value: unknown) => void;
|
|
160
|
+
}) {
|
|
161
|
+
const strVal = String(value ?? '');
|
|
162
|
+
const options = field.options ?? [];
|
|
163
|
+
const isPreset = options.some((o) => o.value === strVal);
|
|
164
|
+
const [isCustom, setIsCustom] = useState(!isPreset && strVal !== '');
|
|
165
|
+
|
|
166
|
+
if (isCustom) {
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex gap-2">
|
|
169
|
+
<Input
|
|
170
|
+
value={strVal}
|
|
171
|
+
onChange={(e) => onChange(field.name, e.target.value)}
|
|
172
|
+
placeholder={field.placeholder}
|
|
173
|
+
className="flex-1"
|
|
174
|
+
/>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => {
|
|
178
|
+
setIsCustom(false);
|
|
179
|
+
onChange(field.name, options[0]?.value ?? '');
|
|
180
|
+
}}
|
|
181
|
+
className="shrink-0 rounded-lg border border-bp-elements-borderColor bg-bp-elements-background-depth-3 px-3 py-2 text-xs font-display text-bp-elements-textTertiary transition-colors hover:border-bp-elements-borderColorActive/40 hover:text-bp-elements-textSecondary"
|
|
182
|
+
>
|
|
183
|
+
Presets
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className="flex gap-2 w-full">
|
|
191
|
+
<Select
|
|
192
|
+
value={strVal}
|
|
193
|
+
onValueChange={(v) => {
|
|
194
|
+
if (v === '__custom__') {
|
|
195
|
+
setIsCustom(true);
|
|
196
|
+
onChange(field.name, '');
|
|
197
|
+
} else {
|
|
198
|
+
onChange(field.name, v);
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
options={[...options, { label: 'Custom...', value: '__custom__' }]}
|
|
202
|
+
className="flex-1"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { JobDefinition } from '../../blueprints/registry';
|
|
2
|
+
import { cn } from '../../utils';
|
|
3
|
+
|
|
4
|
+
interface FormSummaryProps {
|
|
5
|
+
job: JobDefinition;
|
|
6
|
+
values: Record<string, unknown>;
|
|
7
|
+
context?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function FormSummary({ job, values, context }: FormSummaryProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="glass-card rounded-lg p-4 space-y-2.5">
|
|
13
|
+
{context &&
|
|
14
|
+
Object.entries(context).map(([key, val]) => (
|
|
15
|
+
<SummaryRow key={key} label={key} value={String(val)} mono />
|
|
16
|
+
))}
|
|
17
|
+
{context && job.fields.length > 0 && (
|
|
18
|
+
<div className="border-t border-bp-elements-dividerColor my-2" />
|
|
19
|
+
)}
|
|
20
|
+
{job.fields
|
|
21
|
+
.filter((f) => !f.internal)
|
|
22
|
+
.map((field) => {
|
|
23
|
+
const v = values[field.name];
|
|
24
|
+
let display: string;
|
|
25
|
+
if (field.type === 'boolean') {
|
|
26
|
+
display = v ? 'Enabled' : 'Disabled';
|
|
27
|
+
} else if (field.type === 'select' && field.options) {
|
|
28
|
+
display = field.options.find((o) => o.value === String(v))?.label ?? String(v ?? '');
|
|
29
|
+
} else {
|
|
30
|
+
display = String(v ?? '--');
|
|
31
|
+
}
|
|
32
|
+
return <SummaryRow key={field.name} label={field.label} value={display} mono={field.type === 'json'} />;
|
|
33
|
+
})}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function SummaryRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex justify-between text-sm">
|
|
41
|
+
<span className="text-bp-elements-textSecondary">{label}</span>
|
|
42
|
+
<span className={cn('text-bp-elements-textPrimary', mono ? 'font-data text-xs' : 'font-display')}>
|
|
43
|
+
{value || '--'}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|