diff --git a/apps/portal/app-config.yaml b/apps/portal/app-config.yaml
index 1e07132..f3c2c2b 100644
--- a/apps/portal/app-config.yaml
+++ b/apps/portal/app-config.yaml
@@ -31,7 +31,8 @@ backend:
# The production database configuration is stored in app-config.production.yaml
database:
client: better-sqlite3
- connection: ':memory:'
+ connection:
+ filename: ':memory:'
# workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir
integrations:
@@ -132,6 +133,12 @@ catalog:
rules:
- allow: [Template]
+ # Multi-Option React Scaffolder Template
+ - type: file
+ target: ../../examples/multi-option-react-template/template.yaml
+ rules:
+ - allow: [Template]
+
# NestJS + Prisma Template
- type: file
target: ../../examples/nestjs-prisma-template/template.yaml
diff --git a/apps/portal/examples/multi-option-react-template/content/gitops/argocd-app.yaml b/apps/portal/examples/multi-option-react-template/content/gitops/argocd-app.yaml
new file mode 100644
index 0000000..16ef386
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/gitops/argocd-app.yaml
@@ -0,0 +1,22 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: ${{ values.repoName }}-argocd
+ namespace: argocd
+ finalizers:
+ - resources-finalizer.argocd.argoproj.io
+spec:
+ project: default
+ source:
+ repoURL: ${{ values.gitopsRepo }}
+ targetRevision: HEAD
+ path: ./
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: default
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+ syncOptions:
+ - CreateNamespace=true
diff --git a/apps/portal/examples/multi-option-react-template/content/gitops/helios-app.yaml b/apps/portal/examples/multi-option-react-template/content/gitops/helios-app.yaml
new file mode 100644
index 0000000..9885625
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/gitops/helios-app.yaml
@@ -0,0 +1,28 @@
+apiVersion: app.helios.io/v1alpha1
+kind: HeliosApp
+metadata:
+ name: ${{ values.repoName }}
+ namespace: default
+spec:
+ owner: ${{ values.owner }}
+ description: "React application: ${{ values.name }}"
+ gitRepo: ${{ values.sourceRepo }}
+ gitBranch: main
+ imageRepo: ${{ values.image }}
+ gitopsRepo: ${{ values.gitopsRepo }}
+ gitopsPath: ${{ values.repoName }}
+ pipelineName: from-code-to-cluster
+ webhookSecret: git-credentials-${{ values.repoName }}
+ port: ${{ values.port }}
+ testCommand: ${{ values.testCommand }}
+ components:
+ - name: ${{ values.repoName }}
+ type: web-service
+ properties:
+ image: ${{ values.image }}:latest
+ port: ${{ values.port }}
+ replicas: 1
+ traits:
+ - type: service
+ properties:
+ port: ${{ values.port }}
diff --git a/apps/portal/examples/multi-option-react-template/content/gitops/pipeline.yaml b/apps/portal/examples/multi-option-react-template/content/gitops/pipeline.yaml
new file mode 100644
index 0000000..8bbfe9a
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/gitops/pipeline.yaml
@@ -0,0 +1,40 @@
+apiVersion: tekton.dev/v1
+kind: PipelineRun
+metadata:
+ name: ${{ values.repoName }}-pipeline-run
+ annotations:
+ janus-idp.io/tekton: ${{ values.repoName }}
+spec:
+ serviceAccountName: pipeline
+ pipelineRef:
+ name: from-code-to-cluster
+ params:
+ - name: image-repo
+ value: ${{ values.image }}
+ - name: app-repo-url
+ value: ${{ values.sourceRepo }}
+ - name: app-repo-revision
+ value: main
+ - name: GITOPS_REPO_URL
+ value: ${{ values.gitopsRepo }}
+ - name: GITOPS_REPO_BRANCH
+ value: main
+ - name: MANIFEST_PATH
+ value: ./helios-app.yaml
+ - name: docker-secret
+ value: docker-credentials
+ - name: test-command
+ value: ${{ values.testCommand }}
+ - name: argocd-namespace
+ value: argocd
+ - name: argocd-app-name
+ value: ${{ values.repoName }}-argocd
+ workspaces:
+ - name: source-workspace
+ volumeClaimTemplate:
+ spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 1Gi
diff --git a/apps/portal/examples/multi-option-react-template/content/gitops/triggers.yaml b/apps/portal/examples/multi-option-react-template/content/gitops/triggers.yaml
new file mode 100644
index 0000000..1fec45c
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/gitops/triggers.yaml
@@ -0,0 +1,107 @@
+apiVersion: triggers.tekton.dev/v1beta1
+kind: TriggerBinding
+metadata:
+ name: ${{ values.repoName }}-git-binding
+spec:
+ params:
+ - name: git-repo-url
+ value: $(body.repository.clone_url)
+ - name: git-revision
+ value: $(body.head_commit.id)
+ - name: git-repo-name
+ value: $(body.repository.name)
+ - name: git-owner
+ value: $(body.repository.owner.login)
+
+---
+apiVersion: triggers.tekton.dev/v1beta1
+kind: TriggerTemplate
+metadata:
+ name: ${{ values.repoName }}-git-template
+spec:
+ resourcetemplates:
+ - apiVersion: tekton.dev/v1
+ kind: PipelineRun
+ metadata:
+ generateName: ${{ values.repoName }}-run-
+ labels:
+ tekton.dev/pipeline: from-code-to-cluster
+ app.kubernetes.io/instance: ${{ values.repoName }}
+ spec:
+ serviceAccountName: pipeline
+ pipelineRef:
+ name: from-code-to-cluster
+ params:
+ - name: app-repo-url
+ value: $(tt.params.git-repo-url)
+ - name: app-repo-revision
+ value: $(tt.params.git-revision)
+ - name: image-repo
+ value: "${{ values.image }}"
+ - name: GITOPS_REPO_URL
+ value: "${{ values.gitopsRepo }}"
+ - name: GITOPS_REPO_BRANCH
+ value: "main"
+ - name: MANIFEST_PATH
+ value: "helios-app.yaml"
+ - name: docker-secret
+ value: "docker-credentials"
+ - name: test-command
+ value: "${{ values.testCommand }}"
+ - name: argocd-namespace
+ value: "argocd"
+ - name: argocd-app-name
+ value: "${{ values.repoName }}-argocd"
+ workspaces:
+ - name: source-workspace
+ volumeClaimTemplate:
+ spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 1Gi
+
+---
+apiVersion: triggers.tekton.dev/v1beta1
+kind: EventListener
+metadata:
+ name: ${{ values.repoName }}-listener
+spec:
+ serviceAccountName: ${{ values.repoName }}-sa
+ triggers:
+ - binding:
+ name: ${{ values.repoName }}-git-binding
+ template:
+ name: ${{ values.repoName }}-git-template
+
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: ${{ values.repoName }}-sa
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: ${{ values.repoName }}-listener-binding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: tekton-triggers-eventlistener-roles
+subjects:
+ - kind: ServiceAccount
+ name: ${{ values.repoName }}-sa
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: ${{ values.repoName }}-listener-clusterbinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: tekton-triggers-eventlistener-clusterroles
+subjects:
+ - kind: ServiceAccount
+ name: ${{ values.repoName }}-sa
+ namespace: default
diff --git a/apps/portal/examples/multi-option-react-template/content/source/.eslintignore b/apps/portal/examples/multi-option-react-template/content/source/.eslintignore
new file mode 100644
index 0000000..176c58d
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/.eslintignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+*.config.js
+*.config.cjs
+postcss.config.cjs
+tailwind.config.cjs
+vite.config.js
+webpack.config.js
diff --git a/apps/portal/examples/multi-option-react-template/content/source/.eslintrc.json b/apps/portal/examples/multi-option-react-template/content/source/.eslintrc.json
new file mode 100644
index 0000000..d07b507
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/.eslintrc.json
@@ -0,0 +1,31 @@
+{
+ "env": {
+ "browser": true,
+ "es2020": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:react/recommended",
+ "plugin:react/jsx-runtime",
+ "plugin:react-hooks/recommended"
+ ],
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "settings": {
+ "react": {
+ "version": "18.2"
+ }
+ },
+ "plugins": [
+ "react-refresh"
+ ],
+ "rules": {
+ "react-refresh/only-export-components": [
+ "warn",
+ { "allowConstantExport": true }
+ ],
+ "react/prop-types": "off"
+ }
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/.gitignore b/apps/portal/examples/multi-option-react-template/content/source/.gitignore
new file mode 100644
index 0000000..3b6e931
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.env
+.env.local
+.eslintcache
diff --git a/apps/portal/examples/multi-option-react-template/content/source/Dockerfile b/apps/portal/examples/multi-option-react-template/content/source/Dockerfile
new file mode 100644
index 0000000..d3a2f63
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/Dockerfile
@@ -0,0 +1,15 @@
+# Build stage
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+# Production stage
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+ARG PORT=${{ values.port }}
+RUN sed -i "s/listen\( *\)80;/listen ${PORT};/" /etc/nginx/conf.d/default.conf
+EXPOSE ${PORT}
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/portal/examples/multi-option-react-template/content/source/babel.config.json b/apps/portal/examples/multi-option-react-template/content/source/babel.config.json
new file mode 100644
index 0000000..08d007e
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/babel.config.json
@@ -0,0 +1,6 @@
+{
+ "presets": [
+ "@babel/preset-env",
+ ["@babel/preset-react", { "runtime": "automatic" }]
+ ]
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/catalog-info.yaml b/apps/portal/examples/multi-option-react-template/content/source/catalog-info.yaml
new file mode 100644
index 0000000..39928b3
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/catalog-info.yaml
@@ -0,0 +1,18 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: ${{ values.repoName | dump }}
+ description: ${{ (values.description or "") | dump }}
+ annotations:
+ gitea.org/repo-url: http://localhost:3030/${{ values.owner }}/${{ values.repoName }}
+ backstage.io/techdocs-ref: dir:.
+ backstage.io/kubernetes-id: ${{ values.repoName }}
+ backstage.io/kubernetes-label-selector: app.kubernetes.io/name=${{ values.repoName }}
+ backstage.io/kubernetes-namespace: default
+ janus-idp.io/tekton: ${{ values.repoName }}
+ tekton.dev/ci-cd: "true"
+ argocd/app-name: ${{ values.repoName }}-argocd
+spec:
+ type: website
+ lifecycle: experimental
+ owner: ${{ values.owner | dump }}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/index.vite.html b/apps/portal/examples/multi-option-react-template/content/source/index.vite.html
new file mode 100644
index 0000000..0a1335f
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/index.vite.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ ${{ values.name }}
+
+
+
+
+
+
diff --git a/apps/portal/examples/multi-option-react-template/content/source/index.webpack.html b/apps/portal/examples/multi-option-react-template/content/source/index.webpack.html
new file mode 100644
index 0000000..c3238c8
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/index.webpack.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ ${{ values.name }}
+
+
+
+
+
diff --git a/apps/portal/examples/multi-option-react-template/content/source/package.json b/apps/portal/examples/multi-option-react-template/content/source/package.json
new file mode 100644
index 0000000..964a8e8
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "${{ values.repoName | lower }}",
+ "private": true,
+ "version": "1.0.0",
+ {% if values.buildTool == 'vite' -%}
+ "type": "module",
+ {%- endif %}
+ "scripts": {
+ {% if values.buildTool == 'vite' -%}
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ {%- else -%}
+ "dev": "webpack serve --mode development",
+ "build": "webpack --mode production",
+ "lint": "eslint src/ --ext js,jsx --report-unused-disable-directives --max-warnings 0"
+ {%- endif %}
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ {%- if values.stateManagement == 'redux' -%},
+ "@reduxjs/toolkit": "^2.2.5",
+ "react-redux": "^9.1.2"
+ {%- elif values.stateManagement == 'zustand' -%},
+ "zustand": "^4.5.2"
+ {%- endif %}
+ {%- if values.dataFetching == 'react-query' -%},
+ "@tanstack/react-query": "^5.40.1"
+ {%- endif %}
+ },
+ "devDependencies": {
+ "eslint": "^8.57.0",
+ "eslint-plugin-react": "^7.34.1",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6"
+ {%- if values.buildTool == 'vite' -%},
+ "vite": "^5.2.11",
+ "@vitejs/plugin-react": "^4.2.1"
+ {%- else -%},
+ "webpack": "^5.91.0",
+ "webpack-cli": "^5.1.4",
+ "webpack-dev-server": "^5.0.4",
+ "html-webpack-plugin": "^5.6.0",
+ "babel-loader": "^9.1.3",
+ "@babel/core": "^7.24.5",
+ "@babel/preset-env": "^7.24.5",
+ "@babel/preset-react": "^7.24.1",
+ "style-loader": "^4.0.0",
+ "css-loader": "^7.1.1"
+ {%- endif %}
+ {%- if values.styling == 'tailwind' -%},
+ "tailwindcss": "^3.4.3",
+ "postcss": "^8.4.38",
+ "autoprefixer": "^10.4.19"
+ {%- if values.buildTool == 'webpack' -%},
+ "postcss-loader": "^8.1.1"
+ {%- endif %}
+ {%- endif %}
+ }
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/postcss.config.cjs b/apps/portal/examples/multi-option-react-template/content/source/postcss.config.cjs
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/App.jsx b/apps/portal/examples/multi-option-react-template/content/source/src/App.jsx
new file mode 100644
index 0000000..640c7f4
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/App.jsx
@@ -0,0 +1,117 @@
+{% if values.stateManagement == 'none' or values.dataFetching == 'none' -%}
+import * as React from 'react'
+{%- endif %}
+{% if values.stateManagement == 'zustand' -%}
+import { useStore } from './store/store.js'
+{%- elif values.stateManagement == 'redux' -%}
+import { useSelector, useDispatch } from 'react-redux'
+import { increment, decrement } from './store/store.js'
+{%- endif %}
+
+{% if values.dataFetching == 'react-query' -%}
+import { useQuery } from '@tanstack/react-query'
+{%- endif %}
+
+function App() {
+ {% if values.stateManagement == 'zustand' -%}
+ const { count, increment, decrement } = useStore()
+ {%- elif values.stateManagement == 'redux' -%}
+ const count = useSelector((state) => state.counter.value)
+ const dispatch = useDispatch()
+ {%- else -%}
+ const [count, setCount] = React.useState(0)
+ {%- endif %}
+
+ {% if values.dataFetching == 'react-query' -%}
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['repoData'],
+ queryFn: async () => {
+ const res = await fetch('https://api.github.com/repos/facebook/react')
+ if (!res.ok) {
+ throw new Error('Network response was not ok')
+ }
+ return res.json()
+ },
+ })
+ {%- else -%}
+ const [data, setData] = React.useState(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState(null)
+
+ React.useEffect(() => {
+ setIsLoading(true)
+ fetch('https://api.github.com/repos/facebook/react')
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error('Network response was not ok')
+ }
+ return res.json()
+ })
+ .then((data) => {
+ setData(data)
+ setIsLoading(false)
+ })
+ .catch((err) => {
+ setError(err)
+ setIsLoading(false)
+ })
+ }, [])
+ {%- endif %}
+
+ return (
+
+
+
+ React App Scaffolder Template
+
+
+
+
Tech Stack
+
+ - Build Tool: ${{ values.buildTool | capitalize }}
+ - Styling: ${{ values.styling | capitalize }}
+ - State Management: ${{ values.stateManagement | capitalize }}
+ - Data Fetching: {% if values.dataFetching == 'react-query' %}React Query{% else %}Standard Fetch{% endif %}
+
+
+
+ {/* Counter Section */}
+
+
Counter State Example (${{ values.stateManagement | capitalize }})
+
+
+ {count}
+
+
+
+
+ {/* Data Fetching Section */}
+
+
Data Fetching Example ({% if values.dataFetching == 'react-query' %}React Query{% else %}Fetch{% endif %})
+ {isLoading &&
Loading React repo stats...
}
+ {error &&
Error fetching data: {error.message || 'Unknown error'}
}
+ {data && (
+
+
Repo Name: {data.full_name}
+
Stars: {data.stargazers_count?.toLocaleString()}
+
Forks: {data.forks_count?.toLocaleString()}
+
Open Issues: {data.open_issues_count?.toLocaleString()}
+
+ )}
+
+
+
+ )
+}
+
+export default App
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/index.tailwind.css b/apps/portal/examples/multi-option-react-template/content/source/src/index.tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/index.tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/index.vanilla.css b/apps/portal/examples/multi-option-react-template/content/source/src/index.vanilla.css
new file mode 100644
index 0000000..45dcc8f
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/index.vanilla.css
@@ -0,0 +1,136 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #0f172a;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+.app-container {
+ min-height: 100vh;
+ width: 100vw;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ box-sizing: border-box;
+}
+
+.app-card {
+ max-width: 600px;
+ width: 100%;
+ background-color: #1e293b;
+ border-radius: 0.75rem;
+ padding: 2rem;
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ border: 1px solid #334155;
+ box-sizing: border-box;
+}
+
+.app-title {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ font-weight: 700;
+ margin-top: 0;
+ margin-bottom: 1.5rem;
+ background: linear-gradient(to right, #22d3ee, #3b82f6);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.tech-stack-panel {
+ margin-bottom: 2rem;
+ padding: 1rem;
+ background-color: rgba(51, 65, 85, 0.5);
+ border-radius: 0.5rem;
+}
+
+.section-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ color: #67e8f9;
+}
+
+.tech-stack-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ list-style-type: none;
+ padding-left: 0;
+ margin: 0;
+}
+
+.counter-section {
+ margin-bottom: 2rem;
+}
+
+.counter-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.btn {
+ padding: 0.5rem 1rem;
+ background-color: #334155;
+ border: none;
+ border-radius: 0.25rem;
+ color: white;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.btn:hover {
+ background-color: #475569;
+}
+
+.btn-primary {
+ background-color: #0891b2;
+}
+
+.btn-primary:hover {
+ background-color: #06b6d4;
+}
+
+.counter-value {
+ font-family: monospace;
+ font-size: 1.5rem;
+ width: 3rem;
+ text-align: center;
+}
+
+.data-section {
+ border-top: 1px solid #334155;
+ padding-top: 1.5rem;
+}
+
+.stats-grid {
+ font-size: 0.875rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.error-msg {
+ color: #f87171;
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/main.jsx b/apps/portal/examples/multi-option-react-template/content/source/src/main.jsx
new file mode 100644
index 0000000..fd1d130
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/main.jsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+{% if values.dataFetching == 'react-query' -%}
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+const queryClient = new QueryClient()
+{%- endif %}
+
+{% if values.stateManagement == 'redux' -%}
+import { Provider } from 'react-redux'
+import { store } from './store/store.js'
+{%- endif %}
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+ {% if values.dataFetching == 'react-query' -%}
+
+ {%- endif %}
+ {% if values.stateManagement == 'redux' -%}
+
+ {%- endif %}
+
+ {% if values.stateManagement == 'redux' -%}
+
+ {%- endif %}
+ {% if values.dataFetching == 'react-query' -%}
+
+ {%- endif %}
+ ,
+)
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/store/reduxStore.js b/apps/portal/examples/multi-option-react-template/content/source/src/store/reduxStore.js
new file mode 100644
index 0000000..4192c2e
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/store/reduxStore.js
@@ -0,0 +1,24 @@
+import { configureStore, createSlice } from '@reduxjs/toolkit'
+
+const counterSlice = createSlice({
+ name: 'counter',
+ initialState: {
+ value: 0,
+ },
+ reducers: {
+ increment: (state) => {
+ state.value += 1
+ },
+ decrement: (state) => {
+ state.value -= 1
+ },
+ },
+})
+
+export const { increment, decrement } = counterSlice.actions
+
+export const store = configureStore({
+ reducer: {
+ counter: counterSlice.reducer,
+ },
+})
diff --git a/apps/portal/examples/multi-option-react-template/content/source/src/store/zustandStore.js b/apps/portal/examples/multi-option-react-template/content/source/src/store/zustandStore.js
new file mode 100644
index 0000000..6a5863a
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/src/store/zustandStore.js
@@ -0,0 +1,7 @@
+import { create } from 'zustand'
+
+export const useStore = create((set) => ({
+ count: 0,
+ increment: () => set((state) => ({ count: state.count + 1 })),
+ decrement: () => set((state) => ({ count: state.count - 1 })),
+}))
diff --git a/apps/portal/examples/multi-option-react-template/content/source/tailwind.config.cjs b/apps/portal/examples/multi-option-react-template/content/source/tailwind.config.cjs
new file mode 100644
index 0000000..1c3b7e1
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/tailwind.config.cjs
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/apps/portal/examples/multi-option-react-template/content/source/vite.config.js b/apps/portal/examples/multi-option-react-template/content/source/vite.config.js
new file mode 100644
index 0000000..c72ca71
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3000,
+ },
+})
diff --git a/apps/portal/examples/multi-option-react-template/content/source/webpack.config.ejs b/apps/portal/examples/multi-option-react-template/content/source/webpack.config.ejs
new file mode 100644
index 0000000..eb09703
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/content/source/webpack.config.ejs
@@ -0,0 +1,46 @@
+const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+
+module.exports = {
+ entry: './src/main.jsx',
+ output: {
+ path: path.resolve(__dirname, 'dist'),
+ filename: 'bundle.js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.js', '.jsx'],
+ },
+ module: {
+ rules: [
+ {
+ test: /\.(js|jsx)$/,
+ exclude: /node_modules/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ presets: ['@babel/preset-env', '@babel/preset-react'],
+ },
+ },
+ },
+ {
+ test: /\.css$/,
+ use: {% if values.styling == 'tailwind' %}['style-loader', 'css-loader', 'postcss-loader']{% else %}['style-loader', 'css-loader']{% endif %},
+ },
+ ],
+ },
+ plugins: [
+ new HtmlWebpackPlugin({
+ template: './index.html',
+ }),
+ ],
+ devServer: {
+ static: {
+ directory: path.join(__dirname, 'public'),
+ },
+ compress: true,
+ port: 3000,
+ hot: true,
+ historyApiFallback: true,
+ },
+};
diff --git a/apps/portal/examples/multi-option-react-template/template.yaml b/apps/portal/examples/multi-option-react-template/template.yaml
new file mode 100644
index 0000000..bb034cc
--- /dev/null
+++ b/apps/portal/examples/multi-option-react-template/template.yaml
@@ -0,0 +1,236 @@
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: multi-option-react-template
+ title: Multi-Option React Scaffolder Template
+ description: A flexible React template that lets you customize your build tool (Vite/Webpack), styling (Tailwind CSS), state management (Zustand/Redux), and data fetching (React Query), and deploys it automatically.
+spec:
+ owner: user:guest
+ type: service
+
+ parameters:
+ - title: Component Details
+ required:
+ - name
+ - port
+ - dockerOrg
+ - repoName
+ properties:
+ name:
+ title: Name
+ type: string
+ description: Unique name of the component
+ ui:autofocus: true
+ port:
+ title: Port
+ type: number
+ description: The port to expose
+ default: 8080
+ dockerOrg:
+ title: Docker Registry Org/User
+ type: string
+ description: Your Docker Hub username or Organization
+ default: phamhoangkha1403
+ repoName:
+ title: Docker Repository Name
+ type: string
+ description: The name of the repository (e.g. my-react-app)
+ pattern: "^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$"
+ description:
+ title: Description
+ type: string
+ description: A brief description of the component
+ - title: Tech Stack Options
+ required:
+ - buildTool
+ - styling
+ - stateManagement
+ - dataFetching
+ properties:
+ buildTool:
+ title: Build Tool
+ type: string
+ description: Select the build tool and bundler
+ enum:
+ - vite
+ - webpack
+ enumNames:
+ - Vite
+ - Webpack
+ default: vite
+ styling:
+ title: Styling
+ type: string
+ description: Choose the styling method
+ enum:
+ - tailwind
+ - vanilla
+ enumNames:
+ - Tailwind CSS
+ - Vanilla CSS
+ default: tailwind
+ stateManagement:
+ title: State Management
+ type: string
+ description: Choose a state management library
+ enum:
+ - none
+ - zustand
+ - redux
+ enumNames:
+ - None
+ - Zustand
+ - Redux (Toolkit)
+ default: none
+ dataFetching:
+ title: Data Fetching
+ type: string
+ description: Choose a data fetching library
+ enum:
+ - none
+ - react-query
+ enumNames:
+ - None (Standard fetch)
+ - React Query (TanStack Query)
+ default: none
+ - title: Choose a location
+ required:
+ - repoUrl
+ - webhookSecret
+ properties:
+ repoUrl:
+ title: Repository Location
+ type: string
+ ui:field: RepoUrlPicker
+ ui:options:
+ allowedHosts:
+ - localhost:3030
+ webhookSecret:
+ title: Webhook Secret
+ type: string
+ description: A secure random secret to secure the Git webhook
+ ui:field: Secret
+
+ steps:
+ # 1. Fetch Source Code Template
+ - id: fetch-source
+ name: Fetch Source Code Template
+ action: fetch:template
+ input:
+ url: ./content/source
+ targetPath: ./source
+ values:
+ name: ${{ parameters.name }}
+ repoName: ${{ parameters.repoName }}
+ description: ${{ parameters.description }}
+ buildTool: ${{ parameters.buildTool }}
+ styling: ${{ parameters.styling }}
+ stateManagement: ${{ parameters.stateManagement }}
+ dataFetching: ${{ parameters.dataFetching }}
+ port: ${{ parameters.port }}
+ owner: ${{ (parameters.repoUrl | parseRepoUrl).owner }}
+ image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }}
+
+ # 2. Configure Selected Stack (Always run, dynamic list)
+ # File rename logic:
+ # - index.{buildTool}.html -> index.html (vite or webpack)
+ # - src/index.{styling}.css -> src/index.css (tailwind or vanilla)
+ # - src/store/{redux|zustand}Store.js -> src/store/store.js (if not "none")
+ # - webpack.config.ejs -> webpack.config.js (if webpack)
+ - id: configure-files
+ name: Configure Selected Stack
+ action: fs:rename
+ input:
+ files: '${{ [ { from: "./source/index." + parameters.buildTool + ".html", to: "./source/index.html" }, { from: "./source/src/index." + parameters.styling + ".css", to: "./source/src/index.css" } ].concat( [ { from: "./source/src/store/reduxStore.js", to: "./source/src/store/store.js" } ] if parameters.stateManagement == "redux" else ( [ { from: "./source/src/store/zustandStore.js", to: "./source/src/store/store.js" } ] if parameters.stateManagement == "zustand" else [] ) ).concat( [ { from: "./source/webpack.config.ejs", to: "./source/webpack.config.js" } ] if parameters.buildTool == "webpack" else [] ) }}'
+
+ # 3. Clean up Unused Files (Always run, dynamic list)
+ - id: cleanup-files
+ name: Clean up Unused Files
+ action: fs:delete
+ input:
+ files: '${{ ( [ "./source/index.webpack.html", "./source/webpack.config.ejs", "./source/babel.config.json" ] if parameters.buildTool == "vite" else [ "./source/index.vite.html", "./source/vite.config.js" ] ).concat( [ "./source/tailwind.config.cjs", "./source/postcss.config.cjs" ] if parameters.styling == "vanilla" else [] ).concat( [ "./source/src/store/zustandStore.js" ] if parameters.stateManagement == "redux" else ( [ "./source/src/store/reduxStore.js" ] if parameters.stateManagement == "zustand" else [ "./source/src/store" ] ) ).concat( [ "./source/src/index.vanilla.css" ] if parameters.styling == "tailwind" else [ "./source/src/index.tailwind.css" ] ) }}'
+
+ # 4. Publish Source Code to Gitea
+ - id: publish-source
+ name: Publish Source Code
+ action: publish:gitea
+ input:
+ description: Source Code for ${{ parameters.name }}
+ repoUrl: ${{ parameters.repoUrl }}
+ sourcePath: ./source
+ repoVisibility: public
+
+ # 5. Create Gitea Webhook for Tekton CI
+ - id: create-webhook
+ name: Create Webhook
+ action: gitea:create-webhook
+ input:
+ repoUrl: ${{ parameters.repoUrl }}
+ webhookUrl: http://el-${{ parameters.repoName }}-listener.default.svc.cluster.local:8080
+ webhookSecret: ${{ secrets.webhookSecret }}
+ events:
+ - push
+
+ # 6. Fetch GitOps Manifests Template
+ - id: fetch-gitops
+ name: Fetch GitOps Manifests
+ action: fetch:template
+ input:
+ url: ./content/gitops
+ targetPath: ./gitops
+ values:
+ name: ${{ parameters.name }}
+ repoName: ${{ parameters.repoName }}
+ image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }}
+ dockerOrg: ${{ parameters.dockerOrg }}
+ port: ${{ parameters.port }}
+ owner: ${{ (parameters.repoUrl | parseRepoUrl).owner }}
+ sourceRepo: ${{ steps['publish-source'].output.remoteUrl }}
+ gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops
+ testCommand: "npm install && npm run build"
+
+ # 7. Publish GitOps Manifests to Gitea
+ - id: publish-gitops
+ name: Publish GitOps Manifests
+ action: publish:gitea
+ input:
+ description: GitOps Manifests for ${{ parameters.name }}
+ repoUrl: ${{ parameters.repoUrl }}-gitops
+ sourcePath: ./gitops
+ repoVisibility: public
+
+ # 8. Create Git Credentials Secret on Kubernetes
+ - id: create-secret
+ name: Create Git Credentials Secret
+ action: kubernetes:create-git-credentials-secret
+ input:
+ name: ${{ parameters.repoName }}
+ namespace: default
+ username: ${{ (parameters.repoUrl | parseRepoUrl).owner }}
+ webhookSecret: ${{ secrets.webhookSecret }}
+
+ # 9. Deploy HeliosApp to Kubernetes
+ - id: apply-helios
+ name: Deploy to Kubernetes
+ action: kubernetes:apply
+ input:
+ manifestPath: ./gitops/helios-app.yaml
+ namespaced: true
+
+ # 10. Register Component in Catalog
+ - id: register
+ name: Register Component
+ action: catalog:register
+ input:
+ repoContentsUrl: ${{ steps['publish-source'].output.repoContentsUrl }}
+ catalogInfoPath: 'catalog-info.yaml'
+
+ output:
+ links:
+ - title: Source Repository
+ url: ${{ steps['publish-source'].output.remoteUrl }}
+ - title: GitOps Repository
+ url: ${{ steps['publish-gitops'].output.remoteUrl }}
+ - title: Open in Catalog
+ icon: catalog
+ entityRef: ${{ steps['register'].output.entityRef }}