feat(graph): add copy button for entire target configuration (#26284)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

## Current Behavior
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
<img width="540" alt="Screenshot 2024-07-03 at 5 29 12 PM"
src="https://github.com/nrwl/nx/assets/16211801/bed98c56-3bd5-4170-893b-cefe5fe292f9">

<img width="1195" alt="Screenshot 2024-07-03 at 5 29 03 PM"
src="https://github.com/nrwl/nx/assets/16211801/544a4c4e-299f-40fc-a767-215d9c758dd8">



## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Emily Xiong 2024-07-04 07:16:13 -07:00 committed by GitHub
parent e09bad9363
commit 311710e56c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 167 additions and 178 deletions

View File

@ -1,13 +1,8 @@
import {
ClipboardDocumentCheckIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
// @ts-ignore
import { CopyToClipboard } from 'react-copy-to-clipboard';
// @ts-ignore
import SyntaxHighlighter, { createElement } from 'react-syntax-highlighter';
import { JSX, ReactNode, useEffect, useMemo, useState } from 'react';
import { JSX, ReactNode, useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { CopyToClipboardButton } from '@nx/graph/ui-components';
export function JsonCodeBlockPreTag({
children,
@ -30,45 +25,27 @@ export function JsonCodeBlockPreTag({
export interface JsonCodeBlockProps {
data: any;
renderSource: (propertyName: string) => ReactNode;
copyTooltipText: string;
}
export function JsonCodeBlock(props: JsonCodeBlockProps): JSX.Element {
const [copied, setCopied] = useState(false);
const jsonString = useMemo(
() => JSON.stringify(props.data, null, 2),
[props.data]
);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => {
setCopied(false);
}, 3000);
return () => clearTimeout(t);
}, [copied]);
return (
<div className="code-block group relative w-full">
<div className="absolute right-0 top-0 z-10 flex">
<CopyToClipboard
<CopyToClipboardButton
text={jsonString}
onCopy={() => {
setCopied(true);
}}
>
<button
type="button"
tooltipAlignment="right"
tooltipText={props.copyTooltipText}
className={twMerge(
'not-prose flex',
'border border-slate-200 bg-slate-50/50 p-2 dark:border-slate-700 dark:bg-slate-800/60',
'opacity-0 transition-opacity group-hover:opacity-100'
)}
>
{copied ? (
<ClipboardDocumentCheckIcon className="h-5 w-5 text-blue-500 dark:text-sky-500" />
) : (
<ClipboardDocumentIcon className="h-5 w-5" />
)}
</button>
</CopyToClipboard>
/>
</div>
<SyntaxHighlighter
language="json"

View File

@ -1,3 +1,4 @@
export * from './lib/copy-to-clipboard-button';
export * from './lib/debounced-text-input';
export * from './lib/tag';
export * from './lib/dropdown';

View File

@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
CopyToClipboardButton,
CopyToClipboardButtonProps,
} from './copy-to-clipboard-button';
const meta: Meta<typeof CopyToClipboardButton> = {
component: CopyToClipboardButton,
title: 'CopyToClipboardButton',
};
export default meta;
type Story = StoryObj<typeof CopyToClipboardButton>;
export const Simple: Story = {
args: {
text: 'Hello, world!',
tooltipAlignment: 'left',
} as CopyToClipboardButtonProps,
};

View File

@ -0,0 +1,61 @@
// @ts-ignore
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { JSX, ReactNode, useEffect, useState } from 'react';
import {
ClipboardDocumentCheckIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
export interface CopyToClipboardButtonProps {
text: string;
tooltipText?: string;
tooltipAlignment?: 'left' | 'right';
className?: string;
children?: ReactNode;
}
export function CopyToClipboardButton({
text,
tooltipAlignment,
tooltipText,
className,
children,
}: CopyToClipboardButtonProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => {
setCopied(false);
}, 3000);
return () => clearTimeout(t);
}, [copied]);
return (
<CopyToClipboard
text={text}
onCopy={() => {
setCopied(true);
}}
>
<button
type="button"
data-tooltip={tooltipText ? tooltipText : false}
data-tooltip-align-right={tooltipAlignment === 'right'}
data-tooltip-align-left={tooltipAlignment === 'left'}
className={className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{copied ? (
<ClipboardDocumentCheckIcon className="inline h-5 w-5 text-blue-500 dark:text-sky-500" />
) : (
<ClipboardDocumentIcon className="inline h-5 w-5" />
)}
{children}
</button>
</CopyToClipboard>
);
}

View File

@ -1,17 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CopyToClipboard } from './copy-to-clipboard';
const meta: Meta<typeof CopyToClipboard> = {
component: CopyToClipboard,
title: 'CopyToClipboard',
};
export default meta;
type Story = StoryObj<typeof CopyToClipboard>;
export const Simple: Story = {
args: {
onCopy: () => {},
tooltipAlignment: 'left',
},
};

View File

@ -1,36 +0,0 @@
import { ClipboardIcon } from '@heroicons/react/24/outline';
import { JSX, useEffect, useState } from 'react';
interface CopyToClipboardProps {
onCopy: () => void;
tooltipAlignment?: 'left' | 'right';
}
export function CopyToClipboard(props: CopyToClipboardProps): JSX.Element {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false);
}, 3000);
return () => clearTimeout(timeout);
}
});
return (
<span
data-tooltip="Copy to clipboard"
data-tooltip-align-right={props.tooltipAlignment === 'right'}
>
<ClipboardIcon
className={`inline h-4 w-5 !cursor-pointer ${
copied ? 'text-sky-500' : ''
}`}
onClick={(e) => {
e.stopPropagation();
setCopied(true);
props.onCopy();
}}
></ClipboardIcon>
</span>
);
}

View File

@ -1,5 +1,3 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit';
@ -71,7 +69,7 @@ export const ProjectDetails = ({
>
<div
className={twMerge(
`flex items-center justify-between`,
`flex flex-wrap items-center justify-between`,
isCompact ? `gap-1` : `mb-4 gap-2`
)}
>
@ -90,17 +88,15 @@ export const ProjectDetails = ({
className="h-6 w-6"
/>
</div>
<span>
{onViewInProjectGraph && viewInProjectGraphPosition === 'top' && (
<ViewInProjectGraphButton
callback={() =>
onViewInProjectGraph({ projectName: project.name })
}
/>
)}{' '}
</span>
)}
</div>
<div className="flex justify-between py-2">
<div className="flex flex-wrap justify-between py-2">
<div>
{projectData.metadata?.description ? (
<p className="mb-2 text-sm capitalize text-gray-500 dark:text-slate-400">
@ -133,7 +129,6 @@ export const ProjectDetails = ({
) : null}
</div>
<div className="self-end">
<span>
{onViewInProjectGraph &&
viewInProjectGraphPosition === 'bottom' && (
<ViewInProjectGraphButton
@ -141,8 +136,7 @@ export const ProjectDetails = ({
onViewInProjectGraph({ projectName: project.name })
}
/>
)}{' '}
</span>
)}
</div>
</div>
</header>

View File

@ -1,6 +1,7 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { TargetConfiguration } from '@nx/devkit';
import { CopyToClipboardButton } from '@nx/graph/ui-components';
import {
ChevronDownIcon,
ChevronUpIcon,
@ -17,7 +18,6 @@ import { twMerge } from 'tailwind-merge';
import { Pill } from '../pill';
import { TargetTechnologies } from '../target-technologies/target-technologies';
import { SourceInfo } from '../source-info/source-info';
import { CopyToClipboard } from '../copy-to-clipboard/copy-to-clipboard';
import { getDisplayHeaderFromTargetConfiguration } from '../utils/get-display-header-from-target-configuration';
import { TargetExecutor } from '../target-executor/target-executor';
@ -53,10 +53,6 @@ export const TargetConfigurationDetailsHeader = ({
onViewInTaskGraph,
onNxConnect,
}: TargetConfigurationDetailsHeaderProps) => {
const handleCopyClick = async (copyText: string) => {
await window.navigator.clipboard.writeText(copyText);
};
if (!collapsable) {
// when collapsable is false, isCollasped should be false
isCollasped = false;
@ -156,6 +152,12 @@ export const TargetConfigurationDetailsHeader = ({
</div>
</div>
<div className="flex items-center gap-2">
<CopyToClipboardButton
text={JSON.stringify(targetConfiguration, null, 2)}
tooltipText={!isCollasped ? 'Copy Target' : undefined}
tooltipAlignment="right"
className="rounded-md bg-inherit p-1 text-sm text-slate-600 ring-1 ring-inset ring-slate-400/40 hover:bg-slate-200 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-700/60"
/>
{onViewInTaskGraph && (
<button
className="rounded-md bg-inherit p-1 text-sm text-slate-600 ring-1 ring-inset ring-slate-400/40 hover:bg-slate-200 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-700/60"
@ -194,21 +196,22 @@ export const TargetConfigurationDetailsHeader = ({
</div>
{!isCollasped && (
<div className="ml-5 mt-2 text-sm">
<div className="flex">
<SourceInfo
data={sourceMap[`targets.${targetName}`]}
propertyKey={`targets.${targetName}`}
color="text-gray-500 dark:text-slate-400"
/>
</div>
{targetName !== 'nx-release-publish' && (
<div className="mt-2 text-right">
<code className="ml-4 rounded bg-gray-100 px-2 py-1 font-mono text-gray-800 dark:bg-gray-700 dark:text-gray-300">
nx run {projectName}:{targetName}
</code>
<span>
<CopyToClipboard
onCopy={() =>
handleCopyClick(`nx run ${projectName}:${targetName}`)
}
<CopyToClipboardButton
text={`nx run ${projectName}:${targetName}`}
tooltipText="Copy Command"
tooltipAlignment="right"
/>
</span>

View File

@ -1,12 +1,11 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { TargetConfiguration } from '@nx/devkit';
import { JsonCodeBlock } from '@nx/graph/ui-code-block';
import { CopyToClipboardButton } from '@nx/graph/ui-components';
import { useCallback, useContext, useEffect, useState } from 'react';
import { FadingCollapsible } from './fading-collapsible';
import { TargetConfigurationProperty } from './target-configuration-property';
import { CopyToClipboard } from '../copy-to-clipboard/copy-to-clipboard';
import { PropertyInfoTooltip, Tooltip } from '@nx/graph/ui-tooltips';
import { TooltipTriggerText } from './tooltip-trigger-text';
import { Pill } from '../pill';
@ -53,10 +52,6 @@ export default function TargetConfigurationDetails({
const [collapsed, setCollapsed] = useState(true);
const { expandedTargets, toggleTarget } = useContext(ExpandedTargetsContext);
const handleCopyClick = async (copyText: string) => {
await window.navigator.clipboard.writeText(copyText);
};
const handleCollapseToggle = useCallback(() => {
if (toggleTarget) {
toggleTarget(targetName);
@ -110,10 +105,7 @@ export default function TargetConfigurationDetails({
<div className="p-4 text-base">
<div className="group mb-4">
<h4 className="mb-4">
<TargetExecutorTitle
{...displayHeader}
handleCopyClick={handleCopyClick}
/>
<TargetExecutorTitle {...displayHeader} />
</h4>
<p className="pl-5 font-mono">
<TargetExecutor {...displayHeader} link={link}>
@ -131,10 +123,7 @@ export default function TargetConfigurationDetails({
{script && (
<div className="group mb-4">
<h4 className="mb-4">
<TargetExecutorTitle
script={script}
handleCopyClick={handleCopyClick}
/>
<TargetExecutorTitle script={script} />
</h4>
<p className="pl-5 font-mono">
<TargetExecutor script={script} link={link}>
@ -164,6 +153,7 @@ export default function TargetConfigurationDetails({
<FadingCollapsible>
<JsonCodeBlock
data={options}
copyTooltipText="Copy Options"
renderSource={(propertyName: string) => (
<TargetSourceInfo
className="flex min-w-0 pl-4"
@ -198,14 +188,11 @@ export default function TargetConfigurationDetails({
</span>
</Tooltip>
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard
onCopy={() =>
handleCopyClick(
`"inputs": ${JSON.stringify(
<CopyToClipboardButton
text={`"inputs": ${JSON.stringify(
targetConfiguration.inputs
)}`
)
}
)}`}
tooltipText="Copy Inputs"
/>
</span>
</h4>
@ -239,14 +226,11 @@ export default function TargetConfigurationDetails({
</span>
</Tooltip>
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard
onCopy={() =>
handleCopyClick(
`"outputs": ${JSON.stringify(
<CopyToClipboardButton
text={`"outputs": ${JSON.stringify(
targetConfiguration.outputs
)}`
)
}
)}`}
tooltipText="Copy Outputs"
/>
</span>
</h4>
@ -279,15 +263,12 @@ export default function TargetConfigurationDetails({
<TooltipTriggerText>Depends On</TooltipTriggerText>
</span>
</Tooltip>
<span className="inline pl-4 opacity-0 transition-opacity duration-150 ease-in-out group-hover/line:opacity-100">
<CopyToClipboard
onCopy={() =>
handleCopyClick(
`"dependsOn": ${JSON.stringify(
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboardButton
text={`"dependsOn": ${JSON.stringify(
targetConfiguration.dependsOn
)}`
)
}
)}`}
tooltipText="Copy Depends On"
/>
</span>
</h4>
@ -336,6 +317,7 @@ export default function TargetConfigurationDetails({
<FadingCollapsible>
<JsonCodeBlock
data={targetConfiguration.configurations}
copyTooltipText="Copy Configurations"
renderSource={(propertyName: string) => (
<TargetSourceInfo
className="flex min-w-0 pl-4"

View File

@ -1,29 +1,26 @@
import { PropertyInfoTooltip, Tooltip } from '@nx/graph/ui-tooltips';
import { CopyToClipboard } from '../copy-to-clipboard/copy-to-clipboard';
import { CopyToClipboardButton } from '@nx/graph/ui-components';
import { TooltipTriggerText } from '../target-configuration-details/tooltip-trigger-text';
export function TargetExecutorTitle({
commands,
command,
script,
handleCopyClick,
executor,
}: {
handleCopyClick: (copyText: string) => void;
commands?: string[];
command?: string;
script?: string;
executor?: string;
}) {
if (commands && commands.length) {
return (
<span className="font-medium">
Commands
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard
onCopy={() =>
handleCopyClick(
`"commands": [${commands.map((c) => `"${c}"`).join(', ')}]`
)
}
<CopyToClipboardButton
text={`"commands": [${commands.map((c) => `"${c}"`).join(', ')}]`}
tooltipText="Copy Commands"
/>
</span>
</span>
@ -34,8 +31,9 @@ export function TargetExecutorTitle({
<span className="font-medium">
Command
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard
onCopy={() => handleCopyClick(`"command": "${command}"`)}
<CopyToClipboardButton
text={`"command": "${command}"`}
tooltipText="Copy Command"
/>
</span>
</span>
@ -46,7 +44,7 @@ export function TargetExecutorTitle({
<span className="font-medium">
Script
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard onCopy={() => handleCopyClick(script)} />
<CopyToClipboardButton text={script} tooltipText="Copy Script" />
</span>
</span>
);
@ -58,6 +56,12 @@ export function TargetExecutorTitle({
>
<span className="font-medium">
<TooltipTriggerText>Executor</TooltipTriggerText>
<span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboardButton
text={executor ?? ''}
tooltipText="Copy Executor"
/>
</span>
</span>
</Tooltip>
);

View File

@ -338,7 +338,7 @@
"npm-run-path": "^4.0.1",
"preact": "10.6.4",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.0.3",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "18.3.1",
"react-syntax-highlighter": "^15.5.0",
"regenerator-runtime": "0.13.7",

2
pnpm-lock.yaml generated
View File

@ -109,7 +109,7 @@ dependencies:
specifier: 18.3.1
version: 18.3.1
react-copy-to-clipboard:
specifier: ^5.0.3
specifier: ^5.1.0
version: 5.1.0(react@18.3.1)
react-dom:
specifier: 18.3.1