mirror of
https://github.com/jcreek/Tech-Radar-Editor.git
synced 2026-07-12 18:43:46 +00:00
feat(*): Enable loading the file and creating a PR in Azure Devops
This commit is contained in:
@@ -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 editor’s 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
@@ -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
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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';
|
||||
Binary file not shown.
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
+108
-60
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
import TechRadarEditor from "./TechRadarEditor.svelte";
|
||||
|
||||
export type { TechRadarData, Quadrant, Ring, Entry, TimelineEntry } from "./types";
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Generated
+4525
-531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
@@ -1 +0,0 @@
|
||||
import TechRadarEditor from "./TechRadarEditor.svelte";
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user