chore: removed some unneeded code, just use the one that exists
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-95
@@ -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>
|
||||
);
|
||||
}
|
||||
-55
@@ -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>
|
||||
);
|
||||
}
|
||||
-99
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user