Skip to content

Commit

Permalink
feat(widget): Metadata fields can be copied to clipboard (#217)
Browse files Browse the repository at this point in the history
* Also fixed the bug where tooltips wouldn't appear after looking at detail view
* Note that this new code is untested because playwright doesn't support testing the contents of the clipboard.
  • Loading branch information
andrewedstrom committed Dec 17, 2023
1 parent 9d11389 commit a7a6cb1
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 68 deletions.
30 changes: 30 additions & 0 deletions packages/widget/src/assets/copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { svg } from 'lit';

export const copyToClipboardButton = (
textToCopy: string,
onCopy: (newText: string, x: number, y: number) => void,
) => svg`
<svg @click='${(event: PointerEvent) => copyToClipboard(event, textToCopy, onCopy)}'
class='copy-to-clipboard-button clickable' width='12' height='14' viewBox='0 0 12 14' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path class='copy-to-clipboard-button-path' fill='#989898' fill-rule='evenodd' clip-rule='evenodd' d='M7 3H2C1.44772 3 1 3.44772 1 4V12C1 12.5523 1.44772 13 2 13H7C7.55228 13 8 12.5523 8 12V4C8 3.44772 7.55228 3 7 3ZM2 2C0.895431 2 0 2.89543 0 4V12C0 13.1046 0.89543 14 2 14H7C8.10457 14 9 13.1046 9 12V4C9 2.89543 8.10457 2 7 2H2ZM10 1H5C4.44772 1 4 1.44772 4 2V10C4 10.5523 4.44772 11 5 11H10C10.5523 11 11 10.5523 11 10V2C11 1.44772 10.5523 1 10 1ZM5 0C3.89543 0 3 0.895431 3 2V10C3 11.1046 3.89543 12 5 12H10C11.1046 12 12 11.1046 12 10V2C12 0.895431 11.1046 0 10 0H5Z'/>
</svg>
`;

// Be aware: the copy-to-clipboard functionality is not tested. Sadly, it is basically impossible to test clipboard
// functionality in Playwright.
const copyToClipboard = (
event: PointerEvent,
textToCopy: string,
onCopy: (newText: string, x: number, y: number) => void,
) => {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
const x: number = event.pageX;
const y: number = event.pageY;
onCopy('Copied!', x, y);
})
.catch((err) => {
console.log('Failed to copy to clipboard\n\n', err);
});
};
1 change: 1 addition & 0 deletions packages/widget/src/assets/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// All files which will be accessible when the widget is installed via npm are declared here
export * from './copy-to-clipboard';
export * from './close-details-button';
export * from './logo-small';
export * from './logo-large';
2 changes: 1 addition & 1 deletion packages/widget/src/detail-navigation-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const backButton = (
<svg class='docmaps-timeline-back clickable' width='36' height='36' viewBox='0 0 36 36' fill='none' xmlns='http://www.w3.org/2000/svg'
@click='${() => updateSelectedNode(previousNode)}'>
<circle cx='17.5' cy='17.5' r='17' stroke='#474747'/>
<path transform='scale(-1,1) translate(-35,0)' d='M22.5 17.134C23.1667 17.5189 23.1667 18.4811 22.5 18.866L10.5 25.7942C9.83333 26.1791 9 25.698 9 24.9282L9 11.0718C9 10.302 9.83333 9.82087 10.5 10.2058L22.5 17.134Z' fill='#474747'/>
<path fill='#474747' transform='scale(-1,1) translate(-35,0)' d='M22.5 17.134C23.1667 17.5189 23.1667 18.4811 22.5 18.866L10.5 25.7942C9.83333 26.1791 9 25.698 9 24.9282L9 11.0718C9 10.302 9.83333 9.82087 10.5 10.2058L22.5 17.134Z'/>
<path transform='scale(-1,1) translate(-35,0)' d='M24 10L24 26' stroke='#474747' stroke-width='2' stroke-linecap='round'/>
</svg>`;
};
Expand Down
81 changes: 49 additions & 32 deletions packages/widget/src/detail-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
TYPE_DISPLAY_OPTIONS,
} from './display-object.ts';
import { renderDetailNavigationHeader } from './detail-navigation-header';
import { closeDetailsButton } from './assets';
import { closeDetailsButton, copyToClipboardButton } from './assets';

type MetadataKey = string;
type MetadataValue = string | string[];
Expand All @@ -17,14 +17,17 @@ export function renderDetailsView(
allNodes: DisplayObject[],
updateSelectedNode: (node: DisplayObject) => void,
closeDetailsView: () => void,
updateDetailTooltip: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult {
const opts = TYPE_DISPLAY_OPTIONS[selectedNode.type];
const backgroundColor = opts.detailViewBackgroundColor || opts.backgroundColor;
const textColor = opts.detailViewTextColor || opts.textColor;

const fieldsToDisplay: MetadataTuple[] = getMetadataFieldsToDisplay(selectedNode);
const detailBody: HTMLTemplateResult =
fieldsToDisplay.length > 0 ? createMetadataGrid(fieldsToDisplay) : emptyMetadataMessage();
fieldsToDisplay.length > 0
? renderMetadataGrid(fieldsToDisplay, updateDetailTooltip)
: emptyMetadataMessage();

return html`
<div class="detail-timeline no-select">
Expand All @@ -42,16 +45,20 @@ export function renderDetailsView(
`;
}

const createMetadataGrid = (
// Renders the metadata grid, which is a 2-column grid of key-value pairs.
// The key is displayed in the left column, and the value is displayed in the right column. If the value is an array, it
// is displayed as multiple rows, with the key cell spanning all those rows.
const renderMetadataGrid = (
metadataEntries: [MetadataKey, MetadataValue][],
updateDetailTooltip: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult => {
const gridItems: HTMLTemplateResult[] = metadataEntries.map(([key, value], index) =>
createGridItem(key, value, index),
createGridItem(key, value, index, updateDetailTooltip),
);
return html` <div class="metadata-grid">${gridItems}</div>`;
};

function displayMetadataKey(
function renderMetadataKey(
key: MetadataKey,
value: MetadataValue,
index: number,
Expand All @@ -70,45 +77,50 @@ function displayMetadataKey(
return html` <div class="metadata-grid-item key">${key}</div>`;
}

function displayMetadataValue(key: MetadataKey, value: MetadataValue): HTMLTemplateResult {
if (key === 'url') {
function displayMetadataValue(
key: MetadataKey,
value: MetadataValue,
updateDetailTooltip: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult {
if (key === 'url' && typeof value === 'string') {
// display as clickable link
return html` <a href="${value}" target="_blank" class="metadata-grid-item value metadata-link">
${value}
</a>`;
}

if (key === 'content' && Array.isArray(value)) {
// display as list of clickable links
return html` ${value.map(
(val) =>
html` <a
href="${val}"
target="_blank"
class="metadata-grid-item value content metadata-link"
>
${val}
</a>`,
)}`;
const template = html` <a href="${value}" target="_blank" class="metadata-link">${value}</a>`;
return copyableMetadataValue(template, value, updateDetailTooltip);
}

if (Array.isArray(value)) {
// display as list
return html`${value.map(
(val) => html` <div class="metadata-grid-item value content">${val}</div>`,
)}`;
// Display as a list of clickable links.
return html` ${value.map((val) => {
const template = html` <a href="${val}" target="_blank" class="content metadata-link"
>${val}</a
>`;
return copyableMetadataValue(template, val, updateDetailTooltip);
})}`;
}

// Display as single value
return html` <div class="metadata-grid-item value">${value}</div>`;
return copyableMetadataValue(html` <span>${value}</span>`, value, updateDetailTooltip);
}

function copyableMetadataValue(
template: HTMLTemplateResult,
value: string,
onCopy: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult {
return html`
<div class="metadata-grid-item value">${template} ${copyToClipboardButton(value, onCopy)}</div>
`;
}

const createGridItem = (
key: MetadataKey,
value: MetadataValue,
index: number,
updateDetailTooltip: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult => {
return html` ${displayMetadataKey(key, value, index)} ${displayMetadataValue(key, value)} `;
return html`
${renderMetadataKey(key, value, index)} ${displayMetadataValue(key, value, updateDetailTooltip)}
`;
};

const emptyMetadataMessage = (): HTMLTemplateResult => {
Expand All @@ -124,10 +136,15 @@ const getMetadataFieldsToDisplay = (node: DisplayObject): MetadataTuple[] => {
// then keep only the fields that should be displayed:
return Object.entries(normalizedNode)
.filter(([key, value]) => isDisplayObjectMetadataField(key) && value)
.filter(isMetadataTuple);
.filter(valueIsAStringOrStringArray);
};

const isMetadataTuple = (tuple: [string, any]): tuple is MetadataTuple => {
// This type guard asserts that the tuple's value is a string or string array. We need to narrow this down so that we
// can handle "content" differently from other fields, making its key cell span multiple rows.
//
// It's worth noting that if we ever add a non-string or non-string-array field to DisplayObjectMetadata, this method
// will need to be updated, because right now it will filter those fields out and keep them from being displayed.
const valueIsAStringOrStringArray = (tuple: [string, any]): tuple is MetadataTuple => {
const [_, value] = tuple;

const isString = typeof value === 'string';
Expand Down
35 changes: 22 additions & 13 deletions packages/widget/src/display-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ export interface DisplayObjectMetadata {
actors?: string;
}

// DisplayObjects are the widget's internal representation of a node from the graph view.
// They roughly correspond to a ThingT in the Docmap spec, but with only the fields that we want to display.
// A DisplayObject is the widget's internal representation of a node in the graph view.
// This type roughly corresponds to ThingT in the @docmaps/sdk, but with only the fields that we want to display.
export interface DisplayObject extends DisplayObjectMetadata {
nodeId: string; // Used internally to construct graph relationships, never rendered
type: string;
}

// The following 3 statements allow us to use FieldsToDisplay both as a type and as something we can
// check against at runtime. We could also use io-ts for this, but that felt like overkill since this
// is the only place in the widget where we do something like this.
// The following 3 statements allow us to use FieldsToDisplay both as a type and as something we can check against at
// runtime. We could use io-ts for this, but that felt like overkill since this is the only place in the widget where we
// do something like this.
export type DisplayObjectMetadataField = keyof DisplayObjectMetadata;
const DisplayObjectMetadataPrototype: { [K in DisplayObjectMetadataField]: null } = {
doi: null,
Expand All @@ -39,8 +39,8 @@ export function isDisplayObjectMetadataField(key: string): key is DisplayObjectM
return key in DisplayObjectMetadataPrototype;
}

// Returns a new DisplayObject which has no fields set to the value undefined,
// meaning the new Display Object can be merged with another DisplayObject via destructuring.
// Returns a new DisplayObject which has no fields set to the value undefined, meaning the new Display Object can be
// merged with another DisplayObject via destructuring.
//
// Also puts the fields in the order in which they should be displayed.
export function normalizeDisplayObject(displayObject: DisplayObject): DisplayObject {
Expand All @@ -57,14 +57,16 @@ export function normalizeDisplayObject(displayObject: DisplayObject): DisplayObj
};
}

// Lets you combine two display objects into one, with the fields of the second object taking precedence.
// only the first object can be undefined.
export function mergeDisplayObjects(a: DisplayObject | undefined, b: DisplayObject): DisplayObject {
return {
...(a && normalizeDisplayObject(a)),
...normalizeDisplayObject(b),
};
}

// DisplayObjectEdges are the widget's internal representation of an edge connecting two DisplayObjects.
// DisplayObjectEdges are the widget's internal representation of a connection between two DisplayObjects.
export type DisplayObjectEdge = {
sourceId: string;
targetId: string;
Expand All @@ -75,16 +77,23 @@ export type DisplayObjectGraph = {
edges: DisplayObjectEdge[];
};

// The appearance of a DisplayObject in the graph view and in the detail view is determined by its 'type' field.
// The following constants define the possible values of the 'type' field and the appearance that corresponds with each value.
// The appearance of a DisplayObject in the graph view and in the detail view is determined by its 'type' field. The
// following constants define the possible values of the 'type' field and the styling that corresponds with each value.
export type TypeDisplayOption = {
// Abbreviation representing the type, e.g. 'R' for 'Review'
shortLabel: string;
// Full, human-readable name of the type, e.g. 'Review'
longLabel: string;
// Background color of the node in the graph view
backgroundColor: string;
// Text color of the node in the graph view
textColor: string;
dottedBorder?: boolean; // whether the node should be rendered with a dotted border
detailViewBackgroundColor?: string; // if this is not set, backgroundColor will be used
detailViewTextColor?: string; // if this is not set, textColor will be used
// whether the node should be rendered with a dotted border in the graph view
dottedBorder?: boolean;
// Background color of the detail view header. if this is not set, backgroundColor will be used
detailViewBackgroundColor?: string;
// Text color of the detail view header. if this is not set, textColor will be used
detailViewTextColor?: string;
};

export const TYPE_DISPLAY_OPTIONS: {
Expand Down
48 changes: 41 additions & 7 deletions packages/widget/src/docmaps-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export class DocmapsWidget extends LitElement {
@state()
graph?: DisplayObjectGraph;

@state()
detailTooltip: { text: string; x: number; y: number } = {
text: '',
x: 0,
y: 0,
};

// We keep track of this because we have to render once before drawing the graph
// so that D3 has a canvas to draw into.
#hasRenderedOnce: boolean = false;
Expand All @@ -50,9 +57,21 @@ export class DocmapsWidget extends LitElement {
};

// Method to clear the selected node and go back to the graph view
private closeDetailView() {
private closeDetailView = () => {
this.selectedNode = undefined;
}
};

private updateDetailTooltip = (newText: string, x: number, y: number) => {
// Show the tooltip with the provided text and position
this.detailTooltip = {
text: newText,
x: x,
y: y,
};

// Set a timeout to hide the tooltip after 3 seconds
setTimeout(() => (this.detailTooltip = { ...this.detailTooltip, text: '' }), 2000);
};

render(): HTMLTemplateResult {
const d3Canvas: HTMLTemplateResult = html` <div id="${GRAPH_CANVAS_ID}"></div>`;
Expand All @@ -65,6 +84,23 @@ export class DocmapsWidget extends LitElement {
<span>DOCMAP</span>
</div>
${d3Canvas} ${content}
<div id="graph-tooltip" class="tooltip" style="opacity:0;"></div>
${this.renderDetailTooltip()}
</div>
`;
}

private renderDetailTooltip() {
const { text, x, y } = this.detailTooltip;
return html`
<div
id="detail-tooltip"
class="tooltip"
style="opacity: ${text ? 1 : 0}; left: ${x}px; top: ${y}px; visibility: ${text
? 'visible'
: 'hidden'}"
>
${text}
</div>
`;
}
Expand All @@ -87,8 +123,6 @@ export class DocmapsWidget extends LitElement {
};

private graphView() {
const tooltip = html` <div id="tooltip" class="tooltip" style="opacity:0;"></div>`;

if (this.graph) {
if (this.#hasRenderedOnce) {
// There is a canvas for D3 to draw in! We can render the graph now
Expand All @@ -98,11 +132,10 @@ export class DocmapsWidget extends LitElement {
return html` ${this.#docmapFetchingTask?.render({
complete: this.onFetchComplete,
error: (e) => noDocmapFoundScreen(e, this.doi),
})}
${tooltip}`;
})}`;
}

return tooltip;
return nothing;
}

private detailView() {
Expand All @@ -120,6 +153,7 @@ export class DocmapsWidget extends LitElement {
this.graph.nodes,
this.showDetailViewForNode,
this.closeDetailView,
this.updateDetailTooltip,
);
}
}
Expand Down
Loading

0 comments on commit a7a6cb1

Please sign in to comment.