Skip to content

feat(explorer): make field values copyable across the app #6849

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-dryers-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@iota/apps-ui-kit': patch
---

add copy button to DisplayStats component
24 changes: 6 additions & 18 deletions apps/explorer/src/components/gas-breakdown/GasBreakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,10 @@
// SPDX-License-Identifier: Apache-2.0

import { Accordion, AccordionContent, Title, Divider } from '@iota/apps-ui-kit';
import {
CoinFormat,
type TransactionSummaryType,
useCopyToClipboard,
useFormatCoin,
toast,
} from '@iota/core';
import { Copy } from '@iota/apps-ui-icons';
import { CoinFormat, type TransactionSummaryType, useFormatCoin } from '@iota/core';
import { AddressLink, CollapsibleCard, ObjectLink } from '~/components/ui';
import { Fragment } from 'react';
import { onCopySuccess } from '~/lib/utils';

interface GasProps {
amount?: bigint | number | string;
Expand Down Expand Up @@ -53,20 +47,14 @@ function GasAmount({ amount, burnedAmount }: GasProps): JSX.Element | null {
}

function GasPaymentLinks({ objectIds }: { objectIds: string[] }): JSX.Element {
const copyToClipBoard = useCopyToClipboard(() => toast('Copied'));

const handleCopy = async (objectId: string) => {
await copyToClipBoard(objectId);
};

return (
<div className="flex max-h-20 min-h-[20px] flex-wrap items-center gap-x-4 gap-y-2 overflow-y-auto">
{objectIds.map((objectId, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<ObjectLink objectId={objectId} />
<Copy
className="shrink-0 cursor-pointer text-neutral-70"
onClick={() => handleCopy(objectId)}
<ObjectLink
objectId={objectId}
copyText={objectId}
onCopySuccess={onCopySuccess}
/>
</div>
))}
Expand Down
7 changes: 6 additions & 1 deletion apps/explorer/src/components/object/DynamicFieldsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Panel,
LoadingIndicator,
} from '@iota/apps-ui-kit';
import { onCopySuccess } from '~/lib/utils';

interface DynamicFieldRowProps {
id: string;
Expand All @@ -35,7 +36,11 @@ function DynamicFieldRow({ id, result, defaultOpen }: DynamicFieldRowProps): JSX
String(result.name.value)
) : null}
</div>
<ObjectLink objectId={result.objectId} />
<ObjectLink
objectId={result.objectId}
copyText={result.objectId}
onCopySuccess={onCopySuccess}
/>
</div>
</AccordionHeader>
<AccordionContent isExpanded={open}>
Expand Down
15 changes: 13 additions & 2 deletions apps/explorer/src/components/object/FieldItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type IotaMoveNormalizedType } from '@iota/iota-sdk/client';
import { SyntaxHighlighter } from '~/components';
import { AddressLink, Link, ObjectLink } from '~/components/ui';
import { getFieldTypeValue } from '~/lib/ui';
import { onCopySuccess } from '~/lib/utils';

interface FieldItemProps {
value: string | number | object | boolean;
Expand Down Expand Up @@ -34,15 +35,25 @@ export function FieldItem({
if (normalizedType === TYPE_ADDRESS) {
return (
<div className="break-all">
<AddressLink address={value.toString()} noTruncate={!truncate} />
<AddressLink
address={value.toString()}
noTruncate={!truncate}
copyText={value.toString()}
onCopySuccess={onCopySuccess}
/>
</div>
);
}

if (normalizedType === 'string' && TYPE_OBJECT_ID.includes(normalizedType)) {
return (
<div className="break-all">
<ObjectLink objectId={value.toString()} noTruncate={!truncate} />
<ObjectLink
objectId={value.toString()}
noTruncate={!truncate}
copyText={value.toString()}
onCopySuccess={onCopySuccess}
/>
</div>
);
}
Expand Down
8 changes: 7 additions & 1 deletion apps/explorer/src/components/owned-coins/CoinItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CoinFormat, useFormatCoin } from '@iota/core';
import { type CoinStruct } from '@iota/iota-sdk/client';
import { formatAddress } from '@iota/iota-sdk/utils';
import { ObjectLink } from '../ui';
import { onCopySuccess } from '~/lib/utils';

interface CoinItemProps {
coin: CoinStruct;
Expand All @@ -23,7 +24,12 @@ export function CoinItem({ coin }: CoinItemProps): JSX.Element {
keyText={`${formattedBalance} ${symbol}`}
isReverse
value={
<ObjectLink objectId={coin.coinObjectId} label={formatAddress(coin.coinObjectId)} />
<ObjectLink
objectId={coin.coinObjectId}
label={formatAddress(coin.coinObjectId)}
copyText={coin.coinObjectId}
onCopySuccess={onCopySuccess}
/>
}
fullwidth
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type MoveCallMetric } from '@iota/iota-sdk/client';
import { type ColumnDef } from '@tanstack/react-table';

import { ObjectLink, PlaceholderTable, TableCard } from '~/components/ui';
import { onCopySuccess } from '~/lib/utils';

interface TopPackagesTableProps {
data: MoveCallMetric[];
Expand Down Expand Up @@ -47,7 +48,7 @@ const tableColumns: ColumnDef<MoveCallMetric>[] = [
const item = metric[0].package;
return (
<TableCellBase>
<ObjectLink objectId={item}>
<ObjectLink objectId={item} copyText={item} onCopySuccess={onCopySuccess}>
<TableCellText>{item}</TableCellText>
</ObjectLink>
</TableCellBase>
Expand Down
47 changes: 39 additions & 8 deletions apps/explorer/src/components/ui/InternalLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { Copy } from '@iota/apps-ui-icons';
import { ButtonUnstyled } from '@iota/apps-ui-kit';
import { formatAddress, formatDigest, formatType } from '@iota/iota-sdk/utils';
import { type ReactNode } from 'react';

Expand All @@ -11,6 +13,9 @@ interface BaseInternalLinkProps extends LinkProps {
noTruncate?: boolean;
label?: string | ReactNode;
queryStrings?: Record<string, string>;
copyText?: string;
onCopySuccess?: (e: React.MouseEvent<HTMLButtonElement>, text: string) => void;
onCopyError?: (e: unknown, text: string) => void;
}

function createInternalLink<T extends string>(
Expand All @@ -23,21 +28,47 @@ function createInternalLink<T extends string>(
noTruncate,
label,
queryStrings = {},
copyText,
onCopySuccess,
onCopyError,
...props
}: BaseInternalLinkProps & Record<T, string>) => {
const truncatedAddress = noTruncate ? id : formatter(id);
const queryString = new URLSearchParams(queryStrings).toString();
const queryStringPrefix = queryString ? `?${queryString}` : '';

async function handleCopyClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
if (!navigator.clipboard) {
return;
}
if (copyText) {
try {
await navigator.clipboard.writeText(copyText);
onCopySuccess?.(event, copyText);
} catch (error) {
console.error('Failed to copy:', error);
onCopyError?.(error, copyText);
}
}
}

return (
<Link
className="text-primary-30 dark:text-primary-80"
variant="mono"
to={`/${base}/${encodeURI(id)}${queryStringPrefix}`}
{...props}
>
{label || truncatedAddress}
</Link>
<div className="flex flex-row items-center gap-x-xxs">
<Link
className="text-primary-30 dark:text-primary-80"
variant="mono"
to={`/${base}/${encodeURI(id)}${queryStringPrefix}`}
{...props}
>
{label || truncatedAddress}
</Link>
{copyText && (
<ButtonUnstyled onClick={handleCopyClick}>
<Copy className="text-neutral-60 dark:text-neutral-40" />
</ButtonUnstyled>
)}
</div>
);
};
}
Expand Down
10 changes: 2 additions & 8 deletions apps/explorer/src/components/ui/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Placeholder,
} from '@iota/apps-ui-kit';
import { Copy, Warning } from '@iota/apps-ui-icons';
import { useCopyToClipboard, toast } from '@iota/core';
import { onCopySuccess } from '~/lib/utils';

type PageHeaderType = 'Transaction' | 'Checkpoint' | 'Address' | 'Object' | 'Package';

Expand All @@ -35,12 +35,6 @@ export function PageHeader({
after,
status,
}: PageHeaderProps): JSX.Element {
const copyToClipBoard = useCopyToClipboard(() => toast('Copied'));

const handleCopy = async () => {
await copyToClipBoard(title);
};

return (
<Panel>
<div className="flex w-full items-center p-md--rs">
Expand Down Expand Up @@ -83,7 +77,7 @@ export function PageHeader({
{title}
</span>
<Copy
onClick={handleCopy}
onClick={onCopySuccess}
className="shrink-0 cursor-pointer"
/>
</div>
Expand Down
19 changes: 7 additions & 12 deletions apps/explorer/src/components/validator/ValidatorMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { Badge, BadgeType, KeyValueInfo, Panel } from '@iota/apps-ui-kit';
import { type IotaValidatorSummary } from '@iota/iota-sdk/client';
import { ArrowTopRight } from '@iota/apps-ui-icons';
import { AddressLink } from '~/components/ui';
import { ImageIcon, ImageIconSize, toast } from '@iota/core';
import { ImageIcon, ImageIconSize } from '@iota/core';
import type { InactiveValidatorData } from '~/pages/validator/ValidatorDetails';
import { onCopySuccess } from '~/lib/utils';

type ValidatorMetaProps = {
validatorData: IotaValidatorSummary;
Expand All @@ -25,9 +26,6 @@ export function InactiveValidators({
}: {
validatorData: InactiveValidatorData;
}): JSX.Element {
function handleOnCopy() {
toast('Copied to clipboard');
}
return (
<div className="flex flex-col gap-y-md">
<Panel>
Expand Down Expand Up @@ -71,18 +69,19 @@ export function InactiveValidators({
keyText="Pool ID"
value={validatorStakingPoolId}
copyText={validatorStakingPoolId}
onCopySuccess={handleOnCopy}
onCopySuccess={onCopySuccess}
/>
<KeyValueInfo
keyText="Address"
value={<AddressLink address={validatorAddress} label={validatorAddress} />}
copyText={validatorAddress}
onCopySuccess={handleOnCopy}
onCopySuccess={onCopySuccess}
/>
<KeyValueInfo
keyText="Public Key"
value={validatorPublicKey}
copyText={validatorPublicKey}
onCopySuccess={onCopySuccess}
/>
</div>
</Panel>
Expand All @@ -97,10 +96,6 @@ export function ValidatorMeta({ validatorData }: ValidatorMetaProps): JSX.Elemen
const description = validatorData.description;
const projectUrl = validatorData.projectUrl;

function handleOnCopy() {
toast('Copied to clipboard');
}

return (
<div className="flex flex-col gap-y-md">
<Panel>
Expand Down Expand Up @@ -144,7 +139,7 @@ export function ValidatorMeta({ validatorData }: ValidatorMetaProps): JSX.Elemen
keyText="Pool ID"
value={validatorData.stakingPoolId}
copyText={validatorData.stakingPoolId}
onCopySuccess={handleOnCopy}
onCopySuccess={onCopySuccess}
/>
<KeyValueInfo
keyText="Address"
Expand All @@ -155,7 +150,7 @@ export function ValidatorMeta({ validatorData }: ValidatorMetaProps): JSX.Elemen
/>
}
copyText={validatorData.iotaAddress}
onCopySuccess={handleOnCopy}
onCopySuccess={onCopySuccess}
/>
<KeyValueInfo
keyText="Public Key"
Expand Down
8 changes: 1 addition & 7 deletions apps/explorer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,9 @@ body,
'node-map';
}

@screen md {
.home-page-grid-container-bottom {
@apply gap-10;
}
}

@screen lg {
.home-page-grid-container-bottom {
@apply grid grid-cols-2 gap-x-20 gap-y-10;
@apply grid grid-cols-2 gap-x-8 gap-y-8;
grid-template-areas:
'activity activity'
'packages validators'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TableCellBase, TableCellText } from '@iota/apps-ui-kit';
import type { Checkpoint } from '@iota/iota-sdk/client';
import type { ColumnDef } from '@tanstack/react-table';
import { CheckpointSequenceLink, CheckpointLink } from '~/components';
import { onCopySuccess } from '~/lib/utils';
import { getElapsedTime } from '~/pages/epochs/utils';

/**
Expand All @@ -23,6 +24,8 @@ export function generateCheckpointsTableColumns(): ColumnDef<Checkpoint>[] {
<CheckpointLink
digest={digest}
label={<TableCellText>{digest}</TableCellText>}
copyText={digest}
onCopySuccess={onCopySuccess}
/>
</TableCellBase>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import { AddressLink, TransactionLink } from '../../../components/ui';
import { formatAddress, formatDigest, NANOS_PER_IOTA } from '@iota/iota-sdk/utils';
import { getElapsedTime } from '~/pages/epochs/utils';
import { onCopySuccess } from '~/lib/utils';

/**
* Generate table columns renderers for the transactions data.
Expand All @@ -26,6 +27,8 @@ export function generateTransactionsTableColumns(): ColumnDef<IotaTransactionBlo
<TransactionLink
digest={digest}
label={<TableCellText>{formatDigest(digest)}</TableCellText>}
copyText={digest}
onCopySuccess={onCopySuccess}
/>
</TableCellBase>
);
Expand All @@ -41,6 +44,8 @@ export function generateTransactionsTableColumns(): ColumnDef<IotaTransactionBlo
<AddressLink
address={address}
label={<TableCellText>{formatAddress(address)}</TableCellText>}
copyText={address}
onCopySuccess={onCopySuccess}
/>
</TableCellBase>
);
Expand Down
1 change: 1 addition & 0 deletions apps/explorer/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './stringUtils';
export * from './iotaMoveTypeConverters';
export * from './getSupplyChangeAfterEpochEnd';
export * from './sanitizePendingValidators';
export * from './onCopySuccess';
8 changes: 8 additions & 0 deletions apps/explorer/src/lib/utils/onCopySuccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { toast } from '@iota/core';

export function onCopySuccess() {
toast.success('Copied to clipboard');
}
Loading
Loading