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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,581 @@
|
||||
<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;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { TechRadarData, Quadrant, Ring, Entry, TimelineEntry } from "./types";
|
||||
|
||||
// Existing prop
|
||||
export let dataUrl: string = '';
|
||||
|
||||
// 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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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: '',
|
||||
quadrants: [],
|
||||
rings: [],
|
||||
entries: []
|
||||
};
|
||||
|
||||
let jsonTextarea: HTMLTextAreaElement;
|
||||
|
||||
let showRings = false;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
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) {
|
||||
throw new Error(
|
||||
`Failed to fetch from ${dataUrl}: ${response.status}`
|
||||
);
|
||||
}
|
||||
const remoteData = await response.json();
|
||||
initializeData(remoteData);
|
||||
techRadarData = remoteData;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function loadJson() {
|
||||
try {
|
||||
const parsedData = JSON.parse(jsonTextarea.value);
|
||||
|
||||
initializeData(parsedData);
|
||||
|
||||
techRadarData = parsedData;
|
||||
|
||||
showRings = false;
|
||||
showQuadrants = false;
|
||||
showEntries = false;
|
||||
dataLoaded = true;
|
||||
dispatchCustomEvent('radar-data-loaded', { data: getCleanData(techRadarData) });
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeData(data: TechRadarData) {
|
||||
data.entries.forEach((entry: Entry) => {
|
||||
entry.expanded = false;
|
||||
if (!entry.timeline) entry.timeline = [];
|
||||
entry.timeline.forEach((timelineItem: TimelineEntry) => {
|
||||
timelineItem.expanded = false;
|
||||
timelineItem.moved = Number(timelineItem.moved);
|
||||
if (!timelineItem.id) timelineItem.id = generateId();
|
||||
});
|
||||
if (!entry.id) entry.id = generateId();
|
||||
});
|
||||
|
||||
data.rings.forEach((ring: Ring) => {
|
||||
ring.expanded = false;
|
||||
if (!ring.id) ring.id = generateId();
|
||||
});
|
||||
|
||||
data.quadrants.forEach((quadrant: Quadrant) => {
|
||||
quadrant.expanded = false;
|
||||
if (!quadrant.id) quadrant.id = generateId();
|
||||
});
|
||||
}
|
||||
|
||||
function addRing() {
|
||||
const newRing: Ring = {
|
||||
id: generateId(),
|
||||
name: 'New Ring',
|
||||
color: '#000000',
|
||||
description: '',
|
||||
expanded: true
|
||||
};
|
||||
techRadarData.rings = [...techRadarData.rings, newRing];
|
||||
}
|
||||
|
||||
function addQuadrant() {
|
||||
const newQuadrant: Quadrant = {
|
||||
id: generateId(),
|
||||
name: 'New Quadrant',
|
||||
expanded: true
|
||||
};
|
||||
techRadarData.quadrants = [...techRadarData.quadrants, newQuadrant];
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
const newEntry: Entry = {
|
||||
id: generateId(),
|
||||
title: 'New Entry',
|
||||
description: '',
|
||||
key: 'new-key',
|
||||
url: '',
|
||||
quadrant: techRadarData.quadrants.length > 0 ? techRadarData.quadrants[0].id : '',
|
||||
timeline: [],
|
||||
expanded: true
|
||||
};
|
||||
techRadarData.entries = [...techRadarData.entries, newEntry];
|
||||
}
|
||||
|
||||
function addTimelineEntry(entryId: string) {
|
||||
const entry = techRadarData.entries.find(e => e.id === entryId);
|
||||
if (entry) {
|
||||
const newTimelineEntry: TimelineEntry = {
|
||||
id: generateId(),
|
||||
moved: 0,
|
||||
ringId: techRadarData.rings.length > 0 ? techRadarData.rings[0].id : '',
|
||||
date: '',
|
||||
description: '',
|
||||
expanded: true
|
||||
};
|
||||
entry.timeline = [...entry.timeline, newTimelineEntry];
|
||||
updateEntry({ ...entry });
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem<T>(items: T[], itemId: string): T[] {
|
||||
return items.filter((item: any) => item.id !== itemId);
|
||||
}
|
||||
|
||||
function removeRing(ringId: string) {
|
||||
if (confirm('Are you sure you want to remove this ring?')) {
|
||||
techRadarData.rings = removeItem(techRadarData.rings, ringId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeQuadrant(quadrantId: string) {
|
||||
if (confirm('Are you sure you want to remove this quadrant?')) {
|
||||
techRadarData.quadrants = removeItem(techRadarData.quadrants, quadrantId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntry(entryId: string) {
|
||||
if (confirm('Are you sure you want to remove this entry?')) {
|
||||
techRadarData.entries = removeItem(techRadarData.entries, entryId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeTimelineEntry(entryId: string, timelineItemId: string) {
|
||||
const entry = techRadarData.entries.find(e => e.id === entryId);
|
||||
if (entry) {
|
||||
if (confirm('Are you sure you want to remove this timeline entry?')) {
|
||||
entry.timeline = removeItem(entry.timeline, timelineItemId);
|
||||
updateEntry({ ...entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
// Perform validation
|
||||
const validationErrors = validateTechRadarData(techRadarData);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
alert('Validation errors:\n' + validationErrors.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
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.'));
|
||||
}
|
||||
|
||||
function validateTechRadarData(data: TechRadarData): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Validate rings
|
||||
data.rings.forEach((ring, index) => {
|
||||
if (!ring.id) errors.push(`Ring ${index + 1}: ID is required.`);
|
||||
if (!ring.name) errors.push(`Ring ${index + 1}: Name is required.`);
|
||||
if (!ring.color) errors.push(`Ring ${index + 1}: Color is required.`);
|
||||
});
|
||||
|
||||
// Validate quadrants
|
||||
data.quadrants.forEach((quadrant, index) => {
|
||||
if (!quadrant.id) errors.push(`Quadrant ${index + 1}: ID is required.`);
|
||||
if (!quadrant.name) errors.push(`Quadrant ${index + 1}: Name is required.`);
|
||||
});
|
||||
|
||||
// Validate entries
|
||||
data.entries.forEach((entry, index) => {
|
||||
if (!entry.id) errors.push(`Entry ${index + 1}: ID is required.`);
|
||||
if (!entry.title) errors.push(`Entry ${index + 1}: Title is required.`);
|
||||
if (!entry.key) errors.push(`Entry ${index + 1}: Key is required.`);
|
||||
if (!entry.quadrant) errors.push(`Entry ${index + 1}: Quadrant is required.`);
|
||||
if (!entry.timeline || entry.timeline.length === 0) {
|
||||
errors.push(`Entry ${index + 1}: At least one timeline entry is required.`);
|
||||
} else {
|
||||
entry.timeline.forEach((timelineItem, timelineIndex) => {
|
||||
if (typeof timelineItem.moved !== 'number') {
|
||||
errors.push(`Entry ${index + 1}, Timeline ${timelineIndex + 1}: Moved must be a number.`);
|
||||
}
|
||||
if (!timelineItem.ringId) {
|
||||
errors.push(`Entry ${index + 1}, Timeline ${timelineIndex + 1}: Ring ID is required.`);
|
||||
}
|
||||
if (!timelineItem.date) {
|
||||
errors.push(`Entry ${index + 1}, Timeline ${timelineIndex + 1}: Date is required.`);
|
||||
}
|
||||
if (!timelineItem.description) {
|
||||
errors.push(`Entry ${index + 1}, Timeline ${timelineIndex + 1}: Description is required.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Reactive statement to update JSON preview whenever techRadarData changes
|
||||
$: jsonString = JSON.stringify(techRadarData, null, 2);
|
||||
</script>
|
||||
|
||||
<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}
|
||||
|
||||
{#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">
|
||||
<button on:click={() => showRings = !showRings} type="button" class="mr-2">
|
||||
{#if showRings}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Rings
|
||||
</h2>
|
||||
|
||||
{#if showRings}
|
||||
<button class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mb-4" on:click={addRing}>
|
||||
Add Ring
|
||||
</button>
|
||||
|
||||
{#each techRadarData.rings as ring, ringIndex (ring.id)}
|
||||
<fieldset class="border p-2 mb-2">
|
||||
<legend class="font-bold flex items-center">
|
||||
<button on:click={() => ring.expanded = !ring.expanded} type="button" class="mr-2">
|
||||
{#if ring.expanded}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Ring {ringIndex + 1}: {ring.name}
|
||||
</legend>
|
||||
|
||||
{#if ring.expanded}
|
||||
<label class="block mb-1">
|
||||
ID:
|
||||
<input type="text" bind:value={ring.id} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Name:
|
||||
<input type="text" bind:value={ring.name} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Color:
|
||||
<input type="color" bind:value={ring.color} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Description:
|
||||
<textarea bind:value={ring.description} class="border p-1 w-full text-black"></textarea>
|
||||
</label>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded" on:click={() => removeRing(ring.id)} type="button">Remove Ring</button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Quadrants Section -->
|
||||
<h2 class="text-xl font-bold mb-2 flex items-center">
|
||||
<button on:click={() => showQuadrants = !showQuadrants} type="button" class="mr-2">
|
||||
{#if showQuadrants}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Quadrants
|
||||
</h2>
|
||||
|
||||
{#if showQuadrants}
|
||||
<button class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mb-4" on:click={addQuadrant}>
|
||||
Add Quadrant
|
||||
</button>
|
||||
|
||||
{#each techRadarData.quadrants as quadrant, quadrantIndex (quadrant.id)}
|
||||
<fieldset class="border p-2 mb-2">
|
||||
<legend class="font-bold flex items-center">
|
||||
<button on:click={() => quadrant.expanded = !quadrant.expanded} type="button" class="mr-2">
|
||||
{#if quadrant.expanded}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Quadrant {quadrantIndex + 1}: {quadrant.name}
|
||||
</legend>
|
||||
|
||||
{#if quadrant.expanded}
|
||||
<label class="block mb-1">
|
||||
ID:
|
||||
<input type="text" bind:value={quadrant.id} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Name:
|
||||
<input type="text" bind:value={quadrant.name} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded" on:click={() => removeQuadrant(quadrant.id)} type="button">Remove Quadrant</button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Entries Section -->
|
||||
<h2 class="text-xl font-bold mb-2 flex items-center">
|
||||
<button on:click={() => showEntries = !showEntries} type="button" class="mr-2">
|
||||
{#if showEntries}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Entries
|
||||
</h2>
|
||||
|
||||
{#if showEntries}
|
||||
<button class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mb-4" on:click={addEntry}>
|
||||
Add Entry
|
||||
</button>
|
||||
|
||||
{#each techRadarData.entries as entry, entryIndex (entry.id)}
|
||||
<fieldset class="border p-2 mb-2">
|
||||
<legend class="font-bold flex items-center">
|
||||
<button on:click={() => entry.expanded = !entry.expanded} type="button" class="mr-2">
|
||||
{#if entry.expanded}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Entry {entryIndex + 1}: {entry.title}
|
||||
</legend>
|
||||
|
||||
{#if entry.expanded}
|
||||
<label class="block mb-1">
|
||||
ID:
|
||||
<input type="text" bind:value={entry.id} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Title:
|
||||
<input type="text" bind:value={entry.title} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Description:
|
||||
<textarea bind:value={entry.description} class="border p-1 w-full text-black"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Key:
|
||||
<input type="text" bind:value={entry.key} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
URL:
|
||||
<input type="text" bind:value={entry.url} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Quadrant:
|
||||
<select bind:value={entry.quadrant} class="border p-1 w-full text-black">
|
||||
{#each techRadarData.quadrants as quadrant}
|
||||
<option value={quadrant.id}>{quadrant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-2 rounded mb-2" on:click={() => addTimelineEntry(entry.id)} type="button">Add Timeline Entry</button>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded mb-2" on:click={() => removeEntry(entry.id)} type="button">Remove Entry</button>
|
||||
|
||||
<!-- Timeline entries -->
|
||||
{#each entry.timeline as timelineItem, timelineIndex (timelineItem.id)}
|
||||
<fieldset class="border p-2 mb-2">
|
||||
<legend class="font-bold flex items-center">
|
||||
<button on:click={() => timelineItem.expanded = !timelineItem.expanded} type="button" class="mr-2">
|
||||
{#if timelineItem.expanded}
|
||||
[-]
|
||||
{:else}
|
||||
[+]
|
||||
{/if}
|
||||
</button>
|
||||
Timeline {timelineIndex + 1}
|
||||
</legend>
|
||||
|
||||
{#if timelineItem.expanded}
|
||||
<label class="block mb-1">
|
||||
Moved:
|
||||
<select bind:value={timelineItem.moved} class="border p-1 w-full text-black">
|
||||
<option value={-1}>Moved Out (-1)</option>
|
||||
<option value={0}>Stayed (0)</option>
|
||||
<option value={1}>Moved In (1)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Ring ID:
|
||||
<select bind:value={timelineItem.ringId} class="border p-1 w-full text-black">
|
||||
{#each techRadarData.rings as ring}
|
||||
<option value={ring.id}>{ring.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Date:
|
||||
<input type="date" bind:value={timelineItem.date} class="border p-1 w-full text-black"/>
|
||||
</label>
|
||||
|
||||
<label class="block mb-1">
|
||||
Description:
|
||||
<textarea bind:value={timelineItem.description} class="border p-1 w-full text-black"></textarea>
|
||||
</label>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded" on:click={() => removeTimelineEntry(entry.id, timelineItem.id)} type="button">Remove Timeline Entry</button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
|
||||
{#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>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
compilerOptions: {
|
||||
customElement: true,
|
||||
},
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{svelte,js,ts}", "./public/index.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationDir": "dist",
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
customElement: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/main.ts",
|
||||
formats: ["es", "umd"],
|
||||
name: "TechRadarEditor",
|
||||
fileName: (format) => `tech-radar-editor.${format}.js`,
|
||||
},
|
||||
cssCodeSplit: false, // Ensure all CSS is bundled together
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user