Merge pull request #5282 from backstage/rugvip/config-schema

plugins: add config-schema plugin
This commit is contained in:
Patrik Oldsberg
2021-04-13 18:10:39 +02:00
committed by GitHub
31 changed files with 3112 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+19
View File
@@ -0,0 +1,19 @@
# config-schema
The `config-schema` plugin lets you browse a documentation reference of the configuration schema of a particular Backstage installation. It is intended as a tool for integrators rather than something that is useful to end users of Backstage.
## Usage
The plugin exports a single full-page extension, the `ConfigSchemaPage`, which you add to an app like a usual top-level tool on a dedicated route.
It also exports a `configSchemaApiRef` without any default implementation, meaning that an API needs to be registered in the app for the plugin to work. An implementation of the API that is provided out of the box is the `StaticSchemaLoader`, which loads the schema from a URL. It can be added to the app by adding the following to your app's `api.ts`:
```ts
createApiFactory(configSchemaApiRef, new StaticSchemaLoader());
```
The configuration schema consumed by the `StaticSchemaLoader` can be generated using the following command:
```shell
yarn --silent backstage-cli config:schema --format=json
```
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { Schema } from 'jsonschema';
import React from 'react';
import Observable from 'zen-observable';
import { configSchemaApiRef } from '../src/api';
import { ConfigSchemaResult } from '../src/api/types';
import { ConfigSchemaPage, configSchemaPlugin } from '../src/plugin';
import exampleSchema from './example-schema.json';
createDevApp()
.registerPlugin(configSchemaPlugin)
.registerApi({
api: configSchemaApiRef,
deps: {},
factory: () => ({
schema$: () =>
new Observable<ConfigSchemaResult>(sub =>
sub.next({ schema: (exampleSchema as unknown) as Schema }),
),
}),
})
.addPage({
element: <ConfigSchemaPage />,
title: 'Root Page',
})
.render();
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-config-schema",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.4",
"@backstage/core": "^0.7.4",
"@backstage/errors": "^0.1.1",
"@backstage/theme": "^0.2.5",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"jsonschema": "^1.2.6",
"zen-observable": "^0.8.15",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.6.7",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.10",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"msw": "^0.21.2",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
@@ -0,0 +1,57 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Observable } from '@backstage/core';
import ObservableImpl from 'zen-observable';
import { ResponseError } from '@backstage/errors';
import { Schema } from 'jsonschema';
import { ConfigSchemaApi, ConfigSchemaResult } from './types';
const DEFAULT_URL = 'config-schema.json';
/**
* A ConfigSchemaApi implementation that loads the configuration from a URL.
*/
export class StaticSchemaLoader implements ConfigSchemaApi {
private readonly url: string;
constructor({ url = DEFAULT_URL }: { url?: string } = {}) {
this.url = url;
}
schema$(): Observable<ConfigSchemaResult> {
return new ObservableImpl(subscriber => {
this.fetchSchema().then(
schema => subscriber.next({ schema }),
error => subscriber.error(error),
);
});
}
private async fetchSchema(): Promise<undefined | Schema> {
const res = await fetch(this.url);
if (!res.ok) {
if (res.status === 404) {
return undefined;
}
throw ResponseError.fromResponse(res);
}
return await res.json();
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { configSchemaApiRef } from './types';
export type { ConfigSchemaApi } from './types';
export { StaticSchemaLoader } from './StaticSchemaLoader';
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createApiRef, Observable } from '@backstage/core';
import { Schema } from 'jsonschema';
export interface ConfigSchemaResult {
schema?: Schema;
}
export interface ConfigSchemaApi {
schema$(): Observable<ConfigSchemaResult>;
}
export const configSchemaApiRef = createApiRef<ConfigSchemaApi>({
id: 'plugin.config-schema',
});
@@ -0,0 +1,48 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo } from 'react';
import { Header, Page, Content, useApi, Progress } from '@backstage/core';
import { useObservable } from 'react-use';
import { configSchemaApiRef } from '../../api';
import { SchemaViewer } from '../SchemaViewer';
import { Typography } from '@material-ui/core';
export const ConfigSchemaPage = () => {
const configSchemaApi = useApi(configSchemaApiRef);
const schemaResult = useObservable(
useMemo(() => configSchemaApi.schema$(), [configSchemaApi]),
);
let content;
if (schemaResult) {
if (schemaResult.schema) {
content = <SchemaViewer schema={schemaResult.schema} />;
} else {
content = (
<Typography variant="h4">No configuration schema available</Typography>
);
}
} else {
content = <Progress />;
}
return (
<Page themeId="tool">
<Header title="Configuration Reference" />
<Content stretch>{content}</Content>
</Page>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ConfigSchemaPage } from './ConfigSchemaPage';
@@ -0,0 +1,196 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createStyles, fade, withStyles } from '@material-ui/core';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { TreeItem, TreeItemProps, TreeView } from '@material-ui/lab';
import { Schema } from 'jsonschema';
import React, { ReactNode, useMemo, useRef } from 'react';
import { useScrollTargets } from '../ScrollTargetsContext';
const StyledTreeItem = withStyles(theme =>
createStyles({
label: {
userSelect: 'none',
},
group: {
marginLeft: 7,
paddingLeft: theme.spacing(1),
borderLeft: `1px solid ${fade(theme.palette.text.primary, 0.15)}`,
},
}),
)((props: TreeItemProps) => <TreeItem {...props} />);
export function createSchemaBrowserItems(
expanded: string[],
schema: Schema,
path: string = '',
depth: number = 0,
): ReactNode {
let matchArr;
if (schema.anyOf) {
matchArr = schema.anyOf;
} else if (schema.oneOf) {
matchArr = schema.oneOf;
} else if (schema.allOf) {
matchArr = schema.allOf;
}
if (matchArr) {
return matchArr.map((childSchema, index) => {
const childPath = `${path}/${index + 1}`;
if (depth > 0) expanded.push(childPath);
return (
<StyledTreeItem
key={childPath}
nodeId={childPath}
label={`<Option ${index + 1}>`}
>
{createSchemaBrowserItems(
expanded,
childSchema,
childPath,
depth + 1,
)}
</StyledTreeItem>
);
});
}
switch (schema.type) {
case 'array': {
const childPath = `${path}[]`;
if (depth > 0) expanded.push(childPath);
return (
<StyledTreeItem nodeId={childPath} label="[]">
{schema.items &&
createSchemaBrowserItems(
expanded,
schema.items as Schema,
childPath,
depth + 1,
)}
</StyledTreeItem>
);
}
case 'object':
case undefined: {
const children = [];
if (schema.properties) {
children.push(
...Object.entries(schema.properties).map(([name, childSchema]) => {
const childPath = path ? `${path}.${name}` : name;
if (depth > 0) expanded.push(childPath);
return (
<StyledTreeItem key={childPath} nodeId={childPath} label={name}>
{createSchemaBrowserItems(
expanded,
childSchema,
childPath,
depth + 1,
)}
</StyledTreeItem>
);
}),
);
}
if (schema.patternProperties) {
children.push(
...Object.entries(schema.patternProperties).map(
([name, childSchema]) => {
const childPath = `${path}.<${name}>`;
if (depth > 0) expanded.push(childPath);
return (
<StyledTreeItem
key={childPath}
nodeId={childPath}
label={`<${name}>`}
>
{createSchemaBrowserItems(
expanded,
childSchema,
childPath,
depth + 1,
)}
</StyledTreeItem>
);
},
),
);
}
if (schema.additionalProperties && schema.additionalProperties !== true) {
const childPath = `${path}.*`;
if (depth > 0) expanded.push(childPath);
children.push(
<StyledTreeItem key={childPath} nodeId={childPath} label="*">
{createSchemaBrowserItems(
expanded,
schema.additionalProperties,
childPath,
depth + 1,
)}
</StyledTreeItem>,
);
}
return <>{children}</>;
}
default:
return null;
}
}
export function SchemaBrowser({ schema }: { schema: Schema }) {
const scroll = useScrollTargets();
const expandedRef = useRef<string[]>([]);
const data = useMemo(() => {
const expanded = new Array<string>();
const items = createSchemaBrowserItems(expanded, schema);
return { items, expanded };
}, [schema]);
if (!scroll) {
throw new Error('No scroll handler available');
}
const handleToggle = (_event: unknown, expanded: string[]) => {
expandedRef.current = expanded;
};
const handleSelect = (_event: unknown, nodeId: string) => {
if (expandedRef.current.includes(nodeId)) {
scroll.scrollTo(nodeId);
}
};
return (
<TreeView
defaultExpanded={data.expanded}
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
onNodeToggle={handleToggle}
onNodeSelect={handleSelect}
>
{data.items}
</TreeView>
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SchemaBrowser } from './SchemaBrowser';
@@ -0,0 +1,59 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Box, Typography } from '@material-ui/core';
import { Schema } from 'jsonschema';
import React from 'react';
import { ChildView } from './ChildView';
import { MetadataView } from './MetadataView';
import { SchemaViewProps } from './types';
export function ArrayView({ path, depth, schema }: SchemaViewProps) {
const itemDepth = depth + 1;
const itemPath = path ? `${path}[]` : '[]';
const itemSchema = schema.items;
return (
<>
<Box marginBottom={4}>
{schema.description && (
<Box marginBottom={4}>
<Typography variant="body1">{schema.description}</Typography>
</Box>
)}
<MetadataView schema={schema} />
</Box>
<Typography variant="overline">Items</Typography>
<ChildView
lastChild
path={itemPath}
depth={itemDepth}
schema={itemSchema as Schema | undefined}
/>
{schema.additionalItems && schema.additionalItems !== true && (
<>
<Typography variant="overline">Additional Items</Typography>
<ChildView
path={itemPath}
depth={itemDepth}
schema={schema.additionalItems}
lastChild
/>
</>
)}
</>
);
}
@@ -0,0 +1,123 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { JsonValue } from '@backstage/config';
import { Box, Chip, Divider, makeStyles, Typography } from '@material-ui/core';
import { Schema } from 'jsonschema';
import React, { useEffect, useRef } from 'react';
import { useScrollTargets } from '../ScrollTargetsContext/ScrollTargetsContext';
import { SchemaView } from './SchemaView';
export interface MetadataViewRowProps {
label: string;
text?: string;
data?: JsonValue;
}
function titleVariant(depth: number) {
if (depth <= 1) {
return 'h2';
} else if (depth === 2) {
return 'h3';
} else if (depth === 3) {
return 'h4';
} else if (depth === 4) {
return 'h5';
}
return 'h6';
}
const useChildViewStyles = makeStyles(theme => ({
title: {
marginBottom: 0,
},
chip: {
marginLeft: theme.spacing(1),
marginRight: 0,
marginBottom: 0,
},
}));
export function ChildView({
path,
depth,
schema,
required,
lastChild,
}: {
path: string;
depth: number;
schema?: Schema;
required?: boolean;
lastChild?: boolean;
}) {
const classes = useChildViewStyles();
const titleRef = useRef<HTMLElement>(null);
const scroll = useScrollTargets();
useEffect(() => {
return scroll?.setScrollListener(path, () => {
titleRef.current?.scrollIntoView({ behavior: 'smooth' });
});
}, [scroll, path]);
const chips = new Array<JSX.Element>();
const chipProps = { size: 'small' as const, classes: { root: classes.chip } };
if (required) {
chips.push(
<Chip label="required" color="default" key="required" {...chipProps} />,
);
}
const visibility = (schema as { visibility?: string })?.visibility;
if (visibility === 'frontend') {
chips.push(
<Chip label="frontend" color="primary" key="visibility" {...chipProps} />,
);
} else if (visibility === 'secret') {
chips.push(
<Chip label="secret" color="secondary" key="visibility" {...chipProps} />,
);
}
return (
<Box paddingBottom={lastChild ? 4 : 8} display="flex" flexDirection="row">
<Divider orientation="vertical" flexItem />
<Box paddingLeft={2} flex={1}>
<Box
display="flex"
flexDirection="row"
marginBottom={2}
alignItems="center"
>
<Typography
ref={titleRef}
variant={titleVariant(depth)}
classes={{ root: classes.title }}
>
{path}
</Typography>
{chips.length > 0 && <Box marginLeft={1} />}
{chips}
</Box>
{schema && (
<SchemaView path={path} depth={depth} schema={schema as Schema} />
)}
</Box>
</Box>
);
}
@@ -0,0 +1,46 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Typography } from '@material-ui/core';
import { Schema } from 'jsonschema';
import React from 'react';
import { ChildView } from './ChildView';
export function MatchView({
path,
depth,
schema,
label,
}: {
path: string;
depth: number;
schema: Schema[];
label: string;
}) {
return (
<>
<Typography variant="overline">{label}</Typography>
{schema.map((optionSchema, index) => (
<ChildView
key={index}
path={`${path}/${index + 1}`}
depth={depth + 1}
schema={optionSchema}
lastChild={index === schema.length - 1}
/>
))}
</>
);
}
@@ -0,0 +1,110 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { JsonValue } from '@backstage/config';
import {
Paper,
Table,
TableBody,
TableCell,
TableRow,
Typography,
} from '@material-ui/core';
import { Schema } from 'jsonschema';
import React from 'react';
export interface MetadataViewRowProps {
label: string;
text?: string;
data?: JsonValue;
}
export function MetadataViewRow({ label, text, data }: MetadataViewRowProps) {
if (text === undefined && data === undefined) {
return null;
}
return (
<TableRow>
<TableCell style={{ width: 160 }}>
<Typography variant="body1" noWrap style={{ fontWeight: 900 }}>
{label}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body1">
{data ? JSON.stringify(data) : text}
</Typography>
</TableCell>
</TableRow>
);
}
export function MetadataView({ schema }: { schema: Schema }) {
return (
<Paper variant="outlined" square style={{ width: '100%' }}>
<Table size="small">
<TableBody>
<MetadataViewRow label="Type" data={schema.type} />
<MetadataViewRow label="Allowed values" data={schema.enum} />
{schema.additionalProperties === true && (
<MetadataViewRow label="Additional Properties" text="true" />
)}
{schema.additionalItems === true && (
<MetadataViewRow label="Additional Items" text="true" />
)}
<MetadataViewRow label="Format" text={schema.format} />
<MetadataViewRow
label="Pattern"
text={schema.pattern && String(schema.pattern)}
/>
<MetadataViewRow label="Minimum" data={schema.minimum} />
<MetadataViewRow label="Maximum" data={schema.maximum} />
<MetadataViewRow
label="Exclusive minimum"
data={schema.exclusiveMinimum}
/>
<MetadataViewRow
label="Exclusive maximum"
data={schema.exclusiveMaximum}
/>
<MetadataViewRow label="Multiple of" data={schema.multipleOf} />
<MetadataViewRow
label="Maximum number of items"
data={schema.maxItems}
/>
<MetadataViewRow
label="Minimum number of items"
data={schema.minItems}
/>
<MetadataViewRow
label="Maximum number of properties"
data={schema.maxProperties}
/>
<MetadataViewRow
label="Minimum number of properties"
data={schema.minProperties}
/>
<MetadataViewRow label="Maximum Length" data={schema.maxLength} />
<MetadataViewRow label="Minimum Length" data={schema.minLength} />
<MetadataViewRow
label="Items must be unique"
data={schema.uniqueItems}
/>
</TableBody>
</Table>
</Paper>
);
}
@@ -0,0 +1,94 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Box, Typography } from '@material-ui/core';
import React from 'react';
import { ChildView } from './ChildView';
import { MetadataView } from './MetadataView';
import { SchemaViewProps } from './types';
function isRequired(name: string, required?: boolean | string[]) {
if (required === true) {
return true;
}
if (Array.isArray(required)) {
return required.includes(name);
}
return false;
}
export function ObjectView({ path, depth, schema }: SchemaViewProps) {
const properties = Object.entries(schema.properties ?? {});
const patternProperties = Object.entries(schema.patternProperties ?? {});
return (
<>
{depth > 0 && (
<Box marginBottom={4}>
{schema.description && (
<Box marginBottom={4}>
<Typography variant="body1">{schema.description}</Typography>
</Box>
)}
<MetadataView schema={schema} />
</Box>
)}
{properties.length > 0 && (
<>
{depth > 0 && <Typography variant="overline">Properties</Typography>}
{properties.map(([name, propSchema], index) => (
<ChildView
key={name}
path={path ? `${path}.${name}` : name}
depth={depth + 1}
schema={propSchema}
lastChild={index === properties.length - 1}
required={isRequired(name, schema.required)}
/>
))}
</>
)}
{patternProperties.length > 0 && (
<>
{depth > 0 && (
<Typography variant="overline">Pattern Properties</Typography>
)}
{patternProperties.map(([name, propSchema], index) => (
<ChildView
key={name}
path={path ? `${path}.<${name}>` : name}
depth={depth + 1}
schema={propSchema}
lastChild={index === patternProperties.length - 1}
required={isRequired(name, schema.required)}
/>
))}
</>
)}
{schema.additionalProperties && schema.additionalProperties !== true && (
<>
<Typography variant="overline">Additional Properties</Typography>
<ChildView
path={`${path}.*`}
depth={depth + 1}
schema={schema.additionalProperties}
lastChild
/>
</>
)}
</>
);
}
@@ -0,0 +1,33 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Box, Typography } from '@material-ui/core';
import React from 'react';
import { MetadataView } from './MetadataView';
import { SchemaViewProps } from './types';
export function ScalarView({ schema }: SchemaViewProps) {
return (
<>
{schema.description && (
<Box marginBottom={4}>
<Typography variant="body1">{schema.description}</Typography>
</Box>
)}
<MetadataView schema={schema} />
</>
);
}
@@ -0,0 +1,62 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ArrayView } from './ArrayView';
import { MatchView } from './MatchView';
import { ObjectView } from './ObjectView';
import { ScalarView } from './ScalarView';
import { SchemaViewProps } from './types';
export function SchemaView(props: SchemaViewProps) {
const { schema } = props;
if (schema.anyOf) {
return (
<MatchView
{...props}
schema={schema.anyOf}
label="Any of the following"
/>
);
}
if (schema.oneOf) {
return (
<MatchView
{...props}
schema={schema.oneOf}
label="One of the following"
/>
);
}
if (schema.allOf) {
return (
<MatchView
{...props}
schema={schema.allOf}
label="All of the following"
/>
);
}
switch (schema.type) {
case 'array':
return <ArrayView {...props} />;
case 'object':
case undefined:
return <ObjectView {...props} />;
default:
return <ScalarView {...props} />;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SchemaView } from './SchemaView';
@@ -0,0 +1,23 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Schema } from 'jsonschema';
export interface SchemaViewProps {
path: string;
depth: number;
schema: Schema;
}
@@ -0,0 +1,136 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { render, screen } from '@testing-library/react';
import { Schema } from 'jsonschema';
import React from 'react';
import { SchemaViewer } from './SchemaViewer';
describe('SchemaViewer', () => {
it('should render a simple schema', () => {
const schema = {
type: 'object',
properties: {
a: {
description: 'My A',
type: 'string',
},
b: {
description: 'My B',
type: 'number',
},
},
};
render(<SchemaViewer schema={schema} />);
expect(screen.getAllByText('a').length).toBe(2);
expect(screen.getByText('My A')).toBeInTheDocument();
expect(screen.getAllByText('b').length).toBe(2);
expect(screen.getByText('My B')).toBeInTheDocument();
});
it('should render complex schema', () => {
const schema: Schema = {
type: 'object',
properties: {
a: {
description: 'My A',
type: 'object',
patternProperties: {
'prefix.*': {
description: 'Prefix prop',
type: 'string',
},
},
additionalProperties: {
description: 'Additional properties for A',
type: 'number',
minimum: 72,
maximum: 79,
},
},
b: {
oneOf: [
{ type: 'string', description: 'B one of 1' },
{
type: 'array',
description: 'B one of 2',
items: {
anyOf: [
{ type: 'string', description: 'Any of B 1' },
{ type: 'number', description: 'Any of B 2' },
{
type: 'object',
description: 'Any of B 3',
properties: {
deep: {
allOf: [
{
type: 'number',
description: 'Impossible 1',
},
{
type: 'boolean',
description: 'Impossible 2',
},
],
},
},
},
],
},
},
],
},
},
};
render(<SchemaViewer schema={schema} />);
expect(screen.getAllByText('a').length).toBe(2);
expect(screen.getByText('My A')).toBeInTheDocument();
expect(screen.getByText('a.<prefix.*>')).toBeInTheDocument();
expect(screen.getByText('Prefix prop')).toBeInTheDocument();
expect(screen.getByText('a.*')).toBeInTheDocument();
expect(screen.getByText('Additional properties for A')).toBeInTheDocument();
expect(screen.getByText('72')).toBeInTheDocument();
expect(screen.getByText('79')).toBeInTheDocument();
expect(screen.getAllByText('b').length).toBe(2);
expect(screen.getByText('b/1')).toBeInTheDocument();
expect(screen.getByText('B one of 1')).toBeInTheDocument();
expect(screen.getByText('b/2')).toBeInTheDocument();
expect(screen.getByText('B one of 2')).toBeInTheDocument();
expect(screen.getByText('b/2[]')).toBeInTheDocument();
expect(screen.getByText('b/2[]/1')).toBeInTheDocument();
expect(screen.getByText('Any of B 1')).toBeInTheDocument();
expect(screen.getByText('b/2[]/2')).toBeInTheDocument();
expect(screen.getByText('Any of B 2')).toBeInTheDocument();
expect(screen.getByText('b/2[]/3')).toBeInTheDocument();
expect(screen.getByText('Any of B 3')).toBeInTheDocument();
expect(screen.getByText('b/2[]/3.deep')).toBeInTheDocument();
expect(screen.getByText('b/2[]/3.deep/1')).toBeInTheDocument();
expect(screen.getByText('Impossible 1')).toBeInTheDocument();
expect(screen.getByText('b/2[]/3.deep/2')).toBeInTheDocument();
expect(screen.getByText('Impossible 2')).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Box, Paper } from '@material-ui/core';
import { Schema } from 'jsonschema';
import React from 'react';
import { SchemaView } from '../SchemaView';
import { SchemaBrowser } from '../SchemaBrowser';
import { ScrollTargetsProvider } from '../ScrollTargetsContext/ScrollTargetsContext';
export interface SchemaViewerProps {
schema: Schema;
}
export const SchemaViewer = ({ schema }: SchemaViewerProps) => {
return (
<Box flex="1" position="relative">
<Box
clone
position="absolute"
display="flex"
flexDirection="row"
flexWrap="nowrap"
maxHeight="100%"
>
<Paper elevation={3}>
<ScrollTargetsProvider>
<Box padding={1} overflow="auto" width={300}>
<SchemaBrowser schema={schema} />
</Box>
<Box flex="1" overflow="auto">
<SchemaView schema={schema} path="" depth={0} />
</Box>
</ScrollTargetsProvider>
</Paper>
</Box>
</Box>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SchemaViewer } from './SchemaViewer';
@@ -0,0 +1,52 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { createContext, ReactNode, useContext } from 'react';
class ScrollTargetsForwarder {
private readonly listeners = new Map<string, () => void>();
setScrollListener(id: string, listener: () => void): () => void {
this.listeners.set(id, listener);
return () => {
if (this.listeners.get(id) === listener) {
this.listeners.delete(id);
}
};
}
scrollTo(id: string) {
this.listeners.get(id)?.();
}
}
const ScrollTargetsContext = createContext<ScrollTargetsForwarder | undefined>(
undefined,
);
export function ScrollTargetsProvider({ children }: { children: ReactNode }) {
return (
<ScrollTargetsContext.Provider
value={new ScrollTargetsForwarder()}
children={children}
/>
);
}
export function useScrollTargets() {
return useContext(ScrollTargetsContext);
}
@@ -0,0 +1,20 @@
/*
* Copyright 2021 Spotify AB
*
* 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 {
ScrollTargetsProvider,
useScrollTargets,
} from './ScrollTargetsContext';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 Spotify AB
*
* 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 * from './api';
export { configSchemaPlugin, ConfigSchemaPage } from './plugin';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { configSchemaPlugin } from './plugin';
describe('config-schema', () => {
it('should export plugin', () => {
expect(configSchemaPlugin).toBeDefined();
});
});
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createPlugin, createRoutableExtension } from '@backstage/core';
import { rootRouteRef } from './routes';
export const configSchemaPlugin = createPlugin({
id: 'config-schema',
routes: {
root: rootRouteRef,
},
});
export const ConfigSchemaPage = configSchemaPlugin.provide(
createRoutableExtension({
component: () =>
import('./components/ConfigSchemaPage').then(m => m.ConfigSchemaPage),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createRouteRef } from '@backstage/core';
export const rootRouteRef = createRouteRef({
title: 'config-schema',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';