chore: removed some unneeded code, just use the one that exists

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2023-02-10 15:42:47 +01:00
parent a521379688
commit 73981f9407
18 changed files with 8 additions and 1594 deletions
@@ -1,243 +0,0 @@
/*
* 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<void>;
/** Reload the staged content of the file from disk */
reload(): Promise<void>;
}
interface DirectoryEditor {
/** A list of all files in the edited directory */
files: Array<DirectoryEditorFile>;
/** The currently selected file */
selectedFile: DirectoryEditorFile | undefined;
/** Switch the selected file */
setSelectedFile(path: string | undefined): void;
/** Save all files to disk */
save(): Promise<void>;
/** Reload all files from disk */
reload(): Promise<void>;
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<void> {
if (this.#content !== undefined) {
await this.#access.save(this.#content);
this.#savedContent = this.#content;
this.#signalUpdate();
}
}
async reload(): Promise<void> {
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<void> {
await Promise.all(this.#files.map(file => file.save()));
}
async reload(): Promise<void> {
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<DirectoryEditor | undefined>(
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 <ErrorPanel error={error} />;
} else if (!result) {
return <Progress />;
}
return (
<DirectoryEditorContext.Provider value={result}>
{props.children}
</DirectoryEditorContext.Provider>
);
}
@@ -1,44 +0,0 @@
/*
* 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 { base64EncodeContent } from './DryRunContext';
// eslint-disable-next-line no-restricted-imports
import { TextEncoder } from 'util';
window.TextEncoder = TextEncoder;
describe('base64EncodeContent', () => {
it('encodes text files', () => {
expect(base64EncodeContent('abc')).toBe('YWJj');
expect(base64EncodeContent('abc'.repeat(1000000))).toBe(
window.btoa('<file too large>'),
);
});
it('encodes binary files', () => {
expect(base64EncodeContent('\x00\x01\x02')).toBe('AAEC');
expect(base64EncodeContent('😅')).toBe('8J+YhQ==');
// Triggers chunking
expect(base64EncodeContent('😅'.repeat(18000))).toBe(
'8J+YhfCfmIXwn5iF8J+YhfCfmIXwn5iF'.repeat(3000),
);
// Triggers size check
expect(base64EncodeContent('😅'.repeat(1000000))).toBe(
window.btoa('<file too large>'),
);
});
});
@@ -1,179 +0,0 @@
/*
* 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,
ScaffolderDryRunResponse,
} from '@backstage/plugin-scaffolder-react';
const MAX_CONTENT_SIZE = 64 * 1024;
const CHUNK_SIZE = 32 * 1024;
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<void>;
}
const DryRunContext = createContext<DryRun | undefined>(undefined);
interface DryRunProviderProps {
children: ReactNode;
}
export function base64EncodeContent(content: string): string {
if (content.length > MAX_CONTENT_SIZE) {
return window.btoa('<file too large>');
}
try {
return window.btoa(content);
} catch {
const decoder = new TextEncoder();
const buffer = decoder.encode(content);
const chunks = new Array<string>();
for (let offset = 0; offset < buffer.length; offset += CHUNK_SIZE) {
chunks.push(
String.fromCharCode(...buffer.slice(offset, offset + CHUNK_SIZE)),
);
}
return window.btoa(chunks.join(''));
}
}
export function DryRunProvider(props: DryRunProviderProps) {
const scaffolderApi = useApi(scaffolderApiRef);
const [state, setState] = useState<
Pick<DryRun, 'results' | 'selectedResult'>
>({
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: base64EncodeContent(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 (
<DryRunContext.Provider value={dryRun}>
{props.children}
</DryRunContext.Provider>
);
}
export function useDryRun(): DryRun {
const value = useContext(DryRunContext);
if (!value) {
throw new Error('must be used within a DryRunProvider');
}
return value;
}
@@ -1,118 +0,0 @@
/*
* 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 '@backstage/plugin-scaffolder-react';
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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunResults />
</DryRunProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunRemote />
<DryRunResults />
</DryRunProvider>
</TestApiProvider>,
);
expect(screen.getByText('Files')).not.toBeVisible();
await act(async () => {
rerender(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunRemote execute />
<DryRunResults />
</DryRunProvider>
</TestApiProvider>,
);
});
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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunRemote remove />
<DryRunResults />
</DryRunProvider>
</TestApiProvider>,
);
});
await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible());
});
});
@@ -1,91 +0,0 @@
/*
* 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 (
<>
<Accordion
variant="outlined"
expanded={expanded}
hidden={resultsLength === 0 && hidden}
onChange={(_, exp) => setExpanded(exp)}
onTransitionEnd={() => resultsLength === 0 && setHidden(true)}
>
<AccordionSummary
className={classes.header}
expandIcon={<ExpandMoreIcon />}
>
<Typography>Dry-run results</Typography>
</AccordionSummary>
<Divider orientation="horizontal" />
<AccordionDetails className={classes.content}>
<DryRunResultsList />
<Divider orientation="horizontal" />
<DryRunResultsView />
</AccordionDetails>
</Accordion>
</>
);
}
@@ -1,95 +0,0 @@
/*
* 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 '@backstage/plugin-scaffolder-react';
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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunResultsList />
</DryRunProvider>
</TestApiProvider>,
);
expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement();
});
it('adds new result items and deletes them', async () => {
const { rerender } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunRemote execute={1} />
<DryRunResultsList />
</DryRunProvider>
</TestApiProvider>,
);
expect(screen.getByText('Result 1')).toBeInTheDocument();
expect(screen.queryByText('Result 2')).not.toBeInTheDocument();
await act(async () => {
rerender(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<DryRunProvider>
<DryRunRemote execute={2} />
<DryRunResultsList />
</DryRunProvider>
</TestApiProvider>,
);
});
expect(screen.getByText('Result 1')).toBeInTheDocument();
expect(screen.getByText('Result 2')).toBeInTheDocument();
await userEvent.click(screen.getAllByLabelText('delete')[0]);
expect(screen.queryByText('Result 1')).not.toBeInTheDocument();
expect(screen.getByText('Result 2')).toBeInTheDocument();
});
});
@@ -1,83 +0,0 @@
/*
* 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 (
<List className={classes.root} dense>
{dryRun.results.map(result => {
const failed = result.log.some(l => l.body.status === 'failed');
return (
<ListItem
button
key={result.id}
selected={dryRun.selectedResult?.id === result.id}
onClick={() => dryRun.selectResult(result.id)}
>
<ListItemIcon
className={failed ? classes.iconFailure : classes.iconSuccess}
>
{failed ? <CancelIcon /> : <CheckIcon />}
</ListItemIcon>
<ListItemText primary={`Result ${result.id}`} />
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={() => dryRun.deleteResult(result.id)}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
})}
</List>
);
}
@@ -1,55 +0,0 @@
/*
* 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 (
<div className={classes.root}>
<div className={classNames(classes.child, classes.firstChild)}>
{childArray[0]}
</div>
<Divider orientation="horizontal" />
<div className={classes.child}>{childArray[1]}</div>
</div>
);
}
@@ -1,99 +0,0 @@
/*
* 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 '@backstage/plugin-scaffolder-react';
import { DryRunProvider, useDryRun } from '../DryRunContext';
import { DryRunResultsView } from './DryRunResultsView';
// The <AutoSizer> inside <LogViewer> 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(
<TestApiProvider
apis={[
[
scaffolderApiRef,
{
dryRun: async () => ({
directoryContents: [
{
path: 'foo.txt',
base64Content: window.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' }],
}),
},
],
]}
>
<DryRunProvider>
<DryRunResultsView />
<DryRunRemote />
</DryRunProvider>
</TestApiProvider>,
{
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();
});
});
@@ -1,195 +0,0 @@
/*
* 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 { FileBrowser } from '../../../components/FileBrowser';
import { TaskStatusStepper } from '../../../components/TaskPage/TaskPage';
import { TaskPageLinks } from '../../../components/TaskPage/TaskPageLinks';
import { useDryRun } from '../DryRunContext';
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<string>('');
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 (
<DryRunResultsSplitView>
<FileBrowser
selected={selectedPath}
onSelect={setSelectedPath}
filePaths={selectedResult.directoryContents.map(file => file.path)}
/>
<CodeMirror
className={classes.codeMirror}
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
readOnly
value={
selectedFile?.base64Content ? atob(selectedFile.base64Content) : ''
}
/>
</DryRunResultsSplitView>
);
}
function LogContent() {
const { selectedResult } = useDryRun();
const [currentStepId, setUserSelectedStepId] = useState<string>();
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 (
<DryRunResultsSplitView>
<TaskStatusStepper
steps={steps}
currentStepId={selectedStep.id}
onUserStepChange={setUserSelectedStepId}
/>
<LogViewer text={selectedStep?.logString ?? ''} />
</DryRunResultsSplitView>
);
}
function OutputContent() {
const classes = useStyles();
const { selectedResult } = useDryRun();
if (!selectedResult) {
return null;
}
return (
<DryRunResultsSplitView>
<Box pt={2}>
{selectedResult.output?.links?.length && (
<TaskPageLinks output={selectedResult.output} />
)}
</Box>
<CodeMirror
className={classes.codeMirror}
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
readOnly
value={JSON.stringify(selectedResult.output, null, 2)}
/>
</DryRunResultsSplitView>
);
}
export function DryRunResultsView() {
const classes = useStyles();
const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>(
'files',
);
return (
<div className={classes.root}>
<Tabs value={selectedTab} onChange={(_, v) => setSelectedTab(v)}>
<Tab value="files" label="Files" />
<Tab value="log" label="Log" />
<Tab value="output" label="Output" />
</Tabs>
<Divider />
<div className={classes.contentWrapper}>
<div className={classes.content}>
{selectedTab === 'files' && <FilesContent />}
{selectedTab === 'log' && <LogContent />}
{selectedTab === 'output' && <OutputContent />}
</div>
</div>
</div>
);
}
@@ -1,17 +0,0 @@
/*
* 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';
@@ -20,12 +20,12 @@ import type {
NextFieldExtensionOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { DryRunProvider } from './DryRunContext';
import { DryRunResults } from './DryRunResults';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { DirectoryEditorProvider } from '../../components/TemplateEditorPage/DirectoryEditorContext';
import { DryRunProvider } from '../../components/TemplateEditorPage/DryRunContext';
import { DryRunResults } from '../../components/TemplateEditorPage/DryRunResults';
import { TemplateEditorBrowser } from '../../components/TemplateEditorPage/TemplateEditorBrowser';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea';
const useStyles = makeStyles({
// Reset and fix sizing to make sure scrolling behaves correctly
@@ -1,55 +0,0 @@
/*
* 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(
<DirectoryEditorProvider
directory={MockFileSystemAccess.createMockDirectory({
'foo.txt': 'le foo',
'dir/bar.txt': 'le bar',
'dir/baz.txt': 'le baz',
})}
>
<TemplateEditorBrowser />
</DirectoryEditorProvider>,
);
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();
});
});
@@ -1,100 +0,0 @@
/*
* 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 '../../components/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 (
<>
<div className={classes.buttons}>
<Tooltip title="Save all files">
<IconButton
className={classes.button}
disabled={directoryEditor.files.every(file => !file.dirty)}
onClick={() => directoryEditor.save()}
>
<SaveIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reload directory">
<IconButton
className={classes.button}
onClick={() => directoryEditor.reload()}
>
<RefreshIcon />
</IconButton>
</Tooltip>
<div className={classes.buttonsGap} />
<Tooltip title="Close directory">
<IconButton className={classes.button} onClick={handleClose}>
<CloseIcon />
</IconButton>
</Tooltip>
</div>
<Divider className={classes.buttonsDivider} />
<FileBrowser
selected={directoryEditor.selectedFile?.path ?? ''}
onSelect={directoryEditor.setSelectedFile}
filePaths={directoryEditor.files.map(file => file.path)}
/>
</>
);
}
@@ -25,9 +25,8 @@ import {
Stepper,
TemplateParameterSchema,
} from '@backstage/plugin-scaffolder-react';
import { useDirectoryEditor } from './DirectoryEditorContext';
import { useDryRun } from './DryRunContext';
import { useDryRun } from '../../components/TemplateEditorPage/DryRunContext';
import { useDirectoryEditor } from '../../components/TemplateEditorPage/DirectoryEditorContext';
const useStyles = makeStyles({
containerWrapper: {
@@ -1,58 +0,0 @@
/*
* 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(<TemplateEditorPage />);
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(<TemplateEditorPage />);
expect(
screen.getByRole('button', { name: /Load Template Directory/ }),
).toBeDisabled();
});
it('should be able to continue to form preview', async () => {
await renderInTestApp(
<TestApiProvider
apis={[
[
catalogApiRef,
{ getEntities: jest.fn().mockResolvedValue({ items: [] }) },
],
]}
>
<TemplateEditorPage />
</TestApiProvider>,
);
await userEvent.click(screen.getByText('Edit Template Form'));
expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument();
});
});
@@ -1,153 +0,0 @@
/*
* 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 (
<div className={classes.container}>
<CodeMirror
className={classes.codeMirror}
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport), panelExtension]}
value={props.content}
onChange={props.onUpdate}
/>
{(props.onSave || props.onReload) && (
<div className={classes.floatingButtons}>
<Paper>
{props.onSave && (
<Tooltip title="Save file">
<IconButton
className={classes.floatingButton}
onClick={() => props.onSave?.()}
>
<SaveIcon />
</IconButton>
</Tooltip>
)}
{props.onReload && (
<Tooltip title="Reload file">
<IconButton
className={classes.floatingButton}
onClick={() => props.onReload?.()}
>
<RefreshIcon />
</IconButton>
</Tooltip>
)}
</Paper>
</div>
)}
</div>
);
}
/** 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 (
<TemplateEditorTextArea
errorText={props.errorText}
content={directoryEditor.selectedFile?.content}
onUpdate={content => directoryEditor.selectedFile?.updateContent(content)}
{...actions}
/>
);
}
TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea;
@@ -37,7 +37,7 @@ import {
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters: