From 543ea22e95e1ecd20aed6934902f0e3584ef2efd Mon Sep 17 00:00:00 2001 From: Andrew Edstrom Date: Tue, 21 Nov 2023 15:38:15 -0700 Subject: [PATCH] chore(widget): extract detail view and font into separate files (#184) * extract detail view and font * update readme dev instructions * various other refactors --- packages/widget/README.md | 32 +++- packages/widget/package.json | 3 +- .../widget/src/assets/close-details-button.ts | 8 +- .../widget/src/detail-navigation-header.ts | 45 ++--- packages/widget/src/detail-view.ts | 68 ++++++++ packages/widget/src/docmaps-widget.ts | 154 +++++------------- packages/widget/src/font.ts | 20 +++ 7 files changed, 190 insertions(+), 140 deletions(-) create mode 100644 packages/widget/src/detail-view.ts create mode 100644 packages/widget/src/font.ts diff --git a/packages/widget/README.md b/packages/widget/README.md index fec018bf..1541a83b 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -22,14 +22,40 @@ The first time you run the tests, you will need to install browsers for Playwrig pnpm run install:browsers ``` -After that, you can run the tests with +#### All tests ```shell pnpm run test ``` -To see the tests run visually, step-by-step, you can open the Playwright UI like this: +#### Unit tests ```shell -pnpm run test:ui +pnpm run test:unit +``` + +#### Integration tests + +To see the tests run in step-by-step, you can open the Playwright UI like this. The Playwright UI is an amazing tool +because it lets you see screenshots of each step of the test, and it automatically reruns the tests when you make +changes. + +```shell +pnpm run test:integration:ui +``` + +Alternatively, you can run the tests headlessly and see results in the terminal: + +```shell +pnpm run test:integration +``` + +By default, the tests only run in chromium locally. To run in chromium, firefox, and webkit, you can run: + +```shell +# Headless +pnpm run test:integration:all-browsers + +# With UI +pnpm run test:integration:ui:all-browsers ``` diff --git a/packages/widget/package.json b/packages/widget/package.json index ddb48ce9..568e62fa 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -12,7 +12,8 @@ "test:unit": "ava", "test:integration": "playwright test", "test:integration:all-browsers": "ALL_BROWSERS=true playwright test", - "test:integration:ui": "playwright test --ui" + "test:integration:ui": "playwright test --ui", + "test:integration:ui:all-browsers": "ALL_BROWSERS=true playwright test --ui" }, "exports": { ".": "./dist/index.js" diff --git a/packages/widget/src/assets/close-details-button.ts b/packages/widget/src/assets/close-details-button.ts index aa63b695..37a8af2d 100644 --- a/packages/widget/src/assets/close-details-button.ts +++ b/packages/widget/src/assets/close-details-button.ts @@ -1,8 +1,8 @@ import { svg } from 'lit'; export const closeDetailsButton = (color: string) => svg` - - - - + + + + `; diff --git a/packages/widget/src/detail-navigation-header.ts b/packages/widget/src/detail-navigation-header.ts index 8a5a1df4..326ff2dd 100644 --- a/packages/widget/src/detail-navigation-header.ts +++ b/packages/widget/src/detail-navigation-header.ts @@ -1,6 +1,10 @@ import { nothing, svg, SVGTemplateResult } from 'lit'; import { DisplayObject, TYPE_DISPLAY_OPTIONS } from './util'; +const TIMELINE_WIDTH: number = 368; +const FIRST_NODE_X: number = 6.5; +const LAST_NODE_X: number = TIMELINE_WIDTH - 9.5; + const backButton = ( allNodes: DisplayObject[], selectedNode: DisplayObject, @@ -35,30 +39,27 @@ const forwardButton = ( `; }; +function getNodeX(i: number, numberOfNodes: number): number { + const interval = (LAST_NODE_X - FIRST_NODE_X) / (numberOfNodes - 1); + return FIRST_NODE_X + interval * i; +} + const timeline = ( allNodes: DisplayObject[], selectedNode: DisplayObject, updateSelectedNode: (node: DisplayObject) => void, ) => { - const width = 368; - const firstXPosition: number = 6.5; - const lastXPosition: number = 358.5; - - const timelineNodes = allNodes.map((node, i) => { - let x = firstXPosition; - if (i > 0) { - const spaceBetweenNodes: number = (lastXPosition - firstXPosition) / (allNodes.length - 1); - x = firstXPosition + spaceBetweenNodes * i; - } + const timelineNodes: SVGTemplateResult[] = allNodes.map((node, i) => { + const x = getNodeX(i, allNodes.length); const displayOpts = TYPE_DISPLAY_OPTIONS[node.type]; const color = displayOpts.detailBackgroundColor ?? displayOpts.backgroundColor; - const selectedNodeIndicator = - node.nodeId === selectedNode.nodeId - ? svg` - + const thisNodeIsSelected: boolean = node.nodeId === selectedNode.nodeId; + const selectedNodeIndicator: SVGTemplateResult | typeof nothing = thisNodeIsSelected + ? svg` ` - : nothing; + : nothing; + return svg` @@ -66,18 +67,24 @@ const timeline = ( `; }); + const timeline = svg` - + + ${timeline}> ${timelineNodes} `; }; -export const renderDetailNavigationHeader: ( +type RenderDetailNavigationHeader = ( allNodes: DisplayObject[], selectedNode: DisplayObject, updateSelectedNode: (node: DisplayObject) => void, -) => SVGTemplateResult = ( +) => SVGTemplateResult; + +export const renderDetailNavigationHeader: RenderDetailNavigationHeader = ( allNodes: DisplayObject[], selectedNode: DisplayObject, updateSelectedNode, diff --git a/packages/widget/src/detail-view.ts b/packages/widget/src/detail-view.ts new file mode 100644 index 00000000..aec6d041 --- /dev/null +++ b/packages/widget/src/detail-view.ts @@ -0,0 +1,68 @@ +import { html, HTMLTemplateResult } from 'lit'; +import { DisplayObject, filterMetadataEntries, TYPE_DISPLAY_OPTIONS } from './util'; +import { renderDetailNavigationHeader } from './detail-navigation-header'; +import { closeDetailsButton } from './assets'; + +export function renderDetailsView( + selectedNode: DisplayObject, + allNodes: DisplayObject[], + updateSelectedNode: (node: DisplayObject) => void, + closeDetailsView: () => void, +) { + const opts = TYPE_DISPLAY_OPTIONS[selectedNode.type]; + const metadataEntries: [string, any][] = filterMetadataEntries(selectedNode); + + const metadataBody: HTMLTemplateResult = + metadataEntries.length > 0 ? createMetadataGrid(metadataEntries) : emptyMetadataMessage(); + + const backgroundColor = opts.detailBackgroundColor || opts.backgroundColor; + const textColor = opts.detailTextColor || opts.textColor; + + return html` +
+ ${renderDetailNavigationHeader(allNodes, selectedNode, updateSelectedNode)} +
+ +
+ ${opts.longLabel} +
+ ${closeDetailsButton(textColor)} +
+
+ +
${metadataBody}
+ `; +} + +const createMetadataGrid = (metadataEntries: [string, any][]): HTMLTemplateResult => { + const gridItems: HTMLTemplateResult[] = metadataEntries.map((entry, index) => + createGridItem(entry, index), + ); + return html` `; +}; + +const createGridItem = ([key, value]: [string, any], index: number): HTMLTemplateResult => { + if (Array.isArray(value)) { + const values: any[] = value; // rename since it's actually plural + return html` + + ${values.map((val) => html` `)} + `; + } + + return html` + + + `; +}; + +const emptyMetadataMessage = (): HTMLTemplateResult => { + return html` `; +}; diff --git a/packages/widget/src/docmaps-widget.ts b/packages/widget/src/docmaps-widget.ts index 9e874c11..98859bbf 100644 --- a/packages/widget/src/docmaps-widget.ts +++ b/packages/widget/src/docmaps-widget.ts @@ -1,18 +1,13 @@ import { html, HTMLTemplateResult, LitElement, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { customCss } from './styles'; -import { closeDetailsButton, logo } from './assets'; +import { logo } from './assets'; import { Task } from '@lit/task'; import { DocmapFetchingParams, getDocmap } from './docmap-controller'; -import { - DisplayObject, - DisplayObjectGraph, - filterMetadataEntries, - GRAPH_CANVAS_ID, - TYPE_DISPLAY_OPTIONS, -} from './util'; +import { DisplayObject, DisplayObjectGraph, GRAPH_CANVAS_ID } from './util'; import { clearGraph, displayGraph } from './graph-view'; -import { renderDetailNavigationHeader } from './detail-navigation-header'; +import { renderDetailsView } from './detail-view'; +import { loadFont } from './font'; @customElement('docmaps-widget') export class DocmapsWidget extends LitElement { @@ -22,8 +17,8 @@ export class DocmapsWidget extends LitElement { @property({ type: String }) serverUrl: string = ''; - @state() // if the selectedNode changes, this means we trigger a rerender (to show the details view) - selectedNode?: DisplayObject; + @state() // This decorator automatically causes a rerender when the selecetdNode changes + selectedNode?: DisplayObject; // if this is set, we're showing the detail view allNodes: DisplayObject[] = []; @@ -39,14 +34,18 @@ export class DocmapsWidget extends LitElement { loadFont(); } + private showDetailViewForNode = (node: DisplayObject) => { + this.selectedNode = node; + }; + + // Method to clear the selected node and go back to the graph view + private closeDetailView() { + this.selectedNode = undefined; + } + render(): HTMLTemplateResult { - let content: HTMLTemplateResult; - if (this.selectedNode) { - content = this.renderDetailsView(this.selectedNode); - } else { - content = html`
- ${this.#docmapFetchingTask.render({ complete: this.renderGraphView.bind(this) })}`; - } + const d3Canvas: HTMLTemplateResult = html`
`; + const content = this.selectedNode ? this.detailView() : this.graphView(); return html`
@@ -54,113 +53,42 @@ export class DocmapsWidget extends LitElement { ${logo} DOCMAP
-
- ${content} + ${d3Canvas} ${content} `; } - private renderGraphView({ nodes, edges }: DisplayObjectGraph) { - if (this.shadowRoot) { - this.allNodes = nodes; - displayGraph(nodes, edges, this.shadowRoot, this.displayDetailsViewForNode); - } + private graphView() { + const onFetchComplete = ({ nodes, edges }: DisplayObjectGraph) => { + if (this.shadowRoot) { + this.allNodes = nodes; + displayGraph(nodes, edges, this.shadowRoot, this.showDetailViewForNode); + } - // D3 draws the graph for us, so we have nothing to actually render here - return nothing; - } + return nothing; // D3 draws the graph for us, so we have nothing to actually render here + }; - private displayDetailsViewForNode = (node: DisplayObject) => { - this.selectedNode = node; - }; - - private renderDetailsView(selectedNode: DisplayObject): HTMLTemplateResult { - clearGraph(this.shadowRoot); - const opts = TYPE_DISPLAY_OPTIONS[selectedNode.type]; - const metadataEntries: [string, any][] = filterMetadataEntries(selectedNode); - - const metadataBody: HTMLTemplateResult = - metadataEntries.length > 0 - ? this.createMetadataGrid(metadataEntries) - : this.emptyMetadataMessage(); - - const backgroundColor = opts.detailBackgroundColor || opts.backgroundColor; - const textColor = opts.detailTextColor || opts.textColor; - - return html` -
- ${renderDetailNavigationHeader(this.allNodes, selectedNode, this.displayDetailsViewForNode)} -
- -
- ${opts.longLabel} -
- ${closeDetailsButton(textColor)} -
-
+ // this function usually returns a template result, but we don't need it currently since d3 draws the graph for us + this.#docmapFetchingTask.render({ + complete: onFetchComplete, + }); -
${metadataBody}
- `; - } - - // Method to clear the selected node and go back to the graph - private closeDetailsView() { - this.selectedNode = undefined; - } - - private createMetadataGrid(metadataEntries: [string, any][]): HTMLTemplateResult { - const gridItems: HTMLTemplateResult[] = metadataEntries.map((entry, index) => - this.createGridItem(entry, index), - ); - return html` `; + return html`
`; } - private createGridItem([key, value]: [string, any], index: number): HTMLTemplateResult { - if (Array.isArray(value)) { - const values: any[] = value; // rename since it's actually plural - return html` - - ${values.map((val) => html` `)} - `; + private detailView() { + clearGraph(this.shadowRoot); + if (!this.selectedNode) { + return nothing; } - return html` - - - `; - } - - private emptyMetadataMessage(): HTMLTemplateResult { - return html` `; - } -} - -function loadFont() { - // Load IBM Plex Mono font - // It would be nice to do this in styles.ts, but `@import` is not supported there. - addLinkToDocumentHeader('preconnect', 'https://fonts.googleapis.com'); - addLinkToDocumentHeader('preconnect', 'https://fonts.gstatic.com', 'anonymous'); - addLinkToDocumentHeader( - 'stylesheet', - 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,300&display=swap', - ); -} - -function addLinkToDocumentHeader(rel: string, href: string, crossorigin?: string) { - const link = document.createElement('link'); - link.rel = rel; - link.href = href; - if (crossorigin) { - link.crossOrigin = crossorigin; + return renderDetailsView( + this.selectedNode, + this.allNodes, + this.showDetailViewForNode, + this.closeDetailView, + ); } - document.head.appendChild(link); } declare global { diff --git a/packages/widget/src/font.ts b/packages/widget/src/font.ts new file mode 100644 index 00000000..df1e6e68 --- /dev/null +++ b/packages/widget/src/font.ts @@ -0,0 +1,20 @@ +export function loadFont() { + // Load IBM Plex Mono font + // It would be nice to do this in styles.ts, but `@import` is not supported there. + addLinkToDocumentHeader('preconnect', 'https://fonts.googleapis.com'); + addLinkToDocumentHeader('preconnect', 'https://fonts.gstatic.com', 'anonymous'); + addLinkToDocumentHeader( + 'stylesheet', + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,300&display=swap', + ); +} + +function addLinkToDocumentHeader(rel: string, href: string, crossorigin?: string) { + const link = document.createElement('link'); + link.rel = rel; + link.href = href; + if (crossorigin) { + link.crossOrigin = crossorigin; + } + document.head.appendChild(link); +}