feat(*): Enable loading the file and creating a PR in Azure Devops

This commit is contained in:
Joshua Creek
2026-03-05 18:32:45 +00:00
parent 91a8988275
commit 0433fe5c2c
35 changed files with 5503 additions and 776 deletions
+88 -126
View File
@@ -19,109 +19,59 @@ Zalando has a fantastic description [on their website](https://opensource.zaland
It serves and scales well for teams and companies of all sizes that want to have alignment across dozens of technologies and visualize it in a simple way.
## Packages
This is a monorepo containing three packages:
| Package | Description |
|---------|-------------|
| [`tech-radar-editor`](./packages/tech-radar-editor) | The core web component (`<tech-radar-editor>`) |
| [`tech-radar-editor-backend`](./packages/tech-radar-editor-backend) | Backstage backend plugin for Azure DevOps integration |
| [`tech-radar-editor-backstage`](./packages/tech-radar-editor-backstage) | Backstage frontend wrapper component |
## Include the Component in Spotify Backstage
### Install
### Quick Start
For either simple or advanced installations, you'll need to add the dependency using Yarn:
From your Backstage root directory:
1. Install packages:
```bash
yarn --cwd packages/app add tech-radar-editor
# From your Backstage root directory
yarn --cwd packages/app add tech-radar-editor-backstage
yarn --cwd packages/backend add tech-radar-editor-backend
```
### Configuration
Modify your app routes to include the Router component exported from the tech radar, for example:
```tsx
// In packages/app/src/App.tsx
import 'tech-radar-editor';
const routes = (
<FlatRoutes>
{/* ...other routes */}
<Route
path="/tech-radar-editor"
element={<tech-radar-editor></tech-radar-editor>}
/>
```
#### How to Use This Component in Backstage with a Config-Based URL
##### Configure the URL in app-config.yaml
In your Backstage `app-config.yaml`, add a config key for the editors JSON URL. For example:
2. Add config to `app-config.yaml`:
```yaml
techRadar:
url: "https://example.org/path/to/tech-radar.json"
techRadarEditor:
organization: my-org
project: my-project
repository: my-repo
filePath: /tech-radar.json # optional, defaults to /tech-radar.json
targetBranch: main # optional, defaults to main
```
Adjust the key and value to suit your environment.
3. Register the backend plugin in `packages/backend/src/index.ts`:
##### Set up the type for the config value
In your `package.json`, add:
```json
{
// ...
"files": [
// ...
"config.d.ts"
],
"configSchema": "config.d.ts"
}
```ts
backend.add(import('tech-radar-editor-backend'));
```
You'll need to create a type definition for the config value in your Backstage app. In your `config.d.ts` file, add:
````ts
export interface Config {
techRadar: {
/**
* Frontend URL
* @visibility frontend
*/
url: string;
};
}
```
##### Create a small React wrapper in your Backstage app
Somewhere in your Backstage app (for example, in `App.tsx` or in a separate `TechRadarEditorWrapper.tsx` file), create a tiny wrapper component to read the config and pass that URL to the `<tech-radar-editor>` web component as an attribute:
4. Add the route in your `packages/app/src/App.tsx`:
```tsx
import React from "react";
import { useApi, configApiRef } from "@backstage/core-plugin-api";
import { TechRadarEditorPage } from 'tech-radar-editor-backstage';
// Import the custom element so that React recognizes <tech-radar-editor>
import "tech-radar-editor";
export const TechRadarEditorWrapper = () => {
const configApi = useApi(configApiRef);
const dataUrl = configApi.getOptionalString("techRadar.url") ?? "";
return <tech-radar-editor data-url={dataUrl} />;
};
````
##### Add a route for your Tech Radar Editor
In your Backstage `App.tsx` (or wherever you define your routes), import that wrapper and point a route to it:
```tsx
<Route path="/tech-radar-editor" element={<TechRadarEditorWrapper />} />
// In your routes:
<Route path="/tech-radar-editor" element={<TechRadarEditorPage />} />
```
Now when you visit `<your-backstage-host>/tech-radar-editor`, the `<tech-radar-editor>` component will automatically fetch the JSON from your configured URL.
That's it! The editor will fetch data from your Azure DevOps repository and allow users to submit changes as pull requests.
## Consume the Component in Plain JavaScript
## Use the Web Component Standalone
Include the component in an HTML file:
### In Plain HTML
```html
<!DOCTYPE html>
@@ -137,64 +87,76 @@ Include the component in an HTML file:
</html>
```
## Consume the Component in React
### In React
In a React project, you can use the web component as you would use any custom HTML element.
First, ensure that the component is imported or included:
```js
// index.js
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
// Import the component
import "tech-radar-editor";
```tsx
import 'tech-radar-editor';
function App() {
return (
<div className="App">
<tech-radar-editor></tech-radar-editor>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
```
Note: In React, you might need to instruct TypeScript about the custom element. Create a react-app-env.d.ts or a global.d.ts file:
```ts
// react-app-env.d.ts
declare namespace JSX {
interface IntrinsicElements {
"tech-radar-editor": any;
}
return <tech-radar-editor></tech-radar-editor>;
}
```
## Building the Component
### With a Data URL
To build the component, you need to have Node.js installed. Clone the repository and run the following commands:
Load JSON from a URL on mount:
```html
<tech-radar-editor data-url="https://example.org/tech-radar.json"></tech-radar-editor>
```
### With Inline JSON Data
Pass JSON directly via attribute:
```html
<tech-radar-editor data-json='{"title":"My Radar","quadrants":[],"rings":[],"entries":[]}'></tech-radar-editor>
```
### Web Component Props
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `data-url` | `string` | `''` | URL to fetch JSON data from on mount |
| `data-json` | `string` | `''` | JSON string to load directly (bypasses URL fetch) |
| `hide-title` | `boolean\|string` | `false` | Hide the h1 heading |
| `hide-export` | `boolean\|string` | `false` | Hide the "Export JSON" button |
| `hide-json-input` | `boolean\|string` | `false` | Hide the textarea + "Load JSON" button |
| `hide-json-preview` | `boolean\|string` | `false` | Hide the "JSON Preview" section |
| `auto-expand-entries` | `boolean\|string` | `false` | Auto-expand the Entries section on load |
### Custom Events
| Event | Detail | Description |
|-------|--------|-------------|
| `radar-data-loaded` | `{ data: TechRadarData }` | Fired once when data is first loaded |
| `radar-data-change` | `{ data: TechRadarData }` | Fired whenever the data changes |
```js
const editor = document.querySelector('tech-radar-editor');
editor.addEventListener('radar-data-change', (e) => {
console.log('Updated data:', e.detail.data);
});
```
## Building
```bash
pnpm install
pnpm build
```
To get it ready to add to NPM or a CDN, run:
To develop the web component with hot reload:
```bash
pnpm dev
```
To publish the packages to npm:
```bash
pnpm build
pnpm pack
```
You can then test the package locally.
To publish the component to NPM, run:
```bash
npm login
npm publish
cd packages/tech-radar-editor && npm publish
cd packages/tech-radar-editor-backend && npm publish
cd packages/tech-radar-editor-backstage && npm publish
```
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech Radar Editor</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<tech-radar-editor></tech-radar-editor>
</body>
</html>
+5 -45
View File
@@ -1,50 +1,10 @@
{
"name": "tech-radar-editor",
"version": "1.1.0",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/jcreek/Tech-Radar-Editor.git"
},
"keywords": [
"tech-radar",
"editor",
"visualization",
"web component",
"svelte",
"tech trends",
"technology radar",
"opensource",
"thoughtworks",
"zalando"
],
"author": "Josh Creek",
"license": "GPL-3.0",
"main": "dist/tech-radar-editor.umd.js",
"module": "dist/tech-radar-editor.es.js",
"types": "dist/main.d.ts",
"files": [
"dist"
],
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build && tsc -p tsconfig.build.json",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json"
"build": "pnpm -r build",
"dev": "pnpm --filter tech-radar-editor dev"
},
"dependencies": {
"svelte": "^4.2.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tsconfig/svelte": "^5.0.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-load-config": "^6.0.1",
"svelte-check": "^3.8.5",
"tailwindcss": "^3.4.13",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vite": "^5.4.1"
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,35 @@
{
"name": "tech-radar-editor-backend",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"backstage": {
"role": "backend-plugin"
},
"repository": {
"type": "git",
"url": "https://github.com/jcreek/Tech-Radar-Editor.git",
"directory": "packages/tech-radar-editor-backend"
},
"author": "Josh Creek",
"license": "GPL-3.0",
"scripts": {
"build": "tsc"
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.3.1",
"@backstage/config": "^1.3.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0"
},
"devDependencies": {
"@types/express": "^4.17.6",
"typescript": "^5.5.3"
},
"peerDependencies": {
"@backstage/backend-plugin-api": "^1.x.x"
}
}
@@ -0,0 +1 @@
export { techRadarEditorPlugin as default } from './plugin';
@@ -0,0 +1,22 @@
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
export const techRadarEditorPlugin = createBackendPlugin({
pluginId: 'tech-radar-editor',
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
config: coreServices.rootConfig,
},
async init({ httpRouter, logger, config }) {
const router = await createRouter({ logger, config });
httpRouter.use(router);
},
});
},
});
@@ -0,0 +1,242 @@
import { RootConfigService } from '@backstage/backend-plugin-api';
import express from 'express';
import Router from 'express-promise-router';
import { RouterOptions, AzureCredentials, AzureDevOpsTarget } from './types';
function getAzureCredentials(config: RootConfigService): AzureCredentials {
const integrations = config.getConfigArray('integrations.azure');
const azureIntegration = integrations[0];
const credentials = azureIntegration.getConfigArray('credentials');
const cred = credentials[0];
return {
clientId: cred.getString('clientId'),
clientSecret: cred.getString('clientSecret'),
tenantId: cred.getString('tenantId'),
};
}
function getTarget(config: RootConfigService): AzureDevOpsTarget {
return {
organization: config.getString('techRadarEditor.organization'),
project: config.getString('techRadarEditor.project'),
repository: config.getString('techRadarEditor.repository'),
filePath: config.getOptionalString('techRadarEditor.filePath') ?? '/tech-radar.json',
targetBranch: config.getOptionalString('techRadarEditor.targetBranch') ?? 'main',
};
}
async function getAzureToken(credentials: AzureCredentials): Promise<string> {
const tokenUrl = `https://login.microsoftonline.com/${credentials.tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
scope: '499b84ac-1321-427f-aa17-267ca6975798/.default',
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to get Azure token: ${response.status} ${text}`);
}
const data = await response.json() as { access_token: string };
return data.access_token;
}
async function azureDevOpsApi(
token: string,
url: string,
options?: { method?: string; body?: unknown },
): Promise<unknown> {
const response = await fetch(url, {
method: options?.method ?? 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Azure DevOps API error: ${response.status} ${text}`);
}
return response.json();
}
export async function createRouter(options: RouterOptions): Promise<express.Router> {
const { logger, config } = options;
const router = Router();
router.use(express.json({ limit: '1mb' }));
const credentials = getAzureCredentials(config);
const target = getTarget(config);
// Proxy endpoint for the web component to fetch tech radar data without auth
router.get('/data', async (_req, res) => {
try {
const token = await getAzureToken(credentials);
const apiUrl =
`https://dev.azure.com/${target.organization}/${target.project}` +
`/_apis/git/repositories/${target.repository}/items` +
`?path=${encodeURIComponent(target.filePath)}` +
`&versionDescriptor.version=${target.targetBranch}` +
`&versionDescriptor.versionType=branch` +
`&api-version=7.1`;
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const text = await response.text();
logger.error(`Failed to fetch tech radar data: ${response.status} ${text}`);
res.status(502).json({ message: 'Failed to fetch tech radar data' });
return;
}
const data = await response.json();
res.json(data);
} catch (error) {
logger.error(`Error fetching tech radar data: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
});
// Save endpoint: creates a branch and PR with the updated tech radar JSON
router.post('/save', async (req, res) => {
const { data, title, description, userToken } = req.body as {
data: string;
title?: string;
description?: string;
userToken?: string;
};
if (!data) {
res.status(400).json({ message: 'Missing "data" field in request body' });
return;
}
// Validate JSON
try {
JSON.parse(data);
} catch {
res.status(400).json({ message: 'Invalid JSON data' });
return;
}
try {
// Use the user's token if provided (PR will be authored as the user),
// otherwise fall back to the service principal token.
const token = userToken || await getAzureToken(credentials);
const baseApiUrl =
`https://dev.azure.com/${target.organization}/${target.project}` +
`/_apis/git/repositories/${target.repository}`;
// 1. Get the latest commit SHA on the target branch
const refsResult = await azureDevOpsApi(
token,
`${baseApiUrl}/refs?filter=heads/${target.targetBranch}&api-version=7.1`,
) as { value: Array<{ objectId: string }> };
if (!refsResult.value?.length) {
res.status(500).json({ message: `Branch '${target.targetBranch}' not found` });
return;
}
const mainBranchCommitId = refsResult.value[0].objectId;
const branchName = `tech-radar-update-${Date.now()}`;
// 2. Create a new branch from the target branch HEAD
await azureDevOpsApi(
token,
`${baseApiUrl}/refs?api-version=7.1`,
{
method: 'POST',
body: [
{
name: `refs/heads/${branchName}`,
oldObjectId: '0000000000000000000000000000000000000000',
newObjectId: mainBranchCommitId,
},
],
},
);
// 3. Push a commit to the new branch with the updated file
await azureDevOpsApi(
token,
`${baseApiUrl}/pushes?api-version=7.1`,
{
method: 'POST',
body: {
refUpdates: [
{
name: `refs/heads/${branchName}`,
oldObjectId: mainBranchCommitId,
},
],
commits: [
{
comment: title ?? 'Update tech radar data',
changes: [
{
changeType: 'edit',
item: { path: target.filePath },
newContent: {
content: data,
contentType: 'rawtext',
},
},
],
},
],
},
},
);
logger.info(`Created branch '${branchName}' with updated tech radar data`);
// 4. Create a pull request
const prResult = await azureDevOpsApi(
token,
`${baseApiUrl}/pullrequests?api-version=7.1`,
{
method: 'POST',
body: {
sourceRefName: `refs/heads/${branchName}`,
targetRefName: `refs/heads/${target.targetBranch}`,
title: title ?? 'Update tech radar data',
description:
description ??
'This PR was created from the Tech Radar Editor in Backstage.',
},
},
) as { pullRequestId: number; url: string };
const prUrl =
`https://dev.azure.com/${target.organization}/${target.project}` +
`/_git/${target.repository}/pullrequest/${prResult.pullRequestId}`;
logger.info(`Created PR #${prResult.pullRequestId}: ${prUrl}`);
res.json({
pullRequestId: prResult.pullRequestId,
pullRequestUrl: prUrl,
});
} catch (error) {
logger.error(`Error creating tech radar PR: ${error}`);
res.status(500).json({ message: `Failed to create PR: ${error}` });
}
});
return router;
}
@@ -0,0 +1,20 @@
import { LoggerService, RootConfigService } from '@backstage/backend-plugin-api';
export interface RouterOptions {
logger: LoggerService;
config: RootConfigService;
}
export interface AzureCredentials {
clientId: string;
clientSecret: string;
tenantId: string;
}
export interface AzureDevOpsTarget {
organization: string;
project: string;
repository: string;
filePath: string;
targetBranch: string;
}
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"moduleResolution": "node"
},
"include": ["src"]
}
@@ -0,0 +1,37 @@
{
"name": "tech-radar-editor-backstage",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/jcreek/Tech-Radar-Editor.git",
"directory": "packages/tech-radar-editor-backstage"
},
"author": "Josh Creek",
"license": "GPL-3.0",
"scripts": {
"build": "tsc"
},
"dependencies": {
"tech-radar-editor": "^1.2.0"
},
"peerDependencies": {
"react": "^17 || ^18",
"@backstage/core-plugin-api": "^1.x.x",
"@backstage/core-components": ">=0.16.0",
"@material-ui/core": "^4.x.x"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@backstage/core-plugin-api": "^1.9.4",
"@backstage/core-components": "^0.16.2",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"react": "^18.2.0",
"typescript": "^5.5.3"
}
}
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
discoveryApiRef,
fetchApiRef,
microsoftAuthApiRef,
useApi,
} from '@backstage/core-plugin-api';
import {
Content,
Header,
HeaderLabel,
Page,
} from '@backstage/core-components';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import CircularProgress from '@material-ui/core/CircularProgress';
import Snackbar from '@material-ui/core/Snackbar';
import SnackbarContent from '@material-ui/core/SnackbarContent';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import { makeStyles } from '@material-ui/core/styles';
import 'tech-radar-editor';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
'tech-radar-editor': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
'data-json'?: string;
'hide-title'?: string;
'hide-export'?: string;
'hide-json-input'?: string;
'hide-json-preview'?: string;
'auto-expand-entries'?: string;
},
HTMLElement
>;
}
}
}
const useStyles = makeStyles(theme => ({
editorContainer: {
'& tech-radar-editor': {
display: 'block',
width: '100%',
},
},
stickyFooter: {
position: 'sticky',
bottom: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2),
padding: theme.spacing(2, 3),
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
boxShadow: theme.shadows[4],
marginTop: theme.spacing(3),
},
successSnackbar: {
backgroundColor: theme.palette.success?.main ?? '#4caf50',
},
errorSnackbar: {
backgroundColor: theme.palette.error.main,
},
}));
export const TechRadarEditorPage = () => {
const classes = useStyles();
const discoveryApi = useApi(discoveryApiRef);
const fetchApi = useApi(fetchApiRef);
const microsoftAuth = useApi(microsoftAuthApiRef);
const editorRef = useRef<HTMLElement | null>(null);
const [loading, setLoading] = useState(true);
const [initialJson, setInitialJson] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [currentJson, setCurrentJson] = useState<string | null>(null);
const [result, setResult] = useState<{
type: 'success' | 'error';
message: string;
} | null>(null);
// Fetch the current tech radar data from the backend (with Backstage auth)
useEffect(() => {
(async () => {
try {
const baseUrl = await discoveryApi.getBaseUrl('tech-radar-editor');
const response = await fetchApi.fetch(`${baseUrl}/data`);
if (response.ok) {
const data = await response.json();
setInitialJson(JSON.stringify(data, null, 2));
} else {
console.error('Failed to fetch tech radar data:', response.status);
}
} catch (error) {
console.error('Error fetching tech radar data:', error);
} finally {
setLoading(false);
}
})();
}, [discoveryApi, fetchApi]);
// Listen for radar-data-change events from the web component
useEffect(() => {
const editor = editorRef.current;
if (!editor) return undefined;
const handleDataChange = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.data) {
setCurrentJson(JSON.stringify(detail.data, null, 2));
}
};
editor.addEventListener('radar-data-change', handleDataChange);
return () => editor.removeEventListener('radar-data-change', handleDataChange);
}, [loading]);
const handleSave = useCallback(async () => {
const jsonData = currentJson;
if (!jsonData) {
setResult({
type: 'error',
message:
'Could not read valid JSON from the editor. Make sure you have loaded data first.',
});
return;
}
setSaving(true);
setResult(null);
try {
const userAzureToken = await microsoftAuth.getAccessToken(
'499b84ac-1321-427f-aa17-267ca6975798/user_impersonation',
);
const baseUrl = await discoveryApi.getBaseUrl('tech-radar-editor');
const response = await fetchApi.fetch(`${baseUrl}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: jsonData, userToken: userAzureToken }),
});
const body = await response.json();
if (response.ok) {
setResult({
type: 'success',
message: `Pull request created successfully! View it here: ${body.pullRequestUrl}`,
});
} else {
setResult({
type: 'error',
message: body.message || 'Failed to create pull request.',
});
}
} catch (error) {
setResult({
type: 'error',
message: `Error creating pull request: ${error}`,
});
} finally {
setSaving(false);
}
}, [discoveryApi, fetchApi, microsoftAuth, currentJson]);
if (loading) {
return (
<Page themeId="tool">
<Header title="Tech Radar Editor" />
<Content>
<CircularProgress />
</Content>
</Page>
);
}
return (
<Page themeId="tool">
<Header title="Tech Radar Editor">
<HeaderLabel label="Tool" value="Tech Radar" />
</Header>
<Content>
<div className={classes.editorContainer}>
<tech-radar-editor
ref={(el: HTMLElement | null) => {
editorRef.current = el;
}}
{...(initialJson ? { 'data-json': initialJson } : {})}
hide-title="true"
hide-export="true"
hide-json-input={initialJson ? 'true' : undefined}
hide-json-preview="true"
auto-expand-entries="true"
/>
</div>
<div className={classes.stickyFooter}>
<Button
variant="contained"
color="primary"
onClick={handleSave}
disabled={saving}
startIcon={saving ? <CircularProgress size={20} /> : undefined}
>
{saving ? 'Creating PR...' : 'Submit as Pull Request'}
</Button>
<Typography variant="body2" color="textSecondary">
This will create a pull request with your changes for review.
</Typography>
</div>
<Snackbar
open={result !== null}
autoHideDuration={result?.type === 'error' ? undefined : 15000}
onClose={() => setResult(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
{result ? (
<SnackbarContent
className={
result.type === 'success'
? classes.successSnackbar
: classes.errorSnackbar
}
message={
result.type === 'success' ? (
<span>
{result.message.split('View it here: ')[0]}
{result.message.includes('View it here: ') && (
<a
href={result.message.split('View it here: ')[1]}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontWeight: 'bold' }}
>
View the pull request
</a>
)}
</span>
) : (
result.message
)
}
action={
<IconButton
size="small"
color="inherit"
onClick={() => setResult(null)}
>
<CloseIcon fontSize="small" />
</IconButton>
}
/>
) : undefined}
</Snackbar>
</Content>
</Page>
);
};
@@ -0,0 +1 @@
export { TechRadarEditorPage } from './TechRadarEditorPage';
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"moduleResolution": "node",
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech Radar Editor</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<tech-radar-editor></tech-radar-editor>
<script>
// Listen for custom events from the web component
document.querySelector('tech-radar-editor').addEventListener('radar-data-loaded', (e) => {
console.log('Data loaded:', e.detail.data);
});
document.querySelector('tech-radar-editor').addEventListener('radar-data-change', (e) => {
console.log('Data changed:', e.detail.data);
});
</script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{
"name": "tech-radar-editor",
"version": "1.2.0",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/jcreek/Tech-Radar-Editor.git"
},
"keywords": [
"tech-radar",
"editor",
"visualization",
"web component",
"svelte",
"tech trends",
"technology radar",
"opensource",
"thoughtworks",
"zalando"
],
"author": "Josh Creek",
"license": "GPL-3.0",
"main": "dist/tech-radar-editor.umd.js",
"module": "dist/tech-radar-editor.es.js",
"types": "dist/main.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "vite",
"build": "vite build && tsc -p tsconfig.build.json",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json"
},
"dependencies": {
"svelte": "^4.2.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tsconfig/svelte": "^5.0.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-load-config": "^6.0.1",
"svelte-check": "^3.8.5",
"tailwindcss": "^3.4.13",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,4 +1,17 @@
<svelte:options customElement="tech-radar-editor" />
<svelte:options
customElement={{
tag: "tech-radar-editor",
props: {
dataUrl: { attribute: "data-url", reflect: false },
dataJson: { attribute: "data-json", reflect: false },
hideTitle: { attribute: "hide-title", type: "Boolean", reflect: false },
hideExport: { attribute: "hide-export", type: "Boolean", reflect: false },
hideJsonInput: { attribute: "hide-json-input", type: "Boolean", reflect: false },
hideJsonPreview: { attribute: "hide-json-preview", type: "Boolean", reflect: false },
autoExpandEntries: { attribute: "auto-expand-entries", type: "Boolean", reflect: false }
}
}}
/>
<style lang="postcss">
@tailwind base;
@@ -8,50 +21,38 @@
<script lang="ts">
import { onMount } from "svelte";
import type { TechRadarData, Quadrant, Ring, Entry, TimelineEntry } from "./types";
// Accept the dataUrl prop from Backstage
export let dataUrl: string;
// Existing prop
export let dataUrl: string = '';
interface TechRadarData {
title: string;
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
// New props (all backward-compatible, defaulting to falsy)
export let dataJson: string = '';
export let hideTitle: boolean | string = false;
export let hideExport: boolean | string = false;
export let hideJsonInput: boolean | string = false;
export let hideJsonPreview: boolean | string = false;
export let autoExpandEntries: boolean | string = false;
// Ref to container element, used to find the custom element host for event dispatch
let containerEl: HTMLElement;
function dispatchCustomEvent(name: string, detail: unknown) {
if (!containerEl) return;
const root = containerEl.getRootNode();
if (root && 'host' in root) {
(root as ShadowRoot).host.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true })
);
}
}
interface Quadrant {
id: string;
name: string;
expanded?: boolean;
}
interface Ring {
id: string;
name: string;
color: string;
description?: string;
expanded?: boolean;
}
interface Entry {
id: string;
title: string;
description?: string;
key: string;
url?: string;
quadrant: string;
timeline: TimelineEntry[];
expanded?: boolean;
}
interface TimelineEntry {
id: string;
moved: number; // -1, 0, 1
ringId: string;
date: string; // format YYYY-MM-dd
description: string;
expanded?: boolean;
}
// Boolean coercion (Svelte 4 custom elements pass strings for attributes)
$: _hideTitle = hideTitle === true || hideTitle === 'true' || hideTitle === '';
$: _hideExport = hideExport === true || hideExport === 'true' || hideExport === '';
$: _hideJsonInput = hideJsonInput === true || hideJsonInput === 'true' || hideJsonInput === '';
$: _hideJsonPreview = hideJsonPreview === true || hideJsonPreview === 'true' || hideJsonPreview === '';
$: _autoExpandEntries = autoExpandEntries === true || autoExpandEntries === 'true' || autoExpandEntries === '';
let techRadarData: TechRadarData = {
title: '',
@@ -66,11 +67,33 @@
let showQuadrants = false;
let showEntries = false;
let dataLoaded = false;
let idCounter = 0;
function generateId() {
return `id-${idCounter++}`;
}
/**
* Return a clean copy of the data without internal `expanded` properties.
*/
function getCleanData(data: TechRadarData): TechRadarData {
return {
title: data.title,
quadrants: data.quadrants.map(({ expanded, ...q }) => q as Quadrant),
rings: data.rings.map(({ expanded, ...r }) => r as Ring),
entries: data.entries.map(({ expanded, ...e }) => ({
...e,
timeline: e.timeline.map(({ expanded: _exp, ...t }) => t as TimelineEntry),
} as Entry)),
};
}
// Reactive dispatch: emit radar-data-change whenever techRadarData changes
$: if (dataLoaded && containerEl && techRadarData.title !== undefined) {
dispatchCustomEvent('radar-data-change', { data: getCleanData(techRadarData) });
}
function updateEntry(updatedEntry: Entry) {
techRadarData.entries = techRadarData.entries.map(entry =>
entry.id === updatedEntry.id ? updatedEntry : entry
@@ -78,7 +101,18 @@
}
onMount(async () => {
if (dataUrl) {
if (dataJson) {
try {
const parsed = JSON.parse(dataJson);
initializeData(parsed);
techRadarData = parsed;
if (_autoExpandEntries) showEntries = true;
dataLoaded = true;
dispatchCustomEvent('radar-data-loaded', { data: getCleanData(techRadarData) });
} catch (err) {
console.error('Error parsing dataJson:', err);
}
} else if (dataUrl) {
try {
const response = await fetch(dataUrl);
if (!response.ok) {
@@ -93,6 +127,9 @@
showRings = false;
showQuadrants = false;
showEntries = false;
if (_autoExpandEntries) showEntries = true;
dataLoaded = true;
dispatchCustomEvent('radar-data-loaded', { data: getCleanData(techRadarData) });
} catch (err) {
console.error(`Error fetching data from ${dataUrl}:`, err);
}
@@ -110,6 +147,8 @@
showRings = false;
showQuadrants = false;
showEntries = false;
dataLoaded = true;
dispatchCustomEvent('radar-data-loaded', { data: getCleanData(techRadarData) });
} catch (error) {
alert('Invalid JSON format');
console.error(error);
@@ -230,7 +269,8 @@
return;
}
const jsonString = JSON.stringify(techRadarData, null, 2);
const cleanData = getCleanData(techRadarData);
const jsonString = JSON.stringify(cleanData, null, 2);
navigator.clipboard.writeText(jsonString)
.then(() => alert('JSON data has been copied to the clipboard!'))
.catch(() => alert('Failed to copy JSON data.'));
@@ -285,19 +325,23 @@
$: jsonString = JSON.stringify(techRadarData, null, 2);
</script>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Tech Radar Editor</h1>
<div class="container mx-auto p-4" bind:this={containerEl}>
{#if !_hideTitle}
<h1 class="text-2xl font-bold mb-4">Tech Radar Editor</h1>
{/if}
<div class="mb-4">
<textarea
bind:this={jsonTextarea}
class="w-full p-2 border rounded text-black"
placeholder="Paste your JSON here..."
></textarea>
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-2" on:click={loadJson}>
Load JSON
</button>
</div>
{#if !_hideJsonInput}
<div class="mb-4">
<textarea
bind:this={jsonTextarea}
class="w-full p-2 border rounded text-black"
placeholder="Paste your JSON here..."
></textarea>
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-2" on:click={loadJson}>
Load JSON
</button>
</div>
{/if}
<!-- Rings Section -->
<h2 class="text-xl font-bold mb-2 flex items-center">
@@ -524,10 +568,14 @@
{/each}
{/if}
<button class="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded mb-4 mt-4" on:click={exportJson}>
Export JSON
</button>
{#if !_hideExport}
<button class="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded mb-4 mt-4" on:click={exportJson}>
Export JSON
</button>
{/if}
<h2 class="text-xl font-bold mb-2">JSON Preview:</h2>
<pre class="p-2 bg-gray-100 rounded text-black">{jsonString}</pre>
{#if !_hideJsonPreview}
<h2 class="text-xl font-bold mb-2">JSON Preview:</h2>
<pre class="p-2 bg-gray-100 rounded text-black">{jsonString}</pre>
{/if}
</div>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+3
View File
@@ -0,0 +1,3 @@
import TechRadarEditor from "./TechRadarEditor.svelte";
export type { TechRadarData, Quadrant, Ring, Entry, TimelineEntry } from "./types";
+40
View File
@@ -0,0 +1,40 @@
export interface TechRadarData {
title: string;
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
}
export interface Quadrant {
id: string;
name: string;
expanded?: boolean;
}
export interface Ring {
id: string;
name: string;
color: string;
description?: string;
expanded?: boolean;
}
export interface Entry {
id: string;
title: string;
description?: string;
key: string;
url?: string;
quadrant: string;
timeline: TimelineEntry[];
expanded?: boolean;
}
export interface TimelineEntry {
id: string;
moved: number; // -1, 0, 1
ringId: string;
date: string; // format YYYY-MM-dd
description: string;
expanded?: boolean;
}
+4525 -531
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'packages/*'
-1
View File
@@ -1 +0,0 @@
import TechRadarEditor from "./TechRadarEditor.svelte";
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}