diff --git a/src/components/atoms/OpenConfig.tsx b/src/components/atoms/OpenConfig.tsx index 00eb8e5..c50680f 100644 --- a/src/components/atoms/OpenConfig.tsx +++ b/src/components/atoms/OpenConfig.tsx @@ -1,16 +1,17 @@ -import { parsers } from '@circleci/circleci-config-sdk'; -import { OrbImportManifest } from '@circleci/circleci-config-sdk/dist/src/lib/Orb/types/Orb.types'; import { useRef } from 'react'; -import { parse } from 'yaml'; import OpenIcon from '../../icons/ui/OpenIcon'; -import { useStoreActions, useStoreState } from '../../state/Hooks'; +import { + useConfigParser, + useStoreActions, + useStoreState, +} from '../../state/Hooks'; import { Button } from '../atoms/Button'; -import { loadOrb } from '../menus/definitions/OrbDefinitionsMenu'; export const OpenConfig = () => { const inputFile = useRef(null); const config = useStoreState((state) => state.config); const loadConfig = useStoreActions((actions) => actions.loadConfig); + const parseConfig = useConfigParser(); return ( <> @@ -25,73 +26,8 @@ export const OpenConfig = () => { return; } - const setConfig = ( - yml: string, - orbImports?: Record, - ) => { - let parseResult; - try { - parseResult = { - config: parsers.parseConfig(yml, orbImports), - manifests: orbImports, - }; - } catch (e) { - parseResult = e as Error; - } - loadConfig(parseResult); - }; - e.target.files[0].text().then((yml) => { - const configBlob = parse(yml); - - if ('orbs' in configBlob) { - if (!configBlob.orbs) { - setConfig(yml); - return; - } - - const orbPromises = Object.entries(configBlob.orbs).map( - ([alias, stanza]) => { - const parsedOrb = parsers.parseOrbImport({ [alias]: stanza }); - - if (!parsedOrb) { - const parseError = new Error( - `Could not parse orb ${alias}`, - ); - - loadConfig(parseError); - throw parseError; - } - - return loadOrb(stanza as string, parsedOrb, alias); - }, - ); - - Promise.all(orbPromises).then((loadedOrbs) => { - const orbImports: Record = - Object.assign( - {}, - ...loadedOrbs.map(({ orb, manifest, alias }) => { - if (!alias) { - const parseError = new Error( - `Could not parse orb ${orb}, no alias`, - ); - - loadConfig(parseError); - throw parseError; - } - - return { - [alias]: manifest, - }; - }), - ); - - setConfig(yml, orbImports); - }); - } else { - setConfig(yml); - } + parseConfig(yml, loadConfig); }); }} /> diff --git a/src/components/panes/EditorPane.tsx b/src/components/panes/EditorPane.tsx index 4a94ac0..eaec661 100644 --- a/src/components/panes/EditorPane.tsx +++ b/src/components/panes/EditorPane.tsx @@ -1,14 +1,43 @@ import Editor, { DiffEditor } from '@monaco-editor/react'; +import { useEffect, useState } from 'react'; import CopyIcon from '../../icons/ui/CopyIcon'; -import { useStoreState } from '../../state/Hooks'; +import { + useConfigParser, + useStoreActions, + useStoreState, +} from '../../state/Hooks'; import { version } from '../../version.json'; import { Button } from '../atoms/Button'; import { OpenConfig } from '../atoms/OpenConfig'; +import templates from '../../examples'; const EditorPane = (props: any) => { const config = useStoreState((state) => state.config); - const error = useStoreState((state) => state.errorMessage); + const error = useStoreState((state) => state.configError); + const [example, setExample] = useState(undefined); const editingConfig = useStoreState((state) => state.editingConfig); + const loadConfig = useStoreActions((actions) => actions.loadConfig); + const parseConfig = useConfigParser(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + + if (params.has('example') && !example) { + const queryConfig = params.get('example'); + + if (!queryConfig) { + return; + } + + setExample(queryConfig); + + if (queryConfig in templates) { + const template = templates[queryConfig as keyof typeof templates]; + + parseConfig(JSON.stringify(template, null, 2), loadConfig); + } + } + }, [example, loadConfig, parseConfig]); const configYAML = (yml: string) => { const matchSDKComment = yml?.match('# SDK Version: .*\n'); diff --git a/src/examples/blogpost.json b/src/examples/blogpost.json new file mode 100644 index 0000000..e28059c --- /dev/null +++ b/src/examples/blogpost.json @@ -0,0 +1,33 @@ +{ + "version": 2.1, + "setup": false, + "jobs": {}, + "workflows": { + "build-and-deploy": { + "jobs": [ + { + "node/test": { + "run-command": "test:unit", + "pkg-manager": "yarn" + } + }, + { + "heroku/deploy-via-git": { + "context": ["cfd-deploy"], + "requires": ["node/test"], + "filters": { + "branches": { + "only": ["main"] + } + }, + "app-name": "cfd-sample" + } + } + ] + } + }, + "orbs": { + "node": "circleci/node@5.0.2", + "heroku": "circleci/heroku@1.2.6" + } +} diff --git a/src/examples/index.ts b/src/examples/index.ts new file mode 100644 index 0000000..faf2381 --- /dev/null +++ b/src/examples/index.ts @@ -0,0 +1,9 @@ +import blogpost from './blogpost.json'; +import readme from './readme.json'; + +const templates = { + blogpost, + readme, +}; + +export default templates; diff --git a/src/examples/readme.json b/src/examples/readme.json new file mode 100644 index 0000000..b5d550d --- /dev/null +++ b/src/examples/readme.json @@ -0,0 +1,93 @@ +{ + "version": 2.1, + "setup": false, + "jobs": { + "build": { + "steps": [ + "checkout", + { + "run": { + "command": "yarn build" + } + }, + { + "persist_to_workspace": { + "root": "../", + "paths": ["build"] + } + } + ], + "docker": [ + { + "image": "cimg/node:16.11.1" + } + ], + "resource_class": "medium" + }, + "test": { + "steps": [ + { + "attach_workspace": { + "at": "." + } + }, + { + "run": { + "command": "yarn test", + "working_directory": "~/project/build" + } + }, + { + "persist_to_workspace": { + "root": ".", + "paths": ["build"] + } + } + ], + "docker": [ + { + "image": "cimg/node:16.11.1" + } + ], + "resource_class": "medium" + }, + "deploy": { + "steps": [ + { + "attach_workspace": { + "at": "." + } + }, + { + "run": { + "command": "yarn deploy", + "working_directory": "~/project/build" + } + } + ], + "docker": [ + { + "image": "cimg/node:16.11.1" + } + ], + "resource_class": "medium" + } + }, + "workflows": { + "build-and-test": { + "jobs": [ + "build", + { + "test": { + "requires": ["build"] + } + }, + { + "deploy": { + "requires": ["test"] + } + } + ] + } + } +} diff --git a/src/state/Hooks.tsx b/src/state/Hooks.tsx index ff7e96d..9435b1f 100644 --- a/src/state/Hooks.tsx +++ b/src/state/Hooks.tsx @@ -2,6 +2,10 @@ import { createTypedHooks } from 'easy-peasy'; import { useEffect, useState } from 'react'; import { StoreActions, StoreModel } from './Store'; +import { parse } from 'yaml'; +import { OrbImportManifest } from '@circleci/circleci-config-sdk/dist/src/lib/Orb/types/Orb.types'; +import { Config, parsers } from '@circleci/circleci-config-sdk'; +import { loadOrb } from '../components/menus/definitions/OrbDefinitionsMenu'; const typedHooks = createTypedHooks(); export const useStoreActions = typedHooks.useStoreActions; @@ -32,3 +36,70 @@ export default function useWindowDimensions() { return windowDimensions; } +export type CallbackResponse = + | { + config: Config; + manifests: Record | undefined; + } + | Error; + +export const parseConfigHook = ( + yml: string, + callback: (res: CallbackResponse) => void, +) => { + const setConfig = ( + yml: string, + orbImports?: Record, + ) => { + let parseResult; + try { + parseResult = { + config: parsers.parseConfig(yml, orbImports), + manifests: orbImports, + }; + } catch (e) { + parseResult = e as Error; + } + callback(parseResult); + }; + + const configBlob = parse(yml); + + if ('orbs' in configBlob) { + if (!configBlob.orbs) { + setConfig(yml); + return; + } + + const orbPromises = Object.entries(configBlob.orbs).map( + ([alias, stanza]) => { + const parsedOrb = parsers.parseOrbImport({ [alias]: stanza }); + if (!parsedOrb) { + throw new Error(`Could not parse orb ${alias}`); + } + return loadOrb(stanza as string, parsedOrb, alias); + }, + ); + + Promise.all(orbPromises).then((loadedOrbs) => { + const orbImports: Record = Object.assign( + {}, + ...loadedOrbs.map(({ orb, manifest, alias }) => { + if (!alias) { + throw new Error(`Could not load orb ${orb}`); + } + return { + [alias]: manifest, + }; + }), + ); + setConfig(yml, orbImports); + }); + } else { + setConfig(yml); + } +}; + +export const useConfigParser = () => { + return parseConfigHook; +}; diff --git a/src/state/Store.tsx b/src/state/Store.tsx index a67ae3f..27d2e71 100644 --- a/src/state/Store.tsx +++ b/src/state/Store.tsx @@ -142,7 +142,7 @@ export type StoreModel = DefinitionsStoreModel & { }; /** Currently selected workflow pane index */ selectedWorkflowId: string; - errorMessage?: string; + configError?: string; }; export type UpdateDiff = { @@ -714,12 +714,14 @@ const Actions: StoreActions = { loadConfig: action((state, payload) => { if (payload instanceof Error) { - state.errorMessage = payload.message; + state.configError = payload.message; console.error(payload); return; } + state.configError = ''; + const config = payload.config; const nodeWidth = 250; // Make this dynamic const nodeHeight = 60; // Make this dynamic