Skip to content

Commit

Permalink
feat: added ability to copy raw content from comment or goal description
Browse files Browse the repository at this point in the history
  • Loading branch information
DenisVorop committed Oct 10, 2023
1 parent 55f0da0 commit d8fdd7c
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 51 deletions.
3 changes: 2 additions & 1 deletion src/components/CommentView/CommentView.i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"Edit": "Edit",
"Delete": "Delete",
"Save": "Save"
"Save": "Save",
"Copy raw": "Copy raw"
}
3 changes: 2 additions & 1 deletion src/components/CommentView/CommentView.i18n/ru.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"Edit": "Изменить",
"Delete": "Удалить",
"Save": "Сохранить"
"Save": "Сохранить",
"Copy raw": "Скопировать"
}
113 changes: 73 additions & 40 deletions src/components/CommentView/CommentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { Reaction, State, User } from '@prisma/client';
import styled from 'styled-components';
import { brandColor, danger0, gapM, gapS, gray4, gray9, backgroundColor } from '@taskany/colors';
import { Card, CardComment, CardInfo, Dropdown, MenuItem, Text, UserPic, nullable, Button } from '@taskany/bricks';
import { IconBinOutline, IconEditOutline, IconMoreVerticalOutline, IconPinAltOutline } from '@taskany/icons';
import {
IconBinOutline,
IconClipboardOutline,
IconEditOutline,
IconMoreVerticalOutline,
IconPinAltOutline,
} from '@taskany/icons';

import { useReactionsResource } from '../../hooks/useReactionsResource';
import { useLocale } from '../../hooks/useLocale';
Expand All @@ -19,6 +25,9 @@ import { CommentForm } from '../CommentForm/CommentForm';
import { StateDot } from '../StateDot';
import { getUserName } from '../../utils/getUserName';
import { CardHeader } from '../CardHeader';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
import { useLatest } from '../../hooks/useLatest';
import { notifyPromise } from '../../utils/notifyPromise';

import { tr } from './CommentView.i18n';

Expand Down Expand Up @@ -118,6 +127,15 @@ const StyledMd = styled(Md)`
overflow-x: auto;
`;

const StyledMenuItem = styled(MenuItem)`
display: flex;
justify-content: start;
`;

const StyledIconClipboardOutline = styled(IconClipboardOutline)`
display: flex;
`;

export const CommentView: FC<CommentViewProps> = ({
id,
author,
Expand All @@ -140,6 +158,10 @@ export const CommentView: FC<CommentViewProps> = ({
const [commentDescription, setCommentDescription] = useState({ description });
const { reactionsProps } = useReactionsResource(reactions);
const [isRelative, onDateViewTypeChange] = useClickSwitch();
const [, copyValue] = useCopyToClipboard();
const descriptionRef = useLatest(commentDescription.description);

const canEdit = Boolean(onSubmit);

const onCommentDoubleClick = useCallback<React.MouseEventHandler>((e) => {
if (e.detail === 2) {
Expand Down Expand Up @@ -176,25 +198,38 @@ export const CommentView: FC<CommentViewProps> = ({
onCancel?.();
}, [description, onCancel]);

const dropdownItems = useMemo(
() => [
{
label: tr('Edit'),
icon: <IconEditOutline size="xxs" />,
onClick: () => {
setEditMode(true);
setFocused(true);
const dropdownItems = useMemo(() => {
const items = navigator?.clipboard
? [
{
label: tr('Copy raw'),
icon: <StyledIconClipboardOutline size="xxs" />,
onClick: () => notifyPromise(copyValue(descriptionRef.current), 'copy'),
},
]
: [];

if (canEdit) {
return [
{
label: tr('Edit'),
icon: <IconEditOutline size="xxs" />,
onClick: () => {
setEditMode(true);
setFocused(true);
},
},
},
{
label: tr('Delete'),
color: danger0,
icon: <IconBinOutline size="xxs" />,
onClick: onDelete,
},
],
[onDelete],
);
{
label: tr('Delete'),
color: danger0,
icon: <IconBinOutline size="xxs" />,
onClick: onDelete,
},
].concat(items);
}

return items;
}, [canEdit, copyValue, descriptionRef, onDelete]);

return (
<ActivityFeedItem id={pin ? '' : `comment-${id}`}>
Expand Down Expand Up @@ -227,7 +262,7 @@ export const CommentView: FC<CommentViewProps> = ({
}
/>
) : (
<StyledCommentCard highlight={highlight} onClick={onSubmit ? onCommentDoubleClick : undefined}>
<StyledCommentCard highlight={highlight} onClick={canEdit ? onCommentDoubleClick : undefined}>
<StyledCardInfo onClick={onDateViewTypeChange}>
{nullable(author, (data) => (
<CardHeader
Expand All @@ -240,26 +275,24 @@ export const CommentView: FC<CommentViewProps> = ({
{nullable(!reactionsProps.limited, () => (
<ReactionsDropdown view="icon" onClick={onReactionToggle} />
))}
{nullable(onSubmit, () => (
<Dropdown
items={dropdownItems}
renderTrigger={({ ref, onClick }) => (
<IconMoreVerticalOutline size="xs" ref={ref} onClick={onClick} />
)}
renderItem={({ item, cursor, index }) => (
<MenuItem
key={item.label}
ghost
color={item.color}
focused={cursor === index}
icon={item.icon}
onClick={item.onClick}
>
{item.label}
</MenuItem>
)}
/>
))}
<Dropdown
items={dropdownItems}
renderTrigger={({ ref, onClick }) => (
<IconMoreVerticalOutline size="xs" ref={ref} onClick={onClick} />
)}
renderItem={({ item, cursor, index }) => (
<StyledMenuItem
key={item.label}
ghost
color={item.color}
focused={cursor === index}
icon={item.icon}
onClick={item.onClick}
>
{item.label}
</StyledMenuItem>
)}
/>
</StyledCommentActions>
</StyledCardInfo>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"No description provided": "No description provided"
"No description provided": "No description provided",
"Copy raw": "Copy raw"
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"No description provided": "Описание отсутствует"
"No description provided": "Описание отсутствует",
"Copy raw": "Скопировать"
}
62 changes: 58 additions & 4 deletions src/components/GoalContentHeader/GoalContentHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,85 @@
import { Card, CardComment, CardInfo, Text } from '@taskany/bricks';
import { ComponentProps, FC } from 'react';
import { Card, CardComment, CardInfo, Dropdown, MenuItem, Text, nullable } from '@taskany/bricks';
import { ComponentProps, FC, useCallback, useMemo } from 'react';
import { gray7 } from '@taskany/colors';
import dynamic from 'next/dynamic';
import { IconClipboardOutline, IconMoreVerticalOutline } from '@taskany/icons';
import styled from 'styled-components';

import { CardHeader } from '../CardHeader';
import { RelativeTime } from '../RelativeTime/RelativeTime';
import { useClickSwitch } from '../../hooks/useClickSwitch';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
import { notifyPromise } from '../../utils/notifyPromise';

import { tr } from './GoalContentHeader.i18n';

const Md = dynamic(() => import('../Md'));

const StyledCardInfo = styled(CardInfo)`
display: flex;
justify-content: space-between;
align-items: center;
`;

const StyledMenuItem = styled(MenuItem)`
display: flex;
justify-content: start;
`;

const StyledIconClipboardOutline = styled(IconClipboardOutline)`
display: flex;
`;

interface GoalContentHeaderProps extends Pick<ComponentProps<typeof RelativeTime>, 'date' | 'kind'> {
name?: string | null;
description?: string;
}

export const GoalContentHeader: FC<GoalContentHeaderProps> = ({ name, description, date, kind }) => {
const [isRelative, onDateViewTypeChange] = useClickSwitch();
const [, copyValue] = useCopyToClipboard();

const onCopyDescription = useCallback(() => {
if (!description) return;

notifyPromise(copyValue(description), 'copy');
}, [copyValue, description]);

const dropdownItems = useMemo(
() =>
navigator?.clipboard
? [
{
label: tr('Copy raw'),
icon: <StyledIconClipboardOutline size="xxs" />,
onClick: onCopyDescription,
},
]
: [],
[onCopyDescription],
);

return (
<Card>
<CardInfo onClick={onDateViewTypeChange}>
<StyledCardInfo onClick={onDateViewTypeChange}>
<CardHeader
name={name}
timeAgo={<RelativeTime kind={kind} isRelativeTime={isRelative} date={date} />}
/>
</CardInfo>
{nullable(description, () => (
<Dropdown
items={dropdownItems}
renderTrigger={({ ref, onClick }) => (
<IconMoreVerticalOutline size="xs" ref={ref} onClick={onClick} />
)}
renderItem={({ item }) => (
<StyledMenuItem key={item.label} ghost icon={item.icon} onClick={item.onClick}>
{item.label}
</StyledMenuItem>
)}
/>
))}
</StyledCardInfo>

<CardComment>
{description ? (
Expand Down
8 changes: 7 additions & 1 deletion src/components/NotificationsHub/NotificationHub.map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ type Namespaces =
| 'userInvite'
| 'sentFeedback'
| 'clearLSCache'
| 'error';
| 'error'
| 'copy';

export type { Namespaces as NotificationNamespaces };

Expand Down Expand Up @@ -139,6 +140,11 @@ export const getNotificicationKeyMap = (key: keyof NotificationMap) => {
error: {
onError: 'Something went wrong 😿',
},
copy: {
onSuccess: 'Successfully copied',
onPending: 'Copying...',
onError: 'An error occurred while copying',
},
};

return notification[key];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@
"Feedback sent 🎉": "Feedback sent 🎉",
"Feedback is formed": "Feedback is formed",
"We are deleting project": "We are deleting project",
"Project removed": "Project removed"
"Project removed": "Project removed",
"Successfully copied": "Successfully copied",
"An error occurred while copying": "An error occurred while copying",
"Copying...": "Copying..."
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@
"Feedback sent 🎉": "Отзыв отправлен 🎉",
"Feedback is formed": "Отзыв формируется",
"We are deleting project": "Погоди немного, проект удаляется...",
"Project removed": "Проект удален"
"Project removed": "Проект удален",
"Successfully copied": "Успешно скопировано",
"An error occurred while copying": "Произошла ошибка при копировании",
"Copying...": "Копируем..."
}
41 changes: 41 additions & 0 deletions src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react';
import * as Sentry from '@sentry/nextjs';

type CopiedValue = string | null;
type CopyFn = (text: string) => Promise<boolean>;

export const useCopyToClipboard = (ms = 500): [CopiedValue, CopyFn, boolean] => {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const [success, setSuccess] = useState(false);

useEffect(() => {
let timer: NodeJS.Timer;
if (success) {
timer = setTimeout(() => {
setSuccess(false);
}, ms);
}

return () => {
window.clearTimeout(timer);
};
}, [success, ms]);

const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
setSuccess(true);
return true;
} catch (error) {
setCopiedText(null);
setSuccess(false);

const copyFailedError = new Error('Copy failed');
Sentry.captureException(copyFailedError);
return Promise.reject(copyFailedError);
}
}, []);

return [copiedText, copy, success];
};

0 comments on commit d8fdd7c

Please sign in to comment.