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 01/62] 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 02/62] 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 03/62] 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 04/62] 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 05/62] 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 06/62] 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 07/62] 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 08/62] 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 f42a541ade0b5bdce148b559ef0b1530e9abe488 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jun 2020 11:18:58 +0200 Subject: [PATCH 09/62] chore(backend-common): Fixing the headers sent issue so we dont send the response twice --- .../src/middleware/errorHandler.test.ts | 23 +++++++++++++++++++ .../src/middleware/errorHandler.ts | 3 +++ 2 files changed, 26 insertions(+) diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index 022802dff8..e8a5a7f47a 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -34,6 +34,29 @@ describe('errorHandler', () => { expect(response.text).toBe('some message'); }); + it('doesnt try to send the response again if its already been sent', async () => { + const app = express(); + const mockSend = jest.fn(); + + app.use('/works_with_async_fail', (_, res) => { + res.status(200).send('hello'); + + // mutate the response object to test the middlware. + // it's hard to catch errors inside middleware from the outside. + // @ts-ignore + res.send = mockSend; + throw new Error('some message'); + }); + + app.use(errorHandler()); + const response = await request(app).get('/works_with_async_fail'); + + expect(response.status).toBe(200); + expect(response.text).toBe('hello'); + + expect(mockSend).not.toHaveBeenCalled(); + }); + it('takes code from http-errors library errors', async () => { const app = express(); app.use('/breaks', () => { diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 14f6cfa8d7..ad8f170dcd 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -53,7 +53,10 @@ export function errorHandler( next: NextFunction, ) => { if (response.headersSent) { + // If the headers have already been sent, do not send the response again + // as this will throw an error in the backend. next(error); + return; } const status = getStatusCode(error); From 73c8c69c1ce88aa83848fc3f283392d586b22443 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jun 2020 13:56:25 +0200 Subject: [PATCH 10/62] feat(scaffolder): starting to write a job processor --- plugins/scaffolder-backend/package.json | 1 + .../src/scaffolder/index.ts | 1 + .../src/scaffolder/templater/index.ts | 3 +- .../scaffolder-backend/src/service/router.ts | 44 ++++++++++++------- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 061f6ca814..5a8ebddce5 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -36,6 +36,7 @@ "helmet": "^3.22.0", "morgan": "^1.10.0", "nodegit": "0.26.5", + "uuid": "^8.2.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 4cc3f21a9d..f2f7d3dad3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -16,3 +16,4 @@ export * from './templater'; export * from './prepare'; export * from './templater/cookiecutter'; +export * from './jobs'; diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts b/plugins/scaffolder-backend/src/scaffolder/templater/index.ts index 8885e65308..d0bbb0aa6c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/templater/index.ts @@ -16,6 +16,7 @@ import type { Writable } from 'stream'; import Docker from 'dockerode'; +import { JsonValue } from '@backstage/config'; export interface RequiredTemplateValues { component_id: string; @@ -23,7 +24,7 @@ export interface RequiredTemplateValues { export interface TemplaterRunOptions { directory: string; - values: RequiredTemplateValues & object; + values: RequiredTemplateValues & Record; logStream?: Writable; dockerClient: Docker; } diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ab39864e76..a0e31718aa 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -17,10 +17,10 @@ import { Logger } from 'winston'; import Router from 'express-promise-router'; import express from 'express'; -import { PreparerBuilder, TemplaterBase } from '../scaffolder'; +import { PreparerBuilder, TemplaterBase, JobProcessor } from '../scaffolder'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import Docker from 'dockerode'; - +import {} from '@backstage/backend-common'; export interface RouterOptions { preparers: PreparerBuilder; templater: TemplaterBase; @@ -35,10 +35,32 @@ export async function createRouter( const { preparers, templater, logger: parentLogger, dockerClient } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); + const jobProcessor = new JobProcessor({ + preparers, + templater, + logger, + dockerClient, + }); + + router.get('/v1/job/:jobId', ({ params }, res) => { + const job = jobProcessor.get(params.jobId); + + if (!job) { + return res.status(404).send({ error: 'job not found' }); + } + + res.send({ + id: job.id, + metadata: job.metadata, + status: job.status, + log: job.log, + error: job.error, + }); + }); + router.post('/v1/jobs', async (_, res) => { // TODO(blam): Create a unique job here and return the ID so that // The end user can poll for updates on the current job - res.status(201).json({ accepted: true }); // TODO(blam): Take this entity from the post body sent from the frontend const mockEntity: TemplateEntityV1alpha1 = { @@ -64,20 +86,12 @@ export async function createRouter( }, }; - // Get the preparer for the mock entity - const preparer = preparers.get(mockEntity); + const job = jobProcessor.create(mockEntity, { component_id: 'test' }); + res.status(201).json({ jobId: job.id }); - // Run the preparer for the mock entity to produce a temporary directory with template in - const skeletonPath = await preparer.prepare(mockEntity); + jobProcessor.run(job); - // Run the templater on the mock directory with values from the post body - const templatedPath = await templater.run({ - directory: skeletonPath, - values: { component_id: 'test' }, - dockerClient, - }); - - console.warn(templatedPath); + // console.warn(templatedPath); }); const app = express(); From 7787eb99865ef163aadb142957518477c282d258 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jun 2020 13:56:43 +0200 Subject: [PATCH 11/62] feat(scaffolder): implementing a basic API for the processor to mutate the entity state --- .../src/scaffolder/jobs/index.ts | 16 +++ .../src/scaffolder/jobs/processor.ts | 110 ++++++++++++++++++ .../src/scaffolder/jobs/types.ts | 57 +++++++++ 3 files changed, 183 insertions(+) create mode 100644 plugins/scaffolder-backend/src/scaffolder/jobs/index.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/jobs/types.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts new file mode 100644 index 0000000000..303987c5b1 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/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 * from './processor'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts new file mode 100644 index 0000000000..bc9fe4698f --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -0,0 +1,110 @@ +/* + * 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 { Processor, Job, ProcessorContstructorArgs } from './types'; +import { JsonValue } from '@backstage/config'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { PassThrough, Writable } from 'stream'; +import uuid from 'uuid'; +import winston from 'winston'; +import { RequiredTemplateValues } from '../templater'; +import { createNewRootLogger } from '@backstage/backend-common'; + +export class JobProcessor implements Processor { + private preparers: ProcessorContstructorArgs['preparers']; + private templater: ProcessorContstructorArgs['templater']; + private dockerClient: ProcessorContstructorArgs['dockerClient']; + private jobs = new Map(); + + constructor({ + preparers, + templater, + dockerClient, + }: ProcessorContstructorArgs) { + this.preparers = preparers; + this.templater = templater; + this.dockerClient = dockerClient; + return this; + } + + create( + entity: TemplateEntityV1alpha1, + values: RequiredTemplateValues & Record, + ): Job { + const id = uuid.v4(); + const log: string[] = []; + const logStream = new PassThrough(); + logStream.on('data', chunk => log.push(chunk.toString())); + + const logger = createNewRootLogger(); + logger.add(new winston.transports.Stream({ stream: logStream })); + + const job: Job = { + id, + logStream, + logger, + log, + status: 'PENDING', + metadata: { + entity, + values, + }, + }; + + this.jobs.set(job.id, job); + return job; + } + get(id: string): Job | undefined { + return this.jobs.get(id); + } + async run(job: Job) { + if (job.status !== 'PENDING') { + throw new Error('Job is not in pending state'); + } + + const { logger, logStream } = job; + + try { + logger.debug('Prepare started'); + job.status = 'PREPARING'; + const entity = job.metadata.entity; + const preparer = this.preparers.get(entity); + const skeletonPath = await preparer.prepare(entity); + logger.debug('Prepare finished', { + skeletonPath, + }); + + logger.debug('Templating started'); + job.status = 'TEMPLATING'; + // Run the templater on the mock directory with values from the post body + const templatedPath = await this.templater.run({ + directory: skeletonPath, + values: job.metadata.values, + dockerClient: this.dockerClient, + logStream, + }); + logger.debug('Template finished', { templatedPath }); + + job.status = 'STORING'; + // TODO(blam): Implement VCS Push here + + job.status = 'COMPLETE'; + } catch (ex) { + job.error = ex; + job.status = 'FAILED'; + logger.error(`job ${job.id} failed with reason`, { ex }); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts new file mode 100644 index 0000000000..05630e2c63 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -0,0 +1,57 @@ +/* + * 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 { Writable } from 'stream'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/config'; +import { PreparerBuilder } from '../prepare'; +import Docker from 'dockerode'; +import { TemplaterBase, RequiredTemplateValues } from '../templater'; +import { Logger } from 'winston'; + +export type Job = { + id: string; + metadata: { + entity: TemplateEntityV1alpha1; + values: RequiredTemplateValues & Record; + }; + status: + | 'PENDING' + | 'PREPARING' + | 'TEMPLATING' + | 'STORING' + | 'COMPLETE' + | 'FAILED'; + logStream: Writable; + log: string[]; + logger: Logger; + error?: Error; +}; + +export type ProcessorContstructorArgs = { + preparers: PreparerBuilder; + templater: TemplaterBase; + logger: Logger; + dockerClient: Docker; +}; + +export type Processor = { + create( + entity: TemplateEntityV1alpha1, + values: RequiredTemplateValues & Record, + ): Job; + + get(id: string): Job | undefined; +}; From 85d1fbaf3c8c4642e3dc587cc070bd3cbe9be482 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jun 2020 13:57:19 +0200 Subject: [PATCH 12/62] chore(backend-common): expose a create logger function --- .../backend-common/src/logging/rootLogger.ts | 40 ++++++++++--------- yarn.lock | 5 +++ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 11db57c1ed..47379b027c 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -16,24 +16,28 @@ import * as winston from 'winston'; -let rootLogger: winston.Logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: - process.env.NODE_ENV === 'production' - ? winston.format.json() - : winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.simple(), - ), - defaultMeta: { service: 'backstage' }, - transports: [ - new winston.transports.Console({ - silent: - process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL, - }), - ], -}); +export function createNewRootLogger(): winston.Logger { + return winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: + process.env.NODE_ENV === 'production' + ? winston.format.json() + : winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.simple(), + ), + defaultMeta: { service: 'backstage' }, + transports: [ + new winston.transports.Console({ + silent: + process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL, + }), + ], + }); +} + +let rootLogger: winston.Logger = createNewRootLogger(); export function getRootLogger(): winston.Logger { return rootLogger; diff --git a/yarn.lock b/yarn.lock index c9128c5350..ba53ee7513 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18918,6 +18918,11 @@ uuid@^8.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== +uuid@^8.2.0: + version "8.2.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e" + integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" 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 13/62] 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 bca0ebf3c9e08577ddaec2d6e5b1680b97e4fc6f Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Thu, 25 Jun 2020 00:15:58 +0200 Subject: [PATCH 14/62] fix(scaffolder): stringify error --- plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index bc9fe4698f..29b67b5e14 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -104,7 +104,7 @@ export class JobProcessor implements Processor { } catch (ex) { job.error = ex; job.status = 'FAILED'; - logger.error(`job ${job.id} failed with reason`, { ex }); + logger.error(`job ${job.id} failed with reason: ${ex}`); } } } From 1dd56d28a9fc8245f97c4bcfd51f4a1d8b2d17dd Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Thu, 25 Jun 2020 00:16:26 +0200 Subject: [PATCH 15/62] feat(scaffolder): create new tempdir for result --- .../src/scaffolder/templater/cookiecutter.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts index a95eee7fb4..cb11bf04af 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts @@ -48,10 +48,7 @@ export class CookieCutter implements TemplaterBase { await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo); const templateDir = options.directory; - - // TODO(blam): This should be an entirely different directory on the host machine - // not in the template directory - const resultDir = `${templateDir}/result`; + const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); await runDockerContainer({ imageName: 'backstage/cookiecutter', From 82321cb4002e5adc850bec3988957e7bf9140212 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 25 Jun 2020 04:21:43 +0200 Subject: [PATCH 16/62] chore(scaffolder): fixing issues with scaffolder --- .../src/scaffolder/jobs/processor.test.ts | 16 +++++++++++++++ .../src/scaffolder/jobs/processor.ts | 20 ++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts new file mode 100644 index 0000000000..1db114f597 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.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. + */ +describe('JobProcessor', () => {}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index bc9fe4698f..0c2a6ceae2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -16,7 +16,7 @@ import { Processor, Job, ProcessorContstructorArgs } from './types'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { PassThrough, Writable } from 'stream'; +import { PassThrough } from 'stream'; import uuid from 'uuid'; import winston from 'winston'; import { RequiredTemplateValues } from '../templater'; @@ -45,9 +45,16 @@ export class JobProcessor implements Processor { ): Job { const id = uuid.v4(); const log: string[] = []; + + // Create an empty stream to collect all the log lines into + // one variable for the API. const logStream = new PassThrough(); logStream.on('data', chunk => log.push(chunk.toString())); + // TODO(blam): Maybe this is not the right way to build the logger + // Maybe we want to be more ux specific and drop the json support. + // Child loggers can not have specific transports which sucks, so we have to + // create another here. const logger = createNewRootLogger(); logger.add(new winston.transports.Stream({ stream: logStream })); @@ -64,6 +71,7 @@ export class JobProcessor implements Processor { }; this.jobs.set(job.id, job); + return job; } get(id: string): Job | undefined { @@ -77,6 +85,7 @@ export class JobProcessor implements Processor { const { logger, logStream } = job; try { + // Prepare a folder for the templater to run in logger.debug('Prepare started'); job.status = 'PREPARING'; const entity = job.metadata.entity; @@ -86,9 +95,9 @@ export class JobProcessor implements Processor { skeletonPath, }); + // Run the templater on the directory with values passed in logger.debug('Templating started'); job.status = 'TEMPLATING'; - // Run the templater on the mock directory with values from the post body const templatedPath = await this.templater.run({ directory: skeletonPath, values: job.metadata.values, @@ -97,14 +106,15 @@ export class JobProcessor implements Processor { }); logger.debug('Template finished', { templatedPath }); + // Store the template somewhere when finished job.status = 'STORING'; // TODO(blam): Implement VCS Push here job.status = 'COMPLETE'; - } catch (ex) { - job.error = ex; + } catch (error) { + job.error = error; job.status = 'FAILED'; - logger.error(`job ${job.id} failed with reason`, { ex }); + logger.error(`Job failed with error ${error.message}`); } } } From 5a88ef753ba1efb5af3d8168366df4c8f6f26118 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 25 Jun 2020 23:29:54 +0200 Subject: [PATCH 17/62] chore(scaffolder): Reworking how the processor works. It's starting to look a lot cleaner now --- .../src/scaffolder/jobs/processor.test.ts | 34 ++++++- .../src/scaffolder/jobs/processor.ts | 93 +++++++++++-------- .../src/scaffolder/jobs/types.ts | 11 +-- .../scaffolder-backend/src/service/router.ts | 86 ++++++++--------- 4 files changed, 129 insertions(+), 95 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 1db114f597..aacbcf61f0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -13,4 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -describe('JobProcessor', () => {}); +import { JobProcessor } from './processor'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +describe('JobProcessor', () => { + describe('create', () => { + const mockEntity: TemplateEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + + generation: 1, + }, + spec: { + type: 'cookiecutter', + path: './template', + }, + }; + const processor = new JobProcessor(); + + it('should create a unique id for the job', async () => { + const job = processor.create(); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 0c2a6ceae2..4e90b457a7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -13,30 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Processor, Job, ProcessorContstructorArgs } from './types'; +import { Processor, Job } from './types'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { PassThrough } from 'stream'; import uuid from 'uuid'; +import Docker from 'dockerode'; import winston from 'winston'; -import { RequiredTemplateValues } from '../templater'; +import { RequiredTemplateValues, TemplaterBase } from '../templater'; import { createNewRootLogger } from '@backstage/backend-common'; +import { PreparerBuilder } from '../prepare'; + +export type JobProcessorArguments = { + preparers: PreparerBuilder; + templater: TemplaterBase; + dockerClient: Docker; +}; + +export type JobAndDirectoryTuple = { + job: Job; + directory: string; +}; export class JobProcessor implements Processor { - private preparers: ProcessorContstructorArgs['preparers']; - private templater: ProcessorContstructorArgs['templater']; - private dockerClient: ProcessorContstructorArgs['dockerClient']; + private preparers: PreparerBuilder; + private templater: TemplaterBase; + private dockerClient: Docker; private jobs = new Map(); - constructor({ - preparers, - templater, - dockerClient, - }: ProcessorContstructorArgs) { + constructor({ preparers, templater, dockerClient }: JobProcessorArguments) { this.preparers = preparers; this.templater = templater; this.dockerClient = dockerClient; - return this; } create( @@ -74,47 +82,50 @@ export class JobProcessor implements Processor { return job; } + get(id: string): Job | undefined { return this.jobs.get(id); } - async run(job: Job) { + + private async prepare(job: Job): Promise { + job.status = 'PREPARING'; + const entity = job.metadata.entity; + const preparer = this.preparers.get(entity); + return await preparer.prepare(entity); + } + + private async run(job: Job, directory: string): Promise { + job.status = 'TEMPLATING'; + return await this.templater.run({ + directory, + values: job.metadata.values, + dockerClient: this.dockerClient, + logStream: job.logStream, + }); + } + + private async store(job: Job): Promise { + job.status = 'STORING'; + } + + private async complete(job: Job): Promise { + job.status = 'COMPLETE'; + } + + async process(job: Job) { if (job.status !== 'PENDING') { throw new Error('Job is not in pending state'); } - const { logger, logStream } = job; - try { - // Prepare a folder for the templater to run in - logger.debug('Prepare started'); - job.status = 'PREPARING'; - const entity = job.metadata.entity; - const preparer = this.preparers.get(entity); - const skeletonPath = await preparer.prepare(entity); - logger.debug('Prepare finished', { - skeletonPath, - }); - - // Run the templater on the directory with values passed in - logger.debug('Templating started'); - job.status = 'TEMPLATING'; - const templatedPath = await this.templater.run({ - directory: skeletonPath, - values: job.metadata.values, - dockerClient: this.dockerClient, - logStream, - }); - logger.debug('Template finished', { templatedPath }); - - // Store the template somewhere when finished - job.status = 'STORING'; - // TODO(blam): Implement VCS Push here - - job.status = 'COMPLETE'; + const skeletonPath = await this.prepare(job); + await this.run(job, skeletonPath); + await this.store(job); + await this.complete(job); } catch (error) { job.error = error; job.status = 'FAILED'; - logger.error(`Job failed with error ${error.message}`); + job.logger.error(`Job failed with error ${error.message}`); } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index 05630e2c63..e6c5436e95 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -16,9 +16,7 @@ import type { Writable } from 'stream'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; -import { PreparerBuilder } from '../prepare'; -import Docker from 'dockerode'; -import { TemplaterBase, RequiredTemplateValues } from '../templater'; +import { RequiredTemplateValues } from '../templater'; import { Logger } from 'winston'; export type Job = { @@ -40,13 +38,6 @@ export type Job = { error?: Error; }; -export type ProcessorContstructorArgs = { - preparers: PreparerBuilder; - templater: TemplaterBase; - logger: Logger; - dockerClient: Docker; -}; - export type Processor = { create( entity: TemplateEntityV1alpha1, diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a0e31718aa..4d64bc159f 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -42,57 +42,57 @@ export async function createRouter( dockerClient, }); - router.get('/v1/job/:jobId', ({ params }, res) => { - const job = jobProcessor.get(params.jobId); + router + .get('/v1/job/:jobId', ({ params }, res) => { + const job = jobProcessor.get(params.jobId); - if (!job) { - return res.status(404).send({ error: 'job not found' }); - } + if (!job) { + return res.status(404).send({ error: 'job not found' }); + } - res.send({ - id: job.id, - metadata: job.metadata, - status: job.status, - log: job.log, - error: job.error, - }); - }); + res.send({ + id: job.id, + metadata: job.metadata, + status: job.status, + log: job.log, + error: job.error, + }); + }) + .post('/v1/jobs', async (_, res) => { + // TODO(blam): Create a unique job here and return the ID so that + // The end user can poll for updates on the current job - router.post('/v1/jobs', async (_, res) => { - // TODO(blam): Create a unique job here and return the ID so that - // The end user can poll for updates on the current job + // TODO(blam): Take this entity from the post body sent from the frontend + const mockEntity: TemplateEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - // TODO(blam): Take this entity from the post body sent from the frontend - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + generation: 1, }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + spec: { + type: 'cookiecutter', + path: './template', + }, + }; - generation: 1, - }, - spec: { - type: 'cookiecutter', - path: './template', - }, - }; + const job = jobProcessor.create(mockEntity, { component_id: 'test' }); + res.status(201).json({ jobId: job.id }); - const job = jobProcessor.create(mockEntity, { component_id: 'test' }); - res.status(201).json({ jobId: job.id }); + jobProcessor.run(job); - jobProcessor.run(job); - - // console.warn(templatedPath); - }); + // console.warn(templatedPath); + }); const app = express(); app.set('logger', logger); From 1655b8c16e93882070a58ba280b750e275019d14 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 25 Jun 2020 23:39:36 +0200 Subject: [PATCH 18/62] chore(scaffolder): added some more tests and more refactoring --- .../src/scaffolder/jobs/processor.test.ts | 56 ++++++++++--------- .../scaffolder/templater/cookiecutter.test.ts | 6 +- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index aacbcf61f0..49b5ef7f54 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -16,33 +16,37 @@ import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; describe('JobProcessor', () => { + const mockEntity: TemplateEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + + generation: 1, + }, + spec: { + type: 'cookiecutter', + path: './template', + }, + }; + describe('create', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + it.todo('creates a new job'); + }); - generation: 1, - }, - spec: { - type: 'cookiecutter', - path: './template', - }, - }; - const processor = new JobProcessor(); - - it('should create a unique id for the job', async () => { - const job = processor.create(); - }); + describe('process', () => { + it.todo('allows running of a job in a pending state'); + it.todo('fails when the job is not in a pending state'); + it.todo('calls the preparer with the entity'); + it.todo('calls the templater with the correct directory'); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts index 7c78badff1..f7e2d68719 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts @@ -100,7 +100,7 @@ describe('CookieCutter Templater', () => { imageName: 'backstage/cookiecutter', args: ['cookiecutter', '--no-input', '-o', '/result', '/template'], templateDir: tempdir, - resultDir: `${tempdir}/result`, + resultDir: `${tempdir}-result`, logStream: undefined, dockerClient: mockDocker, }); @@ -119,7 +119,7 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); - expect(path).toBe(`${tempdir}/result`); + expect(path).toBe(`${tempdir}-result`); }); it('should pass through the streamer to the run docker helper', async () => { @@ -143,7 +143,7 @@ describe('CookieCutter Templater', () => { imageName: 'backstage/cookiecutter', args: ['cookiecutter', '--no-input', '-o', '/result', '/template'], templateDir: tempdir, - resultDir: `${tempdir}/result`, + resultDir: `${tempdir}-result`, logStream: stream, dockerClient: mockDocker, }); From e0f026bb392a440ab660aaffa22cbe84eef7c2da Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 13:53:14 +0200 Subject: [PATCH 19/62] chore(scaffolder): fixing cookiecutter templater tests --- .../src/scaffolder/templater/cookiecutter.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts index f7e2d68719..f31dd7cdd0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts @@ -100,7 +100,7 @@ describe('CookieCutter Templater', () => { imageName: 'backstage/cookiecutter', args: ['cookiecutter', '--no-input', '-o', '/result', '/template'], templateDir: tempdir, - resultDir: `${tempdir}-result`, + resultDir: expect.stringContaining(`${tempdir}-result`), logStream: undefined, dockerClient: mockDocker, }); @@ -119,7 +119,7 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); - expect(path).toBe(`${tempdir}-result`); + expect(path.startsWith(`${tempdir}-result`)).toBeTruthy(); }); it('should pass through the streamer to the run docker helper', async () => { @@ -143,7 +143,7 @@ describe('CookieCutter Templater', () => { imageName: 'backstage/cookiecutter', args: ['cookiecutter', '--no-input', '-o', '/result', '/template'], templateDir: tempdir, - resultDir: `${tempdir}-result`, + resultDir: expect.stringContaining(`${tempdir}-result`), logStream: stream, dockerClient: mockDocker, }); From f2d01c5cb4fe529ee48f99f025bbd9c608d0c775 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 14:19:13 +0200 Subject: [PATCH 20/62] chore(scaffolder): adding some more tests for scaffolder processor --- .../src/scaffolder/jobs/processor.test.ts | 21 ++++++++++++++++++- .../src/scaffolder/jobs/processor.ts | 2 +- .../scaffolder/templater/cookiecutter.test.ts | 1 + .../scaffolder-backend/src/service/router.ts | 8 +++---- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 49b5ef7f54..8c3643b10b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -15,6 +15,10 @@ */ import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import Docker from 'dockerode'; +import { CookieCutter } from '../templater/cookiecutter'; +import { Preparers } from '../'; + describe('JobProcessor', () => { const mockEntity: TemplateEntityV1alpha1 = { apiVersion: 'backstage.io/v1alpha1', @@ -40,7 +44,22 @@ describe('JobProcessor', () => { }; describe('create', () => { - it.todo('creates a new job'); + const templater = new CookieCutter(); + const preparers = new Preparers(); + const mockDocker = {} as jest.Mocked; + it('creates a new job', async () => { + const processor = new JobProcessor({ + dockerClient: mockDocker, + preparers, + templater, + }); + + const job = processor.create(mockEntity, { component_id: 'bob' }); + + expect(job.id).toMatch( + /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + ); + }); }); describe('process', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 4e90b457a7..341ae6724e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -17,7 +17,7 @@ import { Processor, Job } from './types'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { PassThrough } from 'stream'; -import uuid from 'uuid'; +import * as uuid from 'uuid'; import Docker from 'dockerode'; import winston from 'winston'; import { RequiredTemplateValues, TemplaterBase } from '../templater'; diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts index f31dd7cdd0..82e84bc1d3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts @@ -105,6 +105,7 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); }); + it('should return the result path to the end templated folder', async () => { const tempdir = os.tmpdir(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 4d64bc159f..5957b66915 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -38,7 +38,6 @@ export async function createRouter( const jobProcessor = new JobProcessor({ preparers, templater, - logger, dockerClient, }); @@ -47,7 +46,8 @@ export async function createRouter( const job = jobProcessor.get(params.jobId); if (!job) { - return res.status(404).send({ error: 'job not found' }); + res.status(404).send({ error: 'job not found' }); + return; } res.send({ @@ -89,9 +89,7 @@ export async function createRouter( const job = jobProcessor.create(mockEntity, { component_id: 'test' }); res.status(201).json({ jobId: job.id }); - jobProcessor.run(job); - - // console.warn(templatedPath); + jobProcessor.process(job); }); const app = express(); From b401cf2f1789b03f647bf9452e9d55322b9a730b Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 17:11:46 +0200 Subject: [PATCH 21/62] chore(scaffolder): Updating scaffolder tests to start runnning some stuff --- .../src/scaffolder/jobs/processor.test.ts | 82 +++++++++++++++++-- .../src/scaffolder/jobs/processor.ts | 2 +- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 8c3643b10b..b8f1c8f86e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -18,6 +18,7 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import Docker from 'dockerode'; import { CookieCutter } from '../templater/cookiecutter'; import { Preparers } from '../'; +import { Job } from './types'; describe('JobProcessor', () => { const mockEntity: TemplateEntityV1alpha1 = { @@ -43,6 +44,8 @@ describe('JobProcessor', () => { }, }; + const mockValues = { component_id: 'bob' }; + describe('create', () => { const templater = new CookieCutter(); const preparers = new Preparers(); @@ -54,18 +57,87 @@ describe('JobProcessor', () => { templater, }); - const job = processor.create(mockEntity, { component_id: 'bob' }); + const job = processor.create(mockEntity, mockValues); expect(job.id).toMatch( /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, ); + + expect(job.log).toEqual([]); + expect(job.status).toBe('PENDING'); + expect(job.metadata.entity).toBe(mockEntity); + expect(job.metadata.values).toBe(mockValues); }); }); describe('process', () => { - it.todo('allows running of a job in a pending state'); - it.todo('fails when the job is not in a pending state'); - it.todo('calls the preparer with the entity'); - it.todo('calls the templater with the correct directory'); + const preparers = new Preparers(); + const mockDocker = {} as jest.Mocked; + const mockPreparer = { prepare: jest.fn() }; + const templater = { run: jest.fn() }; + + const createJob = (): { job: Job; processor: JobProcessor } => { + preparers.register('github', mockPreparer); + + const processor = new JobProcessor({ + preparers, + dockerClient: mockDocker, + templater, + }); + + return { job: processor.create(mockEntity, mockValues), processor }; + }; + + // TODO(blam): make this better. + // Wait 10ms for processor to finish. + const waitForProcessor = () => + new Promise(resolve => setTimeout(resolve, 10)); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fails when the job is not in a pending state', async () => { + const { job, processor } = createJob(); + job.status = 'TEMPLATING'; + + await expect(processor.process(job)).rejects.toThrow( + /Job is not in a 'PENDING' state/, + ); + }); + + it('calls the preparer with the entity', async () => { + const { job, processor } = createJob(); + + // Create a promise to hold it at this step so we can test + mockPreparer.prepare.mockImplementationOnce(() => new Promise(() => {})); + + processor.process(job); + + await waitForProcessor(); + + expect(mockPreparer.prepare).toHaveBeenCalledWith(mockEntity); + expect(job.status).toBe('PREPARING'); + }); + + it('calls the templater with the correct directory', async () => { + const { job, processor } = createJob(); + const mockDirectory = '/test/blam/bo'; + mockPreparer.prepare.mockResolvedValueOnce(mockDirectory); + + // Create a promise to hold it at this step so we can test + templater.run.mockImplementationOnce(() => new Promise(() => {})); + + processor.process(job); + + await waitForProcessor(); + + expect(templater.run).toHaveBeenCalledWith({ + directory: mockDirectory, + values: mockValues, + dockerClient: mockDocker, + logStream: job.logStream, + }); + }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 341ae6724e..c97d5ced79 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -114,7 +114,7 @@ export class JobProcessor implements Processor { async process(job: Job) { if (job.status !== 'PENDING') { - throw new Error('Job is not in pending state'); + throw new Error("Job is not in a 'PENDING' state"); } try { From ce43dce8ff7b9daf6feeac6c3d2523baf8734ff2 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 18:12:48 +0200 Subject: [PATCH 22/62] feat(scaffolder): Adjusting the types for the scaffolder --- .../src/scaffolder/jobs/types.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index e6c5436e95..060d5d400e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -19,21 +19,31 @@ import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; import { Logger } from 'winston'; +// Context will be a mutable object which is passed between stages +// To share data, but also thinking that we can pass in functions here too +// To maybe create sub steps or fail the entire thing, or skip stages down the line. +export type StageContext = T & { + values: RequiredTemplateValues & Record; + entity: TemplateEntityV1alpha1; + logger: Logger; +}; + +export type Stage = { + log: string[]; + status: 'PENDING' | 'STARTED' | 'COMPLETE' | 'FAILED'; + name: string; + handler: (ctx: StageContext) => Promise; +}; + export type Job = { id: string; metadata: { entity: TemplateEntityV1alpha1; values: RequiredTemplateValues & Record; }; - status: - | 'PENDING' - | 'PREPARING' - | 'TEMPLATING' - | 'STORING' - | 'COMPLETE' - | 'FAILED'; + status: 'PENDING' | 'STARTED' | 'COMPLETE' | 'FAILED'; + stages: Stage[]; logStream: Writable; - log: string[]; logger: Logger; error?: Error; }; From ad2354909eb1af6fc3b9274453621c3ce3b11f52 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 21:27:48 +0200 Subject: [PATCH 23/62] chore(scaffolder): work on a better api for holding stages and metadata --- .../src/scaffolder/jobs/processor.test.ts | 1 + .../src/scaffolder/jobs/processor.ts | 27 ++++++++------- .../src/scaffolder/jobs/types.ts | 34 ++++++++++++------- .../{ => stages}/prepare/file.test.ts | 0 .../scaffolder/{ => stages}/prepare/file.ts | 0 .../{ => stages}/prepare/github.test.ts | 0 .../scaffolder/{ => stages}/prepare/github.ts | 0 .../{ => stages}/prepare/helpers.test.ts | 0 .../{ => stages}/prepare/helpers.ts | 0 .../scaffolder/{ => stages}/prepare/index.ts | 0 .../{ => stages}/prepare/preparers.test.ts | 0 .../{ => stages}/prepare/preparers.ts | 0 .../scaffolder/{ => stages}/prepare/types.ts | 0 .../templater/cookiecutter.test.ts | 0 .../{ => stages}/templater/cookiecutter.ts | 0 .../{ => stages}/templater/helpers.test.ts | 0 .../{ => stages}/templater/helpers.ts | 0 .../{ => stages}/templater/index.ts | 0 18 files changed, 36 insertions(+), 26 deletions(-) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/file.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/file.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/github.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/github.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/helpers.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/helpers.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/index.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/preparers.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/preparers.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/prepare/types.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/templater/cookiecutter.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/templater/cookiecutter.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/templater/helpers.test.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/templater/helpers.ts (100%) rename plugins/scaffolder-backend/src/scaffolder/{ => stages}/templater/index.ts (100%) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index b8f1c8f86e..367d140f7e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -79,6 +79,7 @@ describe('JobProcessor', () => { const createJob = (): { job: Job; processor: JobProcessor } => { preparers.register('github', mockPreparer); + new JobProcessor(1); const processor = new JobProcessor({ preparers, dockerClient: mockDocker, diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index c97d5ced79..a1b8795156 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Processor, Job } from './types'; +import { Processor, Job, Stage, StageContext } from './types'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { PassThrough } from 'stream'; import * as uuid from 'uuid'; import Docker from 'dockerode'; import winston from 'winston'; -import { RequiredTemplateValues, TemplaterBase } from '../templater'; +import { RequiredTemplateValues, TemplaterBase } from '../stages/templater'; import { createNewRootLogger } from '@backstage/backend-common'; -import { PreparerBuilder } from '../prepare'; +import { PreparerBuilder } from '../stages/prepare'; export type JobProcessorArguments = { preparers: PreparerBuilder; @@ -36,15 +36,10 @@ export type JobAndDirectoryTuple = { }; export class JobProcessor implements Processor { - private preparers: PreparerBuilder; - private templater: TemplaterBase; - private dockerClient: Docker; private jobs = new Map(); - - constructor({ preparers, templater, dockerClient }: JobProcessorArguments) { - this.preparers = preparers; - this.templater = templater; - this.dockerClient = dockerClient; + private stages: Stage[]; + constructor({ stages }: { stages: Stage[] }) { + this.stages = stages; } create( @@ -66,11 +61,17 @@ export class JobProcessor implements Processor { const logger = createNewRootLogger(); logger.add(new winston.transports.Stream({ stream: logStream })); + const context: StageContext = { + entity, + values, + logger, + }; + const job: Job = { id, logStream, - logger, - log, + context, + stages: this.stages.map((stage) => ({})) status: 'PENDING', metadata: { entity, diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index 060d5d400e..b01fae9625 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -16,13 +16,13 @@ import type { Writable } from 'stream'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; +import { RequiredTemplateValues } from '../stages/templater'; import { Logger } from 'winston'; // Context will be a mutable object which is passed between stages // To share data, but also thinking that we can pass in functions here too // To maybe create sub steps or fail the entire thing, or skip stages down the line. -export type StageContext = T & { +export type StageContext = T & { values: RequiredTemplateValues & Record; entity: TemplateEntityV1alpha1; logger: Logger; @@ -35,12 +35,9 @@ export type Stage = { handler: (ctx: StageContext) => Promise; }; -export type Job = { +export type Job = { id: string; - metadata: { - entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; - }; + context: StageContext; status: 'PENDING' | 'STARTED' | 'COMPLETE' | 'FAILED'; stages: Stage[]; logStream: Writable; @@ -48,11 +45,22 @@ export type Job = { error?: Error; }; -export type Processor = { - create( - entity: TemplateEntityV1alpha1, - values: RequiredTemplateValues & Record, - ): Job; +export interface ProcessorConstructor { + new (t: string): Processor; +} + +export interface Processor { + create({ + entity, + values, + stages, + }: { + entity: TemplateEntityV1alpha1; + values: RequiredTemplateValues & Record; + stages: Stage[]; + }): Job; get(id: string): Job | undefined; -}; +} + +declare let Processor: ProcessorConstructor; diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/file.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/file.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/github.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/github.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/helpers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/index.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/preparers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/preparers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/types.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/helpers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/index.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts From 5d7679d8becb363f40556f2a6950f5fbee317e3a Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Mon, 22 Jun 2020 16:32:05 +0200 Subject: [PATCH 24/62] 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 25/62] 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 26/62] 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" - /> - + +