From 16054a87ca9fa1ec7dd20e640268ff69b021ecf5 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Wed, 17 Jun 2020 21:21:32 +0200 Subject: [PATCH 01/64] feat(scaffolder): load data from catalog api --- plugins/scaffolder/package.json | 2 + .../src/components/ScaffolderPage/index.tsx | 70 ++++++++++++------- .../src/components/TemplateCard.tsx | 2 +- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 84132a42f9..87ce329b39 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -22,7 +22,9 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.1.1-alpha.9", "@backstage/core": "^0.1.1-alpha.9", + "@backstage/plugin-catalog": "^0.1.1-alpha.9", "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index aa1cf36082..15d2efe1f4 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -22,24 +22,38 @@ import { Header, Page, pageTheme, + useApi, } from '@backstage/core'; -import { Typography, Link, Button } from '@material-ui/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { + Typography, + Link, + Button, + Grid, + LinearProgress, +} from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import TemplateCard from '../TemplateCard'; +import { useAsync } from 'react-use'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -// TODO(blam): Connect to backend -const STATIC_DATA = [ - { - id: 'react-ssr-template', - type: 'web-infra', - name: 'SSR React Website', - tags: ['Experimental'], - description: - 'Next.js application skeleton for creating isomorphic web applications.', - ownerId: 'something', - }, -]; const ScaffolderPage: React.FC<{}> = () => { + const catalogApi = useApi(catalogApiRef); + + const { value, loading } = useAsync(async () => { + const entities = await catalogApi.getEntities({ kind: 'Template' }); + return (entities as TemplateEntityV1alpha1[]).map(template => ({ + id: template.metadata.uid, + type: template.spec.type, + name: template.metadata.name, + // TODO(shmidt-i): decide on tags + tags: ['not-implemented'], + description: template.metadata.description ?? '-', + // TODO(shmidt-i): decide on owner + ownerId: '-', + })); + }); + return (
= () => { . -
- {STATIC_DATA.map(item => { - return ( - - ); - })} -
+ {loading ? ( + + ) : ( + + {value!.map(item => { + return ( + + ); + })} + + )} ); diff --git a/plugins/scaffolder/src/components/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard.tsx index 56d8907f7b..618eda64f1 100644 --- a/plugins/scaffolder/src/components/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard.tsx @@ -67,7 +67,7 @@ const TemplateCard: FC = ({
{tags?.map(tag => ( - + ))} {description} From 2808d8aa06c931dbe176b3a5dc999e193dd5dae9 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Sat, 20 Jun 2020 13:55:22 +0200 Subject: [PATCH 02/64] feat(scaffolder): use swr, pretty template mocks, move out grid item --- .../react-ssr-template/template.yaml | 6 +- .../springboot-template/template.yaml | 12 ++++ plugins/scaffolder-backend/scripts/mock-data | 16 +++-- plugins/scaffolder/package.json | 3 +- .../src/components/ScaffolderPage/index.tsx | 62 ++++++++++--------- .../src/components/TemplateCard.tsx | 43 +++++-------- 6 files changed, 78 insertions(+), 64 deletions(-) create mode 100644 plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml index 17f9ebbf7d..058e2a715f 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml @@ -4,7 +4,9 @@ metadata: name: react-ssr-template title: React SSR Template description: Next.js application skeleton for creating isomorphic web applications. + annotations: + tags: Recommended,React spec: - type: cookiecutter + processor: cookiecutter + type: website path: '.' - diff --git a/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml b/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml new file mode 100644 index 0000000000..d44d926ef4 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml @@ -0,0 +1,12 @@ +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: springboot-template + title: Spring Boot Service + description: Standard Spring Boot (Java) microservice with recommended configuration. + annotations: + tags: Recommended,Java +spec: + processor: cookiecutter + type: service + path: '.' diff --git a/plugins/scaffolder-backend/scripts/mock-data b/plugins/scaffolder-backend/scripts/mock-data index be3fa2b6cf..594bd76607 100755 --- a/plugins/scaffolder-backend/scripts/mock-data +++ b/plugins/scaffolder-backend/scripts/mock-data @@ -1,8 +1,12 @@ #!/usr/bin/env bash -curl \ ---location \ ---request POST 'localhost:7000/catalog/locations' \ ---header 'Content-Type: application/json' \ ---data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/react-ssr-template/template.yaml\"}" - +for URL in \ + 'react-ssr-template' \ + 'springboot-template' \ +; do \ + curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/${URL}/template.yaml\"}" +done diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 6710fdc1f7..683d277be2 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -31,7 +31,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-alpha.5", - "react-use": "^14.2.0" + "react-use": "^14.2.0", + "swr": "^0.2.2" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.9", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index 1dbd993d93..a6dbac8f83 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { Lifecycle, Content, @@ -24,6 +24,7 @@ import { Page, pageTheme, useApi, + errorApiRef, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { @@ -35,25 +36,25 @@ import { } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import TemplateCard from '../TemplateCard'; -import { useAsync } from 'react-use'; +import useStaleWhileRevalidate from 'swr'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; const ScaffolderPage: React.FC<{}> = () => { const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); - const { value, loading } = useAsync(async () => { - const entities = await catalogApi.getEntities({ kind: 'Template' }); - return (entities as TemplateEntityV1alpha1[]).map(template => ({ - id: template.metadata.uid, - type: template.spec.type, - name: template.metadata.name, - // TODO(shmidt-i): decide on tags - tags: ['not-implemented'], - description: template.metadata.description ?? '-', - // TODO(shmidt-i): decide on owner - ownerId: '-', - })); - }); + const { data: templates, isValidating, error } = useStaleWhileRevalidate( + 'templates/all', + async () => + catalogApi.getEntities({ kind: 'Template' }) as Promise< + TemplateEntityV1alpha1[] + >, + ); + + useEffect(() => { + if (!error) return; + errorApi.post(error); + }, [error, errorApi]); return ( @@ -89,23 +90,26 @@ const ScaffolderPage: React.FC<{}> = () => { . - {loading ? ( - - ) : ( - - {value!.map(item => { + {isValidating && } + + {templates && + templates.map(template => { return ( - + + + ); })} - - )} + ); diff --git a/plugins/scaffolder/src/components/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard.tsx index 618eda64f1..6d47f05693 100644 --- a/plugins/scaffolder/src/components/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard.tsx @@ -14,14 +14,7 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { - Button, - Card, - Chip, - Grid, - Typography, - makeStyles, -} from '@material-ui/core'; +import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ header: { @@ -59,25 +52,23 @@ const TemplateCard: FC = ({ const classes = useStyles(); return ( - - -
- {type} - {title} + +
+ {type} + {title} +
+
+ {tags?.map(tag => ( + + ))} + + {description} + +
+
-
- {tags?.map(tag => ( - - ))} - - {description} - -
- -
-
- - +
+
); }; From f00a76dc968a912ab4624306256c7a895d631810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Fri, 19 Jun 2020 00:46:11 +0200 Subject: [PATCH 03/64] feat(tech-radar): add unit tests --- .../RadarBubble/RadarBubble.test.tsx | 52 +++++++++++++++ .../components/RadarEntry/RadarEntry.test.tsx | 52 +++++++++++++++ .../RadarFooter/RadarFooter.test.tsx | 50 +++++++++++++++ .../components/RadarGrid/RadarGrid.test.tsx | 50 +++++++++++++++ .../RadarLegend/RadarLegend.test.tsx | 60 ++++++++++++++++++ .../components/RadarPlot/RadarPlot.test.tsx | 63 +++++++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx create mode 100644 plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx create mode 100644 plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx create mode 100644 plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx create mode 100644 plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx create mode 100644 plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx new file mode 100644 index 0000000000..688fd55f9f --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarBubble from './RadarBubble'; + +const minProps = { + visible: true, + text: 'RadarBubble', + x: 2, + y: 2, +}; + +describe('RadarBubble', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx new file mode 100644 index 0000000000..20bc6fa7be --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarEntry from './RadarEntry'; + +const minProps = { + x: 2, + y: 2, + value: 2, + color: 'red', +}; + +describe('RadarEntry', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx new file mode 100644 index 0000000000..3e0ee7e407 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarFooter from './RadarFooter'; + +const minProps = { + x: 2, + y: 2, +}; + +describe('RadarFooter', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx new file mode 100644 index 0000000000..8a2873564d --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarGrid from './RadarGrid'; + +const minProps = { + radius: 5, + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], +}; + +describe('RadarGrid', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx new file mode 100644 index 0000000000..8168688fdd --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarLegend from './RadarLegend'; + +const minProps = { + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarLegend', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx new file mode 100644 index 0000000000..31f450baeb --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarPlot from './RadarPlot'; + +const minProps = { + width: 500, + height: 200, + radius: 50, + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarPlot', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered).not.toBeNull(); + }); +}); From de1fe3c0e84f0be71b9d9ea8cf5a693ac7048749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Fri, 19 Jun 2020 00:47:25 +0200 Subject: [PATCH 04/64] refactor(tech-radar): style + code quality --- .../tech-radar/src/components/Radar/Radar.tsx | 6 +- .../tech-radar/src/components/Radar/utils.ts | 2 +- .../components/RadarBubble/RadarBubble.tsx | 4 +- .../src/components/RadarComponent.tsx | 28 ++--- .../src/components/RadarEntry/RadarEntry.tsx | 47 ++++---- .../components/RadarFooter/RadarFooter.tsx | 4 +- .../components/RadarLegend/RadarLegend.tsx | 103 +++++++++--------- .../tech-radar/src/components/RadarPage.tsx | 4 +- .../src/components/RadarPlot/RadarPlot.tsx | 6 +- plugins/tech-radar/src/utils/components.tsx | 35 ++++++ 10 files changed, 134 insertions(+), 105 deletions(-) create mode 100644 plugins/tech-radar/src/utils/components.tsx diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 1db655730d..a1e4d2da78 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import RadarPlot from '../RadarPlot'; import { Ring, Quadrant, Entry } from '../../utils/types'; import { adjustQuadrants, adjustRings, adjustEntries } from './utils'; @@ -28,7 +28,7 @@ type Props = { svgProps?: object; }; -const Radar: FC = props => { +const Radar = (props: Props): JSX.Element => { const { width, height, quadrants, rings, entries } = props; const radius = Math.min(width, height) / 2; @@ -38,7 +38,7 @@ const Radar: FC = props => { // TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem adjustQuadrants(quadrants, radius, width, height); adjustRings(rings, radius); - adjustEntries(entries, activeEntry, quadrants, rings, radius); + adjustEntries(entries, quadrants, rings, radius, activeEntry); return ( diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index a8896ddf31..65268613fe 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -98,10 +98,10 @@ export const adjustQuadrants = ( export const adjustEntries = ( entries: Entry[], - activeEntry: Entry | null | undefined, quadrants: Quadrant[], rings: Ring[], radius: number, + activeEntry?: Entry | null, ) => { let seed = 42; entries.forEach((entry, idx) => { diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index dcf9a37ed7..723a408885 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useRef, useLayoutEffect } from 'react'; +import React, { useRef, useLayoutEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core'; type Props = { @@ -46,7 +46,7 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarBubble: FC = props => { +const RadarBubble = (props: Props): JSX.Element => { const classes = useStyles(props); const { visible, text } = props; diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 981d1f1369..8d56c6aa0f 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect, useState, FC } from 'react'; +import React, { useEffect, useState } from 'react'; import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; @@ -22,15 +22,9 @@ import getSampleData from '../sampleData'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); - const [state, setState] = useState<{ - loading: boolean; - error?: Error; - data?: TechRadarLoaderResponse; - }>({ - loading: true, - error: undefined, - data: undefined, - }); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const [data, setData] = useState(); const { getData } = props; @@ -41,22 +35,20 @@ const useTechRadarLoader = (props: TechRadarComponentProps) => { getData() .then((payload: TechRadarLoaderResponse) => { - setState({ loading: false, error: undefined, data: payload }); + setLoading(false); + setData(payload); }) .catch((err: Error) => { errorApi.post(err); - setState({ - loading: false, - error: err, - data: undefined, - }); + setLoading(false); + setError(err); }); }, [getData, errorApi]); - return state; + return { data, loading, error }; }; -const RadarComponent: FC = props => { +const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { const { loading, error, data } = useTechRadarLoader(props); return ( diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 6006979e67..ff499f4575 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; +import { WithLink } from '../../utils/components'; type Props = { x: number; y: number; - number: number; + value: number; color: string; url?: string; moved?: number; @@ -43,14 +44,27 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarEntry: FC = props => { +const makeBlip = (color: string, moved?: number) => { + const style = { fill: color }; + + let blip = ; + if (moved && moved > 0) { + blip = ; // triangle pointing up + } else if (moved && moved < 0) { + blip = ; // triangle pointing down + } + + return blip; +}; + +const RadarEntry = (props: Props): JSX.Element => { const classes = useStyles(props); const { moved, color, url, - number, + value, x, y, onMouseEnter, @@ -58,24 +72,7 @@ const RadarEntry: FC = props => { onClick, } = props; - const style = { fill: color }; - - let blip; - if (moved && moved > 0) { - blip = ; // triangle pointing up - } else if (moved && moved < 0) { - blip = ; // triangle pointing down - } else { - blip = ; - } - - if (url) { - blip = ( - - {blip} - - ); - } + const blip = makeBlip(color, moved); return ( = props => { onMouseLeave={onMouseLeave} onClick={onClick} > - {blip} + + {blip} + - {number} + {value} ); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index 7d60a63f59..b6284644eb 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; type Props = { @@ -31,7 +31,7 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarFooter: FC = props => { +const RadarFooter = (props: Props): JSX.Element => { const { x, y } = props; const classes = useStyles(props); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 03146a8071..c2509e9cac 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import { Quadrant, Ring, Entry } from '../../utils/types'; +import { WithLink } from '../../utils/components'; type Segments = { [k: number]: { [k: number]: Entry[] }; @@ -29,20 +30,7 @@ type Props = { onEntryMouseLeave?: (entry: Entry) => void; }; -const useStyles = makeStyles(() => ({ - quadrant: { - height: '100%', - width: '100%', - overflow: 'hidden', - pointerEvents: 'none', - }, - quadrantHeading: { - pointerEvents: 'none', - userSelect: 'none', - marginTop: 0, - marginBottom: 'calc(18px * 0.375)', - fontSize: '18px', - }, +const ringStyles = { rings: { columns: 3, }, @@ -69,6 +57,25 @@ const useStyles = makeStyles(() => ({ '-webkit-font-feature-settings': 'pnum', 'font-feature-settings': 'pnum', }, +}; + +const quadrantStyles = { + quadrant: { + height: '100%', + width: '100%', + overflow: 'hidden', + pointerEvents: 'none', + }, + quadrantHeading: { + pointerEvents: 'none', + userSelect: 'none', + marginTop: 0, + marginBottom: 'calc(18px * 0.375)', + fontSize: '18px', + }, +}; + +const entryStyle = { entry: { pointerEvents: 'none', userSelect: 'none', @@ -77,12 +84,18 @@ const useStyles = makeStyles(() => ({ entryLink: { pointerEvents: 'none', }, +}; + +const useStyles = makeStyles(() => ({ + ringStyles, + quadrantStyles, + entryStyle, })); -const RadarLegend: FC = props => { +const RadarLegend = (props: Props): JSX.Element => { const classes = useStyles(props); - const _getSegment = ( + const getSegment = ( segmented: Segments, quadrant: Quadrant, ring: Ring, @@ -94,7 +107,7 @@ const RadarLegend: FC = props => { return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || []; }; - const _renderRing = ( + const renderRing = ( ring: Ring, entries: Entry[], onEntryMouseEnter?: Props['onEntryMouseEnter'], @@ -107,39 +120,29 @@ const RadarLegend: FC = props => {

(empty)

) : (
    - {entries.map(entry => { - let node = {entry.title}; - - if (entry.url) { - node = ( - - {node} - - ); - } - - return ( -
  1. onEntryMouseEnter(entry)) - } - onMouseLeave={ - onEntryMouseLeave && (() => onEntryMouseLeave(entry)) - } - > - {node} -
  2. - ); - })} + {entries.map(entry => ( +
  3. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + > + + {entry.title} + +
  4. + ))}
)}
); }; - const _renderQuadrant = ( + const renderQuadrant = ( segments: Segments, quadrant: Quadrant, rings: Ring[], @@ -158,9 +161,9 @@ const RadarLegend: FC = props => {

{quadrant.name}

{rings.map(ring => - _renderRing( + renderRing( ring, - _getSegment(segments, quadrant, ring), + getSegment(segments, quadrant, ring), onEntryMouseEnter, onEntryMouseLeave, ), @@ -171,7 +174,7 @@ const RadarLegend: FC = props => { ); }; - const _setupSegments = (entries: Entry[]) => { + const setupSegments = (entries: Entry[]) => { const segments: Segments = {}; for (const entry of entries) { @@ -209,12 +212,12 @@ const RadarLegend: FC = props => { onEntryMouseLeave, } = props; - const segments: Segments = _setupSegments(entries); + const segments: Segments = setupSegments(entries); return ( {quadrants.map(quadrant => - _renderQuadrant( + renderQuadrant( segments, quadrant, rings, diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 4c7236e01f..1aefb72eca 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import { Content, @@ -29,7 +29,7 @@ import { import RadarComponent from '../components/RadarComponent'; import { techRadarApiRef, TechRadarApi } from '../api'; -const RadarPage: FC<{}> = () => { +const RadarPage = (): JSX.Element => { const techRadarApi = useApi(techRadarApiRef); return ( diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 48ad660dde..0315afde5f 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Quadrant, Ring, Entry } from '../../utils/types'; import RadarGrid from '../RadarGrid'; @@ -36,7 +36,7 @@ type Props = { }; // A component that draws the radar circle. -const RadarPlot: FC = props => { +const RadarPlot = (props: Props): JSX.Element => { const { width, height, @@ -71,7 +71,7 @@ const RadarPlot: FC = props => { x={entry.x || 0} y={entry.y || 0} color={entry.color || ''} - number={((entry && entry.idx) || 0) + 1} + value={((entry && entry.idx) || 0) + 1} url={entry.url} moved={entry.moved} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} diff --git a/plugins/tech-radar/src/utils/components.tsx b/plugins/tech-radar/src/utils/components.tsx new file mode 100644 index 0000000000..af5a7b05bd --- /dev/null +++ b/plugins/tech-radar/src/utils/components.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 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'; + +type WithLinkProps = { + url?: string; + className: string; + children: React.ReactNode; +}; + +export const WithLink = ({ + url, + className, + children, +}: WithLinkProps): JSX.Element => + url ? ( + + {children} + + ) : ( + <>{children} + ); From 3d318efeaedb55ab59c3d899daaaf153828b9dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Fri, 19 Jun 2020 16:20:37 +0200 Subject: [PATCH 05/64] refactor(tech-radar): variable names + undefined check --- .../tech-radar/src/components/Radar/Radar.tsx | 6 ++-- .../tech-radar/src/components/Radar/utils.ts | 28 +++++++-------- .../src/components/RadarGrid/RadarGrid.tsx | 6 ++-- .../components/RadarLegend/RadarLegend.tsx | 35 ++++++++++--------- .../src/components/RadarPlot/RadarPlot.tsx | 2 +- plugins/tech-radar/src/utils/types.ts | 6 ++-- 6 files changed, 44 insertions(+), 39 deletions(-) diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index a1e4d2da78..8fe3c15fba 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -32,7 +32,7 @@ const Radar = (props: Props): JSX.Element => { const { width, height, quadrants, rings, entries } = props; const radius = Math.min(width, height) / 2; - const [activeEntry, setActiveEntry] = useState(); + const [activeEntry, setActiveEntry] = useState(); const node = useRef(null); // TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem @@ -49,9 +49,9 @@ const Radar = (props: Props): JSX.Element => { entries={entries} quadrants={quadrants} rings={rings} - activeEntry={activeEntry || undefined} + activeEntry={activeEntry} onEntryMouseEnter={entry => setActiveEntry(entry)} - onEntryMouseLeave={() => setActiveEntry(null)} + onEntryMouseLeave={() => setActiveEntry(undefined)} /> ); diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index 65268613fe..25d9e7dac8 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -81,14 +81,14 @@ export const adjustQuadrants = ( }, ]; - quadrants.forEach((quadrant, idx) => { - const legendParam = legendParams[idx % 4]; + quadrants.forEach((quadrant, index) => { + const legendParam = legendParams[index % 4]; - quadrant.idx = idx; - quadrant.radialMin = (idx * Math.PI) / 2; - quadrant.radialMax = ((idx + 1) * Math.PI) / 2; - quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1; - quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1; + quadrant.index = index; + quadrant.radialMin = (index * Math.PI) / 2; + quadrant.radialMax = ((index + 1) * Math.PI) / 2; + quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1; + quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1; quadrant.legendX = legendParam.x; quadrant.legendY = legendParam.y; quadrant.legendWidth = legendParam.width; @@ -101,10 +101,10 @@ export const adjustEntries = ( quadrants: Quadrant[], rings: Ring[], radius: number, - activeEntry?: Entry | null, + activeEntry?: Entry, ) => { let seed = 42; - entries.forEach((entry, idx) => { + entries.forEach((entry, index) => { const quadrant = quadrants.find(q => { const match = typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant; @@ -124,7 +124,7 @@ export const adjustEntries = ( throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`); } - entry.idx = idx; + entry.index = index; entry.quadrant = quadrant; entry.ring = ring; entry.segment = new Segment(quadrant, ring, radius, () => seed++); @@ -163,10 +163,10 @@ export const adjustEntries = ( }; export const adjustRings = (rings: Ring[], radius: number) => { - rings.forEach((ring, idx) => { - ring.idx = idx; - ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius; + rings.forEach((ring, index) => { + ring.index = index; + ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius; ring.innerRadius = - ((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius; + ((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius; }); }; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 514afbe958..68c84e2443 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => { const { radius, rings } = props; const classes = useStyles(props); - const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [ + const makeRingNode = (ringIndex: number, ringRadius?: number) => [ { />, ]; - const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); + const ringNodes = rings + .map(r => r.outerRadius) + .map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius)); return <>{axisNodes.concat(...ringNodes)}; }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index c2509e9cac..ad204f4beb 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -101,10 +101,13 @@ const RadarLegend = (props: Props): JSX.Element => { ring: Ring, ringOffset = 0, ) => { - const qidx = quadrant.idx; - const ridx = ring.idx; - const segmentedData = qidx === undefined ? {} : segmented[qidx] || {}; - return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || []; + const quadrantIndex = quadrant.index; + const ringIndex = ring.index; + const segmentedData = + quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {}; + return ringIndex === undefined + ? [] + : segmentedData[ringIndex + ringOffset] || []; }; const renderRing = ( @@ -116,14 +119,14 @@ const RadarLegend = (props: Props): JSX.Element => { return (

{ring.name}

- {entries.length === 0 ? ( + {!entries.length ? (

(empty)

) : (
    {entries.map(entry => (
  1. onEntryMouseEnter(entry)) } @@ -178,24 +181,24 @@ const RadarLegend = (props: Props): JSX.Element => { const segments: Segments = {}; for (const entry of entries) { - const qidx = entry.quadrant.idx; - const ridx = entry.ring.idx; + const quadrantIndex = entry.quadrant.index; + const ringIndex = entry.ring.index; let quadrantData: { [k: number]: Entry[] } = {}; - if (qidx !== undefined) { - if (segments[qidx] === undefined) { - segments[qidx] = {}; + if (quadrantIndex !== undefined) { + if (segments[quadrantIndex] === undefined) { + segments[quadrantIndex] = {}; } - quadrantData = segments[qidx]; + quadrantData = segments[quadrantIndex]; } let ringData = []; - if (ridx !== undefined) { - if (quadrantData[ridx] === undefined) { - quadrantData[ridx] = []; + if (ringIndex !== undefined) { + if (quadrantData[ringIndex] === undefined) { + quadrantData[ringIndex] = []; } - ringData = quadrantData[ridx]; + ringData = quadrantData[ringIndex]; } ringData.push(entry); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 0315afde5f..7fd46b95b7 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -71,7 +71,7 @@ const RadarPlot = (props: Props): JSX.Element => { x={entry.x || 0} y={entry.y || 0} color={entry.color || ''} - value={((entry && entry.idx) || 0) + 1} + value={((entry && entry.index) || 0) + 1} url={entry.url} moved={entry.moved} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 14a0554a1f..51f3cee7f8 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -17,7 +17,7 @@ // Parameters for a ring; its index in an array determines how close to the center this ring is. export type Ring = { id: string; - idx?: number; + index?: number; name: string; color: string; outerRadius?: number; @@ -27,7 +27,7 @@ export type Ring = { // Parameters for a quadrant (there should be exactly 4 of course) export type Quadrant = { id: string; - idx?: number; + index?: number; name: string; legendX?: number; legendY?: number; @@ -47,7 +47,7 @@ export type Segment = { export type Entry = { id: string; - idx?: number; + index?: number; x?: number; y?: number; color?: string; From 4cae750c72875087988e0818f8d830130eaf61d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Fri, 19 Jun 2020 22:46:23 +0200 Subject: [PATCH 06/64] refactor(tech-radar): update moved prop type --- plugins/tech-radar/src/api.ts | 2 +- plugins/tech-radar/src/components/Radar/Radar.test.tsx | 4 ++-- plugins/tech-radar/src/components/Radar/Radar.tsx | 2 +- .../src/components/RadarBubble/RadarBubble.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx | 2 +- .../tech-radar/src/components/RadarEntry/RadarEntry.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx | 2 +- .../src/components/RadarFooter/RadarFooter.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx | 2 +- .../tech-radar/src/components/RadarGrid/RadarGrid.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx | 2 +- .../src/components/RadarLegend/RadarLegend.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx | 2 +- .../tech-radar/src/components/RadarPlot/RadarPlot.test.tsx | 4 ++-- plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx | 2 +- plugins/tech-radar/src/utils/types.ts | 2 +- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 3dec964ddb..d7f5b57a43 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -34,7 +34,7 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: number; + moved: -1 | 0 | 1; quadrant: RadarQuadrant; ring: RadarRing; title: string; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index 469f0d85cf..f0ad0d66ed 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import Radar from './Radar'; +import Radar, { Props } from './Radar'; -const minProps = { +const minProps: Props = { width: 500, height: 200, quadrants: [{ id: 'languages', name: 'Languages' }], diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 8fe3c15fba..2c7838e803 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -19,7 +19,7 @@ import RadarPlot from '../RadarPlot'; import { Ring, Quadrant, Entry } from '../../utils/types'; import { adjustQuadrants, adjustRings, adjustEntries } from './utils'; -type Props = { +export type Props = { width: number; height: number; quadrants: Quadrant[]; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx index 688fd55f9f..9e402b9a91 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarBubble from './RadarBubble'; +import RadarBubble, { Props } from './RadarBubble'; -const minProps = { +const minProps: Props = { visible: true, text: 'RadarBubble', x: 2, diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index 723a408885..be38231087 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -17,7 +17,7 @@ import React, { useRef, useLayoutEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { visible: boolean; text: string; x: number; diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx index 20bc6fa7be..14614e88e1 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarEntry from './RadarEntry'; +import RadarEntry, { Props } from './RadarEntry'; -const minProps = { +const minProps: Props = { x: 2, y: 2, value: 2, diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index ff499f4575..98841dce87 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import { WithLink } from '../../utils/components'; -type Props = { +export type Props = { x: number; y: number; value: number; diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx index 3e0ee7e407..321947a24c 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarFooter from './RadarFooter'; +import RadarFooter, { Props } from './RadarFooter'; -const minProps = { +const minProps: Props = { x: 2, y: 2, }; diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index b6284644eb..9ba0ed0405 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { x: number; y: number; }; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx index 8a2873564d..9ba170a0e7 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarGrid from './RadarGrid'; +import RadarGrid, { Props } from './RadarGrid'; -const minProps = { +const minProps: Props = { radius: 5, rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], }; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 68c84e2443..cd06bf6ef8 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import { Ring } from '../../utils/types'; -type Props = { +export type Props = { radius: number; rings: Ring[]; }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx index 8168688fdd..5900f5c1bc 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarLegend from './RadarLegend'; +import RadarLegend, { Props } from './RadarLegend'; -const minProps = { +const minProps: Props = { quadrants: [{ id: 'languages', name: 'Languages' }], rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], entries: [ diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index ad204f4beb..59bd2e2337 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -22,7 +22,7 @@ type Segments = { [k: number]: { [k: number]: Entry[] }; }; -type Props = { +export type Props = { quadrants: Quadrant[]; rings: Ring[]; entries: Entry[]; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx index 31f450baeb..876d3009e4 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import RadarPlot from './RadarPlot'; +import RadarPlot, { Props } from './RadarPlot'; -const minProps = { +const minProps: Props = { width: 500, height: 200, radius: 50, diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 7fd46b95b7..c1ce70eecc 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble'; import RadarFooter from '../RadarFooter'; import RadarLegend from '../RadarLegend'; -type Props = { +export type Props = { width: number; height: number; radius: number; diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 51f3cee7f8..5ee27c29c7 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -61,7 +61,7 @@ export type Entry = { // An URL to a longer description as to why this entry is where it is url?: string; // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved - moved?: number; + moved?: -1 | 0 | 1; active?: boolean; }; From 984f777b677157b37603f226d8b081b55c4a240e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Sat, 20 Jun 2020 17:45:12 +0200 Subject: [PATCH 07/64] refactor(tech-radar): update test + RadarLegend refacto --- .../RadarBubble/RadarBubble.test.tsx | 2 +- .../components/RadarBubble/RadarBubble.tsx | 1 + .../components/RadarEntry/RadarEntry.test.tsx | 6 +- .../src/components/RadarEntry/RadarEntry.tsx | 1 + .../RadarFooter/RadarFooter.test.tsx | 6 +- .../components/RadarFooter/RadarFooter.tsx | 6 +- .../components/RadarGrid/RadarGrid.test.tsx | 3 +- .../src/components/RadarGrid/RadarGrid.tsx | 2 + .../RadarLegend/RadarLegend.test.tsx | 4 +- .../components/RadarLegend/RadarLegend.tsx | 84 +++++++++++-------- .../components/RadarPlot/RadarPlot.test.tsx | 6 +- .../src/components/RadarPlot/RadarPlot.tsx | 2 +- 12 files changed, 81 insertions(+), 42 deletions(-) diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx index 9e402b9a91..d56ca743c2 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -47,6 +47,6 @@ describe('RadarBubble', () => { , ); - expect(rendered).not.toBeNull(); + expect(rendered.getByText(minProps.text)).toBeInTheDocument(); }); }); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index be38231087..abaed92253 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -98,6 +98,7 @@ const RadarBubble = (props: Props): JSX.Element => { x={0} y={0} className={visible ? classes.visibleBubble : classes.bubble} + data-testid="radar-bubble" > diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx index 14614e88e1..2d24f301d1 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -47,6 +47,10 @@ describe('RadarEntry', () => { , ); - expect(rendered).not.toBeNull(); + const radarEntry = rendered.getByTestId('radar-entry'); + const { x, y } = minProps; + expect(radarEntry).toBeInTheDocument(); + expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + expect(rendered.getByText(String(minProps.value))).toBeInTheDocument(); }); }); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 98841dce87..29d35e01e2 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -80,6 +80,7 @@ const RadarEntry = (props: Props): JSX.Element => { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} + data-testid="radar-entry" > {blip} diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx index 321947a24c..77f21b6e63 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -44,7 +44,9 @@ describe('RadarFooter', () => { , ); - - expect(rendered).not.toBeNull(); + const radarFooter = rendered.getByTestId('radar-footer'); + const { x, y } = minProps; + expect(radarFooter).toBeInTheDocument(); + expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`); }); }); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index 9ba0ed0405..ced1ab8054 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -36,7 +36,11 @@ const RadarFooter = (props: Props): JSX.Element => { const classes = useStyles(props); return ( - + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} ); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx index 9ba170a0e7..3027cc4adb 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -45,6 +45,7 @@ describe('RadarGrid', () => { , ); - expect(rendered).not.toBeNull(); + expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument(); }); }); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index cd06bf6ef8..63c164bc7d 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -76,6 +76,7 @@ const RadarGrid = (props: Props) => { x2={0} y2={radius} className={classes.axis} + data-testid="radar-grid-x-line" />, // Y axis { x2={radius} y2={0} className={classes.axis} + data-testid="radar-grid-y-line" />, ]; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx index 5900f5c1bc..68bfe892ea 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -55,6 +55,8 @@ describe('RadarLegend', () => { , ); - expect(rendered).not.toBeNull(); + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1); + expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1); }); }); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 59bd2e2337..57a453205a 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; import { WithLink } from '../../utils/components'; type Segments = { @@ -110,14 +110,21 @@ const RadarLegend = (props: Props): JSX.Element => { : segmentedData[ringIndex + ringOffset] || []; }; - const renderRing = ( - ring: Ring, - entries: Entry[], - onEntryMouseEnter?: Props['onEntryMouseEnter'], - onEntryMouseLeave?: Props['onEntryMouseEnter'], - ) => { + type RadarLegendRingProps = { + ring: Ring; + entries: Entry[]; + onEntryMouseEnter?: Props['onEntryMouseEnter']; + onEntryMouseLeave?: Props['onEntryMouseEnter']; + }; + + const RadarLegendRing = ({ + ring, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendRingProps) => { return ( -
    +

    {ring.name}

    {!entries.length ? (

    (empty)

    @@ -145,13 +152,21 @@ const RadarLegend = (props: Props): JSX.Element => { ); }; - const renderQuadrant = ( - segments: Segments, - quadrant: Quadrant, - rings: Ring[], - onEntryMouseEnter: Props['onEntryMouseEnter'], - onEntryMouseLeave: Props['onEntryMouseLeave'], - ) => { + type RadarLegendQuadrantProps = { + segments: Segments; + quadrant: Quadrant; + rings: Ring[]; + onEntryMouseEnter: Props['onEntryMouseEnter']; + onEntryMouseLeave: Props['onEntryMouseLeave']; + }; + + const RadarLegendQuadrant = ({ + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendQuadrantProps) => { return ( { y={quadrant.legendY} width={quadrant.legendWidth} height={quadrant.legendHeight} + data-testid="radar-quadrant" >

    {quadrant.name}

    - {rings.map(ring => - renderRing( - ring, - getSegment(segments, quadrant, ring), - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + {rings.map(ring => ( + + ))}
    @@ -218,16 +235,17 @@ const RadarLegend = (props: Props): JSX.Element => { const segments: Segments = setupSegments(entries); return ( - - {quadrants.map(quadrant => - renderQuadrant( - segments, - quadrant, - rings, - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + + {quadrants.map(quadrant => ( + + ))} ); }; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx index 876d3009e4..5b17578478 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -58,6 +58,10 @@ describe('RadarPlot', () => { , ); - expect(rendered).not.toBeNull(); + expect(rendered.getByTestId('radar-plot')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-footer')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1); }); }); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index c1ce70eecc..f1b41373ca 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -50,7 +50,7 @@ const RadarPlot = (props: Props): JSX.Element => { } = props; return ( - + Date: Sat, 20 Jun 2020 18:41:12 +0200 Subject: [PATCH 08/64] fix(tech-radar): moved @types/react to devDependencies --- plugins/tech-radar/package.json | 2 +- plugins/tech-radar/src/components/Radar/Radar.tsx | 2 +- plugins/tech-radar/src/components/Radar/utils.ts | 2 +- plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx | 2 +- plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 16a36dba5d..ee9ce910ec 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -27,7 +27,6 @@ "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "color": "^3.1.2", "d3-force": "^2.0.1", "prop-types": "^15.7.2", @@ -43,6 +42,7 @@ "@testing-library/user-event": "^10.2.4", "@types/color": "^3.0.1", "@types/d3-force": "^1.2.1", + "@types/react": "^16.9", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.0.4", diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 2c7838e803..124e8bb68a 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -16,7 +16,7 @@ import React, { useState, useRef } from 'react'; import RadarPlot from '../RadarPlot'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; import { adjustQuadrants, adjustRings, adjustEntries } from './utils'; export type Props = { diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index 25d9e7dac8..d7a826543c 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -17,7 +17,7 @@ import color from 'color'; import { forceCollide, forceSimulation } from 'd3-force'; import Segment from '../../utils/segment'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; export const adjustQuadrants = ( quadrants: Quadrant[], diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 63c164bc7d..eae85d4e64 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Ring } from '../../utils/types'; +import type { Ring } from '../../utils/types'; export type Props = { radius: number; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index f1b41373ca..1e28cbaf02 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; import RadarGrid from '../RadarGrid'; import RadarEntry from '../RadarEntry'; From fac4893555ec2f4bccb15a1beffd7b8fc5e27853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Sat, 20 Jun 2020 18:52:15 +0200 Subject: [PATCH 09/64] fix(tech-radar): missing state setters --- plugins/tech-radar/src/components/RadarComponent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 8d56c6aa0f..86ef10e07d 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -37,11 +37,13 @@ const useTechRadarLoader = (props: TechRadarComponentProps) => { .then((payload: TechRadarLoaderResponse) => { setLoading(false); setData(payload); + setError(undefined); }) .catch((err: Error) => { errorApi.post(err); setLoading(false); setError(err); + setData(undefined); }); }, [getData, errorApi]); From 542d209a6bf20be666f562abe01b4c5adefff9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Sat, 20 Jun 2020 23:45:25 +0200 Subject: [PATCH 10/64] refactor(tech-radar): requested changes --- .../src/components/RadarComponent.tsx | 38 +++++++--------- .../components/RadarLegend/RadarLegend.tsx | 44 +++++++------------ .../src/components/RadarPlot/RadarPlot.tsx | 8 ++-- 3 files changed, 36 insertions(+), 54 deletions(-) diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 86ef10e07d..99190202b9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -14,44 +14,38 @@ * limitations under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core'; +import { useAsync } from 'react-use'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; import getSampleData from '../sampleData'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); - const [error, setError] = useState(); - const [loading, setLoading] = useState(true); - const [data, setData] = useState(); const { getData } = props; - useEffect(() => { - if (!getData) { - return; + const state = useAsync(async () => { + if (getData) { + const response: TechRadarLoaderResponse = await getData(); + return response; } - - getData() - .then((payload: TechRadarLoaderResponse) => { - setLoading(false); - setData(payload); - setError(undefined); - }) - .catch((err: Error) => { - errorApi.post(err); - setLoading(false); - setError(err); - setData(undefined); - }); + return undefined; }, [getData, errorApi]); - return { data, loading, error }; + useEffect(() => { + const { error } = state; + if (error) { + errorApi.post(error); + } + }, [errorApi, state]); + + return state; }; const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { - const { loading, error, data } = useTechRadarLoader(props); + const { loading, error, value: data } = useTechRadarLoader(props); return ( <> diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 57a453205a..e319846d78 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -30,7 +30,20 @@ export type Props = { onEntryMouseLeave?: (entry: Entry) => void; }; -const ringStyles = { +const useStyles = makeStyles(theme => ({ + quadrant: { + height: '100%', + width: '100%', + overflow: 'hidden', + pointerEvents: 'none', + }, + quadrantHeading: { + pointerEvents: 'none', + userSelect: 'none', + marginTop: 0, + marginBottom: theme.spacing(8 / (18 * 0.375)), + fontSize: '18px', + }, rings: { columns: 3, }, @@ -44,7 +57,7 @@ const ringStyles = { pointerEvents: 'none', userSelect: 'none', marginTop: 0, - marginBottom: 'calc(12px * 0.375)', + marginBottom: theme.spacing(8 / (12 * 0.375)), fontSize: '12px', fontWeight: 800, }, @@ -57,25 +70,6 @@ const ringStyles = { '-webkit-font-feature-settings': 'pnum', 'font-feature-settings': 'pnum', }, -}; - -const quadrantStyles = { - quadrant: { - height: '100%', - width: '100%', - overflow: 'hidden', - pointerEvents: 'none', - }, - quadrantHeading: { - pointerEvents: 'none', - userSelect: 'none', - marginTop: 0, - marginBottom: 'calc(18px * 0.375)', - fontSize: '18px', - }, -}; - -const entryStyle = { entry: { pointerEvents: 'none', userSelect: 'none', @@ -84,12 +78,6 @@ const entryStyle = { entryLink: { pointerEvents: 'none', }, -}; - -const useStyles = makeStyles(() => ({ - ringStyles, - quadrantStyles, - entryStyle, })); const RadarLegend = (props: Props): JSX.Element => { @@ -126,7 +114,7 @@ const RadarLegend = (props: Props): JSX.Element => { return (

    {ring.name}

    - {!entries.length ? ( + {entries.length === 0 ? (

    (empty)

    ) : (
      diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 1e28cbaf02..7f68ffe021 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -71,7 +71,7 @@ const RadarPlot = (props: Props): JSX.Element => { x={entry.x || 0} y={entry.y || 0} color={entry.color || ''} - value={((entry && entry.index) || 0) + 1} + value={(entry?.index || 0) + 1} url={entry.url} moved={entry.moved} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} @@ -80,9 +80,9 @@ const RadarPlot = (props: Props): JSX.Element => { ))} From 1baa5309a17111633aea2e3e7f09a29df4b9ad5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Wed, 24 Jun 2020 14:03:03 +0200 Subject: [PATCH 11/64] refactor(tech-radar): add moved type --- plugins/tech-radar/src/api.ts | 3 ++- plugins/tech-radar/src/utils/types.ts | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index d7f5b57a43..856cf4ff3c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '@backstage/core'; +import { MovedState } from './utils/types'; /** * Types related to the Radar's visualization. @@ -34,7 +35,7 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: -1 | 0 | 1; + moved: MovedState; quadrant: RadarQuadrant; ring: RadarRing; title: string; diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 5ee27c29c7..f3933ee584 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -45,6 +45,12 @@ export type Segment = { random: Function; }; +export enum MovedState { + Down = -1, + NoChange = 0, + Up = 1, +} + export type Entry = { id: string; index?: number; @@ -61,7 +67,7 @@ export type Entry = { // An URL to a longer description as to why this entry is where it is url?: string; // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved - moved?: -1 | 0 | 1; + moved?: MovedState; active?: boolean; }; From 1088b916e69c7ba046d2f12367a1bf0ffe9a16a1 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Wed, 24 Jun 2020 23:36:09 +0200 Subject: [PATCH 12/64] feat(scaffolder): tags as list --- .../sample-templates/react-ssr-template/template.yaml | 5 +++-- .../sample-templates/springboot-template/template.yaml | 5 +++-- plugins/scaffolder/src/components/ScaffolderPage/index.tsx | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml index 058e2a715f..124d1575ce 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml @@ -4,8 +4,9 @@ metadata: name: react-ssr-template title: React SSR Template description: Next.js application skeleton for creating isomorphic web applications. - annotations: - tags: Recommended,React + tags: + - Recommended + - React spec: processor: cookiecutter type: website diff --git a/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml b/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml index d44d926ef4..681e8e7f51 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml @@ -4,8 +4,9 @@ metadata: name: springboot-template title: Spring Boot Service description: Standard Spring Boot (Java) microservice with recommended configuration. - annotations: - tags: Recommended,Java + tags: + - Recommended + - Java spec: processor: cookiecutter type: service diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index a6dbac8f83..aa2a8b639a 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -103,8 +103,7 @@ const ScaffolderPage: React.FC<{}> = () => { }`} type={template.spec.type ?? ''} description={template.metadata.description ?? '-'} - // TODO(shmidt-i): how to store tags - tags={template.metadata.annotations?.tags.split(',') ?? []} + tags={template.metadata?.tags ?? []} /> ); From 515d64e40a657ca0120a7b97a2db010b787dab8f Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Wed, 24 Jun 2020 22:55:14 -0400 Subject: [PATCH 13/64] Add okta integration --- plugins/auth-backend/package.json | 2 + .../auth-backend/src/providers/factories.ts | 2 + .../auth-backend/src/providers/okta/index.ts | 16 ++ .../src/providers/okta/provider.ts | 208 ++++++++++++++++++ .../src/providers/okta/types.d.ts | 22 ++ plugins/auth-backend/src/providers/types.ts | 4 + plugins/auth-backend/src/service/router.ts | 9 + 7 files changed, 263 insertions(+) create mode 100644 plugins/auth-backend/src/providers/okta/index.ts create mode 100644 plugins/auth-backend/src/providers/okta/provider.ts create mode 100644 plugins/auth-backend/src/providers/okta/types.d.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 2de098c7eb..d137357d60 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -40,6 +40,8 @@ "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.5.0", + "passport-okta-oauth": "^0.0.1", "passport-saml": "^1.3.3", "uuid": "^8.0.0", "winston": "^3.2.1", diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 5096b0698a..9feefdac9f 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -18,6 +18,7 @@ import Router from 'express-promise-router'; import { createGithubProvider } from './github'; import { createGoogleProvider } from './google'; import { createSamlProvider } from './saml'; +import { createOktaProvider } from './okta'; import { AuthProviderFactory, AuthProviderConfig } from './types'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; @@ -26,6 +27,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, github: createGithubProvider, saml: createSamlProvider, + okta: createOktaProvider, }; export const createAuthProviderRouter = ( diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts new file mode 100644 index 0000000000..bc32601ac2 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 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 { createOktaProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts new file mode 100644 index 0000000000..7b3d5770d7 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2020 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 express, { Request } from 'express'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { Strategy as OktaStrategy } from 'passport-okta-oauth'; +import passport from 'passport'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, +} from '../../lib/PassportStrategyHelper'; +import { + OAuthProviderHandlers, + RedirectInfo, + AuthProviderConfig, + EnvironmentProviderConfig, + OAuthProviderOptions, + OAuthProviderConfig, + OAuthResponse, + PassportDoneCallback, +} from '../types'; +import { + EnvironmentHandler, + EnvironmentHandlers, +} from '../../lib/EnvironmentHandler'; +import { Logger } from 'winston'; +import { StateStore } from 'passport-oauth2'; +import { TokenIssuer } from '../../identity'; + +type PrivateInfo = { + refreshToken: string; +}; + +export class OktaAuthProvider implements OAuthProviderHandlers { + + private readonly _strategy: any; + + /** + * Due to passport-okta-oauth forcing options.state = true, + * passport-oauth2 requires express-session to be installed + * so that the 'state' parameter of the oauth2 flow can be stored. + * This implementation of StateStore matches the NullStore found within + * passport-oauth2, which is the StateStore implementation used when options.state = false, + * allowing us to avoid using express-session in order to integrate with Okta. + */ + private _store: StateStore = { + store(req: Request, cb: any) { + cb(null, null); + }, + verify(req: Request, state: string, cb: any) { + cb(null, true); + }, + } + + constructor(options: OAuthProviderOptions) { + this._strategy = new OktaStrategy({ + passReqToCallback: false as true, + ...options, + store: this._store, + response_type: 'code', + }, ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ) + }); + } + + async start( + req: express.Request, + options: Record + ): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler( + req: express.Request + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(refreshToken: string, scope: string): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + // TODO(Rugvip): Hardcoded to the local part of the email for now + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createOktaProvider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const config = (envConfig as unknown) as OAuthProviderConfig; + const { secure, appOrigin } = config; + const callbackURLParam = `?env=${env}`; + const opts = { + audience: config.audience, + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${baseUrl}/okta/handler/frame${callbackURLParam}`, + }; + + if (!opts.clientID || !opts.clientSecret || !opts.audience) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', + ); + } + + logger.warn( + 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), { + disableRefresh: false, + providerId: 'okta', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/okta/types.d.ts b/plugins/auth-backend/src/providers/okta/types.d.ts new file mode 100644 index 0000000000..bed6d24043 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/types.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 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. + */ +declare module 'passport-okta-oauth' { + + export class Strategy { + constructor(options: any, verify: any) + } +} + \ No newline at end of file diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 56d4db79fa..aec4f9dfd0 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -55,6 +55,10 @@ export type OAuthProviderConfig = { * Client Secret of the auth provider. */ clientSecret: string; + /** + * The location of the OAuth Authorization Server + */ + audience?: string; }; export type EnvironmentProviderConfig = { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 28718e85a1..b9c0ff4496 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -82,6 +82,15 @@ export async function createRouter( issuer: 'passport-saml', }, }, + okta: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_OKTA_CLIENT_ID!, + clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, + audience: process.env.AUTH_OKTA_AUDIENCE, + } + } }, }, }; From 907371ffe09e9695397298238ae72f9298106582 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Wed, 24 Jun 2020 22:56:10 -0400 Subject: [PATCH 14/64] Add ui implementation for okta sso integration --- packages/app/src/App.tsx | 2 +- packages/app/src/apis.ts | 11 ++ .../core-api/src/apis/definitions/auth.ts | 17 ++ .../src/apis/implementations/auth/index.ts | 1 + .../auth/okta/OktaAuth.test.ts | 111 ++++++++++++ .../implementations/auth/okta/OktaAuth.ts | 168 ++++++++++++++++++ .../apis/implementations/auth/okta/index.ts | 18 ++ .../apis/implementations/auth/okta/types.ts | 27 +++ .../core/src/layout/Sidebar/UserSettings.tsx | 6 + .../src/layout/SignInPage/oktaProvider.tsx | 93 ++++++++++ .../core/src/layout/SignInPage/providers.tsx | 4 +- yarn.lock | 35 +++- 12 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/index.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/types.ts create mode 100644 packages/core/src/layout/SignInPage/oktaProvider.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index f4515cbf01..ccaeb2828b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -31,7 +31,7 @@ const app = createApp({ plugins: Object.values(plugins), components: { SignInPage: props => ( - + ), }, }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 5011b16eaf..d2b2706464 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,10 +26,12 @@ import { FeatureFlags, GoogleAuth, GithubAuth, + OktaAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oktaAuthApiRef, storageApiRef, WebStorage, } from '@backstage/core'; @@ -91,6 +93,15 @@ export const apis = (config: ConfigApi) => { }), ); + builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); + builder.add( techRadarApiRef, new TechRadar({ diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index d5c0e9c0a4..f82352cbc8 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -233,3 +233,20 @@ export const githubAuthApiRef = createApiRef< id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); + +/** + * Provides authentication towards Okta APIs. + * + * See https://developer.okta.com/docs/reference/api/oidc/ + * for a full list of supported scopes. + */ +export const oktaAuthApiRef = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi +>({ + id: 'core.auth.okta', + description: 'Provides authentication towards Okta APIs', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index f13368b5c4..4fa992dfb5 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -16,3 +16,4 @@ export * from './google'; export * from './github'; +export * from './okta'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..7f2bd5d6bc --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 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 OktaAuth from './OktaAuth'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +describe('OktaAuth', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`not-a-scope`]), + }, + }) + .mockRejectedValue(error); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oktaAuth.getAccessToken('more'); + const promise2 = oktaAuth.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Grab the expired session first + await expect(oktaAuth.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oktaAuth.getIdToken(); + const promise2 = oktaAuth.getIdToken(); + const promise3 = oktaAuth.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..55820375f6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 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 OktaIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { OktaSession } from './types'; +import { + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + ProfileInfo, + SessionStateApi, + SessionState, + BackstageIdentityApi, + AuthRequestOptions, + BackstageIdentity, +} from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type OktaAuthResponse = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'okta', + title: 'Okta', + icon: OktaIcon, +}; + +class OktaAuth implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi +{ + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OktaAuthResponse): OktaSession { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OktaAuth.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + 'email', + 'profile', + 'offline_access', + ]), + sessionScopes: session => session.scopes, + sessionShouldRefresh: session => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OktaAuth(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken( + scope?: string, + options?: AuthRequestOptions + ) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: OktaAuth.normalizeScopes(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} + +export default OktaAuth; \ No newline at end of file diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..2bef0ce0db --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 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 './types'; +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/types.ts b/packages/core-api/src/apis/implementations/auth/okta/types.ts new file mode 100644 index 0000000000..e3392ba1d6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type OktaSession = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 36e8e1088b..668cb1354a 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -23,6 +23,7 @@ import { googleAuthApiRef, githubAuthApiRef, identityApiRef, + oktaAuthApiRef, useApi, } from '@backstage/core-api'; import { @@ -56,6 +57,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + { + const oktaAuthApi = useApi(oktaAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await oktaAuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await oktaAuthApi.getProfile(); + + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Okta + + + ); +}; + +const loader: ProviderLoader = async apis => { + const oktaAuthApi = apis.get(oktaAuthApiRef)!; + + const identity = await oktaAuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await oktaAuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }; +}; + +export const oktaProvider: SignInProvider = { Component, loader }; \ No newline at end of file diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 96dbc30a89..a195b2042a 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -18,6 +18,7 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { guestProvider } from './guestProvider'; import { googleProvider } from './googleProvider'; import { customProvider } from './customProvider'; +import { oktaProvider } from './oktaProvider'; import { SignInPageProps, SignInResult, @@ -30,12 +31,13 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = 'guest' | 'google' | 'custom'; +export type SignInProviderId = 'guest' | 'google' | 'custom' | 'okta'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, google: googleProvider, custom: customProvider, + okta: oktaProvider, }; export const useSignInProviders = ( diff --git a/yarn.lock b/yarn.lock index dc34848cba..873cbb3582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14239,7 +14239,16 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" -passport-oauth2@1.x.x: +passport-oauth1@1.x.x: + version "1.1.0" + resolved "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918" + integrity sha1-p96YiiEfnPRoc3cTDqdN8ycwyRg= + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + utils-merge "1.x.x" + +passport-oauth2@1.x.x, passport-oauth2@^1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== @@ -14250,6 +14259,23 @@ passport-oauth2@1.x.x: uid2 "0.0.x" utils-merge "1.x.x" +passport-oauth@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df" + integrity sha1-kK/2M4dUDwIImvKM2tOep/gNd98= + dependencies: + passport-oauth1 "1.x.x" + passport-oauth2 "1.x.x" + +passport-okta-oauth@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/passport-okta-oauth/-/passport-okta-oauth-0.0.1.tgz#c8bcee02af3d56ca79d3cca776f2df7cf15a5748" + integrity sha1-yLzuAq89Vsp508yndvLffPFaV0g= + dependencies: + passport-oauth "1.0.0" + pkginfo "0.2.x" + uid2 "0.0.3" + passport-saml@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" @@ -14498,6 +14524,11 @@ pkg-up@3.1.0, pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.npmjs.org/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha1-cjnEKl72wwuPMoQ52bn/cQQkkPg= + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -18517,7 +18548,7 @@ uid-number@0.0.6: resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= -uid2@0.0.x: +uid2@0.0.3, uid2@0.0.x: version "0.0.3" resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" integrity sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I= From e73f3fbab7c221e4b99c20d9b08b7a3de340878c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 10:58:33 +0200 Subject: [PATCH 15/64] cli: implement postpack --- packages/cli/src/commands/pack.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts index 51c7e36086..1f81b89767 100644 --- a/packages/cli/src/commands/pack.ts +++ b/packages/cli/src/commands/pack.ts @@ -19,10 +19,15 @@ import { paths } from '../lib/paths'; const SKIPPED_KEYS = ['access', 'registry', 'tag']; -export const pre = async () => { - const pkgPath = paths.resolveTarget('package.json'); +const PKG_PATH = 'package.json'; +const PKG_BACKUP_PATH = 'package.json-prepack'; - const pkg = await fs.readJson(pkgPath); +export const pre = async () => { + const pkgPath = paths.resolveTarget(PKG_PATH); + + const pkgContent = await fs.readFile(pkgPath, 'utf8'); + const pkg = JSON.parse(pkgContent); + await fs.writeFile(PKG_BACKUP_PATH, pkgContent); for (const key of Object.keys(pkg.publishConfig ?? {})) { if (!SKIPPED_KEYS.includes(key)) { @@ -33,5 +38,6 @@ export const pre = async () => { }; export const post = async () => { - // postpack is a noop for now, since it's not called anyway + // postpack isn't called by yarn right now, so it needs to be called manually + await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true }); }; From c9a1b99af21f8caf61084c84eb547f66a9ebd87a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 11:39:28 +0200 Subject: [PATCH 16/64] cli: rename packager to builder --- packages/cli/src/commands/backend/build.ts | 2 +- packages/cli/src/commands/build.ts | 2 +- packages/cli/src/commands/plugin/build.ts | 2 +- packages/cli/src/lib/{packager => builder}/config.ts | 0 packages/cli/src/lib/{packager => builder}/index.ts | 0 packages/cli/src/lib/{packager => builder}/packager.ts | 0 packages/cli/src/lib/{packager => builder}/types.ts | 0 7 files changed, 3 insertions(+), 3 deletions(-) rename packages/cli/src/lib/{packager => builder}/config.ts (100%) rename packages/cli/src/lib/{packager => builder}/index.ts (100%) rename packages/cli/src/lib/{packager => builder}/packager.ts (100%) rename packages/cli/src/lib/{packager => builder}/types.ts (100%) diff --git a/packages/cli/src/commands/backend/build.ts b/packages/cli/src/commands/backend/build.ts index a9cdc51fa9..ceca5b0286 100644 --- a/packages/cli/src/commands/backend/build.ts +++ b/packages/cli/src/commands/backend/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../../lib/packager'; +import { buildPackage, Output } from '../../lib/builder'; export default async () => { await buildPackage({ diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 19e4b17352..bd5bbc5e9f 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../lib/packager'; +import { buildPackage, Output } from '../lib/builder'; import { Command } from 'commander'; export default async (cmd: Command) => { diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 7e4e5cd36a..d62ffbeebd 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../../lib/packager'; +import { buildPackage, Output } from '../../lib/builder'; export default async () => { await buildPackage({ diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/builder/config.ts similarity index 100% rename from packages/cli/src/lib/packager/config.ts rename to packages/cli/src/lib/builder/config.ts diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/builder/index.ts similarity index 100% rename from packages/cli/src/lib/packager/index.ts rename to packages/cli/src/lib/builder/index.ts diff --git a/packages/cli/src/lib/packager/packager.ts b/packages/cli/src/lib/builder/packager.ts similarity index 100% rename from packages/cli/src/lib/packager/packager.ts rename to packages/cli/src/lib/builder/packager.ts diff --git a/packages/cli/src/lib/packager/types.ts b/packages/cli/src/lib/builder/types.ts similarity index 100% rename from packages/cli/src/lib/packager/types.ts rename to packages/cli/src/lib/builder/types.ts From 686018f81161fb016ae89dc4c589c5f73748f546 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 08:44:55 -0400 Subject: [PATCH 17/64] Add correct link for okta resouce scopes --- packages/core-api/src/apis/definitions/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index f82352cbc8..a944e85a7e 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -237,7 +237,7 @@ export const githubAuthApiRef = createApiRef< /** * Provides authentication towards Okta APIs. * - * See https://developer.okta.com/docs/reference/api/oidc/ + * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ * for a full list of supported scopes. */ export const oktaAuthApiRef = createApiRef< From f41868134114cd742303876fc983596defee68a6 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Thu, 25 Jun 2020 17:18:46 +0200 Subject: [PATCH 18/64] feat(techdocs-reader): fetch and basic transform on html --- .../techdocs/src/reader/components/Reader.tsx | 832 ++---------------- 1 file changed, 49 insertions(+), 783 deletions(-) diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index e306a72424..5517c0c715 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -16,796 +16,62 @@ import React from 'react'; import { useShadowDom } from '..'; - -const mockHtml: string = ` - - - - - - - - - - - - - - - - - - - - - - - - - - Download boilerplate - MkDocs Material Boilerplate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      - -
      - -
      - -
      - - - - - - - - - - - - -
      -
      - - -
      -
      -
      - -
      -
      -
      - - -
      -
      -
      - - -
      -
      -
      - - -
      -
      - - - - - - - - - - -

      Download boilerplate

      -

      Git clone

      -
      git clone https://github.com/peaceiris/mkdocs-material-boilerplate.git
      -cd mkdocs-material-boilerplate
      -
      - - -

      Download zip

      -
      wget 'https://github.com/peaceiris/mkdocs-material-boilerplate/archive/master.zip'
      -unzip master.zip
      -cd mkdocs-material-boilerplate-master
      -
      - - -

      👉 Click me to download zip

      - - - - - - - -
      -
      -
      -
      - - - - -
      - - - - - - - - - - - -`; +import { useAsync } from 'react-use'; + +const useFetch = (url: string) => { + const state = useAsync(async () => { + const response = await fetch(url); + const raw = await response.text(); + return raw; + }, [url]); + + return state; +}; + +const addBaseUrl = (htmlString: string, baseUrl: string): string => { + const domParser = new DOMParser().parseFromString(htmlString, 'text/html'); + + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list).forEach((elem: T) => { + const newUrl = new URL( + elem.getAttribute(attributeName)!, + baseUrl, + ).toString(); + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(domParser.images), 'src'); + updateDom( + Array.from(domParser.links), + 'href', + ); + updateDom( + Array.from(domParser.querySelectorAll('link')), + 'href', + ); + + return domParser.body.parentElement?.outerHTML || htmlString; +}; export const Reader = () => { const shadowDomRef = useShadowDom(); + const state = useFetch( + 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html', + ); React.useEffect(() => { const divElement = shadowDomRef.current; - if (!divElement?.shadowRoot) { - return; + if (divElement?.shadowRoot && state.value) { + divElement.shadowRoot.innerHTML = addBaseUrl( + state.value, + 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/', + ); } - divElement.shadowRoot.innerHTML = mockHtml; - }, [shadowDomRef]); + }, [shadowDomRef, state]); return ( <> From d286e3b93fa597283e75ab2c86d5764e51772b56 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 17:15:23 +0200 Subject: [PATCH 19/64] cli: add packager lib for packaging packages into a dist workspace Co-authored-by: Ivan Shmidt Co-authored-by: Raghunandan --- packages/cli/src/lib/packager/index.ts | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/cli/src/lib/packager/index.ts diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts new file mode 100644 index 0000000000..c702fd3df4 --- /dev/null +++ b/packages/cli/src/lib/packager/index.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2020 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 fs from 'fs-extra'; +import { resolve as resolvePath, relative as relativePath } from 'path'; +import { paths } from '../paths'; +import { run } from '../run'; +import tar from 'tar'; +import { tmpdir } from 'os'; + +type LernaPackage = { + name: string; + private: boolean; + location: string; +}; + +type FileEntry = + | string + | { + src: string; + dest: string; + }; + +type Options = { + /** + * Target directory for the dist workspace, defaults to a temporary directory + */ + targetDir?: string; + + /** + * Files to copy into the target workspace. + * + * Defaults to ['yarn.lock', 'package.json']. + */ + files?: FileEntry[]; +}; + +/** + * Uses `yarn pack` to package local packages and unpacks them into a dist workspace. + * The target workspace will end up containing dist version of each package and + * will be suitable for packaging e.g. into a docker image. + * + * This creates a structure that is functionally similar to if the packages where + * installed from NPM, but uses yarn workspaces to link to them at runtime. + */ +export async function createDistWorkspace( + packageNames: string[], + options: Options, +) { + const targetDir = + options.targetDir ?? + (await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace'))); + + const targets = await findTargetPackages(packageNames); + + await moveToDistWorkspace(targetDir, targets); + + const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; + + for (const file of files) { + const src = typeof file === 'string' ? file : file.src; + const dest = typeof file === 'string' ? file : file.dest; + await fs.copy(paths.resolveTargetRoot(src), resolvePath(targetDir, dest)); + } +} + +async function moveToDistWorkspace( + workspaceDir: string, + localPackages: LernaPackage[], +): Promise { + await Promise.all( + localPackages.map(async (target, index) => { + console.log(`Repacking ${target.name} into dist workspace`); + const archive = `temp-package-${index}.tgz`; + const archivePath = resolvePath(workspaceDir, archive); + + await run('yarn', ['pack', '--filename', archivePath], { + cwd: target.location, + }); + // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed + await run('yarn', ['postpack'], { cwd: target.location }); + + const outputDir = relativePath(paths.targetRoot, target.location); + const absoluteOutputPath = resolvePath(workspaceDir, outputDir); + await fs.ensureDir(absoluteOutputPath); + + await tar.extract({ + file: archivePath, + cwd: absoluteOutputPath, + strip: 1, + }); + await fs.remove(archivePath); + }), + ); +} + +async function findTargetPackages(pkgNames: string[]): Promise { + const LernaProject = require('@lerna/project'); + const PackageGraph = require('@lerna/package-graph'); + + const project = new LernaProject(paths.targetDir); + const packages = await project.getPackages(); + const graph = new PackageGraph(packages); + + const targets = new Map(); + const searchNames = pkgNames.slice(); + + while (searchNames.length) { + const name = searchNames.pop()!; + + if (targets.has(name)) { + continue; + } + + const node = graph.get(name); + if (!node) { + throw new Error(`Package '${name}' not found`); + } + + const pkgDeps = Object.keys(node.pkg.dependencies); + const localDeps: string[] = Array.from(node.localDependencies.keys()); + const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep)); + + searchNames.push(...filteredDeps); + targets.set(name, node.pkg); + } + + return Array.from(targets.values()); +} From 84b55c10ca9363a7249b265766a85f8876da59b0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 18:03:21 +0200 Subject: [PATCH 20/64] cli: added backend:build-image command Co-authored-by: Ivan Shmidt --- .../cli/src/commands/backend/buildImage.ts | 39 +++++++++++++++++++ packages/cli/src/index.ts | 9 +++++ packages/cli/src/lib/packager/index.ts | 1 + 3 files changed, 49 insertions(+) create mode 100644 packages/cli/src/commands/backend/buildImage.ts diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts new file mode 100644 index 0000000000..a847f1ef54 --- /dev/null +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 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 fs from 'fs-extra'; +import { createDistWorkspace } from '../../lib/packager'; +import { paths } from '../../lib/paths'; +import { run } from '../../lib/run'; + +export default async (imageTag: string) => { + const tempDistWorkspace = await createDistWorkspace(['example-backend'], { + files: [ + 'package.json', + 'yarn.lock', + 'app-config.yaml', + { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, + ], + }); + + console.log(`Dist workspace ready at ${tempDistWorkspace}`); + + await run('docker', ['build', '.', '-t', imageTag], { + cwd: tempDistWorkspace, + }); + + await fs.remove(tempDistWorkspace); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e67a52c795..bed32bcf8b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -46,6 +46,15 @@ const main = (argv: string[]) => { .description('Build a backend plugin') .action(lazyAction(() => import('./commands/backend/build'), 'default')); + program + .command('backend:build-image ') + .description( + 'Builds a docker image from the package, with all local deps included', + ) + .action( + lazyAction(() => import('./commands/backend/buildImage'), 'default'), + ); + program .command('backend:dev') .description('Start local development server with HMR for the backend') diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index c702fd3df4..2f5233f93f 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -75,6 +75,7 @@ export async function createDistWorkspace( const dest = typeof file === 'string' ? file : file.dest; await fs.copy(paths.resolveTargetRoot(src), resolvePath(targetDir, dest)); } + return targetDir; } async function moveToDistWorkspace( From 66e2bfe073710bd3ac34c0fa0704c2f8523cd2bf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 18:05:11 +0200 Subject: [PATCH 21/64] backend: update packaging and add build-image command + Dockerfile Co-authored-by: Ivan Shmidt --- packages/backend/Dockerfile | 9 +++++++++ packages/backend/package.json | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/backend/Dockerfile diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile new file mode 100644 index 0000000000..3e8ba36cec --- /dev/null +++ b/packages/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM node:12 + +WORKDIR /usr/src/app + +COPY . . + +RUN yarn install --frozen-lockfile --production + +CMD ["node", "packages/backend"] diff --git a/packages/backend/package.json b/packages/backend/package.json index 3be1f5ee3e..53b1f431c0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,19 +1,27 @@ { "name": "example-backend", "version": "0.1.1-alpha.12", - "main": "dist/index.cjs.js", + "main": "src/index.ts", "types": "src/index.ts", "private": true, "license": "Apache-2.0", "engines": { "node": ">=12" }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, "scripts": { "build": "backstage-cli backend:build", + "build-image": "backstage-cli backend:build-image example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", "clean": "backstage-cli clean", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", "migrate:create": "knex migrate:make -x ts" }, "dependencies": { From d49e05e9e7c74540bec38639a8e4cfef1ee15167 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 18:05:45 +0200 Subject: [PATCH 22/64] plugins/auth-backend: include migrations in published package Co-authored-by: Ivan Shmidt --- plugins/auth-backend/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 2de098c7eb..290dd39489 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -57,6 +57,7 @@ "jest-fetch-mock": "^3.0.3" }, "files": [ - "dist" + "dist", + "migrations" ] } From c06c7d7e6bf3c9ea197e9e45aa5b99fc2190551c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 19:56:18 +0200 Subject: [PATCH 23/64] packages/backend,cli: revert publish config and remove postpack script requirement from image-build --- packages/backend/package.json | 9 +-------- packages/cli/src/lib/packager/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 53b1f431c0..64ca2c3113 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,18 +1,13 @@ { "name": "example-backend", "version": "0.1.1-alpha.12", - "main": "src/index.ts", + "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, "license": "Apache-2.0", "engines": { "node": ">=12" }, - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, "scripts": { "build": "backstage-cli backend:build", "build-image": "backstage-cli backend:build-image example-backend", @@ -20,8 +15,6 @@ "lint": "backstage-cli lint", "test": "backstage-cli test", "clean": "backstage-cli clean", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", "migrate:create": "knex migrate:make -x ts" }, "dependencies": { diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 2f5233f93f..5e309ab953 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -25,6 +25,7 @@ type LernaPackage = { name: string; private: boolean; location: string; + scripts: Record; }; type FileEntry = @@ -92,7 +93,9 @@ async function moveToDistWorkspace( cwd: target.location, }); // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed - await run('yarn', ['postpack'], { cwd: target.location }); + if (target.scripts.postpack) { + await run('yarn', ['postpack'], { cwd: target.location }); + } const outputDir = relativePath(paths.targetRoot, target.location); const absoluteOutputPath = resolvePath(workspaceDir, outputDir); From 48aaf9a8db3018505106ce7fabd6e9ce9276f1a2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 25 Jun 2020 21:18:54 +0200 Subject: [PATCH 24/64] yarnrc: bump network timeout --- .yarnrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.yarnrc b/.yarnrc index 524dcb98eb..f465c5c5e6 100644 --- a/.yarnrc +++ b/.yarnrc @@ -5,3 +5,4 @@ registry "https://registry.npmjs.org/" lastUpdateCheck 1580389148099 yarn-path ".yarn/releases/yarn-1.22.1.js" +network-timeout 600000 From c9354c2bff95a3524772ccd80b72c3a2550e3952 Mon Sep 17 00:00:00 2001 From: Remy DeCausemaker Date: Thu, 25 Jun 2020 15:20:19 -0400 Subject: [PATCH 25/64] Patch 3: Fixes #1323 (#1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated FAQ with correct link “Will Spotify’s internal plugins be open sourced, too?” had an incorrect link, and I added the correct one. * Update FAQ.md * Update FAQ.md * Fix links to include full path to .md files Co-authored-by: Nikki Beesetti <12538017+nikkibeesetti@users.noreply.github.com> Co-authored-by: Stefan Ålund --- docs/FAQ.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index e38fa84e73..7bfdbec478 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -36,7 +36,7 @@ more, read our blog post, Yes, we've already started releasing open source versions of some of the plugins we use here, and we'll continue to do so. -[Plugins](https://github.com/spotify/faq#what-is-a-plugin-in-backstage) are the +[Plugins](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage) are the building blocks of functionality in Backstage. We have over 120 plugins inside Spotify — many of those are specialized for our use, so will remain internal and proprietary to us. But we estimate that about a third of our existing plugins @@ -122,15 +122,7 @@ can browse and search for all available plugins. ​ ### Which plugin is used the most at Spotify? -​ By far, our most-used plugin is our TechDocs plugin, which we use for creating -technical documentation. Our philosophy at Spotify is to treat "docs like code", -where you write documentation using the same workflow as you write your code. -This makes it easier to create, find, and update documentation. We hope to -release -[the open source version](https://github.com/spotify/backstage/issues/687) in -the future. (See also: -"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/faq#will-spotifys-internal-plugins-be-open-sourced-too)" -above) ​ +By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#will-spotifys-internal-plugins-be-open-sourced-too)" above) ### Are you planning to have plugins baked into the repo? Or should they be developed in separate repos? From 4e57eeb1010b5ed44d1f498374dc4cda5c64a1a9 Mon Sep 17 00:00:00 2001 From: Raghunandan Balachandran Date: Thu, 25 Jun 2020 21:21:04 +0200 Subject: [PATCH 26/64] fixing doc links (#1456) --- docs/auth/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/auth/README.md b/docs/auth/README.md index 081d10add6..9809f57168 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -80,7 +80,7 @@ sign-in methods. More details are provided in dedicated sections of the documentation. -- [OAuth](./oauth): Description of the generic OAuth flow implemented by the +- [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the [auth-backend](../../plugins/auth-backend). -- [Glossary](./glossary): Glossary of some common terms related to the auth +- [Glossary](./glossary.md): Glossary of some common terms related to the auth flows. From b56524fd718a2c78148b6cb3833675ad237c73f8 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2020 21:22:00 +0200 Subject: [PATCH 27/64] build(deps): bump helmet from 3.22.0 to 3.23.2 (#1450) Bumps [helmet](https://github.com/helmetjs/helmet) from 3.22.0 to 3.23.2. - [Release notes](https://github.com/helmetjs/helmet/releases) - [Changelog](https://github.com/helmetjs/helmet/blob/master/CHANGELOG.md) - [Commits](https://github.com/helmetjs/helmet/compare/v3.22.0...v3.23.2) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- yarn.lock | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/yarn.lock b/yarn.lock index 026946b1e5..e980d91f57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7578,11 +7578,6 @@ dns-packet@^1.3.1: ip "^1.1.0" safe-buffer "^5.0.1" -dns-prefetch-control@0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz#73988161841f3dcc81f47686d539a2c702c88624" - integrity sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q== - dns-txt@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" @@ -9912,12 +9907,11 @@ helmet-csp@2.10.0: dasherize "2.0.0" helmet@^3.22.0: - version "3.22.0" - resolved "https://registry.npmjs.org/helmet/-/helmet-3.22.0.tgz#3a6f11d931799145f0aff15dbc563cff9e13131f" - integrity sha512-Xrqicn2nm1ZIUxP3YGuTBmbDL04neKsIT583Sjh0FkiwKDXYCMUqGqC88w3NUvVXtA75JyR2Jn6jw6ZEMOD+ZA== + version "3.23.2" + resolved "https://registry.npmjs.org/helmet/-/helmet-3.23.2.tgz#390bb6f3f5e593f90f441bdd91000912c543a898" + integrity sha512-pe0UiHw3aHbP8Lon9McCq4AN2XLUMSbhwxJnUY6U2t8wTda7F1SsYg0/pBa1BPugaRqAtx9e1/FyF6E9PsUU5A== dependencies: depd "2.0.0" - dns-prefetch-control "0.2.0" dont-sniff-mimetype "1.1.0" expect-ct "0.2.0" feature-policy "0.3.0" @@ -9927,7 +9921,6 @@ helmet@^3.22.0: hide-powered-by "1.1.0" hpkp "2.0.0" hsts "2.2.0" - ienoopen "1.1.0" nocache "2.1.0" referrer-policy "1.2.0" x-xss-protection "1.3.0" @@ -10290,11 +10283,6 @@ ieee754@^1.1.4: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ienoopen@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" - integrity sha512-MFs36e/ca6ohEKtinTJ5VvAJ6oDRAYFdYXweUnGY9L9vcoqFOU4n2ZhmJ0C4z/cwGZ3YIQRSB3XZ1+ghZkY5NQ== - iferr@^0.1.5: version "0.1.5" resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" From 144a3b9294c42c4a601696c280e1aa0461b70bd9 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 18:20:13 -0400 Subject: [PATCH 28/64] Fix build and add okta oauth2 scope normalization --- .../auth/okta/OktaAuth.test.ts | 18 +++++++++++++++ .../implementations/auth/okta/OktaAuth.ts | 22 +++++++++++++++++-- packages/storybook/.storybook/apis.js | 11 ++++++++++ .../src/providers/okta/provider.ts | 4 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts index 7f2bd5d6bc..ab6c46c9b4 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -18,6 +18,8 @@ import OktaAuth from './OktaAuth'; const theFuture = new Date(Date.now() + 3600000); const thePast = new Date(Date.now() - 10); +const PREFIX = 'okta.'; + describe('OktaAuth', () => { it('should get refreshed access token', async () => { const getSession = jest.fn().mockResolvedValue({ @@ -108,4 +110,20 @@ describe('OktaAuth', () => { await expect(promise3).resolves.toBe('token2'); expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client }); + + it.each([ + ['openid', ['openid']], + ['profile email', ['profile', 'email']], + [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], + ['groups.read', [`${PREFIX}groups.read`]], + [`${PREFIX}groups.manage groups.read, openid`, [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid']], + [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + }); }); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index 55820375f6..5631648016 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -60,6 +60,12 @@ const DEFAULT_PROVIDER = { icon: OktaIcon, }; +const OKTA_OIDC_SCOPES: Set = new Set( + ['openid', 'profile', 'email', 'phone', 'address', 'groups', 'offline_access'] +) + +const OKTA_SCOPE_PREFIX: string = 'okta.' + class OktaAuth implements OAuthApi, OpenIdConnectApi, @@ -152,7 +158,7 @@ class OktaAuth implements return session?.profile; } - static normalizeScopes(scope?: string): Set { + static normalizeScopes(scope?: string | string[]): Set { if (!scope) { return new Set(); } @@ -161,7 +167,19 @@ class OktaAuth implements ? scope : scope.split(/[\s|,]/).filter(Boolean); - return new Set(scopeList); + const normalizedScopes = scopeList.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}` + }); + + return new Set(normalizedScopes); } } diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 3b61ae7af8..0d0cf74e8f 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -6,11 +6,13 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oktaAuthApiRef, AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, GoogleAuth, GithubAuth, + OktaAuth, identityApiRef, } from '@backstage/core'; @@ -50,4 +52,13 @@ builder.add( }), ); +builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + export const apis = builder.build(); diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 7b3d5770d7..788af9c4bd 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -59,10 +59,10 @@ export class OktaAuthProvider implements OAuthProviderHandlers { * allowing us to avoid using express-session in order to integrate with Okta. */ private _store: StateStore = { - store(req: Request, cb: any) { + store({}, cb: any) { cb(null, null); }, - verify(req: Request, state: string, cb: any) { + verify({}, {}, cb: any) { cb(null, true); }, } From 72387eb720ba4c9c94c0f7bdd404774f146b0d3a Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 18:28:51 -0400 Subject: [PATCH 29/64] Fix linting and tsc issues --- .../src/apis/implementations/auth/okta/OktaAuth.ts | 10 +++++----- plugins/auth-backend/src/providers/okta/provider.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index 5631648016..f10ec4ebce 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -158,14 +158,14 @@ class OktaAuth implements return session?.profile; } - static normalizeScopes(scope?: string | string[]): Set { - if (!scope) { + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { return new Set(); } - const scopeList = Array.isArray(scope) - ? scope - : scope.split(/[\s|,]/).filter(Boolean); + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); const normalizedScopes = scopeList.map(scope => { if (OKTA_OIDC_SCOPES.has(scope)) { diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 788af9c4bd..2dd3b57137 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import express, { Request } from 'express'; +import express from 'express'; import { OAuthProvider } from '../../lib/OAuthProvider'; import { Strategy as OktaStrategy } from 'passport-okta-oauth'; import passport from 'passport'; From d3a990ac0492ace10cb7d0f72a4900ee0c7b5d57 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 19:42:32 -0400 Subject: [PATCH 30/64] Add prompt: consent. Adjust unused types for StateStore --- plugins/auth-backend/src/providers/okta/provider.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 2dd3b57137..e73843bb0c 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -59,10 +59,10 @@ export class OktaAuthProvider implements OAuthProviderHandlers { * allowing us to avoid using express-session in order to integrate with Okta. */ private _store: StateStore = { - store({}, cb: any) { + store(_req: express.Request, cb: any) { cb(null, null); }, - verify({}, {}, cb: any) { + verify(_req: express.Request, _state: string, cb: any) { cb(null, true); }, } @@ -104,7 +104,12 @@ export class OktaAuthProvider implements OAuthProviderHandlers { req: express.Request, options: Record ): Promise { - return await executeRedirectStrategy(req, this._strategy, options); + const providerOptions = { + ...options, + accessType: 'offline', + prompt: 'consent', + }; + return await executeRedirectStrategy(req, this._strategy, providerOptions); } async handler( From ced2fb19252c3b8579d12e7a959abaa0f085c680 Mon Sep 17 00:00:00 2001 From: Jeff Feng <46946747+fengypants@users.noreply.github.com> Date: Fri, 26 Jun 2020 03:09:10 -0400 Subject: [PATCH 31/64] Update FAQ.md (#1464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update FAQ.md — Changed anchor links to relative links (/docs/FAQ.md#heading-name) — Restored the heading + answer for "What is a "plugin" in Backstage?" — Cleaned up extra spaces throughout and added line breaks to the answer for "What is the end-to-end user flow?". * Update FAQ.md --- docs/FAQ.md | 98 ++++++++++++++++++++++++++++------------------------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 7bfdbec478..7d568a840e 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -15,11 +15,11 @@ No, but it can be! Backstage is designed to be a developer portal for all your infrastructure tooling, services, and documentation. So, it's not a monitoring platform — but that doesn't mean you can't integrate a monitoring tool into Backstage by writing -[a plugin](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage). +[a plugin](/docs/FAQ.md#what-is-a-plugin-in-backstage). ### How is Backstage licensed? -Backstage was released as open sourced software by Spotify and is licensed under +Backstage was released as open source software by Spotify and is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). ### Why did we open source Backstage? @@ -36,16 +36,16 @@ more, read our blog post, Yes, we've already started releasing open source versions of some of the plugins we use here, and we'll continue to do so. -[Plugins](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage) are the +[Plugins](/docs/FAQ.md#what-is-a-plugin-in-backstage) are the building blocks of functionality in Backstage. We have over 120 plugins inside Spotify — many of those are specialized for our use, so will remain internal and proprietary to us. But we estimate that about a third of our existing plugins make good open source candidates. (And we'll probably end up writing some brand -new ones, too.) ​ +new ones, too.) ### What's the roadmap for Backstage? -​ We envision three phases, which you can learn about in +We envision three phases, which you can learn about in [our project roadmap](https://github.com/spotify/backstage#project-roadmap). Even though the open source version of Backstage is relatively new compared to our internal version, we have already begun work on various aspects of all three @@ -80,134 +80,138 @@ knew that we would like to use. It strikes a good balance between power, customizability, and ease of use. A core focus of Backstage is to make plugin developers productive with as few hurdles as possible. Material-UI lets plugin makers get going easily with both -well-known tech and a large flora of components. ​ +well-known tech and a large flora of components. ### What technology does Backstage use? -​ The code base is a large-scale React application that uses TypeScript. For +The code base is a large-scale React application that uses TypeScript. For [Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use -Node.js and GraphQL. ​ +Node.js and GraphQL. ### What is the end-to-end user flow? The happy path story. -​ There are three main user profiles for Backstage: the integrator, the -contributor, and the software engineer. ​ The **integrator** hosts the Backstage -app and configures which plugins are available to use in the app. ​ The -**contributor** adds functionality to the app by writing plugins. ​ The -**software engineer** uses the app's functionality and interacts with its -plugins. ​ +There are three main user profiles for Backstage: the integrator, the +contributor, and the software engineer. -### What is the use of a "plugin" in Backstage? +The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. -​ A Backstage Plugin adds functionality to Backstage. ​ +The **contributor** adds functionality to the app by writing plugins. +The **software engineer** uses the app's functionality and interacts with its plugins. + +### What is a "plugin" in Backstage? + +Plugins are what provide the feature functionality in Backstage. They are used to integrate different systems into Backstage's frontend, so that the developer gets a consistent UX, no matter what tool or service is being accessed on the other side. + +Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform APIs and reusable UI components. Plugins can fetch data either from the backend or an API exposed through the proxy. + +Learn more about [the different components](https://github.com/spotify/backstage#overview) that make up Backstage. ### Do I have to write plugins in TypeScript? -​ No, you can use JavaScript if you prefer. ​ We want to keep the Backstage core -APIs in TypeScript, but aren't forcing it on individual plugins. ​ +No, you can use JavaScript if you prefer. We want to keep the Backstage core +APIs in TypeScript, but aren't forcing it on individual plugins. ### How do I find out if a plugin already exists? -​ Before you write a plugin, +Before you write a plugin, [search the plugin issues](https://github.com/spotify/backstage/issues?q=is%3Aissue+label%3Aplugin+) to see if it already exists or is in the works. If no one's thought of it yet, great! Open a new issue as [a plugin suggestion](https://github.com/spotify/backstage/issues/new/choose) and describe what your plugin will do. This will help coordinate our -contributors' efforts and avoid duplicating existing functionality. ​ In the +contributors' efforts and avoid duplicating existing functionality. In the future, we will create [a plugin gallery](https://github.com/spotify/backstage/issues/260) where people -can browse and search for all available plugins. ​ +can browse and search for all available plugins. ### Which plugin is used the most at Spotify? -By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#will-spotifys-internal-plugins-be-open-sourced-too)" above) +By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](/docs/FAQ.md#will-spotifys-internal-plugins-be-open-sourced-too)" above) ### Are you planning to have plugins baked into the repo? Or should they be developed in separate repos? -​ Contributors can add open source plugins to the plugins directory in +Contributors can add open source plugins to the plugins directory in [this monorepo](https://github.com/spotify/backstage). Integrators can then configure which open source plugins are available to use in their instance of the app. Open source plugins are downloaded as npm packages published in the -open source repository. ​ While we encourage using the open source model, we +open source repository. While we encourage using the open source model, we know there are cases where contributors might want to experiment internally or keep their plugins closed source. Contributors writing closed source plugins should develop them in the plugins directory in their own Backstage repository. -Integrators also configure closed source plugins locally from the monorepo. ​ +Integrators also configure closed source plugins locally from the monorepo. ### Any plans for integrating with other repository managers, such as GitLab or Bitbucket? -​ We chose GitHub because it is the tool that we are most familiar with, so that +We chose GitHub because it is the tool that we are most familiar with, so that will naturally lead to integrations for GitHub being developed at an early -stage. ​ Hosting this project on GitHub does not exclude integrations with +stage. Hosting this project on GitHub does not exclude integrations with alternatives, such as [GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab) or Bitbucket. We believe that in time there will be plugins that will provide -functionality for these tools as well. Hopefully, contributed by the community! -​ Also note, implementations of Backstage can be hosted wherever you feel suits -your needs best. ​ +functionality for these tools as well. Hopefully, contributed by the community! Also note, implementations of Backstage can be hosted wherever you feel suits +your needs best. ### Who maintains Backstage? -​ Spotify will maintain the open source core, but we envision different parts of +Spotify will maintain the open source core, but we envision different parts of the project being maintained by various companies and contributors. We also envision a large, diverse ecosystem of open source plugins, which would be -maintained by their original authors/contributors or by the community. ​ When it +maintained by their original authors/contributors or by the community. When it comes to [deployment](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md), the system integrator (typically, the infrastructure team in your organization) -maintains Backstage in your own environment. ​ +maintains Backstage in your own environment. ### Does Spotify provide a managed version of Backstage? -​ No, this is not a service offering. We build the piece of software, and +No, this is not a service offering. We build the piece of software, and someone in your infrastructure team is responsible for [deploying](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md) and -maintaining it. ​ +maintaining it. ### How secure is Backstage? -​ We take security seriously. When it comes to packages and code we scan our +We take security seriously. When it comes to packages and code we scan our repositories periodically and update our packages to the latest versions. When it comes to deployment of Backstage within an organisation it depends on the deployment and security setup in your organisation. Reach out to us on [Discord](https://discord.gg/MUpMjP2) if you have specific queries. Please report sensitive security issues via Spotify's -[bug-bounty program](https://hackerone.com/spotify) rather than GitHub. ​ +[bug-bounty program](https://hackerone.com/spotify) rather than GitHub. ### Does Backstage collect any information that is shared with Spotify? -​ No. Backstage does not collect any telemetry from any third party using the +No. Backstage does not collect any telemetry from any third party using the platform. Spotify, and the open source community, does have access to [GitHub Insights](https://github.com/features/insights), which contains -information such as contributors, commits, traffic, and dependencies. ​ +information such as contributors, commits, traffic, and dependencies. Backstage is an open platform, but you are in control of your own data. You control who has access to any data you provide to your version of Backstage and -who that data is shared with. ​ +who that data is shared with. ### Can Backstage be used to build something other than a developer portal? -​ Yes. The core frontend framework could be used for building any large-scale +Yes. The core frontend framework could be used for building any large-scale web application where (1) multiple teams are building separate parts of the app, -and (2) you want the overall experience to be consistent. ​ That being said, in +and (2) you want the overall experience to be consistent. That being said, in [Phase 2](https://github.com/spotify/backstage#project-roadmap) of the project we will add features that are needed for developer portals and systems for -managing software ecosystems. Our ambition will be to keep Backstage modular. ​ +managing software ecosystems. Our ambition will be to keep Backstage modular. ### How can I get involved? -​ Jump right in! Come help us fix some of the +Jump right in! Come help us fix some of the [early bugs and first issues](https://github.com/spotify/backstage/labels/good%20first%20issue) or reach [a new milestone](https://github.com/spotify/backstage/milestones). Or write an open source plugin for Backstage, like this [Lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse). -​ See all the ways you can +See all the ways you can [contribute here](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md). -We'd love to have you as part of the community. ​ +We'd love to have you as part of the community. ### Can I join the Backstage team? -​ If you're interested in being part of the Backstage team, reach out to +If you're interested in being part of the Backstage team, reach out to [fossopportunities@spotify.com](mailto:fossopportunities@spotify.com) From 62130b805041c7e230c1d9c2657987441703ed69 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2020 09:09:49 +0200 Subject: [PATCH 32/64] build(deps): bump inquirer from 7.1.0 to 7.2.0 (#1451) Bumps [inquirer](https://github.com/SBoudrias/Inquirer.js) from 7.1.0 to 7.2.0. - [Release notes](https://github.com/SBoudrias/Inquirer.js/releases) - [Commits](https://github.com/SBoudrias/Inquirer.js/compare/inquirer@7.1.0...inquirer@7.2.0) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- yarn.lock | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index e980d91f57..fa00ad8540 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10530,9 +10530,9 @@ inquirer@^6.2.0: through "^2.3.6" inquirer@^7.0.0, inquirer@^7.0.4: - version "7.1.0" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" - integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== + version "7.2.0" + resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a" + integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ== dependencies: ansi-escapes "^4.2.1" chalk "^3.0.0" @@ -16565,20 +16565,13 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.3.3, rxjs@^6.5.5: +rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.4, rxjs@^6.5.5: version "6.5.5" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec" integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ== dependencies: tslib "^1.9.0" -rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.4: - version "6.5.4" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" - integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== - dependencies: - tslib "^1.9.0" - safe-buffer@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" From 09abd44666172ef7cdb02eab4f0cfaaf26b247f3 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2020 09:12:39 +0200 Subject: [PATCH 33/64] build(deps-dev): bump cypress from 4.7.0 to 4.9.0 (#1454) Bumps [cypress](https://github.com/cypress-io/cypress) from 4.7.0 to 4.9.0. - [Release notes](https://github.com/cypress-io/cypress/releases) - [Commits](https://github.com/cypress-io/cypress/compare/v4.7.0...v4.9.0) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- yarn.lock | 92 ++++++++++++++++++------------------------------------- 1 file changed, 30 insertions(+), 62 deletions(-) diff --git a/yarn.lock b/yarn.lock index fa00ad8540..b42ca52288 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4677,10 +4677,10 @@ aproba@^2.0.0: resolved "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -arch@2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" - integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== +arch@2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" + integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== are-we-there-yet@~1.1.2: version "1.1.5" @@ -4917,7 +4917,7 @@ async@^2.6.1, async@^2.6.2: dependencies: lodash "^4.17.14" -async@^3.1.0: +async@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== @@ -6343,21 +6343,16 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== -commander@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" - integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== +commander@4.1.1, commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" - integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== - commander@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" @@ -7099,38 +7094,38 @@ cyclist@^1.0.1: integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= cypress@*, cypress@^4.2.0: - version "4.7.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-4.7.0.tgz#3ea29bddaf9a1faeaa5b8d54b60a84ed1cafa83d" - integrity sha512-Vav6wUFhPRlImIND/2lOQlUnAWzgCC/iXyJlJjX9nJOJul5LC1vUpf/m8Oiae870PFPyT0ZLLwPHKTXZNdXmHw== + version "4.9.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-4.9.0.tgz#c188a3864ddf841c0fdc81a9e4eff5cf539cd1c1" + integrity sha512-qGxT5E0j21FPryzhb0OBjCdhoR/n1jXtumpFFSBPYWsaZZhNaBvc3XlBUDEZKkkXPsqUFYiyhWdHN/zo0t5FcA== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/request" "2.88.5" "@cypress/xvfb" "1.2.4" "@types/sinonjs__fake-timers" "6.0.1" "@types/sizzle" "2.3.2" - arch "2.1.1" + arch "2.1.2" bluebird "3.7.2" cachedir "2.3.0" chalk "2.4.2" check-more-types "2.24.0" cli-table3 "0.5.1" - commander "4.1.0" + commander "4.1.1" common-tags "1.8.0" debug "4.1.1" - eventemitter2 "4.1.2" + eventemitter2 "6.4.2" execa "1.0.0" executable "4.1.1" extract-zip "1.7.0" fs-extra "8.1.0" - getos "3.1.4" + getos "3.2.1" is-ci "2.0.0" - is-installed-globally "0.1.0" + is-installed-globally "0.3.2" lazy-ass "1.6.0" listr "0.14.3" lodash "4.17.15" log-symbols "3.0.0" minimist "1.2.5" - moment "2.24.0" + moment "2.26.0" ospath "1.2.2" pretty-bytes "5.3.0" ramda "0.26.1" @@ -8360,10 +8355,10 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter2@4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz#0e1a8477af821a6ef3995b311bf74c23a5247f15" - integrity sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU= +eventemitter2@6.4.2: + version "6.4.2" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.2.tgz#f31f8b99d45245f0edbc5b00797830ff3b388970" + integrity sha512-r/Pwupa5RIzxIHbEKCkNXqpEQIIT4uQDxmP4G/Lug/NokVUWj0joz/WzWl3OxRpC5kDrH/WdiUJoR+IrwvXJEw== eventemitter3@^3.1.0: version "3.1.2" @@ -9326,12 +9321,12 @@ getopts@2.2.5: resolved "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== -getos@3.1.4: - version "3.1.4" - resolved "https://registry.npmjs.org/getos/-/getos-3.1.4.tgz#29cdf240ed10a70c049add7b6f8cb08c81876faf" - integrity sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw== +getos@3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" + integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== dependencies: - async "^3.1.0" + async "^3.2.0" getpass@^0.1.1: version "0.1.7" @@ -9436,13 +9431,6 @@ glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glo once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^0.1.0: - version "0.1.1" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= - dependencies: - ini "^1.3.4" - global-dirs@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" @@ -10845,15 +10833,7 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= -is-installed-globally@0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" - integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= - dependencies: - global-dirs "^0.1.0" - is-path-inside "^1.0.0" - -is-installed-globally@^0.3.1: +is-installed-globally@0.3.2, is-installed-globally@^0.3.1: version "0.3.2" resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== @@ -10927,13 +10907,6 @@ is-path-in-cwd@^2.0.0: dependencies: is-path-inside "^2.1.0" -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= - dependencies: - path-is-inside "^1.0.1" - is-path-inside@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" @@ -13059,12 +13032,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@2.24.0: - version "2.24.0" - resolved "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" - integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== - -moment@^2.25.3, moment@^2.26.0: +moment@2.26.0, moment@^2.25.3, moment@^2.26.0: version "2.26.0" resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== @@ -14304,7 +14272,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1, path-is-inside@^1.0.2: +path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= From 22d1c40629ab0a09ccb5da64ac504b55aaccea0a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2020 09:13:24 +0200 Subject: [PATCH 34/64] build(deps): bump graphiql from 1.0.0-alpha.10 to 1.0.3 (#1453) Bumps [graphiql](https://github.com/graphql/graphiql) from 1.0.0-alpha.10 to 1.0.3. - [Release notes](https://github.com/graphql/graphiql/releases) - [Changelog](https://github.com/graphql/graphiql/blob/main/CHANGELOG.md) - [Commits](https://github.com/graphql/graphiql/compare/graphiql@1.0.0-alpha.10...graphiql@1.0.3) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- yarn.lock | 355 +++++++++++------------------------------------------- 1 file changed, 71 insertions(+), 284 deletions(-) diff --git a/yarn.lock b/yarn.lock index b42ca52288..0dd8044248 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1184,71 +1184,6 @@ unique-filename "^1.1.1" which "^1.3.1" -"@graphql-toolkit/common@0.10.7", "@graphql-toolkit/common@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/common/-/common-0.10.7.tgz#e5d4ddeae080c4f7357bc7d6a8d02e75578e9bd1" - integrity sha512-epcJvmIAo+vSEY76F0Dj1Ef6oeewT5pdMe1obHj7LHXN9V22O86aQzwdEEm1iG91qROqSw/apcDnSCMjuVeQVA== - dependencies: - aggregate-error "3.0.1" - camel-case "4.1.1" - graphql-tools "5.0.0" - lodash "4.17.15" - -"@graphql-toolkit/core@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/core/-/core-0.10.7.tgz#e4d86d9e439fbb05b634054b0865c16d488fad97" - integrity sha512-LXcFLG7XcRJrPz/xD+0cExzLx/ptVynDM20650/FbmHbKOU50d9mSbcsrzAOq/3f4q3HrRDssvn0f6pPm0EHMg== - dependencies: - "@graphql-toolkit/common" "0.10.7" - "@graphql-toolkit/schema-merging" "0.10.7" - aggregate-error "3.0.1" - globby "11.0.0" - import-from "^3.0.0" - is-glob "4.0.1" - lodash "4.17.15" - p-limit "2.3.0" - resolve-from "5.0.0" - tslib "1.11.2" - unixify "1.0.0" - valid-url "1.0.9" - -"@graphql-toolkit/graphql-file-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/graphql-file-loader/-/graphql-file-loader-0.10.7.tgz#496e49712def12449b339739d3583ed1bda7a24f" - integrity sha512-6tUIuw/YBlm0VyVgXgMrOXsEQ+WpXVgr2NQwHNzmZo82kPGqImveq7A2D3gBWLyVTcinDScRcKJMxM4kCF5T0A== - dependencies: - "@graphql-toolkit/common" "0.10.7" - tslib "1.11.2" - -"@graphql-toolkit/json-file-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/json-file-loader/-/json-file-loader-0.10.7.tgz#6ac38258bc3da8540b38232b71bb5820bf02cff6" - integrity sha512-nVISrODqvn5LiQ4nKL5pz1Let/W1tuj2viEwrNyTS+9mcjaCE2nhV5MOK/7ZY0cR+XeA4N2u65EH1lQd63U3Cw== - dependencies: - "@graphql-toolkit/common" "0.10.7" - tslib "1.11.2" - -"@graphql-toolkit/schema-merging@0.10.7", "@graphql-toolkit/schema-merging@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/schema-merging/-/schema-merging-0.10.7.tgz#7b33becd48629bc656d602405c0b5c17d33ebd85" - integrity sha512-VngxJbVdRfXYhdMLhL90pqN+hD/2XTZwhHPGvpWqmGQhT6roc98yN3xyDyrWFYYsuiY4gTexdmrHQ3d7mzitwA== - dependencies: - "@graphql-toolkit/common" "0.10.7" - deepmerge "4.2.2" - graphql-tools "5.0.0" - tslib "1.11.2" - -"@graphql-toolkit/url-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/url-loader/-/url-loader-0.10.7.tgz#0de998eeeaebcbf918526a26cb5f30f8250211f0" - integrity sha512-Ec3T4Zuo63LwG+RfK2ryz8ChPfncBf8fiSJ1xr68FtLDVznDNulvlNKFbfREE5koWejwsnJrjLCv6IX5IbhExg== - dependencies: - "@graphql-toolkit/common" "0.10.7" - cross-fetch "3.0.4" - graphql-tools "5.0.0" - tslib "1.11.2" - valid-url "1.0.9" - "@hapi/address@^2.1.2": version "2.1.4" resolved "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -4331,13 +4266,6 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" -"@wry/equality@^0.1.2": - version "0.1.11" - resolved "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" - integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA== - dependencies: - tslib "^1.9.3" - "@xobotyi/scrollbar-width@1.9.5": version "1.9.5" resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" @@ -4465,7 +4393,7 @@ agentkeepalive@^3.4.1: dependencies: humanize-ms "^1.2.1" -aggregate-error@3.0.1, aggregate-error@^3.0.0: +aggregate-error@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" integrity sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA== @@ -4623,45 +4551,6 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-link-http-common@^0.2.14: - version "0.2.16" - resolved "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz#756749dafc732792c8ca0923f9a40564b7c59ecc" - integrity sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg== - dependencies: - apollo-link "^1.2.14" - ts-invariant "^0.4.0" - tslib "^1.9.3" - -apollo-link@^1.2.12, apollo-link@^1.2.14: - version "1.2.14" - resolved "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9" - integrity sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg== - dependencies: - apollo-utilities "^1.3.0" - ts-invariant "^0.4.0" - tslib "^1.9.3" - zen-observable-ts "^0.8.21" - -apollo-upload-client@^13.0.0: - version "13.0.0" - resolved "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-13.0.0.tgz#146d1ddd85d711fcac8ca97a72d3ca6787f2b71b" - integrity sha512-lJ9/bk1BH1lD15WhWRha2J3+LrXrPIX5LP5EwiOUHv8PCORp4EUrcujrA3rI5hZeZygrTX8bshcuMdpqpSrvtA== - dependencies: - "@babel/runtime" "^7.9.2" - apollo-link "^1.2.12" - apollo-link-http-common "^0.2.14" - extract-files "^8.0.0" - -apollo-utilities@^1.3.0: - version "1.3.4" - resolved "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz#6129e438e8be201b6c55b0f13ce49d2c7175c9cf" - integrity sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig== - dependencies: - "@wry/equality" "^0.1.2" - fast-json-stable-stringify "^2.0.0" - ts-invariant "^0.4.0" - tslib "^1.10.0" - app-root-dir@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" @@ -5820,7 +5709,7 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camel-case@4.1.1, camel-case@^4.1.1: +camel-case@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== @@ -6221,13 +6110,13 @@ code-point-at@^1.0.0: resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -codemirror-graphql@^0.12.0-alpha.8: - version "0.12.0-alpha.8" - resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-0.12.0-alpha.8.tgz#cd7653299baefcdff2349bc9c3def9549c78775c" - integrity sha512-leCFSjrwtdKQE70ROWIUoc1U6F8fy6i+cd9/0D1dRbEecoleCt5+zODdANETw9map50kxNPf3EcGW4BPYj14hw== +codemirror-graphql@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-0.12.0.tgz#55f782ffaaea7ff8dbda037e6b97b584dff2402a" + integrity sha512-C/0vKzQT5Uw+DLz6/MwKer29FQM52sh9Bem+VdDW42094j8nES1sdnuqj4k5ahNdQpW4FmVeoj/ngn2g3AWmgg== dependencies: - graphql-language-service-interface "^2.4.0-alpha.8" - graphql-language-service-parser "^1.6.0-alpha.4" + graphql-language-service-interface "^2.4.0" + graphql-language-service-parser "^1.6.0" codemirror@^5.52.2: version "5.53.2" @@ -6686,17 +6575,6 @@ cors@^2.8.5: object-assign "^4" vary "^1" -cosmiconfig@6.0.0, cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" - cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" @@ -6707,6 +6585,17 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: js-yaml "^3.13.1" parse-json "^4.0.0" +cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -6753,7 +6642,7 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.4, cross-fetch@^3.0.4: +cross-fetch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c" integrity sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw== @@ -7327,7 +7216,7 @@ deep-object-diff@^1.1.0: resolved "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.0.tgz#d6fabf476c2ed1751fc94d5ca693d2ed8c18bc5a" integrity sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw== -deepmerge@4.2.2, deepmerge@^4.2.2: +deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== @@ -7433,11 +7322,6 @@ depd@~1.1.2: resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= -deprecated-decorator@^0.1.6: - version "0.1.6" - resolved "https://registry.npmjs.org/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" - integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= - deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" @@ -8581,11 +8465,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extract-files@^8.0.0: - version "8.1.0" - resolved "https://registry.npmjs.org/extract-files/-/extract-files-8.1.0.tgz#46a0690d0fe77411a2e3804852adeaa65cd59288" - integrity sha512-PTGtfthZK79WUMk+avLmwx3NGdU8+iVFXC2NMGxKsn0MnihOG2lvumj+AZo8CTwTrwjXDgZ5tztbRlEdRjBonQ== - extract-zip@1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" @@ -9501,18 +9380,6 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.0: - version "11.0.0" - resolved "https://registry.npmjs.org/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154" - integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@8.0.2: version "8.0.2" resolved "https://registry.npmjs.org/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" @@ -9624,86 +9491,55 @@ graceful-fs@^4.2.4: integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== graphiql@^1.0.0-alpha.10: - version "1.0.0-alpha.10" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.0-alpha.10.tgz#9b6b5e554c6586e9bc09ebc863df8bcfd844110d" - integrity sha512-fKyYXQQRjsxOQw0rZMvICA2+P7ieUjj8AyfMaYlQeXGsGkZo0FUvBXVsN6LTraAPRBvVFo2jH9MSH68ujDXoeQ== + version "1.0.3" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.3.tgz#f6b5d5c417d8f1a28786d3228a69883426ba74ad" + integrity sha512-6YW76m2exdpGiVS75I0QsbM4NanvenKFR9FtW/IFbui0mWv89iVa1FbdjhUt+kxa+LX/WlqTshiqiZRz7E5Bgg== dependencies: "@emotion/core" "^10.0.28" "@mdx-js/react" "^1.5.2" codemirror "^5.52.2" - codemirror-graphql "^0.12.0-alpha.8" + codemirror-graphql "^0.12.0" copy-to-clipboard "^3.2.0" entities "^2.0.0" markdown-it "^10.0.0" - monaco-graphql "^0.1.1" + monaco-graphql "^0.2.0" regenerator-runtime "^0.13.5" theme-ui "^0.3.1" -graphql-config@3.0.0-rc.2: - version "3.0.0-rc.2" - resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.0-rc.2.tgz#760a1d7bcf8b114b7c1675c5a6ac624976d62e09" - integrity sha512-fb14mSDRatKsz2XXWgQjupbRsur/5vfQqHFFKo+N7hZNzRdT4hm+52HG8gn57mHQNZsN18q4aROwRQQRICMqZA== +graphql-language-service-interface@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0.tgz#4e2e63242c76197c4f56c61122db68187ea566b3" + integrity sha512-r7DQPyhCFY5TlpEukdh9tekJ9hAc7MD9TdOsb5CfAPlsIb1/faVVo2Ty19PxGSYDxygXjwpKLOQD0LqqFuw63A== dependencies: - "@graphql-toolkit/common" "~0.10.2" - "@graphql-toolkit/core" "~0.10.2" - "@graphql-toolkit/graphql-file-loader" "~0.10.2" - "@graphql-toolkit/json-file-loader" "~0.10.2" - "@graphql-toolkit/schema-merging" "~0.10.2" - "@graphql-toolkit/url-loader" "~0.10.2" - cosmiconfig "6.0.0" - globby "11.0.0" - minimatch "3.0.4" - tslib "^1.11.1" - -graphql-language-service-interface@^2.4.0-alpha.8: - version "2.4.0-alpha.8" - resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0-alpha.8.tgz#2f50b107c72be01796417bcfec2669d42f3b57c7" - integrity sha512-HT77H0GQk2jtpE9NAE+f8aNKvCkv5e7cZ32RZd+lCL23qNmuLJYpQcy+pbuRxxFKOWpiZLa5lc17T010gd64rQ== - dependencies: - graphql-language-service-parser "^1.6.0-alpha.4" - graphql-language-service-types "^1.6.0-alpha.6" - graphql-language-service-utils "^2.4.0-alpha.7" + graphql-language-service-parser "^1.6.0" + graphql-language-service-types "^1.6.0" + graphql-language-service-utils "^2.4.0" vscode-languageserver-types "^3.15.1" -graphql-language-service-parser@^1.6.0-alpha.4: - version "1.6.0-alpha.4" - resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.6.0-alpha.4.tgz#5d80046b1a3848a655d8f514ef65f40261b74897" - integrity sha512-l76uie88SNsa37Noc0/Bpw3HT1UxLdIrrs5A3NDj6Fm4FHmri+0Mvgjygpa3y4Y0W3AGje2MILtuJUmUSjiriA== +graphql-language-service-parser@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.6.0.tgz#53a97619461ed41ae237b9070e7e20cfaca56118" + integrity sha512-tkfYXl6WBECWNcsyw7O09c0oCMrxqr3JGyDNvdISDSDhvk8EwEk+AOweBEJkycJwoiv1lVlM+EdLfj7dzw4/6w== -graphql-language-service-types@^1.6.0-alpha.6: - version "1.6.0-alpha.6" - resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.6.0-alpha.6.tgz#a7a50abbdfaf41074019d107754fc9c00ed824e2" - integrity sha512-cQfLC4CYDBVKcr3C53apqqd9imT+el3oEvIPz5/MocClWAZa4EsoEr2Z4WP9X5L4QrLk3hhFOJHor1aPzQRsLw== +graphql-language-service-types@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.6.0.tgz#32529bb9d2e3b8ae9dd6d9646e15481ac5dfe9ad" + integrity sha512-7y4pkhTL+MtarC8CeHpvRYVc6pkT6ITdQXs+Gr7OClnk4Lz5nQEK0eO29kUdd0jEnYfHyh5iqhnL5Owfy+wT0A== -graphql-language-service-utils@^2.4.0-alpha.7: - version "2.4.0-alpha.7" - resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.4.0-alpha.7.tgz#a78150eb57a079af52eaffadf9f21a9b3c82cea9" - integrity sha512-iPw9aFUWAJJx+aWY9DUf4rvIKnJVB1Qoaf7NzHegEC7LMIg7052i8ealMFKiSqk0q/zP6PCmiQblCMnGCjnEwg== +graphql-language-service-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.4.0.tgz#d4992a1968860abc43a118de498fec84361a4b87" + integrity sha512-aLa+8iqb7AJYgdcawsKH8/KLc14DHcRsnveshOm8hN6bBVT0YiQP6mEfJoci741O74uaDF2zj1J3c02vorichw== dependencies: - graphql-language-service-types "^1.6.0-alpha.6" + graphql-language-service-types "^1.6.0" -graphql-language-service@^3.0.0-alpha.1: - version "3.0.0-alpha.1" - resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.0.0-alpha.1.tgz#dc04c0ca3f35ce322dc38548aaa023cde867ca1b" - integrity sha512-JkkONXR7+yYcm8Q4bohBJtRl6hLodgogt01lS0ROX8lcwkaihKaU+dyoSVgrRaNAibahjFrDLo0j7iAOarJ5ow== +graphql-language-service@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.0.0.tgz#953c3bbf4cc35ff9a7bd54e428bca17832367490" + integrity sha512-ySPrbecxtjQtzIFdCIOoSJzaco9dQvZK+wZN/tTjcSEEWh3fgMYXmctAG+WddJiZg3AB7I4Xsb2AsO5yFBbLHg== dependencies: - graphql-config "3.0.0-rc.2" - graphql-language-service-interface "^2.4.0-alpha.8" - graphql-language-service-types "^1.6.0-alpha.6" - -graphql-tools@5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/graphql-tools/-/graphql-tools-5.0.0.tgz#67281c834a0e29f458adba8018f424816fa627e9" - integrity sha512-5zn3vtn//382b7G3Wzz3d5q/sh+f7tVrnxeuhTMTJ7pWJijNqLxH7VEzv8VwXCq19zAzHYEosFHfXiK7qzvk7w== - dependencies: - apollo-link "^1.2.14" - apollo-upload-client "^13.0.0" - deprecated-decorator "^0.1.6" - form-data "^3.0.0" - iterall "^1.3.0" - node-fetch "^2.6.0" - tslib "^1.11.1" - uuid "^7.0.3" + graphql-language-service-interface "^2.4.0" + graphql-language-service-types "^1.6.0" graphql@15.1.0, graphql@^15.0.0: version "15.1.0" @@ -10802,13 +10638,6 @@ is-generator-fn@^2.0.0: resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-glob@4.0.1, is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - is-glob@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" @@ -10823,6 +10652,13 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + is-hexadecimal@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" @@ -11170,11 +11006,6 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -iterall@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" - integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== - iterate-iterator@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6" @@ -13042,13 +12873,13 @@ monaco-editor@^0.20.0: resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea" integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ== -monaco-graphql@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-0.1.1.tgz#d74b03a14d60805841157efe99c3ddec6e8c1407" - integrity sha512-P6K03iP2hme9geA9TR2zYkSrdhVR0/YcMBQjAaTALGvI4dIILTieGxuBeElZAZFEhsj38CNZZKUF8pKXapA86Q== +monaco-graphql@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-0.2.0.tgz#be6bd165b6cbdc624fde6c108fe9ee32f50f2e80" + integrity sha512-IwpbJvrNlFxq6xjnQQ8eC72ep+q2vfiUaHBNkfqe314CXYnUCcXYfoMBfR5EuLAXRBOTf4CKl6BCN1/9eHrq7A== dependencies: - graphql-language-service "^3.0.0-alpha.1" - graphql-language-service-utils "^2.4.0-alpha.7" + graphql-language-service "^3.0.0" + graphql-language-service-utils "^2.4.0" monaco-editor "^0.20.0" morgan@^1.10.0: @@ -13906,13 +13737,6 @@ p-finally@^2.0.0: resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== -p-limit@2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - p-limit@^1.1.0: version "1.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -16269,11 +16093,6 @@ resolve-dir@^1.0.0, resolve-dir@^1.0.1: expand-tilde "^2.0.0" global-modules "^1.0.0" -resolve-from@5.0.0, resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" @@ -16284,6 +16103,11 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + resolve-pathname@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" @@ -18282,13 +18106,6 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.10.tgz#b68a49e37e90a05797e590f08494dd528bf383cf" integrity sha512-UJYuKET7ez7ry0CnvfY6fPIUIZDw+UI3qvTUQeS2MyI4TgEeWAUBqy185LeaHcdJ9zG2dgFpPJU/AecXU0Afug== -ts-invariant@^0.4.0: - version "0.4.4" - resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" - integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== - dependencies: - tslib "^1.9.3" - ts-jest@^26.0.0: version "26.1.0" resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.0.tgz#e9070fc97b3ea5557a48b67c631c74eb35e15417" @@ -18347,21 +18164,11 @@ tslib@1.10.0: resolved "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== -tslib@1.11.2: - version "1.11.2" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.2.tgz#9c79d83272c9a7aaf166f73915c9667ecdde3cc9" - integrity sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg== - tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.11.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== -tslib@^1.11.1: - version "1.13.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== - tsutils@^3.17.1: version "3.17.1" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -18644,13 +18451,6 @@ universalify@^1.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== -unixify@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" - integrity sha1-OmQcjC/7zk2mg6XHDwOkYpQMIJA= - dependencies: - normalize-path "^2.1.1" - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -18864,11 +18664,6 @@ v8flags@^3.1.3: dependencies: homedir-polyfill "^1.0.1" -valid-url@1.0.9: - version "1.0.9" - resolved "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" - integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA= - validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3: version "3.0.4" resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -19642,15 +19437,7 @@ yup@^0.29.1: synchronous-promise "^2.0.10" toposort "^2.0.2" -zen-observable-ts@^0.8.21: - version "0.8.21" - resolved "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d" - integrity sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg== - dependencies: - tslib "^1.9.3" - zen-observable "^0.8.0" - -zen-observable@^0.8.0, zen-observable@^0.8.15: +zen-observable@^0.8.15: version "0.8.15" resolved "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== From d65c67ea93da82c95737e92caf7fb72e778a634c Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Fri, 26 Jun 2020 09:27:24 +0200 Subject: [PATCH 35/64] fix(scaffolder): show progressbar if no data --- plugins/scaffolder/src/components/ScaffolderPage/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index aa2a8b639a..2f9774757b 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -90,7 +90,7 @@ const ScaffolderPage: React.FC<{}> = () => { . - {isValidating && } + {!templates && isValidating && } {templates && templates.map(template => { @@ -103,7 +103,7 @@ const ScaffolderPage: React.FC<{}> = () => { }`} type={template.spec.type ?? ''} description={template.metadata.description ?? '-'} - tags={template.metadata?.tags ?? []} + tags={(template.metadata?.tags as string[]) ?? []} /> ); From 984b5acccc48bcc5344822d25fac4450dfead90e Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 12:18:52 +0200 Subject: [PATCH 36/64] feat(mkdocs-reader): initial navigation inside docs --- plugins/techdocs/package.json | 1 + plugins/techdocs/src/config.js | 16 +++++ plugins/techdocs/src/plugin.ts | 2 +- .../techdocs/src/reader/components/Reader.tsx | 62 ++++++++----------- .../techdocs/src/transformers/addBaseUrl.ts | 52 ++++++++++++++++ plugins/techdocs/src/transformers/index.ts | 28 +++++++++ .../src/transformers/rewriteDocLinks.ts | 38 ++++++++++++ 7 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 plugins/techdocs/src/config.js create mode 100644 plugins/techdocs/src/transformers/addBaseUrl.ts create mode 100644 plugins/techdocs/src/transformers/index.ts create mode 100644 plugins/techdocs/src/transformers/rewriteDocLinks.ts diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 9ef290fa40..b84b98c745 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,6 +29,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js new file mode 100644 index 0000000000..2baf8b725c --- /dev/null +++ b/plugins/techdocs/src/config.js @@ -0,0 +1,16 @@ +/* + * Copyright 2020 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 const baseUrl = 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 23bfa3b714..fec2b1cf74 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -33,7 +33,7 @@ import { createPlugin, createRouteRef } from '@backstage/core'; import { Reader } from './reader/components/Reader'; export const rootRouteRef = createRouteRef({ - path: '/docs', + path: '/docs/:componentId/*', title: 'Docs', }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 5517c0c715..36816fcc9a 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,6 +17,10 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; +import { baseUrl } from '../../config'; +import { Link } from '@backstage/core'; +import { useLocation, useParams } from 'react-router-dom'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -28,54 +32,42 @@ const useFetch = (url: string) => { return state; }; -const addBaseUrl = (htmlString: string, baseUrl: string): string => { - const domParser = new DOMParser().parseFromString(htmlString, 'text/html'); - - const updateDom = ( - list: Array, - attributeName: string, - ): void => { - Array.from(list).forEach((elem: T) => { - const newUrl = new URL( - elem.getAttribute(attributeName)!, - baseUrl, - ).toString(); - elem.setAttribute(attributeName, newUrl); - }); - }; - - updateDom(Array.from(domParser.images), 'src'); - updateDom( - Array.from(domParser.links), - 'href', - ); - updateDom( - Array.from(domParser.querySelectorAll('link')), - 'href', - ); - - return domParser.body.parentElement?.outerHTML || htmlString; +const normalizeUrl = (path: string) => { + return path.replace(/\/\/index.html$/, '/index.html'); }; export const Reader = () => { + const location = useLocation(); + const { componentId, '*': path } = useParams(); const shadowDomRef = useShadowDom(); const state = useFetch( - 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html', + normalizeUrl( + `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + ), ); - + // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { - divElement.shadowRoot.innerHTML = addBaseUrl( - state.value, - 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/', - ); + divElement.shadowRoot.innerHTML = transformer(state.value, [ + addBaseUrl({ + baseUrl, + componentId, + path, + }), + rewriteDocLinks({ + componentId, + }), + ]); } - }, [shadowDomRef, state]); + }, [shadowDomRef, state, componentId, path]); return ( <> -

      Shadow DOM should be underneath

      +
      ); diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/transformers/addBaseUrl.ts new file mode 100644 index 0000000000..674e3ab33b --- /dev/null +++ b/plugins/techdocs/src/transformers/addBaseUrl.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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. + */ +type AddBaseUrlOptions = { + baseUrl: string; + componentId: string; + path: string; +}; + +export const addBaseUrl = ({ + baseUrl, + componentId, + path, +}: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = new URL( + elem.getAttribute(attributeName)!, + `${baseUrl}/${componentId}/${path}`, + ).toString(); + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.images), 'src'); + updateDom(Array.from(dom.scripts), 'src'); + updateDom( + Array.from(dom.querySelectorAll('link')), + 'href', + ); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/transformers/index.ts new file mode 100644 index 0000000000..b530840d08 --- /dev/null +++ b/plugins/techdocs/src/transformers/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 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 './addBaseUrl'; +export * from './rewriteDocLinks'; + +type Transformer = (dom: Document) => Document; + +export default (html: string, transformers: Transformer[]): string => { + const dom = new DOMParser().parseFromString(html, 'text/html'); + + transformers.forEach(transformer => transformer(dom)); + + return dom.body.parentElement?.outerHTML ?? ''; +}; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/transformers/rewriteDocLinks.ts new file mode 100644 index 0000000000..459b7eda3c --- /dev/null +++ b/plugins/techdocs/src/transformers/rewriteDocLinks.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 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. + */ +type AddBaseUrlOptions = { + componentId: string; +}; + +export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.getElementsByTagName('a')), 'href'); + + return dom; + }; +}; From 879bc21893f076a1386613dae6da10f4751f4b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 26 Jun 2020 09:41:28 +0200 Subject: [PATCH 37/64] feat(test-utils): mock storage api --- packages/test-utils/package.json | 3 +- .../apis/StorageApi/MockStorageApi.test.ts | 142 ++++++++++++++++++ .../apis/StorageApi/MockStorageApi.ts | 90 +++++++++++ .../src/testUtils/apis/StorageApi/index.ts | 18 +++ .../test-utils/src/testUtils/apis/index.ts | 1 + .../src/testUtils/mockApiRegistry.ts | 3 +- 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/index.ts diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 501e288754..f0ed4905d5 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -41,7 +41,8 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-router": "^6.0.0-alpha.5", - "react-router-dom": "^6.0.0-alpha.5" + "react-router-dom": "^6.0.0-alpha.5", + "zen-observable": "^0.8.15" }, "devDependencies": { "@types/jest": "^25.2.2", diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts new file mode 100644 index 0000000000..668d973830 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2020 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 { MockStorageApi } from './MockStorageApi'; +import { StorageApi } from '@backstage/core-api'; + +describe('WebStorage Storage API', () => { + const createMockStorage = (): StorageApi => { + return MockStorageApi.create(); + }; + + it('should return undefined for values which are unset', async () => { + const storage = createMockStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = createMockStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createMockStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createMockStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = createMockStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts new file mode 100644 index 0000000000..a3a4ef16d6 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 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, + StorageApi, + storageApiRef, + StorageValueChange, +} from '@backstage/core-api'; +import ObservableImpl from 'zen-observable'; + +export type MockStorageBucket = { [key: string]: any }; + +export class MockStorageApi implements StorageApi { + static factory = { + implements: storageApiRef, + deps: {}, + factory: () => MockStorageApi.create(), + }; + + private readonly namespace: string; + private readonly data: MockStorageBucket; + + private constructor(namespace: string, data?: MockStorageBucket) { + this.namespace = namespace; + this.data = { ...data }; + } + + static create(data?: MockStorageBucket) { + return new MockStorageApi('', data); + } + + forBucket(name: string): StorageApi { + return new MockStorageApi(`${this.namespace}/${name}`, this.data); + } + + get(key: string): T | undefined { + return this.data[this.getKeyName(key)]; + } + + async set(key: string, data: T): Promise { + this.data[this.getKeyName(key)] = data; + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + delete this.data[this.getKeyName(key)]; + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/index.ts b/packages/test-utils/src/testUtils/apis/StorageApi/index.ts new file mode 100644 index 0000000000..ec4557b4c7 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 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 { MockStorageApi } from './MockStorageApi'; +export type { MockStorageBucket } from './MockStorageApi'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index 55d6d10dd6..88229061de 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -15,3 +15,4 @@ */ export * from './ErrorApi'; +export * from './StorageApi'; diff --git a/packages/test-utils/src/testUtils/mockApiRegistry.ts b/packages/test-utils/src/testUtils/mockApiRegistry.ts index 96d12a9df2..15733ead88 100644 --- a/packages/test-utils/src/testUtils/mockApiRegistry.ts +++ b/packages/test-utils/src/testUtils/mockApiRegistry.ts @@ -15,12 +15,13 @@ */ import { ApiTestRegistry } from '@backstage/core-api'; -import { MockErrorApi } from './apis'; +import { MockErrorApi, MockStorageApi } from './apis'; export function createMockApiRegistry(): ApiTestRegistry { const registry = new ApiTestRegistry(); registry.register(MockErrorApi.factory); + registry.register(MockStorageApi.factory); return registry; } From d1f9c51d11d3379bc9563ed9db228039cdeaa5aa Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 13:27:37 +0200 Subject: [PATCH 38/64] fix react-router version --- plugins/techdocs/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b84b98c745..ca8e957a46 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,7 +29,8 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router": "^6.0.0-alpha.5", + "react-router-dom": "^6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { From 674d14241870318b8178602b09f01c8f98d64ec5 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:53:19 +0200 Subject: [PATCH 39/64] Update URLS better --- plugins/techdocs/src/config.js | 3 +- .../techdocs/src/reader/components/Reader.tsx | 20 +++++++----- .../{ => reader}/transformers/addBaseUrl.ts | 13 +++++--- .../src/{ => reader}/transformers/index.ts | 7 +++-- .../transformers/rewriteDocLinks.ts | 20 +++++++----- plugins/techdocs/src/reader/urlParser.ts | 31 +++++++++++++++++++ 6 files changed, 71 insertions(+), 23 deletions(-) rename plugins/techdocs/src/{ => reader}/transformers/addBaseUrl.ts (87%) rename plugins/techdocs/src/{ => reader}/transformers/index.ts (85%) rename plugins/techdocs/src/{ => reader}/transformers/rewriteDocLinks.ts (69%) create mode 100644 plugins/techdocs/src/reader/urlParser.ts diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js index 2baf8b725c..296b9fc14c 100644 --- a/plugins/techdocs/src/config.js +++ b/plugins/techdocs/src/config.js @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const baseUrl = 'https://techdocs-mock-sites.storage.googleapis.com'; +export const docStorageURL = + 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 36816fcc9a..0a0bb88421 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; -import { baseUrl } from '../../config'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; +import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; @@ -42,16 +42,16 @@ export const Reader = () => { const shadowDomRef = useShadowDom(); const state = useFetch( normalizeUrl( - `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + `${docStorageURL}${location.pathname.replace('/docs', '')}/index.html`, ), ); - // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { - divElement.shadowRoot.innerHTML = transformer(state.value, [ + const transformedElement = transformer(state.value, [ addBaseUrl({ - baseUrl, + docStorageURL, componentId, path, }), @@ -59,14 +59,18 @@ export const Reader = () => { componentId, }), ]); + + divElement.shadowRoot.innerHTML = ''; + if (transformedElement) + divElement.shadowRoot.appendChild(transformedElement); } }, [shadowDomRef, state, componentId, path]); return ( <>
      diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts similarity index 87% rename from plugins/techdocs/src/transformers/addBaseUrl.ts rename to plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 674e3ab33b..e07a41fc03 100644 --- a/plugins/techdocs/src/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import URLParser from '../urlParser'; + type AddBaseUrlOptions = { - baseUrl: string; + docStorageURL: string; componentId: string; path: string; }; export const addBaseUrl = ({ - baseUrl, + docStorageURL, componentId, path, }: AddBaseUrlOptions) => { @@ -32,10 +35,10 @@ export const addBaseUrl = ({ Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = new URL( + const newUrl = new URLParser( + `${docStorageURL}/${componentId}/${path}`, elem.getAttribute(attributeName)!, - `${baseUrl}/${componentId}/${path}`, - ).toString(); + ).parse(); elem.setAttribute(attributeName, newUrl); }); }; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts similarity index 85% rename from plugins/techdocs/src/transformers/index.ts rename to plugins/techdocs/src/reader/transformers/index.ts index b530840d08..008c86e261 100644 --- a/plugins/techdocs/src/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -19,10 +19,13 @@ export * from './rewriteDocLinks'; type Transformer = (dom: Document) => Document; -export default (html: string, transformers: Transformer[]): string => { +export default ( + html: string, + transformers: Transformer[], +): HTMLElement | undefined => { const dom = new DOMParser().parseFromString(html, 'text/html'); transformers.forEach(transformer => transformer(dom)); - return dom.body.parentElement?.outerHTML ?? ''; + return dom.body.parentElement ?? undefined; }; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts similarity index 69% rename from plugins/techdocs/src/transformers/rewriteDocLinks.ts rename to plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 459b7eda3c..373db51abd 100644 --- a/plugins/techdocs/src/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -13,21 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -type AddBaseUrlOptions = { - componentId: string; -}; -export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { +import URLParser from '../urlParser'; + +type AddBaseUrlOptions = {}; + +export const rewriteDocLinks = ({}: AddBaseUrlOptions) => { return (dom: Document): Document => { const updateDom = ( list: Array, attributeName: string, ): void => { Array.from(list) - .filter(elem => !!elem.getAttribute(attributeName)) + .filter(elem => elem.hasAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; - elem.setAttribute(attributeName, newUrl); + elem.setAttribute( + attributeName, + new URLParser( + window.location.href, + elem.getAttribute(attributeName)!, + ).parse(), + ); }); }; diff --git a/plugins/techdocs/src/reader/urlParser.ts b/plugins/techdocs/src/reader/urlParser.ts new file mode 100644 index 0000000000..b1429b7635 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 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. + */ + +const normalizeBaseURL = (baseURL: string): string => { + const url = new URL(baseURL); + url.pathname = url.pathname.replace(/([^/])$/, '$1/'); + return url.toString(); +}; + +export default class URLParser { + constructor(public baseURL: string, public pathname: string) { + this.baseURL = normalizeBaseURL(baseURL); + } + + parse(): string { + return new URL(this.pathname, this.baseURL).toString(); + } +} From d917f72280589547ae026634f78dc52849dc9520 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:59:50 +0200 Subject: [PATCH 40/64] Enforce trailing slash --- plugins/techdocs/src/reader/components/Reader.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 0a0bb88421..d4e84aa8d5 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -21,6 +21,7 @@ import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; +import URLParser from '../urlParser'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -36,6 +37,17 @@ const normalizeUrl = (path: string) => { return path.replace(/\/\/index.html$/, '/index.html'); }; +const useEnforcedTrailingSlash = (): void => { + React.useEffect(() => { + const actualUrl = window.location.href; + const expectedUrl = new URLParser(window.location.href, '.').parse(); + + if (actualUrl !== expectedUrl) { + window.history.replaceState({}, document.title, expectedUrl); + } + }, []); +}; + export const Reader = () => { const location = useLocation(); const { componentId, '*': path } = useParams(); @@ -46,6 +58,8 @@ export const Reader = () => { ), ); + useEnforcedTrailingSlash(); + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { From 14bbaf099ebb33a62c0a093f9e93691f382e208f Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 15:09:51 +0200 Subject: [PATCH 41/64] Add tests for URLParser --- plugins/techdocs/src/reader/urlParser.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 plugins/techdocs/src/reader/urlParser.test.ts diff --git a/plugins/techdocs/src/reader/urlParser.test.ts b/plugins/techdocs/src/reader/urlParser.test.ts new file mode 100644 index 0000000000..4322fc42e1 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 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 URLParser from './urlParser'; + +describe('URLParser', () => { + it('should not change an absolute url', () => { + const urlParser = new URLParser( + 'https://www.google.com/', + 'https://www.mkdocs.org/', + ); + + expect(urlParser.parse()).toEqual('https://www.mkdocs.org/'); + }); + + it('should convert a relative url to an absolute url', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '../../support/installing/', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/support/installing/', + ); + }); + + it('should add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); + + it('should not add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); +}); From afcf01d6f4dbcb1694c503cd9fedb13d0f620d8b Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 16:34:30 +0200 Subject: [PATCH 42/64] Cleanups and better navigation between docs --- .../techdocs/src/reader/components/Reader.tsx | 33 +++++++------ .../src/reader/transformers/addBaseUrl.ts | 16 +++---- .../reader/transformers/addEventListener.ts | 41 +++++++++++++++++ .../techdocs/src/reader/transformers/index.ts | 46 ++++++++++++++++--- .../reader/transformers/rewriteDocLinks.ts | 5 +- 5 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 plugins/techdocs/src/reader/transformers/addEventListener.ts diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index d4e84aa8d5..0175462cc3 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,10 +17,14 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; +import transformer, { + addBaseUrl, + rewriteDocLinks, + addEventListener, +} from '../transformers'; import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation, useParams, useNavigate } from 'react-router-dom'; import URLParser from '../urlParser'; const useFetch = (url: string) => { @@ -33,10 +37,6 @@ const useFetch = (url: string) => { return state; }; -const normalizeUrl = (path: string) => { - return path.replace(/\/\/index.html$/, '/index.html'); -}; - const useEnforcedTrailingSlash = (): void => { React.useEffect(() => { const actualUrl = window.location.href; @@ -52,11 +52,12 @@ export const Reader = () => { const location = useLocation(); const { componentId, '*': path } = useParams(); const shadowDomRef = useShadowDom(); - const state = useFetch( - normalizeUrl( - `${docStorageURL}${location.pathname.replace('/docs', '')}/index.html`, - ), - ); + const navigate = useNavigate(); + const normalizedUrl = new URLParser( + `${docStorageURL}${location.pathname.replace('/docs', '')}`, + '.', + ).parse(); + const state = useFetch(`${normalizedUrl}index.html`); useEnforcedTrailingSlash(); @@ -75,10 +76,16 @@ export const Reader = () => { ]); divElement.shadowRoot.innerHTML = ''; - if (transformedElement) + if (transformedElement) { divElement.shadowRoot.appendChild(transformedElement); + transformer(divElement.shadowRoot.children[0], [ + addEventListener({ + onClick: navigate, + }), + ]); + } } - }, [shadowDomRef, state, componentId, path]); + }, [shadowDomRef, state, componentId, path, navigate]); return ( <> diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index e07a41fc03..653f0b2f71 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -15,6 +15,7 @@ */ import URLParser from '../urlParser'; +import type { Transformer } from './index'; type AddBaseUrlOptions = { docStorageURL: string; @@ -26,10 +27,10 @@ export const addBaseUrl = ({ docStorageURL, componentId, path, -}: AddBaseUrlOptions) => { - return (dom: Document): Document => { +}: AddBaseUrlOptions): Transformer => { + return dom => { const updateDom = ( - list: Array, + list: HTMLCollectionOf | NodeListOf, attributeName: string, ): void => { Array.from(list) @@ -43,12 +44,9 @@ export const addBaseUrl = ({ }); }; - updateDom(Array.from(dom.images), 'src'); - updateDom(Array.from(dom.scripts), 'src'); - updateDom( - Array.from(dom.querySelectorAll('link')), - 'href', - ); + updateDom(dom.querySelectorAll('img'), 'src'); + updateDom(dom.querySelectorAll('script'), 'src'); + updateDom(dom.querySelectorAll('link'), 'href'); return dom; }; diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.ts b/plugins/techdocs/src/reader/transformers/addEventListener.ts new file mode 100644 index 0000000000..32b9d6b0b9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addEventListener.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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 type { Transformer } from './index'; + +type AddEventListenerOptions = { + onClick: (newUrl: string) => void; +}; + +export const addEventListener = ({ + onClick, +}: AddEventListenerOptions): Transformer => { + return dom => { + Array.from(dom.getElementsByTagName('a')).forEach(elem => { + elem.addEventListener('click', (e: MouseEvent) => { + e.preventDefault(); + const target = e.target as HTMLAnchorElement; + if (target?.getAttribute('href')) { + onClick( + target.getAttribute('href')!.replace(window.location.origin, ''), + ); + } + }); + }); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 008c86e261..74c9eb6549 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -16,16 +16,48 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; +export * from './addEventListener'; -type Transformer = (dom: Document) => Document; +export type Transformer = (dom: Element) => Element; -export default ( - html: string, +function transform( + html: string | Element, transformers: Transformer[], -): HTMLElement | undefined => { - const dom = new DOMParser().parseFromString(html, 'text/html'); +): Element { + let dom: Element; + + if (typeof html === 'string') { + dom = new DOMParser().parseFromString(html, 'text/html').documentElement; + } else if (html instanceof Element) { + dom = html; + } else { + throw new Error('dom is not a recognized type'); + } transformers.forEach(transformer => transformer(dom)); - return dom.body.parentElement ?? undefined; -}; + return dom; +} + +// function transform( +// html: string, +// transformers: Transformer[], +// ): HTMLElement { +// const dom = new DOMParser().parseFromString(html, 'text/html'); + +// transformers.forEach(transformer => transformer(dom)); + +// return dom.documentElement; +// }; + +// function transform( +// html: HTMLElement, +// transformers: Transformer[], +// ): HTMLElement { + +// transformers.forEach(transformer => transformer(element)); + +// return html; +// }; + +export default transform; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 373db51abd..3f80dbe4e9 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -15,11 +15,12 @@ */ import URLParser from '../urlParser'; +import type { Transformer } from './index'; type AddBaseUrlOptions = {}; -export const rewriteDocLinks = ({}: AddBaseUrlOptions) => { - return (dom: Document): Document => { +export const rewriteDocLinks = ({}: AddBaseUrlOptions): Transformer => { + return dom => { const updateDom = ( list: Array, attributeName: string, From 985c1657444ad04ffec8b3a2de5f3da4ba6e3636 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 16:36:14 +0200 Subject: [PATCH 43/64] Removed commented code --- .../techdocs/src/reader/transformers/index.ts | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 74c9eb6549..64c5527fe7 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -39,25 +39,4 @@ function transform( return dom; } -// function transform( -// html: string, -// transformers: Transformer[], -// ): HTMLElement { -// const dom = new DOMParser().parseFromString(html, 'text/html'); - -// transformers.forEach(transformer => transformer(dom)); - -// return dom.documentElement; -// }; - -// function transform( -// html: HTMLElement, -// transformers: Transformer[], -// ): HTMLElement { - -// transformers.forEach(transformer => transformer(element)); - -// return html; -// }; - export default transform; From ad9d74ff240c934ce46a474ae3f440d5f5fa493b Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 18:14:41 +0200 Subject: [PATCH 44/64] chore(scaffolder): Moving the cookiecutter dockerfile to more semantic name --- .../scripts/{Dockerfile => Cookiecutter.dockerfile} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/scaffolder-backend/scripts/{Dockerfile => Cookiecutter.dockerfile} (100%) diff --git a/plugins/scaffolder-backend/scripts/Dockerfile b/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile similarity index 100% rename from plugins/scaffolder-backend/scripts/Dockerfile rename to plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile From 5d7679d8becb363f40556f2a6950f5fbee317e3a Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Mon, 22 Jun 2020 16:32:05 +0200 Subject: [PATCH 45/64] Initial useEntityFilterGroup implementation --- .../CatalogFilter/CatalogFilter.tsx | 5 +- .../components/CatalogPage/CatalogPage.tsx | 2 +- .../catalog/src/hooks/useEntities.test.tsx | 105 ++++++++++++++++++ plugins/catalog/src/hooks/useEntities.ts | 85 +++++++++++++- 4 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 plugins/catalog/src/hooks/useEntities.test.tsx diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 61a83fccd8..e40f4806ed 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -20,6 +20,7 @@ import { List, ListItemIcon, ListItemText, + ListItemSecondaryAction, MenuItem, Typography, Theme, @@ -109,7 +110,9 @@ export const CatalogFilter: FC<{ {item.label} - {entitiesByFilter[item.id]?.length ?? '-'} + + {entitiesByFilter[item.id]?.length ?? '-'} + ))} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ef77cf573f..9329eb9317 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -162,7 +162,7 @@ export const CatalogPage: FC<{}> = () => { color="primary" to={scaffolderRootRoute.path} > - Create Service + Create Component All your software catalog entities diff --git a/plugins/catalog/src/hooks/useEntities.test.tsx b/plugins/catalog/src/hooks/useEntities.test.tsx new file mode 100644 index 0000000000..c8176f3a6c --- /dev/null +++ b/plugins/catalog/src/hooks/useEntities.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2020 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useEntityFilterGroup } from './useEntities'; + +describe('useEntitiesHooks', () => { + const testEntities = [ + { name: 'test1', type: 'type1' }, + { name: 'test2', type: 'type2' }, + { name: 'test3', type: 'type3' }, + { name: 'test4', type: 'type2' }, + { name: 'test5', type: 'type2' }, + ]; + + type TestEntitiy = { + name: string; + type: string; + }; + + const testFilterFunctions = { + type1: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type1', + isSelected: false, + }, + type2: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type2', + isSelected: false, + }, + type3: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type3', + isSelected: false, + }, + }; + + it('should calculate count', async () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + expect(result.current.states.type1.count).toBe(1); + expect(result.current.states.type2.count).toBe(3); + expect(result.current.states.type3.count).toBe(1); + }); + + it('should set the isSelected flag properly', () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + expect(result.current.states.type1.isSelected).toBeFalsy(); + expect(result.current.states.type2.isSelected).toBeFalsy(); + expect(result.current.states.type3.isSelected).toBeFalsy(); + + act(() => { + result.current.selectItems(['type1']); + }); + + expect(result.current.states.type1.isSelected).toBeTruthy(); + expect(result.current.states.type2.isSelected).toBeFalsy(); + expect(result.current.states.type3.isSelected).toBeFalsy(); + }); + + it('should filter entities', () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + act(() => { + result.current.selectItems(['type1']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test1', type: 'type1' }, + ]); + + act(() => { + result.current.selectItems(['type2']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test2', type: 'type2' }, + { name: 'test4', type: 'type2' }, + { name: 'test5', type: 'type2' }, + ]); + + act(() => { + result.current.selectItems(['type3', 'type1']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test1', type: 'type1' }, + { name: 'test3', type: 'type3' }, + ]); + }); +}); diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts index 6509bd9b4b..c48e3af026 100644 --- a/plugins/catalog/src/hooks/useEntities.ts +++ b/plugins/catalog/src/hooks/useEntities.ts @@ -40,6 +40,88 @@ type UseEntities = { selectTypeFilter: (id: string) => void; }; +type EntityFilterGroupOutput = { + selectItems: (items: string[]) => void; + filteredItems: T[]; + states: OutputState; +}; + +type OutputState = { [key: string]: { isSelected: boolean; count: number } }; + +type FilterDefinition = { + [key: string]: { + isSelected: boolean; + filterFunction: (entity: T) => boolean; + }; +}; + +export const useEntityFilterGroup = ( + entities: T[], + filterFunctions: FilterDefinition, +): EntityFilterGroupOutput => { + const [filterFuncs, setFilterFuncs] = useState>( + filterFunctions, + ); + + // and + // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) + + return { + selectItems: (functionNames: Array) => { + const selectedFilterFunctions = Object.fromEntries( + Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ + key, + { isSelected: functionNames.includes(key), filterFunction }, + ]), + ); + setFilterFuncs(selectedFilterFunctions); + }, + filteredItems: entities.filter(entity => + Object.entries(filterFuncs) + .filter(([_, { isSelected }]) => isSelected) + .map(([_, { filterFunction }]) => filterFunction) + .map(filter => filter(entity)) + .some(v => v === true), + ), + states: Object.keys(filterFuncs).reduce( + (acc, val) => ({ + ...acc, + [val]: { + ...filterFuncs[val], + count: entities.filter(filterFuncs[val].filterFunction).length, + }, + }), + {} as OutputState, + ), + }; +}; + +// const MyFilterGroup = () => { +// const { selectedItems, selectItems, counts } = useEntityFilterGroup( +// 'lifecycle', +// { +// production: e => e.spec?.lifecyle === 'production', +// }, +// ); + +// return ( +// +// selectItem('production')} +// > +// Production ({counts.production}) +// +// +// ); +// }; + +export const useUser = () => { + const indentityApi = useApi(identityApiRef); + const userId = indentityApi.getUserId(); + return { userId }; +}; + export const useEntities = (): UseEntities => { const [selectedFilter, setSelectedFilter] = useState< EntityGroup | undefined @@ -51,8 +133,7 @@ export const useEntities = (): UseEntities => { async () => catalogApi.getEntities(), ); - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); + const { userId } = useUser(); const [selectedTypeFilter, selectTypeFilter] = useState( labeledEntityTypes[0].id, From f895e4b4e33a329f1655aaf716143b773daa6f5c Mon Sep 17 00:00:00 2001 From: Nikita Nek Dudnik Date: Tue, 23 Jun 2020 22:23:25 +0200 Subject: [PATCH 46/64] feat(catalog): implement useEntityFilterGroup hook --- .../catalog/src/hooks/useEntities.test.tsx | 105 ----- plugins/catalog/src/hooks/useEntities.ts | 184 -------- plugins/catalog/src/hooks/useEntities.tsx | 404 ++++++++++++++++++ .../src/hooks/useEntityFilterGroup.test.tsx | 114 +++++ .../src/hooks/useEntityFilterGroup.tsx | 273 ++++++++++++ 5 files changed, 791 insertions(+), 289 deletions(-) delete mode 100644 plugins/catalog/src/hooks/useEntities.test.tsx delete mode 100644 plugins/catalog/src/hooks/useEntities.ts create mode 100644 plugins/catalog/src/hooks/useEntities.tsx create mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx create mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.tsx diff --git a/plugins/catalog/src/hooks/useEntities.test.tsx b/plugins/catalog/src/hooks/useEntities.test.tsx deleted file mode 100644 index c8176f3a6c..0000000000 --- a/plugins/catalog/src/hooks/useEntities.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2020 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 { renderHook, act } from '@testing-library/react-hooks'; -import { useEntityFilterGroup } from './useEntities'; - -describe('useEntitiesHooks', () => { - const testEntities = [ - { name: 'test1', type: 'type1' }, - { name: 'test2', type: 'type2' }, - { name: 'test3', type: 'type3' }, - { name: 'test4', type: 'type2' }, - { name: 'test5', type: 'type2' }, - ]; - - type TestEntitiy = { - name: string; - type: string; - }; - - const testFilterFunctions = { - type1: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type1', - isSelected: false, - }, - type2: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type2', - isSelected: false, - }, - type3: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type3', - isSelected: false, - }, - }; - - it('should calculate count', async () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - expect(result.current.states.type1.count).toBe(1); - expect(result.current.states.type2.count).toBe(3); - expect(result.current.states.type3.count).toBe(1); - }); - - it('should set the isSelected flag properly', () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - expect(result.current.states.type1.isSelected).toBeFalsy(); - expect(result.current.states.type2.isSelected).toBeFalsy(); - expect(result.current.states.type3.isSelected).toBeFalsy(); - - act(() => { - result.current.selectItems(['type1']); - }); - - expect(result.current.states.type1.isSelected).toBeTruthy(); - expect(result.current.states.type2.isSelected).toBeFalsy(); - expect(result.current.states.type3.isSelected).toBeFalsy(); - }); - - it('should filter entities', () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - act(() => { - result.current.selectItems(['type1']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test1', type: 'type1' }, - ]); - - act(() => { - result.current.selectItems(['type2']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test2', type: 'type2' }, - { name: 'test4', type: 'type2' }, - { name: 'test5', type: 'type2' }, - ]); - - act(() => { - result.current.selectItems(['type3', 'type1']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test1', type: 'type1' }, - { name: 'test3', type: 'type3' }, - ]); - }); -}); diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts deleted file mode 100644 index c48e3af026..0000000000 --- a/plugins/catalog/src/hooks/useEntities.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright 2020 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 { useState, useMemo } from 'react'; -import { - EntityGroup, - entityFilters, - entityTypeFilter, - labeledEntityTypes, -} from '../data/filters'; -import { useApi, identityApiRef } from '@backstage/core'; -import { catalogApiRef } from '..'; -import { useStarredEntities } from './useStarredEntites'; -import { Entity } from '@backstage/catalog-model'; -import useStaleWhileRevalidate from 'swr'; - -export type EntitiesByFilter = Record; - -type UseEntities = { - selectedFilter: EntityGroup | undefined; - setSelectedFilter: (f: EntityGroup) => void; - error: Error | null; - toggleStarredEntity: any; - isStarredEntity: (e: Entity) => boolean; - entitiesByFilter: EntitiesByFilter; - loading: boolean; - selectedTypeFilter: string; - selectTypeFilter: (id: string) => void; -}; - -type EntityFilterGroupOutput = { - selectItems: (items: string[]) => void; - filteredItems: T[]; - states: OutputState; -}; - -type OutputState = { [key: string]: { isSelected: boolean; count: number } }; - -type FilterDefinition = { - [key: string]: { - isSelected: boolean; - filterFunction: (entity: T) => boolean; - }; -}; - -export const useEntityFilterGroup = ( - entities: T[], - filterFunctions: FilterDefinition, -): EntityFilterGroupOutput => { - const [filterFuncs, setFilterFuncs] = useState>( - filterFunctions, - ); - - // and - // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) - - return { - selectItems: (functionNames: Array) => { - const selectedFilterFunctions = Object.fromEntries( - Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ - key, - { isSelected: functionNames.includes(key), filterFunction }, - ]), - ); - setFilterFuncs(selectedFilterFunctions); - }, - filteredItems: entities.filter(entity => - Object.entries(filterFuncs) - .filter(([_, { isSelected }]) => isSelected) - .map(([_, { filterFunction }]) => filterFunction) - .map(filter => filter(entity)) - .some(v => v === true), - ), - states: Object.keys(filterFuncs).reduce( - (acc, val) => ({ - ...acc, - [val]: { - ...filterFuncs[val], - count: entities.filter(filterFuncs[val].filterFunction).length, - }, - }), - {} as OutputState, - ), - }; -}; - -// const MyFilterGroup = () => { -// const { selectedItems, selectItems, counts } = useEntityFilterGroup( -// 'lifecycle', -// { -// production: e => e.spec?.lifecyle === 'production', -// }, -// ); - -// return ( -// -// selectItem('production')} -// > -// Production ({counts.production}) -// -// -// ); -// }; - -export const useUser = () => { - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); - return { userId }; -}; - -export const useEntities = (): UseEntities => { - const [selectedFilter, setSelectedFilter] = useState< - EntityGroup | undefined - >(); - const catalogApi = useApi(catalogApiRef); - const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const { data: entities, error } = useStaleWhileRevalidate( - ['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]], - async () => catalogApi.getEntities(), - ); - - const { userId } = useUser(); - - const [selectedTypeFilter, selectTypeFilter] = useState( - labeledEntityTypes[0].id, - ); - - const entitiesByFilter = useMemo(() => { - const filterEntities = ( - ents: Entity[] | undefined, - filterId: EntityGroup, - isStarred: (e: Entity) => boolean, - user: string, - ) => { - return ents - ?.filter((e: Entity) => - entityFilters[filterId](e, { - isStarred: isStarred(e), - userId: user, - }), - ) - .filter(e => entityTypeFilter(e, selectedTypeFilter)); - }; - const data = Object.keys(EntityGroup).reduce( - (res, key) => ({ - ...res, - [key]: filterEntities( - entities, - key as EntityGroup, - isStarredEntity, - userId, - ), - }), - {} as EntitiesByFilter, - ); - return data; - }, [entities, isStarredEntity, userId, selectedTypeFilter]); - - return { - selectedFilter, - setSelectedFilter, - error, - toggleStarredEntity, - isStarredEntity, - entitiesByFilter, - loading: entities === undefined, - selectedTypeFilter, - selectTypeFilter, - }; -}; diff --git a/plugins/catalog/src/hooks/useEntities.tsx b/plugins/catalog/src/hooks/useEntities.tsx new file mode 100644 index 0000000000..825a30c09d --- /dev/null +++ b/plugins/catalog/src/hooks/useEntities.tsx @@ -0,0 +1,404 @@ +/* + * Copyright 2020 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 { + EntityGroup, + entityFilters, + entityTypeFilter, + labeledEntityTypes, +} from '../data/filters'; +import { useApi, identityApiRef } from '@backstage/core'; +import { catalogApiRef } from '..'; +import { useStarredEntities } from './useStarredEntites'; +import { Entity } from '@backstage/catalog-model'; +import useStaleWhileRevalidate from 'swr'; +import React, { + createContext, + useState, + useEffect, + useCallback, + useMemo, +} from 'react'; + +export type EntitiesByFilter = Record; + +type UseEntities = { + selectedFilter: EntityGroup | undefined; + setSelectedFilter: (f: EntityGroup) => void; + error: Error | null; + toggleStarredEntity: any; + isStarredEntity: (e: Entity) => boolean; + entitiesByFilter: EntitiesByFilter; + loading: boolean; + selectedTypeFilter: string; + selectTypeFilter: (id: string) => void; +}; + +export type FilterGroup = { + filters: { + [key: string]: (entity: Entity) => boolean; + }; +}; + +export type FilterGroupState = { + filters: { + [key: string]: { + isSelected: boolean; + matchCount: number; + }; + }; +}; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; + +export type FilterGroupsContext = { + register: (filterGroupId: string, filterGroup: FilterGroup) => void; + unregister: (filterGroupId: string) => void; + setSelectedFilters: (filterGroupId: string, filters: string[]) => void; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext( + {} as FilterGroupsContext, +); + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const catalogApi = useApi(catalogApiRef); + const { + data: entities, + error, + } = useStaleWhileRevalidate('catalog/getEntities', async () => + catalogApi.getEntities(), + ); + + const [filterGroups, setFilterGroups] = useState<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [selectedFilterKeys, setSelectedFilterKeys] = useState>( + new Set(), + ); + const [matchingEntities, setMatchingEntities] = useState([]); + + const buildMatchingEntities = useCallback( + (excludeFilterGroupId?: string): Entity[] => { + // Build one filter fn per filter group + const allFilters: ((entity: Entity) => boolean)[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: ((entity: Entity) => boolean)[] = []; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + if (selectedFilterKeys.has(`${filterGroupId}.${filterId}`)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // All filter groups that had any checked filters need to match. Note that + // every() always returns true for an empty array. + return ( + entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [] + ); + }, + [entities?.filter, filterGroups, selectedFilterKeys], + ); + const buildStates = useCallback((): { + [filterGroupId: string]: FilterGroupStates; + } => { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities || !filterGroups.length) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities(filterGroupId); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = selectedFilterKeys.has( + `${filterGroupId}.${filterId}`, + ); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; + }, [ + buildMatchingEntities, + entities, + error, + filterGroups, + selectedFilterKeys, + ]); + + useEffect(() => { + setFilterGroupStates(buildStates()); + setMatchingEntities(buildMatchingEntities()); + }, [ + entities, + error, + filterGroups, + selectedFilterKeys, + buildStates, + buildMatchingEntities, + ]); + + const register = useCallback( + (filterGroupId: string, filterGroup: FilterGroup) => { + setFilterGroups(oldGroups => ({ + ...oldGroups, + [filterGroupId]: filterGroup, + })); + }, + [], + ); + + const unregister = useCallback((filterGroupId: string) => { + setFilterGroups(oldGroups => { + const copy = { ...oldGroups }; + delete copy[filterGroupId]; + return copy; + }); + setFilterGroupStates(oldStates => { + const copy = { ...oldStates }; + delete copy[filterGroupId]; + return copy; + }); + }, []); + + const setSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + const result = new Set(); + for (const key of selectedFilterKeys) { + if (!key.startsWith(`${filterGroupId}.`)) { + result.add(key); + } + } + for (const key of filters) { + result.add(`${filterGroupId}.${key}`); + } + setSelectedFilterKeys(result); + }, + [selectedFilterKeys], + ); + + const state: FilterGroupsContext = { + register, + unregister, + setSelectedFilters, + filterGroupStates, + matchingEntities, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +/* +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, +): EntityFilterGroupOutput => { + const groupsContext = useContext(filterGroupsContext); + if (!groupsContext) { + throw new Error('You must be inside an EntityFilterGroupsProvider'); + } + + useEffect(() => { + groupsContext.register(filterGroupId, filterGroup); + return () => groupsContext.unregister(filterGroupId); + }, []); + + const state = groupsContext.getFilterGroup(filterGroupId); + if (!state) { + return null; + } + + const {} = state; + + const [filterFuncs, setFilterFuncs] = useState>( + filterFunctions, + ); + + // and + // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) + + return { + selectItems: (functionNames: Array) => { + const selectedFilterFunctions = Object.fromEntries( + Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ + key, + { isSelected: functionNames.includes(key), filterFunction }, + ]), + ); + setFilterFuncs(selectedFilterFunctions); + }, + filteredItems: entities.filter(entity => + Object.entries(filterFuncs) + .filter(([_, { isSelected }]) => isSelected) + .map(([_, { filterFunction }]) => filterFunction) + .map(filter => filter(entity)) + .some(v => v === true), + ), + states: Object.keys(filterFuncs).reduce( + (acc, val) => ({ + ...acc, + [val]: { + ...filterFuncs[val], + count: entities.filter(filterFuncs[val].filterFunction).length, + }, + }), + {} as OutputState, + ), + }; +}; +*/ + +export const useUser = () => { + const indentityApi = useApi(identityApiRef); + const userId = indentityApi.getUserId(); + return { userId }; +}; + +export const useEntities = (): UseEntities => { + const [selectedFilter, setSelectedFilter] = useState< + EntityGroup | undefined + >(); + const catalogApi = useApi(catalogApiRef); + const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); + const { data: entities, error } = useStaleWhileRevalidate( + ['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]], + async () => catalogApi.getEntities(), + ); + + const { userId } = useUser(); + + const [selectedTypeFilter, selectTypeFilter] = useState( + labeledEntityTypes[0].id, + ); + + const entitiesByFilter = useMemo(() => { + const filterEntities = ( + ents: Entity[] | undefined, + filterId: EntityGroup, + isStarred: (e: Entity) => boolean, + user: string, + ) => { + return ents + ?.filter((e: Entity) => + entityFilters[filterId](e, { + isStarred: isStarred(e), + userId: user, + }), + ) + .filter(e => entityTypeFilter(e, selectedTypeFilter)); + }; + const data = Object.keys(EntityGroup).reduce( + (res, key) => ({ + ...res, + [key]: filterEntities( + entities, + key as EntityGroup, + isStarredEntity, + userId, + ), + }), + {} as EntitiesByFilter, + ); + return data; + }, [entities, isStarredEntity, userId, selectedTypeFilter]); + + return { + selectedFilter, + setSelectedFilter, + error, + toggleStarredEntity, + isStarredEntity, + entitiesByFilter, + loading: entities === undefined, + selectedTypeFilter, + selectTypeFilter, + }; +}; diff --git a/plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx b/plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..287fa6d11c --- /dev/null +++ b/plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2020 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 { renderHook, act } from '@testing-library/react-hooks'; +import { + EntityFilterGroupsProvider, + useEntityFilterGroup, + FilterGroupStatesReady, +} from './useEntityFilterGroup'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '..'; + +describe('useEntityFilterGroup', () => { + let catalogApi: jest.Mocked; + let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn((_a, _b) => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue([]); + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', { filters: {} }), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toBe('ready')); + }); + + it('works for a single group', async () => { + catalogApi.getEntities.mockResolvedValue([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ]); + const { result, wait } = renderHook( + () => + useEntityFilterGroup('g1', { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + let state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.selectItems(['f1'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: true, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.selectItems(['f2'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: true, + matchCount: 0, + }); + }); +}); diff --git a/plugins/catalog/src/hooks/useEntityFilterGroup.tsx b/plugins/catalog/src/hooks/useEntityFilterGroup.tsx new file mode 100644 index 0000000000..455795baf4 --- /dev/null +++ b/plugins/catalog/src/hooks/useEntityFilterGroup.tsx @@ -0,0 +1,273 @@ +/* + * Copyright 2020 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 { EntityGroup } from '../data/filters'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '..'; +import { Entity } from '@backstage/catalog-model'; +import React, { + createContext, + useState, + useEffect, + useCallback, + useContext, +} from 'react'; +import { useAsync } from 'react-use'; + +export type EntitiesByFilter = Record; + +export type FilterGroup = { + filters: { + [key: string]: (entity: Entity) => boolean; + }; +}; + +export type FilterGroupState = { + filters: { + [key: string]: { + isSelected: boolean; + matchCount: number; + }; + }; +}; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; + +export type FilterGroupsContext = { + register: (filterGroupId: string, filterGroup: FilterGroup) => void; + unregister: (filterGroupId: string) => void; + setSelectedFilters: (filterGroupId: string, filters: string[]) => void; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext( + {} as FilterGroupsContext, +); + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const catalogApi = useApi(catalogApiRef); + const { value: entities, error } = useAsync(() => catalogApi.getEntities()); + + const [filterGroups, setFilterGroups] = useState<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [selectedFilterKeys, setSelectedFilterKeys] = useState>( + new Set(), + ); + const [matchingEntities, setMatchingEntities] = useState([]); + + useEffect(() => { + function buildStates(): { [filterGroupId: string]: FilterGroupStates } { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities(filterGroupId); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + const isSelected = selectedFilterKeys.has( + `${filterGroupId}.${filterId}`, + ); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; + } + + function buildMatchingEntities(excludeFilterGroupId?: string): Entity[] { + // Build one filter fn per filter group + const allFilters: ((entity: Entity) => boolean)[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: ((entity: Entity) => boolean)[] = []; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + if (selectedFilterKeys.has(`${filterGroupId}.${filterId}`)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // All filter groups that had any checked filters need to match. Note that + // every() always returns true for an empty array. + return ( + entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [] + ); + } + + setFilterGroupStates(buildStates()); + setMatchingEntities(buildMatchingEntities()); + }, [entities, error, filterGroups, selectedFilterKeys]); + + const register = useCallback( + (filterGroupId: string, filterGroup: FilterGroup) => { + setFilterGroups(oldGroups => ({ + ...oldGroups, + [filterGroupId]: filterGroup, + })); + }, + [], + ); + + const unregister = useCallback((filterGroupId: string) => { + setFilterGroups(oldGroups => { + const copy = { ...oldGroups }; + delete copy[filterGroupId]; + return copy; + }); + setFilterGroupStates(oldStates => { + const copy = { ...oldStates }; + delete copy[filterGroupId]; + return copy; + }); + }, []); + + const setSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + const result = new Set(); + for (const key of selectedFilterKeys) { + if (!key.startsWith(`${filterGroupId}.`)) { + result.add(key); + } + } + for (const key of filters) { + result.add(`${filterGroupId}.${key}`); + } + setSelectedFilterKeys(result); + }, + [setSelectedFilterKeys], + ); + + const state: FilterGroupsContext = { + register, + unregister, + setSelectedFilters, + filterGroupStates, + matchingEntities, + }; + + return ( + + {children} + + ); +}; + +type EntityFilterGroupOutput = { + state: FilterGroupStates; + selectItems: (filters: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, +): EntityFilterGroupOutput => { + const groupsContext = useContext(filterGroupsContext); + if (!groupsContext) { + throw new Error('You must be inside an EntityFilterGroupsProvider'); + } + + useEffect(() => { + groupsContext.register(filterGroupId, filterGroup); + return () => groupsContext.unregister(filterGroupId); + }, []); + + const selectItems = useCallback( + (filters: string[]) => { + groupsContext.setSelectedFilters(filterGroupId, filters); + }, + [groupsContext, filterGroupId], + ); + + let state = groupsContext.filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, selectItems }; +}; From 410f98e2925a998b9868766bd565daf5e267c8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 25 Jun 2020 17:10:41 +0200 Subject: [PATCH 47/64] Clean up and finalize --- .../CatalogFilter/CatalogFilter.test.tsx | 221 ++++++---- .../CatalogFilter/CatalogFilter.tsx | 127 ++++-- .../CatalogPage/CatalogPage.test.tsx | 77 ++-- .../components/CatalogPage/CatalogPage.tsx | 118 ++--- .../components/CatalogPage/CatalogTabs.tsx | 55 +++ .../components/CatalogPage/WelcomeBanner.tsx | 55 +++ .../components/EntityPage/EntityPage.test.tsx | 4 +- plugins/catalog/src/data/filters.ts | 19 +- .../src/filter/EntityFilterGroupsProvider.tsx | 205 +++++++++ plugins/catalog/src/filter/context.ts | 38 ++ plugins/catalog/src/filter/index.ts | 28 ++ plugins/catalog/src/filter/types.ts | 53 +++ .../useEntityFilterGroup.test.tsx | 42 +- .../src/filter/useEntityFilterGroup.ts | 69 +++ .../catalog/src/filter/useFilteredEntities.ts | 34 ++ plugins/catalog/src/hooks/useEntities.tsx | 404 ------------------ .../src/hooks/useEntityFilterGroup.tsx | 273 ------------ 17 files changed, 870 insertions(+), 952 deletions(-) create mode 100644 plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx create mode 100644 plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx create mode 100644 plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx create mode 100644 plugins/catalog/src/filter/context.ts create mode 100644 plugins/catalog/src/filter/index.ts create mode 100644 plugins/catalog/src/filter/types.ts rename plugins/catalog/src/{hooks => filter}/useEntityFilterGroup.test.tsx (74%) create mode 100644 plugins/catalog/src/filter/useEntityFilterGroup.ts create mode 100644 plugins/catalog/src/filter/useFilteredEntities.ts delete mode 100644 plugins/catalog/src/hooks/useEntities.tsx delete mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.tsx diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index cc4cc62fdb..9dd0caa3b4 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -14,63 +14,78 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; +import { CatalogApi, catalogApiRef } from '../../api/types'; import { EntityGroup } from '../../data/filters'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; describe('Catalog Filter', () => { - const comp1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-1', - }, - spec: { - owner: 'team', - }, + const catalogApi: Partial = { + getEntities: () => + Promise.resolve([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + ] as Entity[]), }; - const comp2 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-2', - }, - spec: { - owner: 'team', - }, - }; - const comp3 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-3', - }, - spec: { - owner: '', - }, - }; - const defaultFilterProps = { - selectedFilter: EntityGroup.ALL, - onFilterChange: (type: EntityGroup) => type, - entitiesByFilter: { - [EntityGroup.ALL]: [comp1, comp2, comp3], - [EntityGroup.STARRED]: [comp1], - [EntityGroup.OWNED]: [comp1], - }, + + const indentityApi: Partial = { + getUserId: () => 'tools@example.com', }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + it('should render the different groups', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', items: [] }, { name: 'Test Group 2', items: [] }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + const { findByText } = renderWrapped( + , ); - for (const group of mockGroups) { expect(await findByText(group.name)).toBeInTheDocument(); } @@ -93,19 +108,16 @@ describe('Catalog Filter', () => { }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + const { findByText } = renderWrapped( + , ); - const [group] = mockGroups; - for (const item of group.items) { + for (const item of mockGroups[0].items) { expect(await findByText(item.label)).toBeInTheDocument(); } }); - it('should render the count in each item', async () => { + it('selects the first item if no desired initial one is set', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -113,33 +125,30 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: 3, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 1, }, ], }, ]; - const { getAllByText } = render( - wrapInTestApp( - , - ), + const onChange = jest.fn(); + + renderWrapped( + , ); - for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) { - const matcher = new RegExp( - `(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`, - ); - const items = await getAllByText(matcher); - items.forEach(el => expect(el).toBeInTheDocument()); - } + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.ALL, + label: 'First Label', + }); + }); }); - it('should fire the callback when an item is clicked', async () => { + it('selects the initial item', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -147,39 +156,34 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: 100, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 400, }, ], }, ]; - const onSelectedChangeHandler = jest.fn(); + const onChange = jest.fn(); - const { findByText } = render( - wrapInTestApp( - , - ), + renderWrapped( + , ); - const item = mockGroups[0].items[0]; - - const element = await findByText(item.label); - - fireEvent.click(element); - - expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.STARRED, + label: 'Second Label', + }); + }); }); - it('should render a component when a function is passed to the count component', async () => { + it('can change the selected item', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -187,22 +191,55 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: () => BACKSTAGE!, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 400, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , ); - expect(await findByText('Test Group 1')).toBeInTheDocument(); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.ALL, + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.STARRED, + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: EntityGroup.OWNED, + label: 'First Label', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index e40f4806ed..71956e2e22 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -14,21 +14,26 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import { IconComponent, identityApiRef, useApi } from '@backstage/core'; import { Card, List, ListItemIcon, - ListItemText, ListItemSecondaryAction, - MenuItem, - Typography, - Theme, + ListItemText, makeStyles, + MenuItem, + Theme, + Typography, } from '@material-ui/core'; -import type { IconComponent } from '@backstage/core'; -import { EntityGroup } from '../../data/filters'; -import { EntitiesByFilter } from '../../hooks/useEntities'; +import React, { FC, useCallback, useMemo, useState, useEffect } from 'react'; +import { + EntityFilterOptions, + entityFilters, + EntityGroup, +} from '../../data/filters'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; export type CatalogFilterItem = { id: EntityGroup; @@ -68,21 +73,43 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CatalogFilter: FC<{ - selectedFilter: EntityGroup; - onFilterChange: (type: EntityGroup) => void; - entitiesByFilter: EntitiesByFilter; - groups: CatalogFilterGroup[]; -}> = ({ - selectedFilter: selectedId, - onFilterChange: setSelectedFilter, - entitiesByFilter, - groups, -}) => { +type Props = { + filterGroups: CatalogFilterGroup[]; + onChange?: (filterItem: CatalogFilterItem) => void; + initiallySelected?: EntityGroup; +}; + +export const CatalogFilter = ({ + filterGroups, + onChange, + initiallySelected, +}: Props) => { const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(); + + const setCurrent = useCallback( + (item: CatalogFilterItem) => { + setCurrentFilter(item.id); + onChange?.(item); + }, + [onChange, setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = filterGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChange?.(item); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - {groups.map(group => ( + {filterGroups.map(group => ( {group.name} @@ -94,10 +121,8 @@ export const CatalogFilter: FC<{ key={item.id} button divider - onClick={() => { - setSelectedFilter(item.id); - }} - selected={item.id === selectedId} + onClick={() => setCurrent(item)} + selected={item.id === currentFilter} className={classes.menuItem} > {item.icon && ( @@ -111,7 +136,7 @@ export const CatalogFilter: FC<{ - {entitiesByFilter[item.id]?.length ?? '-'} + {getFilterCount(item.id) ?? '-'} ))} @@ -122,3 +147,55 @@ export const CatalogFilter: FC<{ ); }; + +function useFilter(): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState('OWNED'); + const { isStarredEntity } = useStarredEntities(); + const userId = useApi(identityApiRef).getUserId(); + + const filterGroup = useMemo(() => { + const result: FilterGroup = { filters: {} }; + const options: EntityFilterOptions = { + userId, + isStarred: isStarredEntity, + }; + for (const [filterId, filterFn] of Object.entries(entityFilters)) { + result.filters[filterId] = entity => filterFn(entity, options); + } + return result; + }, [isStarredEntity, userId]); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + ['OWNED'], + ); + + const setCurrent = useCallback( + (filterId: string) => { + setCurrentFilter(filterId); + setSelectedFilters([filterId]); + }, + [setCurrentFilter, setSelectedFilters], + ); + + const getFilterCount = useCallback( + (filterId: string) => { + if (state.type !== 'ready') { + return undefined; + } + return state.state.filters[filterId].matchCount; + }, + [state], + ); + + return { + currentFilter, + setCurrentFilter: setCurrent, + getFilterCount, + }; +} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 7ba1c39bb7..b4dcc2cb20 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -14,45 +14,43 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, - errorApiRef, - storageApiRef, - WebStorage, IdentityApi, identityApiRef, + storageApiRef, } from '@backstage/core'; -import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils'; -import { render, fireEvent } from '@testing-library/react'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { catalogApiRef } from '../..'; import { CatalogApi } from '../../api/types'; +import { EntityFilterGroupsProvider } from '../../filter'; import { CatalogPage } from './CatalogPage'; -import { Entity } from '@backstage/catalog-model'; describe('CatalogPage', () => { - const mockErrorApi = new MockErrorApi(); const catalogApi: Partial = { getEntities: () => Promise.resolve([ { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity1', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'tools@example.com', type: 'service', }, }, { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity2', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'not-tools@example.com', type: 'service', @@ -62,49 +60,32 @@ describe('CatalogPage', () => { getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; - const mockIndentityApi: Partial = { + const indentityApi: Partial = { getUserId: () => 'tools@example.com', }; + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + // this test right now causes some red lines in the log output when running tests // related to some theme issues in mui-table // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { - const { findByText } = render( - wrapInTestApp( - - - , - ), - ); - - const items = await findByText(/All Services \(2\)/); - expect(items).toBeInTheDocument(); - }); - it('should filter by owner', async () => { - const { findByText, getByText } = render( - wrapInTestApp( - - - , - ), - ); - fireEvent.click(getByText(/Owned/)); - const items = await findByText(/Owned \(1\)/); - expect(items).toBeInTheDocument(); + const { findByText, getByText } = renderWrapped(); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 9329eb9317..be357dfed1 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -15,38 +15,31 @@ */ import { Entity, LocationSpec } from '@backstage/catalog-model'; -import { - Content, - ContentHeader, - DismissableBanner, - HeaderTabs, - SupportButton, -} from '@backstage/core'; -import CatalogLayout from './CatalogLayout'; +import { Content, ContentHeader, SupportButton } from '@backstage/core'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { - Button, - Link, - makeStyles, - Typography, - withStyles, -} from '@material-ui/core'; +import { Button, makeStyles, withStyles } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import GitHub from '@material-ui/icons/GitHub'; import Star from '@material-ui/icons/Star'; import StarOutline from '@material-ui/icons/StarBorder'; -import React, { FC } from 'react'; +import React, { useCallback, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { CatalogFilter } from '../CatalogFilter/CatalogFilter'; -import { CatalogTable } from '../CatalogTable/CatalogTable'; -import { useEntities } from '../../hooks/useEntities'; -import { findLocationForEntityMeta } from '../../data/utils'; import { - getCatalogFilterItemByType, EntityGroup, filterGroups, - labeledEntityTypes, + LabeledEntityType, } from '../../data/filters'; +import { findLocationForEntityMeta } from '../../data/utils'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; +import { + CatalogFilter, + CatalogFilterItem, +} from '../CatalogFilter/CatalogFilter'; +import { CatalogTable } from '../CatalogTable/CatalogTable'; +import CatalogLayout from './CatalogLayout'; +import { CatalogTabs } from './CatalogTabs'; +import { WelcomeBanner } from './WelcomeBanner'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -55,27 +48,14 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: '250px 1fr', gridColumnGap: theme.spacing(2), }, - emoji: { - fontSize: '125%', - marginRight: theme.spacing(2), - }, })); -export const CatalogPage: FC<{}> = () => { - const { - entitiesByFilter, - error, - loading, - selectedFilter, - setSelectedFilter, - toggleStarredEntity, - isStarredEntity, - selectTypeFilter, - } = useEntities(); - - const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL]; - +const CatalogPageContents = () => { const styles = useStyles(); + const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + const { loading, error, matchingEntities } = useFilteredEntities(); + const [selectedTab, setSelectedTab] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); const YellowStar = withStyles({ root: { @@ -105,9 +85,7 @@ export const CatalogPage: FC<{}> = () => { return location.target; } }; - const location = findLocationForEntityMeta(rowData.metadata); - return { icon: Edit, tooltip: 'Edit', @@ -129,33 +107,19 @@ export const CatalogPage: FC<{}> = () => { }, ]; + const onTabChanged = useCallback((type: LabeledEntityType) => { + setSelectedTab(type.label); + }, []); + const onSidebarChanged = useCallback((filterItem: CatalogFilterItem) => { + setSelectedSidebarItem(filterItem.label); + }, []); + return ( - { - selectTypeFilter(labeledEntityTypes[index as number].id); - }} - /> + - - - 👋🏼 - - Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} - - getting started - {' '} - page. - - } - id="catalog_page_welcome_banner" - /> - + +