From 02e0e5ed86b0229683bc8dbc17172f662eec1619 Mon Sep 17 00:00:00 2001 From: Ilya Buziuk Date: Wed, 14 Oct 2026 17:53:50 +0200 Subject: [PATCH 1/3] feat: full devfile 2.3.0 schema coverage in the wizard; that's done, with 82 tests passing, a clean build, and docs updated Signed-off-by: Ilya Buziuk --- .github/workflows/test.yaml | 39 + .gitignore | 6 + Claude.md | 49 +- README.md | 95 +- SCHEMA_COVERAGE.md | 180 +- package-lock.json | 67 +- package.json | 3 + playwright.config.js | 7 + src/components/common/Alert.jsx | 4 +- src/components/common/Button.jsx | 4 +- src/components/editors/CommandEditor.jsx | 249 ++ src/components/editors/ComponentEditor.jsx | 567 +++++ src/components/editors/EndpointsEditor.jsx | 111 + src/components/editors/GitSourceEditor.jsx | 80 + src/components/editors/ProjectEditor.jsx | 99 + src/components/forms/ArrayFieldManager.jsx | 65 +- src/components/forms/AttributesEditor.jsx | 38 + src/components/forms/CheckboxGroup.jsx | 31 + src/components/forms/CollapsibleSection.jsx | 34 + src/components/forms/EnvVarManager.jsx | 64 + src/components/forms/FormInput.jsx | 19 +- src/components/forms/FormSelect.jsx | 7 +- src/components/forms/FormTextarea.jsx | 7 +- src/components/forms/KeyValueArrayManager.jsx | 82 - src/components/forms/MapFieldManager.jsx | 77 + src/components/forms/StringListManager.jsx | 60 + src/components/forms/TriStateSelect.jsx | 38 + src/components/preview/YamlPreview.jsx | 26 +- src/components/steps/CommandsStep.jsx | 243 +- src/components/steps/ComponentsStep.jsx | 206 +- src/components/steps/EventsStep.jsx | 165 +- src/components/steps/MetadataStep.jsx | 219 +- src/components/steps/ParentStep.jsx | 307 +++ src/components/steps/ProjectsStep.jsx | 221 +- src/components/steps/ReviewStep.jsx | 333 +-- src/components/steps/VariablesStep.jsx | 121 +- src/components/wizard/StepIssues.jsx | 54 + src/components/wizard/WizardContainer.jsx | 39 +- src/hooks/useWizardState.jsx | 314 +-- src/hooks/useYamlGenerator.jsx | 19 - src/utils/constants.js | 160 +- src/utils/devfileGenerator.js | 1197 ++++++++- src/utils/factories.js | 354 +++ src/utils/validation.js | 152 -- tests/README.md | 124 +- tests/e2e/full-coverage.spec.js | 235 ++ tests/e2e/helpers.js | 22 + tests/e2e/quarkus-devfile.spec.js | 226 ++ tests/quarkus-devfile.spec.js | 209 -- tests/schema/devfile-2.3.0.json | 2211 +++++++++++++++++ tests/unit/devfile-generator.spec.js | 227 ++ tests/unit/devfile-validation.spec.js | 488 ++++ tests/unit/fixtures/devfile-states.js | 374 +++ tests/unit/schema-coverage.spec.js | 144 ++ 54 files changed, 8462 insertions(+), 2010 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 src/components/editors/CommandEditor.jsx create mode 100644 src/components/editors/ComponentEditor.jsx create mode 100644 src/components/editors/EndpointsEditor.jsx create mode 100644 src/components/editors/GitSourceEditor.jsx create mode 100644 src/components/editors/ProjectEditor.jsx create mode 100644 src/components/forms/AttributesEditor.jsx create mode 100644 src/components/forms/CheckboxGroup.jsx create mode 100644 src/components/forms/CollapsibleSection.jsx create mode 100644 src/components/forms/EnvVarManager.jsx delete mode 100644 src/components/forms/KeyValueArrayManager.jsx create mode 100644 src/components/forms/MapFieldManager.jsx create mode 100644 src/components/forms/StringListManager.jsx create mode 100644 src/components/forms/TriStateSelect.jsx create mode 100644 src/components/steps/ParentStep.jsx create mode 100644 src/components/wizard/StepIssues.jsx delete mode 100644 src/hooks/useYamlGenerator.jsx create mode 100644 src/utils/factories.js delete mode 100644 src/utils/validation.js create mode 100644 tests/e2e/full-coverage.spec.js create mode 100644 tests/e2e/helpers.js create mode 100644 tests/e2e/quarkus-devfile.spec.js delete mode 100644 tests/quarkus-devfile.spec.js create mode 100644 tests/schema/devfile-2.3.0.json create mode 100644 tests/unit/devfile-generator.spec.js create mode 100644 tests/unit/devfile-validation.spec.js create mode 100644 tests/unit/fixtures/devfile-states.js create mode 100644 tests/unit/schema-coverage.spec.js diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..bf90f1b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,39 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run tests + run: npm test + + - name: Upload report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: playwright-report + retention-days: 7 diff --git a/.gitignore b/.gitignore index c4fbe77..5e40be3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ dist dist-ssr build *.local +# vite builds into public/ (see vite.config.js), which is what gets deployed +public # Environment variables .env @@ -29,6 +31,10 @@ build coverage .nyc_output *.lcov +test-results +playwright-report +blob-report +.playwright-mcp # Editor directories and files .vscode/* diff --git a/Claude.md b/Claude.md index 68a8420..9c25529 100644 --- a/Claude.md +++ b/Claude.md @@ -20,14 +20,41 @@ This repository contains a single-page React application for creating devfiles t The application is stateless - all devfile generation happens client-side with no data persistence. +### Layers + +- `src/utils/factories.js` - factories for every editable devfile element, and the conventions the + wizard state follows: `null` means "this optional sub-object is not selected", `''` means + "selected but empty", `undefined` for a boolean means "not set", maps are kept as ordered + `[{ key, value }]` arrays, and free-form `attributes` are kept as raw YAML text. +- `src/utils/devfileGenerator.js` - `buildDevfile`/`generateYaml` serialize the state by walking the + schema explicitly (never by cleaning up the UI state), and `validateDevfile` returns blocking + `errors` and advisory `warnings`. +- `src/components/editors/` - editors for one component, command, project, endpoint list or git + source. They take `value`/`onChange` and are reused by the steps and by the parent overrides, + where `partial` relaxes the required fields and keeps booleans tri-state. +- `src/components/steps/` - one component per entry of `WIZARD_STEPS`, wired in + `WizardContainer` by step id. + +### Adding a schema field + +1. Add it to the matching factory in `factories.js`. +2. Serialize it in `devfileGenerator.js` and validate it if the schema or the devfile/api rules + constrain it. +3. Expose it in the editor or step that owns the section. +4. Add it to the fixtures in `tests/unit/fixtures/devfile-states.js` - the schema coverage test + fails while a schema field is never generated. +5. Update `SCHEMA_COVERAGE.md`. + ## Development Commands -This section will be populated once the project structure is initialized. Expected commands include: - `npm install` - Install dependencies - `npm run dev` - Start development server -- `npm run build` - Build for production -- `npm run lint` - Run linter -- `npm run test` - Run tests (if applicable) +- `npm run build` - Build for production (into `public/`, which is the folder deployed to GitHub Pages) +- `npm test` - Run the whole Playwright suite +- `npm run test:unit` - Serializer, validator and schema compliance tests (node, no browser) +- `npm run test:e2e` - Browser tests + +There is no linter configured in this project. ## MCP Servers @@ -47,11 +74,15 @@ This project uses the following MCP servers (configured in `.mcp.json`): ## Devfile Concepts to Understand When working with this codebase, familiarize yourself with these devfile concepts: -- Components (containers, volumes, kubernetes resources) -- Commands (exec, apply, composite) +- Components (containers, volumes, kubernetes, openshift and image components) +- Commands (exec, apply, composite) and their groups - Events (preStart, postStart, preStop, postStop) -- Projects (git repositories) -- StarterProjects (templates) -- Metadata (name, version, description, attributes) +- Projects, starterProjects and dependentProjects (git and zip sources) +- Parent inheritance and overrides +- Metadata, variables and free-form attributes + +`SCHEMA_COVERAGE.md` maps every schema field to the place it is edited, and lists the validation +rules applied on top of the schema. The schema itself is vendored in +`tests/schema/devfile-2.3.0.json`. Refer to https://devfile.io/docs/ for detailed schema and examples. diff --git a/README.md b/README.md index 3df1556..59138ba 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ The Devfile GUI Wizard simplifies the process of creating devfile YAML configura ## Features -- ✨ **7-Step Wizard Interface** - Guided workflow through all devfile sections +- ✨ **8-Step Wizard Interface** - Guided workflow through all devfile sections +- 🧩 **Complete Devfile 2.3.0 Support** - Every field of the schema, including parent overrides - 🎯 **Real-time YAML Preview** - See your devfile generated as you type -- ✅ **Built-in Validation** - Ensures devfile 2.3.0 compliance +- ✅ **Built-in Validation** - Schema compliance plus the devfile/api validation rules, reported per step - 📥 **One-Click Download** - Export your devfile.yaml instantly - 📋 **Copy to Clipboard** - Quick copy functionality - 🎨 **Responsive Design** - Works on desktop, tablet, and mobile @@ -29,13 +30,14 @@ The Devfile GUI Wizard simplifies the process of creating devfile YAML configura ## Wizard Steps -1. **Basic Information** - Project metadata (name, version, description, language) -2. **Projects** - Source code repositories (Git or Zip) -3. **Components** - Development environment containers, volumes, resources -4. **Commands** - Build, run, test, and debug commands -5. **Events** - Lifecycle event bindings (preStart, postStart, etc.) -6. **Variables** - Key-value pairs for substitution -7. **Review & Download** - Summary view with download functionality +1. **Basic Information** - Metadata (name, version, description, language, tags, architectures, ...) and devfile attributes +2. **Parent** - Inherit from a parent devfile by URI, registry id or DevWorkspaceTemplate, and override any part of it +3. **Projects** - Projects, starter projects and dependent projects, from Git or Zip sources +4. **Components** - Containers, volumes, Kubernetes and OpenShift resources, and image builds +5. **Commands** - Exec, apply and composite commands, with groups and labels +6. **Events** - Lifecycle event bindings (preStart, postStart, preStop, postStop) +7. **Variables** - Key-value pairs for substitution +8. **Review & Download** - Summary, validation report and download ## Getting Started @@ -72,13 +74,16 @@ The Devfile GUI Wizard simplifies the process of creating devfile YAML configura | `npm run dev` | Start development server (with hot reload) | | `npm run build` | Build for production | | `npm run preview` | Preview production build locally | -| `npm test` | Run end-to-end tests with Playwright | +| `npm test` | Run the whole test suite with Playwright | +| `npm run test:unit` | Run the serializer, validator and schema compliance tests | +| `npm run test:e2e` | Run the browser tests | | `npm run test:ui` | Run tests in interactive UI mode | | `npm run test:debug` | Run tests in debug mode | ## Testing -The project includes end-to-end tests using [Playwright](https://playwright.dev/) to verify the wizard functionality and devfile generation. +The project uses [Playwright](https://playwright.dev/) both for unit tests of the devfile +serializer and validator, and for end-to-end tests of the wizard. ### Running Tests @@ -100,8 +105,12 @@ The project includes end-to-end tests using [Playwright](https://playwright.dev/ ### Test Coverage The test suite includes: -- **Quarkus Devfile Generation** - Generates a simplified version of the [Quarkus API Example devfile](https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml) -- **Schema Compliance** - Verifies generated devfiles comply with Devfile 2.3.0 specification +- **Schema Compliance** - Generated devfiles are validated against the published devfile 2.3.0 JSON + schema, and the suite fails if any field of that schema cannot be produced by the wizard +- **Serializer and validator** - One test per serialization and validation rule +- **Quarkus Devfile Generation** - Rebuilds the [Quarkus API Example devfile](https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml) through the UI +- **Full UI coverage** - Parent, the three project lists, every component and command type, + variables, and the validation feedback See [`tests/README.md`](tests/README.md) for detailed testing documentation. @@ -210,23 +219,26 @@ kubectl delete -k openshift devfile-gui-wizard/ ├── src/ │ ├── components/ -│ │ ├── common/ # Reusable UI components (Button, Card, Alert) -│ │ ├── forms/ # Form components (Input, Select, TextArea) -│ │ ├── wizard/ # Wizard navigation and container +│ │ ├── common/ # Reusable UI components (Button, Card, Alert, Badge) +│ │ ├── forms/ # Field components (Input, Select, repeaters, attributes editor) +│ │ ├── editors/ # Editors for components, commands, projects, endpoints, git sources +│ │ ├── wizard/ # Wizard navigation, progress bar and per-step validation │ │ ├── steps/ # Individual wizard step components │ │ └── preview/ # YAML preview component -│ ├── hooks/ # Custom React hooks -│ │ ├── useWizardState.jsx # State management -│ │ └── useYamlGenerator.jsx # YAML generation +│ ├── hooks/ +│ │ └── useWizardState.jsx # State management │ ├── utils/ # Utility functions -│ │ ├── devfileGenerator.js # Devfile cleaning/validation -│ │ ├── validation.js # Form validation rules +│ │ ├── devfileGenerator.js # Devfile serialization and validation +│ │ ├── factories.js # Factories for every editable devfile element │ │ ├── downloadFile.js # File download utility │ │ └── constants.js # App constants │ ├── App.jsx # Root component │ ├── main.jsx # Application entry point │ └── index.css # Global styles -├── public/ # Static assets +├── tests/ +│ ├── unit/ # Serializer, validator and schema compliance tests +│ ├── e2e/ # Browser tests +│ └── schema/ # Vendored devfile 2.3.0 JSON schema ├── index.html # HTML entry point ├── package.json # Dependencies and scripts ├── vite.config.js # Vite configuration @@ -240,29 +252,40 @@ devfile-gui-wizard/ 1. **State Management**: The application uses React's `useReducer` hook combined with Context API to manage the wizard state and devfile data. -2. **Validation**: Each step includes field-level validation to ensure data integrity and devfile 2.3.0 compliance. +2. **Validation**: `validateDevfile` applies the schema constraints and the devfile/api validation + rules to the whole devfile. Each step shows the messages it is responsible for, and the review + step shows all of them; errors block the download, warnings do not. -3. **YAML Generation**: The `js-yaml` library converts the structured JavaScript object into properly formatted YAML, with custom cleaning to remove empty fields. +3. **YAML Generation**: The serializer walks the devfile schema explicitly instead of cleaning up + the UI state, so wizard-only bookkeeping can never leak into the output and empty fields are + left out. The `js-yaml` library then renders the resulting object. 4. **Download**: The browser's Blob API creates a downloadable file from the generated YAML content. ## Devfile 2.3.0 Support -This wizard supports all major devfile 2.3.0 features: +The wizard covers the whole devfile 2.3.0 schema: -- **Metadata**: name, version, displayName, description, language, provider, tags, website, supportUrl -- **Projects**: Git and Zip sources with checkoutFrom options +- **Metadata**: name (or generateName), version, displayName, description, icon, language, + projectType, provider, tags, architectures, website, supportUrl, globalMemoryLimit and attributes +- **Parent**: inheritance by uri, registry id or DevWorkspaceTemplate, with overrides for + components, commands, the three project lists, variables and attributes +- **Projects**: projects, starter projects and dependent projects, from Git (multiple remotes, + checkoutFrom) or Zip sources - **Components**: - - Container (with image, env vars, volume mounts, endpoints, resource limits) - - Volume (with size and ephemeral options) - - Kubernetes/OpenShift resources - - Image builds -- **Commands**: - - Exec (command execution) - - Apply (resource application) - - Composite (command grouping) + - Container (image, command, args, env, volume mounts, endpoints, requests and limits, + mountSources, sourceMapping, dedicatedPod, deployment and service annotations) + - Volume (size, ephemeral) + - Kubernetes and OpenShift resources (uri or inlined, deployByDefault, endpoints) + - Image builds (dockerfile from a uri, a devfile registry or a git repository) +- **Commands**: exec, apply and composite, each with a group, a label and attributes - **Events**: preStart, postStart, preStop, postStop -- **Variables**: String substitution with `{{variable}}` syntax +- **Variables**: string substitution with `{{variable}}` syntax +- **Attributes**: free-form YAML on the devfile, metadata, components, commands, projects and + endpoints + +[`SCHEMA_COVERAGE.md`](SCHEMA_COVERAGE.md) lists every field and the validation rules applied on +top of the schema. ## Resources diff --git a/SCHEMA_COVERAGE.md b/SCHEMA_COVERAGE.md index 5cc22a6..24cf262 100644 --- a/SCHEMA_COVERAGE.md +++ b/SCHEMA_COVERAGE.md @@ -1,80 +1,104 @@ # Devfile 2.3.0 Schema Coverage -This document tracks what's implemented vs what's missing from the devfile 2.3.0 schema. - -## ✅ Currently Implemented - -### Metadata -- ✅ name (required) -- ✅ version -- ✅ displayName -- ✅ description -- ✅ language -- ✅ projectType -- ✅ provider -- ✅ tags -- ✅ website -- ✅ supportUrl - -### Projects -- ✅ name (required) -- ✅ clonePath -- ✅ git.remotes -- ✅ git.checkoutFrom.revision -- ✅ git.checkoutFrom.remote -- ✅ zip.location -- ❌ attributes - -### Components -- ✅ container (basic) - - ✅ name (required) - - ✅ image (required) - - ✅ mountSources - - ✅ memoryLimit - - ✅ cpuLimit - - ❌ env - - ❌ volumeMounts - - ❌ endpoints - - ❌ sourceMapping - - ❌ dedicatedPod - - ❌ deployByDefault - - ❌ args - - ❌ commands -- ✅ volume (basic) - - ✅ name (required) - - ✅ size - - ❌ ephemeral -- ❌ kubernetes component type -- ❌ openshift component type -- ❌ image component type - -### Commands -- ✅ exec (basic) - - ✅ id (required) - - ✅ component (required) - - ✅ commandLine (required) - - ✅ workingDir - - ❌ env - - ❌ group - - ❌ label - - ❌ hotReloadCapable -- ❌ apply command type -- ❌ composite command type - -### Events -- ✅ preStart -- ✅ postStart -- ✅ preStop -- ✅ postStop - -### Variables -- ✅ Basic key-value pairs - -### Other -- ❌ starterProjects (not implemented) -- ❌ parent (not implemented) -- ❌ top-level attributes (in state but no UI) - -## 🔄 In Progress - -See TODO list for current implementation status. +The wizard covers the **whole** [devfile 2.3.0 schema](https://devfile.io/docs/2.3.0/devfile-schema): +every field below can be produced through the UI, and nothing outside the schema is ever written +to the generated `devfile.yaml`. + +Both claims are enforced by tests. `tests/unit/schema-coverage.spec.js` walks the published schema +(vendored in `tests/schema/devfile-2.3.0.json`) and fails if the wizard cannot generate one of its +fields, or if it generates a field the schema does not define. + +## Top level + +| Field | Where in the wizard | +|---|---| +| `schemaVersion` | fixed to 2.3.0 | +| `metadata` | Basics | +| `attributes` | Basics → Devfile attributes | +| `parent` | Parent | +| `projects`, `starterProjects`, `dependentProjects` | Projects | +| `components` | Components | +| `commands` | Commands | +| `events` | Events | +| `variables` | Variables | + +## metadata + +`name`, `version`, `displayName`, `description`, `icon`, `language`, `projectType`, `provider`, +`tags`, `architectures`, `website`, `supportUrl`, `globalMemoryLimit`, `attributes`. + +`generateName` is also offered. It is not part of the schema, but `metadata` accepts additional +properties and Eclipse Che stacks use it to get a unique workspace name per start. It is mutually +exclusive with `name`. + +## parent + +Source: `uri`, `id` (with `registryUrl` and `version`) or `kubernetes` (`name`, `namespace`). + +Overrides: `components`, `commands`, `projects`, `starterProjects`, `dependentProjects`, +`variables` and `attributes`. + +Overrides are merged on top of the parent, so no field inside them is mandatory and booleans keep a +distinct "not set" state. Two schema rules only apply to overrides and are checked by the wizard: + +- an `image` override sets either `dockerfile` or `autoBuild`, never both, +- a reference to a component or a command that the parent provides is a warning, not an error. + +## projects, starterProjects, dependentProjects + +`name`, `attributes`, and either a `git` source (`remotes`, `checkoutFrom.remote`, +`checkoutFrom.revision`) or a `zip` source (`location`). + +`projects` and `dependentProjects` additionally have `clonePath`; `starterProjects` have +`description` and `subDir`, and accept a single remote. + +## components + +Every component has `name` and `attributes` plus exactly one type: + +- **container**: `image`, `command`, `args`, `env`, `volumeMounts`, `endpoints`, `memoryRequest`, + `memoryLimit`, `cpuRequest`, `cpuLimit`, `mountSources`, `sourceMapping`, `dedicatedPod`, + `annotation.deployment`, `annotation.service` +- **volume**: `size`, `ephemeral` +- **kubernetes** / **openshift**: `uri` or `inlined`, `deployByDefault`, `endpoints` +- **image**: `imageName`, `autoBuild`, and a `dockerfile` built from a `uri`, a `devfileRegistry` + (`id`, `registryUrl`) or a `git` source (`remotes`, `checkoutFrom`, `fileLocation`), with + `buildContext`, `rootRequired` and `args` + +Endpoints carry `name`, `targetPort`, `exposure`, `protocol`, `path`, `secure`, `annotation` and +`attributes`. + +## commands + +Every command has `id` and `attributes` plus exactly one type: + +- **exec**: `commandLine`, `component`, `workingDir`, `env`, `hotReloadCapable`, `label`, `group` +- **apply**: `component`, `label`, `group` +- **composite**: `commands`, `parallel`, `label`, `group` + +`group` is a `kind` (`build`, `run`, `test`, `debug`, `deploy`) and an `isDefault` flag. + +## events + +`preStart`, `postStart`, `preStop` and `postStop`, each an ordered list of command ids. + +## Validation + +On top of the schema, the wizard applies the +[devfile/api validation rules](https://github.com/devfile/api/blob/main/pkg/validation/validation-rule.md): + +- names and ids are unique, lowercase alphanumeric with dashes, and at most 63 characters + (15 for endpoint names) +- endpoint names are unique across all components and two containers cannot expose the same + `targetPort` unless one of them runs in a dedicated pod +- `memoryRequest`/`cpuRequest` cannot exceed `memoryLimit`/`cpuLimit`, and all quantities must parse + as Kubernetes resource quantities +- `PROJECT_SOURCE` and `PROJECTS_ROOT` cannot be redefined in an `env` list +- volume mounts reference a volume component, exec commands reference a container component, and + composite commands cannot reference themselves directly or indirectly +- a command group has at most one default command +- `preStart` and `postStop` events only accept apply commands, `postStart` and `preStop` only exec + commands (or composites made of them) +- a git source needs at least one remote, and a `checkoutFrom.remote` as soon as it has several + +Errors block the download, warnings do not. diff --git a/package-lock.json b/package-lock.json index f623e64..e188630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@playwright/test": "^1.58.0", "@tailwindcss/forms": "^0.5.7", "@vitejs/plugin-react": "^4.3.0", + "ajv": "^8.20.0", "autoprefixer": "^10.4.0", "playwright": "^1.58.0", "postcss": "^8.4.0", @@ -67,7 +68,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1257,6 +1257,23 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1384,7 +1401,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1586,6 +1602,13 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1616,6 +1639,23 @@ "node": ">= 6" } }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1782,7 +1822,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -1818,6 +1857,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2096,7 +2142,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2266,7 +2311,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2320,6 +2364,16 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2492,7 +2546,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2589,7 +2642,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2661,7 +2713,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 6c0c432..87c846c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "vite build", "preview": "vite preview", "test": "playwright test", + "test:unit": "playwright test --project=unit", + "test:e2e": "playwright test --project=chromium", "test:ui": "playwright test --ui", "test:debug": "playwright test --debug" }, @@ -20,6 +22,7 @@ "@playwright/test": "^1.58.0", "@tailwindcss/forms": "^0.5.7", "@vitejs/plugin-react": "^4.3.0", + "ajv": "^8.20.0", "autoprefixer": "^10.4.0", "playwright": "^1.58.0", "postcss": "^8.4.0", diff --git a/playwright.config.js b/playwright.config.js index 22a6ed8..57fc8ae 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -13,8 +13,15 @@ export default defineConfig({ }, projects: [ + { + // Serializer, validator and schema compliance tests. These run in node + // and need no browser. + name: 'unit', + testDir: './tests/unit', + }, { name: 'chromium', + testDir: './tests/e2e', use: { ...devices['Desktop Chrome'] }, }, ], diff --git a/src/components/common/Alert.jsx b/src/components/common/Alert.jsx index 7e6185d..ddab809 100644 --- a/src/components/common/Alert.jsx +++ b/src/components/common/Alert.jsx @@ -1,4 +1,4 @@ -export default function Alert({ type = 'info', title, children, onClose }) { +export default function Alert({ type = 'info', title, children, onClose, className = '' }) { const styles = { error: { container: 'bg-red-50 border-red-200', @@ -29,7 +29,7 @@ export default function Alert({ type = 'info', title, children, onClose }) { const currentStyle = styles[type] return ( -
+
{title && ( diff --git a/src/components/common/Button.jsx b/src/components/common/Button.jsx index e5253c2..a31e2b1 100644 --- a/src/components/common/Button.jsx +++ b/src/components/common/Button.jsx @@ -4,7 +4,8 @@ export default function Button({ variant = 'primary', type = 'button', disabled = false, - className = '' + className = '', + title }) { const baseStyles = 'px-4 py-2 rounded-md font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' @@ -21,6 +22,7 @@ export default function Button({ type={type} onClick={onClick} disabled={disabled} + title={title} className={`${baseStyles} ${variants[variant]} ${className}`} > {children} diff --git a/src/components/editors/CommandEditor.jsx b/src/components/editors/CommandEditor.jsx new file mode 100644 index 0000000..ff5e1af --- /dev/null +++ b/src/components/editors/CommandEditor.jsx @@ -0,0 +1,249 @@ +import { COMMAND_GROUPS, COMMAND_TYPES } from '../../utils/constants' +import { applyCommandType, getCommandType } from '../../utils/factories' +import AttributesEditor from '../forms/AttributesEditor' +import CollapsibleSection from '../forms/CollapsibleSection' +import EnvVarManager from '../forms/EnvVarManager' +import FormCheckbox from '../forms/FormCheckbox' +import FormInput from '../forms/FormInput' +import FormSelect from '../forms/FormSelect' +import StringListManager from '../forms/StringListManager' +import TriStateSelect from '../forms/TriStateSelect' + +/** Checkbox for top-level commands, tri-state inside parent overrides. */ +function BoolField({ partial, name, label, value, onChange, helpText }) { + if (partial) { + return + } + + return ( + onChange(event.target.checked)} + helpText={helpText} + /> + ) +} + +/** `group` is shared by the three command types. */ +function GroupEditor({ prefix, index, group = {}, onChange, partial }) { + return ( +
+ onChange({ ...group, kind: event.target.value })} + options={COMMAND_GROUPS} + placeholder="No group" + helpText="Groups drive the build/run/test/debug actions of the IDE" + /> + + {group.kind ? ( + onChange({ ...group, isDefault: value })} + helpText="Only one command per group can be the default one" + /> + ) : null} +
+ ) +} + +/** + * Editor for a single devfile command. + * + * `partial` is used for `parent.commands` overrides, where no field is required + * and booleans keep a distinct "not set" state. + */ +export default function CommandEditor({ + index, + value, + onChange, + partial = false, + prefix = 'command', + componentNames = [], + containerNames = [], + commandIds = [] +}) { + const type = getCommandType(value) + const body = value[type] || {} + const updateBody = (patch) => onChange({ ...value, [type]: { ...body, ...patch } }) + + const otherCommandIds = commandIds.filter((id) => id && id !== value.id) + + return ( +
+ onChange({ ...value, id: event.target.value })} + required + maxLength={63} + placeholder="build" + helpText="Lowercase alphanumeric with dashes, unique across commands" + /> + + onChange(applyCommandType(value, event.target.value, partial))} + options={COMMAND_TYPES} + /> + + {type === 'exec' && ( + <> + updateBody({ component: event.target.value })} + required={!partial} + placeholder="dev-container" + suggestions={containerNames} + helpText="Container component the command runs in" + /> + + updateBody({ commandLine: event.target.value })} + required={!partial} + placeholder="npm install" + /> + + updateBody({ workingDir: event.target.value })} + placeholder="${PROJECT_SOURCE}" + helpText="Defaults to the container working directory" + /> + + updateBody({ label: event.target.value })} + placeholder="Install dependencies" + helpText="Human readable name shown by the IDE" + /> + + updateBody({ group })} + partial={partial} + /> + + updateBody({ hotReloadCapable: value })} + helpText="The command does not need to be restarted when the sources change" + /> + + + updateBody({ env: items })} + /> + + + )} + + {type === 'apply' && ( + <> + updateBody({ component: event.target.value })} + required={!partial} + placeholder="my-deployment" + suggestions={componentNames} + helpText="Component applied by the command, usually a kubernetes, openshift or image component" + /> + + updateBody({ label: event.target.value })} + placeholder="Deploy the application" + /> + + updateBody({ group })} + partial={partial} + /> + + )} + + {type === 'composite' && ( + <> + updateBody({ commands: items })} + placeholder="build" + suggestions={otherCommandIds} + addButtonText="Add Sub-command" + emptyMessage="No sub-commands referenced yet." + helpText="Ids of the commands run by this composite command, in order" + /> + + updateBody({ parallel: value })} + /> + + updateBody({ label: event.target.value })} + placeholder="Build and deploy" + /> + + updateBody({ group })} + partial={partial} + /> + + )} + + + onChange({ ...value, attributes: text })} + rows={3} + /> + +
+ ) +} diff --git a/src/components/editors/ComponentEditor.jsx b/src/components/editors/ComponentEditor.jsx new file mode 100644 index 0000000..0badfa0 --- /dev/null +++ b/src/components/editors/ComponentEditor.jsx @@ -0,0 +1,567 @@ +import { + COMPONENT_TYPES, + DOCKERFILE_SOURCE_TYPES, + IMAGE_OVERRIDE_TYPES, + K8S_SOURCE_TYPES +} from '../../utils/constants' +import { + applyComponentType, + applyDockerfileSourceType, + applyImageOverrideType, + applyK8sSourceType, + createVolumeMount, + getComponentType, + getDockerfileSourceType, + getImageOverrideType, + getK8sSourceType +} from '../../utils/factories' +import AttributesEditor from '../forms/AttributesEditor' +import CollapsibleSection from '../forms/CollapsibleSection' +import FormCheckbox from '../forms/FormCheckbox' +import FormInput from '../forms/FormInput' +import FormSelect from '../forms/FormSelect' +import FormTextarea from '../forms/FormTextarea' +import EnvVarManager from '../forms/EnvVarManager' +import MapFieldManager from '../forms/MapFieldManager' +import StringListManager from '../forms/StringListManager' +import TriStateSelect from '../forms/TriStateSelect' +import Button from '../common/Button' +import EndpointsEditor from './EndpointsEditor' +import GitSourceEditor from './GitSourceEditor' + +/** Checkbox for top-level components, tri-state inside parent overrides. */ +function BoolField({ partial, name, label, value, onChange, helpText }) { + if (partial) { + return + } + + return ( + onChange(event.target.checked)} + helpText={helpText} + /> + ) +} + +function VolumeMountsEditor({ name, mounts = [], onChange, volumeNames }) { + const update = (index, patch) => + onChange(mounts.map((mount, position) => (position === index ? { ...mount, ...patch } : mount))) + + return ( +
+ Volume Mounts + {mounts.length === 0 ? ( +

+ No volumes mounted. Mounted volumes must have a matching volume component. +

+ ) : ( +
+ {mounts.map((mount, index) => ( +
+ update(index, { name: event.target.value })} + placeholder="volume component name" + className="flex-1 rounded-md border-gray-300 shadow-sm sm:text-sm focus:border-blue-500 focus:ring-blue-500" + /> + update(index, { path: event.target.value })} + placeholder="/home/user/.m2 (defaults to /)" + className="flex-[2] rounded-md border-gray-300 shadow-sm sm:text-sm focus:border-blue-500 focus:ring-blue-500" + /> + +
+ ))} +
+ )} + + {volumeNames.map((volume) => ( + + +
+ ) +} + +function ContainerEditor({ prefix, index, container, onChange, partial, volumeNames }) { + const update = (patch) => onChange({ ...container, ...patch }) + const annotation = container.annotation || { deployment: [], service: [] } + + return ( +
+ update({ image: event.target.value })} + placeholder="quay.io/devfile/base-developer-image:ubi10-latest" + required={!partial} + helpText="Full container image reference" + /> + + update({ mountSources: value })} + helpText="Mount the project sources into the container" + /> + + update({ sourceMapping: event.target.value })} + placeholder="/projects" + helpText="Path in the container where the project sources are mounted. Defaults to /projects." + /> + +
+ update({ memoryRequest: event.target.value })} + placeholder="512Mi" + /> + update({ memoryLimit: event.target.value })} + placeholder="1Gi" + /> + update({ cpuRequest: event.target.value })} + placeholder="100m" + /> + update({ cpuLimit: event.target.value })} + placeholder="1000m" + /> +
+ + + update({ env: items })} + /> + + + + update({ endpoints: items })} + /> + + + + update({ volumeMounts: items })} + volumeNames={volumeNames} + /> + + + + update({ command: items })} + placeholder="sleep" + addButtonText="Add Command Entry" + emptyMessage="Using the image entrypoint." + helpText="Overrides the image entrypoint. Non-UDI images usually need ['sleep', 'infinity']." + /> + + update({ args: items })} + placeholder="infinity" + addButtonText="Add Arg" + emptyMessage="Using the image default arguments." + /> + + update({ dedicatedPod: value })} + helpText="Run the container in its own pod instead of the shared workspace pod" + /> + + update({ annotation: { ...annotation, deployment: items } })} + emptyMessage="No deployment annotations." + addButtonText="Add Deployment Annotation" + /> + + update({ annotation: { ...annotation, service: items } })} + emptyMessage="No service annotations." + addButtonText="Add Service Annotation" + /> + +
+ ) +} + +function VolumeEditor({ prefix, index, volume, onChange, partial }) { + return ( +
+ onChange({ ...volume, size: event.target.value })} + placeholder="2Gi" + helpText="Storage size, for example 1Gi or 512Mi" + /> + + onChange({ ...volume, ephemeral: value })} + helpText="Back the volume with emptyDir instead of a persistent volume claim" + /> +
+ ) +} + +function K8sEditor({ prefix, index, type, k8s, onChange }) { + const sourceType = getK8sSourceType(k8s) + + return ( +
+ onChange(applyK8sSourceType(k8s, event.target.value))} + options={K8S_SOURCE_TYPES} + /> + + {sourceType === 'uri' ? ( + onChange({ ...k8s, uri: event.target.value })} + placeholder="manifests/deployment.yaml" + required + helpText="Relative path inside the repository or an absolute URL" + /> + ) : ( + onChange({ ...k8s, inlined: event.target.value })} + rows={8} + monospace + required + placeholder={'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service'} + /> + )} + + onChange({ ...k8s, deployByDefault: value })} + helpText="When unset, the resource is deployed only if no apply command references it." + /> + + + onChange({ ...k8s, endpoints: items })} + /> + +
+ ) +} + +function ImageEditor({ prefix, index, image, onChange, partial }) { + const dockerfile = image.dockerfile || {} + const sourceType = getDockerfileSourceType(dockerfile) + const overrideType = getImageOverrideType(image) + const showDockerfile = !partial || overrideType === 'dockerfile' + const updateDockerfile = (patch) => onChange({ ...image, dockerfile: { ...dockerfile, ...patch } }) + + return ( +
+ onChange({ ...image, imageName: event.target.value })} + placeholder="quay.io/my-org/my-app:latest" + required={!partial} + helpText="Name of the image the build produces" + /> + + {partial && ( + onChange(applyImageOverrideType(image, event.target.value))} + options={IMAGE_OVERRIDE_TYPES} + helpText="An image override either redefines the build or only changes autoBuild, never both." + /> + )} + + {!showDockerfile && ( + onChange({ ...image, autoBuild: value })} + helpText="Whether the inherited image is built automatically. Required by this override." + /> + )} + + {!partial && ( + onChange({ ...image, autoBuild: value })} + helpText="When unset, the image is built only if an apply command references it." + /> + )} + + {showDockerfile && ( + <> + onChange({ ...image, dockerfile: applyDockerfileSourceType(dockerfile, event.target.value) })} + options={DOCKERFILE_SOURCE_TYPES} + /> + + {sourceType === 'uri' && ( + updateDockerfile({ uri: event.target.value })} + placeholder="docker/Dockerfile" + required + /> + )} + + {sourceType === 'devfileRegistry' && ( +
+ + updateDockerfile({ devfileRegistry: { ...dockerfile.devfileRegistry, id: event.target.value } }) + } + placeholder="nodejs" + required + /> + + updateDockerfile({ devfileRegistry: { ...dockerfile.devfileRegistry, registryUrl: event.target.value } }) + } + placeholder="https://registry.devfile.io" + helpText="Defaults to the registry the parent devfile came from" + /> +
+ )} + + {sourceType === 'git' && ( + updateDockerfile({ git })} + singleRemote + withFileLocation + /> + )} + + updateDockerfile({ buildContext: event.target.value })} + placeholder="." + helpText="Path of the build context relative to the project root" + /> + + updateDockerfile({ rootRequired: value })} + /> + + updateDockerfile({ args: items })} + placeholder="--build-arg=VERSION=1.0" + addButtonText="Add Build Argument" + emptyMessage="No extra arguments passed to the build." + /> + + )} +
+ ) +} + +/** + * Editor for a single devfile component. + * + * `partial` is used for `parent.components` overrides where no field is + * required and booleans must keep a distinct "not set" state. + */ +export default function ComponentEditor({ + index, + value, + onChange, + partial = false, + prefix = 'component', + volumeNames = [] +}) { + const type = getComponentType(value) + + return ( +
+ onChange({ ...value, name: event.target.value })} + required + maxLength={63} + placeholder="dev-container" + helpText="Lowercase alphanumeric with dashes, unique across components" + /> + + onChange(applyComponentType(value, event.target.value, partial))} + options={COMPONENT_TYPES} + /> + + {type === 'container' && ( + onChange({ ...value, container })} + partial={partial} + volumeNames={volumeNames} + /> + )} + + {type === 'volume' && ( + onChange({ ...value, volume })} + partial={partial} + /> + )} + + {(type === 'kubernetes' || type === 'openshift') && ( + onChange({ ...value, [type]: k8s })} + /> + )} + + {type === 'image' && ( + onChange({ ...value, image })} + partial={partial} + /> + )} + + + onChange({ ...value, attributes: text })} + helpText="Free-form YAML map, for example container-overrides or pod-overrides." + /> + +
+ ) +} diff --git a/src/components/editors/EndpointsEditor.jsx b/src/components/editors/EndpointsEditor.jsx new file mode 100644 index 0000000..263ee76 --- /dev/null +++ b/src/components/editors/EndpointsEditor.jsx @@ -0,0 +1,111 @@ +import { ENDPOINT_EXPOSURES, ENDPOINT_PROTOCOLS } from '../../utils/constants' +import { createEndpoint } from '../../utils/factories' +import AttributesEditor from '../forms/AttributesEditor' +import CollapsibleSection from '../forms/CollapsibleSection' +import FormCheckbox from '../forms/FormCheckbox' +import FormInput from '../forms/FormInput' +import FormSelect from '../forms/FormSelect' +import MapFieldManager from '../forms/MapFieldManager' +import ArrayFieldManager from '../forms/ArrayFieldManager' + +/** Editor for the `endpoints` list of container, kubernetes and openshift components. */ +export default function EndpointsEditor({ name, endpoints = [], onChange }) { + const renderEndpoint = (endpoint, index, update) => ( +
+
+ update({ ...endpoint, name: event.target.value })} + required + maxLength={15} + placeholder="http-8080" + helpText="Unique across all components, max 15 characters" + /> + + update({ ...endpoint, targetPort: event.target.value })} + required + placeholder="8080" + /> +
+ +
+ update({ ...endpoint, protocol: event.target.value })} + options={ENDPOINT_PROTOCOLS} + placeholder="Default (http)" + /> + + update({ ...endpoint, exposure: event.target.value })} + options={ENDPOINT_EXPOSURES} + placeholder="Default (public)" + /> +
+ + update({ ...endpoint, path: event.target.value })} + placeholder="/api" + helpText="Path of the endpoint URL" + /> + + update({ ...endpoint, secure: event.target.checked })} + helpText="Put the endpoint behind a JWT proxy that requires authentication" + /> + + + update({ ...endpoint, annotation: items })} + emptyMessage="No annotations added to the generated ingress or route." + addButtonText="Add Annotation" + /> + + update({ ...endpoint, attributes: text })} + rows={3} + /> + +
+ ) + + return ( + onChange([...endpoints, createEndpoint()])} + onRemove={(index) => onChange(endpoints.filter((_, position) => position !== index))} + onChangeItem={(index, value) => + onChange(endpoints.map((item, position) => (position === index ? value : item))) + } + renderItem={renderEndpoint} + itemLabel={(endpoint, index) => endpoint.name || `Endpoint ${index + 1}`} + addButtonText="Add Endpoint" + emptyMessage="No endpoints exposed by this component." + /> + ) +} diff --git a/src/components/editors/GitSourceEditor.jsx b/src/components/editors/GitSourceEditor.jsx new file mode 100644 index 0000000..7049cf7 --- /dev/null +++ b/src/components/editors/GitSourceEditor.jsx @@ -0,0 +1,80 @@ +import FormInput from '../forms/FormInput' +import FormSelect from '../forms/FormSelect' +import MapFieldManager from '../forms/MapFieldManager' + +/** + * Editor for the devfile `git` source shared by projects, starter projects, + * dependent projects and image component dockerfiles. + */ +export default function GitSourceEditor({ + name, + value, + onChange, + singleRemote = false, + withFileLocation = false +}) { + const git = value || {} + const remotes = git.remotes || [] + const checkoutFrom = git.checkoutFrom || {} + + const remoteOptions = remotes + .map((remote) => remote.key.trim()) + .filter(Boolean) + .filter((remote, index, keys) => keys.indexOf(remote) === index) + .map((remote) => ({ value: remote, label: remote })) + + const update = (patch) => onChange({ ...git, ...patch }) + + return ( +
+ update({ remotes: items })} + keyLabel="Remote name" + valueLabel="Remote URL" + keyPlaceholder="origin" + valuePlaceholder="https://github.com/org/repo.git" + addButtonText="Add Remote" + emptyMessage="No remotes configured. At least one remote is required." + helpText={ + singleRemote + ? 'Only one remote may be configured for this source.' + : 'When more than one remote is configured, a checkout remote is mandatory.' + } + /> + +
+ update({ checkoutFrom: { ...checkoutFrom, remote: event.target.value } })} + options={remoteOptions} + placeholder="Default (the only remote)" + /> + + update({ checkoutFrom: { ...checkoutFrom, revision: event.target.value } })} + placeholder="main" + helpText="Branch, tag or commit id. Defaults to the remote's default branch." + /> +
+ + {withFileLocation && ( + update({ fileLocation: event.target.value })} + placeholder="docker/Dockerfile" + helpText="Location of the Dockerfile in the git repository, relative to its root." + /> + )} +
+ ) +} diff --git a/src/components/editors/ProjectEditor.jsx b/src/components/editors/ProjectEditor.jsx new file mode 100644 index 0000000..261ed84 --- /dev/null +++ b/src/components/editors/ProjectEditor.jsx @@ -0,0 +1,99 @@ +import { PROJECT_SOURCE_TYPES } from '../../utils/constants' +import { applyProjectSourceType, getProjectSourceType } from '../../utils/factories' +import AttributesEditor from '../forms/AttributesEditor' +import CollapsibleSection from '../forms/CollapsibleSection' +import FormInput from '../forms/FormInput' +import FormSelect from '../forms/FormSelect' +import GitSourceEditor from './GitSourceEditor' + +/** + * Editor for one entry of `projects`, `starterProjects` or `dependentProjects`. + * + * Starter projects carry `description`/`subDir` instead of `clonePath` and only + * accept a single git remote, everything else is shared between the three lists. + */ +export default function ProjectEditor({ index, value, onChange, kind = 'projects', prefix = 'project' }) { + const sourceType = getProjectSourceType(value) + const isStarter = kind === 'starterProjects' + const update = (patch) => onChange({ ...value, ...patch }) + + return ( +
+ update({ name: event.target.value })} + required + maxLength={63} + placeholder="my-project" + helpText="Lowercase alphanumeric with dashes, unique across the list" + /> + + {isStarter ? ( +
+ update({ description: event.target.value })} + placeholder="A starter project for ..." + /> + update({ subDir: event.target.value })} + placeholder="examples/rest-api" + helpText="Sub-directory of the source to use as the starter project" + /> +
+ ) : ( + update({ clonePath: event.target.value })} + placeholder="relative/path" + helpText="Path relative to the projects root the source is cloned into" + /> + )} + + onChange(applyProjectSourceType(value, event.target.value))} + options={PROJECT_SOURCE_TYPES} + /> + + {sourceType === 'git' ? ( + update({ git })} + singleRemote={isStarter} + /> + ) : ( + update({ zip: { ...value.zip, location: event.target.value } })} + placeholder="https://example.com/archive.zip" + required + helpText="URL of the zip archive to download" + /> + )} + + + update({ attributes: text })} + rows={3} + /> + +
+ ) +} diff --git a/src/components/forms/ArrayFieldManager.jsx b/src/components/forms/ArrayFieldManager.jsx index 6503b58..02d4475 100644 --- a/src/components/forms/ArrayFieldManager.jsx +++ b/src/components/forms/ArrayFieldManager.jsx @@ -1,13 +1,22 @@ import Button from '../common/Button' +/** + * Generic repeater for the devfile's list sections. + * + * `renderItem` receives the item together with a setter that replaces the + * whole item, which keeps editors free of field-path plumbing. + */ export default function ArrayFieldManager({ items = [], onAdd, onRemove, - onUpdate, + onChangeItem, + onMove, renderItem, + itemLabel, addButtonText = 'Add Item', - emptyMessage = 'No items added yet.' + emptyMessage = 'No items added yet.', + testId }) { return (
@@ -18,27 +27,49 @@ export default function ArrayFieldManager({ ) : (
{items.map((item, index) => ( -
+
- Item {index + 1} - + + {itemLabel ? itemLabel(item, index) : `Item ${index + 1}`} + +
+ {onMove && items.length > 1 && ( + <> + + + + )} + +
- {renderItem(item, index, (field, value) => onUpdate(index, field, value))} + {renderItem(item, index, (value) => onChangeItem(index, value))}
))}
)} -
diff --git a/src/components/forms/AttributesEditor.jsx b/src/components/forms/AttributesEditor.jsx new file mode 100644 index 0000000..9c78bd5 --- /dev/null +++ b/src/components/forms/AttributesEditor.jsx @@ -0,0 +1,38 @@ +import { parseAttributes } from '../../utils/devfileGenerator' +import FormTextarea from './FormTextarea' + +const PLACEHOLDER = `controller.devfile.io/storage-type: ephemeral +pod-overrides: + spec: + runtimeClassName: kata` + +/** + * Free-form `attributes` map editor. + * + * The devfile schema allows arbitrary YAML here, so the value is kept as raw + * text in the wizard state and parsed when the devfile is serialized. + */ +export default function AttributesEditor({ + name, + label = 'Attributes', + value = '', + onChange, + rows = 4, + helpText = 'Free-form YAML map of implementation specific attributes.' +}) { + const { error } = parseAttributes(value) + + return ( + onChange(event.target.value)} + rows={rows} + monospace + placeholder={PLACEHOLDER} + helpText={helpText} + error={error ? `Not applied until valid: ${error}` : undefined} + /> + ) +} diff --git a/src/components/forms/CheckboxGroup.jsx b/src/components/forms/CheckboxGroup.jsx new file mode 100644 index 0000000..6ad085d --- /dev/null +++ b/src/components/forms/CheckboxGroup.jsx @@ -0,0 +1,31 @@ +/** Multi-select rendered as checkboxes, used for `metadata.architectures`. */ +export default function CheckboxGroup({ name, label, options, selected = [], onChange, helpText }) { + const toggle = (option, checked) => { + if (checked) { + onChange([...selected, option].filter((value, index, values) => values.indexOf(value) === index)) + } else { + onChange(selected.filter((value) => value !== option)) + } + } + + return ( +
+ {label} +
+ {options.map((option) => ( + + ))} +
+ {helpText &&

{helpText}

} +
+ ) +} diff --git a/src/components/forms/CollapsibleSection.jsx b/src/components/forms/CollapsibleSection.jsx new file mode 100644 index 0000000..b832318 --- /dev/null +++ b/src/components/forms/CollapsibleSection.jsx @@ -0,0 +1,34 @@ +import { useState } from 'react' + +export default function CollapsibleSection({ + title, + description, + badge, + defaultOpen = false, + children +}) { + const [open, setOpen] = useState(defaultOpen) + + return ( +
+ + {open &&
{children}
} +
+ ) +} diff --git a/src/components/forms/EnvVarManager.jsx b/src/components/forms/EnvVarManager.jsx new file mode 100644 index 0000000..0c1fcf1 --- /dev/null +++ b/src/components/forms/EnvVarManager.jsx @@ -0,0 +1,64 @@ +import { RESERVED_ENV_NAMES } from '../../utils/constants' +import Button from '../common/Button' + +/** Editor for devfile `env` lists (`[{ name, value }]`). */ +export default function EnvVarManager({ + name, + label = 'Environment Variables', + items = [], + onChange, + emptyMessage = 'No environment variables defined.' +}) { + const handleAdd = () => onChange([...items, { name: '', value: '' }]) + const handleRemove = (index) => onChange(items.filter((_, position) => position !== index)) + const handleUpdate = (index, field, value) => + onChange(items.map((item, position) => (position === index ? { ...item, [field]: value } : item))) + + const reserved = items.map((item) => item.name.trim()).filter((item) => RESERVED_ENV_NAMES.includes(item)) + + return ( +
+ {label && {label}} + {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {items.map((item, index) => ( +
+ handleUpdate(index, 'name', event.target.value)} + placeholder="NAME" + className="flex-1 rounded-md border-gray-300 shadow-sm sm:text-sm focus:border-blue-500 focus:ring-blue-500" + /> + handleUpdate(index, 'value', event.target.value)} + placeholder="value" + className="flex-[2] rounded-md border-gray-300 shadow-sm sm:text-sm focus:border-blue-500 focus:ring-blue-500" + /> + +
+ ))} +
+ )} + {reserved.length > 0 && ( +

+ {[...new Set(reserved)].join(', ')} {reserved.length > 1 ? 'are' : 'is'} reserved by the platform and + cannot be redefined. +

+ )} + +
+ ) +} diff --git a/src/components/forms/FormInput.jsx b/src/components/forms/FormInput.jsx index ed79ec3..105e9dc 100644 --- a/src/components/forms/FormInput.jsx +++ b/src/components/forms/FormInput.jsx @@ -8,9 +8,14 @@ export default function FormInput({ placeholder = '', pattern, maxLength, + min, + max, type = 'text', - helpText + helpText, + suggestions }) { + const listId = suggestions && suggestions.length > 0 ? `${name}-suggestions` : undefined + return (