diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx
index cb77689b6e..78d6a452ea 100644
--- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx
+++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx
@@ -41,7 +41,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
.filter(({ url, entityRef }) => url || entityRef)
.map(({ url, entityRef, title, icon }) => {
if (entityRef) {
- const entityName = parseEntityRef(entityRef);
+ const entityName = parseEntityRef(entityRef, {
+ defaultKind: '
',
+ defaultNamespace: '',
+ });
const target = entityRoute(entityName);
return { title, icon, url: target };
}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx
new file mode 100644
index 0000000000..0edf390bfe
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ErrorPanel, Progress } from '@backstage/core-components';
+import { useAsync, useRerender } from '@react-hookz/web';
+import React, { createContext, ReactNode, useContext, useEffect } from 'react';
+import {
+ TemplateDirectoryAccess,
+ TemplateFileAccess,
+} from '../../lib/filesystem';
+
+const MAX_SIZE = 1024 * 1024;
+const MAX_SIZE_MESSAGE = 'This file is too large to be displayed';
+
+interface DirectoryEditorFile {
+ /** The path of the file relative to the root directory */
+ path: string;
+ /** The staged content of the file */
+ content: string;
+ /** Whether the staged content matches what is on disk */
+ dirty: boolean;
+
+ /** Update the staged content of the file without saving */
+ updateContent(content: string): void;
+ /** Save the staged content of the file to disk */
+ save(): Promise;
+ /** Reload the staged content of the file from disk */
+ reload(): Promise;
+}
+
+interface DirectoryEditor {
+ /** A list of all files in the edited directory */
+ files: Array;
+
+ /** The currently selected file */
+ selectedFile: DirectoryEditorFile | undefined;
+ /** Switch the selected file */
+ setSelectedFile(path: string | undefined): void;
+
+ /** Save all files to disk */
+ save(): Promise;
+ /** Reload all files from disk */
+ reload(): Promise;
+
+ subscribe(listener: () => void): () => void;
+}
+
+class DirectoryEditorFileManager implements DirectoryEditorFile {
+ readonly #access: TemplateFileAccess;
+ readonly #signalUpdate: () => void;
+
+ #content?: string;
+ #savedContent?: string;
+
+ constructor(access: TemplateFileAccess, signalUpdate: () => void) {
+ this.#access = access;
+ this.#signalUpdate = signalUpdate;
+ }
+
+ get path() {
+ return this.#access.path;
+ }
+
+ get content() {
+ return this.#content ?? MAX_SIZE_MESSAGE;
+ }
+
+ updateContent(content: string): void {
+ if (this.#content === undefined) {
+ return;
+ }
+ this.#content = content;
+ this.#signalUpdate();
+ }
+
+ get dirty() {
+ return this.#content !== this.#savedContent;
+ }
+
+ async save(): Promise {
+ if (this.#content !== undefined) {
+ await this.#access.save(this.#content);
+ this.#savedContent = this.#content;
+ this.#signalUpdate();
+ }
+ }
+
+ async reload(): Promise {
+ const file = await this.#access.file();
+ if (file.size > MAX_SIZE) {
+ if (this.#content !== undefined) {
+ this.#content = undefined;
+ this.#savedContent = undefined;
+ this.#signalUpdate();
+ }
+ return;
+ }
+
+ const content = await file.text();
+ if (this.#content !== content) {
+ this.#content = content;
+ this.#savedContent = content;
+ this.#signalUpdate();
+ }
+ }
+}
+
+class DirectoryEditorManager implements DirectoryEditor {
+ readonly #access: TemplateDirectoryAccess;
+ readonly #listeners = new Set<() => void>();
+
+ #files: DirectoryEditorFile[] = [];
+ #selectedFile: DirectoryEditorFile | undefined;
+
+ constructor(access: TemplateDirectoryAccess) {
+ this.#access = access;
+ }
+
+ get files() {
+ return this.#files;
+ }
+
+ get selectedFile() {
+ return this.#selectedFile;
+ }
+
+ setSelectedFile = (path: string | undefined): void => {
+ const prev = this.#selectedFile;
+ const next = this.#files.find(file => file.path === path);
+ if (prev !== next) {
+ this.#selectedFile = next;
+ this.#signalUpdate();
+ }
+ };
+
+ get dirty() {
+ return this.#files.some(file => file.dirty);
+ }
+
+ async save(): Promise {
+ await Promise.all(this.#files.map(file => file.save()));
+ }
+
+ async reload(): Promise {
+ const selectedPath = this.#selectedFile?.path;
+
+ const files = await this.#access.listFiles();
+ const fileManagers = await Promise.all(
+ files.map(async file => {
+ const manager = new DirectoryEditorFileManager(
+ file,
+ this.#signalUpdate,
+ );
+ await manager.reload();
+ return manager;
+ }),
+ );
+ this.#files.length = 0;
+ this.#files.push(...fileManagers);
+
+ this.setSelectedFile(selectedPath);
+ this.#signalUpdate();
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.#listeners.add(listener);
+ return () => {
+ this.#listeners.delete(listener);
+ };
+ }
+
+ #signalUpdate = () => {
+ this.#listeners.forEach(listener => listener());
+ };
+}
+
+const DirectoryEditorContext = createContext(
+ undefined,
+);
+
+export function useDirectoryEditor(): DirectoryEditor {
+ const value = useContext(DirectoryEditorContext);
+ const rerender = useRerender();
+
+ useEffect(() => value?.subscribe(rerender), [value, rerender]);
+
+ if (!value) {
+ throw new Error('must be used within a DirectoryEditorProvider');
+ }
+ return value;
+}
+
+interface DirectoryEditorProviderProps {
+ directory: TemplateDirectoryAccess;
+ children?: ReactNode;
+}
+
+export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
+ const { directory } = props;
+
+ const [{ result, error }, { execute }] = useAsync(
+ async (dir: TemplateDirectoryAccess) => {
+ const manager = new DirectoryEditorManager(dir);
+ await manager.reload();
+
+ const firstYaml = manager.files.find(file => file.path.match(/\.ya?ml$/));
+ if (firstYaml) {
+ manager.setSelectedFile(firstYaml.path);
+ }
+
+ return manager;
+ },
+ );
+
+ useEffect(() => {
+ execute(directory);
+ }, [execute, directory]);
+
+ if (error) {
+ return ;
+ } else if (!result) {
+ return ;
+ }
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx
new file mode 100644
index 0000000000..b1fe8a19ff
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import yaml from 'yaml';
+import { useApi } from '@backstage/core-plugin-api';
+import { JsonObject } from '@backstage/types';
+import React, {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { scaffolderApiRef } from '../../api';
+import { ScaffolderDryRunResponse } from '../../types';
+
+interface DryRunOptions {
+ templateContent: string;
+ values: JsonObject;
+ files: Array<{ path: string; content: string }>;
+}
+
+interface DryRunResult extends ScaffolderDryRunResponse {
+ id: number;
+}
+
+interface DryRun {
+ results: DryRunResult[];
+ selectedResult: DryRunResult | undefined;
+
+ selectResult(id: number): void;
+ deleteResult(id: number): void;
+ execute(options: DryRunOptions): Promise;
+}
+
+const DryRunContext = createContext(undefined);
+
+interface DryRunProviderProps {
+ children: ReactNode;
+}
+
+export function DryRunProvider(props: DryRunProviderProps) {
+ const scaffolderApi = useApi(scaffolderApiRef);
+
+ const [state, setState] = useState<
+ Pick
+ >({
+ results: [],
+ selectedResult: undefined,
+ });
+ const idRef = useRef(1);
+
+ const selectResult = useCallback((id: number) => {
+ setState(prevState => {
+ const result = prevState.results.find(r => r.id === id);
+ if (result === prevState.selectedResult) {
+ return prevState;
+ }
+ return {
+ results: prevState.results,
+ selectedResult: result,
+ };
+ });
+ }, []);
+
+ const deleteResult = useCallback((id: number) => {
+ setState(prevState => {
+ const index = prevState.results.findIndex(r => r.id === id);
+ if (index === -1) {
+ return prevState;
+ }
+ const newResults = prevState.results.slice();
+ const [deleted] = newResults.splice(index, 1);
+ return {
+ results: newResults,
+ selectedResult:
+ prevState.selectedResult?.id === deleted.id
+ ? newResults[0]
+ : prevState.selectedResult,
+ };
+ });
+ }, []);
+
+ const execute = useCallback(
+ async (options: DryRunOptions) => {
+ if (!scaffolderApi.dryRun) {
+ throw new Error('Scaffolder API does not support dry-run');
+ }
+
+ const parsed = yaml.parse(options.templateContent);
+
+ const response = await scaffolderApi.dryRun({
+ template: parsed,
+ values: options.values,
+ secrets: {},
+ directoryContents: options.files.map(file => ({
+ path: file.path,
+ base64Content: btoa(file.content),
+ })),
+ });
+
+ const result = {
+ ...response,
+ id: idRef.current++,
+ };
+
+ setState(prevState => ({
+ results: [...prevState.results, result],
+ selectedResult: prevState.selectedResult ?? result,
+ }));
+ },
+ [scaffolderApi],
+ );
+
+ const dryRun = useMemo(
+ () => ({
+ ...state,
+ selectResult,
+ deleteResult,
+ execute,
+ }),
+ [state, selectResult, deleteResult, execute],
+ );
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useDryRun(): DryRun {
+ const value = useContext(DryRunContext);
+ if (!value) {
+ throw new Error('must be used within a DryRunProvider');
+ }
+ return value;
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx
new file mode 100644
index 0000000000..bc3354cc46
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React, { useEffect } from 'react';
+import { scaffolderApiRef } from '../../../api';
+import { DryRunProvider, useDryRun } from '../DryRunContext';
+import { DryRunResults } from './DryRunResults';
+
+function DryRunRemote({
+ execute,
+ remove,
+}: {
+ execute?: boolean;
+ remove?: boolean;
+}) {
+ const dryRun = useDryRun();
+
+ useEffect(() => {
+ if (execute) {
+ dryRun.execute({
+ templateContent: '',
+ values: {},
+ files: [],
+ });
+ }
+ if (remove) {
+ if (dryRun.selectedResult) {
+ dryRun.deleteResult(dryRun.selectedResult.id);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [execute, remove]);
+
+ return null;
+}
+
+const mockScaffolderApi = {
+ dryRun: async () => ({
+ directoryContents: [],
+ log: [],
+ output: {},
+ steps: [],
+ }),
+};
+
+describe('DryRunResults', () => {
+ it('renders without exploding', async () => {
+ await renderInTestApp(
+
+
+
+
+ ,
+ );
+ expect(screen.getByText('Dry-run results')).toBeInTheDocument();
+ });
+
+ it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => {
+ const { rerender } = await renderInTestApp(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('Files')).not.toBeVisible();
+
+ await act(async () => {
+ rerender(
+
+
+
+
+
+ ,
+ );
+ });
+
+ expect(screen.getByText('Files')).toBeVisible();
+
+ await userEvent.click(screen.getByText('Dry-run results'));
+ await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible());
+
+ await userEvent.click(screen.getByText('Dry-run results'));
+ expect(screen.getByText('Files')).toBeVisible();
+
+ await act(async () => {
+ rerender(
+
+
+
+
+
+ ,
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible());
+ });
+});
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx
new file mode 100644
index 0000000000..ae9374438f
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BackstageTheme } from '@backstage/theme';
+import Accordion from '@material-ui/core/Accordion';
+import AccordionDetails from '@material-ui/core/AccordionDetails';
+import AccordionSummary from '@material-ui/core/AccordionSummary';
+import Divider from '@material-ui/core/Divider';
+import { makeStyles } from '@material-ui/core/styles';
+import Typography from '@material-ui/core/Typography';
+import ExpandMoreIcon from '@material-ui/icons/ExpandLess';
+import { usePrevious } from '@react-hookz/web';
+import React, { useEffect, useState } from 'react';
+import { useDryRun } from '../DryRunContext';
+import { DryRunResultsList } from './DryRunResultsList';
+import { DryRunResultsView } from './DryRunResultsView';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ header: {
+ height: 48,
+ minHeight: 0,
+ '&.Mui-expanded': {
+ height: 48,
+ minHeight: 0,
+ },
+ },
+ content: {
+ display: 'grid',
+ background: theme.palette.background.default,
+ gridTemplateColumns: '180px auto 1fr',
+ gridTemplateRows: '1fr',
+ padding: 0,
+ height: 400,
+ },
+}));
+
+export function DryRunResults() {
+ const classes = useStyles();
+ const dryRun = useDryRun();
+ const [expanded, setExpanded] = useState(false);
+ const [hidden, setHidden] = useState(true);
+
+ const resultsLength = dryRun.results.length;
+ const prevResultsLength = usePrevious(resultsLength);
+ useEffect(() => {
+ if (prevResultsLength === 0 && resultsLength === 1) {
+ setHidden(false);
+ setExpanded(true);
+ } else if (prevResultsLength === 1 && resultsLength === 0) {
+ setExpanded(false);
+ }
+ }, [prevResultsLength, resultsLength]);
+
+ return (
+ <>
+ setExpanded(exp)}
+ onTransitionEnd={() => resultsLength === 0 && setHidden(true)}
+ >
+ }
+ >
+ Dry-run results
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx
new file mode 100644
index 0000000000..65eb59c84a
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React, { useEffect } from 'react';
+import { scaffolderApiRef } from '../../../api';
+import { DryRunProvider, useDryRun } from '../DryRunContext';
+import { DryRunResultsList } from './DryRunResultsList';
+
+function DryRunRemote({ execute }: { execute?: number }) {
+ const dryRun = useDryRun();
+
+ useEffect(() => {
+ if (execute) {
+ dryRun.execute({
+ templateContent: '',
+ values: {},
+ files: [],
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [execute]);
+
+ return null;
+}
+
+const mockScaffolderApi = {
+ dryRun: async () => ({
+ directoryContents: [],
+ log: [],
+ output: {},
+ steps: [],
+ }),
+};
+
+describe('DryRunResultsList', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp(
+
+
+
+
+ ,
+ );
+ expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement();
+ });
+
+ it('adds new result items and deletes them', async () => {
+ const { rerender } = await renderInTestApp(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.queryByText('Result 1')).toBeInTheDocument();
+ expect(screen.queryByText('Result 2')).not.toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+
+
+
+
+
+ ,
+ );
+ });
+
+ expect(screen.queryByText('Result 1')).toBeInTheDocument();
+ expect(screen.queryByText('Result 2')).toBeInTheDocument();
+
+ await userEvent.click(screen.getAllByLabelText('delete')[0]);
+
+ expect(screen.queryByText('Result 1')).not.toBeInTheDocument();
+ expect(screen.queryByText('Result 2')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx
new file mode 100644
index 0000000000..462c3b58fd
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BackstageTheme } from '@backstage/theme';
+import IconButton from '@material-ui/core/IconButton';
+import List from '@material-ui/core/List';
+import ListItem from '@material-ui/core/ListItem';
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
+import ListItemText from '@material-ui/core/ListItemText';
+import { makeStyles } from '@material-ui/core/styles';
+import CancelIcon from '@material-ui/icons/Cancel';
+import CheckIcon from '@material-ui/icons/Check';
+import DeleteIcon from '@material-ui/icons/Delete';
+import React from 'react';
+import { useDryRun } from '../DryRunContext';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ root: {
+ overflowY: 'auto',
+ background: theme.palette.background.default,
+ },
+ iconSuccess: {
+ minWidth: 0,
+ marginRight: theme.spacing(1),
+ color: theme.palette.status.ok,
+ },
+ iconFailure: {
+ minWidth: 0,
+ marginRight: theme.spacing(1),
+ color: theme.palette.status.error,
+ },
+}));
+
+export function DryRunResultsList() {
+ const classes = useStyles();
+ const dryRun = useDryRun();
+
+ return (
+
+ {dryRun.results.map(result => {
+ const failed = result.log.some(l => l.body.status === 'failed');
+ return (
+ dryRun.selectResult(result.id)}
+ >
+
+ {failed ? : }
+
+
+
+ dryRun.deleteResult(result.id)}
+ >
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx
new file mode 100644
index 0000000000..b554a1c0c0
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { makeStyles } from '@material-ui/core/styles';
+import Divider from '@material-ui/core/Divider';
+import React, { Children, ReactNode } from 'react';
+import classNames from 'classnames';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ display: 'grid',
+ gridTemplateColumns: '280px auto 3fr',
+ gridTemplateRows: '1fr',
+ },
+ child: {
+ overflowY: 'auto',
+ height: '100%',
+ minHeight: 0,
+ },
+ firstChild: {
+ background: theme.palette.background.paper,
+ },
+}));
+
+export function DryRunResultsSplitView(props: { children: ReactNode }) {
+ const classes = useStyles();
+ const childArray = Children.toArray(props.children);
+
+ if (childArray.length !== 2) {
+ throw new Error('must have exactly 2 children');
+ }
+
+ return (
+
+
+ {childArray[0]}
+
+
+
{childArray[1]}
+
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx
new file mode 100644
index 0000000000..bc6b5d8f6f
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React, { ReactNode, useEffect } from 'react';
+import { scaffolderApiRef } from '../../../api';
+import { DryRunProvider, useDryRun } from '../DryRunContext';
+import { DryRunResultsView } from './DryRunResultsView';
+
+// The inside needs mocking to render in jsdom
+jest.mock('react-virtualized-auto-sizer', () => ({
+ __esModule: true,
+ default: (props: {
+ children: (size: { width: number; height: number }) => ReactNode;
+ }) => <>{props.children({ width: 400, height: 200 })}>,
+}));
+
+function DryRunRemote() {
+ const dryRun = useDryRun();
+
+ useEffect(() => {
+ dryRun.execute({
+ templateContent: '',
+ values: {},
+ files: [],
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return null;
+}
+
+describe('DryRunResultsView', () => {
+ it('renders a result', async () => {
+ await renderInTestApp(
+ ({
+ directoryContents: [
+ {
+ path: 'foo.txt',
+ base64Content: btoa('Foo Content'),
+ executable: false,
+ },
+ ],
+ log: [{ body: { message: 'Foo Message', stepId: 'foo' } }],
+ output: {
+ links: [{ title: 'Foo Link', url: 'http://example.com' }],
+ },
+ steps: [{ id: 'foo', action: 'foo', name: 'Foo' }],
+ }),
+ },
+ ],
+ ]}
+ >
+
+
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:kind/:namespace/:name': entityRouteRef,
+ },
+ },
+ );
+
+ expect(screen.getByText('foo.txt')).toBeInTheDocument();
+ expect(screen.getByText('Foo Content')).toBeInTheDocument();
+
+ expect(screen.queryByText('Foo Message')).not.toBeInTheDocument();
+ expect(screen.queryByText('Foo Link')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Log'));
+ expect(screen.getByText('Foo Message')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Output'));
+ expect(screen.getByText('Foo Link')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx
new file mode 100644
index 0000000000..9c56039808
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx
@@ -0,0 +1,195 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { LogViewer } from '@backstage/core-components';
+import { StreamLanguage } from '@codemirror/language';
+import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
+import Box from '@material-ui/core/Box';
+import Divider from '@material-ui/core/Divider';
+import { makeStyles } from '@material-ui/core/styles';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import CodeMirror from '@uiw/react-codemirror';
+import React, { useEffect, useMemo, useState } from 'react';
+import { TaskStatusStepper } from '../../TaskPage/TaskPage';
+import { TaskPageLinks } from '../../TaskPage/TaskPageLinks';
+import { useDryRun } from '../DryRunContext';
+import { FileBrowser } from '../../FileBrowser';
+import { DryRunResultsSplitView } from './DryRunResultsSplitView';
+
+const useStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ },
+ contentWrapper: {
+ flex: 1,
+ position: 'relative',
+ },
+ content: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+
+ display: 'flex',
+ '& > *': {
+ flex: 1,
+ },
+ },
+ codeMirror: {
+ height: '100%',
+ overflowY: 'auto',
+ },
+});
+
+function FilesContent() {
+ const classes = useStyles();
+ const { selectedResult } = useDryRun();
+ const [selectedPath, setSelectedPath] = useState('');
+ const selectedFile = selectedResult?.directoryContents.find(
+ f => f.path === selectedPath,
+ );
+
+ useEffect(() => {
+ if (selectedResult) {
+ const [firstFile] = selectedResult.directoryContents;
+ if (firstFile) {
+ setSelectedPath(firstFile.path);
+ } else {
+ setSelectedPath('');
+ }
+ }
+ return undefined;
+ }, [selectedResult]);
+
+ if (!selectedResult) {
+ return null;
+ }
+ return (
+
+ file.path)}
+ />
+
+
+ );
+}
+function LogContent() {
+ const { selectedResult } = useDryRun();
+ const [currentStepId, setUserSelectedStepId] = useState();
+
+ const steps = useMemo(() => {
+ if (!selectedResult) {
+ return [];
+ }
+ return (
+ selectedResult.steps.map(step => {
+ const stepLog = selectedResult.log.filter(
+ l => l.body.stepId === step.id,
+ );
+ return {
+ id: step.id,
+ name: step.name,
+ logString: stepLog.map(l => l.body.message).join('\n'),
+ status: stepLog[stepLog.length - 1]?.body.status ?? 'completed',
+ };
+ }) ?? []
+ );
+ }, [selectedResult]);
+
+ if (!selectedResult) {
+ return null;
+ }
+
+ const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0];
+
+ return (
+
+
+
+
+ );
+}
+
+function OutputContent() {
+ const classes = useStyles();
+ const { selectedResult } = useDryRun();
+
+ if (!selectedResult) {
+ return null;
+ }
+
+ return (
+
+
+ {selectedResult.output?.links?.length && (
+
+ )}
+
+
+
+ );
+}
+
+export function DryRunResultsView() {
+ const classes = useStyles();
+ const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>(
+ 'files',
+ );
+
+ return (
+
+
setSelectedTab(v)}>
+
+
+
+
+
+
+
+
+ {selectedTab === 'files' && }
+ {selectedTab === 'log' && }
+ {selectedTab === 'output' && }
+
+
+
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts
new file mode 100644
index 0000000000..af8ef5214f
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { DryRunResults } from './DryRunResults';
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx
new file mode 100644
index 0000000000..cc4dd8c9a1
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { makeStyles } from '@material-ui/core';
+import React, { useState } from 'react';
+import { FieldExtensionOptions } from '../../extensions';
+import { TemplateDirectoryAccess } from '../../lib/filesystem';
+import { DirectoryEditorProvider } from './DirectoryEditorContext';
+import { DryRunProvider } from './DryRunContext';
+import { DryRunResults } from './DryRunResults';
+import { TemplateEditorBrowser } from './TemplateEditorBrowser';
+import { TemplateEditorForm } from './TemplateEditorForm';
+import { TemplateEditorTextArea } from './TemplateEditorTextArea';
+
+const useStyles = makeStyles({
+ // Reset and fix sizing to make sure scrolling behaves correctly
+ root: {
+ gridArea: 'pageContent',
+
+ display: 'grid',
+ gridTemplateAreas: `
+ "browser editor preview"
+ "results results results"
+ `,
+ gridTemplateColumns: '1fr 3fr 2fr',
+ gridTemplateRows: '1fr auto',
+ },
+ browser: {
+ gridArea: 'browser',
+ overflow: 'auto',
+ },
+ editor: {
+ gridArea: 'editor',
+ overflow: 'auto',
+ },
+ preview: {
+ gridArea: 'preview',
+ overflow: 'auto',
+ },
+ results: {
+ gridArea: 'results',
+ },
+});
+
+export const TemplateEditor = (props: {
+ directory: TemplateDirectoryAccess;
+ fieldExtensions?: FieldExtensionOptions[];
+ onClose?: () => void;
+}) => {
+ const classes = useStyles();
+
+ const [errorText, setErrorText] = useState();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx
new file mode 100644
index 0000000000..cec311a5cd
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess';
+import { DirectoryEditorProvider } from './DirectoryEditorContext';
+import { TemplateEditorBrowser } from './TemplateEditorBrowser';
+
+Blob.prototype.text = async function text() {
+ return new Promise(resolve => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsText(this);
+ });
+};
+
+describe('TemplateEditorBrowser', () => {
+ it('should render files and expand dirs without exploding', async () => {
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await expect(screen.findByText('foo.txt')).resolves.toBeInTheDocument();
+ expect(screen.getByText('dir')).toBeInTheDocument();
+ expect(screen.queryByText('bar.txt')).not.toBeInTheDocument();
+ expect(screen.queryByText('baz.txt')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('dir'));
+ expect(screen.getByText('bar.txt')).toBeInTheDocument();
+ expect(screen.getByText('baz.txt')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx
new file mode 100644
index 0000000000..177ad11c2b
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Divider, IconButton, makeStyles, Tooltip } from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import RefreshIcon from '@material-ui/icons/Refresh';
+import SaveIcon from '@material-ui/icons/Save';
+import React from 'react';
+import { FileBrowser } from '../FileBrowser';
+import { useDirectoryEditor } from './DirectoryEditorContext';
+
+const useStyles = makeStyles(theme => ({
+ button: {
+ padding: theme.spacing(1),
+ },
+ buttons: {
+ display: 'flex',
+ flexFlow: 'row nowrap',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ },
+ buttonsGap: {
+ flex: '1 1 auto',
+ },
+ buttonsDivider: {
+ marginBottom: theme.spacing(1),
+ },
+}));
+
+/** The local file browser for the template editor */
+export function TemplateEditorBrowser(props: { onClose?: () => void }) {
+ const classes = useStyles();
+ const directoryEditor = useDirectoryEditor();
+ const changedFiles = directoryEditor.files.filter(file => file.dirty);
+
+ const handleClose = () => {
+ if (!props.onClose) {
+ return;
+ }
+ if (changedFiles.length > 0) {
+ // eslint-disable-next-line no-alert
+ const accepted = window.confirm(
+ 'Are you sure? Unsaved changes will be lost',
+ );
+ if (!accepted) {
+ return;
+ }
+ }
+ props.onClose();
+ };
+
+ return (
+ <>
+
+
+ !file.dirty)}
+ onClick={() => directoryEditor.save()}
+ >
+
+
+
+
+ directoryEditor.reload()}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ file.path)}
+ />
+ >
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx
new file mode 100644
index 0000000000..11b4da246d
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx
@@ -0,0 +1,240 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useApiHolder } from '@backstage/core-plugin-api';
+import { JsonObject, JsonValue } from '@backstage/types';
+import { makeStyles } from '@material-ui/core/styles';
+import React, { Component, ReactNode, useMemo, useState } from 'react';
+import useDebounce from 'react-use/lib/useDebounce';
+import yaml from 'yaml';
+import { FieldExtensionOptions } from '../../extensions';
+import { TemplateParameterSchema } from '../../types';
+import { MultistepJsonForm } from '../MultistepJsonForm';
+import { createValidator } from '../TemplatePage';
+import { useDirectoryEditor } from './DirectoryEditorContext';
+import { useDryRun } from './DryRunContext';
+
+const useStyles = makeStyles({
+ containerWrapper: {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ },
+ container: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ overflow: 'auto',
+ },
+});
+
+interface ErrorBoundaryProps {
+ invalidator: unknown;
+ setErrorText(errorText: string | undefined): void;
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ shouldRender: boolean;
+}
+
+class ErrorBoundary extends Component {
+ state = {
+ shouldRender: true,
+ };
+
+ componentDidUpdate(prevProps: { invalidator: unknown }) {
+ if (prevProps.invalidator !== this.props.invalidator) {
+ this.setState({ shouldRender: true });
+ }
+ }
+
+ componentDidCatch(error: Error) {
+ this.props.setErrorText(error.message);
+ this.setState({ shouldRender: false });
+ }
+
+ render() {
+ return this.state.shouldRender ? this.props.children : null;
+ }
+}
+
+interface TemplateEditorFormProps {
+ content?: string;
+ /** Setting this to true will cause the content to be parsed as if it is the template entity spec */
+ contentIsSpec?: boolean;
+ data: JsonObject;
+ onUpdate: (data: JsonObject) => void;
+ setErrorText: (errorText?: string) => void;
+
+ onDryRun?: (data: JsonObject) => Promise;
+ fieldExtensions?: FieldExtensionOptions[];
+}
+
+function isJsonObject(value: JsonValue | undefined): value is JsonObject {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** Shows the a template form that is parsed from the provided content */
+export function TemplateEditorForm(props: TemplateEditorFormProps) {
+ const {
+ content,
+ contentIsSpec,
+ data,
+ onUpdate,
+ onDryRun,
+ setErrorText,
+ fieldExtensions = [],
+ } = props;
+ const classes = useStyles();
+ const apiHolder = useApiHolder();
+
+ const [steps, setSteps] = useState();
+
+ const fields = useMemo(() => {
+ return Object.fromEntries(
+ fieldExtensions.map(({ name, component }) => [name, component]),
+ );
+ }, [fieldExtensions]);
+
+ useDebounce(
+ () => {
+ try {
+ if (!content) {
+ setSteps(undefined);
+ return;
+ }
+ const parsed: JsonValue = yaml.parse(content);
+
+ if (!isJsonObject(parsed)) {
+ setSteps(undefined);
+ return;
+ }
+
+ let rootObj = parsed;
+ if (!contentIsSpec) {
+ const isTemplate =
+ String(parsed.kind).toLocaleLowerCase('en-US') === 'template';
+ if (!isTemplate) {
+ setSteps(undefined);
+ return;
+ }
+
+ rootObj = isJsonObject(parsed.spec) ? parsed.spec : {};
+ }
+
+ const { parameters } = rootObj;
+ if (!Array.isArray(parameters)) {
+ setErrorText('Template parameters must be an array');
+ setSteps(undefined);
+ return;
+ }
+
+ const fieldValidators = Object.fromEntries(
+ fieldExtensions.map(({ name, validation }) => [name, validation]),
+ );
+
+ setErrorText();
+ setSteps(
+ parameters.flatMap(param =>
+ isJsonObject(param)
+ ? [
+ {
+ title: String(param.title),
+ schema: param,
+ validate: createValidator(param, fieldValidators, {
+ apiHolder,
+ }),
+ },
+ ]
+ : [],
+ ),
+ );
+ } catch (e) {
+ setErrorText(e.message);
+ }
+ },
+ 250,
+ [contentIsSpec, content, apiHolder],
+ );
+
+ if (!steps) {
+ return null;
+ }
+
+ return (
+
+
+
+ onUpdate(e.formData)}
+ onReset={() => onUpdate({})}
+ finishButtonLabel={onDryRun && 'Try It'}
+ onFinish={onDryRun && (() => onDryRun(data))}
+ />
+
+
+
+ );
+}
+
+/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
+export function TemplateEditorFormDirectoryEditorDryRun(
+ props: Pick,
+) {
+ const { setErrorText, fieldExtensions = [] } = props;
+ const dryRun = useDryRun();
+
+ const directoryEditor = useDirectoryEditor();
+ const { selectedFile } = directoryEditor;
+
+ const [data, setData] = useState({});
+
+ const handleDryRun = async () => {
+ if (!selectedFile) {
+ return;
+ }
+
+ await dryRun.execute({
+ templateContent: selectedFile.content,
+ values: data,
+ files: directoryEditor.files,
+ });
+ };
+
+ const content =
+ selectedFile && selectedFile.path.match(/\.ya?ml$/)
+ ? selectedFile.content
+ : undefined;
+
+ return (
+
+ );
+}
+
+TemplateEditorForm.DirectoryEditorDryRun =
+ TemplateEditorFormDirectoryEditorDryRun;
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx
new file mode 100644
index 0000000000..dc411ee965
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import Card from '@material-ui/core/Card';
+import CardActionArea from '@material-ui/core/CardActionArea';
+import CardContent from '@material-ui/core/CardContent';
+import Tooltip from '@material-ui/core/Tooltip';
+import Typography from '@material-ui/core/Typography';
+import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined';
+import { makeStyles } from '@material-ui/core/styles';
+import { WebFileSystemAccess } from '../../lib/filesystem';
+
+const useStyles = makeStyles(theme => ({
+ introText: {
+ textAlign: 'center',
+ marginTop: theme.spacing(2),
+ },
+ card: {
+ position: 'relative',
+ maxWidth: 340,
+ marginTop: theme.spacing(4),
+ margin: theme.spacing(0, 2),
+ },
+ infoIcon: {
+ position: 'absolute',
+ top: theme.spacing(1),
+ right: theme.spacing(1),
+ },
+}));
+
+interface EditorIntroProps {
+ style?: JSX.IntrinsicElements['div']['style'];
+ onSelect?: (option: 'local' | 'form') => void;
+}
+
+export function TemplateEditorIntro(props: EditorIntroProps) {
+ const classes = useStyles();
+ const supportsLoad = WebFileSystemAccess.isSupported();
+
+ const cardLoadLocal = (
+
+ props.onSelect?.('local')}
+ >
+
+
+ Load Template Directory
+
+
+ Load a local template directory, allowing you to both edit and try
+ executing your own template.
+
+
+
+ {!supportsLoad && (
+
+
+
+
+
+ )}
+
+ );
+
+ const cardFormEditor = (
+
+ props.onSelect?.('form')}>
+
+
+ Edit Template Form
+
+
+ Preview and edit a template form, either using a sample template or
+ by loading a template from the catalog.
+
+
+
+
+ );
+
+ return (
+
+
+ Get started by choosing one of the options below
+
+
+ {supportsLoad && cardLoadLocal}
+ {cardFormEditor}
+ {!supportsLoad && cardLoadLocal}
+
+
+ );
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx
new file mode 100644
index 0000000000..bf9cd8c464
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { TemplateEditorPage } from './TemplateEditorPage';
+
+describe('TemplateEditorPage', () => {
+ it('renders without exploding', async () => {
+ await renderInTestApp( );
+
+ expect(screen.getByText('Load Template Directory')).toBeInTheDocument();
+ expect(screen.getByText('Edit Template Form')).toBeInTheDocument();
+ });
+
+ it('template directory loading should not be supported in Jest', async () => {
+ await renderInTestApp( );
+
+ expect(
+ screen.getByRole('button', { name: /Load Template Directory/ }),
+ ).toBeDisabled();
+ });
+
+ it('should be able to continue to form preview', async () => {
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Edit Template Form'));
+
+ expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx
index 71c64a9fa4..fce2881190 100644
--- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx
@@ -13,258 +13,76 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React, { useCallback, useState } from 'react';
-import useAsync from 'react-use/lib/useAsync';
-import useDebounce from 'react-use/lib/useDebounce';
-import { Entity } from '@backstage/catalog-model';
-import { Content, Header, InfoCard, Page } from '@backstage/core-components';
-import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api';
+import React, { useState } from 'react';
+import { Content, Header, Page } from '@backstage/core-components';
import {
- catalogApiRef,
- humanizeEntityRef,
-} from '@backstage/plugin-catalog-react';
-import { JsonObject } from '@backstage/types';
-import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
-import { showPanel } from '@codemirror/view';
-import { StreamLanguage } from '@codemirror/language';
-
-import {
- FormControl,
- Grid,
- InputLabel,
- LinearProgress,
- makeStyles,
- MenuItem,
- Select,
-} from '@material-ui/core';
-import { IChangeEvent } from '@rjsf/core';
-import CodeMirror from '@uiw/react-codemirror';
-import yaml from 'yaml';
+ TemplateDirectoryAccess,
+ WebFileSystemAccess,
+} from '../../lib/filesystem';
+import { TemplateEditorIntro } from './TemplateEditorIntro';
+import { TemplateEditor } from './TemplateEditor';
+import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import { FieldExtensionOptions } from '../../extensions';
-import { TemplateParameterSchema } from '../../types';
-import { MultistepJsonForm } from '../MultistepJsonForm';
-import { createValidator } from '../TemplatePage';
-const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
-parameters:
- - title: Fill in some steps
- required:
- - name
- properties:
- name:
- title: Name
- type: string
- description: Unique name of the component
- owner:
- title: Owner
- type: string
- description: Owner of the component
- ui:field: OwnerPicker
- ui:options:
- allowedKinds:
- - Group
- - title: Choose a location
- required:
- - repoUrl
- properties:
- repoUrl:
- title: Repository Location
- type: string
- ui:field: RepoUrlPicker
- ui:options:
- allowedHosts:
- - github.com
-`;
+type Selection =
+ | {
+ type: 'local';
+ directory: TemplateDirectoryAccess;
+ }
+ | {
+ type: 'form';
+ };
-type TemplateOption = {
- label: string;
- value: Entity;
-};
-
-const useStyles = makeStyles({
- templateSelect: {
- marginBottom: '10px',
- },
- grid: {
- height: '100%',
- },
- codeMirror: {
- height: '95%',
- },
-});
-
-export const TemplateEditorPage = ({
- defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
- customFieldExtensions = [],
-}: {
+interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions[];
-}) => {
- const classes = useStyles();
- const alertApi = useApi(alertApiRef);
- const catalogApi = useApi(catalogApiRef);
- const apiHolder = useApiHolder();
- const [selectedTemplate, setSelectedTemplate] = useState('');
- const [schema, setSchema] = useState({
- title: '',
- steps: [],
- });
- const [templateOptions, setTemplateOptions] = useState([]);
- const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
- const [formState, setFormState] = useState({});
+}
- const { loading } = useAsync(
- () =>
- catalogApi
- .getEntities({
- filter: { kind: 'template' },
- fields: [
- 'kind',
- 'metadata.namespace',
- 'metadata.name',
- 'metadata.title',
- 'spec.parameters',
- ],
- })
- .then(({ items }) =>
- setTemplateOptions(
- items.map(template => ({
- label:
- template.metadata.title ??
- humanizeEntityRef(template, { defaultKind: 'template' }),
- value: template,
- })),
- ),
- )
- .catch(e =>
- alertApi.post({
- message: `Error loading exisiting templates: ${e.message}`,
- severity: 'error',
- }),
- ),
- [catalogApi],
- );
+export function TemplateEditorPage(props: TemplateEditorPageProps) {
+ const [selection, setSelection] = useState();
- const errorPanel = document.createElement('div');
- errorPanel.style.color = 'red';
-
- useDebounce(
- () => {
- try {
- const parsedTemplate = yaml.parse(templateYaml);
-
- setSchema({
- title: 'Preview',
- steps: parsedTemplate.parameters.map((param: JsonObject) => ({
- title: param.title,
- schema: param,
- })),
- });
- setFormState({});
- } catch (e) {
- errorPanel.textContent = e.message;
- }
- },
- 250,
- [setFormState, setSchema, templateYaml],
- );
-
- const handleSelectChange = useCallback(
- selected => {
- setSelectedTemplate(selected);
- setTemplateYaml(yaml.stringify(selected.spec));
- },
- [setTemplateYaml],
- );
-
- const handleFormReset = () => setFormState({});
- const handleFormChange = useCallback(
- (e: IChangeEvent) => setFormState(e.formData),
- [setFormState],
- );
-
- const handleCodeChange = useCallback(
- (code: string) => {
- setTemplateYaml(code);
- },
- [setTemplateYaml],
- );
-
- const customFieldComponents = Object.fromEntries(
- customFieldExtensions.map(({ name, component }) => [name, component]),
- );
-
- const customFieldValidators = Object.fromEntries(
- customFieldExtensions.map(({ name, validation }) => [name, validation]),
- );
+ let content: JSX.Element | null = null;
+ if (selection?.type === 'local') {
+ content = (
+ setSelection(undefined)}
+ />
+ );
+ } else if (selection?.type === 'form') {
+ content = (
+ setSelection(undefined)}
+ />
+ );
+ } else {
+ content = (
+
+ {
+ if (option === 'local') {
+ WebFileSystemAccess.requestDirectoryAccess()
+ .then(directory => setSelection({ type: 'local', directory }))
+ .catch(() => {});
+ } else if (option === 'form') {
+ setSelection({ type: 'form' });
+ }
+ }}
+ />
+
+ );
+ }
return (
-
- {loading && }
-
-
-
-
- Load Existing Template
-
- handleSelectChange(e.target.value)}
- >
- {templateOptions.map((option, idx) => (
-
- {option.label}
-
- ))}
-
-
- ({ dom: errorPanel, top: true })),
- ]}
- onChange={handleCodeChange}
- />
-
-
- {schema && (
-
- {
- return {
- ...step,
- validate: createValidator(
- step.schema,
- customFieldValidators,
- { apiHolder },
- ),
- };
- })}
- />
-
- )}
-
-
-
+ {content}
);
-};
+}
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx
new file mode 100644
index 0000000000..588b0cd4ba
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { StreamLanguage } from '@codemirror/language';
+import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
+import { showPanel } from '@codemirror/view';
+import { IconButton, makeStyles, Paper, Tooltip } from '@material-ui/core';
+import RefreshIcon from '@material-ui/icons/Refresh';
+import SaveIcon from '@material-ui/icons/Save';
+import { useKeyboardEvent } from '@react-hookz/web';
+import CodeMirror from '@uiw/react-codemirror';
+import React, { useMemo } from 'react';
+import { useDirectoryEditor } from './DirectoryEditorContext';
+
+const useStyles = makeStyles(theme => ({
+ container: {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ },
+ codeMirror: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ },
+ errorPanel: {
+ color: theme.palette.error.main,
+ lineHeight: 2,
+ margin: theme.spacing(0, 1),
+ },
+ floatingButtons: {
+ position: 'absolute',
+ top: theme.spacing(1),
+ right: theme.spacing(3),
+ },
+ floatingButton: {
+ padding: theme.spacing(1),
+ },
+}));
+
+/** A wrapper around CodeMirror with an error panel and extra actions available */
+export function TemplateEditorTextArea(props: {
+ content?: string;
+ onUpdate?: (content: string) => void;
+ errorText?: string;
+ onSave?: () => void;
+ onReload?: () => void;
+}) {
+ const { errorText } = props;
+ const classes = useStyles();
+
+ const panelExtension = useMemo(() => {
+ if (!errorText) {
+ return showPanel.of(null);
+ }
+
+ const dom = document.createElement('div');
+ dom.classList.add(classes.errorPanel);
+ dom.textContent = errorText;
+ return showPanel.of(() => ({ dom, bottom: true }));
+ }, [classes, errorText]);
+
+ useKeyboardEvent(
+ e => e.key === 's' && (e.ctrlKey || e.metaKey),
+ e => {
+ e.preventDefault();
+ if (props.onSave) {
+ props.onSave();
+ }
+ },
+ );
+
+ return (
+
+
+ {(props.onSave || props.onReload) && (
+
+
+ {props.onSave && (
+
+ props.onSave?.()}
+ >
+
+
+
+ )}
+ {props.onReload && (
+
+ props.onReload?.()}
+ >
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
+
+/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */
+export function TemplateEditorDirectoryEditorTextArea(props: {
+ errorText?: string;
+}) {
+ const directoryEditor = useDirectoryEditor();
+
+ const actions = directoryEditor.selectedFile?.dirty
+ ? {
+ onSave: () => directoryEditor.save(),
+ onReload: () => directoryEditor.reload(),
+ }
+ : {
+ onReload: () => directoryEditor.reload(),
+ };
+
+ return (
+ directoryEditor.selectedFile?.updateContent(content)}
+ {...actions}
+ />
+ );
+}
+
+TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea;
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx
new file mode 100644
index 0000000000..bfa7071d48
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Entity } from '@backstage/catalog-model';
+import { alertApiRef, useApi } from '@backstage/core-plugin-api';
+import {
+ catalogApiRef,
+ humanizeEntityRef,
+} from '@backstage/plugin-catalog-react';
+import {
+ FormControl,
+ IconButton,
+ InputLabel,
+ LinearProgress,
+ makeStyles,
+ MenuItem,
+ Select,
+} from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import React, { useCallback, useState } from 'react';
+import useAsync from 'react-use/lib/useAsync';
+import yaml from 'yaml';
+import { FieldExtensionOptions } from '../../extensions';
+import { TemplateEditorForm } from './TemplateEditorForm';
+import { TemplateEditorTextArea } from './TemplateEditorTextArea';
+
+const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
+parameters:
+ - title: Fill in some steps
+ required:
+ - name
+ properties:
+ name:
+ title: Name
+ type: string
+ description: Unique name of the component
+ owner:
+ title: Owner
+ type: string
+ description: Owner of the component
+ ui:field: OwnerPicker
+ ui:options:
+ allowedKinds:
+ - Group
+ - title: Choose a location
+ required:
+ - repoUrl
+ properties:
+ repoUrl:
+ title: Repository Location
+ type: string
+ ui:field: RepoUrlPicker
+ ui:options:
+ allowedHosts:
+ - github.com
+steps:
+ - id: fetch-base
+ name: Fetch Base
+ action: fetch:template
+ input:
+ url: ./template
+ values:
+ name: \${{parameters.name}}
+`;
+
+type TemplateOption = {
+ label: string;
+ value: Entity;
+};
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ gridArea: 'pageContent',
+ display: 'grid',
+ gridTemplateAreas: `
+ "controls controls"
+ "textArea preview"
+ `,
+ gridTemplateRows: 'auto 1fr',
+ gridTemplateColumns: '1fr 1fr',
+ },
+ controls: {
+ gridArea: 'controls',
+ display: 'flex',
+ flexFlow: 'row nowrap',
+ alignItems: 'center',
+ margin: theme.spacing(1),
+ },
+ textArea: {
+ gridArea: 'textArea',
+ },
+ preview: {
+ gridArea: 'preview',
+ },
+}));
+
+export const TemplateFormPreviewer = ({
+ defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
+ customFieldExtensions = [],
+ onClose,
+}: {
+ defaultPreviewTemplate?: string;
+ customFieldExtensions?: FieldExtensionOptions[];
+ onClose?: () => void;
+}) => {
+ const classes = useStyles();
+ const alertApi = useApi(alertApiRef);
+ const catalogApi = useApi(catalogApiRef);
+ const [selectedTemplate, setSelectedTemplate] = useState('');
+ const [errorText, setErrorText] = useState();
+ const [templateOptions, setTemplateOptions] = useState([]);
+ const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
+ const [formState, setFormState] = useState({});
+
+ const { loading } = useAsync(
+ () =>
+ catalogApi
+ .getEntities({
+ filter: { kind: 'template' },
+ fields: [
+ 'kind',
+ 'metadata.namespace',
+ 'metadata.name',
+ 'metadata.title',
+ 'spec.parameters',
+ 'spec.steps',
+ 'spec.output',
+ ],
+ })
+ .then(({ items }) =>
+ setTemplateOptions(
+ items.map(template => ({
+ label:
+ template.metadata.title ??
+ humanizeEntityRef(template, { defaultKind: 'template' }),
+ value: template,
+ })),
+ ),
+ )
+ .catch(e =>
+ alertApi.post({
+ message: `Error loading exisiting templates: ${e.message}`,
+ severity: 'error',
+ }),
+ ),
+ [catalogApi],
+ );
+
+ const handleSelectChange = useCallback(
+ selected => {
+ setSelectedTemplate(selected);
+ setTemplateYaml(yaml.stringify(selected.spec));
+ },
+ [setTemplateYaml],
+ );
+
+ return (
+ <>
+ {loading && }
+
+
+
+
+ Load Existing Template
+
+ handleSelectChange(e.target.value)}
+ >
+ {templateOptions.map((option, idx) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts
index 506ff08f24..7ec6bddb64 100644
--- a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts
+++ b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
export { TemplateEditorPage } from './TemplateEditorPage';
diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts
index 4f4c650f1c..d98fd96e55 100644
--- a/plugins/scaffolder/src/index.ts
+++ b/plugins/scaffolder/src/index.ts
@@ -25,6 +25,8 @@ export type {
ListActionsResponse,
LogEvent,
ScaffolderApi,
+ ScaffolderDryRunOptions,
+ ScaffolderDryRunResponse,
ScaffolderGetIntegrationsListOptions,
ScaffolderGetIntegrationsListResponse,
ScaffolderOutputLink,
diff --git a/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts
new file mode 100644
index 0000000000..d466543dbd
--- /dev/null
+++ b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TemplateDirectoryAccess, TemplateFileAccess } from './types';
+
+class MockFileAccess implements TemplateFileAccess {
+ constructor(readonly path: string, private content: string) {}
+
+ async file(): Promise {
+ const blob = new Blob([this.content]);
+ return Object.assign(blob, {
+ name: this.path.split('/').pop()!,
+ lastModified: Date.now(),
+ webkitRelativePath: this.path,
+ });
+ }
+
+ async save(data: string | Blob | BufferSource): Promise {
+ this.content = await new Response(data).text();
+ }
+}
+
+class MockDirectoryAccess implements TemplateDirectoryAccess {
+ private readonly files = new Array();
+
+ constructor(inputFiles: Record) {
+ this.files = Object.entries(inputFiles).map(
+ ([path, content]) => new MockFileAccess(path, content),
+ );
+ }
+
+ async listFiles(): Promise {
+ return this.files;
+ }
+}
+
+/** @internal */
+export class MockFileSystemAccess {
+ private constructor() {}
+
+ static createMockDirectory(files: Record) {
+ return new MockDirectoryAccess(files);
+ }
+}
diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts
new file mode 100644
index 0000000000..36e7b11de2
--- /dev/null
+++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TemplateDirectoryAccess, TemplateFileAccess } from './types';
+
+type WritableFileHandle = FileSystemFileHandle & {
+ createWritable(): Promise<{
+ write(data: string | Blob | BufferSource): Promise;
+ close(): Promise;
+ }>;
+};
+
+// A nicer type than the one from the TS lib
+interface IterableDirectoryHandle extends FileSystemDirectoryHandle {
+ values(): AsyncIterable<
+ | ({ kind: 'file' } & WritableFileHandle)
+ | ({ kind: 'directory' } & IterableDirectoryHandle)
+ >;
+}
+
+const showDirectoryPicker = (window as any).showDirectoryPicker as
+ | (() => Promise)
+ | undefined;
+
+class WebFileAccess implements TemplateFileAccess {
+ constructor(
+ readonly path: string,
+ private readonly handle: WritableFileHandle,
+ ) {}
+
+ file(): Promise {
+ return this.handle.getFile();
+ }
+
+ async save(data: string | Blob | BufferSource): Promise {
+ const writable = await this.handle.createWritable();
+ await writable.write(data);
+ await writable.close();
+ }
+}
+
+class WebDirectoryAccess implements TemplateDirectoryAccess {
+ constructor(private readonly handle: IterableDirectoryHandle) {}
+
+ async listFiles(): Promise {
+ const content = [];
+ for await (const entry of this.listDirectoryContents(this.handle)) {
+ content.push(entry);
+ }
+ return content;
+ }
+
+ private async *listDirectoryContents(
+ dirHandle: IterableDirectoryHandle,
+ basePath: string[] = [],
+ ): AsyncIterable {
+ for await (const handle of dirHandle.values()) {
+ if (handle.kind === 'file') {
+ yield new WebFileAccess([...basePath, handle.name].join('/'), handle);
+ } else if (handle.kind === 'directory') {
+ yield* this.listDirectoryContents(handle, [...basePath, handle.name]);
+ }
+ }
+ }
+}
+
+/** @internal */
+export class WebFileSystemAccess {
+ static isSupported(): boolean {
+ return Boolean(showDirectoryPicker);
+ }
+
+ static async requestDirectoryAccess(): Promise {
+ if (!showDirectoryPicker) {
+ throw new Error('File system access is not supported');
+ }
+ const handle = await showDirectoryPicker();
+ return new WebDirectoryAccess(handle);
+ }
+
+ private constructor() {}
+}
diff --git a/plugins/scaffolder/src/lib/filesystem/helpers.ts b/plugins/scaffolder/src/lib/filesystem/helpers.ts
new file mode 100644
index 0000000000..530cf87277
--- /dev/null
+++ b/plugins/scaffolder/src/lib/filesystem/helpers.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export async function blobToBase64(file: Blob) {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ return new Promise((resolve, reject) => {
+ reader.onload = () => {
+ const uri = reader.result as string;
+ resolve(uri.slice(uri.indexOf(',') + 1));
+ };
+ reader.onerror = reject;
+ });
+}
diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts
new file mode 100644
index 0000000000..1f485d0743
--- /dev/null
+++ b/plugins/scaffolder/src/lib/filesystem/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type { TemplateFileAccess, TemplateDirectoryAccess } from './types';
+export { blobToBase64 } from './helpers';
+export { WebFileSystemAccess } from './WebFileSystemAccess';
diff --git a/plugins/scaffolder/src/lib/filesystem/types.ts b/plugins/scaffolder/src/lib/filesystem/types.ts
new file mode 100644
index 0000000000..11de159b2a
--- /dev/null
+++ b/plugins/scaffolder/src/lib/filesystem/types.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface TemplateFileAccess {
+ path: string;
+ file(): Promise;
+ save(data: string | BufferSource | Blob): Promise;
+}
+
+export interface TemplateDirectoryAccess {
+ listFiles(): Promise>;
+}
diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts
index 10252413a7..5499f969dc 100644
--- a/plugins/scaffolder/src/setupTests.ts
+++ b/plugins/scaffolder/src/setupTests.ts
@@ -19,3 +19,16 @@ import 'cross-fetch/polyfill';
const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill');
global.EventSource = EventSourcePolyfill;
+
+// Patch jsdom to add feature used by CodeMirror
+document.createRange = () => {
+ const range = new Range();
+
+ range.getClientRects = () => ({
+ item: () => null,
+ length: 0,
+ [Symbol.iterator]: jest.fn(),
+ });
+
+ return range;
+};
diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts
index 608d586b09..df7afe065f 100644
--- a/plugins/scaffolder/src/types.ts
+++ b/plugins/scaffolder/src/types.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { TaskSpec } from '@backstage/plugin-scaffolder-common';
+import { TaskSpec, TaskStep } from '@backstage/plugin-scaffolder-common';
import { JsonObject, JsonValue, Observable } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
@@ -156,6 +156,27 @@ export interface ScaffolderStreamLogsOptions {
taskId: string;
after?: number;
}
+
+/** @public */
+export interface ScaffolderDryRunOptions {
+ template: JsonValue;
+ values: JsonObject;
+ secrets?: Record;
+ directoryContents: { path: string; base64Content: string }[];
+}
+
+/** @public */
+export interface ScaffolderDryRunResponse {
+ directoryContents: Array<{
+ path: string;
+ base64Content: string;
+ executable: boolean;
+ }>;
+ log: Array>;
+ steps: TaskStep[];
+ output: ScaffolderTaskOutput;
+}
+
/**
* An API to interact with the scaffolder backend.
*
@@ -194,4 +215,6 @@ export interface ScaffolderApi {
listActions(): Promise;
streamLogs(options: ScaffolderStreamLogsOptions): Observable;
+
+ dryRun?(options: ScaffolderDryRunOptions): Promise;
}
diff --git a/plugins/search/package.json b/plugins/search/package.json
index 5724c2af0c..57c8cacfa9 100644
--- a/plugins/search/package.json
+++ b/plugins/search/package.json
@@ -68,7 +68,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json
index 40566e1f2c..fecf38c165 100644
--- a/plugins/sentry/package.json
+++ b/plugins/sentry/package.json
@@ -65,7 +65,7 @@
"@types/node": "^16.11.26",
"@types/react": "^16.13.1 || ^17.0.0",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist",
diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json
index 987be389ef..6b007c4261 100644
--- a/plugins/shortcuts/package.json
+++ b/plugins/shortcuts/package.json
@@ -52,7 +52,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json
index 8f872c4e1c..b6640c2551 100644
--- a/plugins/sonarqube/package.json
+++ b/plugins/sonarqube/package.json
@@ -62,7 +62,7 @@
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist",
diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json
index 2bcd01dc9c..79d6147407 100644
--- a/plugins/splunk-on-call/package.json
+++ b/plugins/splunk-on-call/package.json
@@ -62,7 +62,7 @@
"@types/luxon": "^2.0.4",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"configSchema": "config.d.ts",
"files": [
diff --git a/plugins/stack-overflow/package.json b/plugins/stack-overflow/package.json
index 2632d113af..ce500e7886 100644
--- a/plugins/stack-overflow/package.json
+++ b/plugins/stack-overflow/package.json
@@ -50,7 +50,7 @@
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist",
diff --git a/plugins/tech-insights/package.json b/plugins/tech-insights/package.json
index 461151659c..da889a6a79 100644
--- a/plugins/tech-insights/package.json
+++ b/plugins/tech-insights/package.json
@@ -57,7 +57,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json
index 99975b5542..050f9a9877 100644
--- a/plugins/tech-radar/package.json
+++ b/plugins/tech-radar/package.json
@@ -62,7 +62,7 @@
"@types/node": "^16.11.26",
"@types/react": "^16.13.1 || ^17.0.0",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/plugins/techdocs-addons-test-utils/package.json b/plugins/techdocs-addons-test-utils/package.json
index 81e05d18c2..26449881a0 100644
--- a/plugins/techdocs-addons-test-utils/package.json
+++ b/plugins/techdocs-addons-test-utils/package.json
@@ -63,7 +63,7 @@
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
- "msw": "^0.35.0",
+ "msw": "^0.42.0",
"cross-fetch": "^3.1.5"
},
"files": [
diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json
index 9bca7b7a81..d3563f5165 100644
--- a/plugins/techdocs-backend/package.json
+++ b/plugins/techdocs-backend/package.json
@@ -60,7 +60,7 @@
"@backstage/cli": "^0.17.2-next.0",
"@backstage/plugin-search-backend-node": "0.6.2-next.0",
"@types/dockerode": "^3.3.0",
- "msw": "^0.35.0",
+ "msw": "^0.42.0",
"supertest": "^6.1.3"
},
"files": [
diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts
index 684d5729a2..3f1a857c4d 100644
--- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts
+++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts
@@ -74,144 +74,145 @@ const expectedEntities: Entity[] = [
},
];
-describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
- let mockDiscoveryApi: jest.Mocked;
- let mockTokenManager: jest.Mocked;
- let collator: DefaultTechDocsCollator;
-
+describe('TechDocs Collator', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
- beforeEach(() => {
- mockDiscoveryApi = {
- getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
- getExternalBaseUrl: jest.fn(),
- };
- mockTokenManager = {
- getToken: jest.fn().mockResolvedValue({ token: '' }),
- authenticate: jest.fn(),
- };
- const mockConfig = new ConfigReader({
- techdocs: {
- legacyUseCaseSensitiveTripletPaths: true,
- },
- });
- collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
- discovery: mockDiscoveryApi,
- tokenManager: mockTokenManager,
- logger,
- legacyPathCasing: true,
- });
- worker.use(
- rest.get(
- 'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
- (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
- ),
- rest.get('http://test-backend/entities', (_, res, ctx) =>
- res(ctx.status(200), ctx.json(expectedEntities)),
- ),
- );
- });
+ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
+ let mockDiscoveryApi: jest.Mocked;
+ let mockTokenManager: jest.Mocked;
+ let collator: DefaultTechDocsCollator;
- it('fetches from the configured catalog and tech docs services', async () => {
- const documents = await collator.execute();
- expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
- expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
- expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
- });
-
- it('should create documents for each tech docs search index', async () => {
- const documents = await collator.execute();
- const entity = expectedEntities[0];
- documents.forEach((document, idx) => {
- expect(document).toMatchObject({
- title: mockSearchDocIndex.docs[idx].title,
- location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
- text: mockSearchDocIndex.docs[idx].text,
- namespace: 'default',
- entityTitle: entity!.metadata.title,
- componentType: entity!.spec!.type,
- lifecycle: entity!.spec!.lifecycle,
- owner: '',
- kind: entity.kind,
- name: entity.metadata.name,
- });
- });
- });
-});
-
-describe('DefaultTechDocsCollator', () => {
- let mockDiscoveryApi: jest.Mocked;
- let mockTokenManager: jest.Mocked;
- let collator: DefaultTechDocsCollator;
-
- const worker = setupServer();
- setupRequestMockHandlers(worker);
- beforeEach(() => {
- mockDiscoveryApi = {
- getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
- getExternalBaseUrl: jest.fn(),
- };
- mockTokenManager = {
- getToken: jest.fn().mockResolvedValue({ token: '' }),
- authenticate: jest.fn(),
- };
- collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), {
- discovery: mockDiscoveryApi,
- tokenManager: mockTokenManager,
- logger,
- });
-
- worker.use(
- rest.get(
- 'http://test-backend/static/docs/default/component/test-entity-with-docs/search/search_index.json',
- (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
- ),
- rest.get('http://test-backend/entities', (_, res, ctx) =>
- res(ctx.status(200), ctx.json(expectedEntities)),
- ),
- );
- });
-
- it('should create documents for each tech docs search index', async () => {
- const documents = await collator.execute();
- const entity = expectedEntities[0];
- documents.forEach((document, idx) => {
- expect(document).toMatchObject({
- title: mockSearchDocIndex.docs[idx].title,
- location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
- text: mockSearchDocIndex.docs[idx].text,
- namespace: 'default',
- entityTitle: entity!.metadata.title,
- componentType: entity!.spec!.type,
- lifecycle: entity!.spec!.lifecycle,
- owner: '',
- kind: entity.kind.toLocaleLowerCase('en-US'),
- name: entity.metadata.name.toLocaleLowerCase('en-US'),
- authorization: {
- resourceRef: `component:default/${entity.metadata.name}`,
+ beforeEach(() => {
+ mockDiscoveryApi = {
+ getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
+ getExternalBaseUrl: jest.fn(),
+ };
+ mockTokenManager = {
+ getToken: jest.fn().mockResolvedValue({ token: '' }),
+ authenticate: jest.fn(),
+ };
+ const mockConfig = new ConfigReader({
+ techdocs: {
+ legacyUseCaseSensitiveTripletPaths: true,
},
});
+ collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
+ discovery: mockDiscoveryApi,
+ tokenManager: mockTokenManager,
+ logger,
+ legacyPathCasing: true,
+ });
+
+ worker.use(
+ rest.get(
+ 'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
+ (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
+ ),
+ rest.get('http://test-backend/entities', (_, res, ctx) =>
+ res(ctx.status(200), ctx.json(expectedEntities)),
+ ),
+ );
+ });
+
+ it('fetches from the configured catalog and tech docs services', async () => {
+ const documents = await collator.execute();
+ expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
+ expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
+ expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
+ });
+
+ it('should create documents for each tech docs search index', async () => {
+ const documents = await collator.execute();
+ const entity = expectedEntities[0];
+ documents.forEach((document, idx) => {
+ expect(document).toMatchObject({
+ title: mockSearchDocIndex.docs[idx].title,
+ location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
+ text: mockSearchDocIndex.docs[idx].text,
+ namespace: 'default',
+ entityTitle: entity!.metadata.title,
+ componentType: entity!.spec!.type,
+ lifecycle: entity!.spec!.lifecycle,
+ owner: '',
+ kind: entity.kind,
+ name: entity.metadata.name,
+ });
+ });
});
});
- it('maps a returned entity with a custom locationTemplate', async () => {
- const mockConfig = new ConfigReader({
- techdocs: {
- legacyUseCaseSensitiveTripletPaths: true,
- },
- });
- // Provide an alternate location template.
- collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
- discovery: mockDiscoveryApi,
- tokenManager: mockTokenManager,
- locationTemplate: '/software/:name',
- logger,
+ describe('DefaultTechDocsCollator', () => {
+ let mockDiscoveryApi: jest.Mocked;
+ let mockTokenManager: jest.Mocked;
+ let collator: DefaultTechDocsCollator;
+
+ beforeEach(() => {
+ mockDiscoveryApi = {
+ getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
+ getExternalBaseUrl: jest.fn(),
+ };
+ mockTokenManager = {
+ getToken: jest.fn().mockResolvedValue({ token: '' }),
+ authenticate: jest.fn(),
+ };
+ collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), {
+ discovery: mockDiscoveryApi,
+ tokenManager: mockTokenManager,
+ logger,
+ });
+
+ worker.use(
+ rest.get(
+ 'http://test-backend/static/docs/default/component/test-entity-with-docs/search/search_index.json',
+ (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
+ ),
+ rest.get('http://test-backend/entities', (_, res, ctx) =>
+ res(ctx.status(200), ctx.json(expectedEntities)),
+ ),
+ );
});
- const documents = await collator.execute();
- expect(documents[0]).toMatchObject({
- location: '/software/test-entity-with-docs',
+ it('should create documents for each tech docs search index', async () => {
+ const documents = await collator.execute();
+ const entity = expectedEntities[0];
+ documents.forEach((document, idx) => {
+ expect(document).toMatchObject({
+ title: mockSearchDocIndex.docs[idx].title,
+ location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
+ text: mockSearchDocIndex.docs[idx].text,
+ namespace: 'default',
+ entityTitle: entity!.metadata.title,
+ componentType: entity!.spec!.type,
+ lifecycle: entity!.spec!.lifecycle,
+ owner: '',
+ kind: entity.kind.toLocaleLowerCase('en-US'),
+ name: entity.metadata.name.toLocaleLowerCase('en-US'),
+ authorization: {
+ resourceRef: `component:default/${entity.metadata.name}`,
+ },
+ });
+ });
+ });
+
+ it('maps a returned entity with a custom locationTemplate', async () => {
+ const mockConfig = new ConfigReader({
+ techdocs: {
+ legacyUseCaseSensitiveTripletPaths: true,
+ },
+ });
+ // Provide an alternate location template.
+ collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
+ discovery: mockDiscoveryApi,
+ tokenManager: mockTokenManager,
+ locationTemplate: '/software/:name',
+ logger,
+ });
+
+ const documents = await collator.execute();
+ expect(documents[0]).toMatchObject({
+ location: '/software/test-entity-with-docs',
+ });
});
});
});
diff --git a/plugins/techdocs-module-addons-contrib/package.json b/plugins/techdocs-module-addons-contrib/package.json
index eb6f4c9c2d..28eb02c0ca 100644
--- a/plugins/techdocs-module-addons-contrib/package.json
+++ b/plugins/techdocs-module-addons-contrib/package.json
@@ -62,7 +62,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"@types/react": "^16.13.1 || ^17.0.0",
- "msw": "^0.35.0",
+ "msw": "^0.42.0",
"cross-fetch": "^3.1.5"
},
"files": [
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index 49dd1a61fb..368fcb538e 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -81,7 +81,7 @@
"@types/node": "^16.11.26",
"canvas": "^2.6.1",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist",
diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json
index c050bbf6ca..0120940fe0 100644
--- a/plugins/todo-backend/package.json
+++ b/plugins/todo-backend/package.json
@@ -45,7 +45,7 @@
"devDependencies": {
"@backstage/cli": "^0.17.2-next.0",
"@types/supertest": "^2.0.8",
- "msw": "^0.35.0",
+ "msw": "^0.42.0",
"supertest": "^6.1.3"
},
"files": [
diff --git a/plugins/todo/package.json b/plugins/todo/package.json
index 8df4c8b31b..f5a0c512b5 100644
--- a/plugins/todo/package.json
+++ b/plugins/todo/package.json
@@ -55,7 +55,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0",
+ "msw": "^0.42.0",
"react-router": "6.0.0-beta.0"
},
"files": [
diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json
index dae4e4b360..acc3c211ef 100644
--- a/plugins/user-settings/package.json
+++ b/plugins/user-settings/package.json
@@ -58,7 +58,7 @@
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/plugins/xcmetrics/package.json b/plugins/xcmetrics/package.json
index da57f71fef..f5fe1e0f2a 100644
--- a/plugins/xcmetrics/package.json
+++ b/plugins/xcmetrics/package.json
@@ -51,7 +51,7 @@
"@types/luxon": "^2.0.4",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
- "msw": "^0.35.0"
+ "msw": "^0.42.0"
},
"files": [
"dist"
diff --git a/yarn.lock b/yarn.lock
index 4ed8ee03bf..aeac084923 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4176,14 +4176,6 @@
resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd"
integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw==
-"@mswjs/cookies@^0.1.6", "@mswjs/cookies@^0.1.7":
- version "0.1.7"
- resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.7.tgz#d334081b2c51057a61c1dd7b76ca3cac02251651"
- integrity sha512-bDg1ReMBx+PYDB4Pk7y1Q07Zz1iKIEUWQpkEXiA2lEWg9gvOZ8UBmGXilCEUvyYoRFlmr/9iXTRR69TrgSwX/Q==
- dependencies:
- "@types/set-cookie-parser" "^2.4.0"
- set-cookie-parser "^2.4.6"
-
"@mswjs/cookies@^0.2.0":
version "0.2.0"
resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.0.tgz#7ef2b5d7e444498bb27cf57720e61f76a4ce9f23"
@@ -4192,22 +4184,10 @@
"@types/set-cookie-parser" "^2.4.0"
set-cookie-parser "^2.4.6"
-"@mswjs/interceptors@^0.12.6", "@mswjs/interceptors@^0.12.7":
- version "0.12.7"
- resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909"
- integrity sha512-eGjZ3JRAt0Fzi5FgXiV/P3bJGj0NqsN7vBS0J0FO2AQRQ0jCKQS4lEFm4wvlSgKQNfeuc/Vz6d81VtU3Gkx/zg==
- dependencies:
- "@open-draft/until" "^1.0.3"
- "@xmldom/xmldom" "^0.7.2"
- debug "^4.3.2"
- headers-utils "^3.0.2"
- outvariant "^1.2.0"
- strict-event-emitter "^0.2.0"
-
"@mswjs/interceptors@^0.15.1":
- version "0.15.1"
- resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.15.1.tgz#4a0009f56e51bc2cd3176f1507065c7d2f6c0d5e"
- integrity sha512-D5B+ZJNlfvBm6ZctAfRBdNJdCHYAe2Ix4My5qfbHV5WH+3lkt3mmsjiWJzEh5ZwGDauzY487TldI275If7DJVw==
+ version "0.15.3"
+ resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.15.3.tgz#bcd17b5d7558d4f598007a4bb383b42dc9264f8d"
+ integrity sha512-GJ1qzBq82EQ3bwhsvw5nScbrLzOSI5H/TyB2CGd1K7dDqX58DJDLJHexiN+S5Ucvl6/84FjRdIysz0RxE/L8MA==
dependencies:
"@open-draft/until" "^1.0.3"
"@xmldom/xmldom" "^0.7.5"
@@ -4216,6 +4196,18 @@
outvariant "^1.2.1"
strict-event-emitter "^0.2.0"
+"@mswjs/interceptors@^0.16.3":
+ version "0.16.4"
+ resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.16.4.tgz#fa41de240dd094e6ad19e3a6829651589551d159"
+ integrity sha512-McPKUFlZNS/wo+OAor15k0fv2skK+EdWl9CEcdxAqsN4vKajlxCxDU4B5W/pn1y0TJPSAOmxR6LYFe/8esePrg==
+ dependencies:
+ "@open-draft/until" "^1.0.3"
+ "@xmldom/xmldom" "^0.7.5"
+ debug "^4.3.3"
+ headers-polyfill "^3.0.4"
+ outvariant "^1.2.1"
+ strict-event-emitter "^0.2.4"
+
"@n1ru4l/graphql-live-query@^0.9.0":
version "0.9.0"
resolved "https://registry.npmjs.org/@n1ru4l/graphql-live-query/-/graphql-live-query-0.9.0.tgz#defaebdd31f625bee49e6745934f36312532b2bc"
@@ -5975,14 +5967,6 @@
resolved "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.1.tgz#f14740d1f585a0a8e3f46359b62fda8b0eaa31e7"
integrity sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==
-"@types/inquirer@^7.3.3":
- version "7.3.3"
- resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac"
- integrity sha512-HhxyLejTHMfohAuhRun4csWigAMjXTmRyiJTU1Y/I1xmggikFMkOUoMQRlFm+zQcPEGHSs3io/0FAmNZf8EymQ==
- dependencies:
- "@types/through" "*"
- rxjs "^6.4.0"
-
"@types/inquirer@^8.1.3":
version "8.2.1"
resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.1.tgz#28a139be3105a1175e205537e8ac10830e38dbf4"
@@ -6059,11 +6043,6 @@
resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
-"@types/js-levenshtein@^1.1.0":
- version "1.1.0"
- resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9"
- integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ==
-
"@types/js-levenshtein@^1.1.1":
version "1.1.1"
resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5"
@@ -7184,7 +7163,7 @@
"@webassemblyjs/ast" "1.11.1"
"@xtuc/long" "4.2.2"
-"@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.2", "@xmldom/xmldom@^0.7.5":
+"@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.5":
version "0.7.5"
resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==
@@ -9848,7 +9827,7 @@ cookie@0.4.1:
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
-cookie@0.4.2, cookie@^0.4.1, cookie@^0.4.2, cookie@~0.4.1:
+cookie@0.4.2, cookie@^0.4.2, cookie@~0.4.1:
version "0.4.2"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
@@ -13577,11 +13556,6 @@ graphql-ws@^5.4.1:
resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.5.5.tgz#f375486d3f196e2a2527b503644693ae3a8670a9"
integrity sha512-hvyIS71vs4Tu/yUYHPvGXsTgo0t3arU820+lT5VjZS2go0ewp2LqyCgxEN56CzOG7Iys52eRhHBiD1gGRdiQtw==
-graphql@^15.5.1:
- version "15.8.0"
- resolved "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38"
- integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==
-
graphql@^16.0.0, graphql@^16.3.0:
version "16.5.0"
resolved "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz#41b5c1182eaac7f3d47164fb247f61e4dfb69c85"
@@ -13808,11 +13782,6 @@ headers-polyfill@^3.0.4:
resolved "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.7.tgz#725c4f591e6748f46b036197eae102c92b959ff4"
integrity sha512-JoLCAdCEab58+2/yEmSnOlficyHFpIl0XJqwu3l+Unkm1gXpFUYsThz6Yha3D6tNhocWkCPfyW0YVIGWFqTi7w==
-headers-utils@^3.0.2:
- version "3.0.2"
- resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-3.0.2.tgz#dfc65feae4b0e34357308aefbcafa99c895e59ef"
- integrity sha512-xAxZkM1dRyGV2Ou5bzMxBPNLoRCjcX+ya7KSWybQD2KwLphxsapUVK6x/02o7f4VU6GPSXch9vNY2+gkU8tYWQ==
-
helmet@^5.0.2:
version "5.0.2"
resolved "https://registry.npmjs.org/helmet/-/helmet-5.0.2.tgz#3264ec6bab96c82deaf65e3403c369424cb2366c"
@@ -14392,7 +14361,7 @@ inquirer@^7.3.3:
strip-ansi "^6.0.0"
through "^2.3.6"
-inquirer@^8.0.0, inquirer@^8.1.1, inquirer@^8.2.0:
+inquirer@^8.0.0, inquirer@^8.2.0:
version "8.2.4"
resolved "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz#ddbfe86ca2f67649a67daa6f1051c128f684f0b4"
integrity sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==
@@ -18177,58 +18146,6 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
-msw@^0.35.0:
- version "0.35.0"
- resolved "https://registry.npmjs.org/msw/-/msw-0.35.0.tgz#18a4ceb6c822ef226a30421d434413bc45030d38"
- integrity sha512-V7A6PqaS31F1k//fPS0OnO7vllfaqBUFsMEu3IpYixyWpiUInfyglodnbXhhtDyytkQikpkPZv8TZi/CvZzv/w==
- dependencies:
- "@mswjs/cookies" "^0.1.6"
- "@mswjs/interceptors" "^0.12.6"
- "@open-draft/until" "^1.0.3"
- "@types/cookie" "^0.4.1"
- "@types/inquirer" "^7.3.3"
- "@types/js-levenshtein" "^1.1.0"
- chalk "^4.1.1"
- chokidar "^3.4.2"
- cookie "^0.4.1"
- graphql "^15.5.1"
- headers-utils "^3.0.2"
- inquirer "^8.1.1"
- is-node-process "^1.0.1"
- js-levenshtein "^1.1.6"
- node-fetch "^2.6.1"
- node-match-path "^0.6.3"
- statuses "^2.0.0"
- strict-event-emitter "^0.2.0"
- type-fest "^1.2.2"
- yargs "^17.0.1"
-
-msw@^0.36.3:
- version "0.36.8"
- resolved "https://registry.npmjs.org/msw/-/msw-0.36.8.tgz#33ff8bfb0299626a95f43d0e4c3dc2c73c17f1ba"
- integrity sha512-K7lOQoYqhGhTSChsmHMQbf/SDCsxh/m0uhN6Ipt206lGoe81fpTmaGD0KLh4jUxCONMOUnwCSj0jtX2CM4pEdw==
- dependencies:
- "@mswjs/cookies" "^0.1.7"
- "@mswjs/interceptors" "^0.12.7"
- "@open-draft/until" "^1.0.3"
- "@types/cookie" "^0.4.1"
- "@types/inquirer" "^8.1.3"
- "@types/js-levenshtein" "^1.1.0"
- chalk "4.1.1"
- chokidar "^3.4.2"
- cookie "^0.4.1"
- graphql "^15.5.1"
- headers-utils "^3.0.2"
- inquirer "^8.2.0"
- is-node-process "^1.0.1"
- js-levenshtein "^1.1.6"
- node-fetch "^2.6.7"
- path-to-regexp "^6.2.0"
- statuses "^2.0.0"
- strict-event-emitter "^0.2.0"
- type-fest "^1.2.2"
- yargs "^17.3.0"
-
msw@^0.39.2:
version "0.39.2"
resolved "https://registry.npmjs.org/msw/-/msw-0.39.2.tgz#832e9274db62c43cb79854d5a69dce031c700de8"
@@ -18254,6 +18171,32 @@ msw@^0.39.2:
type-fest "^1.2.2"
yargs "^17.3.1"
+msw@^0.42.0:
+ version "0.42.0"
+ resolved "https://registry.npmjs.org/msw/-/msw-0.42.0.tgz#2286aefad82808888914e2bc5e40666e82b2824b"
+ integrity sha512-vB9rzgiGHoQGfkKpp3QZHxobzfuuQOJk+0bff0wtbK8k3P3CaUSt8bCwvExours682AY4mUfTjIkCsxy0JoS3w==
+ dependencies:
+ "@mswjs/cookies" "^0.2.0"
+ "@mswjs/interceptors" "^0.16.3"
+ "@open-draft/until" "^1.0.3"
+ "@types/cookie" "^0.4.1"
+ "@types/js-levenshtein" "^1.1.1"
+ chalk "4.1.1"
+ chokidar "^3.4.2"
+ cookie "^0.4.2"
+ graphql "^16.3.0"
+ headers-polyfill "^3.0.4"
+ inquirer "^8.2.0"
+ is-node-process "^1.0.1"
+ js-levenshtein "^1.1.6"
+ node-fetch "^2.6.7"
+ outvariant "^1.3.0"
+ path-to-regexp "^6.2.0"
+ statuses "^2.0.0"
+ strict-event-emitter "^0.2.0"
+ type-fest "^1.2.2"
+ yargs "^17.3.1"
+
multicast-dns@^7.2.4:
version "7.2.4"
resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz#cf0b115c31e922aeb20b64e6556cbeb34cf0dd19"
@@ -18547,11 +18490,6 @@ node-libs-browser@^2.2.1:
util "^0.11.0"
vm-browserify "^1.0.1"
-node-match-path@^0.6.3:
- version "0.6.3"
- resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.6.3.tgz#55dd8443d547f066937a0752dce462ea7dc27551"
- integrity sha512-fB1reOHKLRZCJMAka28hIxCwQLxGmd7WewOCBDYKpyA1KXi68A7vaGgdZAPhY2E6SXoYt3KqYCCvXLJ+O0Fu/Q==
-
node-modules-regexp@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40"
@@ -19161,12 +19099,7 @@ outdent@^0.5.0:
resolved "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz#9e10982fdc41492bb473ad13840d22f9655be2ff"
integrity sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==
-outvariant@^1.2.0:
- version "1.2.1"
- resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.2.1.tgz#e630f6cdc1dbf398ed857e36f219de4a005ccd35"
- integrity sha512-bcILvFkvpMXh66+Ubax/inxbKRyWTUiiFIW2DWkiS79wakrLGn3Ydy+GvukadiyfZjaL6C7YhIem4EZSM282wA==
-
-outvariant@^1.2.1:
+outvariant@^1.2.1, outvariant@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.3.0.tgz#c39723b1d2cba729c930b74bf962317a81b9b1c9"
integrity sha512-yeWM9k6UPfG/nzxdaPlJkB2p08hCg4xP6Lx99F+vP8YF7xyZVfTmJjrrNalkmzudD4WFvNLVudQikqUmF8zhVQ==
@@ -22200,7 +22133,7 @@ rxjs@7.5.5, rxjs@^7.1.0, rxjs@^7.2.0, rxjs@^7.5.1, rxjs@^7.5.5:
dependencies:
tslib "^2.1.0"
-rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.3:
+rxjs@^6.3.3, rxjs@^6.6.0, rxjs@^6.6.3:
version "6.6.7"
resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
@@ -23256,6 +23189,13 @@ strict-event-emitter@^0.2.0:
dependencies:
events "^3.3.0"
+strict-event-emitter@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.4.tgz#365714f0c95f059db31064ca745d5b33e5b30f6e"
+ integrity sha512-xIqTLS5azUH1djSUsLH9DbP6UnM/nI18vu8d43JigCQEoVsnY+mrlE+qv6kYqs6/1OkMnMIiL6ffedQSZStuoQ==
+ dependencies:
+ events "^3.3.0"
+
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
@@ -25815,7 +25755,7 @@ yargs@^16.2.0:
y18n "^5.0.5"
yargs-parser "^20.2.2"
-yargs@^17.0.0, yargs@^17.0.1, yargs@^17.1.1, yargs@^17.2.1, yargs@^17.3.0:
+yargs@^17.0.0, yargs@^17.1.1, yargs@^17.2.1:
version "17.3.1"
resolved "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz#da56b28f32e2fd45aefb402ed9c26f42be4c07b9"
integrity sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==