From 19c0db60aa471e50bd4d10daf7e9ee7d275cdfd5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 14 Mar 2022 16:26:56 +0100 Subject: [PATCH 01/47] Initial package to encapsulate the TechDocs addon framework. Signed-off-by: Eric Peterson --- plugins/techdocs-addons/.eslintrc.js | 1 + plugins/techdocs-addons/README.md | 5 ++ plugins/techdocs-addons/api-report.md | 24 ++++++++++ plugins/techdocs-addons/package.json | 33 +++++++++++++ plugins/techdocs-addons/src/index.ts | 23 +++++++++ plugins/techdocs-addons/src/types.ts | 69 +++++++++++++++++++++++++++ scripts/api-extractor.ts | 3 +- 7 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 plugins/techdocs-addons/.eslintrc.js create mode 100644 plugins/techdocs-addons/README.md create mode 100644 plugins/techdocs-addons/api-report.md create mode 100644 plugins/techdocs-addons/package.json create mode 100644 plugins/techdocs-addons/src/index.ts create mode 100644 plugins/techdocs-addons/src/types.ts diff --git a/plugins/techdocs-addons/.eslintrc.js b/plugins/techdocs-addons/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/techdocs-addons/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/techdocs-addons/README.md b/plugins/techdocs-addons/README.md new file mode 100644 index 0000000000..b97a8d583d --- /dev/null +++ b/plugins/techdocs-addons/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-techdocs-addons + +Package encapsulating the TechDocs Addons framework. + +todo(backstage/techdocs-core): Fill in with real documentation! diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md new file mode 100644 index 0000000000..e14ea6112c --- /dev/null +++ b/plugins/techdocs-addons/api-report.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/plugin-techdocs-addons" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ComponentType } from 'react'; + +// @public +export enum TechDocsAddonLocations { + COMPONENT = 'component', + CONTENT = 'content', + HEADER = 'header', + PRIMARY_SIDEBAR = 'primary sidebar', + SECONDARY_SIDEBAR = 'secondary sidebar', + SUBHEADER = 'subheader', +} + +// @public +export type TechDocsAddonOptions = { + name: string; + location: TechDocsAddonLocations; + component: ComponentType; +}; +``` diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json new file mode 100644 index 0000000000..4df0488689 --- /dev/null +++ b/plugins/techdocs-addons/package.json @@ -0,0 +1,33 @@ +{ + "name": "@backstage/plugin-techdocs-addons", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": {}, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": {}, + "files": [ + "dist" + ] +} diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts new file mode 100644 index 0000000000..715da10e20 --- /dev/null +++ b/plugins/techdocs-addons/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Package encapsulating the TechDocs Addon framework. + * + * @packageDocumentation + */ + +export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; diff --git a/plugins/techdocs-addons/src/types.ts b/plugins/techdocs-addons/src/types.ts new file mode 100644 index 0000000000..584d2229b3 --- /dev/null +++ b/plugins/techdocs-addons/src/types.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; + +/** + * Locations for which TechDocs addons may be declared and rendered. + * @public + */ +export enum TechDocsAddonLocations { + /** + * These addons fill up the header from the right, on the same line as the + * title. + */ + HEADER = 'header', + + /** + * These addons appear below the header and above all content; tooling addons + * can be inserted for convenience. + */ + SUBHEADER = 'subheader', + + /** + * These addons appear left of the content and above the navigation. + */ + PRIMARY_SIDEBAR = 'primary sidebar', + + /** + * These addons appear right of the content and above the table of contents. + */ + SECONDARY_SIDEBAR = 'secondary sidebar', + + /** + * A virtual location which allows mutation of all content within the shadow + * root by transforming DOM nodes. These addons should return null on render. + */ + CONTENT = 'content', + + /** + * A virtual location allowing an instance of the addon to be rendered for + * every HTML node with the same tag name as the addon name in the markdown + * content. If no reference is made, no instance will be rendered. Works like + * regular React components, just being accessible from markdown. + */ + COMPONENT = 'component', +} + +/** + * Options for creating a TechDocs addon. + * @public + */ +export type TechDocsAddonOptions = { + name: string; + location: TechDocsAddonLocations; + component: ComponentType; +}; diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 79f1f1eeb4..19f463d45e 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -257,6 +257,8 @@ const NO_WARNING_PACKAGES = [ 'plugins/scaffolder-common', 'plugins/search-backend-node', 'plugins/search-common', + 'plugins/techdocs', + 'plugins/techdocs-addons', 'plugins/techdocs-backend', 'plugins/techdocs-node', 'plugins/tech-insights', @@ -264,7 +266,6 @@ const NO_WARNING_PACKAGES = [ 'plugins/tech-insights-backend-module-jsonfc', 'plugins/tech-insights-common', 'plugins/tech-insights-node', - 'plugins/techdocs', 'plugins/todo', 'plugins/todo-backend', ]; From 30d2ca266968337fa3405e4d4a12558070578eb2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 15 Mar 2022 13:15:25 +0100 Subject: [PATCH 02/47] Allow addons to be created and registered. Co-authored-by: Emma Indal Co-authored-by: Camila Belo Co-authored-by: Otto Sichert Signed-off-by: Eric Peterson --- plugins/techdocs-addons/README.md | 60 ++++++++++++- plugins/techdocs-addons/api-report.md | 10 +++ plugins/techdocs-addons/package.json | 5 +- plugins/techdocs-addons/src/addons.tsx | 120 +++++++++++++++++++++++++ plugins/techdocs-addons/src/index.ts | 1 + 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 plugins/techdocs-addons/src/addons.tsx diff --git a/plugins/techdocs-addons/README.md b/plugins/techdocs-addons/README.md index b97a8d583d..432aa757e4 100644 --- a/plugins/techdocs-addons/README.md +++ b/plugins/techdocs-addons/README.md @@ -1,5 +1,61 @@ # @backstage/plugin-techdocs-addons -Package encapsulating the TechDocs Addons framework. +Package encapsulating the TechDocs Addon framework. -todo(backstage/techdocs-core): Fill in with real documentation! +## What is an addon? + +An addon is a isolated piece of functionality that one can use to augment the +TechDocs experience at render-time. For example: an issue counter showing the +number of issues reported on the documentation, or the top contributors to the +documentation. + +## Create a new addon + +To create a new addon, you can use the `createTechDocsAddon` factory exported +from this plugin. Normally, addons are provided by Backstage plugins, which can +then be composed within a Backstage app. + +When you create a new Addon, it requires three things. + +1. A `name` for debugging and analytics purposes) +2. A `location`, indicating where/how the addon will be rendered +3. A `component`, encapsulating the addon's logic and functionality + +```tsx +import { + createTechDocsAddon, + TechDocsAddonLocations, +} from '@backstage/plugin-techdocs-addons'; +import { StackOverflowSecondarySidebarAddon } from './components'; + +export const StackOverflowSecondarySidebar = yourBackstagePlugin.provide( + createTechDocsAddon({ + name: 'StackOverflowSecondarySidebar', + type: TechDocsAddonLocations.SECONDARY_SIDEBAR, + component: StackOverflowSecondarySidebarAddon, + }), +); +``` + +## Compose your app with addons + +To configure which addons will augment the TechDocs experience in your +Backstage app, you need two things: + +- The `TechDocsAddons` component, which is responsible for registering the + addons. +- A list of the addons themselves, as exported by their respective plugins. + +```tsx +import { + TechDocsAddons, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs-addons'; +import { StackOverflowSecondarySidebar } from '@backstage/plugin-soe'; + +}> + + + +; +``` diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md index e14ea6112c..f3b3d5fe69 100644 --- a/plugins/techdocs-addons/api-report.md +++ b/plugins/techdocs-addons/api-report.md @@ -4,6 +4,13 @@ ```ts import { ComponentType } from 'react'; +import { Extension } from '@backstage/core-plugin-api'; +import { default as React_2 } from 'react'; + +// @public +export function createTechDocsAddon( + options: TechDocsAddonOptions, +): Extension>; // @public export enum TechDocsAddonLocations { @@ -21,4 +28,7 @@ export type TechDocsAddonOptions = { location: TechDocsAddonLocations; component: ComponentType; }; + +// @public +export const TechDocsAddons: React_2.ComponentType; ``` diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 4df0488689..79da76e32a 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -21,7 +21,10 @@ "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack" }, - "dependencies": {}, + "dependencies": { + "@backstage/core-plugin-api": "^0.8.0", + "react-router-dom": "6.0.0-beta.0" + }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", "react": "^16.13.1 || ^17.0.0" diff --git a/plugins/techdocs-addons/src/addons.tsx b/plugins/techdocs-addons/src/addons.tsx new file mode 100644 index 0000000000..36b5d767ba --- /dev/null +++ b/plugins/techdocs-addons/src/addons.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + attachComponentData, + createReactExtension, + ElementCollection, + Extension, + useElementFilter, +} from '@backstage/core-plugin-api'; +import React, { ComponentType, useCallback } from 'react'; +import { useOutlet } from 'react-router-dom'; + +import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; + +export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1'; +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + +/** + * TechDocs Addon registry. + * @public + */ +export const TechDocsAddons: React.ComponentType = () => null; + +attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); + +const getDataKeyByName = (name: string) => { + return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; +}; + +/** + * Create a TechDocs addon. + * @public + */ +export function createTechDocsAddon( + options: TechDocsAddonOptions, +): Extension> { + const { name, component: TechDocsAddon } = options; + return createReactExtension({ + name, + component: { + sync: (props: TComponentProps) => , + }, + data: { + [TECHDOCS_ADDONS_KEY]: options, + [getDataKeyByName(name)]: true, + }, + }); +} + +const getTechDocsAddonByName = (collection: ElementCollection, key: string) => { + return collection.selectByComponentData({ key }).getElements()[0]; +}; + +const getAllTechDocsAddons = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .selectByComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +const getAllTechDocsAddonsData = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .findComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +export const useTechDocsAddons = () => { + const node = useOutlet(); + + const collection = useElementFilter(node, getAllTechDocsAddons); + const options = useElementFilter(node, getAllTechDocsAddonsData); + + const findAddonByData = useCallback( + (data: TechDocsAddonOptions | undefined) => { + if (!collection || !data) return null; + const nameKey = getDataKeyByName(data.name); + return getTechDocsAddonByName(collection, nameKey) ?? null; + }, + [collection], + ); + + const renderComponentWithName = useCallback( + (name: string) => { + const data = options.find(option => option.name === name); + return data ? findAddonByData(data) : null; + }, + [options, findAddonByData], + ); + + const renderComponentsWithLocation = useCallback( + (location: TechDocsAddonLocations) => { + const data = options.filter(option => option.location === location); + return data.length ? data.map(findAddonByData) : null; + }, + [options, findAddonByData], + ); + + return { renderComponentWithName, renderComponentsWithLocation }; +}; diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index 715da10e20..ba9f2a7266 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -20,4 +20,5 @@ * @packageDocumentation */ +export { createTechDocsAddon, TechDocsAddons } from './addons'; export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; From ff1cc8bced568fdf66d446adc304ec0fd1c54cfc Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 16 Mar 2022 10:57:22 +0100 Subject: [PATCH 03/47] Expose an alternative, add-on-aware read component and hooks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Emma Indal Co-authored-by: Camila Belo Co-authored-by: Otto Sichert Co-authored-by: Anders Näsman Signed-off-by: Eric Peterson --- .changeset/techdocs-hold-me-closer.md | 5 + plugins/techdocs-addons/api-report.md | 29 +++ plugins/techdocs-addons/package.json | 11 +- plugins/techdocs-addons/src/context.tsx | 193 ++++++++++++++++++ plugins/techdocs-addons/src/index.ts | 8 + plugins/techdocs-addons/src/reader.tsx | 217 ++++++++++++++++++++ plugins/techdocs-addons/src/types.ts | 3 + plugins/techdocs/package.json | 1 + yarn.lock | 256 +++++++++++++++++++++++- 9 files changed, 717 insertions(+), 6 deletions(-) create mode 100644 .changeset/techdocs-hold-me-closer.md create mode 100644 plugins/techdocs-addons/src/context.tsx create mode 100644 plugins/techdocs-addons/src/reader.tsx diff --git a/.changeset/techdocs-hold-me-closer.md b/.changeset/techdocs-hold-me-closer.md new file mode 100644 index 0000000000..2295603388 --- /dev/null +++ b/.changeset/techdocs-hold-me-closer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-addons': minor +--- + +Introducing an addon framework for TechDocs. diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md index f3b3d5fe69..c5a61a34e3 100644 --- a/plugins/techdocs-addons/api-report.md +++ b/plugins/techdocs-addons/api-report.md @@ -3,9 +3,14 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { ComponentType } from 'react'; +import { CompoundEntityRef } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; +import { TechDocsEntityMetadata } from '@backstage/plugin-techdocs'; +import { TechDocsMetadata } from '@backstage/plugin-techdocs'; // @public export function createTechDocsAddon( @@ -31,4 +36,28 @@ export type TechDocsAddonOptions = { // @public export const TechDocsAddons: React_2.ComponentType; + +// @public +export const TechDocsReaderPage: ( + props: TechDocsReaderPageProps, +) => JSX.Element; + +// @public (undocumented) +export type TechDocsReaderPageProps = { + entityName: CompoundEntityRef; +}; + +// @public +export const useEntityMetadata: () => TechDocsEntityMetadata | undefined; + +// @public +export const useMetadata: () => TechDocsMetadata | undefined; + +// @public +export const useShadowRoot: () => ShadowRoot | undefined; + +// @public +export const useShadowRootElements: ( + selectors: string[], +) => T[]; ``` diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 79da76e32a..3eb29ea77a 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -22,8 +22,17 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/catalog-model": "^0.13.0", + "@backstage/core-components": "^0.9.1", "@backstage/core-plugin-api": "^0.8.0", - "react-router-dom": "6.0.0-beta.0" + "@backstage/plugin-techdocs": "^0.15.1", + "@material-ui/core": "^4.12.2", + "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/styles": "^4.11.0", + "jss": "~10.8.2", + "react-helmet": "6.1.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/techdocs-addons/src/context.tsx b/plugins/techdocs-addons/src/context.tsx new file mode 100644 index 0000000000..7908033874 --- /dev/null +++ b/plugins/techdocs-addons/src/context.tsx @@ -0,0 +1,193 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { useApi, useApp } from '@backstage/core-plugin-api'; +import { + techdocsApiRef, + TechDocsEntityMetadata, + TechDocsMetadata, +} from '@backstage/plugin-techdocs'; +import React, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +type PropsWithEntityName = PropsWithChildren<{ entityName: CompoundEntityRef }>; + +const TechDocsMetadataContext = createContext( + undefined, +); + +export const TechDocsMetadataProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const { NotFoundErrorPage } = useApp().getComponents(); + const techdocsApi = useApi(techdocsApiRef); + + const { value, loading, error } = useAsync(async () => { + return await techdocsApi.getTechDocsMetadata(entityName); + }, []); + + if (!loading && error) { + return ; + } + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the + * current TechDocs site. + * @public + */ +export const useMetadata = () => { + return useContext(TechDocsMetadataContext); +}; + +const TechDocsEntityContext = createContext( + undefined, +); + +export const TechDocsEntityProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const { NotFoundErrorPage } = useApp().getComponents(); + const techdocsApi = useApi(techdocsApiRef); + + const { value, loading, error } = useAsync(async () => { + return await techdocsApi.getEntityMetadata(entityName); + }, []); + + if (!loading && error) { + return ; + } + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve Entity Metadata for the + * current TechDocs site. + * @public + */ +export const useEntityMetadata = () => { + return useContext(TechDocsEntityContext); +}; + +export type TechDocsReaderPageValue = { + entityName: CompoundEntityRef; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; +}; + +export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { + title: '', + setTitle: () => {}, + subtitle: '', + setSubtitle: () => {}, + setShadowRoot: () => {}, + entityName: { kind: '', name: '', namespace: '' }, +}; + +export const TechDocsReaderPageContext = createContext( + defaultTechDocsReaderPageValue, +); + +export const useTechDocsReaderPage = () => { + return useContext(TechDocsReaderPageContext); +}; + +export const TechDocsReaderPageProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + entityName, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons that provides access to the underlying + * shadow root of the current page, allowing the DOM within to be mutated. + * @public + */ +export const useShadowRoot = () => { + const { shadowRoot } = useTechDocsReaderPage(); + return shadowRoot; +}; + +/** + * Convenience hook for use within TechDocs addons that provides access to + * elements that match a given selector within the shadow root. + * + * todo(backstage/techdocs-core): Consider extending `selectors` from string[] + * to some kind of typed object array, so users have more control over the + * shape of the result. e.g. a flag to indicate querySelector vs. + * querySelectorAll. + * + * @public + */ +export const useShadowRootElements = ( + selectors: string[], +): T[] => { + const shadowRoot = useShadowRoot(); + if (!shadowRoot) return []; + return selectors + .map(selector => shadowRoot?.querySelectorAll(selector)) + .filter(nodeList => nodeList.length) + .map(nodeList => Array.from(nodeList)) + .flat(); +}; diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index ba9f2a7266..43802de14f 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -21,4 +21,12 @@ */ export { createTechDocsAddon, TechDocsAddons } from './addons'; +export { + useEntityMetadata, + useMetadata, + useShadowRoot, + useShadowRootElements, +} from './context'; +export { TechDocsReaderPage } from './reader'; +export type { TechDocsReaderPageProps } from './reader'; export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; diff --git a/plugins/techdocs-addons/src/reader.tsx b/plugins/techdocs-addons/src/reader.tsx new file mode 100644 index 0000000000..f7f2407d46 --- /dev/null +++ b/plugins/techdocs-addons/src/reader.tsx @@ -0,0 +1,217 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { Content, Header, Page, Progress } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs +import { + // @ts-ignore + useTechDocsReaderDom, + // @ts-ignore + withTechDocsReaderProvider, + // @ts-ignore + TechDocsStateIndicator as TechDocReaderPageIndicator, +} from '@backstage/plugin-techdocs'; +import { + withStyles, + Portal, + Box, + Toolbar, + ToolbarProps, +} from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { StylesProvider, jssPreset } from '@material-ui/styles'; +import React, { useEffect, useRef, useState } from 'react'; +import { create } from 'jss'; +import Helmet from 'react-helmet'; + +import { useTechDocsAddons } from './addons'; +import { + TechDocsMetadataProvider, + useMetadata, + TechDocsEntityProvider, + TechDocsReaderPageProvider, + useTechDocsReaderPage, +} from './context'; +import { TechDocsAddonLocations as locations } from './types'; + +const TechDocsReaderPageSubheader = withStyles(theme => ({ + root: { + gridArea: 'pageSubheader', + flexDirection: 'column', + minHeight: 'auto', + padding: theme.spacing(3, 3, 0), + }, +}))(({ ...rest }: ToolbarProps) => { + const addons = useTechDocsAddons(); + + if (!addons.renderComponentsWithLocation(locations.SUBHEADER)) return null; + + return ( + + {addons.renderComponentsWithLocation(locations.SUBHEADER) && ( + + {addons.renderComponentsWithLocation(locations.SUBHEADER)} + + )} + + ); +}); + +const skeleton = ; + +const TechDocsReaderPageHeader = () => { + const addons = useTechDocsAddons(); + const configApi = useApi(configApiRef); + + const metadata = useMetadata(); + + const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage(); + + useEffect(() => { + if (!metadata) return; + setTitle(prevTitle => prevTitle || metadata.site_name); + setSubtitle( + prevSubtitle => prevSubtitle || metadata.site_description || 'Home', + ); + }, [metadata, setTitle, setSubtitle]); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); + + return ( +
+ + {tabTitle} + + {addons.renderComponentsWithLocation(locations.HEADER)} +
+ ); +}; + +const TechDocsReaderPageContent = () => { + const ref = useRef(null); + const [jss, setJss] = useState( + create({ + ...jssPreset(), + insertionPoint: undefined, + }), + ); + + const addons = useTechDocsAddons(); + const { entityName, setShadowRoot } = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(entityName); + + useEffect(() => { + const shadowHost = ref.current; + if (!dom || !shadowHost || shadowHost.shadowRoot) return; + + setJss( + create({ + ...jssPreset(), + insertionPoint: dom.querySelector('head') || undefined, + }), + ); + + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + shadowRoot.appendChild(dom); + setShadowRoot(shadowRoot); + }, [dom, setShadowRoot]); + + const contentElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="container"]', + ); + const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="navigation"]', + ); + const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="toc"]', + ); + + const primarySidebarAddonSpace = document.createElement('div'); + primarySidebarElement?.prepend(primarySidebarAddonSpace); + + const secondarySidebarAddonSpace = document.createElement('div'); + secondarySidebarElement?.prepend(secondarySidebarAddonSpace); + + // do not return content until dom is ready + if (!dom) { + return ( + + + + ); + } + + return ( + + {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} + +
+ + {addons.renderComponentsWithLocation(locations.PRIMARY_SIDEBAR)} + + + {addons.renderComponentsWithLocation(locations.CONTENT)} + + + {addons.renderComponentsWithLocation(locations.SECONDARY_SIDEBAR)} + + + + ); +}; + +/** + * @public + */ +export type TechDocsReaderPageProps = { entityName: CompoundEntityRef }; + +/** + * An addon-aware implementation of the TechDocsReaderPage. + * @public + */ +export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { + const { entityName } = props; + const Component = withTechDocsReaderProvider(() => { + return ( + + + + + + + + + + + + + ); + }, entityName); + return ; +}; diff --git a/plugins/techdocs-addons/src/types.ts b/plugins/techdocs-addons/src/types.ts index 584d2229b3..9cb095577f 100644 --- a/plugins/techdocs-addons/src/types.ts +++ b/plugins/techdocs-addons/src/types.ts @@ -54,6 +54,9 @@ export enum TechDocsAddonLocations { * every HTML node with the same tag name as the addon name in the markdown * content. If no reference is made, no instance will be rendered. Works like * regular React components, just being accessible from markdown. + * + * todo(backstage/techdocs-core): Keep and implement or remove before + * releasing this package! */ COMPONENT = 'component', } diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 8516c09783..d0ef75e32c 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -72,6 +72,7 @@ "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.0.0", + "@types/event-source-polyfill": "^1.0.0", "@types/dompurify": "^2.2.2", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", diff --git a/yarn.lock b/yarn.lock index 37046a1f16..3cfe0b84fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1456,6 +1456,15 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@backstage/catalog-client@^0.9.0": + version "0.9.0" + resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-0.9.0.tgz#3e1024fab13fd8e2000d33833d2463ea9be5df9d" + integrity sha1-PhAk+rE/2OIADTODPSRj6pvl350= + dependencies: + "@backstage/catalog-model" "^0.13.0" + "@backstage/errors" "^0.2.2" + cross-fetch "^3.1.5" + "@backstage/catalog-client@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-1.0.0.tgz#05f9ee3b771ca17e4800f5116d63bd183fe0c4d6" @@ -1465,6 +1474,19 @@ "@backstage/errors" "^1.0.0" cross-fetch "^3.1.5" +"@backstage/catalog-model@^0.13.0": + version "0.13.0" + resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-0.13.0.tgz#abeb91522ac7ef7907907ad5bc889803131db209" + integrity sha1-q+uRUirH73kHkHrVvIiYAxMdsgk= + dependencies: + "@backstage/config" "^0.1.15" + "@backstage/errors" "^0.2.2" + "@backstage/types" "^0.1.3" + ajv "^7.0.3" + json-schema "^0.4.0" + lodash "^4.17.21" + uuid "^8.0.0" + "@backstage/catalog-model@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-1.0.0.tgz#0aa8694a3182aaf4232725842da751bf5f78bd68" @@ -1478,7 +1500,15 @@ lodash "^4.17.21" uuid "^8.0.0" -"@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": +"@backstage/config@^0.1.15": + version "0.1.15" + resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.15.tgz#4bad122ad861be5bd61a60639f92d2494fa245c5" + integrity sha512-eNJEYYSEu9MkrkBYiMpUBWEc3Bu64YgB9pZZGCMW7/9350tV2wbylEdoBJHslilJlJhiUyTXBckn8Ua7DOH7rw== + dependencies: + "@backstage/types" "^0.1.3" + lodash "^4.17.21" + +"@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.1", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" integrity sha512-kh0FB0FmjC55W+xSEkKrAc7D6hvbYLY7N1UUd6M4VBghYXD61Y8RrJFKmBM3bAfPgYaryQNjYgA0BsoTo53PJA== @@ -1522,6 +1552,43 @@ zen-observable "^0.8.15" zod "^3.11.6" +"@backstage/core-plugin-api@^0.8.0": + version "0.8.0" + resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.8.0.tgz#e2096bff679183168a7f9b47ed27c50a01970e32" + integrity sha1-4glr/2eRgxaKf5tH7SfFCgGXDjI= + dependencies: + "@backstage/config" "^0.1.15" + "@backstage/types" "^0.1.3" + "@backstage/version-bridge" "^0.1.2" + history "^5.0.0" + prop-types "^15.7.2" + react-router-dom "6.0.0-beta.0" + zen-observable "^0.8.15" + +"@backstage/errors@^0.2.2": + version "0.2.2" + resolved "https://registry.npmjs.org/@backstage/errors/-/errors-0.2.2.tgz#2113e0bc859e645b8b59bfcb435f7535739b02f8" + integrity sha1-IRPgvIWeZFuLWb/LQ191NXObAvg= + dependencies: + "@backstage/types" "^0.1.3" + cross-fetch "^3.1.5" + serialize-error "^8.0.1" + +"@backstage/integration-react@^0.1.25": + version "0.1.25" + resolved "https://registry.npmjs.org/@backstage/integration-react/-/integration-react-0.1.25.tgz#ebbdd30d66e1d210b7cd33a682ad2be0d5ea5fc0" + integrity sha1-673TDWbh0hC3zTOmgq0r4NXqX8A= + dependencies: + "@backstage/config" "^0.1.15" + "@backstage/core-components" "^0.9.1" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/integration" "^0.8.0" + "@backstage/theme" "^0.2.15" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.57" + react-use "^17.2.4" + "@backstage/integration-react@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/integration-react/-/integration-react-1.0.0.tgz#8075e65c6b5387631d27a9c242e7a4fff6f92417" @@ -1537,6 +1604,19 @@ "@material-ui/lab" "4.0.0-alpha.57" react-use "^17.2.4" +"@backstage/integration@^0.8.0": + version "0.8.0" + resolved "https://registry.npmjs.org/@backstage/integration/-/integration-0.8.0.tgz#d74131ad347272b4935973aa4bd098fad9548ce6" + integrity sha1-10ExrTRycrSTWXOqS9CY+tlUjOY= + dependencies: + "@backstage/config" "^0.1.15" + "@octokit/auth-app" "^3.4.0" + "@octokit/rest" "^18.5.3" + cross-fetch "^3.1.5" + git-url-parse "^11.6.0" + lodash "^4.17.21" + luxon "^2.0.2" + "@backstage/integration@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/integration/-/integration-1.0.0.tgz#e307cfddea014bfb0eb2281a5ae25ea0b742e9cf" @@ -1550,6 +1630,41 @@ lodash "^4.17.21" luxon "^2.0.2" +"@backstage/plugin-catalog-common@^0.2.2": + version "0.2.2" + resolved "https://registry.npmjs.org/@backstage/plugin-catalog-common/-/plugin-catalog-common-0.2.2.tgz#2f039ecd829d1d8e017609cb0bbf9af7c231ab63" + integrity sha1-LwOezYKdHY4BdgnLC7+a98Ixq2M= + dependencies: + "@backstage/plugin-permission-common" "^0.5.2" + "@backstage/search-common" "^0.3.1" + +"@backstage/plugin-catalog-react@^0.9.0": + version "0.9.0" + resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.9.0.tgz#ff8c09ec455655fadb2c2fafd578908138b858bc" + integrity sha1-/4wJ7EVWVfrbLC+v1XiQgTi4WLw= + dependencies: + "@backstage/catalog-client" "^0.9.0" + "@backstage/catalog-model" "^0.13.0" + "@backstage/core-components" "^0.9.1" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/errors" "^0.2.2" + "@backstage/integration" "^0.8.0" + "@backstage/plugin-permission-common" "^0.5.2" + "@backstage/plugin-permission-react" "^0.3.3" + "@backstage/types" "^0.1.3" + "@backstage/version-bridge" "^0.1.2" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.57" + classnames "^2.2.6" + jwt-decode "^3.1.0" + lodash "^4.17.21" + qs "^6.9.4" + react-router "6.0.0-beta.0" + react-use "^17.2.4" + yaml "^1.10.0" + zen-observable "^0.8.15" + "@backstage/plugin-catalog-react@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.0.0.tgz#4f42c070ffe5c9690e45a5288e18bacd8d4e0e66" @@ -1578,7 +1693,33 @@ yaml "^1.10.0" zen-observable "^0.8.15" -"@backstage/plugin-permission-common@^0.5.3": +"@backstage/plugin-catalog@^0.10.0": + version "0.10.0" + resolved "https://registry.npmjs.org/@backstage/plugin-catalog/-/plugin-catalog-0.10.0.tgz#7b7f1b54704380c51f7506bfba948a3e0ff0575d" + integrity sha1-e38bVHBDgMUfdQa/upSKPg/wV10= + dependencies: + "@backstage/catalog-client" "^0.9.0" + "@backstage/catalog-model" "^0.13.0" + "@backstage/core-components" "^0.9.1" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/errors" "^0.2.2" + "@backstage/integration-react" "^0.1.25" + "@backstage/plugin-catalog-common" "^0.2.2" + "@backstage/plugin-catalog-react" "^0.9.0" + "@backstage/plugin-search-common" "^0.3.1" + "@backstage/theme" "^0.2.15" + "@backstage/types" "^0.1.2" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.57" + history "^5.0.0" + lodash "^4.17.21" + react-helmet "6.1.0" + react-router "6.0.0-beta.0" + react-use "^17.2.4" + zen-observable "^0.8.15" + +"@backstage/plugin-permission-common@^0.5.2", "@backstage/plugin-permission-common@^0.5.3": version "0.5.3" resolved "https://registry.npmjs.org/@backstage/plugin-permission-common/-/plugin-permission-common-0.5.3.tgz#a1a4446e603584f2c82763745051f75f4a942eb1" integrity sha512-zppDsNZEK9ffgXbf/Zx0sw4ffuOVOEvBZlft1+Oph2rO4+uN7dmCLMRRcKsYeNQ6/F50e6BMyNWpPZQDR/JQsA== @@ -1589,7 +1730,7 @@ uuid "^8.0.0" zod "^3.11.6" -"@backstage/plugin-permission-react@^0.3.4": +"@backstage/plugin-permission-react@^0.3.3", "@backstage/plugin-permission-react@^0.3.4": version "0.3.4" resolved "https://registry.npmjs.org/@backstage/plugin-permission-react/-/plugin-permission-react-0.3.4.tgz#e769dc1489c35d9c924234c0764a584558891716" integrity sha512-S8s1cvCZFmxP4Dn5V9fOls31s4V1rgx3YUXqHSkgLatYHOXczf+GM/c5rdGLQyOb/+Hb+MQqaPAkojvSxNliow== @@ -1602,6 +1743,83 @@ react-use "^17.2.4" swr "^1.1.2" +"@backstage/plugin-search-common@0.3.2", "@backstage/plugin-search-common@^0.3.1", "@backstage/plugin-search-common@^0.3.2": + version "0.3.2" + resolved "https://registry.npmjs.org/@backstage/plugin-search-common/-/plugin-search-common-0.3.2.tgz#15984ba4c14f8a9119168e8c79344ef8101863dc" + integrity sha1-FZhLpMFPipEZFo6MeTRO+BAYY9w= + dependencies: + "@backstage/plugin-permission-common" "^0.5.3" + "@backstage/types" "^1.0.0" + +"@backstage/plugin-search@^0.7.3": + version "0.7.4" + resolved "https://registry.npmjs.org/@backstage/plugin-search/-/plugin-search-0.7.4.tgz#d6571da128342d122f80253a756ece0a702967f9" + integrity sha1-1lcdoSg0LRIvgCU6dW7OCnApZ/k= + dependencies: + "@backstage/catalog-model" "^1.0.0" + "@backstage/config" "^1.0.0" + "@backstage/core-components" "^0.9.2" + "@backstage/core-plugin-api" "^1.0.0" + "@backstage/errors" "^1.0.0" + "@backstage/plugin-catalog-react" "^1.0.0" + "@backstage/plugin-search-common" "^0.3.2" + "@backstage/theme" "^0.2.15" + "@backstage/types" "^1.0.0" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.57" + qs "^6.9.4" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-text-truncate "^0.18.0" + react-use "^17.2.4" + +"@backstage/plugin-techdocs@^0.15.1": + version "0.15.1" + resolved "https://registry.npmjs.org/@backstage/plugin-techdocs/-/plugin-techdocs-0.15.1.tgz#f57b63526976da04f1d926b5b5b50e1a89b29184" + integrity sha1-9XtjUml22gTx2Sa1tbUOGomykYQ= + dependencies: + "@backstage/catalog-model" "^0.13.0" + "@backstage/config" "^0.1.15" + "@backstage/core-components" "^0.9.1" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/errors" "^0.2.2" + "@backstage/integration" "^0.8.0" + "@backstage/integration-react" "^0.1.25" + "@backstage/plugin-catalog" "^0.10.0" + "@backstage/plugin-catalog-react" "^0.9.0" + "@backstage/plugin-search" "^0.7.3" + "@backstage/theme" "^0.2.15" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.57" + "@material-ui/styles" "^4.10.0" + dompurify "^2.2.9" + event-source-polyfill "^1.0.25" + git-url-parse "^11.6.0" + lodash "^4.17.21" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-text-truncate "^0.18.0" + react-use "^17.2.4" + +"@backstage/search-common@^0.3.1": + version "0.3.2" + resolved "https://registry.npmjs.org/@backstage/search-common/-/search-common-0.3.2.tgz#608a4eddf7eae71ed807ec1f723a80c6f7cdf3e4" + integrity sha1-YIpO3ffq5x7YB+wfcjqAxvfN8+Q= + dependencies: + "@backstage/plugin-search-common" "0.3.2" + +"@backstage/types@^0.1.2", "@backstage/types@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14" + integrity sha512-fJVi4oVrlO+G3PRv1fYSll9/X4pE11HLnkI//Geare9sP6wSfp/2zXpLYfKVsG0e24jOl7Swkc8lwLkQ90zMaQ== + +"@backstage/version-bridge@^0.1.2": + version "0.1.2" + resolved "https://registry.npmjs.org/@backstage/version-bridge/-/version-bridge-0.1.2.tgz#a24f42e0f383d497576f8c9d43851c6538345c03" + integrity sha1-ok9C4POD1JdXb4ydQ4UcZTg0XAM= + "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -6022,6 +6240,11 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/event-source-polyfill@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.0.tgz#f93f13433f750c8ea0e3cfa69c72e3c7393e0585" + integrity sha512-b8O8/rg7NIW0iJ8i9MNDBZqPljHA+b7AjC3QFqH3dSyW6vgrl3oBgyIv5dw2fibh5enHHDkkPZG5PHza7U4NRw== + "@types/expect@^1.20.4": version "1.20.4" resolved "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5" @@ -7857,7 +8080,7 @@ array-ify@^1.0.0: resolved "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4= -array-includes@^3.1.3, array-includes@^3.1.4: +array-includes@^3.1.2, array-includes@^3.1.3, array-includes@^3.1.4: version "3.1.4" resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz#f5b493162c760f3539631f005ba2bb46acb45ba9" integrity sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw== @@ -12111,6 +12334,11 @@ event-source-polyfill@1.0.25: resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.25.tgz#d8bb7f99cb6f8119c2baf086d9f6ee0514b6d9c8" integrity sha512-hQxu6sN1Eq4JjoI7ITdQeGGUN193A2ra83qC0Ltm9I2UJVAten3OFVN6k5RX4YWeCS0BoC8xg/5czOCIHVosQg== +event-source-polyfill@^1.0.25: + version "1.0.26" + resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.26.tgz#86c04d088ef078279168eefa028f928fec5059a4" + integrity sha512-IwDLs9fUTcGAyacHBeS53T8wcEkDyDn0UP4tfQqJ4wQP8AyH0mszuQf2ULTylnpI0sMquzJ4usrNV7+uztwI9A== + event-stream@=3.3.4: version "3.3.4" resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" @@ -16221,7 +16449,25 @@ jss@10.6.0, jss@^10.5.1: is-in-browser "^1.1.3" tiny-warning "^1.0.2" -"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1: +jss@~10.8.2: + version "10.8.2" + resolved "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz#4b2a30b094b924629a64928236017a52c7c97505" + integrity sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ== + dependencies: + "@babel/runtime" "^7.3.1" + csstype "^3.0.2" + is-in-browser "^1.1.3" + tiny-warning "^1.0.2" + +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.2.0" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" + integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== + dependencies: + array-includes "^3.1.2" + object.assign "^4.1.2" + +jsx-ast-utils@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz#720b97bfe7d901b927d87c3773637ae8ea48781b" integrity sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA== From d8db6db3d70c0c7fedf6b8b552b3a7f111a79266 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 16 Mar 2022 12:02:03 +0100 Subject: [PATCH 04/47] Review feedback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Emma Indal Co-authored-by: Anders Näsman Signed-off-by: Eric Peterson --- plugins/techdocs-addons/README.md | 7 +- plugins/techdocs-addons/src/addons.tsx | 6 +- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 64 ++++++ .../components/TechDocsReaderPage/index.ts | 18 ++ .../TechDocsReaderPageContent.tsx | 102 ++++++++ .../TechDocsReaderPageContent/index.ts | 17 ++ .../TechDocsReaderPageHeader.tsx | 61 +++++ .../TechDocsReaderPageHeader/index.ts | 17 ++ .../TechDocsReaderPageSubheader.tsx | 49 ++++ .../TechDocsReaderPageSubheader/index.ts | 17 ++ .../techdocs-addons/src/components/index.ts | 17 ++ plugins/techdocs-addons/src/index.ts | 3 +- plugins/techdocs-addons/src/reader.tsx | 217 ------------------ 13 files changed, 371 insertions(+), 224 deletions(-) create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx create mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts create mode 100644 plugins/techdocs-addons/src/components/index.ts delete mode 100644 plugins/techdocs-addons/src/reader.tsx diff --git a/plugins/techdocs-addons/README.md b/plugins/techdocs-addons/README.md index 432aa757e4..0ee88f84d0 100644 --- a/plugins/techdocs-addons/README.md +++ b/plugins/techdocs-addons/README.md @@ -18,7 +18,10 @@ then be composed within a Backstage app. When you create a new Addon, it requires three things. 1. A `name` for debugging and analytics purposes) -2. A `location`, indicating where/how the addon will be rendered +2. A `location`, indicating where/how the addon will be rendered. Valid + locations include: `header`, `subheader`, `primary sidebar`, + `secondary sidebar`, `content`, and `component`. Values are available on an + enumerable `TechDocsAddonLocations` and are type-hinted. 3. A `component`, encapsulating the addon's logic and functionality ```tsx @@ -31,7 +34,7 @@ import { StackOverflowSecondarySidebarAddon } from './components'; export const StackOverflowSecondarySidebar = yourBackstagePlugin.provide( createTechDocsAddon({ name: 'StackOverflowSecondarySidebar', - type: TechDocsAddonLocations.SECONDARY_SIDEBAR, + location: TechDocsAddonLocations.SECONDARY_SIDEBAR, component: StackOverflowSecondarySidebarAddon, }), ); diff --git a/plugins/techdocs-addons/src/addons.tsx b/plugins/techdocs-addons/src/addons.tsx index 36b5d767ba..06ce1eb137 100644 --- a/plugins/techdocs-addons/src/addons.tsx +++ b/plugins/techdocs-addons/src/addons.tsx @@ -100,7 +100,7 @@ export const useTechDocsAddons = () => { [collection], ); - const renderComponentWithName = useCallback( + const renderComponentByName = useCallback( (name: string) => { const data = options.find(option => option.name === name); return data ? findAddonByData(data) : null; @@ -108,7 +108,7 @@ export const useTechDocsAddons = () => { [options, findAddonByData], ); - const renderComponentsWithLocation = useCallback( + const renderComponentsByLocation = useCallback( (location: TechDocsAddonLocations) => { const data = options.filter(option => option.location === location); return data.length ? data.map(findAddonByData) : null; @@ -116,5 +116,5 @@ export const useTechDocsAddons = () => { [options, findAddonByData], ); - return { renderComponentWithName, renderComponentsWithLocation }; + return { renderComponentByName, renderComponentsByLocation }; }; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx new file mode 100644 index 0000000000..a9db8ddb6d --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { Page } from '@backstage/core-components'; +// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs +import { + withTechDocsReaderProvider, + // @ts-ignore + TechDocsStateIndicator as TechDocReaderPageIndicator, +} from '@backstage/plugin-techdocs'; +import React from 'react'; + +import { + TechDocsMetadataProvider, + TechDocsEntityProvider, + TechDocsReaderPageProvider, +} from '../../context'; +import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; +import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; +import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; + +/** + * @public + */ +export type TechDocsReaderPageProps = { entityName: CompoundEntityRef }; + +/** + * An addon-aware implementation of the TechDocsReaderPage. + * @public + */ +export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { + const { entityName } = props; + const Component = withTechDocsReaderProvider(() => { + return ( + + + + + + + + + + + + + ); + }, entityName); + return ; +}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts new file mode 100644 index 0000000000..3055a865df --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPage } from './TechDocsReaderPage'; +export type { TechDocsReaderPageProps } from './TechDocsReaderPage'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx new file mode 100644 index 0000000000..9bdab3f5c5 --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Content, Progress } from '@backstage/core-components'; +// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs +// @ts-ignore +import { useTechDocsReaderDom } from '@backstage/plugin-techdocs'; +import { Portal } from '@material-ui/core'; +import { StylesProvider, jssPreset } from '@material-ui/styles'; +import React, { useEffect, useRef, useState } from 'react'; +import { create } from 'jss'; + +import { useTechDocsAddons } from '../../addons'; +import { useTechDocsReaderPage } from '../../context'; +import { TechDocsAddonLocations as locations } from '../../types'; + +export const TechDocsReaderPageContent = () => { + const ref = useRef(null); + const [jss, setJss] = useState( + create({ + ...jssPreset(), + insertionPoint: undefined, + }), + ); + + const addons = useTechDocsAddons(); + const { entityName, setShadowRoot } = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(entityName); + + useEffect(() => { + const shadowHost = ref.current; + if (!dom || !shadowHost || shadowHost.shadowRoot) return; + + setJss( + create({ + ...jssPreset(), + insertionPoint: dom.querySelector('head') || undefined, + }), + ); + + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + shadowRoot.appendChild(dom); + setShadowRoot(shadowRoot); + }, [dom, setShadowRoot]); + + const contentElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="container"]', + ); + const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="navigation"]', + ); + const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="toc"]', + ); + + const primarySidebarAddonLocation = document.createElement('div'); + primarySidebarElement?.prepend(primarySidebarAddonLocation); + + const secondarySidebarAddonLocation = document.createElement('div'); + secondarySidebarElement?.prepend(secondarySidebarAddonLocation); + + // do not return content until dom is ready + if (!dom) { + return ( + + + + ); + } + + return ( + + {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} + +
+ + {addons.renderComponentsByLocation(locations.PRIMARY_SIDEBAR)} + + + {addons.renderComponentsByLocation(locations.CONTENT)} + + + {addons.renderComponentsByLocation(locations.SECONDARY_SIDEBAR)} + + + + ); +}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts new file mode 100644 index 0000000000..6ad45cd281 --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx new file mode 100644 index 0000000000..66eef06c3e --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Header } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs +import { Skeleton } from '@material-ui/lab'; +import React, { useEffect } from 'react'; +import Helmet from 'react-helmet'; + +import { useTechDocsAddons } from '../../addons'; +import { useMetadata, useTechDocsReaderPage } from '../../context'; +import { TechDocsAddonLocations as locations } from '../../types'; + +const skeleton = ; + +export const TechDocsReaderPageHeader = () => { + const addons = useTechDocsAddons(); + const configApi = useApi(configApiRef); + + const metadata = useMetadata(); + + const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage(); + + useEffect(() => { + if (!metadata) return; + setTitle(prevTitle => prevTitle || metadata.site_name); + setSubtitle( + prevSubtitle => prevSubtitle || metadata.site_description || 'Home', + ); + }, [metadata, setTitle, setSubtitle]); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); + + return ( +
+ + {tabTitle} + + {addons.renderComponentsByLocation(locations.HEADER)} +
+ ); +}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts new file mode 100644 index 0000000000..741a8e9af1 --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx new file mode 100644 index 0000000000..9e323cce3f --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Box, Toolbar, ToolbarProps, withStyles } from '@material-ui/core'; +import React from 'react'; + +import { useTechDocsAddons } from '../../addons'; +import { TechDocsAddonLocations as locations } from '../../types'; + +export const TechDocsReaderPageSubheader = withStyles(theme => ({ + root: { + gridArea: 'pageSubheader', + flexDirection: 'column', + minHeight: 'auto', + padding: theme.spacing(3, 3, 0), + }, +}))(({ ...rest }: ToolbarProps) => { + const addons = useTechDocsAddons(); + + if (!addons.renderComponentsByLocation(locations.SUBHEADER)) return null; + + return ( + + {addons.renderComponentsByLocation(locations.SUBHEADER) && ( + + {addons.renderComponentsByLocation(locations.SUBHEADER)} + + )} + + ); +}); diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts new file mode 100644 index 0000000000..78f270e191 --- /dev/null +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageSubheader } from './TechDocsReaderPageSubheader'; diff --git a/plugins/techdocs-addons/src/components/index.ts b/plugins/techdocs-addons/src/components/index.ts new file mode 100644 index 0000000000..8d5b43143e --- /dev/null +++ b/plugins/techdocs-addons/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './TechDocsReaderPage'; diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index 43802de14f..aa337982f1 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -21,12 +21,11 @@ */ export { createTechDocsAddon, TechDocsAddons } from './addons'; +export * from './components'; export { useEntityMetadata, useMetadata, useShadowRoot, useShadowRootElements, } from './context'; -export { TechDocsReaderPage } from './reader'; -export type { TechDocsReaderPageProps } from './reader'; export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; diff --git a/plugins/techdocs-addons/src/reader.tsx b/plugins/techdocs-addons/src/reader.tsx deleted file mode 100644 index f7f2407d46..0000000000 --- a/plugins/techdocs-addons/src/reader.tsx +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CompoundEntityRef } from '@backstage/catalog-model'; -import { Content, Header, Page, Progress } from '@backstage/core-components'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs -import { - // @ts-ignore - useTechDocsReaderDom, - // @ts-ignore - withTechDocsReaderProvider, - // @ts-ignore - TechDocsStateIndicator as TechDocReaderPageIndicator, -} from '@backstage/plugin-techdocs'; -import { - withStyles, - Portal, - Box, - Toolbar, - ToolbarProps, -} from '@material-ui/core'; -import { Skeleton } from '@material-ui/lab'; -import { StylesProvider, jssPreset } from '@material-ui/styles'; -import React, { useEffect, useRef, useState } from 'react'; -import { create } from 'jss'; -import Helmet from 'react-helmet'; - -import { useTechDocsAddons } from './addons'; -import { - TechDocsMetadataProvider, - useMetadata, - TechDocsEntityProvider, - TechDocsReaderPageProvider, - useTechDocsReaderPage, -} from './context'; -import { TechDocsAddonLocations as locations } from './types'; - -const TechDocsReaderPageSubheader = withStyles(theme => ({ - root: { - gridArea: 'pageSubheader', - flexDirection: 'column', - minHeight: 'auto', - padding: theme.spacing(3, 3, 0), - }, -}))(({ ...rest }: ToolbarProps) => { - const addons = useTechDocsAddons(); - - if (!addons.renderComponentsWithLocation(locations.SUBHEADER)) return null; - - return ( - - {addons.renderComponentsWithLocation(locations.SUBHEADER) && ( - - {addons.renderComponentsWithLocation(locations.SUBHEADER)} - - )} - - ); -}); - -const skeleton = ; - -const TechDocsReaderPageHeader = () => { - const addons = useTechDocsAddons(); - const configApi = useApi(configApiRef); - - const metadata = useMetadata(); - - const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage(); - - useEffect(() => { - if (!metadata) return; - setTitle(prevTitle => prevTitle || metadata.site_name); - setSubtitle( - prevSubtitle => prevSubtitle || metadata.site_description || 'Home', - ); - }, [metadata, setTitle, setSubtitle]); - - const appTitle = configApi.getOptional('app.title') || 'Backstage'; - const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); - - return ( -
- - {tabTitle} - - {addons.renderComponentsWithLocation(locations.HEADER)} -
- ); -}; - -const TechDocsReaderPageContent = () => { - const ref = useRef(null); - const [jss, setJss] = useState( - create({ - ...jssPreset(), - insertionPoint: undefined, - }), - ); - - const addons = useTechDocsAddons(); - const { entityName, setShadowRoot } = useTechDocsReaderPage(); - const dom = useTechDocsReaderDom(entityName); - - useEffect(() => { - const shadowHost = ref.current; - if (!dom || !shadowHost || shadowHost.shadowRoot) return; - - setJss( - create({ - ...jssPreset(), - insertionPoint: dom.querySelector('head') || undefined, - }), - ); - - const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = ''; - shadowRoot.appendChild(dom); - setShadowRoot(shadowRoot); - }, [dom, setShadowRoot]); - - const contentElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="container"]', - ); - const primarySidebarElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="navigation"]', - ); - const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="toc"]', - ); - - const primarySidebarAddonSpace = document.createElement('div'); - primarySidebarElement?.prepend(primarySidebarAddonSpace); - - const secondarySidebarAddonSpace = document.createElement('div'); - secondarySidebarElement?.prepend(secondarySidebarAddonSpace); - - // do not return content until dom is ready - if (!dom) { - return ( - - - - ); - } - - return ( - - {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} - -
- - {addons.renderComponentsWithLocation(locations.PRIMARY_SIDEBAR)} - - - {addons.renderComponentsWithLocation(locations.CONTENT)} - - - {addons.renderComponentsWithLocation(locations.SECONDARY_SIDEBAR)} - - - - ); -}; - -/** - * @public - */ -export type TechDocsReaderPageProps = { entityName: CompoundEntityRef }; - -/** - * An addon-aware implementation of the TechDocsReaderPage. - * @public - */ -export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { - const { entityName } = props; - const Component = withTechDocsReaderProvider(() => { - return ( - - - - - - - - - - - - - ); - }, entityName); - return ; -}; From e328e3d31b3096d4f9b0cc9ad825ef450c8bad52 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 16 Mar 2022 14:09:14 +0100 Subject: [PATCH 05/47] Make utility hook responses async, allowing addons to handle errors independently. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Emma Indal Co-authored-by: Anders Näsman Signed-off-by: Eric Peterson --- plugins/techdocs-addons/api-report.md | 18 ++++--- .../TechDocsReaderPageHeader.tsx | 4 +- plugins/techdocs-addons/src/context.tsx | 51 ++++++++++--------- plugins/techdocs-addons/src/index.ts | 8 ++- plugins/techdocs-addons/src/types.ts | 7 +++ 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md index c5a61a34e3..26c0825493 100644 --- a/plugins/techdocs-addons/api-report.md +++ b/plugins/techdocs-addons/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { AsyncState } from 'react-use/lib/useAsyncFn'; import { ComponentType } from 'react'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; @@ -17,6 +18,9 @@ export function createTechDocsAddon( options: TechDocsAddonOptions, ): Extension>; +// @public +export type TechDocsAddonAsyncMetadata = AsyncState; + // @public export enum TechDocsAddonLocations { COMPONENT = 'component', @@ -48,16 +52,18 @@ export type TechDocsReaderPageProps = { }; // @public -export const useEntityMetadata: () => TechDocsEntityMetadata | undefined; - -// @public -export const useMetadata: () => TechDocsMetadata | undefined; +export const useEntityMetadata: () => TechDocsAddonAsyncMetadata; // @public export const useShadowRoot: () => ShadowRoot | undefined; // @public -export const useShadowRootElements: ( +export const useShadowRootElements: < + TReturnedElement extends HTMLElement = HTMLElement, +>( selectors: string[], -) => T[]; +) => TReturnedElement[]; + +// @public +export const useTechDocsMetadata: () => TechDocsAddonAsyncMetadata; ``` diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index 66eef06c3e..6a8322e57e 100644 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -22,7 +22,7 @@ import React, { useEffect } from 'react'; import Helmet from 'react-helmet'; import { useTechDocsAddons } from '../../addons'; -import { useMetadata, useTechDocsReaderPage } from '../../context'; +import { useTechDocsMetadata, useTechDocsReaderPage } from '../../context'; import { TechDocsAddonLocations as locations } from '../../types'; const skeleton = ; @@ -31,7 +31,7 @@ export const TechDocsReaderPageHeader = () => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); - const metadata = useMetadata(); + const { value: metadata } = useTechDocsMetadata(); const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage(); diff --git a/plugins/techdocs-addons/src/context.tsx b/plugins/techdocs-addons/src/context.tsx index 7908033874..7576bdc163 100644 --- a/plugins/techdocs-addons/src/context.tsx +++ b/plugins/techdocs-addons/src/context.tsx @@ -15,7 +15,7 @@ */ import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, useApp } from '@backstage/core-plugin-api'; +import { useApi } from '@backstage/core-plugin-api'; import { techdocsApiRef, TechDocsEntityMetadata, @@ -30,30 +30,33 @@ import React, { useState, } from 'react'; import useAsync from 'react-use/lib/useAsync'; +import { TechDocsAddonAsyncMetadata } from './types'; type PropsWithEntityName = PropsWithChildren<{ entityName: CompoundEntityRef }>; -const TechDocsMetadataContext = createContext( - undefined, -); +const initialContextValue = { + loading: true, + error: undefined, + value: undefined, +}; + +const TechDocsMetadataContext = + createContext>( + initialContextValue, + ); export const TechDocsMetadataProvider = ({ entityName, children, }: PropsWithEntityName) => { - const { NotFoundErrorPage } = useApp().getComponents(); const techdocsApi = useApi(techdocsApiRef); - const { value, loading, error } = useAsync(async () => { + const metadataResponse = useAsync(async () => { return await techdocsApi.getTechDocsMetadata(entityName); }, []); - if (!loading && error) { - return ; - } - return ( - + {children} ); @@ -64,31 +67,27 @@ export const TechDocsMetadataProvider = ({ * current TechDocs site. * @public */ -export const useMetadata = () => { +export const useTechDocsMetadata = () => { return useContext(TechDocsMetadataContext); }; -const TechDocsEntityContext = createContext( - undefined, -); +const TechDocsEntityContext = + createContext>( + initialContextValue, + ); export const TechDocsEntityProvider = ({ entityName, children, }: PropsWithEntityName) => { - const { NotFoundErrorPage } = useApp().getComponents(); const techdocsApi = useApi(techdocsApiRef); - const { value, loading, error } = useAsync(async () => { + const metadataResponse = useAsync(async () => { return await techdocsApi.getEntityMetadata(entityName); }, []); - if (!loading && error) { - return ; - } - return ( - + {children} ); @@ -180,13 +179,15 @@ export const useShadowRoot = () => { * * @public */ -export const useShadowRootElements = ( +export const useShadowRootElements = < + TReturnedElement extends HTMLElement = HTMLElement, +>( selectors: string[], -): T[] => { +): TReturnedElement[] => { const shadowRoot = useShadowRoot(); if (!shadowRoot) return []; return selectors - .map(selector => shadowRoot?.querySelectorAll(selector)) + .map(selector => shadowRoot?.querySelectorAll(selector)) .filter(nodeList => nodeList.length) .map(nodeList => Array.from(nodeList)) .flat(); diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index aa337982f1..5114a2b2e6 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -24,8 +24,12 @@ export { createTechDocsAddon, TechDocsAddons } from './addons'; export * from './components'; export { useEntityMetadata, - useMetadata, + useTechDocsMetadata, useShadowRoot, useShadowRootElements, } from './context'; -export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; +export type { + TechDocsAddonAsyncMetadata, + TechDocsAddonLocations, + TechDocsAddonOptions, +} from './types'; diff --git a/plugins/techdocs-addons/src/types.ts b/plugins/techdocs-addons/src/types.ts index 9cb095577f..8e42e0a6bc 100644 --- a/plugins/techdocs-addons/src/types.ts +++ b/plugins/techdocs-addons/src/types.ts @@ -15,6 +15,7 @@ */ import { ComponentType } from 'react'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; /** * Locations for which TechDocs addons may be declared and rendered. @@ -70,3 +71,9 @@ export type TechDocsAddonOptions = { location: TechDocsAddonLocations; component: ComponentType; }; + +/** + * Common response envelope for addon-related hooks. + * @public + */ +export type TechDocsAddonAsyncMetadata = AsyncState; From f0a3055d170c3fe8081fd33056498c33261b7247 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 21 Mar 2022 15:40:01 +0100 Subject: [PATCH 06/47] add tests for useEntityMetadata, useTechDocsMetadata, useTechDocsReaderPage Signed-off-by: Emma Indal --- plugins/techdocs-addons/src/context.test.tsx | 157 +++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 plugins/techdocs-addons/src/context.test.tsx diff --git a/plugins/techdocs-addons/src/context.test.tsx b/plugins/techdocs-addons/src/context.test.tsx new file mode 100644 index 0000000000..5455264f04 --- /dev/null +++ b/plugins/techdocs-addons/src/context.test.tsx @@ -0,0 +1,157 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + techdocsApiRef, + TechDocsApi, + TechDocsMetadata, +} from '@backstage/plugin-techdocs'; +import { + useEntityMetadata, + useTechDocsMetadata, + useTechDocsReaderPage, + useShadowRoot, + useShadowRootElements, + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from './context'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; +import { TestApiProvider } from '@backstage/test-utils'; + +const mockEntity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { name: 'test-component', namespace: 'default' }, +}; + +const mockTechDocsMetadata: TechDocsMetadata = { + site_name: 'test-componnet', + site_description: 'this is a test component', +}; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const techdocsApi: Partial = { + getEntityMetadata: () => Promise.resolve(mockEntity), + getTechDocsMetadata: () => Promise.resolve(mockTechDocsMetadata), +}; + +const wrapper = ({ + entityName = { + namespace: mockEntity.metadata.namespace!!, + kind: mockEntity.kind, + name: mockEntity.metadata.name, + }, + children, +}: { + entityName: CompoundEntityRef; + children: React.ReactNode; +}) => ( + + + + + {children} + + + + +); + +describe('context', () => { + describe('useEntityMetadata', () => { + it('should return loading state', async () => { + const { result } = renderHook(() => useEntityMetadata()); + + await expect(result.current.loading).toEqual(true); + }); + + it('should return expected entity values', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useEntityMetadata(), + { wrapper }, + ); + + await waitForNextUpdate(); + + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockEntity); + }); + }); + + describe('useTechDocsMetadata', () => { + it('should return loading state', async () => { + const { result } = renderHook(() => useTechDocsMetadata()); + + await expect(result.current.loading).toEqual(true); + }); + + it('should return expected techdocs metadata values', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsMetadata(), + { wrapper }, + ); + + await waitForNextUpdate(); + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockTechDocsMetadata); + }); + }); + + describe('useTechDocsReaderPage', () => { + it('should set title', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.title).toBe(''); + + act(() => result.current.setTitle('test site title')); + expect(result.current.title).toBe('test site title'); + }); + + it('should set subtitle', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.subtitle).toBe(''); + + act(() => result.current.setSubtitle('test site subtitle')); + expect(result.current.subtitle).toBe('test site subtitle'); + }); + + it('should set shadow root', async () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + // mock shadowroot + const shadowRoot = mockShadowRoot(); + + act(() => result.current.setShadowRoot(shadowRoot)); + + expect(result.current.shadowRoot?.innerHTML).toBe( + '

Shadow DOM Mock

', + ); + }); + }); +}); From 42cc7e599118038715b3ca2e7de04e384f3e43c9 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 21 Mar 2022 16:37:04 +0100 Subject: [PATCH 07/47] Split hooks into separate file Signed-off-by: Emma Indal Co-authored-by: Camila Belo --- plugins/techdocs-addons/package.json | 5 +- plugins/techdocs-addons/src/context.test.tsx | 2 - plugins/techdocs-addons/src/context.tsx | 35 -------------- plugins/techdocs-addons/src/hooks.ts | 51 ++++++++++++++++++++ plugins/techdocs-addons/src/index.ts | 8 +-- 5 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 plugins/techdocs-addons/src/hooks.ts diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 3eb29ea77a..4769ebd189 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -38,7 +38,10 @@ "@types/react": "^16.13.1 || ^17.0.0", "react": "^16.13.1 || ^17.0.0" }, - "devDependencies": {}, + "devDependencies": { + "@testing-library/react-hooks": "^7.0.2", + "@backstage/test-utils": "^1.0.0" + }, "files": [ "dist" ] diff --git a/plugins/techdocs-addons/src/context.test.tsx b/plugins/techdocs-addons/src/context.test.tsx index 5455264f04..ea0486604f 100644 --- a/plugins/techdocs-addons/src/context.test.tsx +++ b/plugins/techdocs-addons/src/context.test.tsx @@ -24,8 +24,6 @@ import { useEntityMetadata, useTechDocsMetadata, useTechDocsReaderPage, - useShadowRoot, - useShadowRootElements, TechDocsEntityProvider, TechDocsMetadataProvider, TechDocsReaderPageProvider, diff --git a/plugins/techdocs-addons/src/context.tsx b/plugins/techdocs-addons/src/context.tsx index 7576bdc163..9aa057117d 100644 --- a/plugins/techdocs-addons/src/context.tsx +++ b/plugins/techdocs-addons/src/context.tsx @@ -157,38 +157,3 @@ export const TechDocsReaderPageProvider = ({ ); }; - -/** - * Hook for use within TechDocs addons that provides access to the underlying - * shadow root of the current page, allowing the DOM within to be mutated. - * @public - */ -export const useShadowRoot = () => { - const { shadowRoot } = useTechDocsReaderPage(); - return shadowRoot; -}; - -/** - * Convenience hook for use within TechDocs addons that provides access to - * elements that match a given selector within the shadow root. - * - * todo(backstage/techdocs-core): Consider extending `selectors` from string[] - * to some kind of typed object array, so users have more control over the - * shape of the result. e.g. a flag to indicate querySelector vs. - * querySelectorAll. - * - * @public - */ -export const useShadowRootElements = < - TReturnedElement extends HTMLElement = HTMLElement, ->( - selectors: string[], -): TReturnedElement[] => { - const shadowRoot = useShadowRoot(); - if (!shadowRoot) return []; - return selectors - .map(selector => shadowRoot?.querySelectorAll(selector)) - .filter(nodeList => nodeList.length) - .map(nodeList => Array.from(nodeList)) - .flat(); -}; diff --git a/plugins/techdocs-addons/src/hooks.ts b/plugins/techdocs-addons/src/hooks.ts new file mode 100644 index 0000000000..7bc6152006 --- /dev/null +++ b/plugins/techdocs-addons/src/hooks.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useTechDocsReaderPage } from './context'; + +/** + * Hook for use within TechDocs addons that provides access to the underlying + * shadow root of the current page, allowing the DOM within to be mutated. + * @public + */ +export const useShadowRoot = () => { + const { shadowRoot } = useTechDocsReaderPage(); + return shadowRoot; +}; + +/** + * Convenience hook for use within TechDocs addons that provides access to + * elements that match a given selector within the shadow root. + * + * todo(backstage/techdocs-core): Consider extending `selectors` from string[] + * to some kind of typed object array, so users have more control over the + * shape of the result. e.g. a flag to indicate querySelector vs. + * querySelectorAll. + * + * @public + */ +export const useShadowRootElements = < + TReturnedElement extends HTMLElement = HTMLElement, +>( + selectors: string[], +): TReturnedElement[] => { + const shadowRoot = useShadowRoot(); + if (!shadowRoot) return []; + return selectors + .map(selector => shadowRoot?.querySelectorAll(selector)) + .filter(nodeList => nodeList.length) + .map(nodeList => Array.from(nodeList)) + .flat(); +}; diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index 5114a2b2e6..0993eaba97 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -22,12 +22,8 @@ export { createTechDocsAddon, TechDocsAddons } from './addons'; export * from './components'; -export { - useEntityMetadata, - useTechDocsMetadata, - useShadowRoot, - useShadowRootElements, -} from './context'; +export { useEntityMetadata, useTechDocsMetadata } from './context'; +export { useShadowRoot, useShadowRootElements } from './hooks'; export type { TechDocsAddonAsyncMetadata, TechDocsAddonLocations, From 0b67af3485aca07308f08fa50e5026b1e4cbb515 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 21 Mar 2022 16:38:42 +0100 Subject: [PATCH 08/47] Write tests for hooks Signed-off-by: Emma Indal Co-authored-by: Camila Belo --- plugins/techdocs-addons/src/hooks.test.ts | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 plugins/techdocs-addons/src/hooks.test.ts diff --git a/plugins/techdocs-addons/src/hooks.test.ts b/plugins/techdocs-addons/src/hooks.test.ts new file mode 100644 index 0000000000..c85d3a3d11 --- /dev/null +++ b/plugins/techdocs-addons/src/hooks.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useShadowRoot, useShadowRootElements } from './hooks'; +import { renderHook } from '@testing-library/react-hooks'; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const shadowRoot = mockShadowRoot(); + +jest.mock('./context', () => { + return { + useTechDocsReaderPage: () => ({ shadowRoot }), + }; +}); + +describe('hooks', () => { + describe('useShadowRoot', () => { + it('should return shadow root', async () => { + const { result } = renderHook(() => useShadowRoot()); + + expect(result.current?.innerHTML).toBe(shadowRoot.innerHTML); + }); + }); + + describe('useShadowRootElements', () => { + it('should return shadow root elements based on selector', () => { + const { result } = renderHook(() => useShadowRootElements(['h1'])); + + expect(result.current).toHaveLength(1); + }); + }); +}); From 10a7736ced89c28ef485f7b3d8ce5066397f8d26 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 21 Mar 2022 12:26:03 +0100 Subject: [PATCH 09/47] Testbed to help iterate on addons + correct dependency graph Signed-off-by: Eric Peterson --- packages/app/package.json | 1 + packages/app/src/App.tsx | 21 +++- .../src/components/techdocs/ExampleAddons.tsx | 104 ++++++++++++++++++ plugins/techdocs-addons/api-report.md | 25 ++++- plugins/techdocs-addons/package.json | 1 - plugins/techdocs-addons/src/addons.tsx | 5 + .../TechDocsReaderPage/TechDocsReaderPage.tsx | 52 ++++----- .../TechDocsReaderPageContent.tsx | 12 +- plugins/techdocs-addons/src/context.tsx | 41 +++---- plugins/techdocs-addons/src/index.ts | 11 +- plugins/techdocs-addons/src/types.ts | 20 ++++ plugins/techdocs/package.json | 2 + .../reader/components/TechDocsReaderPage.tsx | 94 +++++++++------- 13 files changed, 280 insertions(+), 109 deletions(-) create mode 100644 packages/app/src/components/techdocs/ExampleAddons.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 4522cabaf1..69afcc11e0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -52,6 +52,7 @@ "@backstage/plugin-shortcuts": "^0.2.5-next.0", "@backstage/plugin-tech-radar": "^0.5.11-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", + "@backstage/plugin-techdocs-addons": "^0.0.0", "@backstage/plugin-todo": "^0.2.6-next.0", "@backstage/plugin-user-settings": "^0.4.3-next.0", "@backstage/plugin-tech-insights": "^0.1.14-next.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9cbbcc86a9..9073af7d2e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -66,14 +66,15 @@ import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechDocsIndexPage, - techdocsPlugin, TechDocsReaderPage, + techdocsPlugin, } from '@backstage/plugin-techdocs'; import { UserSettingsPage, UserSettingsTab, } from '@backstage/plugin-user-settings'; import { AdvancedSettings } from './components/advancedSettings'; +import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; import AlarmIcon from '@material-ui/icons/Alarm'; import React from 'react'; import { hot } from 'react-hot-loader/root'; @@ -87,10 +88,18 @@ import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTe import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; -import { techDocsPage } from './components/techdocs/TechDocsPage'; + +// import { techDocsPage } from './components/techdocs/TechDocsPage'; import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; import { PermissionedRoute } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common'; +import { + ExampleContent, + ExampleHeader, + ExamplePrimarySidebar, + ExampleSecondarySidebar, + ExampleSubHeader, +} from './components/techdocs/ExampleAddons'; const app = createApp({ apis, @@ -177,7 +186,13 @@ const routes = ( path="/docs/:namespace/:kind/:name/*" element={} > - {techDocsPage} + + + + + + + { + return ; + }, + }), +); + +export const ExampleSubHeader = techdocsPlugin.provide( + createTechDocsAddon({ + name: 'ExampleSubHeader', + location: TechDocsAddonLocations.SUBHEADER, + component: () => { + return ( + + Subheader. + + ); + }, + }), +); + +export const ExamplePrimarySidebar = techdocsPlugin.provide( + createTechDocsAddon({ + name: 'ExamplePrimarySidebar', + location: TechDocsAddonLocations.PRIMARY_SIDEBAR, + component: () => { + return ( + + Primary Sidebar. + + ); + }, + }), +); + +export const ExampleSecondarySidebar = techdocsPlugin.provide( + createTechDocsAddon({ + name: 'ExampleSecondarySidebar', + location: TechDocsAddonLocations.SECONDARY_SIDEBAR, + component: () => { + return ( + + Secondary Sidebar. + + ); + }, + }), +); + +const ExampleContentComponent = () => { + const h1 = useShadowRootElements(['h1'])[0]; + useEffect(() => { + if (h1 && !h1.innerText.startsWith('Modified: ')) { + h1.innerText = `Modified: ${h1.innerText}`; + } + }, [h1]); + return null; +}; + +export const ExampleContent = techdocsPlugin.provide( + createTechDocsAddon({ + name: 'ExampleContent', + location: TechDocsAddonLocations.CONTENT, + component: ExampleContentComponent, + }), +); diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md index 26c0825493..5f7f2e8cd0 100644 --- a/plugins/techdocs-addons/api-report.md +++ b/plugins/techdocs-addons/api-report.md @@ -7,17 +7,18 @@ import { AsyncState } from 'react-use/lib/useAsyncFn'; import { ComponentType } from 'react'; -import { CompoundEntityRef } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; -import { TechDocsEntityMetadata } from '@backstage/plugin-techdocs'; -import { TechDocsMetadata } from '@backstage/plugin-techdocs'; // @public export function createTechDocsAddon( options: TechDocsAddonOptions, ): Extension>; +// @public +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + // @public export type TechDocsAddonAsyncMetadata = AsyncState; @@ -41,6 +42,20 @@ export type TechDocsAddonOptions = { // @public export const TechDocsAddons: React_2.ComponentType; +// @public +export type TechDocsEntityMetadata = Entity & { + locationMetadata?: { + type: string; + target: string; + }; +}; + +// @public +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + // @public export const TechDocsReaderPage: ( props: TechDocsReaderPageProps, @@ -48,7 +63,9 @@ export const TechDocsReaderPage: ( // @public (undocumented) export type TechDocsReaderPageProps = { - entityName: CompoundEntityRef; + dom: Element | null; + asyncEntityMetadata: AsyncState; + asyncTechDocsMetadata: AsyncState; }; // @public diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 4769ebd189..1b3d736a7d 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -25,7 +25,6 @@ "@backstage/catalog-model": "^0.13.0", "@backstage/core-components": "^0.9.1", "@backstage/core-plugin-api": "^0.8.0", - "@backstage/plugin-techdocs": "^0.15.1", "@material-ui/core": "^4.12.2", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.11.0", diff --git a/plugins/techdocs-addons/src/addons.tsx b/plugins/techdocs-addons/src/addons.tsx index 06ce1eb137..afb3b151c0 100644 --- a/plugins/techdocs-addons/src/addons.tsx +++ b/plugins/techdocs-addons/src/addons.tsx @@ -27,6 +27,11 @@ import { useOutlet } from 'react-router-dom'; import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1'; + +/** + * Marks the registry component. + * @public + */ export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; /** diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx index a9db8ddb6d..d9d54e2248 100644 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,21 +14,17 @@ * limitations under the License. */ -import { CompoundEntityRef } from '@backstage/catalog-model'; import { Page } from '@backstage/core-components'; -// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs -import { - withTechDocsReaderProvider, - // @ts-ignore - TechDocsStateIndicator as TechDocReaderPageIndicator, -} from '@backstage/plugin-techdocs'; import React from 'react'; +import { useParams } from 'react-router-dom'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; import { TechDocsMetadataProvider, TechDocsEntityProvider, TechDocsReaderPageProvider, } from '../../context'; +import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; @@ -36,29 +32,33 @@ import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; /** * @public */ -export type TechDocsReaderPageProps = { entityName: CompoundEntityRef }; +export type TechDocsReaderPageProps = { + dom: Element | null; + asyncEntityMetadata: AsyncState; + asyncTechDocsMetadata: AsyncState; +}; /** * An addon-aware implementation of the TechDocsReaderPage. * @public */ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { - const { entityName } = props; - const Component = withTechDocsReaderProvider(() => { - return ( - - - - - - - - - - - - - ); - }, entityName); - return ; + const { asyncEntityMetadata, asyncTechDocsMetadata, dom } = props; + const { namespace, kind, name } = useParams(); + const entityName = { namespace, kind, name }; + return ( + + + + + + + {/* todo(backstage/techdocs-core): handle state indicator */} + {/* */} + + + + + + ); }; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 9bdab3f5c5..6e3ec99b21 100644 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -15,9 +15,6 @@ */ import { Content, Progress } from '@backstage/core-components'; -// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs -// @ts-ignore -import { useTechDocsReaderDom } from '@backstage/plugin-techdocs'; import { Portal } from '@material-ui/core'; import { StylesProvider, jssPreset } from '@material-ui/styles'; import React, { useEffect, useRef, useState } from 'react'; @@ -27,7 +24,7 @@ import { useTechDocsAddons } from '../../addons'; import { useTechDocsReaderPage } from '../../context'; import { TechDocsAddonLocations as locations } from '../../types'; -export const TechDocsReaderPageContent = () => { +export const TechDocsReaderPageContent = ({ dom }: { dom: Element | null }) => { const ref = useRef(null); const [jss, setJss] = useState( create({ @@ -37,8 +34,7 @@ export const TechDocsReaderPageContent = () => { ); const addons = useTechDocsAddons(); - const { entityName, setShadowRoot } = useTechDocsReaderPage(); - const dom = useTechDocsReaderDom(entityName); + const { setShadowRoot } = useTechDocsReaderPage(); useEffect(() => { const shadowHost = ref.current; @@ -61,10 +57,10 @@ export const TechDocsReaderPageContent = () => { '[data-md-component="container"]', ); const primarySidebarElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="navigation"]', + 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', ); const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="toc"]', + 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', ); const primarySidebarAddonLocation = document.createElement('div'); diff --git a/plugins/techdocs-addons/src/context.tsx b/plugins/techdocs-addons/src/context.tsx index 9aa057117d..54a73e2589 100644 --- a/plugins/techdocs-addons/src/context.tsx +++ b/plugins/techdocs-addons/src/context.tsx @@ -15,12 +15,6 @@ */ import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi } from '@backstage/core-plugin-api'; -import { - techdocsApiRef, - TechDocsEntityMetadata, - TechDocsMetadata, -} from '@backstage/plugin-techdocs'; import React, { createContext, Dispatch, @@ -29,9 +23,16 @@ import React, { useContext, useState, } from 'react'; -import useAsync from 'react-use/lib/useAsync'; -import { TechDocsAddonAsyncMetadata } from './types'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { + TechDocsAddonAsyncMetadata, + TechDocsEntityMetadata, + TechDocsMetadata, +} from './types'; +type PropsWithAsyncMetadata = PropsWithChildren<{ + asyncValue: AsyncState; +}>; type PropsWithEntityName = PropsWithChildren<{ entityName: CompoundEntityRef }>; const initialContextValue = { @@ -46,17 +47,11 @@ const TechDocsMetadataContext = ); export const TechDocsMetadataProvider = ({ - entityName, + asyncValue, children, -}: PropsWithEntityName) => { - const techdocsApi = useApi(techdocsApiRef); - - const metadataResponse = useAsync(async () => { - return await techdocsApi.getTechDocsMetadata(entityName); - }, []); - +}: PropsWithAsyncMetadata) => { return ( - + {children} ); @@ -77,17 +72,11 @@ const TechDocsEntityContext = ); export const TechDocsEntityProvider = ({ - entityName, + asyncValue, children, -}: PropsWithEntityName) => { - const techdocsApi = useApi(techdocsApiRef); - - const metadataResponse = useAsync(async () => { - return await techdocsApi.getEntityMetadata(entityName); - }, []); - +}: PropsWithAsyncMetadata) => { return ( - + {children} ); diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index 0993eaba97..0d966cfd67 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -20,12 +20,17 @@ * @packageDocumentation */ -export { createTechDocsAddon, TechDocsAddons } from './addons'; +export { + createTechDocsAddon, + TechDocsAddons, + TECHDOCS_ADDONS_WRAPPER_KEY, +} from './addons'; export * from './components'; export { useEntityMetadata, useTechDocsMetadata } from './context'; -export { useShadowRoot, useShadowRootElements } from './hooks'; +export { TechDocsAddonLocations } from './types'; export type { TechDocsAddonAsyncMetadata, - TechDocsAddonLocations, TechDocsAddonOptions, + TechDocsMetadata, + TechDocsEntityMetadata, } from './types'; diff --git a/plugins/techdocs-addons/src/types.ts b/plugins/techdocs-addons/src/types.ts index 8e42e0a6bc..d8ab7e9520 100644 --- a/plugins/techdocs-addons/src/types.ts +++ b/plugins/techdocs-addons/src/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { ComponentType } from 'react'; import { AsyncState } from 'react-use/lib/useAsyncFn'; @@ -77,3 +78,22 @@ export type TechDocsAddonOptions = { * @public */ export type TechDocsAddonAsyncMetadata = AsyncState; + +/** + * Metadata for TechDocs page + * + * @public + */ +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + +/** + * Metadata for TechDocs Entity + * + * @public + */ +export type TechDocsEntityMetadata = Entity & { + locationMetadata?: { type: string; target: string }; +}; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index d0ef75e32c..b4868ab4e7 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -43,7 +43,9 @@ "@backstage/integration": "^1.1.0-next.1", "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog-react": "^1.0.1-next.2", + "@backstage/plugin-catalog": "^0.10.0", "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-techdocs-addons": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx index 1bfd0e6f51..1c041dfe97 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx @@ -17,14 +17,17 @@ import React, { useCallback, useState } from 'react'; import { useOutlet } from 'react-router'; import { useParams } from 'react-router-dom'; -import useAsync from 'react-use/lib/useAsync'; -import { Reader } from './Reader'; -import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; import { techdocsApiRef } from '../../api'; import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, useApp } from '@backstage/core-plugin-api'; -import { Page, Content } from '@backstage/core-components'; +import { getComponentData, useApi, useApp } from '@backstage/core-plugin-api'; +import { Page } from '@backstage/core-components'; +import { + TechDocsReaderPage as AddonAwareReaderPage, + TECHDOCS_ADDONS_WRAPPER_KEY, +} from '@backstage/plugin-techdocs-addons'; +import { useTechDocsReaderDom, withTechDocsReaderProvider } from './Reader'; /** * Helper function that gives the children of {@link TechDocsReaderPage} access to techdocs and entity metadata @@ -42,6 +45,24 @@ export type TechDocsReaderPageRenderFunction = ({ onReady: () => void; }) => JSX.Element; +type SpecialReaderPageProps = { + entityName: CompoundEntityRef; + asyncEntityMetadata: AsyncState; + asyncTechDocsMetadata: AsyncState; +}; + +const SpecialReaderPage = (props: SpecialReaderPageProps) => { + const dom = useTechDocsReaderDom(props.entityName); + + return ( + + ); +}; + /** * Props for {@link TechDocsReaderPage} * @@ -61,7 +82,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { const techdocsApi = useApi(techdocsApiRef); - const { value: techdocsMetadataValue } = useAsync(() => { + const asyncTechDocsMetadata = useAsync(() => { if (documentReady) { return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); } @@ -69,50 +90,47 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { return Promise.resolve(undefined); }, [kind, namespace, name, techdocsApi, documentReady]); - const { value: entityMetadataValue, error: entityMetadataError } = - useAsync(() => { - return techdocsApi.getEntityMetadata({ kind, namespace, name }); - }, [kind, namespace, name, techdocsApi]); + const asyncEntityMetadata = useAsync(() => { + return techdocsApi.getEntityMetadata({ kind, namespace, name }); + }, [kind, namespace, name, techdocsApi]); const onReady = useCallback(() => { setDocumentReady(true); }, [setDocumentReady]); - if (entityMetadataError) return ; + if (asyncEntityMetadata.error) return ; - if (!children) - return ( - outlet || ( - - - - - - - ) - ); + ); + } + // Otherwise, just return the outlet (legacy-style composability). + return outlet; + } + } return ( {children instanceof Function ? children({ - techdocsMetadataValue, - entityMetadataValue, + techdocsMetadataValue: asyncTechDocsMetadata.value, + entityMetadataValue: asyncEntityMetadata.value, entityRef: { kind, namespace, name }, onReady, }) From 4f9de91af5e59aceee46377ee15d347f91c3906b Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Wed, 23 Mar 2022 11:22:11 +0100 Subject: [PATCH 10/47] update tests based on implementation changes Signed-off-by: Emma Indal --- plugins/techdocs-addons/src/context.test.tsx | 53 ++++++++------------ plugins/techdocs-addons/src/index.ts | 1 + 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/plugins/techdocs-addons/src/context.test.tsx b/plugins/techdocs-addons/src/context.test.tsx index ea0486604f..921c2efd7d 100644 --- a/plugins/techdocs-addons/src/context.test.tsx +++ b/plugins/techdocs-addons/src/context.test.tsx @@ -15,11 +15,7 @@ */ import React from 'react'; -import { - techdocsApiRef, - TechDocsApi, - TechDocsMetadata, -} from '@backstage/plugin-techdocs'; +import { TechDocsMetadata } from './types'; import { useEntityMetadata, useTechDocsMetadata, @@ -31,7 +27,6 @@ import { import { renderHook, act } from '@testing-library/react-hooks'; import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; -import { TestApiProvider } from '@backstage/test-utils'; const mockEntity: Entity = { apiVersion: 'v1', @@ -51,11 +46,6 @@ const mockShadowRoot = () => { return shadowRoot; }; -const techdocsApi: Partial = { - getEntityMetadata: () => Promise.resolve(mockEntity), - getTechDocsMetadata: () => Promise.resolve(mockTechDocsMetadata), -}; - const wrapper = ({ entityName = { namespace: mockEntity.metadata.namespace!!, @@ -67,15 +57,25 @@ const wrapper = ({ entityName: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - - {children} - - - - + + + + {children} + + + ); describe('context', () => { @@ -87,12 +87,7 @@ describe('context', () => { }); it('should return expected entity values', async () => { - const { result, waitForNextUpdate } = renderHook( - () => useEntityMetadata(), - { wrapper }, - ); - - await waitForNextUpdate(); + const { result } = renderHook(() => useEntityMetadata(), { wrapper }); expect(result.current.value).toBeDefined(); expect(result.current.error).toBeUndefined(); @@ -108,12 +103,8 @@ describe('context', () => { }); it('should return expected techdocs metadata values', async () => { - const { result, waitForNextUpdate } = renderHook( - () => useTechDocsMetadata(), - { wrapper }, - ); + const { result } = renderHook(() => useTechDocsMetadata(), { wrapper }); - await waitForNextUpdate(); expect(result.current.value).toBeDefined(); expect(result.current.error).toBeUndefined(); expect(result.current.value).toMatchObject(mockTechDocsMetadata); diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts index 0d966cfd67..5e7b3211a9 100644 --- a/plugins/techdocs-addons/src/index.ts +++ b/plugins/techdocs-addons/src/index.ts @@ -27,6 +27,7 @@ export { } from './addons'; export * from './components'; export { useEntityMetadata, useTechDocsMetadata } from './context'; +export { useShadowRoot, useShadowRootElements } from './hooks'; export { TechDocsAddonLocations } from './types'; export type { TechDocsAddonAsyncMetadata, From 5c86f3e2b7ad3890ec7d730eebf331a911f86f2a Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Wed, 23 Mar 2022 11:35:21 +0100 Subject: [PATCH 11/47] downgrade test utils package Signed-off-by: Emma Indal --- plugins/techdocs-addons/package.json | 2 +- yarn.lock | 115 +++++++++++++++------------ 2 files changed, 63 insertions(+), 54 deletions(-) diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 1b3d736a7d..1ff346442f 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@testing-library/react-hooks": "^7.0.2", - "@backstage/test-utils": "^1.0.0" + "@backstage/test-utils": "^0.3.0" }, "files": [ "dist" diff --git a/yarn.lock b/yarn.lock index 3cfe0b84fc..03de40b659 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1508,6 +1508,22 @@ "@backstage/types" "^0.1.3" lodash "^4.17.21" +"@backstage/core-app-api@^0.6.0": + version "0.6.0" + resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-0.6.0.tgz#691f0586d97682f1af67828ab2c67014397a530a" + integrity sha512-v1t1w/U/JHjm9eZupPmJpKH5WB0vysEFxRo6/Ia77VP6ZcPxszG4IG+d+S3Km5fM3lu4oosGiz+RaKUTLrttBA== + dependencies: + "@backstage/config" "^0.1.15" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/types" "^0.1.3" + "@backstage/version-bridge" "^0.1.2" + "@types/prop-types" "^15.7.3" + prop-types "^15.7.2" + react-router-dom "6.0.0-beta.0" + react-use "^17.2.4" + zen-observable "^0.8.15" + zod "^3.11.6" + "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.1", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -1743,7 +1759,7 @@ react-use "^17.2.4" swr "^1.1.2" -"@backstage/plugin-search-common@0.3.2", "@backstage/plugin-search-common@^0.3.1", "@backstage/plugin-search-common@^0.3.2": +"@backstage/plugin-search-common@0.3.2", "@backstage/plugin-search-common@^0.3.1": version "0.3.2" resolved "https://registry.npmjs.org/@backstage/plugin-search-common/-/plugin-search-common-0.3.2.tgz#15984ba4c14f8a9119168e8c79344ef8101863dc" integrity sha1-FZhLpMFPipEZFo6MeTRO+BAYY9w= @@ -1751,58 +1767,6 @@ "@backstage/plugin-permission-common" "^0.5.3" "@backstage/types" "^1.0.0" -"@backstage/plugin-search@^0.7.3": - version "0.7.4" - resolved "https://registry.npmjs.org/@backstage/plugin-search/-/plugin-search-0.7.4.tgz#d6571da128342d122f80253a756ece0a702967f9" - integrity sha1-1lcdoSg0LRIvgCU6dW7OCnApZ/k= - dependencies: - "@backstage/catalog-model" "^1.0.0" - "@backstage/config" "^1.0.0" - "@backstage/core-components" "^0.9.2" - "@backstage/core-plugin-api" "^1.0.0" - "@backstage/errors" "^1.0.0" - "@backstage/plugin-catalog-react" "^1.0.0" - "@backstage/plugin-search-common" "^0.3.2" - "@backstage/theme" "^0.2.15" - "@backstage/types" "^1.0.0" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - qs "^6.9.4" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - react-text-truncate "^0.18.0" - react-use "^17.2.4" - -"@backstage/plugin-techdocs@^0.15.1": - version "0.15.1" - resolved "https://registry.npmjs.org/@backstage/plugin-techdocs/-/plugin-techdocs-0.15.1.tgz#f57b63526976da04f1d926b5b5b50e1a89b29184" - integrity sha1-9XtjUml22gTx2Sa1tbUOGomykYQ= - dependencies: - "@backstage/catalog-model" "^0.13.0" - "@backstage/config" "^0.1.15" - "@backstage/core-components" "^0.9.1" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/errors" "^0.2.2" - "@backstage/integration" "^0.8.0" - "@backstage/integration-react" "^0.1.25" - "@backstage/plugin-catalog" "^0.10.0" - "@backstage/plugin-catalog-react" "^0.9.0" - "@backstage/plugin-search" "^0.7.3" - "@backstage/theme" "^0.2.15" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - "@material-ui/styles" "^4.10.0" - dompurify "^2.2.9" - event-source-polyfill "^1.0.25" - git-url-parse "^11.6.0" - lodash "^4.17.21" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - react-text-truncate "^0.18.0" - react-use "^17.2.4" - "@backstage/search-common@^0.3.1": version "0.3.2" resolved "https://registry.npmjs.org/@backstage/search-common/-/search-common-0.3.2.tgz#608a4eddf7eae71ed807ec1f723a80c6f7cdf3e4" @@ -1810,6 +1774,28 @@ dependencies: "@backstage/plugin-search-common" "0.3.2" +"@backstage/test-utils@^0.3.0": + version "0.3.0" + resolved "https://registry.npmjs.org/@backstage/test-utils/-/test-utils-0.3.0.tgz#9c47efd97cdfa3809fe8493a39795a59373d00bd" + integrity sha512-UmJE0dZhtZGKcl58Yru2whEApamY71eJaC4uV0APySUjh0Z39pPxOOkM8a9VKH0/oafqoS4LXUJ4vGDzmy11UQ== + dependencies: + "@backstage/config" "^0.1.15" + "@backstage/core-app-api" "^0.6.0" + "@backstage/core-plugin-api" "^0.8.0" + "@backstage/plugin-permission-common" "^0.5.2" + "@backstage/plugin-permission-react" "^0.3.3" + "@backstage/theme" "^0.2.15" + "@backstage/types" "^0.1.3" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.11.2" + "@testing-library/jest-dom" "^5.10.1" + "@testing-library/react" "^11.2.5" + "@testing-library/user-event" "^13.1.8" + cross-fetch "^3.1.5" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + zen-observable "^0.8.15" + "@backstage/types@^0.1.2", "@backstage/types@^0.1.3": version "0.1.3" resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14" @@ -5749,6 +5735,20 @@ "@babel/runtime" "^7.14.6" "@testing-library/dom" "^8.1.0" +"@testing-library/dom@^7.28.1": + version "7.31.2" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" + integrity sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.6" + lz-string "^1.4.4" + pretty-format "^26.6.2" + "@testing-library/dom@^8.0.0", "@testing-library/dom@^8.1.0": version "8.11.3" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.3.tgz#38fd63cbfe14557021e88982d931e33fb7c1a808" @@ -5789,6 +5789,14 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" +"@testing-library/react@^11.2.5": + version "11.2.7" + resolved "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz#b29e2e95c6765c815786c0bc1d5aed9cb2bf7818" + integrity sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^7.28.1" + "@testing-library/react@^12.1.3": version "12.1.4" resolved "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" @@ -12439,6 +12447,7 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/plugin-tech-insights" "^0.1.14-next.0" "@backstage/plugin-tech-radar" "^0.5.11-next.1" "@backstage/plugin-techdocs" "^1.0.1-next.1" + "@backstage/plugin-techdocs-addons" "^0.0.0" "@backstage/plugin-todo" "^0.2.6-next.0" "@backstage/plugin-user-settings" "^0.4.3-next.0" "@backstage/theme" "^0.2.15" From 3efd08388a585e47947625cd4a0cfa3f71e645f9 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Wed, 23 Mar 2022 11:38:18 +0100 Subject: [PATCH 12/47] add changeset Signed-off-by: Emma Indal --- .changeset/techdocs-swift-apricots-learn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/techdocs-swift-apricots-learn.md diff --git a/.changeset/techdocs-swift-apricots-learn.md b/.changeset/techdocs-swift-apricots-learn.md new file mode 100644 index 0000000000..70d21b3535 --- /dev/null +++ b/.changeset/techdocs-swift-apricots-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-addons': patch +--- + +Separation between contexts and hooks From 413024e18284ea21db4652a2921556f98bd9c694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20N=C3=A4sman?= Date: Fri, 25 Mar 2022 16:40:55 +0100 Subject: [PATCH 13/47] add test-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Camila Belo Co-authored-by: Emma Indal Signed-off-by: Anders Näsman --- plugins/techdocs-addons/package.json | 10 +- .../techdocs-addons/src/test-utils/index.ts | 17 ++ .../techdocs-addons/src/test-utils/mocks.ts | 29 +++ .../src/test-utils/test-utils.tsx | 219 ++++++++++++++++++ yarn.lock | 17 ++ 5 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 plugins/techdocs-addons/src/test-utils/index.ts create mode 100644 plugins/techdocs-addons/src/test-utils/mocks.ts create mode 100644 plugins/techdocs-addons/src/test-utils/test-utils.tsx diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json index 1ff346442f..3e441eb56e 100644 --- a/plugins/techdocs-addons/package.json +++ b/plugins/techdocs-addons/package.json @@ -25,13 +25,18 @@ "@backstage/catalog-model": "^0.13.0", "@backstage/core-components": "^0.9.1", "@backstage/core-plugin-api": "^0.8.0", + "@backstage/test-utils": "^0.3.0", "@material-ui/core": "^4.12.2", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.11.0", "jss": "~10.8.2", + "lodash.debounce": "^4.0.8", + "react-dom": "^17.0.2", "react-helmet": "6.1.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^17.2.4" + "react-use": "^17.2.4", + "testing-library__dom": "^7.29.4-beta.1", + "@testing-library/react": "^12.1.3" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", @@ -39,7 +44,8 @@ }, "devDependencies": { "@testing-library/react-hooks": "^7.0.2", - "@backstage/test-utils": "^0.3.0" + "@testing-library/jest-dom": "^5.10.1", + "@types/lodash.debounce": "^4.0.6" }, "files": [ "dist" diff --git a/plugins/techdocs-addons/src/test-utils/index.ts b/plugins/techdocs-addons/src/test-utils/index.ts new file mode 100644 index 0000000000..3bcf5f5ab8 --- /dev/null +++ b/plugins/techdocs-addons/src/test-utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './test-utils'; diff --git a/plugins/techdocs-addons/src/test-utils/mocks.ts b/plugins/techdocs-addons/src/test-utils/mocks.ts new file mode 100644 index 0000000000..fd4665237c --- /dev/null +++ b/plugins/techdocs-addons/src/test-utils/mocks.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const useTechDocsReaderDom = jest.fn(); +export const useParams = jest.fn(); +jest.mock('@backstage/plugin-techdocs', () => ({ + ...(jest.requireActual('@backstage/plugin-techdocs') as {}), + useTechDocsReaderDom, + withTechDocsReaderProvider: jest.fn(x => x), + TechDocsStateIndicator: jest.fn(() => null), +})); +// todo(backstage/techdocs-core): Use core test-utils' `routeEntries` option. +jest.mock('react-router', () => ({ + ...(jest.requireActual('react-router') as {}), + useParams, +})); diff --git a/plugins/techdocs-addons/src/test-utils/test-utils.tsx b/plugins/techdocs-addons/src/test-utils/test-utils.tsx new file mode 100644 index 0000000000..4362bb8cf7 --- /dev/null +++ b/plugins/techdocs-addons/src/test-utils/test-utils.tsx @@ -0,0 +1,219 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import order matters for jest manual mocks! import this first. +import { useTechDocsReaderDom, useParams } from './mocks'; + +import React, { ReactElement, Fragment } from 'react'; + +// Shadow DOM support for the simple and complete DOM testing utilities +// https://github.com/testing-library/dom-testing-library/issues/742#issuecomment-674987855 +import { screen } from 'testing-library__dom'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Route, Routes } from 'react-router-dom'; +import { act, render } from '@testing-library/react'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; + +import { + wrapInTestApp, + TestApiProvider, + TestApiProviderProps, +} from '@backstage/test-utils'; + +import { TechDocsEntityMetadata, TechDocsMetadata } from '../types'; +import { TechDocsReaderPage, TechDocsAddons } from '..'; + +type RecursivePartial = { + [P in keyof T]?: RecursivePartial; +}; + +type Apis = TestApiProviderProps['apis']; + +export type TechDocsAddonsBuilder = { + dom: ReactElement; + entity: RecursivePartial; + metadata: RecursivePartial; + componentId: string; + apis: Apis; + path: string; +}; + +const defaultOptions: TechDocsAddonsBuilder = { + dom: <>, + entity: {}, + metadata: {}, + componentId: 'docs', + apis: [], + path: '', +}; + +const defaultMetadata = { + site_name: 'Tech Docs', + site_description: 'Tech Docs', +}; + +const defaultEntity = { + kind: 'Component', + metadata: { namespace: 'default', name: 'docs' }, +}; + +const defaultDom = ( + + + +
+
+
+
+
+ + +); + +export class TechDocsAddonBuilder { + private options: TechDocsAddonsBuilder = defaultOptions; + private addons: ReactElement[]; + + static buildAddonsInTechDocs(addons: ReactElement[]) { + return new TechDocsAddonBuilder(addons); + } + + constructor(addons: ReactElement[]) { + this.addons = addons; + } + + withApis(apis: Apis) { + const refs = apis.map(([ref]) => ref); + this.options.apis = this.options.apis + .filter(([ref]) => !refs.includes(ref)) + .concat(apis); + return this; + } + + withDom(dom: ReactElement) { + this.options.dom = dom; + return this; + } + + withMetadata(metadata: RecursivePartial) { + this.options.metadata = metadata; + return this; + } + + withEntity(entity: RecursivePartial) { + this.options.entity = entity; + return this; + } + + atPath(path: string) { + this.options.path = path; + return this; + } + + build() { + const apis = [...this.options.apis]; + const entityName = { + namespace: + this.options.entity?.metadata?.namespace || + defaultEntity.metadata.namespace, + kind: this.options.entity?.kind || defaultEntity.kind, + name: this.options.entity?.metadata?.name || defaultEntity.metadata.name, + }; + + const techDocsMetadata: AsyncState = { + loading: false, + error: undefined, + value: (this.options.metadata || { + ...defaultMetadata, + }) as TechDocsMetadata, + }; + + const entityMetadata: AsyncState = { + loading: false, + error: undefined, + value: (this.options.entity || { + ...defaultEntity, + }) as TechDocsEntityMetadata, + }; + + const dom = document.createElement('html'); + dom.innerHTML = renderToStaticMarkup(this.options.dom || defaultDom); + useTechDocsReaderDom.mockReturnValue(dom); + // todo(backstage/techdocs-core): Use core test-utils' `routeEntries` option to mock + // the current path. We use jest mocks instead for now because of a bug in + // react-router that prevents '*' params from being mocked. + useParams.mockReturnValue({ + ...entityName, + '*': this.options.path, + }); + + return wrapInTestApp( + + + + } + > + + {this.addons.map((addon, index) => ( + {addon} + ))} + + + + , + ); + } + + render(): typeof screen & { shadowRoot: ShadowRoot | null } { + render(this.build()); + + const shadowHost = screen.getByTestId('techdocs-native-shadowroot'); + + return { + ...screen, + shadowRoot: shadowHost?.shadowRoot, + }; + } + + // Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async + // act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit + // cleaner, since act doesn't return the result of the evaluated function. + // https://github.com/testing-library/react-testing-library/issues/281 + // https://github.com/facebook/react/pull/14853 + async renderWithEffects(): Promise< + ReturnType + > { + await act(async () => { + this.render(); + }); + + const shadowHost = screen.getByTestId('techdocs-native-shadowroot'); + + return { + ...screen, + shadowRoot: shadowHost?.shadowRoot, + }; + } +} + +export default TechDocsAddonBuilder.buildAddonsInTechDocs; diff --git a/yarn.lock b/yarn.lock index 03de40b659..90e3ad3e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6555,6 +6555,18 @@ dependencies: "@types/node" "*" +"@types/lodash.debounce@^4.0.6": + version "4.0.6" + resolved "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60" + integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.180" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz#4ab7c9ddfc92ec4a887886483bc14c79fb380670" + integrity sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g== + "@types/lodash@^4.14.151", "@types/lodash@^4.14.173", "@types/lodash@^4.14.175": version "4.14.178" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" @@ -24094,6 +24106,11 @@ testcontainers@^8.1.2: ssh-remote-port-forward "^1.0.4" tar-fs "^2.1.1" +testing-library__dom@^7.29.4-beta.1: + version "7.29.4-beta.1" + resolved "https://registry.npmjs.org/testing-library__dom/-/testing-library__dom-7.29.4-beta.1.tgz#dc755f485837e923efbe12c1b7ae43b0ed326f96" + integrity sha512-vb/SMg8rXYcYYFY2eQ2n2a0p2VWNAseM4WHLfsckIyLwxRz5fYqKysUzUYpAX8SwRfruRK+tZqLuL4ND+D1s7Q== + text-extensions@^1.0.0: version "1.9.0" resolved "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26" From ace749b785c61d96b570e6e64cc9be3b2617fa3b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 21 Mar 2022 21:57:04 +0100 Subject: [PATCH 14/47] Allow addons (except header location) to be used in Entity Reader Signed-off-by: Eric Peterson --- .changeset/techdocs-until-you-puke.md | 58 ++++++++++++ .../app/src/components/catalog/EntityPage.tsx | 38 +++++++- plugins/techdocs-addons/api-report.md | 4 +- plugins/techdocs-addons/src/addons.tsx | 32 ++++++- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 31 ++++-- plugins/techdocs/api-report.md | 7 +- plugins/techdocs/src/EntityPageDocs.tsx | 94 ++++++++++++++++--- plugins/techdocs/src/Router.tsx | 10 +- .../reader/components/TechDocsReaderPage.tsx | 4 +- 9 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 .changeset/techdocs-until-you-puke.md diff --git a/.changeset/techdocs-until-you-puke.md b/.changeset/techdocs-until-you-puke.md new file mode 100644 index 0000000000..af70fe315b --- /dev/null +++ b/.changeset/techdocs-until-you-puke.md @@ -0,0 +1,58 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +TechDocs now supports a new method of customization: addons! + +To customize the standalone TechDocs reader page experience, update your `/packages/app/src/App.tsx` in the following way: + +```diff +import { TechDocsIndexPage, TechDocsReaderPage } from '@backstage/plugin-techdocs'; ++ import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; ++ import { SomeAddon } from '@backstage/plugin-some-plugin'; +- import { techDocsPage } from './components/techdocs/TechDocsPage'; + +// ... + + } /> + } + > +- {techDocsPage} ++ ++ ++ + + +// ... +``` + +To customize the TechDocs reader experience on the Catalog entity page, update your `packages/app/src/components/catalog/EntityPage.tsx` in the following way: + +```diff +import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; ++ import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; ++ import { SomeAddon } from '@backstage/plugin-some-plugin'; + +// ... + + + + {overviewContent} + + + +- ++ ++ ++ ++ ++ + + + +// ... +``` + +If you do not wish to customize your TechDocs reader experience in this way at this time, no changes are necessary! diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 26068c6c8f..534b7eacda 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -138,6 +138,14 @@ import { import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; import React, { ReactNode, useMemo, useState } from 'react'; +import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; +import { + ExampleContent, + ExampleHeader, + ExamplePrimarySidebar, + ExampleSecondarySidebar, + ExampleSubHeader, +} from '../techdocs/ExampleAddons'; const customEntityFilterKind = ['Component', 'API', 'System']; @@ -397,7 +405,15 @@ const serviceEntityPage = ( - + + + + + + + + + - + + + + + + + + + - + + + + + + + + + diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md index 5f7f2e8cd0..77c9f4d276 100644 --- a/plugins/techdocs-addons/api-report.md +++ b/plugins/techdocs-addons/api-report.md @@ -3,8 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -/// - import { AsyncState } from 'react-use/lib/useAsyncFn'; import { ComponentType } from 'react'; import { Entity } from '@backstage/catalog-model'; @@ -63,6 +61,8 @@ export const TechDocsReaderPage: ( // @public (undocumented) export type TechDocsReaderPageProps = { + hideHeader?: boolean; + addonConfig?: React_2.ReactNode; dom: Element | null; asyncEntityMetadata: AsyncState; asyncTechDocsMetadata: AsyncState; diff --git a/plugins/techdocs-addons/src/addons.tsx b/plugins/techdocs-addons/src/addons.tsx index afb3b151c0..2151abd948 100644 --- a/plugins/techdocs-addons/src/addons.tsx +++ b/plugins/techdocs-addons/src/addons.tsx @@ -21,7 +21,13 @@ import { Extension, useElementFilter, } from '@backstage/core-plugin-api'; -import React, { ComponentType, useCallback } from 'react'; +import React, { + ComponentType, + createContext, + PropsWithChildren, + useCallback, + useContext, +} from 'react'; import { useOutlet } from 'react-router-dom'; import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; @@ -90,8 +96,30 @@ const getAllTechDocsAddonsData = (collection: ElementCollection) => { }); }; +type TechDocsAddonConfig = { + config?: React.ReactNode | null; +}; + +const TechDocsAddonConfigContext = createContext({}); + +export const TechDocsAddonConfigProvider = ( + props: PropsWithChildren<{ config?: React.ReactNode }>, +) => { + const fromOutlet = useOutlet(); + const config = props.config ?? fromOutlet; + return ( + + {props.children} + + ); +}; + +const useTechDocsAddonsConfig = (): React.ReactNode | null => { + return useContext(TechDocsAddonConfigContext).config || null; +}; + export const useTechDocsAddons = () => { - const node = useOutlet(); + const node = useTechDocsAddonsConfig(); const collection = useElementFilter(node, getAllTechDocsAddons); const options = useElementFilter(node, getAllTechDocsAddonsData); diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx index d9d54e2248..2941278feb 100644 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -18,6 +18,7 @@ import { Page } from '@backstage/core-components'; import React from 'react'; import { useParams } from 'react-router-dom'; import { AsyncState } from 'react-use/lib/useAsyncFn'; +import { TechDocsAddonConfigProvider } from '../../addons'; import { TechDocsMetadataProvider, @@ -33,6 +34,8 @@ import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; * @public */ export type TechDocsReaderPageProps = { + hideHeader?: boolean; + addonConfig?: React.ReactNode; dom: Element | null; asyncEntityMetadata: AsyncState; asyncTechDocsMetadata: AsyncState; @@ -43,21 +46,29 @@ export type TechDocsReaderPageProps = { * @public */ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { - const { asyncEntityMetadata, asyncTechDocsMetadata, dom } = props; + const { + addonConfig, + asyncEntityMetadata, + asyncTechDocsMetadata, + dom, + hideHeader = false, + } = props; const { namespace, kind, name } = useParams(); const entityName = { namespace, kind, name }; return ( - - - - - {/* todo(backstage/techdocs-core): handle state indicator */} - {/* */} - - - + + + + {!hideHeader && } + + {/* todo(backstage/techdocs-core): handle state indicator */} + {/* */} + + + + ); diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index ecab23e225..0af87a2838 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -16,6 +16,7 @@ import { FetchApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { TableColumn } from '@backstage/core-components'; import { TableProps } from '@backstage/core-components'; @@ -89,7 +90,7 @@ export type DocsTableRow = { }; // @public -export const EmbeddedDocsRouter: () => JSX.Element; +export const EmbeddedDocsRouter: (props: PropsWithChildren<{}>) => JSX.Element; // @public export const EntityListDocsGrid: () => JSX.Element; @@ -129,7 +130,9 @@ export type EntityListDocsTableProps = { }; // @public -export const EntityTechdocsContent: () => JSX.Element; +export const EntityTechdocsContent: (props: { + children?: ReactNode; +}) => JSX.Element; // @public export const isTechDocsAvailable: (entity: Entity) => boolean; diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index b10fba6d2e..ba64ca2c78 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -14,22 +14,90 @@ * limitations under the License. */ -import React from 'react'; -import { Entity } from '@backstage/catalog-model'; -import { Reader } from './reader'; +import React, { PropsWithChildren } from 'react'; +import { + CompoundEntityRef, + DEFAULT_NAMESPACE, + Entity, +} from '@backstage/catalog-model'; +import { + Reader, + useTechDocsReaderDom, + withTechDocsReaderProvider, +} from './reader'; import { toLowerMaybe } from './helpers'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { + configApiRef, + getComponentData, + useApi, +} from '@backstage/core-plugin-api'; +import { + TechDocsReaderPage as AddonAwareReaderPage, + TECHDOCS_ADDONS_WRAPPER_KEY, +} from '@backstage/plugin-techdocs-addons'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; +import { TechDocsEntityMetadata } from './types'; +import { techdocsApiRef } from '.'; +import useAsync from 'react-use/lib/useAsync'; + +type SpecialReaderPageProps = { + entityName: CompoundEntityRef; + asyncEntityMetadata: AsyncState; + addonConfig?: React.ReactNode; +}; + +// todo(backstage/techdocs-core): Combine with and simplify +// with the version in TechDocsReaderPage.tsx +const SpecialReaderPage = (props: SpecialReaderPageProps) => { + const techdocsApi = useApi(techdocsApiRef); + const dom = useTechDocsReaderDom(props.entityName); + const { kind, namespace, name } = props.entityName; + + const asyncTechDocsMetadata = useAsync(() => { + return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); + }, [kind, namespace, name, techdocsApi]); -export const EntityPageDocs = ({ entity }: { entity: Entity }) => { - const config = useApi(configApiRef); return ( - ); }; + +export const EntityPageDocs = ({ + children, + entity, +}: PropsWithChildren<{ entity: Entity }>) => { + const config = useApi(configApiRef); + const entityName = { + namespace: toLowerMaybe( + entity.metadata.namespace ?? DEFAULT_NAMESPACE, + config, + ), + kind: toLowerMaybe(entity.kind, config), + name: toLowerMaybe(entity.metadata.name, config), + }; + + // Check if we were given a set of TechDocs addons. + if (children && getComponentData(children, TECHDOCS_ADDONS_WRAPPER_KEY)) { + const Component = withTechDocsReaderProvider(SpecialReaderPage, entityName); + return ( + + ); + } + + // Otherwise, return a version of the reader that is not addon-aware. + return ; +}; diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 3a49f6edf9..0a2c569097 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; @@ -55,7 +55,8 @@ export const Router = () => { * * @public */ -export const EmbeddedDocsRouter = () => { +export const EmbeddedDocsRouter = (props: PropsWithChildren<{}>) => { + const { children } = props; const { entity } = useEntity(); const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION]; @@ -66,7 +67,10 @@ export const EmbeddedDocsRouter = () => { return ( - } /> + {children}} + /> ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx index 1c041dfe97..e51f5272eb 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx @@ -47,8 +47,8 @@ export type TechDocsReaderPageRenderFunction = ({ type SpecialReaderPageProps = { entityName: CompoundEntityRef; - asyncEntityMetadata: AsyncState; - asyncTechDocsMetadata: AsyncState; + asyncEntityMetadata: AsyncState; + asyncTechDocsMetadata: AsyncState; }; const SpecialReaderPage = (props: SpecialReaderPageProps) => { From 948d6425de43afae0ef7dfc840795540fc0fa063 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 29 Mar 2022 20:03:48 +0200 Subject: [PATCH 15/47] feat(techdocs): create addons package Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- packages/techdocs-addons/.eslintrc.js | 1 + packages/techdocs-addons/README.md | 11 +++ packages/techdocs-addons/package.json | 59 +++++++++++ packages/techdocs-addons/src/addons.tsx | 125 ++++++++++++++++++++++++ packages/techdocs-addons/src/index.ts | 30 ++++++ packages/techdocs-addons/src/types.ts | 80 +++++++++++++++ 6 files changed, 306 insertions(+) create mode 100644 packages/techdocs-addons/.eslintrc.js create mode 100644 packages/techdocs-addons/README.md create mode 100644 packages/techdocs-addons/package.json create mode 100644 packages/techdocs-addons/src/addons.tsx create mode 100644 packages/techdocs-addons/src/index.ts create mode 100644 packages/techdocs-addons/src/types.ts diff --git a/packages/techdocs-addons/.eslintrc.js b/packages/techdocs-addons/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/techdocs-addons/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/techdocs-addons/README.md b/packages/techdocs-addons/README.md new file mode 100644 index 0000000000..050adbbaef --- /dev/null +++ b/packages/techdocs-addons/README.md @@ -0,0 +1,11 @@ +# @backstage/techdocs-addons + +This package provides a TechDocs Addons framework used to create and consume TechDocs Addons. + +## Installation + +Install the package: + +```sh +yarn add @backstage/techdocs-addons +``` diff --git a/packages/techdocs-addons/package.json b/packages/techdocs-addons/package.json new file mode 100644 index 0000000000..36e508918f --- /dev/null +++ b/packages/techdocs-addons/package.json @@ -0,0 +1,59 @@ +{ + "name": "@backstage/techdocs-addons", + "description": "TechDocs Addons Framework", + "version": "0.0.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-addons" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean", + "start": "backstage-cli package start" + }, + "dependencies": { + "@backstage/catalog-model": "^0.13.0", + "@backstage/core-components": "^0.9.1", + "@backstage/core-plugin-api": "^0.8.0", + "@material-ui/core": "^4.12.2", + "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/styles": "^4.11.0", + "jss": "~10.8.2", + "react-helmet": "6.1.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@testing-library/react-hooks": "^7.0.2", + "@backstage/test-utils": "^0.3.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/techdocs-addons/src/addons.tsx b/packages/techdocs-addons/src/addons.tsx new file mode 100644 index 0000000000..0eb102aa24 --- /dev/null +++ b/packages/techdocs-addons/src/addons.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType, useCallback } from 'react'; +import { useOutlet } from 'react-router-dom'; + +import { + attachComponentData, + createReactExtension, + ElementCollection, + Extension, + useElementFilter, +} from '@backstage/core-plugin-api'; + +import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; + +export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1'; + +/** + * Marks the registry component. + * @public + */ +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + +/** + * TechDocs Addon registry. + * @public + */ +export const TechDocsAddons: React.ComponentType = () => null; + +attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); + +const getDataKeyByName = (name: string) => { + return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; +}; + +/** + * Create a TechDocs addon. + * @public + */ +export function createTechDocsAddon( + options: TechDocsAddonOptions, +): Extension> { + const { name, component: TechDocsAddon } = options; + return createReactExtension({ + name, + component: { + sync: (props: TComponentProps) => , + }, + data: { + [TECHDOCS_ADDONS_KEY]: options, + [getDataKeyByName(name)]: true, + }, + }); +} + +const getTechDocsAddonByName = (collection: ElementCollection, key: string) => { + return collection.selectByComponentData({ key }).getElements()[0]; +}; + +const getAllTechDocsAddons = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .selectByComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +const getAllTechDocsAddonsData = (collection: ElementCollection) => { + return collection + .selectByComponentData({ + key: TECHDOCS_ADDONS_WRAPPER_KEY, + }) + .findComponentData({ + key: TECHDOCS_ADDONS_KEY, + }); +}; + +export const useTechDocsAddons = () => { + const node = useOutlet(); + const collection = useElementFilter(node, getAllTechDocsAddons); + const options = useElementFilter(node, getAllTechDocsAddonsData); + + const findAddonByData = useCallback( + (data: TechDocsAddonOptions | undefined) => { + if (!collection || !data) return null; + const nameKey = getDataKeyByName(data.name); + return getTechDocsAddonByName(collection, nameKey) ?? null; + }, + [collection], + ); + + const renderComponentByName = useCallback( + (name: string) => { + const data = options.find(option => option.name === name); + return data ? findAddonByData(data) : null; + }, + [options, findAddonByData], + ); + + const renderComponentsByLocation = useCallback( + (location: TechDocsAddonLocations) => { + const data = options.filter(option => option.location === location); + return data.length ? data.map(findAddonByData) : null; + }, + [options, findAddonByData], + ); + + return { renderComponentByName, renderComponentsByLocation }; +}; diff --git a/packages/techdocs-addons/src/index.ts b/packages/techdocs-addons/src/index.ts new file mode 100644 index 0000000000..2ef7265db3 --- /dev/null +++ b/packages/techdocs-addons/src/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Package encapsulating the TechDocs Addon framework. + * + * @packageDocumentation + */ + +export { + useTechDocsAddons, + createTechDocsAddon, + TechDocsAddons, + TECHDOCS_ADDONS_WRAPPER_KEY, +} from './addons'; +export { TechDocsAddonLocations } from './types'; +export type { TechDocsAddonAsyncMetadata, TechDocsAddonOptions } from './types'; diff --git a/packages/techdocs-addons/src/types.ts b/packages/techdocs-addons/src/types.ts new file mode 100644 index 0000000000..2eb8a2f57e --- /dev/null +++ b/packages/techdocs-addons/src/types.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { ComponentType } from 'react'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; + +/** + * Locations for which TechDocs addons may be declared and rendered. + * @public + */ +export enum TechDocsAddonLocations { + /** + * These addons fill up the header from the right, on the same line as the + * title. + */ + HEADER = 'header', + + /** + * These addons appear below the header and above all content; tooling addons + * can be inserted for convenience. + */ + SUBHEADER = 'subheader', + + /** + * These addons appear left of the content and above the navigation. + */ + PRIMARY_SIDEBAR = 'primary sidebar', + + /** + * These addons appear right of the content and above the table of contents. + */ + SECONDARY_SIDEBAR = 'secondary sidebar', + + /** + * A virtual location which allows mutation of all content within the shadow + * root by transforming DOM nodes. These addons should return null on render. + */ + CONTENT = 'content', + + /** + * A virtual location allowing an instance of the addon to be rendered for + * every HTML node with the same tag name as the addon name in the markdown + * content. If no reference is made, no instance will be rendered. Works like + * regular React components, just being accessible from markdown. + * + * todo(backstage/techdocs-core): Keep and implement or remove before + * releasing this package! + */ + COMPONENT = 'component', +} + +/** + * Options for creating a TechDocs addon. + * @public + */ +export type TechDocsAddonOptions = { + name: string; + location: TechDocsAddonLocations; + component: ComponentType; +}; + +/** + * Common response envelope for addon-related hooks. + * @public + */ +export type TechDocsAddonAsyncMetadata = AsyncState; From fbc5c68d916dedea44661ddcc10f776847c50e4f Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 29 Mar 2022 20:05:46 +0200 Subject: [PATCH 16/47] feat(techdocs): merge reader components Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- plugins/techdocs/package.json | 4 +- plugins/techdocs/src/EntityPageDocs.tsx | 88 +- plugins/techdocs/src/Router.tsx | 7 +- .../src/reader/components/Reader.test.tsx | 85 -- .../techdocs/src/reader/components/Reader.tsx | 953 ------------------ .../components/TechDocsReaderPage.test.tsx | 176 ---- .../reader/components/TechDocsReaderPage.tsx | 140 --- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 95 ++ .../TechDocsReaderPage/context.test.tsx | 146 +++ .../components/TechDocsReaderPage/context.tsx | 175 ++++ .../TechDocsReaderPage/hooks.test.ts | 51 + .../components/TechDocsReaderPage/hooks.ts | 51 + .../components/TechDocsReaderPage/index.ts | 23 + .../TechDocsReaderPageContent.tsx | 136 +++ .../TechDocsReaderPageContent/context.tsx | 856 ++++++++++++++++ .../TechDocsReaderPageContent/index.ts | 18 + .../TechDocsReaderPageHeader.test.tsx | 0 .../TechDocsReaderPageHeader.tsx | 88 +- .../TechDocsReaderPageHeader/index.ts | 17 + .../TechDocsReaderPageSubheader.tsx | 52 + .../TechDocsReaderPageSubheader/index.ts | 17 + .../components/TechDocsStateIndicator.tsx | 2 +- .../techdocs/src/reader/components/index.ts | 25 +- plugins/techdocs/src/types.ts | 17 +- 24 files changed, 1738 insertions(+), 1484 deletions(-) delete mode 100644 plugins/techdocs/src/reader/components/Reader.test.tsx delete mode 100644 plugins/techdocs/src/reader/components/Reader.tsx delete mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx delete mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts rename plugins/techdocs/src/reader/components/{ => TechDocsReaderPageHeader}/TechDocsReaderPageHeader.test.tsx (100%) rename plugins/techdocs/src/reader/components/{ => TechDocsReaderPageHeader}/TechDocsReaderPageHeader.tsx (56%) create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b4868ab4e7..6716a1a2c5 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -45,7 +45,7 @@ "@backstage/plugin-catalog-react": "^1.0.1-next.2", "@backstage/plugin-catalog": "^0.10.0", "@backstage/plugin-search": "^0.7.5-next.0", - "@backstage/plugin-techdocs-addons": "^0.0.0", + "@backstage/techdocs-addons": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -54,7 +54,9 @@ "dompurify": "^2.2.9", "event-source-polyfill": "1.0.25", "git-url-parse": "^11.6.0", + "jss": "~10.8.2", "lodash": "^4.17.21", + "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-text-truncate": "^0.18.0", diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index ba64ca2c78..50f44b3773 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -14,65 +14,20 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { - CompoundEntityRef, - DEFAULT_NAMESPACE, - Entity, -} from '@backstage/catalog-model'; -import { - Reader, - useTechDocsReaderDom, - withTechDocsReaderProvider, -} from './reader'; +import React from 'react'; + +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; + import { toLowerMaybe } from './helpers'; -import { - configApiRef, - getComponentData, - useApi, -} from '@backstage/core-plugin-api'; -import { - TechDocsReaderPage as AddonAwareReaderPage, - TECHDOCS_ADDONS_WRAPPER_KEY, -} from '@backstage/plugin-techdocs-addons'; -import { AsyncState } from 'react-use/lib/useAsyncFn'; -import { TechDocsEntityMetadata } from './types'; -import { techdocsApiRef } from '.'; -import useAsync from 'react-use/lib/useAsync'; +import { TechDocsReaderPage } from './plugin'; +import { TechDocsReaderLayout } from './reader'; -type SpecialReaderPageProps = { - entityName: CompoundEntityRef; - asyncEntityMetadata: AsyncState; - addonConfig?: React.ReactNode; -}; +type EntityPageDocsProps = { entity: Entity }; -// todo(backstage/techdocs-core): Combine with and simplify -// with the version in TechDocsReaderPage.tsx -const SpecialReaderPage = (props: SpecialReaderPageProps) => { - const techdocsApi = useApi(techdocsApiRef); - const dom = useTechDocsReaderDom(props.entityName); - const { kind, namespace, name } = props.entityName; - - const asyncTechDocsMetadata = useAsync(() => { - return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); - }, [kind, namespace, name, techdocsApi]); - - return ( - - ); -}; - -export const EntityPageDocs = ({ - children, - entity, -}: PropsWithChildren<{ entity: Entity }>) => { +export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => { const config = useApi(configApiRef); + const entityName = { namespace: toLowerMaybe( entity.metadata.namespace ?? DEFAULT_NAMESPACE, @@ -82,22 +37,9 @@ export const EntityPageDocs = ({ name: toLowerMaybe(entity.metadata.name, config), }; - // Check if we were given a set of TechDocs addons. - if (children && getComponentData(children, TECHDOCS_ADDONS_WRAPPER_KEY)) { - const Component = withTechDocsReaderProvider(SpecialReaderPage, entityName); - return ( - - ); - } - - // Otherwise, return a version of the reader that is not addon-aware. - return ; + return ( + + + + ); }; diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 0a2c569097..40308e3379 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -67,10 +67,9 @@ export const EmbeddedDocsRouter = (props: PropsWithChildren<{}>) => { return ( - {children}} - /> + }> + {children} + ); }; diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx deleted file mode 100644 index 6e8609694d..0000000000 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader } from '@backstage/config'; -import { - ScmIntegrationsApi, - scmIntegrationsApiRef, -} from '@backstage/integration-react'; -import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; -import { act, render } from '@testing-library/react'; -import React from 'react'; -import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api'; -import { Reader } from './Reader'; -import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - return { - ...actual, - useParams: jest.fn(), - }; -}); - -const { useParams }: { useParams: jest.Mock } = - jest.requireMock('react-router-dom'); - -describe('', () => { - it('should render Reader content', async () => { - useParams.mockReturnValue({ - entityRef: 'Component::backstage', - }); - - const scmIntegrationsApi: ScmIntegrationsApi = - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: {}, - }), - ); - const techdocsStorageApi: Partial = {}; - const searchApi = { - query: () => - Promise.resolve({ - results: [], - }), - }; - const apiRegistry = TestApiRegistry.from( - [scmIntegrationsApiRef, scmIntegrationsApi], - [techdocsStorageApiRef, techdocsStorageApi], - [searchApiRef, searchApi], - ); - - await act(async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - expect( - rendered.getByTestId('techdocs-content-shadowroot'), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx deleted file mode 100644 index 13157459f4..0000000000 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ /dev/null @@ -1,953 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { - PropsWithChildren, - ComponentType, - createContext, - useContext, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { - Grid, - makeStyles, - useTheme, - Theme, - lighten, - alpha, -} from '@material-ui/core'; - -import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { BackstageTheme } from '@backstage/theme'; -import { - sidebarConfig, - SidebarPinStateContext, -} from '@backstage/core-components'; - -import { techdocsStorageApiRef } from '../../api'; - -import { - addBaseUrl, - addGitFeedbackLink, - addLinkClickListener, - addSidebarToggle, - injectCss, - onCssReady, - removeMkdocsHeader, - rewriteDocLinks, - sanitizeDOM, - simplifyMkdocsFooter, - scrollIntoAnchor, - transform as transformer, - copyToClipboard, -} from '../transformers'; - -import { TechDocsSearch } from '../../search'; -import { TechDocsStateIndicator } from './TechDocsStateIndicator'; -import { useReaderState } from './useReaderState'; - -/** - * Props for {@link Reader} - * - * @public - */ -export type ReaderProps = { - entityRef: CompoundEntityRef; - withSearch?: boolean; - onReady?: () => void; -}; - -const useStyles = makeStyles(theme => ({ - searchBar: { - maxWidth: 'calc(100% - 16rem * 2 - 2.4rem)', - marginTop: 0, - marginBottom: theme.spacing(1), - marginLeft: 'calc(16rem + 1.2rem)', - '@media screen and (max-width: 76.1875em)': { - marginLeft: '0', - maxWidth: '100%', - }, - }, -})); - -type TechDocsReaderValue = ReturnType; - -const TechDocsReaderContext = createContext( - {} as TechDocsReaderValue, -); - -const TechDocsReaderProvider = ({ - children, - entityRef, -}: PropsWithChildren<{ entityRef: CompoundEntityRef }>) => { - const { '*': path } = useParams(); - const { kind, namespace, name } = entityRef; - const value = useReaderState(kind, namespace, name, path); - return ( - - {children} - - ); -}; - -/** - * Note: this HOC is currently being exported so that we can rapidly - * iterate on alternative implementations that extend core - * functionality. There is no guarantee that this HOC will continue to be - * exported by the package in the future! - * - * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") - * @internal - */ -export const withTechDocsReaderProvider = - (Component: ComponentType, entityRef: CompoundEntityRef) => - (props: T) => - ( - - - - ); - -/** - * Note: this hook is currently being exported so that we can rapidly - * iterate on alternative implementations that extend core - * functionality. There is no guarantee that this hook will continue to be - * exported by the package in the future! - * - * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") - * @internal - */ -export const useTechDocsReader = () => useContext(TechDocsReaderContext); - -type TypographyHeadings = Pick< - Theme['typography'], - 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' ->; - -type TypographyHeadingsKeys = keyof TypographyHeadings; - -const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; - -/** - * Hook that encapsulates the behavior of getting raw HTML and applying - * transforms to it in order to make it function at a basic level in the - * Backstage UI. - * - * Note: this hook is currently being exported so that we can rapidly iterate - * on alternative implementations that extend core functionality. - * There is no guarantee that this hook will continue to be exported by the - * package in the future! - * - * todo: Make public or stop exporting (see others: "altReaderExperiments") - * @internal - */ -export const useTechDocsReaderDom = ( - entityRef: CompoundEntityRef, -): Element | null => { - const navigate = useNavigate(); - const theme = useTheme(); - const techdocsStorageApi = useApi(techdocsStorageApiRef); - const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const techdocsSanitizer = useApi(configApiRef); - const { namespace = '', kind = '', name = '' } = entityRef; - const { state, path, content: rawPage } = useTechDocsReader(); - const isDarkTheme = theme.palette.type === 'dark'; - - const [sidebars, setSidebars] = useState(); - const [dom, setDom] = useState(null); - - // sidebar pinned status to be used in computing CSS style injections - const { isPinned } = useContext(SidebarPinStateContext); - - const updateSidebarPosition = useCallback(() => { - if (!dom || !sidebars) return; - // set sidebar height so they don't initially render in wrong position - const mdTabs = dom.querySelector('.md-container > .md-tabs'); - const sidebarsCollapsed = window.matchMedia( - 'screen and (max-width: 76.1875em)', - ).matches; - const newTop = Math.max(dom.getBoundingClientRect().top, 0); - sidebars.forEach(sidebar => { - if (sidebarsCollapsed) { - sidebar.style.top = '0px'; - } else if (mdTabs) { - sidebar.style.top = `${ - newTop + mdTabs.getBoundingClientRect().height - }px`; - } else { - sidebar.style.top = `${newTop}px`; - } - }); - }, [dom, sidebars]); - - useEffect(() => { - updateSidebarPosition(); - window.addEventListener('scroll', updateSidebarPosition, true); - window.addEventListener('resize', updateSidebarPosition); - return () => { - window.removeEventListener('scroll', updateSidebarPosition, true); - window.removeEventListener('resize', updateSidebarPosition); - }; - // an update to "state" might lead to an updated UI so we include it as a trigger - }, [updateSidebarPosition, state]); - - // dynamically set width of footer to accommodate for pinning of the sidebar - const updateFooterWidth = useCallback(() => { - if (!dom) return; - const footer = dom.querySelector('.md-footer') as HTMLElement; - if (footer) { - footer.style.width = `${dom.getBoundingClientRect().width}px`; - } - }, [dom]); - - useEffect(() => { - updateFooterWidth(); - window.addEventListener('resize', updateFooterWidth); - return () => { - window.removeEventListener('resize', updateFooterWidth); - }; - }); - - // a function that performs transformations that are executed prior to adding it to the DOM - const preRender = useCallback( - (rawContent: string, contentPath: string) => - transformer(rawContent, [ - sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), - addBaseUrl({ - techdocsStorageApi, - entityId: { - kind, - name, - namespace, - }, - path: contentPath, - }), - rewriteDocLinks(), - addSidebarToggle(), - removeMkdocsHeader(), - simplifyMkdocsFooter(), - addGitFeedbackLink(scmIntegrationsApi), - injectCss({ - // Variables - css: ` - /* - As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host. - As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them. - */ - :host { - /* FONT */ - --md-default-fg-color: ${theme.palette.text.primary}; - --md-default-fg-color--light: ${theme.palette.text.secondary}; - --md-default-fg-color--lighter: ${lighten( - theme.palette.text.secondary, - 0.7, - )}; - --md-default-fg-color--lightest: ${lighten( - theme.palette.text.secondary, - 0.3, - )}; - - /* BACKGROUND */ - --md-default-bg-color:${theme.palette.background.default}; - --md-default-bg-color--light: ${theme.palette.background.paper}; - --md-default-bg-color--lighter: ${lighten( - theme.palette.background.paper, - 0.7, - )}; - --md-default-bg-color--lightest: ${lighten( - theme.palette.background.paper, - 0.3, - )}; - - /* PRIMARY */ - --md-primary-fg-color: ${theme.palette.primary.main}; - --md-primary-fg-color--light: ${theme.palette.primary.light}; - --md-primary-fg-color--dark: ${theme.palette.primary.dark}; - --md-primary-bg-color: ${theme.palette.primary.contrastText}; - --md-primary-bg-color--light: ${lighten( - theme.palette.primary.contrastText, - 0.7, - )}; - - /* ACCENT */ - --md-accent-fg-color: var(--md-primary-fg-color); - - /* SHADOW */ - --md-shadow-z1: ${theme.shadows[1]}; - --md-shadow-z2: ${theme.shadows[2]}; - --md-shadow-z3: ${theme.shadows[3]}; - - /* EXTENSIONS */ - --md-admonition-fg-color: var(--md-default-fg-color); - --md-admonition-bg-color: var(--md-default-bg-color); - /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */ - --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); - --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); - --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,'); - --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,'); - --md-toc-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-version-icon: url('data:image/svg+xml;charset=utf-8,'); - } - - :host > * { - /* CODE */ - --md-code-fg-color: ${theme.palette.text.primary}; - --md-code-bg-color: ${theme.palette.background.paper}; - --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; - --md-code-hl-keyword-color: ${ - isDarkTheme - ? theme.palette.primary.light - : theme.palette.primary.dark - }; - --md-code-hl-function-color: ${ - isDarkTheme - ? theme.palette.secondary.light - : theme.palette.secondary.dark - }; - --md-code-hl-string-color: ${ - isDarkTheme - ? theme.palette.success.light - : theme.palette.success.dark - }; - --md-code-hl-number-color: ${ - isDarkTheme ? theme.palette.error.light : theme.palette.error.dark - }; - --md-code-hl-constant-color: var(--md-code-hl-function-color); - --md-code-hl-special-color: var(--md-code-hl-function-color); - --md-code-hl-name-color: var(--md-code-fg-color); - --md-code-hl-comment-color: var(--md-default-fg-color--light); - --md-code-hl-generic-color: var(--md-default-fg-color--light); - --md-code-hl-variable-color: var(--md-default-fg-color--light); - --md-code-hl-operator-color: var(--md-default-fg-color--light); - --md-code-hl-punctuation-color: var(--md-default-fg-color--light); - - /* TYPESET */ - --md-typeset-font-size: 1rem; - --md-typeset-color: var(--md-default-fg-color); - --md-typeset-a-color: var(--md-accent-fg-color); - --md-typeset-table-color: ${theme.palette.text.primary}; - --md-typeset-del-color: ${ - isDarkTheme - ? alpha(theme.palette.error.dark, 0.5) - : alpha(theme.palette.error.light, 0.5) - }; - --md-typeset-ins-color: ${ - isDarkTheme - ? alpha(theme.palette.success.dark, 0.5) - : alpha(theme.palette.success.light, 0.5) - }; - --md-typeset-mark-color: ${ - isDarkTheme - ? alpha(theme.palette.warning.dark, 0.5) - : alpha(theme.palette.warning.light, 0.5) - }; - } - - @media screen and (max-width: 76.1875em) { - :host > * { - /* TYPESET */ - --md-typeset-font-size: .9rem; - } - } - - @media screen and (max-width: 600px) { - :host > * { - /* TYPESET */ - --md-typeset-font-size: .7rem; - } - } - `, - }), - injectCss({ - // Reset - css: ` - body { - --md-text-color: var(--md-default-fg-color); - --md-text-link-color: var(--md-accent-fg-color); - --md-text-font-family: ${theme.typography.fontFamily}; - font-family: var(--md-text-font-family); - background-color: unset; - } - `, - }), - injectCss({ - // Layout - css: ` - .md-grid { - max-width: 100%; - margin: 0; - } - - .md-nav { - font-size: calc(var(--md-typeset-font-size) * 0.9); - } - .md-nav__link { - display: flex; - align-items: center; - justify-content: space-between; - } - .md-nav__icon { - height: 20px !important; - width: 20px !important; - margin-left:${theme.spacing(1)}px; - } - .md-nav__icon svg { - margin: 0; - width: 20px !important; - height: 20px !important; - } - .md-nav__icon:after { - width: 20px !important; - height: 20px !important; - } - - .md-main__inner { - margin-top: 0; - } - - .md-sidebar { - bottom: 75px; - position: fixed; - width: 16rem; - overflow-y: auto; - overflow-x: hidden; - scrollbar-color: rgb(193, 193, 193) #eee; - scrollbar-width: thin; - } - .md-sidebar::-webkit-scrollbar { - width: 5px; - } - .md-sidebar::-webkit-scrollbar-button { - width: 5px; - height: 5px; - } - .md-sidebar::-webkit-scrollbar-track { - background: #eee; - border: 1 px solid rgb(250, 250, 250); - box-shadow: 0px 0px 3px #dfdfdf inset; - border-radius: 3px; - } - .md-sidebar::-webkit-scrollbar-thumb { - width: 5px; - background: rgb(193, 193, 193); - border: transparent; - border-radius: 3px; - } - .md-sidebar::-webkit-scrollbar-thumb:hover { - background: rgb(125, 125, 125); - } - .md-sidebar--secondary { - right: ${theme.spacing(3)}px; - } - .md-sidebar__scrollwrap { - overflow: unset !important; - } - - .md-content { - max-width: calc(100% - 16rem * 2); - margin-left: 16rem; - margin-bottom: 50px; - } - - .md-footer { - position: fixed; - bottom: 0px; - } - .md-footer__title { - background-color: unset; - } - .md-footer__link, .md-footer-nav__link { - width: 16rem; - } - - .md-dialog { - background-color: unset; - } - - @media screen and (min-width: 76.25em) { - .md-sidebar { - height: auto; - } - } - - @media screen and (max-width: 76.1875em) { - .md-nav { - transition: none !important; - background-color: var(--md-default-bg-color) - } - .md-nav--primary .md-nav__title { - cursor: auto; - color: var(--md-default-fg-color); - font-weight: 700; - white-space: normal; - line-height: 1rem; - height: auto; - display: flex; - flex-flow: column; - row-gap: 1.6rem; - padding: 1.2rem .8rem .8rem; - background-color: var(--md-default-bg-color); - } - .md-nav--primary .md-nav__title~.md-nav__list { - box-shadow: none; - } - .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child { - border-top: none; - } - .md-nav--primary .md-nav__title .md-nav__button { - display: none; - } - .md-nav--primary .md-nav__title .md-nav__icon { - color: var(--md-default-fg-color); - position: static; - height: auto; - margin: 0 0 0 -0.2rem; - } - .md-nav--primary > .md-nav__title [for="none"] { - padding-top: 0; - } - .md-nav--primary .md-nav__item { - border-top: none; - } - .md-nav--primary :is(.md-nav__title,.md-nav__item) { - font-size : var(--md-typeset-font-size); - } - .md-nav .md-source { - display: none; - } - - .md-sidebar { - height: 100%; - } - .md-sidebar--primary { - width: 16rem !important; - z-index: 200; - left: ${ - isPinned - ? `calc(-16rem + ${sidebarConfig.drawerWidthOpen}px)` - : `calc(-16rem + ${sidebarConfig.drawerWidthClosed}px)` - } !important; - } - .md-sidebar--secondary:not([hidden]) { - display: none; - } - [data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary { - transform: translateX(16rem); - } - - .md-content { - max-width: 100%; - margin-left: 0; - } - .md-content__inner { - margin: 0; - } - .md-content__inner .highlighttable { - max-width: 100%; - margin: 1em 0; - } - - .md-header__button { - margin: 0.4rem 0; - margin-left: 0.4rem; - padding: 0; - } - - .md-overlay { - left: 0; - } - - .md-footer { - position: static; - padding-left: 0; - } - .md-footer__link, .md-footer-nav__link { - /* footer links begin to overlap at small sizes without setting width */ - width: 50%; - } - } - - @media screen and (max-width: 600px) { - .md-sidebar--primary { - left: -16rem !important; - width: 16rem; - } - .md-sidebar--primary .md-sidebar__scrollwrap { - bottom: ${sidebarConfig.mobileSidebarHeight}px; - } - } - `, - }), - injectCss({ - // Typeset - css: ` - .md-typeset { - font-size: var(--md-typeset-font-size); - } - - ${headings.reduce((style, heading) => { - const styles = theme.typography[heading]; - const { lineHeight, fontFamily, fontWeight, fontSize } = styles; - const calculate = (value: typeof fontSize) => { - let factor: number | string = 1; - if (typeof value === 'number') { - // 60% of the size defined because it is too big - factor = (value / 16) * 0.6; - } - if (typeof value === 'string') { - factor = value.replace('rem', ''); - } - return `calc(${factor} * var(--md-typeset-font-size))`; - }; - return style.concat(` - .md-typeset ${heading} { - color: var(--md-default-fg-color); - line-height: ${lineHeight}; - font-family: ${fontFamily}; - font-weight: ${fontWeight}; - font-size: ${calculate(fontSize)}; - } - `); - }, '')} - - .md-typeset .md-content__button { - color: var(--md-default-fg-color); - } - - .md-typeset hr { - border-bottom: 0.05rem dotted ${theme.palette.divider}; - } - - .md-typeset details { - font-size: var(--md-typeset-font-size) !important; - } - .md-typeset details summary { - padding-left: 2.5rem !important; - } - .md-typeset details summary:before, - .md-typeset details summary:after { - top: 50% !important; - width: 20px !important; - height: 20px !important; - transform: rotate(0deg) translateY(-50%) !important; - } - .md-typeset details[open] > summary:after { - transform: rotate(90deg) translateX(-50%) !important; - } - - .md-typeset blockquote { - color: var(--md-default-fg-color--light); - border-left: 0.2rem solid var(--md-default-fg-color--light); - } - - .md-typeset table:not([class]) { - font-size: var(--md-typeset-font-size); - border: 1px solid var(--md-default-fg-color); - border-bottom: none; - border-collapse: collapse; - } - .md-typeset table:not([class]) th { - font-weight: bold; - } - .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { - border-bottom: 1px solid var(--md-default-fg-color); - } - - .md-typeset pre > code::-webkit-scrollbar-thumb { - background-color: hsla(0, 0%, 0%, 0.32); - } - .md-typeset pre > code::-webkit-scrollbar-thumb:hover { - background-color: hsla(0, 0%, 0%, 0.87); - } - `, - }), - injectCss({ - // Animations - css: ` - /* - Disable CSS animations on link colors as they lead to issues in dark mode. - The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages. - */ - .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { - transition: none; - } - `, - }), - injectCss({ - // Extensions - css: ` - /* HIGHLIGHT */ - .highlight .md-clipboard:after { - content: unset; - } - - .highlight .nx { - color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; - } - - /* CODE HILITE */ - .codehilite .gd { - background-color: ${ - isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' - }; - } - - .codehilite .gi { - background-color: ${ - isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' - }; - } - - /* TABBED */ - .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1), - .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2), - .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3), - .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4), - .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5), - .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6), - .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7), - .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8), - .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9), - .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10), - .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11), - .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12), - .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13), - .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14), - .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15), - .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16), - .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17), - .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18), - .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19), - .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) { - color: var(--md-accent-fg-color); - border-color: var(--md-accent-fg-color); - } - - /* TASK-LIST */ - .task-list-control .task-list-indicator::before { - background-color: ${theme.palette.action.disabledBackground}; - } - .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { - background-color: ${theme.palette.success.main}; - } - - /* ADMONITION */ - .admonition { - font-size: var(--md-typeset-font-size) !important; - } - .admonition .admonition-title { - padding-left: 2.5rem !important; - } - - .admonition .admonition-title:before { - top: 50% !important; - width: 20px !important; - height: 20px !important; - transform: translateY(-50%) !important; - } - `, - }), - ]), - [ - kind, - name, - namespace, - scmIntegrationsApi, - techdocsSanitizer, - techdocsStorageApi, - theme, - isDarkTheme, - isPinned, - ], - ); - - // a function that performs transformations that are executed after adding it to the DOM - const postRender = useCallback( - async (transformedElement: Element) => - transformer(transformedElement, [ - scrollIntoAnchor(), - copyToClipboard(theme), - addLinkClickListener({ - baseUrl: window.location.origin, - onClick: (event: MouseEvent, url: string) => { - // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open` - const modifierActive = event.ctrlKey || event.metaKey; - const parsedUrl = new URL(url); - - // hash exists when anchor is clicked on secondary sidebar - if (parsedUrl.hash) { - if (modifierActive) { - window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank'); - } else { - navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); - // Scroll to hash if it's on the current page - transformedElement - ?.querySelector(`[id='${parsedUrl.hash.slice(1)}']`) - ?.scrollIntoView(); - } - } else { - if (modifierActive) { - window.open(parsedUrl.pathname, '_blank'); - } else { - navigate(parsedUrl.pathname); - // Scroll to top of reader if primary sidebar link is clicked - transformedElement - ?.querySelector('.md-content__inner') - ?.scrollIntoView(); - } - } - }, - }), - onCssReady({ - docStorageUrl: await techdocsStorageApi.getApiOrigin(), - onLoading: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.setProperty('opacity', '0'); - }, - onLoaded: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.removeProperty('opacity'); - // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) - renderedElement - .querySelector('.md-nav__title') - ?.removeAttribute('for'); - setSidebars( - Array.from(renderedElement.querySelectorAll('.md-sidebar')), - ); - }, - }), - ]), - [theme, navigate, techdocsStorageApi], - ); - - useEffect(() => { - if (!rawPage) return () => {}; - - // if false, there is already a newer execution of this effect - let shouldReplaceContent = true; - - // Pre-render - preRender(rawPage, path).then(async preTransformedDomElement => { - if (!preTransformedDomElement?.innerHTML) { - return; // An unexpected error occurred - } - - // don't manipulate the shadow dom if this isn't the latest effect execution - if (!shouldReplaceContent) { - return; - } - - // Scroll to top after render - window.scroll({ top: 0 }); - - // Post-render - const postTransformedDomElement = await postRender( - preTransformedDomElement, - ); - setDom(postTransformedDomElement as HTMLElement); - }); - - // cancel this execution - return () => { - shouldReplaceContent = false; - }; - }, [rawPage, path, preRender, postRender]); - - return dom; -}; - -const TheReader = ({ - entityRef, - onReady = () => {}, - withSearch = true, -}: ReaderProps) => { - const classes = useStyles(); - const dom = useTechDocsReaderDom(entityRef); - const shadowDomRef = useRef(null); - - const onReadyRef = useRef<() => void>(onReady); - useEffect(() => { - onReadyRef.current = onReady; - }, [onReady]); - - useEffect(() => { - if (!dom || !shadowDomRef.current) return; - const shadowDiv = shadowDomRef.current; - const shadowRoot = - shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' }); - Array.from(shadowRoot.children).forEach(child => - shadowRoot.removeChild(child), - ); - shadowRoot.appendChild(dom); - onReadyRef.current(); - - // this hook must ONLY be triggered by a changed dom - }, [dom]); - - return ( - <> - - {withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && ( - - - - )} -
- - ); -}; - -/** - * Component responsible for rendering TechDocs documentation - * - * @public - */ -export const Reader = (props: ReaderProps) => { - const { entityRef, onReady = () => {}, withSearch = true } = props; - return ( - - - - ); -}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx deleted file mode 100644 index 55637806a3..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { TechDocsReaderPage } from './TechDocsReaderPage'; -import { render, act } from '@testing-library/react'; -import { ConfigReader } from '@backstage/config'; -import { - ScmIntegrationsApi, - scmIntegrationsApiRef, -} from '@backstage/integration-react'; -import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; -import { Header } from '@backstage/core-components'; -import { - techdocsApiRef, - TechDocsApi, - techdocsStorageApiRef, - TechDocsStorageApi, -} from '../../api'; -import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - return { - ...actual, - useParams: jest.fn(), - }; -}); - -jest.mock('./TechDocsReaderPageHeader', () => { - return { - __esModule: true, - TechDocsReaderPageHeader: () =>
, - }; -}); - -const { useParams }: { useParams: jest.Mock } = - jest.requireMock('react-router-dom'); -global.scroll = jest.fn(); - -describe('', () => { - it('should render techdocs page', async () => { - useParams.mockReturnValue({ - entityRef: 'Component::backstage', - }); - - const scmIntegrationsApi: ScmIntegrationsApi = - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: {}, - }), - ); - const techdocsApi: Partial = { - getEntityMetadata: () => - Promise.resolve({ - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'backstage', - }, - }), - getTechDocsMetadata: () => - Promise.resolve({ - site_name: 'string', - site_description: 'string', - }), - }; - - const techdocsStorageApi: Partial = { - getEntityDocs: (): Promise => Promise.resolve('String'), - getBaseUrl: (): Promise => Promise.resolve('String'), - getApiOrigin: (): Promise => Promise.resolve('String'), - }; - const searchApi = { - query: () => - Promise.resolve({ - results: [], - }), - }; - const apiRegistry = TestApiRegistry.from( - [scmIntegrationsApiRef, scmIntegrationsApi], - [techdocsApiRef, techdocsApi], - [techdocsStorageApiRef, techdocsStorageApi], - [searchApiRef, searchApi], - ); - - await act(async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - expect(rendered.getByTestId('techdocs-content')).toBeInTheDocument(); - }); - }); - - it('should render techdocs page with custom header', async () => { - useParams.mockReturnValue({ - entityRef: 'Component::backstage', - }); - - const scmIntegrationsApi: ScmIntegrationsApi = - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: {}, - }), - ); - const techdocsApi: Partial = { - getEntityMetadata: () => - Promise.resolve({ - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'backstage', - }, - }), - getTechDocsMetadata: () => - Promise.resolve({ - site_name: 'string', - site_description: 'string', - }), - }; - - const techdocsStorageApi: Partial = { - getEntityDocs: (): Promise => Promise.resolve('String'), - getBaseUrl: (): Promise => Promise.resolve('String'), - getApiOrigin: (): Promise => Promise.resolve('String'), - }; - const searchApi = { - query: () => - Promise.resolve({ - results: [], - }), - }; - const apiRegistry = TestApiRegistry.from( - [scmIntegrationsApiRef, scmIntegrationsApi], - [techdocsApiRef, techdocsApi], - [techdocsStorageApiRef, techdocsStorageApi], - [searchApiRef, searchApi], - ); - - await act(async () => { - const rendered = render( - wrapInTestApp( - - - {({ techdocsMetadataValue }) => ( -
- )} - - , - ), - ); - expect(rendered.getByText('A custom header')).toBeInTheDocument(); - }); - }); -}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx deleted file mode 100644 index e51f5272eb..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useCallback, useState } from 'react'; -import { useOutlet } from 'react-router'; -import { useParams } from 'react-router-dom'; -import useAsync, { AsyncState } from 'react-use/lib/useAsync'; -import { techdocsApiRef } from '../../api'; -import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; -import { CompoundEntityRef } from '@backstage/catalog-model'; -import { getComponentData, useApi, useApp } from '@backstage/core-plugin-api'; -import { Page } from '@backstage/core-components'; -import { - TechDocsReaderPage as AddonAwareReaderPage, - TECHDOCS_ADDONS_WRAPPER_KEY, -} from '@backstage/plugin-techdocs-addons'; -import { useTechDocsReaderDom, withTechDocsReaderProvider } from './Reader'; - -/** - * Helper function that gives the children of {@link TechDocsReaderPage} access to techdocs and entity metadata - * - * @public - */ -export type TechDocsReaderPageRenderFunction = ({ - techdocsMetadataValue, - entityMetadataValue, - entityRef, -}: { - techdocsMetadataValue?: TechDocsMetadata | undefined; - entityMetadataValue?: TechDocsEntityMetadata | undefined; - entityRef: CompoundEntityRef; - onReady: () => void; -}) => JSX.Element; - -type SpecialReaderPageProps = { - entityName: CompoundEntityRef; - asyncEntityMetadata: AsyncState; - asyncTechDocsMetadata: AsyncState; -}; - -const SpecialReaderPage = (props: SpecialReaderPageProps) => { - const dom = useTechDocsReaderDom(props.entityName); - - return ( - - ); -}; - -/** - * Props for {@link TechDocsReaderPage} - * - * @public - */ -export type TechDocsReaderPageProps = { - children?: TechDocsReaderPageRenderFunction | React.ReactNode; -}; - -export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { - const { children } = props; - const { NotFoundErrorPage } = useApp().getComponents(); - const outlet = useOutlet(); - - const [documentReady, setDocumentReady] = useState(false); - const { namespace, kind, name } = useParams(); - - const techdocsApi = useApi(techdocsApiRef); - - const asyncTechDocsMetadata = useAsync(() => { - if (documentReady) { - return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); - } - - return Promise.resolve(undefined); - }, [kind, namespace, name, techdocsApi, documentReady]); - - const asyncEntityMetadata = useAsync(() => { - return techdocsApi.getEntityMetadata({ kind, namespace, name }); - }, [kind, namespace, name, techdocsApi]); - - const onReady = useCallback(() => { - setDocumentReady(true); - }, [setDocumentReady]); - - if (asyncEntityMetadata.error) return ; - - if (!children) { - if (outlet) { - // If the outlet is a single child and that child is an instance of the - // TechDocsAddons registry, then render it a certain way. - if ( - getComponentData(outlet.props.children, TECHDOCS_ADDONS_WRAPPER_KEY) - ) { - const Component = withTechDocsReaderProvider(SpecialReaderPage, { - kind, - namespace, - name, - }); - return ( - - ); - } - // Otherwise, just return the outlet (legacy-style composability). - return outlet; - } - } - - return ( - - {children instanceof Function - ? children({ - techdocsMetadataValue: asyncTechDocsMetadata.value, - entityMetadataValue: asyncEntityMetadata.value, - entityRef: { kind, namespace, name }, - onReady, - }) - : children} - - ); -}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx new file mode 100644 index 0000000000..f49f7b74c1 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { CompoundEntityRef } from '@backstage/catalog-model'; + +import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; +import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; +import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; + +import { TechDocsReaderPageRenderFunction } from '../../../types'; + +import { + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from './context'; + +export type TechDocsReaderLayoutProps = { + hideHeader?: boolean; +}; + +export const TechDocsReaderLayout = ({ + hideHeader = false, +}: TechDocsReaderLayoutProps) => ( + <> + {!hideHeader && } + + + +); + +/** + * @public + */ +export type TechDocsReaderPageProps = { + path?: string; + entityName?: CompoundEntityRef; + children?: TechDocsReaderPageRenderFunction | ReactNode; +}; + +/** + * An addon-aware implementation of the TechDocsReaderPage. + * @public + */ +export const TechDocsReaderPage = ({ + path: defaultPath, + entityName: defaultEntityName, + children = , +}: TechDocsReaderPageProps) => { + const params = useParams(); + + const path = useMemo(() => { + if (defaultPath) { + return defaultPath; + } + return params['*'] ?? ''; + }, [params, defaultPath]); + + const entityName = useMemo(() => { + if (defaultEntityName) { + return defaultEntityName; + } + return { + kind: params.kind, + name: params.name, + namespace: params.namespace, + }; + }, [params, defaultEntityName]); + + return ( + + + + {children} + + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx new file mode 100644 index 0000000000..921c2efd7d --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { TechDocsMetadata } from './types'; +import { + useEntityMetadata, + useTechDocsMetadata, + useTechDocsReaderPage, + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from './context'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +const mockEntity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { name: 'test-component', namespace: 'default' }, +}; + +const mockTechDocsMetadata: TechDocsMetadata = { + site_name: 'test-componnet', + site_description: 'this is a test component', +}; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const wrapper = ({ + entityName = { + namespace: mockEntity.metadata.namespace!!, + kind: mockEntity.kind, + name: mockEntity.metadata.name, + }, + children, +}: { + entityName: CompoundEntityRef; + children: React.ReactNode; +}) => ( + + + + {children} + + + +); + +describe('context', () => { + describe('useEntityMetadata', () => { + it('should return loading state', async () => { + const { result } = renderHook(() => useEntityMetadata()); + + await expect(result.current.loading).toEqual(true); + }); + + it('should return expected entity values', async () => { + const { result } = renderHook(() => useEntityMetadata(), { wrapper }); + + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockEntity); + }); + }); + + describe('useTechDocsMetadata', () => { + it('should return loading state', async () => { + const { result } = renderHook(() => useTechDocsMetadata()); + + await expect(result.current.loading).toEqual(true); + }); + + it('should return expected techdocs metadata values', async () => { + const { result } = renderHook(() => useTechDocsMetadata(), { wrapper }); + + expect(result.current.value).toBeDefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toMatchObject(mockTechDocsMetadata); + }); + }); + + describe('useTechDocsReaderPage', () => { + it('should set title', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.title).toBe(''); + + act(() => result.current.setTitle('test site title')); + expect(result.current.title).toBe('test site title'); + }); + + it('should set subtitle', () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + expect(result.current.subtitle).toBe(''); + + act(() => result.current.setSubtitle('test site subtitle')); + expect(result.current.subtitle).toBe('test site subtitle'); + }); + + it('should set shadow root', async () => { + const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + + // mock shadowroot + const shadowRoot = mockShadowRoot(); + + act(() => result.current.setShadowRoot(shadowRoot)); + + expect(result.current.shadowRoot?.innerHTML).toBe( + '

Shadow DOM Mock

', + ); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx new file mode 100644 index 0000000000..0c580d17cd --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -0,0 +1,175 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from 'react'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; + +import { Page } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { CompoundEntityRef } from '@backstage/catalog-model'; + +import { techdocsApiRef } from '../../../api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from '../../../types'; + +type PropsWithEntityName = PropsWithChildren< + T & { entityName: CompoundEntityRef } +>; + +const initialContextValue = { + loading: true, + error: undefined, + value: undefined, +}; + +const TechDocsMetadataContext = + createContext>(initialContextValue); + +export const TechDocsMetadataProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const techdocsApi = useApi(techdocsApiRef); + + const value = useAsync(async () => { + return techdocsApi.getTechDocsMetadata(entityName); + }, [entityName]); + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the + * current TechDocs site. + * @public + */ +export const useTechDocsMetadata = () => { + return useContext(TechDocsMetadataContext); +}; + +const TechDocsEntityContext = + createContext>(initialContextValue); + +export const TechDocsEntityProvider = ({ + entityName, + children, +}: PropsWithEntityName) => { + const techdocsApi = useApi(techdocsApiRef); + + const value = useAsync(async () => { + return techdocsApi.getEntityMetadata(entityName); + }, [entityName]); + + return ( + + {children} + + ); +}; + +/** + * Hook for use within TechDocs addons to retrieve Entity Metadata for the + * current TechDocs site. + * @public + */ +export const useEntityMetadata = () => { + return useContext(TechDocsEntityContext); +}; + +export type TechDocsReaderPageValue = { + path: string; + entityName: CompoundEntityRef; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; +}; + +export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { + path: '', + title: '', + setTitle: () => {}, + subtitle: '', + setSubtitle: () => {}, + setShadowRoot: () => {}, + entityName: { kind: '', name: '', namespace: '' }, +}; + +export const TechDocsReaderPageContext = createContext( + defaultTechDocsReaderPageValue, +); + +export const useTechDocsReaderPage = () => { + return useContext(TechDocsReaderPageContext); +}; + +type TechDocsReaderPageProviderProps = PropsWithEntityName<{ + path: string; +}>; + +export const TechDocsReaderPageProvider = ({ + path, + entityName, + children, +}: TechDocsReaderPageProviderProps) => { + const metadata = useTechDocsMetadata(); + const entityMetadata = useEntityMetadata(); + + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + path, + entityName, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + + return ( + + + {children instanceof Function + ? children({ + entityRef: entityName, + techdocsMetadataValue: metadata.value, + entityMetadataValue: entityMetadata.value, + }) + : children} + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts new file mode 100644 index 0000000000..c85d3a3d11 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useShadowRoot, useShadowRootElements } from './hooks'; +import { renderHook } from '@testing-library/react-hooks'; + +const mockShadowRoot = () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '

Shadow DOM Mock

'; + return shadowRoot; +}; + +const shadowRoot = mockShadowRoot(); + +jest.mock('./context', () => { + return { + useTechDocsReaderPage: () => ({ shadowRoot }), + }; +}); + +describe('hooks', () => { + describe('useShadowRoot', () => { + it('should return shadow root', async () => { + const { result } = renderHook(() => useShadowRoot()); + + expect(result.current?.innerHTML).toBe(shadowRoot.innerHTML); + }); + }); + + describe('useShadowRootElements', () => { + it('should return shadow root elements based on selector', () => { + const { result } = renderHook(() => useShadowRootElements(['h1'])); + + expect(result.current).toHaveLength(1); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts new file mode 100644 index 0000000000..7bc6152006 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useTechDocsReaderPage } from './context'; + +/** + * Hook for use within TechDocs addons that provides access to the underlying + * shadow root of the current page, allowing the DOM within to be mutated. + * @public + */ +export const useShadowRoot = () => { + const { shadowRoot } = useTechDocsReaderPage(); + return shadowRoot; +}; + +/** + * Convenience hook for use within TechDocs addons that provides access to + * elements that match a given selector within the shadow root. + * + * todo(backstage/techdocs-core): Consider extending `selectors` from string[] + * to some kind of typed object array, so users have more control over the + * shape of the result. e.g. a flag to indicate querySelector vs. + * querySelectorAll. + * + * @public + */ +export const useShadowRootElements = < + TReturnedElement extends HTMLElement = HTMLElement, +>( + selectors: string[], +): TReturnedElement[] => { + const shadowRoot = useShadowRoot(); + if (!shadowRoot) return []; + return selectors + .map(selector => shadowRoot?.querySelectorAll(selector)) + .filter(nodeList => nodeList.length) + .map(nodeList => Array.from(nodeList)) + .flat(); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts new file mode 100644 index 0000000000..10749dedeb --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPage, TechDocsReaderLayout } from './TechDocsReaderPage'; +export type { + TechDocsReaderPageProps, + TechDocsReaderLayoutProps, +} from './TechDocsReaderPage'; +export * from './context'; +export * from './hooks'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx new file mode 100644 index 0000000000..0feeb65e12 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -0,0 +1,136 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { create } from 'jss'; + +import { makeStyles, Grid, Portal } from '@material-ui/core'; +import { StylesProvider, jssPreset } from '@material-ui/styles'; + +import { + useTechDocsAddons, + TechDocsAddonLocations as locations, +} from '@backstage/techdocs-addons'; +import { Content, Progress } from '@backstage/core-components'; + +import { TechDocsSearch } from '../../../search'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; +import { TechDocsStateIndicator } from '../TechDocsStateIndicator'; +import { useTechDocsReaderDom, withTechDocsReaderProvider } from './context'; + +const useStyles = makeStyles({ + search: { + width: '100%', + '@media (min-width: 76.1875em)': { + width: 'calc(100% - 34.4rem)', + margin: '0 auto', + }, + }, +}); + +export type TechDocsReaderPageContentProps = { + withSearch?: boolean; +}; + +export const TechDocsReaderPageContent = withTechDocsReaderProvider( + ({ withSearch = true }: TechDocsReaderPageContentProps) => { + const classes = useStyles(); + const addons = useTechDocsAddons(); + const page = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(page.entityName); + + const ref = useRef(null); + const [jss, setJss] = useState( + create({ + ...jssPreset(), + insertionPoint: undefined, + }), + ); + + useEffect(() => { + const shadowHost = ref.current; + if (!dom || !shadowHost) return; + + setJss( + create({ + ...jssPreset(), + insertionPoint: dom.querySelector('head') || undefined, + }), + ); + + const shadowRoot = + shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + shadowRoot.appendChild(dom); + page.setShadowRoot(shadowRoot); + }, [dom, page]); + + const contentElement = ref.current?.shadowRoot?.querySelector( + '[data-md-component="container"]', + ); + const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', + ); + const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( + 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', + ); + + const primarySidebarAddonLocation = document.createElement('div'); + primarySidebarElement?.prepend(primarySidebarAddonLocation); + + const secondarySidebarAddonLocation = document.createElement('div'); + secondarySidebarElement?.prepend(secondarySidebarAddonLocation); + + // do not return content until dom is ready + if (!dom) { + return ( + + + + ); + } + + return ( + + + + + + {withSearch && ( + + + + )} + + {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} + +
+ + {addons.renderComponentsByLocation(locations.PRIMARY_SIDEBAR)} + + + {addons.renderComponentsByLocation(locations.CONTENT)} + + + {addons.renderComponentsByLocation(locations.SECONDARY_SIDEBAR)} + + + + + + ); + }, +); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx new file mode 100644 index 0000000000..007b1fcc65 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -0,0 +1,856 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + FC, + ComponentType, + createContext, + useContext, + useCallback, + useEffect, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useTheme, Theme, lighten, alpha } from '@material-ui/core'; + +import { BackstageTheme } from '@backstage/theme'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { useApi, configApiRef } from '@backstage/core-plugin-api'; +import { SidebarPinStateContext } from '@backstage/core-components'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; + +import { techdocsStorageApiRef } from '../../../api'; + +import { + addBaseUrl, + addGitFeedbackLink, + addLinkClickListener, + addSidebarToggle, + injectCss, + onCssReady, + removeMkdocsHeader, + rewriteDocLinks, + sanitizeDOM, + simplifyMkdocsFooter, + scrollIntoAnchor, + transform as transformer, + copyToClipboard, +} from '../../transformers'; + +import { useReaderState } from '../useReaderState'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; + +/** + * Props for {@link Reader} + * + * @public + */ +export type ReaderProps = { + entityRef: CompoundEntityRef; + withSearch?: boolean; + onReady?: () => void; +}; + +type TechDocsReaderValue = ReturnType; + +const TechDocsReaderContext = createContext( + {} as TechDocsReaderValue, +); + +export const TechDocsReaderProvider: FC = ({ children }) => { + const { path, entityName } = useTechDocsReaderPage(); + const { kind, namespace, name } = entityName; + const value = useReaderState(kind, namespace, name, path); + return ( + + {children} + + ); +}; + +/** + * Note: this HOC is currently being exported so that we can rapidly + * iterate on alternative implementations that extend core + * functionality. There is no guarantee that this HOC will continue to be + * exported by the package in the future! + * + * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") + * @internal + */ +export const withTechDocsReaderProvider = + (Component: ComponentType) => + (props: T) => + ( + + + + ); + +/** + * Note: this hook is currently being exported so that we can rapidly + * iterate on alternative implementations that extend core + * functionality. There is no guarantee that this hook will continue to be + * exported by the package in the future! + * + * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") + * @internal + */ +export const useTechDocsReader = () => useContext(TechDocsReaderContext); + +type TypographyHeadings = Pick< + Theme['typography'], + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +>; + +type TypographyHeadingsKeys = keyof TypographyHeadings; + +const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + +/** + * Hook that encapsulates the behavior of getting raw HTML and applying + * transforms to it in order to make it function at a basic level in the + * Backstage UI. + * + * Note: this hook is currently being exported so that we can rapidly iterate + * on alternative implementations that extend core functionality. + * There is no guarantee that this hook will continue to be exported by the + * package in the future! + * + * todo: Make public or stop exporting (see others: "altReaderExperiments") + * @internal + */ +export const useTechDocsReaderDom = ( + entityRef: CompoundEntityRef, +): Element | null => { + const navigate = useNavigate(); + const theme = useTheme(); + const techdocsStorageApi = useApi(techdocsStorageApiRef); + const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const techdocsSanitizer = useApi(configApiRef); + const { namespace = '', kind = '', name = '' } = entityRef; + const { state, path, content: rawPage } = useTechDocsReader(); + const isDarkTheme = theme.palette.type === 'dark'; + + const [sidebars, setSidebars] = useState(); + const [dom, setDom] = useState(null); + + // sidebar pinned status to be used in computing CSS style injections + const { isPinned } = useContext(SidebarPinStateContext); + + const updateSidebarPosition = useCallback(() => { + if (!dom || !sidebars) return; + // set sidebar height so they don't initially render in wrong position + const mdTabs = dom.querySelector('.md-container > .md-tabs'); + const sidebarsCollapsed = window.matchMedia( + 'screen and (max-width: 76.1875em)', + ).matches; + const newTop = Math.max(dom.getBoundingClientRect().top, 0); + sidebars.forEach(sidebar => { + if (sidebarsCollapsed) { + sidebar.style.top = '0px'; + } else if (mdTabs) { + sidebar.style.top = `${ + newTop + mdTabs.getBoundingClientRect().height + }px`; + } else { + sidebar.style.top = `${newTop}px`; + } + }); + }, [dom, sidebars]); + + useEffect(() => { + updateSidebarPosition(); + window.addEventListener('scroll', updateSidebarPosition, true); + window.addEventListener('resize', updateSidebarPosition); + return () => { + window.removeEventListener('scroll', updateSidebarPosition, true); + window.removeEventListener('resize', updateSidebarPosition); + }; + // an update to "state" might lead to an updated UI so we include it as a trigger + }, [updateSidebarPosition, state]); + + // dynamically set width of footer to accommodate for pinning of the sidebar + const updateFooterWidth = useCallback(() => { + if (!dom) return; + const footer = dom.querySelector('.md-footer') as HTMLElement; + if (footer) { + footer.style.width = `${dom.getBoundingClientRect().width}px`; + } + }, [dom]); + + useEffect(() => { + updateFooterWidth(); + window.addEventListener('resize', updateFooterWidth); + return () => { + window.removeEventListener('resize', updateFooterWidth); + }; + }); + + // a function that performs transformations that are executed prior to adding it to the DOM + const preRender = useCallback( + (rawContent: string, contentPath: string) => + transformer(rawContent, [ + sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), + addBaseUrl({ + techdocsStorageApi, + entityId: { + kind, + name, + namespace, + }, + path: contentPath, + }), + rewriteDocLinks(), + addSidebarToggle(), + removeMkdocsHeader(), + simplifyMkdocsFooter(), + addGitFeedbackLink(scmIntegrationsApi), + injectCss({ + // Variables + css: ` + /* + As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host. + As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them. + */ + :host { + /* FONT */ + --md-default-fg-color: ${theme.palette.text.primary}; + --md-default-fg-color--light: ${theme.palette.text.secondary}; + --md-default-fg-color--lighter: ${lighten( + theme.palette.text.secondary, + 0.7, + )}; + --md-default-fg-color--lightest: ${lighten( + theme.palette.text.secondary, + 0.3, + )}; + + /* BACKGROUND */ + --md-default-bg-color:${theme.palette.background.default}; + --md-default-bg-color--light: ${theme.palette.background.paper}; + --md-default-bg-color--lighter: ${lighten( + theme.palette.background.paper, + 0.7, + )}; + --md-default-bg-color--lightest: ${lighten( + theme.palette.background.paper, + 0.3, + )}; + + /* PRIMARY */ + --md-primary-fg-color: ${theme.palette.primary.main}; + --md-primary-fg-color--light: ${theme.palette.primary.light}; + --md-primary-fg-color--dark: ${theme.palette.primary.dark}; + --md-primary-bg-color: ${theme.palette.primary.contrastText}; + --md-primary-bg-color--light: ${lighten( + theme.palette.primary.contrastText, + 0.7, + )}; + + /* ACCENT */ + --md-accent-fg-color: var(--md-primary-fg-color); + + /* SHADOW */ + --md-shadow-z1: ${theme.shadows[1]}; + --md-shadow-z2: ${theme.shadows[2]}; + --md-shadow-z3: ${theme.shadows[3]}; + + /* EXTENSIONS */ + --md-admonition-fg-color: var(--md-default-fg-color); + --md-admonition-bg-color: var(--md-default-bg-color); + /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */ + --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); + --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,'); + --md-toc-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-version-icon: url('data:image/svg+xml;charset=utf-8,'); + } + + :host > * { + /* CODE */ + --md-code-fg-color: ${theme.palette.text.primary}; + --md-code-bg-color: ${theme.palette.background.paper}; + --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; + --md-code-hl-keyword-color: ${ + isDarkTheme + ? theme.palette.primary.light + : theme.palette.primary.dark + }; + --md-code-hl-function-color: ${ + isDarkTheme + ? theme.palette.secondary.light + : theme.palette.secondary.dark + }; + --md-code-hl-string-color: ${ + isDarkTheme + ? theme.palette.success.light + : theme.palette.success.dark + }; + --md-code-hl-number-color: ${ + isDarkTheme + ? theme.palette.error.light + : theme.palette.error.dark + }; + --md-code-hl-constant-color: var(--md-code-hl-function-color); + --md-code-hl-special-color: var(--md-code-hl-function-color); + --md-code-hl-name-color: var(--md-code-fg-color); + --md-code-hl-comment-color: var(--md-default-fg-color--light); + --md-code-hl-generic-color: var(--md-default-fg-color--light); + --md-code-hl-variable-color: var(--md-default-fg-color--light); + --md-code-hl-operator-color: var(--md-default-fg-color--light); + --md-code-hl-punctuation-color: var(--md-default-fg-color--light); + + /* TYPESET */ + --md-typeset-font-size: 1rem; + --md-typeset-color: var(--md-default-fg-color); + --md-typeset-a-color: var(--md-accent-fg-color); + --md-typeset-table-color: ${theme.palette.text.primary}; + --md-typeset-del-color: ${ + isDarkTheme + ? alpha(theme.palette.error.dark, 0.5) + : alpha(theme.palette.error.light, 0.5) + }; + --md-typeset-ins-color: ${ + isDarkTheme + ? alpha(theme.palette.success.dark, 0.5) + : alpha(theme.palette.success.light, 0.5) + }; + --md-typeset-mark-color: ${ + isDarkTheme + ? alpha(theme.palette.warning.dark, 0.5) + : alpha(theme.palette.warning.light, 0.5) + }; + } + + @media screen and (max-width: 76.1875em) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .9rem; + } + } + + @media screen and (max-width: 600px) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .7rem; + } + } + `, + }), + injectCss({ + // Reset + css: ` + body { + --md-text-color: var(--md-default-fg-color); + --md-text-link-color: var(--md-accent-fg-color); + --md-text-font-family: ${theme.typography.fontFamily}; + font-family: var(--md-text-font-family); + background-color: unset; + } + `, + }), + injectCss({ + // Layout + css: ` + .md-grid { + max-width: 100%; + margin: 0; + } + + .md-nav { + font-size: calc(var(--md-typeset-font-size) * 0.9); + } + .md-nav__link { + display: flex; + align-items: center; + justify-content: space-between; + } + .md-nav__icon { + height: 20px !important; + width: 20px !important; + margin-left:${theme.spacing(1)}px; + } + .md-nav__icon svg { + margin: 0; + width: 20px !important; + height: 20px !important; + } + .md-nav__icon:after { + width: 20px !important; + height: 20px !important; + } + + .md-main__inner { + margin-top: 0; + } + + .md-sidebar { + bottom: 75px; + position: fixed; + width: 16rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-color: rgb(193, 193, 193) #eee; + scrollbar-width: thin; + } + .md-sidebar .md-sidebar__scrollwrap { + width: calc(16rem - 10px); + } + .md-sidebar--secondary { + right: ${theme.spacing(3)}px; + } + .md-sidebar::-webkit-scrollbar { + width: 5px; + } + .md-sidebar::-webkit-scrollbar-button { + width: 5px; + height: 5px; + } + .md-sidebar::-webkit-scrollbar-track { + background: #eee; + border: 1 px solid rgb(250, 250, 250); + box-shadow: 0px 0px 3px #dfdfdf inset; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb { + width: 5px; + background: rgb(193, 193, 193); + border: transparent; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb:hover { + background: rgb(125, 125, 125); + } + + .md-content { + max-width: calc(100% - 16rem * 2); + margin-left: 16rem; + margin-bottom: 50px; + } + + .md-footer { + position: fixed; + bottom: 0px; + } + .md-footer__title { + background-color: unset; + } + .md-footer-nav__link { + width: 16rem; + } + + .md-dialog { + background-color: unset; + } + + @media screen and (min-width: 76.25em) { + .md-sidebar { + height: auto; + } + } + + @media screen and (max-width: 76.1875em) { + .md-nav { + transition: none !important; + background-color: var(--md-default-bg-color) + } + .md-nav--primary .md-nav__title { + cursor: auto; + color: var(--md-default-fg-color); + font-weight: 700; + white-space: normal; + line-height: 1rem; + height: auto; + display: flex; + flex-flow: column; + row-gap: 1.6rem; + padding: 1.2rem .8rem .8rem; + background-color: var(--md-default-bg-color); + } + .md-nav--primary .md-nav__title~.md-nav__list { + box-shadow: none; + } + .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child { + border-top: none; + } + .md-nav--primary .md-nav__title .md-nav__button { + display: none; + } + .md-nav--primary .md-nav__title .md-nav__icon { + color: var(--md-default-fg-color); + position: static; + height: auto; + margin: 0 0 0 -0.2rem; + } + .md-nav--primary > .md-nav__title [for="none"] { + padding-top: 0; + } + .md-nav--primary .md-nav__item { + border-top: none; + } + .md-nav--primary :is(.md-nav__title,.md-nav__item) { + font-size : var(--md-typeset-font-size); + } + .md-nav .md-source { + display: none; + } + + .md-sidebar { + height: 100%; + } + .md-sidebar--primary { + width: 12.1rem !important; + z-index: 200; + left: ${ + isPinned + ? 'calc(-12.1rem + 242px)' + : 'calc(-12.1rem + 72px)' + } !important; + } + .md-sidebar--secondary:not([hidden]) { + display: none; + } + + .md-content { + max-width: 100%; + margin-left: 0; + } + + .md-header__button { + margin: 0.4rem 0; + margin-left: 0.4rem; + padding: 0; + } + + .md-overlay { + left: 0; + } + + .md-footer { + position: static; + padding-left: 0; + } + .md-footer-nav__link { + /* footer links begin to overlap at small sizes without setting width */ + width: 50%; + } + } + + @media screen and (max-width: 600px) { + .md-sidebar--primary { + left: -12.1rem !important; + width: 12.1rem; + } + } + `, + }), + injectCss({ + // Typeset + css: ` + .md-typeset { + font-size: var(--md-typeset-font-size); + } + + ${headings.reduce((style, heading) => { + const styles = theme.typography[heading]; + const { lineHeight, fontFamily, fontWeight, fontSize } = styles; + const calculate = (value: typeof fontSize) => { + let factor: number | string = 1; + if (typeof value === 'number') { + // 60% of the size defined because it is too big + factor = (value / 16) * 0.6; + } + if (typeof value === 'string') { + factor = value.replace('rem', ''); + } + return `calc(${factor} * var(--md-typeset-font-size))`; + }; + return style.concat(` + .md-typeset ${heading} { + color: var(--md-default-fg-color); + line-height: ${lineHeight}; + font-family: ${fontFamily}; + font-weight: ${fontWeight}; + font-size: ${calculate(fontSize)}; + } + `); + }, '')} + + .md-typeset .md-content__button { + color: var(--md-default-fg-color); + } + + .md-typeset hr { + border-bottom: 0.05rem dotted ${theme.palette.divider}; + } + + .md-typeset details { + font-size: var(--md-typeset-font-size) !important; + } + .md-typeset details summary { + padding-left: 2.5rem !important; + } + .md-typeset details summary:before, + .md-typeset details summary:after { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: rotate(0deg) translateY(-50%) !important; + } + .md-typeset details[open] > summary:after { + transform: rotate(90deg) translateX(-50%) !important; + } + + .md-typeset blockquote { + color: var(--md-default-fg-color--light); + border-left: 0.2rem solid var(--md-default-fg-color--light); + } + + .md-typeset table:not([class]) { + font-size: var(--md-typeset-font-size); + border: 1px solid var(--md-default-fg-color); + border-bottom: none; + border-collapse: collapse; + } + .md-typeset table:not([class]) th { + font-weight: bold; + } + .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { + border-bottom: 1px solid var(--md-default-fg-color); + } + + .md-typeset pre > code::-webkit-scrollbar-thumb { + background-color: hsla(0, 0%, 0%, 0.32); + } + .md-typeset pre > code::-webkit-scrollbar-thumb:hover { + background-color: hsla(0, 0%, 0%, 0.87); + } + `, + }), + injectCss({ + // Animations + css: ` + /* + Disable CSS animations on link colors as they lead to issues in dark mode. + The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages. + */ + .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, + }), + injectCss({ + // Extensions + css: ` + /* HIGHLIGHT */ + .highlight .md-clipboard:after { + content: unset; + } + + .highlight .nx { + color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; + } + + /* CODE HILITE */ + .codehilite .gd { + background-color: ${ + isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' + }; + } + + .codehilite .gi { + background-color: ${ + isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' + }; + } + + /* TABBED */ + .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1), + .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2), + .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3), + .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4), + .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5), + .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6), + .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7), + .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8), + .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9), + .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10), + .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11), + .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12), + .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13), + .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14), + .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15), + .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16), + .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17), + .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18), + .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19), + .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) { + color: var(--md-accent-fg-color); + border-color: var(--md-accent-fg-color); + } + + /* TASK-LIST */ + .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + + /* ADMONITION */ + .admonition { + font-size: var(--md-typeset-font-size) !important; + } + .admonition .admonition-title { + padding-left: 2.5rem !important; + } + + .admonition .admonition-title:before { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: translateY(-50%) !important; + } + `, + }), + ]), + [ + kind, + name, + namespace, + scmIntegrationsApi, + techdocsSanitizer, + techdocsStorageApi, + theme, + isDarkTheme, + isPinned, + ], + ); + + // a function that performs transformations that are executed after adding it to the DOM + const postRender = useCallback( + async (transformedElement: Element) => + transformer(transformedElement, [ + scrollIntoAnchor(), + copyToClipboard(theme), + addLinkClickListener({ + baseUrl: window.location.origin, + onClick: (event: MouseEvent, url: string) => { + // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open` + const modifierActive = event.ctrlKey || event.metaKey; + const parsedUrl = new URL(url); + + // hash exists when anchor is clicked on secondary sidebar + if (parsedUrl.hash) { + if (modifierActive) { + window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank'); + } else { + navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + // Scroll to hash if it's on the current page + transformedElement + ?.querySelector(`#${parsedUrl.hash.slice(1)}`) + ?.scrollIntoView(); + } + } else { + if (modifierActive) { + window.open(parsedUrl.pathname, '_blank'); + } else { + navigate(parsedUrl.pathname); + // Scroll to top of reader if primary sidebar link is clicked + transformedElement + ?.querySelector('.md-content__inner') + ?.scrollIntoView(); + } + } + }, + }), + onCssReady({ + docStorageUrl: await techdocsStorageApi.getApiOrigin(), + onLoading: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.setProperty('opacity', '0'); + }, + onLoaded: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.removeProperty('opacity'); + // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) + renderedElement + .querySelector('.md-nav__title') + ?.removeAttribute('for'); + setSidebars( + Array.from(renderedElement.querySelectorAll('.md-sidebar')), + ); + }, + }), + ]), + [theme, navigate, techdocsStorageApi], + ); + + useEffect(() => { + if (!rawPage) return () => {}; + + // if false, there is already a newer execution of this effect + let shouldReplaceContent = true; + + // Pre-render + preRender(rawPage, path).then(async preTransformedDomElement => { + if (!preTransformedDomElement?.innerHTML) { + return; // An unexpected error occurred + } + + // don't manipulate the shadow dom if this isn't the latest effect execution + if (!shouldReplaceContent) { + return; + } + + // Scroll to top after render + window.scroll({ top: 0 }); + + // Post-render + const postTransformedDomElement = await postRender( + preTransformedDomElement, + ); + setDom(postTransformedDomElement as HTMLElement); + }); + + // cancel this execution + return () => { + shouldReplaceContent = false; + }; + }, [rawPage, path, preRender, postRender]); + + return dom; +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts new file mode 100644 index 0000000000..d3da3d6aa4 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; +export * from './context'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx similarity index 100% rename from plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.test.tsx rename to plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx similarity index 56% rename from plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx rename to plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index f6a3352e7d..b1ca6ce50a 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2022 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,45 +14,62 @@ * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; +import React, { FC, useEffect } from 'react'; +import Helmet from 'react-helmet'; + +import { Skeleton } from '@material-ui/lab'; import CodeIcon from '@material-ui/icons/Code'; -import { useRouteRef } from '@backstage/core-plugin-api'; -import { Header, HeaderLabel } from '@backstage/core-components'; -import { CompoundEntityRef, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + TechDocsAddonLocations as locations, + useTechDocsAddons, +} from '@backstage/techdocs-addons'; import { EntityRefLink, EntityRefLinks, getEntityRelations, } from '@backstage/plugin-catalog-react'; +import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Header, HeaderLabel } from '@backstage/core-components'; +import { useRouteRef, configApiRef, useApi } from '@backstage/core-plugin-api'; -import { rootRouteRef } from '../../routes'; -import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; +import { + useTechDocsReaderPage, + useTechDocsMetadata, + useEntityMetadata, +} from '../TechDocsReaderPage'; -/** - * Props for {@link TechDocsReaderPageHeader} - * - * @public - */ -export type TechDocsReaderPageHeaderProps = PropsWithChildren<{ - entityRef: CompoundEntityRef; - entityMetadata?: TechDocsEntityMetadata; - techDocsMetadata?: TechDocsMetadata; -}>; +import { rootRouteRef } from '../../../routes'; -/** - * Component responsible for rendering a Header with metadata on TechDocs reader page. - * - * @public - */ -export const TechDocsReaderPageHeader = ( - props: TechDocsReaderPageHeaderProps, -) => { - const { entityRef, entityMetadata, techDocsMetadata, children } = props; - const { name } = entityRef; +const skeleton = ; - const { site_name: siteName, site_description: siteDescription } = - techDocsMetadata || {}; +export const TechDocsReaderPageHeader: FC = props => { + const { children } = props; + const addons = useTechDocsAddons(); + const configApi = useApi(configApiRef); + + const { value: techDocsMetadata } = useTechDocsMetadata(); + const { value: entityMetadata } = useEntityMetadata(); + + const { + title, + setTitle, + subtitle, + setSubtitle, + entityName: entityRef, + } = useTechDocsReaderPage(); + + useEffect(() => { + if (!techDocsMetadata) return; + setTitle(prevTitle => prevTitle || techDocsMetadata.site_name); + setSubtitle( + prevSubtitle => + prevSubtitle || techDocsMetadata.site_description || 'Home', + ); + }, [techDocsMetadata, setTitle, setSubtitle]); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); const { locationMetadata, spec } = entityMetadata || {}; const lifecycle = spec?.lifecycle; @@ -109,16 +126,17 @@ export const TechDocsReaderPageHeader = ( return (
+ + {tabTitle} + {labels} {children} + {addons.renderComponentsByLocation(locations.HEADER)}
); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts new file mode 100644 index 0000000000..741a8e9af1 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx new file mode 100644 index 0000000000..45a89d0c84 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Box, Toolbar, ToolbarProps, withStyles } from '@material-ui/core'; + +import { + TechDocsAddonLocations as locations, + useTechDocsAddons, +} from '@backstage/techdocs-addons'; + +export const TechDocsReaderPageSubheader = withStyles(theme => ({ + root: { + gridArea: 'pageSubheader', + flexDirection: 'column', + minHeight: 'auto', + padding: theme.spacing(3, 3, 0), + }, +}))(({ ...rest }: ToolbarProps) => { + const addons = useTechDocsAddons(); + + if (!addons.renderComponentsByLocation(locations.SUBHEADER)) return null; + + return ( + + {addons.renderComponentsByLocation(locations.SUBHEADER) && ( + + {addons.renderComponentsByLocation(locations.SUBHEADER)} + + )} + + ); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts new file mode 100644 index 0000000000..78f270e191 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TechDocsReaderPageSubheader } from './TechDocsReaderPageSubheader'; diff --git a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx index 417dd5727c..93694280d7 100644 --- a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx @@ -21,7 +21,7 @@ import { Alert } from '@material-ui/lab'; import { TechDocsBuildLogs } from './TechDocsBuildLogs'; import { TechDocsNotFound } from './TechDocsNotFound'; -import { useTechDocsReader } from './Reader'; +import { useTechDocsReader } from './TechDocsReaderPageContent'; const useStyles = makeStyles(theme => ({ root: { diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts index 8e660767ad..f6294200a3 100644 --- a/plugins/techdocs/src/reader/components/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -14,23 +14,18 @@ * limitations under the License. */ -export * from './Reader'; export type { TechDocsReaderPageProps, - TechDocsReaderPageRenderFunction, + TechDocsReaderLayoutProps, +} from './TechDocsReaderPage'; +export { + TechDocsReaderLayout, + useTechDocsMetadata, + useEntityMetadata, + useTechDocsReaderPage, + useShadowRoot, + useShadowRootElements, } from './TechDocsReaderPage'; export * from './TechDocsReaderPageHeader'; +export * from './TechDocsReaderPageContent'; export * from './TechDocsStateIndicator'; - -/** - * Note: this component is currently being exported so that we can rapidly - * iterate on alternative implementations that extend core - * functionality. There is no guarantee that this component will continue to be - * exported by the package in the future! - * - * Why is this comment here instead of above the component itself? It's a - * workaround for some kind of bug in @microsoft/api-extractor. - * - * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") - * @internal - */ diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index ee6b88756c..20f78c01f3 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -14,7 +14,22 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +/** + * Helper function that gives the children of {@link TechDocsReaderPage} access to techdocs and entity metadata + * + * @public + */ +export type TechDocsReaderPageRenderFunction = ({ + techdocsMetadataValue, + entityMetadataValue, + entityRef, +}: { + techdocsMetadataValue?: TechDocsMetadata | undefined; + entityMetadataValue?: TechDocsEntityMetadata | undefined; + entityRef: CompoundEntityRef; +}) => JSX.Element; /** * Metadata for TechDocs page From 24581a2fd3f10065deee0fe27fd42ce227f86454 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 29 Mar 2022 20:07:54 +0200 Subject: [PATCH 17/47] feat(techdocs): compose app addons Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- packages/app/package.json | 2 +- packages/app/src/App.tsx | 2 +- .../app/src/components/catalog/EntityPage.tsx | 2 +- .../src/components/techdocs/ExampleAddons.tsx | 16 ++++++++++------ .../src/components/techdocs/TechDocsPage.tsx | 17 +++-------------- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 69afcc11e0..0d94417ddc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -52,7 +52,7 @@ "@backstage/plugin-shortcuts": "^0.2.5-next.0", "@backstage/plugin-tech-radar": "^0.5.11-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", - "@backstage/plugin-techdocs-addons": "^0.0.0", + "@backstage/techdocs-addons": "^0.0.0", "@backstage/plugin-todo": "^0.2.6-next.0", "@backstage/plugin-user-settings": "^0.4.3-next.0", "@backstage/plugin-tech-insights": "^0.1.14-next.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9073af7d2e..ec03e0e5dd 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -74,7 +74,7 @@ import { UserSettingsTab, } from '@backstage/plugin-user-settings'; import { AdvancedSettings } from './components/advancedSettings'; -import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; +import { TechDocsAddons } from '@backstage/techdocs-addons'; import AlarmIcon from '@material-ui/icons/Alarm'; import React from 'react'; import { hot } from 'react-hot-loader/root'; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 534b7eacda..b3291ffa91 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -138,7 +138,7 @@ import { import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; import React, { ReactNode, useMemo, useState } from 'react'; -import { TechDocsAddons } from '@backstage/plugin-techdocs-addons'; +import { TechDocsAddons } from '@backstage/techdocs-addons'; import { ExampleContent, ExampleHeader, diff --git a/packages/app/src/components/techdocs/ExampleAddons.tsx b/packages/app/src/components/techdocs/ExampleAddons.tsx index 690ef5a36d..7b5fdba80c 100644 --- a/packages/app/src/components/techdocs/ExampleAddons.tsx +++ b/packages/app/src/components/techdocs/ExampleAddons.tsx @@ -14,15 +14,19 @@ * limitations under the License. */ -import { HeaderLabel } from '@backstage/core-components'; -import { techdocsPlugin } from '@backstage/plugin-techdocs'; +import React, { useEffect } from 'react'; + +import { Card, CardContent } from '@material-ui/core'; + +import { + techdocsPlugin, + useShadowRootElements, +} from '@backstage/plugin-techdocs'; import { createTechDocsAddon, TechDocsAddonLocations, - useShadowRootElements, -} from '@backstage/plugin-techdocs-addons'; -import { Card, CardContent } from '@material-ui/core'; -import React, { useEffect } from 'react'; +} from '@backstage/techdocs-addons'; +import { HeaderLabel } from '@backstage/core-components'; /** * Note: this is not typically how or where one might define such things. It diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx index 4f49cf38e8..e5142472f0 100644 --- a/packages/app/src/components/techdocs/TechDocsPage.tsx +++ b/packages/app/src/components/techdocs/TechDocsPage.tsx @@ -14,29 +14,18 @@ * limitations under the License. */ -import { Content } from '@backstage/core-components'; import { TechDocsReaderPageHeader, + TechDocsReaderPageContent, TechDocsReaderPage, - Reader, } from '@backstage/plugin-techdocs'; import React from 'react'; const DefaultTechDocsPage = () => { return ( - {({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => ( - <> - - - - - - )} + + ); }; From 236089c6cd04b36b350932506a128d0b9cb0862c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 29 Mar 2022 20:09:17 +0200 Subject: [PATCH 18/47] feat(techdocs): delete addons plugin Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- plugins/techdocs-addons/.eslintrc.js | 1 - plugins/techdocs-addons/README.md | 64 ----- plugins/techdocs-addons/api-report.md | 86 ------- plugins/techdocs-addons/package.json | 53 ----- plugins/techdocs-addons/src/addons.tsx | 153 ------------ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 75 ------ .../components/TechDocsReaderPage/index.ts | 18 -- .../TechDocsReaderPageContent.tsx | 98 -------- .../TechDocsReaderPageContent/index.ts | 17 -- .../TechDocsReaderPageHeader.tsx | 61 ----- .../TechDocsReaderPageHeader/index.ts | 17 -- .../TechDocsReaderPageSubheader.tsx | 49 ---- .../TechDocsReaderPageSubheader/index.ts | 17 -- .../techdocs-addons/src/components/index.ts | 17 -- plugins/techdocs-addons/src/context.test.tsx | 146 ------------ plugins/techdocs-addons/src/context.tsx | 148 ------------ plugins/techdocs-addons/src/hooks.test.ts | 51 ---- plugins/techdocs-addons/src/hooks.ts | 51 ---- plugins/techdocs-addons/src/index.ts | 37 --- .../techdocs-addons/src/test-utils/index.ts | 17 -- .../techdocs-addons/src/test-utils/mocks.ts | 29 --- .../src/test-utils/test-utils.tsx | 219 ------------------ plugins/techdocs-addons/src/types.ts | 99 -------- 23 files changed, 1523 deletions(-) delete mode 100644 plugins/techdocs-addons/.eslintrc.js delete mode 100644 plugins/techdocs-addons/README.md delete mode 100644 plugins/techdocs-addons/api-report.md delete mode 100644 plugins/techdocs-addons/package.json delete mode 100644 plugins/techdocs-addons/src/addons.tsx delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx delete mode 100644 plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts delete mode 100644 plugins/techdocs-addons/src/components/index.ts delete mode 100644 plugins/techdocs-addons/src/context.test.tsx delete mode 100644 plugins/techdocs-addons/src/context.tsx delete mode 100644 plugins/techdocs-addons/src/hooks.test.ts delete mode 100644 plugins/techdocs-addons/src/hooks.ts delete mode 100644 plugins/techdocs-addons/src/index.ts delete mode 100644 plugins/techdocs-addons/src/test-utils/index.ts delete mode 100644 plugins/techdocs-addons/src/test-utils/mocks.ts delete mode 100644 plugins/techdocs-addons/src/test-utils/test-utils.tsx delete mode 100644 plugins/techdocs-addons/src/types.ts diff --git a/plugins/techdocs-addons/.eslintrc.js b/plugins/techdocs-addons/.eslintrc.js deleted file mode 100644 index e2a53a6ad2..0000000000 --- a/plugins/techdocs-addons/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/techdocs-addons/README.md b/plugins/techdocs-addons/README.md deleted file mode 100644 index 0ee88f84d0..0000000000 --- a/plugins/techdocs-addons/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# @backstage/plugin-techdocs-addons - -Package encapsulating the TechDocs Addon framework. - -## What is an addon? - -An addon is a isolated piece of functionality that one can use to augment the -TechDocs experience at render-time. For example: an issue counter showing the -number of issues reported on the documentation, or the top contributors to the -documentation. - -## Create a new addon - -To create a new addon, you can use the `createTechDocsAddon` factory exported -from this plugin. Normally, addons are provided by Backstage plugins, which can -then be composed within a Backstage app. - -When you create a new Addon, it requires three things. - -1. A `name` for debugging and analytics purposes) -2. A `location`, indicating where/how the addon will be rendered. Valid - locations include: `header`, `subheader`, `primary sidebar`, - `secondary sidebar`, `content`, and `component`. Values are available on an - enumerable `TechDocsAddonLocations` and are type-hinted. -3. A `component`, encapsulating the addon's logic and functionality - -```tsx -import { - createTechDocsAddon, - TechDocsAddonLocations, -} from '@backstage/plugin-techdocs-addons'; -import { StackOverflowSecondarySidebarAddon } from './components'; - -export const StackOverflowSecondarySidebar = yourBackstagePlugin.provide( - createTechDocsAddon({ - name: 'StackOverflowSecondarySidebar', - location: TechDocsAddonLocations.SECONDARY_SIDEBAR, - component: StackOverflowSecondarySidebarAddon, - }), -); -``` - -## Compose your app with addons - -To configure which addons will augment the TechDocs experience in your -Backstage app, you need two things: - -- The `TechDocsAddons` component, which is responsible for registering the - addons. -- A list of the addons themselves, as exported by their respective plugins. - -```tsx -import { - TechDocsAddons, - TechDocsReaderPage, -} from '@backstage/plugin-techdocs-addons'; -import { StackOverflowSecondarySidebar } from '@backstage/plugin-soe'; - -}> - - - -; -``` diff --git a/plugins/techdocs-addons/api-report.md b/plugins/techdocs-addons/api-report.md deleted file mode 100644 index 77c9f4d276..0000000000 --- a/plugins/techdocs-addons/api-report.md +++ /dev/null @@ -1,86 +0,0 @@ -## API Report File for "@backstage/plugin-techdocs-addons" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { AsyncState } from 'react-use/lib/useAsyncFn'; -import { ComponentType } from 'react'; -import { Entity } from '@backstage/catalog-model'; -import { Extension } from '@backstage/core-plugin-api'; -import { default as React_2 } from 'react'; - -// @public -export function createTechDocsAddon( - options: TechDocsAddonOptions, -): Extension>; - -// @public -export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; - -// @public -export type TechDocsAddonAsyncMetadata = AsyncState; - -// @public -export enum TechDocsAddonLocations { - COMPONENT = 'component', - CONTENT = 'content', - HEADER = 'header', - PRIMARY_SIDEBAR = 'primary sidebar', - SECONDARY_SIDEBAR = 'secondary sidebar', - SUBHEADER = 'subheader', -} - -// @public -export type TechDocsAddonOptions = { - name: string; - location: TechDocsAddonLocations; - component: ComponentType; -}; - -// @public -export const TechDocsAddons: React_2.ComponentType; - -// @public -export type TechDocsEntityMetadata = Entity & { - locationMetadata?: { - type: string; - target: string; - }; -}; - -// @public -export type TechDocsMetadata = { - site_name: string; - site_description: string; -}; - -// @public -export const TechDocsReaderPage: ( - props: TechDocsReaderPageProps, -) => JSX.Element; - -// @public (undocumented) -export type TechDocsReaderPageProps = { - hideHeader?: boolean; - addonConfig?: React_2.ReactNode; - dom: Element | null; - asyncEntityMetadata: AsyncState; - asyncTechDocsMetadata: AsyncState; -}; - -// @public -export const useEntityMetadata: () => TechDocsAddonAsyncMetadata; - -// @public -export const useShadowRoot: () => ShadowRoot | undefined; - -// @public -export const useShadowRootElements: < - TReturnedElement extends HTMLElement = HTMLElement, ->( - selectors: string[], -) => TReturnedElement[]; - -// @public -export const useTechDocsMetadata: () => TechDocsAddonAsyncMetadata; -``` diff --git a/plugins/techdocs-addons/package.json b/plugins/techdocs-addons/package.json deleted file mode 100644 index 3e441eb56e..0000000000 --- a/plugins/techdocs-addons/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@backstage/plugin-techdocs-addons", - "version": "0.0.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" - }, - "backstage": { - "role": "frontend-plugin" - }, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", - "clean": "backstage-cli package clean", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" - }, - "dependencies": { - "@backstage/catalog-model": "^0.13.0", - "@backstage/core-components": "^0.9.1", - "@backstage/core-plugin-api": "^0.8.0", - "@backstage/test-utils": "^0.3.0", - "@material-ui/core": "^4.12.2", - "@material-ui/lab": "4.0.0-alpha.57", - "@material-ui/styles": "^4.11.0", - "jss": "~10.8.2", - "lodash.debounce": "^4.0.8", - "react-dom": "^17.0.2", - "react-helmet": "6.1.0", - "react-router-dom": "6.0.0-beta.0", - "react-use": "^17.2.4", - "testing-library__dom": "^7.29.4-beta.1", - "@testing-library/react": "^12.1.3" - }, - "peerDependencies": { - "@types/react": "^16.13.1 || ^17.0.0", - "react": "^16.13.1 || ^17.0.0" - }, - "devDependencies": { - "@testing-library/react-hooks": "^7.0.2", - "@testing-library/jest-dom": "^5.10.1", - "@types/lodash.debounce": "^4.0.6" - }, - "files": [ - "dist" - ] -} diff --git a/plugins/techdocs-addons/src/addons.tsx b/plugins/techdocs-addons/src/addons.tsx deleted file mode 100644 index 2151abd948..0000000000 --- a/plugins/techdocs-addons/src/addons.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - attachComponentData, - createReactExtension, - ElementCollection, - Extension, - useElementFilter, -} from '@backstage/core-plugin-api'; -import React, { - ComponentType, - createContext, - PropsWithChildren, - useCallback, - useContext, -} from 'react'; -import { useOutlet } from 'react-router-dom'; - -import { TechDocsAddonLocations, TechDocsAddonOptions } from './types'; - -export const TECHDOCS_ADDONS_KEY = 'techdocs.addons.addon.v1'; - -/** - * Marks the registry component. - * @public - */ -export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; - -/** - * TechDocs Addon registry. - * @public - */ -export const TechDocsAddons: React.ComponentType = () => null; - -attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); - -const getDataKeyByName = (name: string) => { - return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; -}; - -/** - * Create a TechDocs addon. - * @public - */ -export function createTechDocsAddon( - options: TechDocsAddonOptions, -): Extension> { - const { name, component: TechDocsAddon } = options; - return createReactExtension({ - name, - component: { - sync: (props: TComponentProps) => , - }, - data: { - [TECHDOCS_ADDONS_KEY]: options, - [getDataKeyByName(name)]: true, - }, - }); -} - -const getTechDocsAddonByName = (collection: ElementCollection, key: string) => { - return collection.selectByComponentData({ key }).getElements()[0]; -}; - -const getAllTechDocsAddons = (collection: ElementCollection) => { - return collection - .selectByComponentData({ - key: TECHDOCS_ADDONS_WRAPPER_KEY, - }) - .selectByComponentData({ - key: TECHDOCS_ADDONS_KEY, - }); -}; - -const getAllTechDocsAddonsData = (collection: ElementCollection) => { - return collection - .selectByComponentData({ - key: TECHDOCS_ADDONS_WRAPPER_KEY, - }) - .findComponentData({ - key: TECHDOCS_ADDONS_KEY, - }); -}; - -type TechDocsAddonConfig = { - config?: React.ReactNode | null; -}; - -const TechDocsAddonConfigContext = createContext({}); - -export const TechDocsAddonConfigProvider = ( - props: PropsWithChildren<{ config?: React.ReactNode }>, -) => { - const fromOutlet = useOutlet(); - const config = props.config ?? fromOutlet; - return ( - - {props.children} - - ); -}; - -const useTechDocsAddonsConfig = (): React.ReactNode | null => { - return useContext(TechDocsAddonConfigContext).config || null; -}; - -export const useTechDocsAddons = () => { - const node = useTechDocsAddonsConfig(); - - const collection = useElementFilter(node, getAllTechDocsAddons); - const options = useElementFilter(node, getAllTechDocsAddonsData); - - const findAddonByData = useCallback( - (data: TechDocsAddonOptions | undefined) => { - if (!collection || !data) return null; - const nameKey = getDataKeyByName(data.name); - return getTechDocsAddonByName(collection, nameKey) ?? null; - }, - [collection], - ); - - const renderComponentByName = useCallback( - (name: string) => { - const data = options.find(option => option.name === name); - return data ? findAddonByData(data) : null; - }, - [options, findAddonByData], - ); - - const renderComponentsByLocation = useCallback( - (location: TechDocsAddonLocations) => { - const data = options.filter(option => option.location === location); - return data.length ? data.map(findAddonByData) : null; - }, - [options, findAddonByData], - ); - - return { renderComponentByName, renderComponentsByLocation }; -}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx deleted file mode 100644 index 2941278feb..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Page } from '@backstage/core-components'; -import React from 'react'; -import { useParams } from 'react-router-dom'; -import { AsyncState } from 'react-use/lib/useAsyncFn'; -import { TechDocsAddonConfigProvider } from '../../addons'; - -import { - TechDocsMetadataProvider, - TechDocsEntityProvider, - TechDocsReaderPageProvider, -} from '../../context'; -import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types'; -import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; -import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; -import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; - -/** - * @public - */ -export type TechDocsReaderPageProps = { - hideHeader?: boolean; - addonConfig?: React.ReactNode; - dom: Element | null; - asyncEntityMetadata: AsyncState; - asyncTechDocsMetadata: AsyncState; -}; - -/** - * An addon-aware implementation of the TechDocsReaderPage. - * @public - */ -export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { - const { - addonConfig, - asyncEntityMetadata, - asyncTechDocsMetadata, - dom, - hideHeader = false, - } = props; - const { namespace, kind, name } = useParams(); - const entityName = { namespace, kind, name }; - return ( - - - - - - {!hideHeader && } - - {/* todo(backstage/techdocs-core): handle state indicator */} - {/* */} - - - - - - - ); -}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts deleted file mode 100644 index 3055a865df..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPage/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { TechDocsReaderPage } from './TechDocsReaderPage'; -export type { TechDocsReaderPageProps } from './TechDocsReaderPage'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx deleted file mode 100644 index 6e3ec99b21..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Content, Progress } from '@backstage/core-components'; -import { Portal } from '@material-ui/core'; -import { StylesProvider, jssPreset } from '@material-ui/styles'; -import React, { useEffect, useRef, useState } from 'react'; -import { create } from 'jss'; - -import { useTechDocsAddons } from '../../addons'; -import { useTechDocsReaderPage } from '../../context'; -import { TechDocsAddonLocations as locations } from '../../types'; - -export const TechDocsReaderPageContent = ({ dom }: { dom: Element | null }) => { - const ref = useRef(null); - const [jss, setJss] = useState( - create({ - ...jssPreset(), - insertionPoint: undefined, - }), - ); - - const addons = useTechDocsAddons(); - const { setShadowRoot } = useTechDocsReaderPage(); - - useEffect(() => { - const shadowHost = ref.current; - if (!dom || !shadowHost || shadowHost.shadowRoot) return; - - setJss( - create({ - ...jssPreset(), - insertionPoint: dom.querySelector('head') || undefined, - }), - ); - - const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = ''; - shadowRoot.appendChild(dom); - setShadowRoot(shadowRoot); - }, [dom, setShadowRoot]); - - const contentElement = ref.current?.shadowRoot?.querySelector( - '[data-md-component="container"]', - ); - const primarySidebarElement = ref.current?.shadowRoot?.querySelector( - 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', - ); - const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( - 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', - ); - - const primarySidebarAddonLocation = document.createElement('div'); - primarySidebarElement?.prepend(primarySidebarAddonLocation); - - const secondarySidebarAddonLocation = document.createElement('div'); - secondarySidebarElement?.prepend(secondarySidebarAddonLocation); - - // do not return content until dom is ready - if (!dom) { - return ( - - - - ); - } - - return ( - - {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} - -
- - {addons.renderComponentsByLocation(locations.PRIMARY_SIDEBAR)} - - - {addons.renderComponentsByLocation(locations.CONTENT)} - - - {addons.renderComponentsByLocation(locations.SECONDARY_SIDEBAR)} - - - - ); -}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts deleted file mode 100644 index 6ad45cd281..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageContent/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx deleted file mode 100644 index 6a8322e57e..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Header } from '@backstage/core-components'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs -import { Skeleton } from '@material-ui/lab'; -import React, { useEffect } from 'react'; -import Helmet from 'react-helmet'; - -import { useTechDocsAddons } from '../../addons'; -import { useTechDocsMetadata, useTechDocsReaderPage } from '../../context'; -import { TechDocsAddonLocations as locations } from '../../types'; - -const skeleton = ; - -export const TechDocsReaderPageHeader = () => { - const addons = useTechDocsAddons(); - const configApi = useApi(configApiRef); - - const { value: metadata } = useTechDocsMetadata(); - - const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage(); - - useEffect(() => { - if (!metadata) return; - setTitle(prevTitle => prevTitle || metadata.site_name); - setSubtitle( - prevSubtitle => prevSubtitle || metadata.site_description || 'Home', - ); - }, [metadata, setTitle, setSubtitle]); - - const appTitle = configApi.getOptional('app.title') || 'Backstage'; - const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); - - return ( -
- - {tabTitle} - - {addons.renderComponentsByLocation(locations.HEADER)} -
- ); -}; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts deleted file mode 100644 index 741a8e9af1..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageHeader/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx deleted file mode 100644 index 9e323cce3f..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Box, Toolbar, ToolbarProps, withStyles } from '@material-ui/core'; -import React from 'react'; - -import { useTechDocsAddons } from '../../addons'; -import { TechDocsAddonLocations as locations } from '../../types'; - -export const TechDocsReaderPageSubheader = withStyles(theme => ({ - root: { - gridArea: 'pageSubheader', - flexDirection: 'column', - minHeight: 'auto', - padding: theme.spacing(3, 3, 0), - }, -}))(({ ...rest }: ToolbarProps) => { - const addons = useTechDocsAddons(); - - if (!addons.renderComponentsByLocation(locations.SUBHEADER)) return null; - - return ( - - {addons.renderComponentsByLocation(locations.SUBHEADER) && ( - - {addons.renderComponentsByLocation(locations.SUBHEADER)} - - )} - - ); -}); diff --git a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts b/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts deleted file mode 100644 index 78f270e191..0000000000 --- a/plugins/techdocs-addons/src/components/TechDocsReaderPageSubheader/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { TechDocsReaderPageSubheader } from './TechDocsReaderPageSubheader'; diff --git a/plugins/techdocs-addons/src/components/index.ts b/plugins/techdocs-addons/src/components/index.ts deleted file mode 100644 index 8d5b43143e..0000000000 --- a/plugins/techdocs-addons/src/components/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './TechDocsReaderPage'; diff --git a/plugins/techdocs-addons/src/context.test.tsx b/plugins/techdocs-addons/src/context.test.tsx deleted file mode 100644 index 921c2efd7d..0000000000 --- a/plugins/techdocs-addons/src/context.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { TechDocsMetadata } from './types'; -import { - useEntityMetadata, - useTechDocsMetadata, - useTechDocsReaderPage, - TechDocsEntityProvider, - TechDocsMetadataProvider, - TechDocsReaderPageProvider, -} from './context'; -import { renderHook, act } from '@testing-library/react-hooks'; - -import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; - -const mockEntity: Entity = { - apiVersion: 'v1', - kind: 'Component', - metadata: { name: 'test-component', namespace: 'default' }, -}; - -const mockTechDocsMetadata: TechDocsMetadata = { - site_name: 'test-componnet', - site_description: 'this is a test component', -}; - -const mockShadowRoot = () => { - const div = document.createElement('div'); - const shadowRoot = div.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = '

Shadow DOM Mock

'; - return shadowRoot; -}; - -const wrapper = ({ - entityName = { - namespace: mockEntity.metadata.namespace!!, - kind: mockEntity.kind, - name: mockEntity.metadata.name, - }, - children, -}: { - entityName: CompoundEntityRef; - children: React.ReactNode; -}) => ( - - - - {children} - - - -); - -describe('context', () => { - describe('useEntityMetadata', () => { - it('should return loading state', async () => { - const { result } = renderHook(() => useEntityMetadata()); - - await expect(result.current.loading).toEqual(true); - }); - - it('should return expected entity values', async () => { - const { result } = renderHook(() => useEntityMetadata(), { wrapper }); - - expect(result.current.value).toBeDefined(); - expect(result.current.error).toBeUndefined(); - expect(result.current.value).toMatchObject(mockEntity); - }); - }); - - describe('useTechDocsMetadata', () => { - it('should return loading state', async () => { - const { result } = renderHook(() => useTechDocsMetadata()); - - await expect(result.current.loading).toEqual(true); - }); - - it('should return expected techdocs metadata values', async () => { - const { result } = renderHook(() => useTechDocsMetadata(), { wrapper }); - - expect(result.current.value).toBeDefined(); - expect(result.current.error).toBeUndefined(); - expect(result.current.value).toMatchObject(mockTechDocsMetadata); - }); - }); - - describe('useTechDocsReaderPage', () => { - it('should set title', () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); - - expect(result.current.title).toBe(''); - - act(() => result.current.setTitle('test site title')); - expect(result.current.title).toBe('test site title'); - }); - - it('should set subtitle', () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); - - expect(result.current.subtitle).toBe(''); - - act(() => result.current.setSubtitle('test site subtitle')); - expect(result.current.subtitle).toBe('test site subtitle'); - }); - - it('should set shadow root', async () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); - - // mock shadowroot - const shadowRoot = mockShadowRoot(); - - act(() => result.current.setShadowRoot(shadowRoot)); - - expect(result.current.shadowRoot?.innerHTML).toBe( - '

Shadow DOM Mock

', - ); - }); - }); -}); diff --git a/plugins/techdocs-addons/src/context.tsx b/plugins/techdocs-addons/src/context.tsx deleted file mode 100644 index 54a73e2589..0000000000 --- a/plugins/techdocs-addons/src/context.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CompoundEntityRef } from '@backstage/catalog-model'; -import React, { - createContext, - Dispatch, - PropsWithChildren, - SetStateAction, - useContext, - useState, -} from 'react'; -import { AsyncState } from 'react-use/lib/useAsync'; -import { - TechDocsAddonAsyncMetadata, - TechDocsEntityMetadata, - TechDocsMetadata, -} from './types'; - -type PropsWithAsyncMetadata = PropsWithChildren<{ - asyncValue: AsyncState; -}>; -type PropsWithEntityName = PropsWithChildren<{ entityName: CompoundEntityRef }>; - -const initialContextValue = { - loading: true, - error: undefined, - value: undefined, -}; - -const TechDocsMetadataContext = - createContext>( - initialContextValue, - ); - -export const TechDocsMetadataProvider = ({ - asyncValue, - children, -}: PropsWithAsyncMetadata) => { - return ( - - {children} - - ); -}; - -/** - * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the - * current TechDocs site. - * @public - */ -export const useTechDocsMetadata = () => { - return useContext(TechDocsMetadataContext); -}; - -const TechDocsEntityContext = - createContext>( - initialContextValue, - ); - -export const TechDocsEntityProvider = ({ - asyncValue, - children, -}: PropsWithAsyncMetadata) => { - return ( - - {children} - - ); -}; - -/** - * Hook for use within TechDocs addons to retrieve Entity Metadata for the - * current TechDocs site. - * @public - */ -export const useEntityMetadata = () => { - return useContext(TechDocsEntityContext); -}; - -export type TechDocsReaderPageValue = { - entityName: CompoundEntityRef; - shadowRoot?: ShadowRoot; - setShadowRoot: Dispatch>; - title: string; - setTitle: Dispatch>; - subtitle: string; - setSubtitle: Dispatch>; -}; - -export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { - title: '', - setTitle: () => {}, - subtitle: '', - setSubtitle: () => {}, - setShadowRoot: () => {}, - entityName: { kind: '', name: '', namespace: '' }, -}; - -export const TechDocsReaderPageContext = createContext( - defaultTechDocsReaderPageValue, -); - -export const useTechDocsReaderPage = () => { - return useContext(TechDocsReaderPageContext); -}; - -export const TechDocsReaderPageProvider = ({ - entityName, - children, -}: PropsWithEntityName) => { - const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); - const [subtitle, setSubtitle] = useState( - defaultTechDocsReaderPageValue.subtitle, - ); - const [shadowRoot, setShadowRoot] = useState( - defaultTechDocsReaderPageValue.shadowRoot, - ); - - const value = { - entityName, - shadowRoot, - setShadowRoot, - title, - setTitle, - subtitle, - setSubtitle, - }; - - return ( - - {children} - - ); -}; diff --git a/plugins/techdocs-addons/src/hooks.test.ts b/plugins/techdocs-addons/src/hooks.test.ts deleted file mode 100644 index c85d3a3d11..0000000000 --- a/plugins/techdocs-addons/src/hooks.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useShadowRoot, useShadowRootElements } from './hooks'; -import { renderHook } from '@testing-library/react-hooks'; - -const mockShadowRoot = () => { - const div = document.createElement('div'); - const shadowRoot = div.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = '

Shadow DOM Mock

'; - return shadowRoot; -}; - -const shadowRoot = mockShadowRoot(); - -jest.mock('./context', () => { - return { - useTechDocsReaderPage: () => ({ shadowRoot }), - }; -}); - -describe('hooks', () => { - describe('useShadowRoot', () => { - it('should return shadow root', async () => { - const { result } = renderHook(() => useShadowRoot()); - - expect(result.current?.innerHTML).toBe(shadowRoot.innerHTML); - }); - }); - - describe('useShadowRootElements', () => { - it('should return shadow root elements based on selector', () => { - const { result } = renderHook(() => useShadowRootElements(['h1'])); - - expect(result.current).toHaveLength(1); - }); - }); -}); diff --git a/plugins/techdocs-addons/src/hooks.ts b/plugins/techdocs-addons/src/hooks.ts deleted file mode 100644 index 7bc6152006..0000000000 --- a/plugins/techdocs-addons/src/hooks.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useTechDocsReaderPage } from './context'; - -/** - * Hook for use within TechDocs addons that provides access to the underlying - * shadow root of the current page, allowing the DOM within to be mutated. - * @public - */ -export const useShadowRoot = () => { - const { shadowRoot } = useTechDocsReaderPage(); - return shadowRoot; -}; - -/** - * Convenience hook for use within TechDocs addons that provides access to - * elements that match a given selector within the shadow root. - * - * todo(backstage/techdocs-core): Consider extending `selectors` from string[] - * to some kind of typed object array, so users have more control over the - * shape of the result. e.g. a flag to indicate querySelector vs. - * querySelectorAll. - * - * @public - */ -export const useShadowRootElements = < - TReturnedElement extends HTMLElement = HTMLElement, ->( - selectors: string[], -): TReturnedElement[] => { - const shadowRoot = useShadowRoot(); - if (!shadowRoot) return []; - return selectors - .map(selector => shadowRoot?.querySelectorAll(selector)) - .filter(nodeList => nodeList.length) - .map(nodeList => Array.from(nodeList)) - .flat(); -}; diff --git a/plugins/techdocs-addons/src/index.ts b/plugins/techdocs-addons/src/index.ts deleted file mode 100644 index 5e7b3211a9..0000000000 --- a/plugins/techdocs-addons/src/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Package encapsulating the TechDocs Addon framework. - * - * @packageDocumentation - */ - -export { - createTechDocsAddon, - TechDocsAddons, - TECHDOCS_ADDONS_WRAPPER_KEY, -} from './addons'; -export * from './components'; -export { useEntityMetadata, useTechDocsMetadata } from './context'; -export { useShadowRoot, useShadowRootElements } from './hooks'; -export { TechDocsAddonLocations } from './types'; -export type { - TechDocsAddonAsyncMetadata, - TechDocsAddonOptions, - TechDocsMetadata, - TechDocsEntityMetadata, -} from './types'; diff --git a/plugins/techdocs-addons/src/test-utils/index.ts b/plugins/techdocs-addons/src/test-utils/index.ts deleted file mode 100644 index 3bcf5f5ab8..0000000000 --- a/plugins/techdocs-addons/src/test-utils/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './test-utils'; diff --git a/plugins/techdocs-addons/src/test-utils/mocks.ts b/plugins/techdocs-addons/src/test-utils/mocks.ts deleted file mode 100644 index fd4665237c..0000000000 --- a/plugins/techdocs-addons/src/test-utils/mocks.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const useTechDocsReaderDom = jest.fn(); -export const useParams = jest.fn(); -jest.mock('@backstage/plugin-techdocs', () => ({ - ...(jest.requireActual('@backstage/plugin-techdocs') as {}), - useTechDocsReaderDom, - withTechDocsReaderProvider: jest.fn(x => x), - TechDocsStateIndicator: jest.fn(() => null), -})); -// todo(backstage/techdocs-core): Use core test-utils' `routeEntries` option. -jest.mock('react-router', () => ({ - ...(jest.requireActual('react-router') as {}), - useParams, -})); diff --git a/plugins/techdocs-addons/src/test-utils/test-utils.tsx b/plugins/techdocs-addons/src/test-utils/test-utils.tsx deleted file mode 100644 index 4362bb8cf7..0000000000 --- a/plugins/techdocs-addons/src/test-utils/test-utils.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// import order matters for jest manual mocks! import this first. -import { useTechDocsReaderDom, useParams } from './mocks'; - -import React, { ReactElement, Fragment } from 'react'; - -// Shadow DOM support for the simple and complete DOM testing utilities -// https://github.com/testing-library/dom-testing-library/issues/742#issuecomment-674987855 -import { screen } from 'testing-library__dom'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { Route, Routes } from 'react-router-dom'; -import { act, render } from '@testing-library/react'; -import { AsyncState } from 'react-use/lib/useAsyncFn'; - -import { - wrapInTestApp, - TestApiProvider, - TestApiProviderProps, -} from '@backstage/test-utils'; - -import { TechDocsEntityMetadata, TechDocsMetadata } from '../types'; -import { TechDocsReaderPage, TechDocsAddons } from '..'; - -type RecursivePartial = { - [P in keyof T]?: RecursivePartial; -}; - -type Apis = TestApiProviderProps['apis']; - -export type TechDocsAddonsBuilder = { - dom: ReactElement; - entity: RecursivePartial; - metadata: RecursivePartial; - componentId: string; - apis: Apis; - path: string; -}; - -const defaultOptions: TechDocsAddonsBuilder = { - dom: <>, - entity: {}, - metadata: {}, - componentId: 'docs', - apis: [], - path: '', -}; - -const defaultMetadata = { - site_name: 'Tech Docs', - site_description: 'Tech Docs', -}; - -const defaultEntity = { - kind: 'Component', - metadata: { namespace: 'default', name: 'docs' }, -}; - -const defaultDom = ( - - - -
-
-
-
-
- - -); - -export class TechDocsAddonBuilder { - private options: TechDocsAddonsBuilder = defaultOptions; - private addons: ReactElement[]; - - static buildAddonsInTechDocs(addons: ReactElement[]) { - return new TechDocsAddonBuilder(addons); - } - - constructor(addons: ReactElement[]) { - this.addons = addons; - } - - withApis(apis: Apis) { - const refs = apis.map(([ref]) => ref); - this.options.apis = this.options.apis - .filter(([ref]) => !refs.includes(ref)) - .concat(apis); - return this; - } - - withDom(dom: ReactElement) { - this.options.dom = dom; - return this; - } - - withMetadata(metadata: RecursivePartial) { - this.options.metadata = metadata; - return this; - } - - withEntity(entity: RecursivePartial) { - this.options.entity = entity; - return this; - } - - atPath(path: string) { - this.options.path = path; - return this; - } - - build() { - const apis = [...this.options.apis]; - const entityName = { - namespace: - this.options.entity?.metadata?.namespace || - defaultEntity.metadata.namespace, - kind: this.options.entity?.kind || defaultEntity.kind, - name: this.options.entity?.metadata?.name || defaultEntity.metadata.name, - }; - - const techDocsMetadata: AsyncState = { - loading: false, - error: undefined, - value: (this.options.metadata || { - ...defaultMetadata, - }) as TechDocsMetadata, - }; - - const entityMetadata: AsyncState = { - loading: false, - error: undefined, - value: (this.options.entity || { - ...defaultEntity, - }) as TechDocsEntityMetadata, - }; - - const dom = document.createElement('html'); - dom.innerHTML = renderToStaticMarkup(this.options.dom || defaultDom); - useTechDocsReaderDom.mockReturnValue(dom); - // todo(backstage/techdocs-core): Use core test-utils' `routeEntries` option to mock - // the current path. We use jest mocks instead for now because of a bug in - // react-router that prevents '*' params from being mocked. - useParams.mockReturnValue({ - ...entityName, - '*': this.options.path, - }); - - return wrapInTestApp( - - - - } - > - - {this.addons.map((addon, index) => ( - {addon} - ))} - - - - , - ); - } - - render(): typeof screen & { shadowRoot: ShadowRoot | null } { - render(this.build()); - - const shadowHost = screen.getByTestId('techdocs-native-shadowroot'); - - return { - ...screen, - shadowRoot: shadowHost?.shadowRoot, - }; - } - - // Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async - // act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit - // cleaner, since act doesn't return the result of the evaluated function. - // https://github.com/testing-library/react-testing-library/issues/281 - // https://github.com/facebook/react/pull/14853 - async renderWithEffects(): Promise< - ReturnType - > { - await act(async () => { - this.render(); - }); - - const shadowHost = screen.getByTestId('techdocs-native-shadowroot'); - - return { - ...screen, - shadowRoot: shadowHost?.shadowRoot, - }; - } -} - -export default TechDocsAddonBuilder.buildAddonsInTechDocs; diff --git a/plugins/techdocs-addons/src/types.ts b/plugins/techdocs-addons/src/types.ts deleted file mode 100644 index d8ab7e9520..0000000000 --- a/plugins/techdocs-addons/src/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { ComponentType } from 'react'; -import { AsyncState } from 'react-use/lib/useAsyncFn'; - -/** - * Locations for which TechDocs addons may be declared and rendered. - * @public - */ -export enum TechDocsAddonLocations { - /** - * These addons fill up the header from the right, on the same line as the - * title. - */ - HEADER = 'header', - - /** - * These addons appear below the header and above all content; tooling addons - * can be inserted for convenience. - */ - SUBHEADER = 'subheader', - - /** - * These addons appear left of the content and above the navigation. - */ - PRIMARY_SIDEBAR = 'primary sidebar', - - /** - * These addons appear right of the content and above the table of contents. - */ - SECONDARY_SIDEBAR = 'secondary sidebar', - - /** - * A virtual location which allows mutation of all content within the shadow - * root by transforming DOM nodes. These addons should return null on render. - */ - CONTENT = 'content', - - /** - * A virtual location allowing an instance of the addon to be rendered for - * every HTML node with the same tag name as the addon name in the markdown - * content. If no reference is made, no instance will be rendered. Works like - * regular React components, just being accessible from markdown. - * - * todo(backstage/techdocs-core): Keep and implement or remove before - * releasing this package! - */ - COMPONENT = 'component', -} - -/** - * Options for creating a TechDocs addon. - * @public - */ -export type TechDocsAddonOptions = { - name: string; - location: TechDocsAddonLocations; - component: ComponentType; -}; - -/** - * Common response envelope for addon-related hooks. - * @public - */ -export type TechDocsAddonAsyncMetadata = AsyncState; - -/** - * Metadata for TechDocs page - * - * @public - */ -export type TechDocsMetadata = { - site_name: string; - site_description: string; -}; - -/** - * Metadata for TechDocs Entity - * - * @public - */ -export type TechDocsEntityMetadata = Entity & { - locationMetadata?: { type: string; target: string }; -}; From e0dc1e8ccd127e830f4a8ff6302d572028566f5c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 30 Mar 2022 08:56:24 +0200 Subject: [PATCH 19/47] tests(techdocs): fix broken tests Signed-off-by: Camila Belo --- packages/app/src/App.tsx | 4 +- .../src/components/techdocs/TechDocsPage.tsx | 5 +- packages/techdocs-addons/src/types.ts | 1 - .../components/TechDocsPage/TechDocsPage.tsx | 51 +----- plugins/techdocs/dev/index.tsx | 11 +- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 2 +- .../TechDocsReaderPage/context.test.tsx | 117 +++++++++----- .../components/TechDocsReaderPage/context.tsx | 17 +- .../TechDocsReaderPageContent/context.tsx | 3 +- .../TechDocsReaderPageHeader.test.tsx | 148 ++++++++++++------ .../TechDocsReaderPageHeader.tsx | 21 ++- .../techdocs/src/reader/components/index.ts | 9 +- 12 files changed, 224 insertions(+), 165 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ec03e0e5dd..d792f2ed44 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -89,7 +89,7 @@ import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; -// import { techDocsPage } from './components/techdocs/TechDocsPage'; +import { techDocsPage } from './components/techdocs/TechDocsPage'; import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; import { PermissionedRoute } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common'; @@ -184,7 +184,7 @@ const routes = ( } /> } + element={{techDocsPage}} > diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx index e5142472f0..170d4f8f77 100644 --- a/packages/app/src/components/techdocs/TechDocsPage.tsx +++ b/packages/app/src/components/techdocs/TechDocsPage.tsx @@ -17,16 +17,15 @@ import { TechDocsReaderPageHeader, TechDocsReaderPageContent, - TechDocsReaderPage, } from '@backstage/plugin-techdocs'; import React from 'react'; const DefaultTechDocsPage = () => { return ( - + <> - + ); }; diff --git a/packages/techdocs-addons/src/types.ts b/packages/techdocs-addons/src/types.ts index 2eb8a2f57e..8e42e0a6bc 100644 --- a/packages/techdocs-addons/src/types.ts +++ b/packages/techdocs-addons/src/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { ComponentType } from 'react'; import { AsyncState } from 'react-use/lib/useAsyncFn'; diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index 56686dd081..7bfe7642ff 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -29,14 +29,11 @@ import LightIcon from '@material-ui/icons/Brightness7'; import DarkIcon from '@material-ui/icons/Brightness4'; import { lightTheme, darkTheme } from '@backstage/theme'; -import { CompoundEntityRef } from '@backstage/catalog-model'; - -import { Content } from '@backstage/core-components'; import { - Reader, TechDocsReaderPage, TechDocsReaderPageHeader, + TechDocsReaderPageContent, } from '@backstage/plugin-techdocs'; const useStyles = makeStyles((theme: Theme) => ({ @@ -123,47 +120,13 @@ const TechDocsThemeToggle = () => { ); }; -const TechDocsPageContent = ({ - onReady, - entityRef, -}: { - entityRef: CompoundEntityRef; - onReady: () => void; -}) => { - const classes = useStyles(); - - return ( - - - - ); -}; - -const DefaultTechDocsPage = () => { - const techDocsMetadata = { - site_name: 'Live preview environment', - site_description: '', - }; - - return ( - - {({ entityRef, onReady }) => ( - <> - - - - - - )} - - ); -}; - export const techDocsPage = ( - + + + + + + ); diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index d08e914736..aa4b7bde6c 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -19,7 +19,8 @@ import { NotFoundError } from '@backstage/errors'; import React from 'react'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { - Reader, + TechDocsReaderPageProvider, + TechDocsReaderPageContent, SyncResult, TechDocsStorageApi, techdocsStorageApiRef, @@ -112,13 +113,15 @@ function createPage({ render() { return ( - + > + + ); } } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index f49f7b74c1..0ba0b47458 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -69,7 +69,7 @@ export const TechDocsReaderPage = ({ if (defaultPath) { return defaultPath; } - return params['*'] ?? ''; + return params['*']; }, [params, defaultPath]); const entityName = useMemo(() => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx index 921c2efd7d..f27e5aeb58 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx @@ -15,7 +15,17 @@ */ import React from 'react'; -import { TechDocsMetadata } from './types'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { ThemeProvider } from '@material-ui/core'; + +import { lightTheme } from '@backstage/theme'; +import { TestApiProvider } from '@backstage/test-utils'; +import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +import { techdocsApiRef } from '../../../api'; +import { TechDocsMetadata } from '../../../types'; + import { useEntityMetadata, useTechDocsMetadata, @@ -24,14 +34,17 @@ import { TechDocsMetadataProvider, TechDocsReaderPageProvider, } from './context'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; - -const mockEntity: Entity = { +const mockEntityMetadata: Entity = { apiVersion: 'v1', kind: 'Component', - metadata: { name: 'test-component', namespace: 'default' }, + metadata: { + name: 'test', + namespace: 'default', + }, + spec: { + owner: 'test', + }, }; const mockTechDocsMetadata: TechDocsMetadata = { @@ -46,39 +59,42 @@ const mockShadowRoot = () => { return shadowRoot; }; +const techdocsApiMock = { + getEntityMetadata: jest.fn().mockResolvedValue(mockEntityMetadata), + getTechDocsMetadata: jest.fn().mockResolvedValue(mockTechDocsMetadata), +}; + const wrapper = ({ + path = '', entityName = { - namespace: mockEntity.metadata.namespace!!, - kind: mockEntity.kind, - name: mockEntity.metadata.name, + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, }, children, }: { - entityName: CompoundEntityRef; + path?: string; + entityName?: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - {children} - - - + + + + + + {children} + + + + + ); describe('context', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('useEntityMetadata', () => { it('should return loading state', async () => { const { result } = renderHook(() => useEntityMetadata()); @@ -87,11 +103,16 @@ describe('context', () => { }); it('should return expected entity values', async () => { - const { result } = renderHook(() => useEntityMetadata(), { wrapper }); + const { result, waitForNextUpdate } = renderHook( + () => useEntityMetadata(), + { wrapper }, + ); + + await waitForNextUpdate(); expect(result.current.value).toBeDefined(); expect(result.current.error).toBeUndefined(); - expect(result.current.value).toMatchObject(mockEntity); + expect(result.current.value).toMatchObject(mockEntityMetadata); }); }); @@ -103,7 +124,12 @@ describe('context', () => { }); it('should return expected techdocs metadata values', async () => { - const { result } = renderHook(() => useTechDocsMetadata(), { wrapper }); + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsMetadata(), + { wrapper }, + ); + + await waitForNextUpdate(); expect(result.current.value).toBeDefined(); expect(result.current.error).toBeUndefined(); @@ -112,32 +138,49 @@ describe('context', () => { }); describe('useTechDocsReaderPage', () => { - it('should set title', () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + it('should set title', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); expect(result.current.title).toBe(''); act(() => result.current.setTitle('test site title')); + + await waitForNextUpdate(); + expect(result.current.title).toBe('test site title'); }); - it('should set subtitle', () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + it('should set subtitle', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); expect(result.current.subtitle).toBe(''); act(() => result.current.setSubtitle('test site subtitle')); + + await waitForNextUpdate(); + expect(result.current.subtitle).toBe('test site subtitle'); }); it('should set shadow root', async () => { - const { result } = renderHook(() => useTechDocsReaderPage(), { wrapper }); + const { result, waitForNextUpdate } = renderHook( + () => useTechDocsReaderPage(), + { wrapper }, + ); // mock shadowroot const shadowRoot = mockShadowRoot(); act(() => result.current.setShadowRoot(shadowRoot)); + await waitForNextUpdate(); + expect(result.current.shadowRoot?.innerHTML).toBe( '

Shadow DOM Mock

', ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx index 0c580d17cd..56e0991df6 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -31,9 +31,8 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { techdocsApiRef } from '../../../api'; import { TechDocsEntityMetadata, TechDocsMetadata } from '../../../types'; -type PropsWithEntityName = PropsWithChildren< - T & { entityName: CompoundEntityRef } ->; +type PropsWithEntityName = T & + PropsWithChildren<{ entityName: CompoundEntityRef }>; const initialContextValue = { loading: true, @@ -129,16 +128,16 @@ export const useTechDocsReaderPage = () => { }; type TechDocsReaderPageProviderProps = PropsWithEntityName<{ - path: string; + path?: string; }>; export const TechDocsReaderPageProvider = ({ - path, + path = '', entityName, children, }: TechDocsReaderPageProviderProps) => { - const metadata = useTechDocsMetadata(); - const entityMetadata = useEntityMetadata(); + const { value: entityMetadataValue } = useEntityMetadata(); + const { value: techdocsMetadataValue } = useTechDocsMetadata(); const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); const [subtitle, setSubtitle] = useState( @@ -165,8 +164,8 @@ export const TechDocsReaderPageProvider = ({ {children instanceof Function ? children({ entityRef: entityName, - techdocsMetadataValue: metadata.value, - entityMetadataValue: entityMetadata.value, + entityMetadataValue, + techdocsMetadataValue, }) : children} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx index 007b1fcc65..7a4877d3cd 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -25,7 +25,8 @@ import React, { } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useTheme, Theme, lighten, alpha } from '@material-ui/core'; +import { useTheme, Theme } from '@material-ui/core'; +import { lighten, alpha } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx index 9245bb66c5..3fb05a400e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx @@ -14,41 +14,91 @@ * limitations under the License. */ import React from 'react'; -import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; -import { act } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { act, waitFor } from '@testing-library/react'; + +import { ThemeProvider } from '@material-ui/core'; + +import { lightTheme } from '@backstage/theme'; +import { CompoundEntityRef } from '@backstage/catalog-model'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; -import { rootRouteRef } from '../../routes'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { techdocsApiRef } from '../../../api'; +import { rootRouteRef } from '../../../routes'; + +import { + TechDocsEntityProvider, + TechDocsMetadataProvider, + TechDocsReaderPageProvider, +} from '../TechDocsReaderPage'; + +import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; + +const mockEntityMetadata = { + locationMetadata: { + type: 'github', + target: 'https://example.com/', + }, + apiVersion: 'v1', + kind: 'test', + metadata: { + name: 'test-name', + namespace: 'test-namespace', + }, + spec: { + owner: 'test', + }, +}; + +const mockTechDocsMetadata = { + site_name: 'test-site-name', + site_description: 'test-site-desc', +}; + +const getEntityMetadata = jest.fn(); +const getTechDocsMetadata = jest.fn(); + +const techdocsApiMock = { + getEntityMetadata, + getTechDocsMetadata, +}; + +const Wrapper = ({ + path = '', + entityName = { + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, + }, + children, +}: { + path?: string; + entityName?: CompoundEntityRef; + children: React.ReactNode; +}) => ( + + + + + + {children} + + + + + +); describe('', () => { it('should render a techdocs page header', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + await act(async () => { const rendered = await renderInTestApp( - , + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, @@ -58,7 +108,11 @@ describe('', () => { ); expect(rendered.container.innerHTML).toContain('header'); - expect(rendered.getAllByText('test-site-name')).toHaveLength(2); + + await waitFor(() => { + expect(rendered.getAllByText('test-site-name')).toHaveLength(2); + }); + expect(rendered.getByText('test-site-desc')).toBeDefined(); }); }); @@ -66,13 +120,9 @@ describe('', () => { it('should render a techdocs page header even if metadata is missing', async () => { await act(async () => { const rendered = await renderInTestApp( - , + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, @@ -86,19 +136,13 @@ describe('', () => { }); it('should render a link back to the component page', async () => { + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + await act(async () => { const rendered = await renderInTestApp( - , + + + , { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, @@ -107,9 +151,11 @@ describe('', () => { }, ); - expect(rendered.container.innerHTML).toContain( - '/catalog/test-namespace/test/test-name', - ); + await waitFor(() => { + expect( + rendered.getByRole('link', { name: 'test:test-namespace/test-name' }), + ).toHaveAttribute('href', '/catalog/test-namespace/test/test-name'); + }); }); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index b1ca6ce50a..c417274403 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -43,13 +43,12 @@ import { rootRouteRef } from '../../../routes'; const skeleton = ; -export const TechDocsReaderPageHeader: FC = props => { - const { children } = props; +export const TechDocsReaderPageHeader: FC = ({ children }) => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); - const { value: techDocsMetadata } = useTechDocsMetadata(); const { value: entityMetadata } = useEntityMetadata(); + const { value: techDocsMetadata } = useTechDocsMetadata(); const { title, @@ -61,11 +60,17 @@ export const TechDocsReaderPageHeader: FC = props => { useEffect(() => { if (!techDocsMetadata) return; - setTitle(prevTitle => prevTitle || techDocsMetadata.site_name); - setSubtitle( - prevSubtitle => - prevSubtitle || techDocsMetadata.site_description || 'Home', - ); + setTitle(prevTitle => { + const { site_name } = techDocsMetadata; + return prevTitle || site_name; + }); + setSubtitle(prevSubtitle => { + let { site_description } = techDocsMetadata; + if (site_description === 'None') { + site_description = 'Home'; + } + return prevSubtitle || site_description; + }); }, [techDocsMetadata, setTitle, setSubtitle]); const appTitle = configApi.getOptional('app.title') || 'Backstage'; diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts index f6294200a3..5abdf002b1 100644 --- a/plugins/techdocs/src/reader/components/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -19,12 +19,13 @@ export type { TechDocsReaderLayoutProps, } from './TechDocsReaderPage'; export { - TechDocsReaderLayout, - useTechDocsMetadata, - useEntityMetadata, - useTechDocsReaderPage, useShadowRoot, useShadowRootElements, + useEntityMetadata, + useTechDocsMetadata, + useTechDocsReaderPage, + TechDocsReaderLayout, + TechDocsReaderPageProvider, } from './TechDocsReaderPage'; export * from './TechDocsReaderPageHeader'; export * from './TechDocsReaderPageContent'; From 5ad229a24bd3909a53a80d7611506d72f320f5be Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 31 Mar 2022 08:45:15 +0200 Subject: [PATCH 20/47] fix(techdocs): entity page links Signed-off-by: Camila Belo --- plugins/techdocs/package.json | 1 + plugins/techdocs/src/EntityPageDocs.tsx | 15 ++--------- plugins/techdocs/src/Router.tsx | 15 ++++++----- yarn.lock | 35 ++++++++++++------------- 4 files changed, 29 insertions(+), 37 deletions(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 6716a1a2c5..1ac94c4a03 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -38,6 +38,7 @@ "@backstage/catalog-model": "^1.0.1-next.1", "@backstage/config": "^1.0.0", "@backstage/core-components": "^0.9.3-next.1", + "@backstage/core-app-api": "^1.0.0", "@backstage/core-plugin-api": "^1.0.0", "@backstage/errors": "^1.0.0", "@backstage/integration": "^1.1.0-next.1", diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 50f44b3773..6cb11a7b25 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -16,26 +16,15 @@ import React from 'react'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; +import { Entity, getCompoundEntityRef } from '@backstage/catalog-model'; -import { toLowerMaybe } from './helpers'; import { TechDocsReaderPage } from './plugin'; import { TechDocsReaderLayout } from './reader'; type EntityPageDocsProps = { entity: Entity }; export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => { - const config = useApi(configApiRef); - - const entityName = { - namespace: toLowerMaybe( - entity.metadata.namespace ?? DEFAULT_NAMESPACE, - config, - ), - kind: toLowerMaybe(entity.kind, config), - name: toLowerMaybe(entity.metadata.name, config), - }; + const entityName = getCompoundEntityRef(entity); return ( diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 40308e3379..1908e6cad0 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -15,13 +15,16 @@ */ import React, { PropsWithChildren } from 'react'; -import { Entity } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; + +import { Entity } from '@backstage/catalog-model'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { MissingAnnotationEmptyState } from '@backstage/core-components'; + +import { EntityPageDocs } from './EntityPageDocs'; import { TechDocsIndexPage } from './home/components/TechDocsIndexPage'; import { TechDocsReaderPage } from './reader/components/TechDocsReaderPage'; -import { EntityPageDocs } from './EntityPageDocs'; -import { MissingAnnotationEmptyState } from '@backstage/core-components'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; @@ -66,10 +69,10 @@ export const EmbeddedDocsRouter = (props: PropsWithChildren<{}>) => { } return ( - + }> {children} - + ); }; diff --git a/yarn.lock b/yarn.lock index 90e3ad3e7b..44d2cf05fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1524,6 +1524,22 @@ zen-observable "^0.8.15" zod "^3.11.6" +"@backstage/core-app-api@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-1.0.0.tgz#2dae97b050b2f2e5ec1ea42b3d95c57e8bf434d6" + integrity sha512-hmoFMPCxAfHgDPQTHbf6rquiG0SCSycWTUrScpYeLwkH3UOekgX8o8ThKT0t3w7WPx83LwT0NqcbSH6zqI9nag== + dependencies: + "@backstage/config" "^1.0.0" + "@backstage/core-plugin-api" "^1.0.0" + "@backstage/types" "^1.0.0" + "@backstage/version-bridge" "^1.0.0" + "@types/prop-types" "^15.7.3" + prop-types "^15.7.2" + react-router-dom "6.0.0-beta.0" + react-use "^17.2.4" + zen-observable "^0.8.15" + zod "^3.11.6" + "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.1", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -6555,18 +6571,6 @@ dependencies: "@types/node" "*" -"@types/lodash.debounce@^4.0.6": - version "4.0.6" - resolved "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60" - integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.14.180" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz#4ab7c9ddfc92ec4a887886483bc14c79fb380670" - integrity sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g== - "@types/lodash@^4.14.151", "@types/lodash@^4.14.173", "@types/lodash@^4.14.175": version "4.14.178" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" @@ -12459,9 +12463,9 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/plugin-tech-insights" "^0.1.14-next.0" "@backstage/plugin-tech-radar" "^0.5.11-next.1" "@backstage/plugin-techdocs" "^1.0.1-next.1" - "@backstage/plugin-techdocs-addons" "^0.0.0" "@backstage/plugin-todo" "^0.2.6-next.0" "@backstage/plugin-user-settings" "^0.4.3-next.0" + "@backstage/techdocs-addons" "^0.0.0" "@backstage/theme" "^0.2.15" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" @@ -24106,11 +24110,6 @@ testcontainers@^8.1.2: ssh-remote-port-forward "^1.0.4" tar-fs "^2.1.1" -testing-library__dom@^7.29.4-beta.1: - version "7.29.4-beta.1" - resolved "https://registry.npmjs.org/testing-library__dom/-/testing-library__dom-7.29.4-beta.1.tgz#dc755f485837e923efbe12c1b7ae43b0ed326f96" - integrity sha512-vb/SMg8rXYcYYFY2eQ2n2a0p2VWNAseM4WHLfsckIyLwxRz5fYqKysUzUYpAX8SwRfruRK+tZqLuL4ND+D1s7Q== - text-extensions@^1.0.0: version "1.9.0" resolved "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26" From 64122098159cb5c7a73302c0d1410c861c204fc2 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 31 Mar 2022 08:46:37 +0200 Subject: [PATCH 21/47] fix(techdocs): fix page re-renders Co-authored-by: Emma Indal Co-authored-by: Eric Peterson Co-authored-by: Otto Sichert Signed-off-by: Camila Belo --- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 59 +- .../components/TechDocsReaderPage/context.tsx | 205 ++--- .../TechDocsReaderPageContent.tsx | 24 +- .../TechDocsReaderPageContent/context.tsx | 817 +----------------- .../TechDocsReaderPageContent/dom.tsx | 790 +++++++++++++++++ .../TechDocsReaderPageContent/index.ts | 1 + .../TechDocsReaderPageHeader.tsx | 23 +- 7 files changed, 956 insertions(+), 963 deletions(-) create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 0ba0b47458..b55f42e984 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,34 +14,33 @@ * limitations under the License. */ -import React, { ReactNode, useMemo } from 'react'; +import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; +import { Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; +import { TechDocsReaderPageRenderFunction } from '../../../types'; + import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; -import { TechDocsReaderPageRenderFunction } from '../../../types'; - -import { - TechDocsEntityProvider, - TechDocsMetadataProvider, - TechDocsReaderPageProvider, -} from './context'; +import { TechDocsReaderPageProvider } from './context'; export type TechDocsReaderLayoutProps = { hideHeader?: boolean; + withSearch?: boolean; }; export const TechDocsReaderLayout = ({ hideHeader = false, + withSearch, }: TechDocsReaderLayoutProps) => ( <> {!hideHeader && } - + ); @@ -49,7 +48,6 @@ export const TechDocsReaderLayout = ({ * @public */ export type TechDocsReaderPageProps = { - path?: string; entityName?: CompoundEntityRef; children?: TechDocsReaderPageRenderFunction | ReactNode; }; @@ -59,37 +57,26 @@ export type TechDocsReaderPageProps = { * @public */ export const TechDocsReaderPage = ({ - path: defaultPath, entityName: defaultEntityName, children = , }: TechDocsReaderPageProps) => { - const params = useParams(); + const { kind, name, namespace } = useParams(); - const path = useMemo(() => { - if (defaultPath) { - return defaultPath; - } - return params['*']; - }, [params, defaultPath]); - - const entityName = useMemo(() => { - if (defaultEntityName) { - return defaultEntityName; - } - return { - kind: params.kind, - name: params.name, - namespace: params.namespace, - }; - }, [params, defaultEntityName]); + const entityName = defaultEntityName || { kind, name, namespace }; return ( - - - - {children} - - - + + {({ metadata, entityMetadata }) => ( + + {children instanceof Function + ? children({ + entityRef: entityName, + techdocsMetadataValue: metadata.value, + entityMetadataValue: entityMetadata.value, + }) + : children} + + )} + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx index 56e0991df6..bd1c884ca0 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -15,92 +15,42 @@ */ import React, { - createContext, + ReactNode, + memo, Dispatch, - PropsWithChildren, SetStateAction, + createContext, useContext, useState, } from 'react'; import useAsync, { AsyncState } from 'react-use/lib/useAsync'; -import { Page } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { techdocsApiRef } from '../../../api'; import { TechDocsEntityMetadata, TechDocsMetadata } from '../../../types'; -type PropsWithEntityName = T & - PropsWithChildren<{ entityName: CompoundEntityRef }>; - -const initialContextValue = { - loading: true, - error: undefined, - value: undefined, -}; - -const TechDocsMetadataContext = - createContext>(initialContextValue); - -export const TechDocsMetadataProvider = ({ - entityName, - children, -}: PropsWithEntityName) => { - const techdocsApi = useApi(techdocsApiRef); - - const value = useAsync(async () => { - return techdocsApi.getTechDocsMetadata(entityName); - }, [entityName]); - - return ( - - {children} - - ); -}; - -/** - * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the - * current TechDocs site. - * @public - */ -export const useTechDocsMetadata = () => { - return useContext(TechDocsMetadataContext); -}; - -const TechDocsEntityContext = - createContext>(initialContextValue); - -export const TechDocsEntityProvider = ({ - entityName, - children, -}: PropsWithEntityName) => { - const techdocsApi = useApi(techdocsApiRef); - - const value = useAsync(async () => { - return techdocsApi.getEntityMetadata(entityName); - }, [entityName]); - - return ( - - {children} - - ); -}; - -/** - * Hook for use within TechDocs addons to retrieve Entity Metadata for the - * current TechDocs site. - * @public - */ -export const useEntityMetadata = () => { - return useContext(TechDocsEntityContext); +const areEntityNamesEqual = ( + prevEntityName: CompoundEntityRef, + nextEntityName: CompoundEntityRef, +) => { + if (prevEntityName.kind !== nextEntityName.kind) { + return false; + } + if (prevEntityName.name !== nextEntityName.name) { + return false; + } + if (prevEntityName.namespace !== nextEntityName.namespace) { + return false; + } + return true; }; export type TechDocsReaderPageValue = { - path: string; + metadata: AsyncState; entityName: CompoundEntityRef; + entityMetadata: AsyncState; shadowRoot?: ShadowRoot; setShadowRoot: Dispatch>; title: string; @@ -110,12 +60,13 @@ export type TechDocsReaderPageValue = { }; export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { - path: '', title: '', - setTitle: () => {}, subtitle: '', + setTitle: () => {}, setSubtitle: () => {}, setShadowRoot: () => {}, + metadata: { loading: true }, + entityMetadata: { loading: true }, entityName: { kind: '', name: '', namespace: '' }, }; @@ -127,48 +78,74 @@ export const useTechDocsReaderPage = () => { return useContext(TechDocsReaderPageContext); }; -type TechDocsReaderPageProviderProps = PropsWithEntityName<{ - path?: string; -}>; +type TechDocsReaderPageProviderRenderFunction = ( + value: TechDocsReaderPageValue, +) => JSX.Element; -export const TechDocsReaderPageProvider = ({ - path = '', - entityName, - children, -}: TechDocsReaderPageProviderProps) => { - const { value: entityMetadataValue } = useEntityMetadata(); - const { value: techdocsMetadataValue } = useTechDocsMetadata(); - - const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); - const [subtitle, setSubtitle] = useState( - defaultTechDocsReaderPageValue.subtitle, - ); - const [shadowRoot, setShadowRoot] = useState( - defaultTechDocsReaderPageValue.shadowRoot, - ); - - const value = { - path, - entityName, - shadowRoot, - setShadowRoot, - title, - setTitle, - subtitle, - setSubtitle, - }; - - return ( - - - {children instanceof Function - ? children({ - entityRef: entityName, - entityMetadataValue, - techdocsMetadataValue, - }) - : children} - - - ); +type TechDocsReaderPageProviderProps = { + entityName: CompoundEntityRef; + children: TechDocsReaderPageProviderRenderFunction | ReactNode; +}; + +export const TechDocsReaderPageProvider = memo( + ({ entityName, children }: TechDocsReaderPageProviderProps) => { + const techdocsApi = useApi(techdocsApiRef); + + const metadata = useAsync(async () => { + return techdocsApi.getTechDocsMetadata(entityName); + }, [entityName]); + + const entityMetadata = useAsync(async () => { + return techdocsApi.getEntityMetadata(entityName); + }, [entityName]); + + const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title); + const [subtitle, setSubtitle] = useState( + defaultTechDocsReaderPageValue.subtitle, + ); + const [shadowRoot, setShadowRoot] = useState( + defaultTechDocsReaderPageValue.shadowRoot, + ); + + const value = { + metadata, + entityName, + entityMetadata, + shadowRoot, + setShadowRoot, + title, + setTitle, + subtitle, + setSubtitle, + }; + + return ( + + {children instanceof Function ? children(value) : children} + + ); + }, + (prevProps, nextProps) => { + return areEntityNamesEqual(prevProps.entityName, nextProps.entityName); + }, +); + +/** + * Hook for use within TechDocs addons to retrieve Entity Metadata for the + * current TechDocs site. + * @public + */ +export const useEntityMetadata = () => { + const { entityMetadata } = useTechDocsReaderPage(); + return entityMetadata; +}; + +/** + * Hook for use within TechDocs addons to retrieve TechDocs Metadata for the + * current TechDocs site. + * @public + */ +export const useTechDocsMetadata = () => { + const { metadata } = useTechDocsReaderPage(); + return metadata; }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 0feeb65e12..4a823a9e7c 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { create } from 'jss'; import { makeStyles, Grid, Portal } from '@material-ui/core'; @@ -29,7 +29,9 @@ import { Content, Progress } from '@backstage/core-components'; import { TechDocsSearch } from '../../../search'; import { useTechDocsReaderPage } from '../TechDocsReaderPage'; import { TechDocsStateIndicator } from '../TechDocsStateIndicator'; -import { useTechDocsReaderDom, withTechDocsReaderProvider } from './context'; + +import { useTechDocsReaderDom } from './dom'; +import { withTechDocsReaderProvider } from './context'; const useStyles = makeStyles({ search: { @@ -49,10 +51,10 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( ({ withSearch = true }: TechDocsReaderPageContentProps) => { const classes = useStyles(); const addons = useTechDocsAddons(); - const page = useTechDocsReaderPage(); - const dom = useTechDocsReaderDom(page.entityName); + const { entityName, setShadowRoot } = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(entityName); - const ref = useRef(null); + const ref = useRef(null); const [jss, setJss] = useState( create({ ...jssPreset(), @@ -75,16 +77,16 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); shadowRoot.innerHTML = ''; shadowRoot.appendChild(dom); - page.setShadowRoot(shadowRoot); - }, [dom, page]); + setShadowRoot(shadowRoot); + }, [dom, setShadowRoot]); - const contentElement = ref.current?.shadowRoot?.querySelector( + const contentElement = ref.current?.querySelector( '[data-md-component="container"]', ); - const primarySidebarElement = ref.current?.shadowRoot?.querySelector( + const primarySidebarElement = ref.current?.querySelector( 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', ); - const secondarySidebarElement = ref.current?.shadowRoot?.querySelector( + const secondarySidebarElement = ref.current?.querySelector( 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', ); @@ -111,7 +113,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( {withSearch && ( - + )} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx index 7a4877d3cd..e1d6a0d55f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -15,42 +15,14 @@ */ import React, { - FC, ComponentType, createContext, useContext, - useCallback, - useEffect, - useState, + ReactNode, } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; -import { useTheme, Theme } from '@material-ui/core'; -import { lighten, alpha } from '@material-ui/core/styles'; - -import { BackstageTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core-plugin-api'; -import { SidebarPinStateContext } from '@backstage/core-components'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; - -import { techdocsStorageApiRef } from '../../../api'; - -import { - addBaseUrl, - addGitFeedbackLink, - addLinkClickListener, - addSidebarToggle, - injectCss, - onCssReady, - removeMkdocsHeader, - rewriteDocLinks, - sanitizeDOM, - simplifyMkdocsFooter, - scrollIntoAnchor, - transform as transformer, - copyToClipboard, -} from '../../transformers'; import { useReaderState } from '../useReaderState'; import { useTechDocsReaderPage } from '../TechDocsReaderPage'; @@ -72,13 +44,37 @@ const TechDocsReaderContext = createContext( {} as TechDocsReaderValue, ); -export const TechDocsReaderProvider: FC = ({ children }) => { - const { path, entityName } = useTechDocsReaderPage(); +/** + * Note: this hook is currently being exported so that we can rapidly + * iterate on alternative implementations that extend core + * functionality. There is no guarantee that this hook will continue to be + * exported by the package in the future! + * + * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") + * @internal + */ + +export const useTechDocsReader = () => useContext(TechDocsReaderContext); + +type TechDocsReaderProviderRenderFunction = ( + value: TechDocsReaderValue, +) => JSX.Element; + +type TechDocsReaderProviderProps = { + children: TechDocsReaderProviderRenderFunction | ReactNode; +}; + +export const TechDocsReaderProvider = ({ + children, +}: TechDocsReaderProviderProps) => { + const { '*': path = '' } = useParams(); + const { entityName } = useTechDocsReaderPage(); const { kind, namespace, name } = entityName; const value = useReaderState(kind, namespace, name, path); + return ( - {children} + {children instanceof Function ? children(value) : children} ); }; @@ -100,758 +96,3 @@ export const withTechDocsReaderProvider = ); - -/** - * Note: this hook is currently being exported so that we can rapidly - * iterate on alternative implementations that extend core - * functionality. There is no guarantee that this hook will continue to be - * exported by the package in the future! - * - * todo: Make public or stop exporting (ctrl+f "altReaderExperiments") - * @internal - */ -export const useTechDocsReader = () => useContext(TechDocsReaderContext); - -type TypographyHeadings = Pick< - Theme['typography'], - 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' ->; - -type TypographyHeadingsKeys = keyof TypographyHeadings; - -const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; - -/** - * Hook that encapsulates the behavior of getting raw HTML and applying - * transforms to it in order to make it function at a basic level in the - * Backstage UI. - * - * Note: this hook is currently being exported so that we can rapidly iterate - * on alternative implementations that extend core functionality. - * There is no guarantee that this hook will continue to be exported by the - * package in the future! - * - * todo: Make public or stop exporting (see others: "altReaderExperiments") - * @internal - */ -export const useTechDocsReaderDom = ( - entityRef: CompoundEntityRef, -): Element | null => { - const navigate = useNavigate(); - const theme = useTheme(); - const techdocsStorageApi = useApi(techdocsStorageApiRef); - const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const techdocsSanitizer = useApi(configApiRef); - const { namespace = '', kind = '', name = '' } = entityRef; - const { state, path, content: rawPage } = useTechDocsReader(); - const isDarkTheme = theme.palette.type === 'dark'; - - const [sidebars, setSidebars] = useState(); - const [dom, setDom] = useState(null); - - // sidebar pinned status to be used in computing CSS style injections - const { isPinned } = useContext(SidebarPinStateContext); - - const updateSidebarPosition = useCallback(() => { - if (!dom || !sidebars) return; - // set sidebar height so they don't initially render in wrong position - const mdTabs = dom.querySelector('.md-container > .md-tabs'); - const sidebarsCollapsed = window.matchMedia( - 'screen and (max-width: 76.1875em)', - ).matches; - const newTop = Math.max(dom.getBoundingClientRect().top, 0); - sidebars.forEach(sidebar => { - if (sidebarsCollapsed) { - sidebar.style.top = '0px'; - } else if (mdTabs) { - sidebar.style.top = `${ - newTop + mdTabs.getBoundingClientRect().height - }px`; - } else { - sidebar.style.top = `${newTop}px`; - } - }); - }, [dom, sidebars]); - - useEffect(() => { - updateSidebarPosition(); - window.addEventListener('scroll', updateSidebarPosition, true); - window.addEventListener('resize', updateSidebarPosition); - return () => { - window.removeEventListener('scroll', updateSidebarPosition, true); - window.removeEventListener('resize', updateSidebarPosition); - }; - // an update to "state" might lead to an updated UI so we include it as a trigger - }, [updateSidebarPosition, state]); - - // dynamically set width of footer to accommodate for pinning of the sidebar - const updateFooterWidth = useCallback(() => { - if (!dom) return; - const footer = dom.querySelector('.md-footer') as HTMLElement; - if (footer) { - footer.style.width = `${dom.getBoundingClientRect().width}px`; - } - }, [dom]); - - useEffect(() => { - updateFooterWidth(); - window.addEventListener('resize', updateFooterWidth); - return () => { - window.removeEventListener('resize', updateFooterWidth); - }; - }); - - // a function that performs transformations that are executed prior to adding it to the DOM - const preRender = useCallback( - (rawContent: string, contentPath: string) => - transformer(rawContent, [ - sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), - addBaseUrl({ - techdocsStorageApi, - entityId: { - kind, - name, - namespace, - }, - path: contentPath, - }), - rewriteDocLinks(), - addSidebarToggle(), - removeMkdocsHeader(), - simplifyMkdocsFooter(), - addGitFeedbackLink(scmIntegrationsApi), - injectCss({ - // Variables - css: ` - /* - As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host. - As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them. - */ - :host { - /* FONT */ - --md-default-fg-color: ${theme.palette.text.primary}; - --md-default-fg-color--light: ${theme.palette.text.secondary}; - --md-default-fg-color--lighter: ${lighten( - theme.palette.text.secondary, - 0.7, - )}; - --md-default-fg-color--lightest: ${lighten( - theme.palette.text.secondary, - 0.3, - )}; - - /* BACKGROUND */ - --md-default-bg-color:${theme.palette.background.default}; - --md-default-bg-color--light: ${theme.palette.background.paper}; - --md-default-bg-color--lighter: ${lighten( - theme.palette.background.paper, - 0.7, - )}; - --md-default-bg-color--lightest: ${lighten( - theme.palette.background.paper, - 0.3, - )}; - - /* PRIMARY */ - --md-primary-fg-color: ${theme.palette.primary.main}; - --md-primary-fg-color--light: ${theme.palette.primary.light}; - --md-primary-fg-color--dark: ${theme.palette.primary.dark}; - --md-primary-bg-color: ${theme.palette.primary.contrastText}; - --md-primary-bg-color--light: ${lighten( - theme.palette.primary.contrastText, - 0.7, - )}; - - /* ACCENT */ - --md-accent-fg-color: var(--md-primary-fg-color); - - /* SHADOW */ - --md-shadow-z1: ${theme.shadows[1]}; - --md-shadow-z2: ${theme.shadows[2]}; - --md-shadow-z3: ${theme.shadows[3]}; - - /* EXTENSIONS */ - --md-admonition-fg-color: var(--md-default-fg-color); - --md-admonition-bg-color: var(--md-default-bg-color); - /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */ - --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); - --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); - --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); - --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,'); - --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,'); - --md-toc-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-version-icon: url('data:image/svg+xml;charset=utf-8,'); - } - - :host > * { - /* CODE */ - --md-code-fg-color: ${theme.palette.text.primary}; - --md-code-bg-color: ${theme.palette.background.paper}; - --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; - --md-code-hl-keyword-color: ${ - isDarkTheme - ? theme.palette.primary.light - : theme.palette.primary.dark - }; - --md-code-hl-function-color: ${ - isDarkTheme - ? theme.palette.secondary.light - : theme.palette.secondary.dark - }; - --md-code-hl-string-color: ${ - isDarkTheme - ? theme.palette.success.light - : theme.palette.success.dark - }; - --md-code-hl-number-color: ${ - isDarkTheme - ? theme.palette.error.light - : theme.palette.error.dark - }; - --md-code-hl-constant-color: var(--md-code-hl-function-color); - --md-code-hl-special-color: var(--md-code-hl-function-color); - --md-code-hl-name-color: var(--md-code-fg-color); - --md-code-hl-comment-color: var(--md-default-fg-color--light); - --md-code-hl-generic-color: var(--md-default-fg-color--light); - --md-code-hl-variable-color: var(--md-default-fg-color--light); - --md-code-hl-operator-color: var(--md-default-fg-color--light); - --md-code-hl-punctuation-color: var(--md-default-fg-color--light); - - /* TYPESET */ - --md-typeset-font-size: 1rem; - --md-typeset-color: var(--md-default-fg-color); - --md-typeset-a-color: var(--md-accent-fg-color); - --md-typeset-table-color: ${theme.palette.text.primary}; - --md-typeset-del-color: ${ - isDarkTheme - ? alpha(theme.palette.error.dark, 0.5) - : alpha(theme.palette.error.light, 0.5) - }; - --md-typeset-ins-color: ${ - isDarkTheme - ? alpha(theme.palette.success.dark, 0.5) - : alpha(theme.palette.success.light, 0.5) - }; - --md-typeset-mark-color: ${ - isDarkTheme - ? alpha(theme.palette.warning.dark, 0.5) - : alpha(theme.palette.warning.light, 0.5) - }; - } - - @media screen and (max-width: 76.1875em) { - :host > * { - /* TYPESET */ - --md-typeset-font-size: .9rem; - } - } - - @media screen and (max-width: 600px) { - :host > * { - /* TYPESET */ - --md-typeset-font-size: .7rem; - } - } - `, - }), - injectCss({ - // Reset - css: ` - body { - --md-text-color: var(--md-default-fg-color); - --md-text-link-color: var(--md-accent-fg-color); - --md-text-font-family: ${theme.typography.fontFamily}; - font-family: var(--md-text-font-family); - background-color: unset; - } - `, - }), - injectCss({ - // Layout - css: ` - .md-grid { - max-width: 100%; - margin: 0; - } - - .md-nav { - font-size: calc(var(--md-typeset-font-size) * 0.9); - } - .md-nav__link { - display: flex; - align-items: center; - justify-content: space-between; - } - .md-nav__icon { - height: 20px !important; - width: 20px !important; - margin-left:${theme.spacing(1)}px; - } - .md-nav__icon svg { - margin: 0; - width: 20px !important; - height: 20px !important; - } - .md-nav__icon:after { - width: 20px !important; - height: 20px !important; - } - - .md-main__inner { - margin-top: 0; - } - - .md-sidebar { - bottom: 75px; - position: fixed; - width: 16rem; - overflow-y: auto; - overflow-x: hidden; - scrollbar-color: rgb(193, 193, 193) #eee; - scrollbar-width: thin; - } - .md-sidebar .md-sidebar__scrollwrap { - width: calc(16rem - 10px); - } - .md-sidebar--secondary { - right: ${theme.spacing(3)}px; - } - .md-sidebar::-webkit-scrollbar { - width: 5px; - } - .md-sidebar::-webkit-scrollbar-button { - width: 5px; - height: 5px; - } - .md-sidebar::-webkit-scrollbar-track { - background: #eee; - border: 1 px solid rgb(250, 250, 250); - box-shadow: 0px 0px 3px #dfdfdf inset; - border-radius: 3px; - } - .md-sidebar::-webkit-scrollbar-thumb { - width: 5px; - background: rgb(193, 193, 193); - border: transparent; - border-radius: 3px; - } - .md-sidebar::-webkit-scrollbar-thumb:hover { - background: rgb(125, 125, 125); - } - - .md-content { - max-width: calc(100% - 16rem * 2); - margin-left: 16rem; - margin-bottom: 50px; - } - - .md-footer { - position: fixed; - bottom: 0px; - } - .md-footer__title { - background-color: unset; - } - .md-footer-nav__link { - width: 16rem; - } - - .md-dialog { - background-color: unset; - } - - @media screen and (min-width: 76.25em) { - .md-sidebar { - height: auto; - } - } - - @media screen and (max-width: 76.1875em) { - .md-nav { - transition: none !important; - background-color: var(--md-default-bg-color) - } - .md-nav--primary .md-nav__title { - cursor: auto; - color: var(--md-default-fg-color); - font-weight: 700; - white-space: normal; - line-height: 1rem; - height: auto; - display: flex; - flex-flow: column; - row-gap: 1.6rem; - padding: 1.2rem .8rem .8rem; - background-color: var(--md-default-bg-color); - } - .md-nav--primary .md-nav__title~.md-nav__list { - box-shadow: none; - } - .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child { - border-top: none; - } - .md-nav--primary .md-nav__title .md-nav__button { - display: none; - } - .md-nav--primary .md-nav__title .md-nav__icon { - color: var(--md-default-fg-color); - position: static; - height: auto; - margin: 0 0 0 -0.2rem; - } - .md-nav--primary > .md-nav__title [for="none"] { - padding-top: 0; - } - .md-nav--primary .md-nav__item { - border-top: none; - } - .md-nav--primary :is(.md-nav__title,.md-nav__item) { - font-size : var(--md-typeset-font-size); - } - .md-nav .md-source { - display: none; - } - - .md-sidebar { - height: 100%; - } - .md-sidebar--primary { - width: 12.1rem !important; - z-index: 200; - left: ${ - isPinned - ? 'calc(-12.1rem + 242px)' - : 'calc(-12.1rem + 72px)' - } !important; - } - .md-sidebar--secondary:not([hidden]) { - display: none; - } - - .md-content { - max-width: 100%; - margin-left: 0; - } - - .md-header__button { - margin: 0.4rem 0; - margin-left: 0.4rem; - padding: 0; - } - - .md-overlay { - left: 0; - } - - .md-footer { - position: static; - padding-left: 0; - } - .md-footer-nav__link { - /* footer links begin to overlap at small sizes without setting width */ - width: 50%; - } - } - - @media screen and (max-width: 600px) { - .md-sidebar--primary { - left: -12.1rem !important; - width: 12.1rem; - } - } - `, - }), - injectCss({ - // Typeset - css: ` - .md-typeset { - font-size: var(--md-typeset-font-size); - } - - ${headings.reduce((style, heading) => { - const styles = theme.typography[heading]; - const { lineHeight, fontFamily, fontWeight, fontSize } = styles; - const calculate = (value: typeof fontSize) => { - let factor: number | string = 1; - if (typeof value === 'number') { - // 60% of the size defined because it is too big - factor = (value / 16) * 0.6; - } - if (typeof value === 'string') { - factor = value.replace('rem', ''); - } - return `calc(${factor} * var(--md-typeset-font-size))`; - }; - return style.concat(` - .md-typeset ${heading} { - color: var(--md-default-fg-color); - line-height: ${lineHeight}; - font-family: ${fontFamily}; - font-weight: ${fontWeight}; - font-size: ${calculate(fontSize)}; - } - `); - }, '')} - - .md-typeset .md-content__button { - color: var(--md-default-fg-color); - } - - .md-typeset hr { - border-bottom: 0.05rem dotted ${theme.palette.divider}; - } - - .md-typeset details { - font-size: var(--md-typeset-font-size) !important; - } - .md-typeset details summary { - padding-left: 2.5rem !important; - } - .md-typeset details summary:before, - .md-typeset details summary:after { - top: 50% !important; - width: 20px !important; - height: 20px !important; - transform: rotate(0deg) translateY(-50%) !important; - } - .md-typeset details[open] > summary:after { - transform: rotate(90deg) translateX(-50%) !important; - } - - .md-typeset blockquote { - color: var(--md-default-fg-color--light); - border-left: 0.2rem solid var(--md-default-fg-color--light); - } - - .md-typeset table:not([class]) { - font-size: var(--md-typeset-font-size); - border: 1px solid var(--md-default-fg-color); - border-bottom: none; - border-collapse: collapse; - } - .md-typeset table:not([class]) th { - font-weight: bold; - } - .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { - border-bottom: 1px solid var(--md-default-fg-color); - } - - .md-typeset pre > code::-webkit-scrollbar-thumb { - background-color: hsla(0, 0%, 0%, 0.32); - } - .md-typeset pre > code::-webkit-scrollbar-thumb:hover { - background-color: hsla(0, 0%, 0%, 0.87); - } - `, - }), - injectCss({ - // Animations - css: ` - /* - Disable CSS animations on link colors as they lead to issues in dark mode. - The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages. - */ - .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { - transition: none; - } - `, - }), - injectCss({ - // Extensions - css: ` - /* HIGHLIGHT */ - .highlight .md-clipboard:after { - content: unset; - } - - .highlight .nx { - color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; - } - - /* CODE HILITE */ - .codehilite .gd { - background-color: ${ - isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' - }; - } - - .codehilite .gi { - background-color: ${ - isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' - }; - } - - /* TABBED */ - .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1), - .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2), - .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3), - .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4), - .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5), - .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6), - .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7), - .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8), - .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9), - .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10), - .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11), - .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12), - .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13), - .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14), - .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15), - .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16), - .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17), - .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18), - .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19), - .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) { - color: var(--md-accent-fg-color); - border-color: var(--md-accent-fg-color); - } - - /* TASK-LIST */ - .task-list-control .task-list-indicator::before { - background-color: ${theme.palette.action.disabledBackground}; - } - .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { - background-color: ${theme.palette.success.main}; - } - - /* ADMONITION */ - .admonition { - font-size: var(--md-typeset-font-size) !important; - } - .admonition .admonition-title { - padding-left: 2.5rem !important; - } - - .admonition .admonition-title:before { - top: 50% !important; - width: 20px !important; - height: 20px !important; - transform: translateY(-50%) !important; - } - `, - }), - ]), - [ - kind, - name, - namespace, - scmIntegrationsApi, - techdocsSanitizer, - techdocsStorageApi, - theme, - isDarkTheme, - isPinned, - ], - ); - - // a function that performs transformations that are executed after adding it to the DOM - const postRender = useCallback( - async (transformedElement: Element) => - transformer(transformedElement, [ - scrollIntoAnchor(), - copyToClipboard(theme), - addLinkClickListener({ - baseUrl: window.location.origin, - onClick: (event: MouseEvent, url: string) => { - // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open` - const modifierActive = event.ctrlKey || event.metaKey; - const parsedUrl = new URL(url); - - // hash exists when anchor is clicked on secondary sidebar - if (parsedUrl.hash) { - if (modifierActive) { - window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank'); - } else { - navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); - // Scroll to hash if it's on the current page - transformedElement - ?.querySelector(`#${parsedUrl.hash.slice(1)}`) - ?.scrollIntoView(); - } - } else { - if (modifierActive) { - window.open(parsedUrl.pathname, '_blank'); - } else { - navigate(parsedUrl.pathname); - // Scroll to top of reader if primary sidebar link is clicked - transformedElement - ?.querySelector('.md-content__inner') - ?.scrollIntoView(); - } - } - }, - }), - onCssReady({ - docStorageUrl: await techdocsStorageApi.getApiOrigin(), - onLoading: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.setProperty('opacity', '0'); - }, - onLoaded: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.removeProperty('opacity'); - // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) - renderedElement - .querySelector('.md-nav__title') - ?.removeAttribute('for'); - setSidebars( - Array.from(renderedElement.querySelectorAll('.md-sidebar')), - ); - }, - }), - ]), - [theme, navigate, techdocsStorageApi], - ); - - useEffect(() => { - if (!rawPage) return () => {}; - - // if false, there is already a newer execution of this effect - let shouldReplaceContent = true; - - // Pre-render - preRender(rawPage, path).then(async preTransformedDomElement => { - if (!preTransformedDomElement?.innerHTML) { - return; // An unexpected error occurred - } - - // don't manipulate the shadow dom if this isn't the latest effect execution - if (!shouldReplaceContent) { - return; - } - - // Scroll to top after render - window.scroll({ top: 0 }); - - // Post-render - const postTransformedDomElement = await postRender( - preTransformedDomElement, - ); - setDom(postTransformedDomElement as HTMLElement); - }); - - // cancel this execution - return () => { - shouldReplaceContent = false; - }; - }, [rawPage, path, preRender, postRender]); - - return dom; -}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx new file mode 100644 index 0000000000..a6cf79f1db --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -0,0 +1,790 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useContext, useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useTheme, Theme } from '@material-ui/core'; +import { lighten, alpha } from '@material-ui/core/styles'; + +import { BackstageTheme } from '@backstage/theme'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { useApi, configApiRef } from '@backstage/core-plugin-api'; +import { SidebarPinStateContext } from '@backstage/core-components'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; + +import { techdocsStorageApiRef } from '../../../api'; + +import { useTechDocsReader } from './context'; + +import { + addBaseUrl, + addGitFeedbackLink, + addLinkClickListener, + addSidebarToggle, + injectCss, + onCssReady, + removeMkdocsHeader, + rewriteDocLinks, + sanitizeDOM, + simplifyMkdocsFooter, + scrollIntoAnchor, + transform as transformer, + copyToClipboard, +} from '../../transformers'; + +type TypographyHeadings = Pick< + Theme['typography'], + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +>; +type TypographyHeadingsKeys = keyof TypographyHeadings; + +const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + +/** + * Hook that encapsulates the behavior of getting raw HTML and applying + * transforms to it in order to make it function at a basic level in the + * Backstage UI. + * + * Note: this hook is currently being exported so that we can rapidly iterate + * on alternative implementations that extend core functionality. + * There is no guarantee that this hook will continue to be exported by the + * package in the future! + * + * todo: Make public or stop exporting (see others: "altReaderExperiments") + * @internal + */ +export const useTechDocsReaderDom = ( + entityRef: CompoundEntityRef, +): Element | null => { + const navigate = useNavigate(); + const theme = useTheme(); + const techdocsStorageApi = useApi(techdocsStorageApiRef); + const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const techdocsSanitizer = useApi(configApiRef); + const { namespace = '', kind = '', name = '' } = entityRef; + const { state, path, content: rawPage } = useTechDocsReader(); + const isDarkTheme = theme.palette.type === 'dark'; + + const [sidebars, setSidebars] = useState(); + const [dom, setDom] = useState(null); + + // sidebar pinned status to be used in computing CSS style injections + const { isPinned } = useContext(SidebarPinStateContext); + + const updateSidebarPosition = useCallback(() => { + if (!dom || !sidebars) return; + // set sidebar height so they don't initially render in wrong position + const mdTabs = dom.querySelector('.md-container > .md-tabs'); + const sidebarsCollapsed = window.matchMedia( + 'screen and (max-width: 76.1875em)', + ).matches; + const newTop = Math.max(dom.getBoundingClientRect().top, 0); + sidebars.forEach(sidebar => { + if (sidebarsCollapsed) { + sidebar.style.top = '0px'; + } else if (mdTabs) { + sidebar.style.top = `${ + newTop + mdTabs.getBoundingClientRect().height + }px`; + } else { + sidebar.style.top = `${newTop}px`; + } + }); + }, [dom, sidebars]); + + useEffect(() => { + updateSidebarPosition(); + window.addEventListener('scroll', updateSidebarPosition, true); + window.addEventListener('resize', updateSidebarPosition); + return () => { + window.removeEventListener('scroll', updateSidebarPosition, true); + window.removeEventListener('resize', updateSidebarPosition); + }; + // an update to "state" might lead to an updated UI so we include it as a trigger + }, [updateSidebarPosition, state]); + + // dynamically set width of footer to accommodate for pinning of the sidebar + const updateFooterWidth = useCallback(() => { + if (!dom) return; + const footer = dom.querySelector('.md-footer') as HTMLElement; + if (footer) { + footer.style.width = `${dom.getBoundingClientRect().width}px`; + } + }, [dom]); + + useEffect(() => { + updateFooterWidth(); + window.addEventListener('resize', updateFooterWidth); + return () => { + window.removeEventListener('resize', updateFooterWidth); + }; + }); + + // a function that performs transformations that are executed prior to adding it to the DOM + const preRender = useCallback( + (rawContent: string, contentPath: string) => + transformer(rawContent, [ + sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), + addBaseUrl({ + techdocsStorageApi, + entityId: { + kind, + name, + namespace, + }, + path: contentPath, + }), + rewriteDocLinks(), + addSidebarToggle(), + removeMkdocsHeader(), + simplifyMkdocsFooter(), + addGitFeedbackLink(scmIntegrationsApi), + injectCss({ + // Variables + css: ` + /* + As the MkDocs output is rendered in shadow DOM, the CSS variable definitions on the root selector are not applied. Instead, they have to be applied on :host. + As there is no way to transform the served main*.css yet (for example in the backend), we have to copy from main*.css and modify them. + */ + :host { + /* FONT */ + --md-default-fg-color: ${theme.palette.text.primary}; + --md-default-fg-color--light: ${theme.palette.text.secondary}; + --md-default-fg-color--lighter: ${lighten( + theme.palette.text.secondary, + 0.7, + )}; + --md-default-fg-color--lightest: ${lighten( + theme.palette.text.secondary, + 0.3, + )}; + + /* BACKGROUND */ + --md-default-bg-color:${theme.palette.background.default}; + --md-default-bg-color--light: ${theme.palette.background.paper}; + --md-default-bg-color--lighter: ${lighten( + theme.palette.background.paper, + 0.7, + )}; + --md-default-bg-color--lightest: ${lighten( + theme.palette.background.paper, + 0.3, + )}; + + /* PRIMARY */ + --md-primary-fg-color: ${theme.palette.primary.main}; + --md-primary-fg-color--light: ${theme.palette.primary.light}; + --md-primary-fg-color--dark: ${theme.palette.primary.dark}; + --md-primary-bg-color: ${theme.palette.primary.contrastText}; + --md-primary-bg-color--light: ${lighten( + theme.palette.primary.contrastText, + 0.7, + )}; + + /* ACCENT */ + --md-accent-fg-color: var(--md-primary-fg-color); + + /* SHADOW */ + --md-shadow-z1: ${theme.shadows[1]}; + --md-shadow-z2: ${theme.shadows[2]}; + --md-shadow-z3: ${theme.shadows[3]}; + + /* EXTENSIONS */ + --md-admonition-fg-color: var(--md-default-fg-color); + --md-admonition-bg-color: var(--md-default-bg-color); + /* Admonitions and others are using SVG masks to define icons. These masks are defined as CSS variables. */ + --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); + --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--prev: url('data:image/svg+xml;charset=utf-8,'); + --md-nav-icon--next: url('data:image/svg+xml;charset=utf-8,'); + --md-toc-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-clipboard-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-search-result-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-forks-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-repositories-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-stars-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-source-version-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-version-icon: url('data:image/svg+xml;charset=utf-8,'); + } + + :host > * { + /* CODE */ + --md-code-fg-color: ${theme.palette.text.primary}; + --md-code-bg-color: ${theme.palette.background.paper}; + --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; + --md-code-hl-keyword-color: ${ + isDarkTheme + ? theme.palette.primary.light + : theme.palette.primary.dark + }; + --md-code-hl-function-color: ${ + isDarkTheme + ? theme.palette.secondary.light + : theme.palette.secondary.dark + }; + --md-code-hl-string-color: ${ + isDarkTheme + ? theme.palette.success.light + : theme.palette.success.dark + }; + --md-code-hl-number-color: ${ + isDarkTheme + ? theme.palette.error.light + : theme.palette.error.dark + }; + --md-code-hl-constant-color: var(--md-code-hl-function-color); + --md-code-hl-special-color: var(--md-code-hl-function-color); + --md-code-hl-name-color: var(--md-code-fg-color); + --md-code-hl-comment-color: var(--md-default-fg-color--light); + --md-code-hl-generic-color: var(--md-default-fg-color--light); + --md-code-hl-variable-color: var(--md-default-fg-color--light); + --md-code-hl-operator-color: var(--md-default-fg-color--light); + --md-code-hl-punctuation-color: var(--md-default-fg-color--light); + + /* TYPESET */ + --md-typeset-font-size: 1rem; + --md-typeset-color: var(--md-default-fg-color); + --md-typeset-a-color: var(--md-accent-fg-color); + --md-typeset-table-color: ${theme.palette.text.primary}; + --md-typeset-del-color: ${ + isDarkTheme + ? alpha(theme.palette.error.dark, 0.5) + : alpha(theme.palette.error.light, 0.5) + }; + --md-typeset-ins-color: ${ + isDarkTheme + ? alpha(theme.palette.success.dark, 0.5) + : alpha(theme.palette.success.light, 0.5) + }; + --md-typeset-mark-color: ${ + isDarkTheme + ? alpha(theme.palette.warning.dark, 0.5) + : alpha(theme.palette.warning.light, 0.5) + }; + } + + @media screen and (max-width: 76.1875em) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .9rem; + } + } + + @media screen and (max-width: 600px) { + :host > * { + /* TYPESET */ + --md-typeset-font-size: .7rem; + } + } + `, + }), + injectCss({ + // Reset + css: ` + body { + --md-text-color: var(--md-default-fg-color); + --md-text-link-color: var(--md-accent-fg-color); + --md-text-font-family: ${theme.typography.fontFamily}; + font-family: var(--md-text-font-family); + background-color: unset; + } + `, + }), + injectCss({ + // Layout + css: ` + .md-grid { + max-width: 100%; + margin: 0; + } + + .md-nav { + font-size: calc(var(--md-typeset-font-size) * 0.9); + } + .md-nav__link { + display: flex; + align-items: center; + justify-content: space-between; + } + .md-nav__icon { + height: 20px !important; + width: 20px !important; + margin-left:${theme.spacing(1)}px; + } + .md-nav__icon svg { + margin: 0; + width: 20px !important; + height: 20px !important; + } + .md-nav__icon:after { + width: 20px !important; + height: 20px !important; + } + + .md-main__inner { + margin-top: 0; + } + + .md-sidebar { + bottom: 75px; + position: fixed; + width: 16rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-color: rgb(193, 193, 193) #eee; + scrollbar-width: thin; + } + .md-sidebar .md-sidebar__scrollwrap { + width: calc(16rem - 10px); + } + .md-sidebar--secondary { + right: ${theme.spacing(3)}px; + } + .md-sidebar::-webkit-scrollbar { + width: 5px; + } + .md-sidebar::-webkit-scrollbar-button { + width: 5px; + height: 5px; + } + .md-sidebar::-webkit-scrollbar-track { + background: #eee; + border: 1 px solid rgb(250, 250, 250); + box-shadow: 0px 0px 3px #dfdfdf inset; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb { + width: 5px; + background: rgb(193, 193, 193); + border: transparent; + border-radius: 3px; + } + .md-sidebar::-webkit-scrollbar-thumb:hover { + background: rgb(125, 125, 125); + } + + .md-content { + max-width: calc(100% - 16rem * 2); + margin-left: 16rem; + margin-bottom: 50px; + } + + .md-footer { + position: fixed; + bottom: 0px; + } + .md-footer__title { + background-color: unset; + } + .md-footer-nav__link { + width: 16rem; + } + + .md-dialog { + background-color: unset; + } + + @media screen and (min-width: 76.25em) { + .md-sidebar { + height: auto; + } + } + + @media screen and (max-width: 76.1875em) { + .md-nav { + transition: none !important; + background-color: var(--md-default-bg-color) + } + .md-nav--primary .md-nav__title { + cursor: auto; + color: var(--md-default-fg-color); + font-weight: 700; + white-space: normal; + line-height: 1rem; + height: auto; + display: flex; + flex-flow: column; + row-gap: 1.6rem; + padding: 1.2rem .8rem .8rem; + background-color: var(--md-default-bg-color); + } + .md-nav--primary .md-nav__title~.md-nav__list { + box-shadow: none; + } + .md-nav--primary .md-nav__title ~ .md-nav__list > :first-child { + border-top: none; + } + .md-nav--primary .md-nav__title .md-nav__button { + display: none; + } + .md-nav--primary .md-nav__title .md-nav__icon { + color: var(--md-default-fg-color); + position: static; + height: auto; + margin: 0 0 0 -0.2rem; + } + .md-nav--primary > .md-nav__title [for="none"] { + padding-top: 0; + } + .md-nav--primary .md-nav__item { + border-top: none; + } + .md-nav--primary :is(.md-nav__title,.md-nav__item) { + font-size : var(--md-typeset-font-size); + } + .md-nav .md-source { + display: none; + } + + .md-sidebar { + height: 100%; + } + .md-sidebar--primary { + width: 12.1rem !important; + z-index: 200; + left: ${ + isPinned + ? 'calc(-12.1rem + 242px)' + : 'calc(-12.1rem + 72px)' + } !important; + } + .md-sidebar--secondary:not([hidden]) { + display: none; + } + + .md-content { + max-width: 100%; + margin-left: 0; + } + + .md-header__button { + margin: 0.4rem 0; + margin-left: 0.4rem; + padding: 0; + } + + .md-overlay { + left: 0; + } + + .md-footer { + position: static; + padding-left: 0; + } + .md-footer-nav__link { + /* footer links begin to overlap at small sizes without setting width */ + width: 50%; + } + } + + @media screen and (max-width: 600px) { + .md-sidebar--primary { + left: -12.1rem !important; + width: 12.1rem; + } + } + `, + }), + injectCss({ + // Typeset + css: ` + .md-typeset { + font-size: var(--md-typeset-font-size); + } + + ${headings.reduce((style, heading) => { + const styles = theme.typography[heading]; + const { lineHeight, fontFamily, fontWeight, fontSize } = styles; + const calculate = (value: typeof fontSize) => { + let factor: number | string = 1; + if (typeof value === 'number') { + // 60% of the size defined because it is too big + factor = (value / 16) * 0.6; + } + if (typeof value === 'string') { + factor = value.replace('rem', ''); + } + return `calc(${factor} * var(--md-typeset-font-size))`; + }; + return style.concat(` + .md-typeset ${heading} { + color: var(--md-default-fg-color); + line-height: ${lineHeight}; + font-family: ${fontFamily}; + font-weight: ${fontWeight}; + font-size: ${calculate(fontSize)}; + } + `); + }, '')} + + .md-typeset .md-content__button { + color: var(--md-default-fg-color); + } + + .md-typeset hr { + border-bottom: 0.05rem dotted ${theme.palette.divider}; + } + + .md-typeset details { + font-size: var(--md-typeset-font-size) !important; + } + .md-typeset details summary { + padding-left: 2.5rem !important; + } + .md-typeset details summary:before, + .md-typeset details summary:after { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: rotate(0deg) translateY(-50%) !important; + } + .md-typeset details[open] > summary:after { + transform: rotate(90deg) translateX(-50%) !important; + } + + .md-typeset blockquote { + color: var(--md-default-fg-color--light); + border-left: 0.2rem solid var(--md-default-fg-color--light); + } + + .md-typeset table:not([class]) { + font-size: var(--md-typeset-font-size); + border: 1px solid var(--md-default-fg-color); + border-bottom: none; + border-collapse: collapse; + } + .md-typeset table:not([class]) th { + font-weight: bold; + } + .md-typeset table:not([class]) td, .md-typeset table:not([class]) th { + border-bottom: 1px solid var(--md-default-fg-color); + } + + .md-typeset pre > code::-webkit-scrollbar-thumb { + background-color: hsla(0, 0%, 0%, 0.32); + } + .md-typeset pre > code::-webkit-scrollbar-thumb:hover { + background-color: hsla(0, 0%, 0%, 0.87); + } + `, + }), + injectCss({ + // Animations + css: ` + /* + Disable CSS animations on link colors as they lead to issues in dark mode. + The dark mode color theme is applied later and theirfore there is always an animation from light to dark mode when navigation between pages. + */ + .md-dialog, .md-nav__link, .md-footer__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, + }), + injectCss({ + // Extensions + css: ` + /* HIGHLIGHT */ + .highlight .md-clipboard:after { + content: unset; + } + + .highlight .nx { + color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; + } + + /* CODE HILITE */ + .codehilite .gd { + background-color: ${ + isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' + }; + } + + .codehilite .gi { + background-color: ${ + isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' + }; + } + + /* TABBED */ + .tabbed-set>input:nth-child(1):checked~.tabbed-labels>:nth-child(1), + .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2), + .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3), + .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4), + .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5), + .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6), + .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7), + .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8), + .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9), + .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10), + .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11), + .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12), + .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13), + .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14), + .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15), + .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16), + .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17), + .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18), + .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19), + .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20) { + color: var(--md-accent-fg-color); + border-color: var(--md-accent-fg-color); + } + + /* TASK-LIST */ + .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + + /* ADMONITION */ + .admonition { + font-size: var(--md-typeset-font-size) !important; + } + .admonition .admonition-title { + padding-left: 2.5rem !important; + } + + .admonition .admonition-title:before { + top: 50% !important; + width: 20px !important; + height: 20px !important; + transform: translateY(-50%) !important; + } + `, + }), + ]), + [ + kind, + name, + namespace, + scmIntegrationsApi, + techdocsSanitizer, + techdocsStorageApi, + theme, + isDarkTheme, + isPinned, + ], + ); + + // a function that performs transformations that are executed after adding it to the DOM + const postRender = useCallback( + async (transformedElement: Element) => + transformer(transformedElement, [ + scrollIntoAnchor(), + copyToClipboard(theme), + addLinkClickListener({ + baseUrl: window.location.origin, + onClick: (event: MouseEvent, url: string) => { + // detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open` + const modifierActive = event.ctrlKey || event.metaKey; + const parsedUrl = new URL(url); + + // hash exists when anchor is clicked on secondary sidebar + if (parsedUrl.hash) { + if (modifierActive) { + window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank'); + } else { + navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + // Scroll to hash if it's on the current page + transformedElement + ?.querySelector(`#${parsedUrl.hash.slice(1)}`) + ?.scrollIntoView(); + } + } else { + if (modifierActive) { + window.open(parsedUrl.pathname, '_blank'); + } else { + navigate(parsedUrl.pathname); + // Scroll to top of reader if primary sidebar link is clicked + transformedElement + ?.querySelector('.md-content__inner') + ?.scrollIntoView(); + } + } + }, + }), + onCssReady({ + docStorageUrl: await techdocsStorageApi.getApiOrigin(), + onLoading: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.setProperty('opacity', '0'); + }, + onLoaded: (renderedElement: Element) => { + (renderedElement as HTMLElement).style.removeProperty('opacity'); + // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) + renderedElement + .querySelector('.md-nav__title') + ?.removeAttribute('for'); + setSidebars( + Array.from(renderedElement.querySelectorAll('.md-sidebar')), + ); + }, + }), + ]), + [theme, navigate, techdocsStorageApi], + ); + + useEffect(() => { + if (!rawPage) return () => {}; + + // if false, there is already a newer execution of this effect + let shouldReplaceContent = true; + + // Pre-render + preRender(rawPage, path).then(async preTransformedDomElement => { + if (!preTransformedDomElement?.innerHTML) { + return; // An unexpected error occurred + } + + // don't manipulate the shadow dom if this isn't the latest effect execution + if (!shouldReplaceContent) { + return; + } + + // Scroll to top after render + window.scroll({ top: 0 }); + + // Post-render + const postTransformedDomElement = await postRender( + preTransformedDomElement, + ); + setDom(postTransformedDomElement as HTMLElement); + }); + + // cancel this execution + return () => { + shouldReplaceContent = false; + }; + }, [rawPage, path, preRender, postRender]); + + return dom; +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts index d3da3d6aa4..288969e73b 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts @@ -16,3 +16,4 @@ export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; export * from './context'; +export * from './dom'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index c417274403..3f9a5b9e1f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -33,11 +33,7 @@ import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import { Header, HeaderLabel } from '@backstage/core-components'; import { useRouteRef, configApiRef, useApi } from '@backstage/core-plugin-api'; -import { - useTechDocsReaderPage, - useTechDocsMetadata, - useEntityMetadata, -} from '../TechDocsReaderPage'; +import { useTechDocsReaderPage } from '../TechDocsReaderPage'; import { rootRouteRef } from '../../../routes'; @@ -47,31 +43,30 @@ export const TechDocsReaderPageHeader: FC = ({ children }) => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); - const { value: entityMetadata } = useEntityMetadata(); - const { value: techDocsMetadata } = useTechDocsMetadata(); - const { title, setTitle, subtitle, setSubtitle, - entityName: entityRef, + entityName, + metadata: { value: metadata }, + entityMetadata: { value: entityMetadata }, } = useTechDocsReaderPage(); useEffect(() => { - if (!techDocsMetadata) return; + if (!metadata) return; setTitle(prevTitle => { - const { site_name } = techDocsMetadata; + const { site_name } = metadata; return prevTitle || site_name; }); setSubtitle(prevSubtitle => { - let { site_description } = techDocsMetadata; + let { site_description } = metadata; if (site_description === 'None') { site_description = 'Home'; } return prevSubtitle || site_description; }); - }, [techDocsMetadata, setTitle, setSubtitle]); + }, [metadata, setTitle, setSubtitle]); const appTitle = configApi.getOptional('app.title') || 'Backstage'; const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | '); @@ -92,7 +87,7 @@ export const TechDocsReaderPageHeader: FC = ({ children }) => { value={ } From 3fdc1b4f71b7af0d0798ff7560351be78877cfa6 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 1 Apr 2022 00:51:00 +0200 Subject: [PATCH 22/47] fix(techdocs): add backwards compatibility Signed-off-by: Camila Belo --- packages/app/src/App.tsx | 3 +- .../src/components/techdocs/TechDocsPage.tsx | 5 +- plugins/techdocs/src/EntityPageDocs.tsx | 6 +- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 60 ++++++++++++++----- .../TechDocsReaderPage/context.test.tsx | 14 +---- .../TechDocsReaderPageContent.tsx | 53 ++++++++-------- .../TechDocsReaderPageHeader.test.tsx | 18 ++---- .../TechDocsReaderPageHeader.tsx | 3 +- 8 files changed, 90 insertions(+), 72 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index d792f2ed44..51012b042a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -184,8 +184,9 @@ const routes = ( } /> {techDocsPage}} + element={} > + {techDocsPage} diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx index 170d4f8f77..89d0c74ad5 100644 --- a/packages/app/src/components/techdocs/TechDocsPage.tsx +++ b/packages/app/src/components/techdocs/TechDocsPage.tsx @@ -15,6 +15,7 @@ */ import { + TechDocsReaderPage, TechDocsReaderPageHeader, TechDocsReaderPageContent, } from '@backstage/plugin-techdocs'; @@ -22,10 +23,10 @@ import React from 'react'; const DefaultTechDocsPage = () => { return ( - <> + - + ); }; diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 6cb11a7b25..29f44f2931 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -24,11 +24,11 @@ import { TechDocsReaderLayout } from './reader'; type EntityPageDocsProps = { entity: Entity }; export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => { - const entityName = getCompoundEntityRef(entity); + const entityRef = getCompoundEntityRef(entity); return ( - - + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index b55f42e984..f58fb610b4 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ -import React, { ReactNode } from 'react'; -import { useParams } from 'react-router-dom'; +import React, { ReactNode, ReactChild, Children } from 'react'; +import { useOutlet, useParams } from 'react-router-dom'; import { Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; +import { TECHDOCS_ADDONS_WRAPPER_KEY } from '@backstage/techdocs-addons'; import { TechDocsReaderPageRenderFunction } from '../../../types'; @@ -28,27 +29,37 @@ import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; import { TechDocsReaderPageProvider } from './context'; +type Extension = ReactChild & { + type: { + __backstage_data: { + map: Map; + }; + }; +}; + export type TechDocsReaderLayoutProps = { - hideHeader?: boolean; + withHeader?: boolean; withSearch?: boolean; }; export const TechDocsReaderLayout = ({ - hideHeader = false, withSearch, -}: TechDocsReaderLayoutProps) => ( - <> - {!hideHeader && } - - - -); + withHeader = true, +}: TechDocsReaderLayoutProps) => { + return ( + <> + {withHeader && } + + + + ); +}; /** * @public */ export type TechDocsReaderPageProps = { - entityName?: CompoundEntityRef; + entityRef?: CompoundEntityRef; children?: TechDocsReaderPageRenderFunction | ReactNode; }; @@ -57,12 +68,31 @@ export type TechDocsReaderPageProps = { * @public */ export const TechDocsReaderPage = ({ - entityName: defaultEntityName, - children = , + entityRef, + children, }: TechDocsReaderPageProps) => { const { kind, name, namespace } = useParams(); + const route = useOutlet() || { props: { children: [] } }; + const entityName = entityRef ?? { kind, name, namespace }; - const entityName = defaultEntityName || { kind, name, namespace }; + if (!children) { + const outlet = Children.toArray(route.props.children); + + const page = outlet.find(child => { + const { type } = child as Extension; + return !type?.__backstage_data?.map?.get(TECHDOCS_ADDONS_WRAPPER_KEY); + }); + + return ( + (page as JSX.Element) || ( + + + + + + ) + ); + } return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx index f27e5aeb58..8ec0f9fd55 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.test.tsx @@ -30,8 +30,6 @@ import { useEntityMetadata, useTechDocsMetadata, useTechDocsReaderPage, - TechDocsEntityProvider, - TechDocsMetadataProvider, TechDocsReaderPageProvider, } from './context'; @@ -65,7 +63,6 @@ const techdocsApiMock = { }; const wrapper = ({ - path = '', entityName = { kind: mockEntityMetadata.kind, name: mockEntityMetadata.metadata.name, @@ -73,19 +70,14 @@ const wrapper = ({ }, children, }: { - path?: string; entityName?: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - {children} - - - + + {children} + ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 4a823a9e7c..b5cfd38e3c 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { create } from 'jss'; import { makeStyles, Grid, Portal } from '@material-ui/core'; @@ -45,16 +45,16 @@ const useStyles = makeStyles({ export type TechDocsReaderPageContentProps = { withSearch?: boolean; + onReady?: () => void; }; export const TechDocsReaderPageContent = withTechDocsReaderProvider( - ({ withSearch = true }: TechDocsReaderPageContentProps) => { + ({ withSearch = true, onReady }: TechDocsReaderPageContentProps) => { const classes = useStyles(); const addons = useTechDocsAddons(); - const { entityName, setShadowRoot } = useTechDocsReaderPage(); + const { entityName, shadowRoot, setShadowRoot } = useTechDocsReaderPage(); const dom = useTechDocsReaderDom(entityName); - const ref = useRef(null); const [jss, setJss] = useState( create({ ...jssPreset(), @@ -62,31 +62,36 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( }), ); - useEffect(() => { - const shadowHost = ref.current; - if (!dom || !shadowHost) return; + const ref = useCallback( + (shadowHost: HTMLDivElement) => { + if (!dom || !shadowHost) return; - setJss( - create({ - ...jssPreset(), - insertionPoint: dom.querySelector('head') || undefined, - }), - ); + setJss( + create({ + ...jssPreset(), + insertionPoint: dom.querySelector('head') || undefined, + }), + ); - const shadowRoot = - shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = ''; - shadowRoot.appendChild(dom); - setShadowRoot(shadowRoot); - }, [dom, setShadowRoot]); - - const contentElement = ref.current?.querySelector( - '[data-md-component="container"]', + const newShadowRoot = + shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); + newShadowRoot.innerHTML = ''; + newShadowRoot.appendChild(dom); + setShadowRoot(newShadowRoot); + if (onReady instanceof Function) { + onReady(); + } + }, + [dom, setShadowRoot, onReady], ); - const primarySidebarElement = ref.current?.querySelector( + + const contentElement = shadowRoot?.querySelector( + '[data-md-component="content"]', + ); + const primarySidebarElement = shadowRoot?.querySelector( 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', ); - const secondarySidebarElement = ref.current?.querySelector( + const secondarySidebarElement = shadowRoot?.querySelector( 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx index 3fb05a400e..0ace77c2ed 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx @@ -26,11 +26,7 @@ import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { techdocsApiRef } from '../../../api'; import { rootRouteRef } from '../../../routes'; -import { - TechDocsEntityProvider, - TechDocsMetadataProvider, - TechDocsReaderPageProvider, -} from '../TechDocsReaderPage'; +import { TechDocsReaderPageProvider } from '../TechDocsReaderPage'; import { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; @@ -64,7 +60,6 @@ const techdocsApiMock = { }; const Wrapper = ({ - path = '', entityName = { kind: mockEntityMetadata.kind, name: mockEntityMetadata.metadata.name, @@ -72,19 +67,14 @@ const Wrapper = ({ }, children, }: { - path?: string; entityName?: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - {children} - - - + + {children} + ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index 3f9a5b9e1f..8875581473 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -39,7 +39,7 @@ import { rootRouteRef } from '../../../routes'; const skeleton = ; -export const TechDocsReaderPageHeader: FC = ({ children }) => { +export const TechDocsReaderPageHeader = () => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); @@ -135,7 +135,6 @@ export const TechDocsReaderPageHeader: FC = ({ children }) => { {tabTitle} {labels} - {children} {addons.renderComponentsByLocation(locations.HEADER)}
); From 2605805cb56c277220184861d440612a117425f5 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 1 Apr 2022 07:49:28 +0200 Subject: [PATCH 23/47] fix(techdocs): embed cli app reader Signed-off-by: Camila Belo --- .../techdocs-cli-embedded-app/package.json | 1 + .../techdocs-cli-embedded-app/src/App.tsx | 28 +++++++++++++++---- .../components/TechDocsPage/TechDocsPage.tsx | 16 +++++------ .../TechDocsReaderPageHeader.tsx | 2 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/techdocs-cli-embedded-app/package.json b/packages/techdocs-cli-embedded-app/package.json index 8771d5c14b..c7c65ad55f 100644 --- a/packages/techdocs-cli-embedded-app/package.json +++ b/packages/techdocs-cli-embedded-app/package.json @@ -17,6 +17,7 @@ "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog": "^1.1.0-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", + "@backstage/techdocs-addons": "^0.0.0", "@backstage/test-utils": "^1.0.1-next.1", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.11.0", diff --git a/packages/techdocs-cli-embedded-app/src/App.tsx b/packages/techdocs-cli-embedded-app/src/App.tsx index 51bdfbbb11..4583a1bc0e 100644 --- a/packages/techdocs-cli-embedded-app/src/App.tsx +++ b/packages/techdocs-cli-embedded-app/src/App.tsx @@ -16,20 +16,27 @@ import React from 'react'; import { Navigate, Route } from 'react-router'; -import { createApp } from '@backstage/app-defaults'; -import { FlatRoutes } from '@backstage/core-app-api'; -import { CatalogEntityPage } from '@backstage/plugin-catalog'; import { DefaultTechDocsHome, TechDocsIndexPage, TechDocsReaderPage, + techdocsPlugin, } from '@backstage/plugin-techdocs'; +import { + createTechDocsAddon, + TechDocsAddons, + TechDocsAddonLocations, +} from '@backstage/techdocs-addons'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + import { apis } from './apis'; -import { Root } from './components/Root'; -import { techDocsPage } from './components/TechDocsPage'; import * as plugins from './plugins'; import { configLoader } from './config'; +import { Root } from './components/Root'; +import { techDocsPage, TechDocsThemeToggle } from './components/TechDocsPage'; const app = createApp({ apis, @@ -40,6 +47,14 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); +const ThemeToggleAddon = techdocsPlugin.provide( + createTechDocsAddon({ + name: 'ThemeToggleAddon', + component: TechDocsThemeToggle, + location: TechDocsAddonLocations.HEADER, + }), +); + const routes = ( @@ -56,6 +71,9 @@ const routes = ( element={} > {techDocsPage} + + + ); diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index 7bfe7642ff..8c44336d73 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -92,7 +92,7 @@ const TechdocsThemeProvider: FC = ({ children }) => { const useTechDocsTheme = () => useContext(TechDocsThemeContext); -const TechDocsThemeToggle = () => { +export const TechDocsThemeToggle = () => { const classes = useStyles(); const { theme, toggleTheme } = useTechDocsTheme(); @@ -121,12 +121,10 @@ const TechDocsThemeToggle = () => { }; export const techDocsPage = ( - - - - - - - - + + + + + + ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index 8875581473..45ebe3ae0f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; import Helmet from 'react-helmet'; import { Skeleton } from '@material-ui/lab'; From 914cfdb0b7f117c7abdce3553ef319a26aec327c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 1 Apr 2022 11:15:23 +0200 Subject: [PATCH 24/47] fix(techdocs): update api reports Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- packages/techdocs-addons/api-report.md | 59 ++++++++ packages/techdocs-addons/src/addons.tsx | 4 + plugins/techdocs/api-report.md | 141 ++++++++++++++++-- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 17 ++- .../components/TechDocsReaderPage/context.tsx | 31 +++- .../TechDocsReaderPageContent.tsx | 37 ++++- .../TechDocsReaderPageContent/context.tsx | 38 ++--- .../TechDocsReaderPageContent/index.ts | 3 +- .../TechDocsReaderPageHeader.tsx | 31 +++- .../TechDocsReaderPageHeader/index.ts | 1 + .../techdocs/src/reader/components/index.ts | 4 + .../src/reader/components/useReaderState.ts | 23 +-- plugins/techdocs/src/types.ts | 4 + scripts/api-extractor.ts | 2 +- 14 files changed, 338 insertions(+), 57 deletions(-) create mode 100644 packages/techdocs-addons/api-report.md diff --git a/packages/techdocs-addons/api-report.md b/packages/techdocs-addons/api-report.md new file mode 100644 index 0000000000..0afcb9f3a3 --- /dev/null +++ b/packages/techdocs-addons/api-report.md @@ -0,0 +1,59 @@ +## API Report File for "@backstage/techdocs-addons" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AsyncState } from 'react-use/lib/useAsyncFn'; +import { ComponentType } from 'react'; +import { Extension } from '@backstage/core-plugin-api'; +import { default as React_2 } from 'react'; + +// @public +export function createTechDocsAddon( + options: TechDocsAddonOptions, +): Extension>; + +// @public +export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1'; + +// @public +export type TechDocsAddonAsyncMetadata = AsyncState; + +// @public +export enum TechDocsAddonLocations { + COMPONENT = 'component', + CONTENT = 'content', + HEADER = 'header', + PRIMARY_SIDEBAR = 'primary sidebar', + SECONDARY_SIDEBAR = 'secondary sidebar', + SUBHEADER = 'subheader', +} + +// @public +export type TechDocsAddonOptions = { + name: string; + location: TechDocsAddonLocations; + component: ComponentType; +}; + +// @public +export const TechDocsAddons: React_2.ComponentType; + +// @public +export const useTechDocsAddons: () => { + renderComponentByName: (name: string) => React_2.ReactElement< + { + [name: string]: unknown; + }, + string | React_2.JSXElementConstructor + > | null; + renderComponentsByLocation: (location: TechDocsAddonLocations) => + | (React_2.ReactElement< + { + [name: string]: unknown; + }, + string | React_2.JSXElementConstructor + > | null)[] + | null; +}; +``` diff --git a/packages/techdocs-addons/src/addons.tsx b/packages/techdocs-addons/src/addons.tsx index 0eb102aa24..60fe13bfeb 100644 --- a/packages/techdocs-addons/src/addons.tsx +++ b/packages/techdocs-addons/src/addons.tsx @@ -91,6 +91,10 @@ const getAllTechDocsAddonsData = (collection: ElementCollection) => { }); }; +/** + * hook to use addons in components + * @public + */ export const useTechDocsAddons = () => { const node = useOutlet(); const collection = useElementFilter(node, getAllTechDocsAddons); diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index 0af87a2838..7909621c92 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -6,11 +6,13 @@ /// import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from 'react-use/lib/useAsync'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { CSSProperties } from '@material-ui/styles'; import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { Dispatch } from 'react'; import { Entity } from '@backstage/catalog-model'; import { FetchApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -18,10 +20,28 @@ import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SetStateAction } from 'react'; import { TableColumn } from '@backstage/core-components'; import { TableProps } from '@backstage/core-components'; import { UserListFilterKind } from '@backstage/plugin-catalog-react'; +// @public +export type ContentStateTypes = + /** There is nothing to display but a loading indicator */ + | 'CHECKING' + /** There is no content yet -> present a full screen loading page */ + | 'INITIAL_BUILD' + /** There is content, but the backend is about to update it */ + | 'CONTENT_STALE_REFRESHING' + /** There is content, but after a reload, the content will be different */ + | 'CONTENT_STALE_READY' + /** There is content, the backend tried to update it, but failed */ + | 'CONTENT_STALE_ERROR' + /** There is nothing to see but a "not found" page. Is also shown on page load errors */ + | 'CONTENT_NOT_FOUND' + /** There is only the latest and greatest content */ + | 'CONTENT_FRESH'; + // @public export const DefaultTechDocsHome: ( props: DefaultTechDocsHomeProps, @@ -155,13 +175,14 @@ export interface PanelConfig { export type PanelType = 'DocsCardGrid' | 'DocsTable'; // @public -export const Reader: (props: ReaderProps) => JSX.Element; - -// @public -export type ReaderProps = { - entityRef: CompoundEntityRef; - withSearch?: boolean; - onReady?: () => void; +export type ReaderState = { + state: ContentStateTypes; + path: string; + contentReload: () => void; + content?: string; + contentErrorMessage?: string; + syncErrorMessage?: string; + buildLog: string[]; }; // @public @@ -275,27 +296,69 @@ export { techdocsPlugin as plugin }; export { techdocsPlugin }; // @public -export const TechDocsReaderPage: ( - props: TechDocsReaderPageProps, +export const TechDocsReaderLayout: ({ + withSearch, + withHeader, +}: TechDocsReaderLayoutProps) => JSX.Element; + +// @public +export type TechDocsReaderLayoutProps = { + withHeader?: boolean; + withSearch?: boolean; +}; + +// @public +export const TechDocsReaderPage: ({ + entityRef, + children, +}: TechDocsReaderPageProps) => JSX.Element; + +// @public +export const TechDocsReaderPageContent: ( + props: TechDocsReaderPageContentProps, ) => JSX.Element; +// @public +export type TechDocsReaderPageContentProps = { + entityRef?: CompoundEntityRef; + withSearch?: boolean; + onReady?: () => void; +}; + // @public export const TechDocsReaderPageHeader: ( - props: TechDocsReaderPageHeaderProps, + _props: TechDocsReaderPageHeaderProps, ) => JSX.Element; -// @public +// @public @deprecated export type TechDocsReaderPageHeaderProps = PropsWithChildren<{ - entityRef: CompoundEntityRef; + entityRef?: CompoundEntityRef; entityMetadata?: TechDocsEntityMetadata; techDocsMetadata?: TechDocsMetadata; }>; -// @public +// @public (undocumented) export type TechDocsReaderPageProps = { - children?: TechDocsReaderPageRenderFunction | React_2.ReactNode; + entityRef?: CompoundEntityRef; + children?: TechDocsReaderPageRenderFunction | ReactNode; }; +// @public +export const TechDocsReaderPageProvider: React_2.MemoExoticComponent< + ({ entityName, children }: TechDocsReaderPageProviderProps) => JSX.Element +>; + +// @public +export type TechDocsReaderPageProviderProps = { + entityName: CompoundEntityRef; + children: TechDocsReaderPageProviderRenderFunction | ReactNode; +}; + +// @public +export type TechDocsReaderPageProviderRenderFunction = ( + value: TechDocsReaderPageValue, +) => JSX.Element; + // @public export type TechDocsReaderPageRenderFunction = ({ techdocsMetadataValue, @@ -305,9 +368,38 @@ export type TechDocsReaderPageRenderFunction = ({ techdocsMetadataValue?: TechDocsMetadata | undefined; entityMetadataValue?: TechDocsEntityMetadata | undefined; entityRef: CompoundEntityRef; - onReady: () => void; + onReady?: () => void; }) => JSX.Element; +// @public +export type TechDocsReaderPageValue = { + metadata: AsyncState; + entityName: CompoundEntityRef; + entityMetadata: AsyncState; + shadowRoot?: ShadowRoot; + setShadowRoot: Dispatch>; + title: string; + setTitle: Dispatch>; + subtitle: string; + setSubtitle: Dispatch>; + onReady?: () => void; +}; + +// @public +export const TechDocsReaderProvider: ({ + children, +}: TechDocsReaderProviderProps) => JSX.Element; + +// @public +export type TechDocsReaderProviderProps = { + children: TechDocsReaderProviderRenderFunction | ReactNode; +}; + +// @public +export type TechDocsReaderProviderRenderFunction = ( + value: ReaderState, +) => JSX.Element; + // @public export const TechDocsSearch: (props: TechDocsSearchProps) => JSX.Element; @@ -389,4 +481,23 @@ export class TechDocsStorageClient implements TechDocsStorageApi { logHandler?: (line: string) => void, ): Promise; } + +// @public +export const useEntityMetadata: () => AsyncState; + +// @public +export const useShadowRoot: () => ShadowRoot | undefined; + +// @public +export const useShadowRootElements: < + TReturnedElement extends HTMLElement = HTMLElement, +>( + selectors: string[], +) => TReturnedElement[]; + +// @public +export const useTechDocsMetadata: () => AsyncState; + +// @public +export const useTechDocsReaderPage: () => TechDocsReaderPageValue; ``` diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index f58fb610b4..fd80ae3af6 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -37,11 +37,25 @@ type Extension = ReactChild & { }; }; +/** + * Props for {@link TechDocsReaderLayout} + * @public + */ export type TechDocsReaderLayoutProps = { + /** + * Show or hide the header, defaults to true. + */ withHeader?: boolean; + /** + * Show or hide the content search bar, defaults to true. + */ withSearch?: boolean; }; +/** + * Default TechDocs reader page structure composed with a header and content + * @public + */ export const TechDocsReaderLayout = ({ withSearch, withHeader = true, @@ -96,13 +110,14 @@ export const TechDocsReaderPage = ({ return ( - {({ metadata, entityMetadata }) => ( + {({ metadata, entityMetadata, onReady }) => ( {children instanceof Function ? children({ entityRef: entityName, techdocsMetadataValue: metadata.value, entityMetadataValue: entityMetadata.value, + onReady, }) : children} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx index bd1c884ca0..a2c1897cbe 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/context.tsx @@ -47,6 +47,10 @@ const areEntityNamesEqual = ( return true; }; +/** + * @public type for the value of the TechDocsReaderPageContext + */ + export type TechDocsReaderPageValue = { metadata: AsyncState; entityName: CompoundEntityRef; @@ -57,6 +61,10 @@ export type TechDocsReaderPageValue = { setTitle: Dispatch>; subtitle: string; setSubtitle: Dispatch>; + /** + * @deprecated property can be passed down directly to the `TechDocsReaderPageContent` instead. + */ + onReady?: () => void; }; export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { @@ -73,20 +81,37 @@ export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = { export const TechDocsReaderPageContext = createContext( defaultTechDocsReaderPageValue, ); - +/** + * Hook used to get access to shared state between reader page components. + * @public + */ export const useTechDocsReaderPage = () => { return useContext(TechDocsReaderPageContext); }; -type TechDocsReaderPageProviderRenderFunction = ( +/** + * render function for {@link TechDocsReaderPageProvider} + * + * @public + */ +export type TechDocsReaderPageProviderRenderFunction = ( value: TechDocsReaderPageValue, ) => JSX.Element; -type TechDocsReaderPageProviderProps = { +/** + * Props for {@link TechDocsReaderPageProvider} + * + * @public + */ +export type TechDocsReaderPageProviderProps = { entityName: CompoundEntityRef; children: TechDocsReaderPageProviderRenderFunction | ReactNode; }; +/** + * A context to store the reader page state + * @public + */ export const TechDocsReaderPageProvider = memo( ({ entityName, children }: TechDocsReaderPageProviderProps) => { const techdocsApi = useApi(techdocsApiRef); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index b5cfd38e3c..41bf6d3af7 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -24,6 +24,7 @@ import { useTechDocsAddons, TechDocsAddonLocations as locations, } from '@backstage/techdocs-addons'; +import { CompoundEntityRef } from '@backstage/catalog-model'; import { Content, Progress } from '@backstage/core-components'; import { TechDocsSearch } from '../../../search'; @@ -43,13 +44,32 @@ const useStyles = makeStyles({ }, }); +/** + * Props for {@link TechDocsReaderPageContent} + * @public + */ export type TechDocsReaderPageContentProps = { + /** + * @deprecated No need to pass down entityRef as property anymore. Consumes the entityName from `TechDocsReaderPageContext`. Use the {@link useTechDocsReaderPage} hook for custom reader page content. + */ + entityRef?: CompoundEntityRef; + /** + * Show or hide the search bar, defaults to true. + */ withSearch?: boolean; + /** + * Callback called when the content is rendered. + */ onReady?: () => void; }; +/** + * Renders the reader page content + * @public + */ export const TechDocsReaderPageContent = withTechDocsReaderProvider( - ({ withSearch = true, onReady }: TechDocsReaderPageContentProps) => { + (props: TechDocsReaderPageContentProps) => { + const { withSearch = true, onReady } = props; const classes = useStyles(); const addons = useTechDocsAddons(); const { entityName, shadowRoot, setShadowRoot } = useTechDocsReaderPage(); @@ -141,3 +161,18 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( ); }, ); + +/** + * Props for {@link Reader} + * + * @public + * @deprecated use `TechDocsReaderPageContentProps` instead. + */ +export type ReaderProps = TechDocsReaderPageContentProps; + +/** + * Component responsible for rendering TechDocs documentation + * @public + * @deprecated use `TechDocsReaderPageContent` component instead. + */ +export const Reader = TechDocsReaderPageContent; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx index e1d6a0d55f..82e5ce1756 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/context.tsx @@ -22,27 +22,10 @@ import React, { } from 'react'; import { useParams } from 'react-router-dom'; -import { CompoundEntityRef } from '@backstage/catalog-model'; - -import { useReaderState } from '../useReaderState'; +import { useReaderState, ReaderState } from '../useReaderState'; import { useTechDocsReaderPage } from '../TechDocsReaderPage'; -/** - * Props for {@link Reader} - * - * @public - */ -export type ReaderProps = { - entityRef: CompoundEntityRef; - withSearch?: boolean; - onReady?: () => void; -}; - -type TechDocsReaderValue = ReturnType; - -const TechDocsReaderContext = createContext( - {} as TechDocsReaderValue, -); +const TechDocsReaderContext = createContext({} as ReaderState); /** * Note: this hook is currently being exported so that we can rapidly @@ -56,14 +39,25 @@ const TechDocsReaderContext = createContext( export const useTechDocsReader = () => useContext(TechDocsReaderContext); -type TechDocsReaderProviderRenderFunction = ( - value: TechDocsReaderValue, +/** + * @public Render function for {@link TechDocsReaderProvider} + */ +export type TechDocsReaderProviderRenderFunction = ( + value: ReaderState, ) => JSX.Element; -type TechDocsReaderProviderProps = { +/** + * @public Props for {@link TechDocsReaderProvider} + */ +export type TechDocsReaderProviderProps = { children: TechDocsReaderProviderRenderFunction | ReactNode; }; +/** + * Provides shared building process state to the reader page components. + * + * @public + */ export const TechDocsReaderProvider = ({ children, }: TechDocsReaderProviderProps) => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts index 288969e73b..a7ea76a5d6 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ -export { TechDocsReaderPageContent } from './TechDocsReaderPageContent'; +export { TechDocsReaderPageContent, Reader } from './TechDocsReaderPageContent'; +export type { TechDocsReaderPageContentProps } from './TechDocsReaderPageContent'; export * from './context'; export * from './dom'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index 45ebe3ae0f..0658078411 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { PropsWithChildren, useEffect } from 'react'; import Helmet from 'react-helmet'; import { Skeleton } from '@material-ui/lab'; @@ -29,17 +29,39 @@ import { EntityRefLinks, getEntityRelations, } from '@backstage/plugin-catalog-react'; -import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { RELATION_OWNED_BY, CompoundEntityRef } from '@backstage/catalog-model'; import { Header, HeaderLabel } from '@backstage/core-components'; import { useRouteRef, configApiRef, useApi } from '@backstage/core-plugin-api'; import { useTechDocsReaderPage } from '../TechDocsReaderPage'; import { rootRouteRef } from '../../../routes'; +import { TechDocsEntityMetadata, TechDocsMetadata } from '../../../types'; const skeleton = ; -export const TechDocsReaderPageHeader = () => { +/** + * Props for {@link TechDocsReaderPageHeader} + * + * @public + * @deprecated No need to pass down properties anymore. The component consumes data from `TechDocsReaderPageContext` instead. Use the {@link useTechDocsReaderPage} hook for custom header. + */ +export type TechDocsReaderPageHeaderProps = PropsWithChildren<{ + entityRef?: CompoundEntityRef; + entityMetadata?: TechDocsEntityMetadata; + techDocsMetadata?: TechDocsMetadata; +}>; + +/** + * Renders the reader page header. + * This component does not accept props, please use + * the Tech Docs add-ons to customize it + * @public + */ +export const TechDocsReaderPageHeader = ( + props: TechDocsReaderPageHeaderProps, +) => { + const { children } = props; const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); @@ -61,7 +83,7 @@ export const TechDocsReaderPageHeader = () => { }); setSubtitle(prevSubtitle => { let { site_description } = metadata; - if (site_description === 'None') { + if (!site_description || site_description === 'None') { site_description = 'Home'; } return prevSubtitle || site_description; @@ -135,6 +157,7 @@ export const TechDocsReaderPageHeader = () => { {tabTitle} {labels} + {children} {addons.renderComponentsByLocation(locations.HEADER)} ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts index 741a8e9af1..e733d9f3b7 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/index.ts @@ -15,3 +15,4 @@ */ export { TechDocsReaderPageHeader } from './TechDocsReaderPageHeader'; +export type { TechDocsReaderPageHeaderProps } from './TechDocsReaderPageHeader'; diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts index 5abdf002b1..281a16cc08 100644 --- a/plugins/techdocs/src/reader/components/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -17,6 +17,9 @@ export type { TechDocsReaderPageProps, TechDocsReaderLayoutProps, + TechDocsReaderPageValue, + TechDocsReaderPageProviderProps, + TechDocsReaderPageProviderRenderFunction, } from './TechDocsReaderPage'; export { useShadowRoot, @@ -30,3 +33,4 @@ export { export * from './TechDocsReaderPageHeader'; export * from './TechDocsReaderPageContent'; export * from './TechDocsStateIndicator'; +export type { ReaderState, ContentStateTypes } from './useReaderState'; diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts index 8bccb3cdb8..39f09af264 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.ts +++ b/plugins/techdocs/src/reader/components/useReaderState.ts @@ -21,9 +21,10 @@ import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { techdocsStorageApiRef } from '../../api'; /** + * @public * A state representation that is used to configure the UI of */ -type ContentStateTypes = +export type ContentStateTypes = /** There is nothing to display but a loading indicator */ | 'CHECKING' @@ -224,13 +225,10 @@ export function reducer( return newState; } - -export function useReaderState( - kind: string, - namespace: string, - name: string, - path: string, -): { +/** + * @public shared reader state + */ +export type ReaderState = { state: ContentStateTypes; path: string; contentReload: () => void; @@ -238,7 +236,14 @@ export function useReaderState( contentErrorMessage?: string; syncErrorMessage?: string; buildLog: string[]; -} { +}; + +export function useReaderState( + kind: string, + namespace: string, + name: string, + path: string, +): ReaderState { const [state, dispatch] = useReducer(reducer, { activeSyncState: 'CHECKING', path, diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index 20f78c01f3..bf880e8305 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -29,6 +29,10 @@ export type TechDocsReaderPageRenderFunction = ({ techdocsMetadataValue?: TechDocsMetadata | undefined; entityMetadataValue?: TechDocsEntityMetadata | undefined; entityRef: CompoundEntityRef; + /** + * @deprecated You can continue pass this property, but directly to the `TechDocsReaderPageContent` component. + */ + onReady?: () => void; }) => JSX.Element; /** diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 19f463d45e..3d2d776262 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -225,6 +225,7 @@ const NO_WARNING_PACKAGES = [ 'packages/integration', 'packages/integration-react', 'packages/search-common', + 'packages/techdocs-addons', 'packages/techdocs-common', 'packages/test-utils', 'packages/theme', @@ -258,7 +259,6 @@ const NO_WARNING_PACKAGES = [ 'plugins/search-backend-node', 'plugins/search-common', 'plugins/techdocs', - 'plugins/techdocs-addons', 'plugins/techdocs-backend', 'plugins/techdocs-node', 'plugins/tech-insights', From c969da2fcd2baaee17781f5a8a11cb55127b1e94 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Fri, 1 Apr 2022 14:40:00 +0200 Subject: [PATCH 25/47] fix techdocs-cli embedded app Co-authored-by: Camila Belo Co-authored-by: Eric Peterson Signed-off-by: Emma Indal --- packages/techdocs-cli-embedded-app/package.json | 2 +- .../src/components/TechDocsPage/TechDocsPage.tsx | 16 +++++++++------- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 9 +++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/techdocs-cli-embedded-app/package.json b/packages/techdocs-cli-embedded-app/package.json index c7c65ad55f..d985066ef8 100644 --- a/packages/techdocs-cli-embedded-app/package.json +++ b/packages/techdocs-cli-embedded-app/package.json @@ -66,4 +66,4 @@ "last 1 safari version" ] } -} +} \ No newline at end of file diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index 8c44336d73..f160faab0b 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -64,7 +64,7 @@ type TechDocsThemeValue = { const TechDocsThemeContext = createContext({ theme: Themes.LIGHT, - toggleTheme: () => {}, + toggleTheme: () => { }, }); const TechdocsThemeProvider: FC = ({ children }) => { @@ -120,11 +120,13 @@ export const TechDocsThemeToggle = () => { ); }; -export const techDocsPage = ( - - +const DefaultTechDocsPage = () => { + return + - - -); +
+ +} + +export const techDocsPage = diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index fd80ae3af6..df65609ecc 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -83,16 +83,17 @@ export type TechDocsReaderPageProps = { */ export const TechDocsReaderPage = ({ entityRef, - children, + children }: TechDocsReaderPageProps) => { const { kind, name, namespace } = useParams(); - const route = useOutlet() || { props: { children: [] } }; + + const outlet = useOutlet() const entityName = entityRef ?? { kind, name, namespace }; if (!children) { - const outlet = Children.toArray(route.props.children); + const childrenList = outlet ? Children.toArray(outlet.props.children) : []; - const page = outlet.find(child => { + const page = childrenList.find(child => { const { type } = child as Extension; return !type?.__backstage_data?.map?.get(TECHDOCS_ADDONS_WRAPPER_KEY); }); From 377309a98b82e5433e321b85fd0bf846450fd184 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Fri, 1 Apr 2022 15:14:30 +0200 Subject: [PATCH 26/47] use app theme api for techdocs cli embedded app Co-authored-by: Camila Belo Co-authored-by: Eric Peterson Signed-off-by: Emma Indal --- .../components/TechDocsPage/TechDocsPage.tsx | 62 +++++-------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index f160faab0b..86ebcbab8e 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -15,20 +15,16 @@ */ import React, { - FC, - createContext, - useContext, useState, - useCallback, } from 'react'; import { Theme, makeStyles } from '@material-ui/core'; -import { ThemeProvider, Box, Tooltip, IconButton } from '@material-ui/core'; +import { Box, Tooltip, IconButton } from '@material-ui/core'; import LightIcon from '@material-ui/icons/Brightness7'; import DarkIcon from '@material-ui/icons/Brightness4'; -import { lightTheme, darkTheme } from '@backstage/theme'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; import { TechDocsReaderPage, @@ -57,44 +53,10 @@ enum Themes { DARK = 'dark', } -type TechDocsThemeValue = { - theme: Themes; - toggleTheme: () => void; -}; - -const TechDocsThemeContext = createContext({ - theme: Themes.LIGHT, - toggleTheme: () => { }, -}); - -const TechdocsThemeProvider: FC = ({ children }) => { - const [theme, setTheme] = useState(Themes.LIGHT); - - const toggleTheme = useCallback(() => { - setTheme(prevTheme => - prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT, - ); - }, [setTheme]); - - const value = { theme, toggleTheme }; - - const themes = { - [Themes.LIGHT]: lightTheme, - [Themes.DARK]: darkTheme, - }; - - return ( - - {children} - - ); -}; - -const useTechDocsTheme = () => useContext(TechDocsThemeContext); - export const TechDocsThemeToggle = () => { + const appThemeApi = useApi(appThemeApiRef) const classes = useStyles(); - const { theme, toggleTheme } = useTechDocsTheme(); + const [theme, setTheme] = useState(appThemeApi.getActiveThemeId() as Themes || Themes.LIGHT); const themes = { [Themes.LIGHT]: { @@ -109,10 +71,18 @@ export const TechDocsThemeToggle = () => { const { title, icon: Icon } = themes[theme]; + const handleSetTheme = () => { + setTheme(prevTheme => { + const newTheme = prevTheme === Themes.LIGHT ? Themes.DARK : Themes.LIGHT; + appThemeApi.setActiveThemeId(newTheme); + return newTheme; + }); + } + return ( - + @@ -121,12 +91,12 @@ export const TechDocsThemeToggle = () => { }; const DefaultTechDocsPage = () => { - return + return ( - + ) } -export const techDocsPage = +export const techDocsPage = \ No newline at end of file From a122923bd05bc13408d812c0bc3520b909ff2c85 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Fri, 1 Apr 2022 15:19:51 +0200 Subject: [PATCH 27/47] api-reports and prettier Co-authored-by: Camila Belo Co-authored-by: Eric Peterson Signed-off-by: Emma Indal --- .../techdocs-cli-embedded-app/package.json | 2 +- .../components/TechDocsPage/TechDocsPage.tsx | 18 +++++++++--------- plugins/techdocs/api-report.md | 5 ++++- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 4 ++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/techdocs-cli-embedded-app/package.json b/packages/techdocs-cli-embedded-app/package.json index d985066ef8..c7c65ad55f 100644 --- a/packages/techdocs-cli-embedded-app/package.json +++ b/packages/techdocs-cli-embedded-app/package.json @@ -66,4 +66,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx index 86ebcbab8e..c7328a0487 100644 --- a/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx +++ b/packages/techdocs-cli-embedded-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -14,9 +14,7 @@ * limitations under the License. */ -import React, { - useState, -} from 'react'; +import React, { useState } from 'react'; import { Theme, makeStyles } from '@material-ui/core'; @@ -54,9 +52,11 @@ enum Themes { } export const TechDocsThemeToggle = () => { - const appThemeApi = useApi(appThemeApiRef) + const appThemeApi = useApi(appThemeApiRef); const classes = useStyles(); - const [theme, setTheme] = useState(appThemeApi.getActiveThemeId() as Themes || Themes.LIGHT); + const [theme, setTheme] = useState( + (appThemeApi.getActiveThemeId() as Themes) || Themes.LIGHT, + ); const themes = { [Themes.LIGHT]: { @@ -77,7 +77,7 @@ export const TechDocsThemeToggle = () => { appThemeApi.setActiveThemeId(newTheme); return newTheme; }); - } + }; return ( @@ -96,7 +96,7 @@ const DefaultTechDocsPage = () => {
- ) -} + ); +}; -export const techDocsPage = \ No newline at end of file +export const techDocsPage = ; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index 7909621c92..0feb4dd7a4 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -174,6 +174,9 @@ export interface PanelConfig { // @public export type PanelType = 'DocsCardGrid' | 'DocsTable'; +// @public @deprecated +export const Reader: (props: TechDocsReaderPageContentProps) => JSX.Element; + // @public export type ReaderState = { state: ContentStateTypes; @@ -327,7 +330,7 @@ export type TechDocsReaderPageContentProps = { // @public export const TechDocsReaderPageHeader: ( - _props: TechDocsReaderPageHeaderProps, + props: TechDocsReaderPageHeaderProps, ) => JSX.Element; // @public @deprecated diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index df65609ecc..3c96d463ff 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -83,11 +83,11 @@ export type TechDocsReaderPageProps = { */ export const TechDocsReaderPage = ({ entityRef, - children + children, }: TechDocsReaderPageProps) => { const { kind, name, namespace } = useParams(); - const outlet = useOutlet() + const outlet = useOutlet(); const entityName = entityRef ?? { kind, name, namespace }; if (!children) { From 68f49e4ac612022d2c013121fe1614d32389f9a1 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sun, 3 Apr 2022 11:58:40 +0200 Subject: [PATCH 28/47] fix(techdocs): scroll flickering when navigating Signed-off-by: Camila Belo --- .../src/reader/components/TechDocsReaderPageContent/dom.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index a6cf79f1db..de791a7068 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -725,10 +725,6 @@ export const useTechDocsReaderDom = ( window.open(parsedUrl.pathname, '_blank'); } else { navigate(parsedUrl.pathname); - // Scroll to top of reader if primary sidebar link is clicked - transformedElement - ?.querySelector('.md-content__inner') - ?.scrollIntoView(); } } }, From f04b458bbc35d6c5fc4ea0f7185a17716a55bee6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 1 Apr 2022 18:27:43 +0200 Subject: [PATCH 29/47] Resolve or clarify simpler todos in techdocs-addons Signed-off-by: Eric Peterson --- packages/techdocs-addons/api-report.md | 1 - packages/techdocs-addons/src/types.ts | 29 ++++++++++++++----- .../components/TechDocsReaderPage/hooks.ts | 5 ---- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/techdocs-addons/api-report.md b/packages/techdocs-addons/api-report.md index 0afcb9f3a3..4d5eef5edb 100644 --- a/packages/techdocs-addons/api-report.md +++ b/packages/techdocs-addons/api-report.md @@ -21,7 +21,6 @@ export type TechDocsAddonAsyncMetadata = AsyncState; // @public export enum TechDocsAddonLocations { - COMPONENT = 'component', CONTENT = 'content', HEADER = 'header', PRIMARY_SIDEBAR = 'primary sidebar', diff --git a/packages/techdocs-addons/src/types.ts b/packages/techdocs-addons/src/types.ts index 8e42e0a6bc..aa0d128427 100644 --- a/packages/techdocs-addons/src/types.ts +++ b/packages/techdocs-addons/src/types.ts @@ -51,15 +51,30 @@ export enum TechDocsAddonLocations { CONTENT = 'content', /** - * A virtual location allowing an instance of the addon to be rendered for - * every HTML node with the same tag name as the addon name in the markdown - * content. If no reference is made, no instance will be rendered. Works like - * regular React components, just being accessible from markdown. + * todo(backstage/community): This is a proposed virtual location which would + * help implement a common addon pattern in which many instances of a given + * element in markdown would be dynamically replaced at render-time based on + * attributes provided on that element, for example: * - * todo(backstage/techdocs-core): Keep and implement or remove before - * releasing this package! + * ```md + * ## Component Metadata + * [CatalogEntityCard](default:component/some-component-name) + * + * ## System Metadata + * [CatalogEntityCard](default:system/some-system-name) + * ``` + * + * Could correspond to a TechDocs addon named `CatalogEntityCard` with + * location `TechDocsAddonLocations.COMPONENT`, whose `component` would be + * the react component that would be rendered in place of all instances of + * the markdown illustrated above. + * + * The `@backstage/techdocs-addons` plugin would need to be updated to, in + * cases where such addons had been registered, find all instances of the + * rendered markdown (e.g. `CatalogEntityCard`) and + * replace them with react portals to the addon component. */ - COMPONENT = 'component', + // COMPONENT = 'component', } /** diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts index 7bc6152006..ac81a56132 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/hooks.ts @@ -29,11 +29,6 @@ export const useShadowRoot = () => { * Convenience hook for use within TechDocs addons that provides access to * elements that match a given selector within the shadow root. * - * todo(backstage/techdocs-core): Consider extending `selectors` from string[] - * to some kind of typed object array, so users have more control over the - * shape of the result. e.g. a flag to indicate querySelector vs. - * querySelectorAll. - * * @public */ export const useShadowRootElements = < From e315a3b1547ecd3d95ea0a77eb93883e968f8184 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Tue, 5 Apr 2022 16:21:43 +0200 Subject: [PATCH 30/47] add TechDocsReaderPage tests Signed-off-by: Emma Indal --- plugins/techdocs/src/EntityPageDocs.tsx | 6 +- .../TechDocsReaderPage.test.tsx | 149 ++++++++++++++++++ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 8 +- 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 29f44f2931..b0e529f9a5 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -19,7 +19,8 @@ import React from 'react'; import { Entity, getCompoundEntityRef } from '@backstage/catalog-model'; import { TechDocsReaderPage } from './plugin'; -import { TechDocsReaderLayout } from './reader'; +import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader'; +import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent'; type EntityPageDocsProps = { entity: Entity }; @@ -28,7 +29,8 @@ export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => { return ( - + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx new file mode 100644 index 0000000000..4b23075908 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -0,0 +1,149 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { act } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; + +import { lightTheme } from '@backstage/theme'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { techdocsApiRef, techdocsStorageApiRef } from '../../../api'; + +import { rootRouteRef, rootDocsRouteRef } from '../../../routes'; + +import { TechDocsReaderPage } from './TechDocsReaderPage'; + +const mockEntityMetadata = { + locationMetadata: { + type: 'github', + target: 'https://example.com/', + }, + apiVersion: 'v1', + kind: 'test', + metadata: { + name: 'test-name', + namespace: 'test-namespace', + }, + spec: { + owner: 'test', + }, +}; + +const mockTechDocsMetadata = { + site_name: 'test-site-name', + site_description: 'test-site-desc', +}; + +const getEntityMetadata = jest.fn(); +const getTechDocsMetadata = jest.fn(); + +const techdocsApiMock = { + getEntityMetadata, + getTechDocsMetadata, +}; + +const techdocsStorageApiMock: jest.Mocked = { + getApiOrigin: jest.fn(), + getBaseUrl: jest.fn(), + getBuilder: jest.fn(), + getEntityDocs: jest.fn(), + getStorageUrl: jest.fn(), + syncEntityDocs: jest.fn(), +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + + {children} + + + ); +}; + +const mountedRoutes = { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, + '/docs/:namespace/:kind/:name/*': rootDocsRouteRef, +}; + +describe('', () => { + beforeEach(() => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + it('should render a techdocs reader page without children', async () => { + const rendered = await renderInTestApp( + + + , + { + mountedRoutes, + }, + ); + + // TechDocsReaderPageHeader + expect(rendered.container.querySelector('header')).toBeInTheDocument(); + // TechDocsReaderPageContent + expect(rendered.container.querySelector('article')).toBeInTheDocument(); + }); + + it('should render a techdocs reader page with children', async () => { + await act(async () => { + const rendered = await renderInTestApp( + + + techdocs reader page + + , + { + mountedRoutes, + }, + ); + expect( + rendered.container.querySelector('header'), + ).not.toBeInTheDocument(); + expect( + rendered.container.querySelector('article'), + ).not.toBeInTheDocument(); + expect(rendered.getByText('techdocs reader page')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 3c96d463ff..e26cd5a3d6 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -61,11 +61,11 @@ export const TechDocsReaderLayout = ({ withHeader = true, }: TechDocsReaderLayoutProps) => { return ( - <> + {withHeader && } - + ); }; @@ -101,9 +101,7 @@ export const TechDocsReaderPage = ({ return ( (page as JSX.Element) || ( - - - + ) ); From b4ed5c12265ba6a1a72be00e9e63c9e2429a8fc1 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Tue, 5 Apr 2022 16:21:56 +0200 Subject: [PATCH 31/47] remove duplicated dependency Signed-off-by: Emma Indal --- plugins/techdocs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 1ac94c4a03..b1f2ea8840 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -38,7 +38,7 @@ "@backstage/catalog-model": "^1.0.1-next.1", "@backstage/config": "^1.0.0", "@backstage/core-components": "^0.9.3-next.1", - "@backstage/core-app-api": "^1.0.0", + "@backstage/core-app-api": "^1.0.1-next.0", "@backstage/core-plugin-api": "^1.0.0", "@backstage/errors": "^1.0.0", "@backstage/integration": "^1.1.0-next.1", From 88fd7d1e55ea5bca7d5c7a25a1ea364496f2a071 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Sat, 9 Apr 2022 13:48:49 +0200 Subject: [PATCH 32/47] rebase fixups Signed-off-by: Emma Indal --- packages/techdocs-addons/package.json | 8 +- plugins/techdocs/package.json | 2 +- yarn.lock | 254 +------------------------- 3 files changed, 9 insertions(+), 255 deletions(-) diff --git a/packages/techdocs-addons/package.json b/packages/techdocs-addons/package.json index 36e508918f..c8ed12ec8b 100644 --- a/packages/techdocs-addons/package.json +++ b/packages/techdocs-addons/package.json @@ -34,9 +34,9 @@ "start": "backstage-cli package start" }, "dependencies": { - "@backstage/catalog-model": "^0.13.0", - "@backstage/core-components": "^0.9.1", - "@backstage/core-plugin-api": "^0.8.0", + "@backstage/catalog-model": "^1.0.1-next.1", + "@backstage/core-components": "^0.9.3-next.1", + "@backstage/core-plugin-api": "^1.0.0", "@material-ui/core": "^4.12.2", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.11.0", @@ -51,7 +51,7 @@ }, "devDependencies": { "@testing-library/react-hooks": "^7.0.2", - "@backstage/test-utils": "^0.3.0" + "@backstage/test-utils": "^1.0.1-next.1" }, "files": [ "dist" diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b1f2ea8840..43aba14f2f 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -44,7 +44,7 @@ "@backstage/integration": "^1.1.0-next.1", "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-catalog": "^0.10.0", + "@backstage/plugin-catalog": "^1.1.0-next.2", "@backstage/plugin-search": "^0.7.5-next.0", "@backstage/techdocs-addons": "^0.0.0", "@backstage/theme": "^0.2.15", diff --git a/yarn.lock b/yarn.lock index 44d2cf05fc..1771e0fbef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1456,15 +1456,6 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" -"@backstage/catalog-client@^0.9.0": - version "0.9.0" - resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-0.9.0.tgz#3e1024fab13fd8e2000d33833d2463ea9be5df9d" - integrity sha1-PhAk+rE/2OIADTODPSRj6pvl350= - dependencies: - "@backstage/catalog-model" "^0.13.0" - "@backstage/errors" "^0.2.2" - cross-fetch "^3.1.5" - "@backstage/catalog-client@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-1.0.0.tgz#05f9ee3b771ca17e4800f5116d63bd183fe0c4d6" @@ -1474,19 +1465,6 @@ "@backstage/errors" "^1.0.0" cross-fetch "^3.1.5" -"@backstage/catalog-model@^0.13.0": - version "0.13.0" - resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-0.13.0.tgz#abeb91522ac7ef7907907ad5bc889803131db209" - integrity sha1-q+uRUirH73kHkHrVvIiYAxMdsgk= - dependencies: - "@backstage/config" "^0.1.15" - "@backstage/errors" "^0.2.2" - "@backstage/types" "^0.1.3" - ajv "^7.0.3" - json-schema "^0.4.0" - lodash "^4.17.21" - uuid "^8.0.0" - "@backstage/catalog-model@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-1.0.0.tgz#0aa8694a3182aaf4232725842da751bf5f78bd68" @@ -1500,47 +1478,7 @@ lodash "^4.17.21" uuid "^8.0.0" -"@backstage/config@^0.1.15": - version "0.1.15" - resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.15.tgz#4bad122ad861be5bd61a60639f92d2494fa245c5" - integrity sha512-eNJEYYSEu9MkrkBYiMpUBWEc3Bu64YgB9pZZGCMW7/9350tV2wbylEdoBJHslilJlJhiUyTXBckn8Ua7DOH7rw== - dependencies: - "@backstage/types" "^0.1.3" - lodash "^4.17.21" - -"@backstage/core-app-api@^0.6.0": - version "0.6.0" - resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-0.6.0.tgz#691f0586d97682f1af67828ab2c67014397a530a" - integrity sha512-v1t1w/U/JHjm9eZupPmJpKH5WB0vysEFxRo6/Ia77VP6ZcPxszG4IG+d+S3Km5fM3lu4oosGiz+RaKUTLrttBA== - dependencies: - "@backstage/config" "^0.1.15" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/types" "^0.1.3" - "@backstage/version-bridge" "^0.1.2" - "@types/prop-types" "^15.7.3" - prop-types "^15.7.2" - react-router-dom "6.0.0-beta.0" - react-use "^17.2.4" - zen-observable "^0.8.15" - zod "^3.11.6" - -"@backstage/core-app-api@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-1.0.0.tgz#2dae97b050b2f2e5ec1ea42b3d95c57e8bf434d6" - integrity sha512-hmoFMPCxAfHgDPQTHbf6rquiG0SCSycWTUrScpYeLwkH3UOekgX8o8ThKT0t3w7WPx83LwT0NqcbSH6zqI9nag== - dependencies: - "@backstage/config" "^1.0.0" - "@backstage/core-plugin-api" "^1.0.0" - "@backstage/types" "^1.0.0" - "@backstage/version-bridge" "^1.0.0" - "@types/prop-types" "^15.7.3" - prop-types "^15.7.2" - react-router-dom "6.0.0-beta.0" - react-use "^17.2.4" - zen-observable "^0.8.15" - zod "^3.11.6" - -"@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.1", "@backstage/core-components@^0.9.2": +"@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" integrity sha512-kh0FB0FmjC55W+xSEkKrAc7D6hvbYLY7N1UUd6M4VBghYXD61Y8RrJFKmBM3bAfPgYaryQNjYgA0BsoTo53PJA== @@ -1584,43 +1522,6 @@ zen-observable "^0.8.15" zod "^3.11.6" -"@backstage/core-plugin-api@^0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.8.0.tgz#e2096bff679183168a7f9b47ed27c50a01970e32" - integrity sha1-4glr/2eRgxaKf5tH7SfFCgGXDjI= - dependencies: - "@backstage/config" "^0.1.15" - "@backstage/types" "^0.1.3" - "@backstage/version-bridge" "^0.1.2" - history "^5.0.0" - prop-types "^15.7.2" - react-router-dom "6.0.0-beta.0" - zen-observable "^0.8.15" - -"@backstage/errors@^0.2.2": - version "0.2.2" - resolved "https://registry.npmjs.org/@backstage/errors/-/errors-0.2.2.tgz#2113e0bc859e645b8b59bfcb435f7535739b02f8" - integrity sha1-IRPgvIWeZFuLWb/LQ191NXObAvg= - dependencies: - "@backstage/types" "^0.1.3" - cross-fetch "^3.1.5" - serialize-error "^8.0.1" - -"@backstage/integration-react@^0.1.25": - version "0.1.25" - resolved "https://registry.npmjs.org/@backstage/integration-react/-/integration-react-0.1.25.tgz#ebbdd30d66e1d210b7cd33a682ad2be0d5ea5fc0" - integrity sha1-673TDWbh0hC3zTOmgq0r4NXqX8A= - dependencies: - "@backstage/config" "^0.1.15" - "@backstage/core-components" "^0.9.1" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/integration" "^0.8.0" - "@backstage/theme" "^0.2.15" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - react-use "^17.2.4" - "@backstage/integration-react@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/integration-react/-/integration-react-1.0.0.tgz#8075e65c6b5387631d27a9c242e7a4fff6f92417" @@ -1636,19 +1537,6 @@ "@material-ui/lab" "4.0.0-alpha.57" react-use "^17.2.4" -"@backstage/integration@^0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@backstage/integration/-/integration-0.8.0.tgz#d74131ad347272b4935973aa4bd098fad9548ce6" - integrity sha1-10ExrTRycrSTWXOqS9CY+tlUjOY= - dependencies: - "@backstage/config" "^0.1.15" - "@octokit/auth-app" "^3.4.0" - "@octokit/rest" "^18.5.3" - cross-fetch "^3.1.5" - git-url-parse "^11.6.0" - lodash "^4.17.21" - luxon "^2.0.2" - "@backstage/integration@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/integration/-/integration-1.0.0.tgz#e307cfddea014bfb0eb2281a5ae25ea0b742e9cf" @@ -1662,41 +1550,6 @@ lodash "^4.17.21" luxon "^2.0.2" -"@backstage/plugin-catalog-common@^0.2.2": - version "0.2.2" - resolved "https://registry.npmjs.org/@backstage/plugin-catalog-common/-/plugin-catalog-common-0.2.2.tgz#2f039ecd829d1d8e017609cb0bbf9af7c231ab63" - integrity sha1-LwOezYKdHY4BdgnLC7+a98Ixq2M= - dependencies: - "@backstage/plugin-permission-common" "^0.5.2" - "@backstage/search-common" "^0.3.1" - -"@backstage/plugin-catalog-react@^0.9.0": - version "0.9.0" - resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.9.0.tgz#ff8c09ec455655fadb2c2fafd578908138b858bc" - integrity sha1-/4wJ7EVWVfrbLC+v1XiQgTi4WLw= - dependencies: - "@backstage/catalog-client" "^0.9.0" - "@backstage/catalog-model" "^0.13.0" - "@backstage/core-components" "^0.9.1" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/errors" "^0.2.2" - "@backstage/integration" "^0.8.0" - "@backstage/plugin-permission-common" "^0.5.2" - "@backstage/plugin-permission-react" "^0.3.3" - "@backstage/types" "^0.1.3" - "@backstage/version-bridge" "^0.1.2" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - classnames "^2.2.6" - jwt-decode "^3.1.0" - lodash "^4.17.21" - qs "^6.9.4" - react-router "6.0.0-beta.0" - react-use "^17.2.4" - yaml "^1.10.0" - zen-observable "^0.8.15" - "@backstage/plugin-catalog-react@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.0.0.tgz#4f42c070ffe5c9690e45a5288e18bacd8d4e0e66" @@ -1725,33 +1578,7 @@ yaml "^1.10.0" zen-observable "^0.8.15" -"@backstage/plugin-catalog@^0.10.0": - version "0.10.0" - resolved "https://registry.npmjs.org/@backstage/plugin-catalog/-/plugin-catalog-0.10.0.tgz#7b7f1b54704380c51f7506bfba948a3e0ff0575d" - integrity sha1-e38bVHBDgMUfdQa/upSKPg/wV10= - dependencies: - "@backstage/catalog-client" "^0.9.0" - "@backstage/catalog-model" "^0.13.0" - "@backstage/core-components" "^0.9.1" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/errors" "^0.2.2" - "@backstage/integration-react" "^0.1.25" - "@backstage/plugin-catalog-common" "^0.2.2" - "@backstage/plugin-catalog-react" "^0.9.0" - "@backstage/plugin-search-common" "^0.3.1" - "@backstage/theme" "^0.2.15" - "@backstage/types" "^0.1.2" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - history "^5.0.0" - lodash "^4.17.21" - react-helmet "6.1.0" - react-router "6.0.0-beta.0" - react-use "^17.2.4" - zen-observable "^0.8.15" - -"@backstage/plugin-permission-common@^0.5.2", "@backstage/plugin-permission-common@^0.5.3": +"@backstage/plugin-permission-common@^0.5.3": version "0.5.3" resolved "https://registry.npmjs.org/@backstage/plugin-permission-common/-/plugin-permission-common-0.5.3.tgz#a1a4446e603584f2c82763745051f75f4a942eb1" integrity sha512-zppDsNZEK9ffgXbf/Zx0sw4ffuOVOEvBZlft1+Oph2rO4+uN7dmCLMRRcKsYeNQ6/F50e6BMyNWpPZQDR/JQsA== @@ -1762,7 +1589,7 @@ uuid "^8.0.0" zod "^3.11.6" -"@backstage/plugin-permission-react@^0.3.3", "@backstage/plugin-permission-react@^0.3.4": +"@backstage/plugin-permission-react@^0.3.4": version "0.3.4" resolved "https://registry.npmjs.org/@backstage/plugin-permission-react/-/plugin-permission-react-0.3.4.tgz#e769dc1489c35d9c924234c0764a584558891716" integrity sha512-S8s1cvCZFmxP4Dn5V9fOls31s4V1rgx3YUXqHSkgLatYHOXczf+GM/c5rdGLQyOb/+Hb+MQqaPAkojvSxNliow== @@ -1775,53 +1602,6 @@ react-use "^17.2.4" swr "^1.1.2" -"@backstage/plugin-search-common@0.3.2", "@backstage/plugin-search-common@^0.3.1": - version "0.3.2" - resolved "https://registry.npmjs.org/@backstage/plugin-search-common/-/plugin-search-common-0.3.2.tgz#15984ba4c14f8a9119168e8c79344ef8101863dc" - integrity sha1-FZhLpMFPipEZFo6MeTRO+BAYY9w= - dependencies: - "@backstage/plugin-permission-common" "^0.5.3" - "@backstage/types" "^1.0.0" - -"@backstage/search-common@^0.3.1": - version "0.3.2" - resolved "https://registry.npmjs.org/@backstage/search-common/-/search-common-0.3.2.tgz#608a4eddf7eae71ed807ec1f723a80c6f7cdf3e4" - integrity sha1-YIpO3ffq5x7YB+wfcjqAxvfN8+Q= - dependencies: - "@backstage/plugin-search-common" "0.3.2" - -"@backstage/test-utils@^0.3.0": - version "0.3.0" - resolved "https://registry.npmjs.org/@backstage/test-utils/-/test-utils-0.3.0.tgz#9c47efd97cdfa3809fe8493a39795a59373d00bd" - integrity sha512-UmJE0dZhtZGKcl58Yru2whEApamY71eJaC4uV0APySUjh0Z39pPxOOkM8a9VKH0/oafqoS4LXUJ4vGDzmy11UQ== - dependencies: - "@backstage/config" "^0.1.15" - "@backstage/core-app-api" "^0.6.0" - "@backstage/core-plugin-api" "^0.8.0" - "@backstage/plugin-permission-common" "^0.5.2" - "@backstage/plugin-permission-react" "^0.3.3" - "@backstage/theme" "^0.2.15" - "@backstage/types" "^0.1.3" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.11.2" - "@testing-library/jest-dom" "^5.10.1" - "@testing-library/react" "^11.2.5" - "@testing-library/user-event" "^13.1.8" - cross-fetch "^3.1.5" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - zen-observable "^0.8.15" - -"@backstage/types@^0.1.2", "@backstage/types@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14" - integrity sha512-fJVi4oVrlO+G3PRv1fYSll9/X4pE11HLnkI//Geare9sP6wSfp/2zXpLYfKVsG0e24jOl7Swkc8lwLkQ90zMaQ== - -"@backstage/version-bridge@^0.1.2": - version "0.1.2" - resolved "https://registry.npmjs.org/@backstage/version-bridge/-/version-bridge-0.1.2.tgz#a24f42e0f383d497576f8c9d43851c6538345c03" - integrity sha1-ok9C4POD1JdXb4ydQ4UcZTg0XAM= - "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -5751,20 +5531,6 @@ "@babel/runtime" "^7.14.6" "@testing-library/dom" "^8.1.0" -"@testing-library/dom@^7.28.1": - version "7.31.2" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" - integrity sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^4.2.0" - aria-query "^4.2.2" - chalk "^4.1.0" - dom-accessibility-api "^0.5.6" - lz-string "^1.4.4" - pretty-format "^26.6.2" - "@testing-library/dom@^8.0.0", "@testing-library/dom@^8.1.0": version "8.11.3" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.3.tgz#38fd63cbfe14557021e88982d931e33fb7c1a808" @@ -5805,14 +5571,6 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" -"@testing-library/react@^11.2.5": - version "11.2.7" - resolved "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz#b29e2e95c6765c815786c0bc1d5aed9cb2bf7818" - integrity sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA== - dependencies: - "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^7.28.1" - "@testing-library/react@^12.1.3": version "12.1.4" resolved "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" @@ -12358,11 +12116,6 @@ event-source-polyfill@1.0.25: resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.25.tgz#d8bb7f99cb6f8119c2baf086d9f6ee0514b6d9c8" integrity sha512-hQxu6sN1Eq4JjoI7ITdQeGGUN193A2ra83qC0Ltm9I2UJVAten3OFVN6k5RX4YWeCS0BoC8xg/5czOCIHVosQg== -event-source-polyfill@^1.0.25: - version "1.0.26" - resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.26.tgz#86c04d088ef078279168eefa028f928fec5059a4" - integrity sha512-IwDLs9fUTcGAyacHBeS53T8wcEkDyDn0UP4tfQqJ4wQP8AyH0mszuQf2ULTylnpI0sMquzJ4usrNV7+uztwI9A== - event-stream@=3.3.4: version "3.3.4" resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" @@ -24004,6 +23757,7 @@ tdigest@^0.1.1: "@backstage/integration-react" "^1.0.1-next.1" "@backstage/plugin-catalog" "^1.1.0-next.1" "@backstage/plugin-techdocs" "^1.0.1-next.1" + "@backstage/techdocs-addons" "^0.0.0" "@backstage/test-utils" "^1.0.1-next.1" "@backstage/theme" "^0.2.15" "@material-ui/core" "^4.11.0" From d26e1b01462f84e0ad98251fd56c8ea383fb2b47 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 08:55:54 +0200 Subject: [PATCH 33/47] [TechDocs Addons] Give Feedback Addon (#10733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * give feedback addon implementation Co-authored-by: Camila Belo Co-authored-by: Anders Näsman Signed-off-by: Emma Indal * export replaceUrlType from @backstage/integration-reacte Co-authored-by: Camila Belo Co-authored-by: Anders Näsman Signed-off-by: Emma Indal * export give feedback addon Signed-off-by: Emma Indal * add hooks used for give feedback addon Co-authored-by: Camila Belo Co-authored-by: Anders Näsman Signed-off-by: Emma Indal * replaceUrlType -> replaceGitLabUrlType Signed-off-by: Emma Indal * clarify template + template builder types Signed-off-by: Emma Indal * feedback fixups Signed-off-by: Emma Indal Co-authored-by: Camila Belo Co-authored-by: Anders Näsman --- .changeset/spotty-ducks-exercise.md | 5 + packages/integration/api-report.md | 6 + .../src/gitlab/GitLabIntegration.test.ts | 12 +- .../src/gitlab/GitLabIntegration.ts | 11 +- packages/integration/src/gitlab/index.ts | 2 +- plugins/techdocs/api-report.md | 21 +++ plugins/techdocs/package.json | 2 +- .../addons/GiveFeedback/FeedbackLink.test.tsx | 84 +++++++++++ .../src/addons/GiveFeedback/FeedbackLink.tsx | 83 +++++++++++ .../src/addons/GiveFeedback/GiveFeedback.tsx | 132 ++++++++++++++++++ .../src/addons/GiveFeedback/constants.ts | 22 +++ .../techdocs/src/addons/GiveFeedback/hooks.ts | 96 +++++++++++++ .../techdocs/src/addons/GiveFeedback/index.ts | 22 +++ .../techdocs/src/addons/GiveFeedback/types.ts | 38 +++++ plugins/techdocs/src/addons/index.ts | 21 +++ plugins/techdocs/src/index.ts | 2 + plugins/techdocs/src/plugin.ts | 19 +++ .../TechDocsReaderPage/hooks.test.ts | 54 ++++++- .../components/TechDocsReaderPage/hooks.ts | 49 +++++++ 19 files changed, 670 insertions(+), 11 deletions(-) create mode 100644 .changeset/spotty-ducks-exercise.md create mode 100644 plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx create mode 100644 plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx create mode 100644 plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx create mode 100644 plugins/techdocs/src/addons/GiveFeedback/constants.ts create mode 100644 plugins/techdocs/src/addons/GiveFeedback/hooks.ts create mode 100644 plugins/techdocs/src/addons/GiveFeedback/index.ts create mode 100644 plugins/techdocs/src/addons/GiveFeedback/types.ts create mode 100644 plugins/techdocs/src/addons/index.ts diff --git a/.changeset/spotty-ducks-exercise.md b/.changeset/spotty-ducks-exercise.md new file mode 100644 index 0000000000..be1ae987cb --- /dev/null +++ b/.changeset/spotty-ducks-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +replaceGitLabUrlType exported from package diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index cdb260f089..190278c99f 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -416,6 +416,12 @@ export function replaceGitHubUrlType( type: 'blob' | 'tree' | 'edit', ): string; +// @public +export function replaceGitLabUrlType( + url: string, + type: 'blob' | 'tree' | 'edit', +): string; + // @public export interface ScmIntegration { resolveEditUrl(url: string): string; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 01e42e0984..575ce75308 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { GitLabIntegration, replaceUrlType } from './GitLabIntegration'; +import { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration'; describe('GitLabIntegration', () => { it('has a working factory', () => { @@ -55,28 +55,28 @@ describe('GitLabIntegration', () => { }); }); -describe('replaceUrlType', () => { +describe('replaceGitLabUrlType', () => { it('should replace with expected type', () => { expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/my-org/my-project/-/blob/develop/README.md', 'edit', ), ).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/webmodules/blob/-/blob/develop/test', 'tree', ), ).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/blob/blob/-/blob/develop/test', 'tree', ), ).toBe('https://gitlab.com/blob/blob/-/tree/develop/test'); expect( - replaceUrlType( + replaceGitLabUrlType( 'https://gitlab.com/blob/blob/-/edit/develop/README.md', 'tree', ), diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index cb24829946..0c52799599 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -60,11 +60,18 @@ export class GitLabIntegration implements ScmIntegration { } resolveEditUrl(url: string): string { - return replaceUrlType(url, 'edit'); + return replaceGitLabUrlType(url, 'edit'); } } -export function replaceUrlType( +/** + * Takes a GitLab URL and replaces the type part (blob, tree etc). + * + * @param url - The original URL + * @param type - The desired type, e.g. 'blob', 'tree', 'edit' + * @public + */ +export function replaceGitLabUrlType( url: string, type: 'blob' | 'tree' | 'edit', ): string { diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 950205d61c..e8d6665a6f 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -20,4 +20,4 @@ export { } from './config'; export type { GitLabIntegrationConfig } from './config'; export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; -export { GitLabIntegration } from './GitLabIntegration'; +export { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration'; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index 0feb4dd7a4..0aeacde75c 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -8,6 +8,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsync'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { CSSProperties } from '@material-ui/styles'; @@ -154,6 +155,26 @@ export const EntityTechdocsContent: (props: { children?: ReactNode; }) => JSX.Element; +// @public +export const GiveFeedbackAddon: ComponentType; + +// @public (undocumented) +export type GiveFeedbackProps = { + debounceTime?: number; + templateBuilder?: GiveFeedbackTemplateBuilder; +}; + +// @public (undocumented) +export type GiveFeedbackTemplate = { + title: string; + body: string; +}; + +// @public (undocumented) +export type GiveFeedbackTemplateBuilder = ( + selection: Selection, +) => GiveFeedbackTemplate; + // @public export const isTechDocsAvailable: (entity: Entity) => boolean; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 43aba14f2f..9813bdc66f 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -53,7 +53,7 @@ "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/styles": "^4.10.0", "dompurify": "^2.2.9", - "event-source-polyfill": "1.0.25", + "event-source-polyfill": "^1.0.25", "git-url-parse": "^11.6.0", "jss": "~10.8.2", "lodash": "^4.17.21", diff --git a/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx new file mode 100644 index 0000000000..a3af0a09f0 --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; + +import { FeedbackLink } from './FeedbackLink'; + +const defaultProps = { + repository: { + type: 'github', + name: 'backstage', + owner: 'backstage', + protocol: 'https', + resource: 'github.com', + }, + template: { + title: 'Documentation feedback', + body: '## Documentation Feedback 📝', + }, +}; + +describe('FeedbackLink', () => { + const apiSpy = new MockAnalyticsApi(); + + it('Should open new issue tab', () => { + render( + wrapInTestApp( + + + , + ), + ); + + const link = screen.getByText(/Open new Github issue/); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('target', '_blank'); + const encodedTitle = encodeURIComponent(defaultProps.template.title); + const encodedBody = encodeURIComponent(defaultProps.template.body); + expect(link).toHaveAttribute( + 'href', + `https://github.com/backstage/backstage/issues/new?title=${encodedTitle}&body=${encodedBody}`, + ); + }); + + it('Should track click events', async () => { + render( + wrapInTestApp( + + + , + ), + ); + + fireEvent.click(screen.getByText(/Open new Github issue/)); + + await waitFor(() => { + expect(apiSpy.getEvents()[0]).toMatchObject({ + action: 'click', + subject: 'Open new Github issue', + }); + }); + }); +}); diff --git a/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx new file mode 100644 index 0000000000..51cff1324c --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { makeStyles } from '@material-ui/core'; +import BugReportIcon from '@material-ui/icons/BugReport'; + +import { Link, GitHubIcon } from '@backstage/core-components'; + +import { GiveFeedbackTemplate, Repository } from './types'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'grid', + gridGap: theme.spacing(1), + gridAutoFlow: 'column', + justifyContent: 'center', + alignItems: 'center', + color: theme.palette.common.black, + fontSize: theme.typography.button.fontSize, + }, +})); + +type FeedbackLinkProps = { + template: GiveFeedbackTemplate; + repository: Repository; +}; + +const getIcon = ({ type }: Repository) => { + if (type === 'github') { + return GitHubIcon; + } + return BugReportIcon; +}; + +const getName = ({ type }: Repository) => { + if (type === 'github') { + return 'Github'; + } + return 'Gitlab'; +}; + +const getUrl = (repository: Repository, template: GiveFeedbackTemplate) => { + const { title, body } = template; + const encodedTitle = encodeURIComponent(title); + const encodedBody = encodeURIComponent(body); + const { protocol, resource, owner, name, type } = repository; + const encodedOwner = encodeURIComponent(owner); + const encodedName = encodeURIComponent(name); + + const url = `${protocol}://${resource}/${encodedOwner}/${encodedName}`; + if (type === 'github') { + return `${url}/issues/new?title=${encodedTitle}&body=${encodedBody}`; + } + return `${url}/issues/new?[title]=${encodedTitle}&[body]=${encodedBody}`; +}; + +export const FeedbackLink = ({ template, repository }: FeedbackLinkProps) => { + const classes = useStyles(); + + const Icon = getIcon(repository); + const url = getUrl(repository, template); + + return ( + + Open new {getName(repository)} issue + + ); +}; diff --git a/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx b/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx new file mode 100644 index 0000000000..e6da14a424 --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; + +import { makeStyles, Portal, Paper } from '@material-ui/core'; + +import { useGitTemplate, useGitRepository } from './hooks'; +import { GiveFeedbackTemplateBuilder } from './types'; +import { + PAGE_MAIN_CONTENT_SELECTOR, + PAGE_FEEDBACK_LINK_SELECTOR, + ADDON_FEEDBACK_CONTAINER_ID, + ADDON_FEEDBACK_CONTAINER_SELECTOR, +} from './constants'; +import { FeedbackLink } from './FeedbackLink'; + +import { + useShadowRootElements, + useShadowRootSelection, +} from '../../reader/components/TechDocsReaderPage'; + +const useStyles = makeStyles(theme => ({ + root: { + transform: 'translate(-100%, -100%)', + position: 'absolute', + padding: theme.spacing(1), + zIndex: theme.zIndex.tooltip, + background: theme.palette.common.white, + }, +})); + +type Style = { + top: string; + left: string; +}; + +/** + * @public + */ +export type GiveFeedbackProps = { + debounceTime?: number; + templateBuilder?: GiveFeedbackTemplateBuilder; +}; + +/** + * Show give feedback button when text is highlighted + */ +export const GiveFeedback = ({ + debounceTime = 500, + templateBuilder: buildTemplate, +}: GiveFeedbackProps) => { + const classes = useStyles(); + const [style, setStyle] = useState