refactor: to useCopyToClipboard to catch errors - when user has disable copy clipboard permission in browser (#13330)

as discussed here
https://github.com/twentyhq/twenty/issues/13292#issuecomment-3092215050
with @prastoin

- [x] introduce optional message param to `copyToClipboard` method in
`useCopyToClipboard` and refactor it across app, as in case if user has
disallowed clipboard permission in BROWSER unprotected clipboard access
breaks without catch.

- [x] Email copied to clipboard

- [x] run lingui extract
This commit is contained in:
Nabhag Motivaras
2025-07-30 11:13:55 +02:00
committed by GitHub
parent 00f0c68478
commit 316f2ec38c
13 changed files with 61 additions and 172 deletions
@@ -8,12 +8,12 @@ export const useCopyToClipboard = () => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const copyToClipboard = async (valueAsString: string) => {
const copyToClipboard = async (valueAsString: string, message?: string) => {
try {
await navigator.clipboard.writeText(valueAsString);
enqueueSuccessSnackBar({
message: t`Copied to clipboard`,
message: message || t`Copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
@@ -3,7 +3,6 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropd
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
@@ -16,7 +15,6 @@ import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDeleteViewFromCurrentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
@@ -27,6 +25,7 @@ import {
IconTrash,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const ObjectOptionsDropdownMenuContent = () => {
const { t } = useLingui();
@@ -62,9 +61,6 @@ export const ObjectOptionsDropdownMenuContent = () => {
closeDropdown();
};
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const isDefaultView = currentView?.key === 'INDEX';
const selectableItemIdArray = [
@@ -80,6 +76,8 @@ export const ObjectOptionsDropdownMenuContent = () => {
OBJECT_OPTIONS_DROPDOWN_ID,
);
const { copyToClipboard } = useCopyToClipboard();
return (
<DropdownContent>
{currentView && (
@@ -167,28 +165,14 @@ export const ObjectOptionsDropdownMenuContent = () => {
itemId="Copy link to view"
onEnter={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
copyToClipboard(currentUrl, t`Link copied to clipboard`);
}}
>
<MenuItem
focused={selectedItemId === 'Copy link to view'}
onClick={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
copyToClipboard(currentUrl, t`Link copied to clipboard`);
}}
LeftIcon={IconCopy}
text={t`Copy link to view`}
@@ -1,10 +1,8 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { IconCopy } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
padding: 0 ${({ theme }) => theme.spacing(1)};
@@ -15,8 +13,7 @@ export type LightCopyIconButtonProps = {
};
export const LightCopyIconButton = ({ copyText }: LightCopyIconButtonProps) => {
const { enqueueSuccessSnackBar } = useSnackBar();
const theme = useTheme();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
return (
@@ -24,14 +21,7 @@ export const LightCopyIconButton = ({ copyText }: LightCopyIconButtonProps) => {
<LightIconButton
Icon={IconCopy}
onClick={() => {
enqueueSuccessSnackBar({
message: t`Text copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(copyText);
copyToClipboard(copyText, t`Text copied to clipboard`);
}}
aria-label="Copy to Clipboard"
/>
@@ -3,6 +3,7 @@ import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/inp
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -20,6 +21,7 @@ export const EmailsFieldInput = ({
}: EmailsFieldInputProps) => {
const { persistEmailsField, fieldValue } = useEmailsField();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
const emails = useMemo<string[]>(
() =>
@@ -59,7 +61,7 @@ export const EmailsFieldInput = ({
};
const handleCopy = (email: string) => {
copyToClipboard(email);
copyToClipboard(email, t`Email copied to clipboard`);
};
return (
@@ -1,9 +1,7 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type SettingsAdminConfigCopyableTextProps = {
text: string;
@@ -32,18 +30,10 @@ export const SettingsAdminConfigCopyableText = ({
multiline = false,
maxRows,
}: SettingsAdminConfigCopyableTextProps) => {
const { enqueueSuccessSnackBar } = useSnackBar();
const theme = useTheme();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const copyToClipboardDebounced = useDebouncedCallback((value: string) => {
navigator.clipboard.writeText(value);
enqueueSuccessSnackBar({
message: t`Copied to clipboard!`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
},
});
copyToClipboard(value);
}, 200);
return (
@@ -1,12 +1,9 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { useLingui } from '@lingui/react/macro';
import { IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
@@ -21,10 +18,8 @@ const StyledLinkContainer = styled.div`
type ApiKeyInputProps = { apiKey: string };
export const ApiKeyInput = ({ apiKey }: ApiKeyInputProps) => {
const theme = useTheme();
const { t } = useLingui();
const { enqueueSuccessSnackBar } = useSnackBar();
const { copyToClipboard } = useCopyToClipboard();
return (
<StyledContainer>
<StyledLinkContainer>
@@ -34,14 +29,7 @@ export const ApiKeyInput = ({ apiKey }: ApiKeyInputProps) => {
Icon={IconCopy}
title={t`Copy`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`API Key copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(apiKey);
copyToClipboard(apiKey, t`API Key copied to clipboard`);
}}
/>
</StyledContainer>
@@ -1,4 +1,3 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -7,6 +6,7 @@ import { useState } from 'react';
import { IconCopy, IconDatabase, IconSitemap } from 'twenty-ui/display';
import { Button, CodeEditor } from 'twenty-ui/input';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledWrapper = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
@@ -57,8 +57,8 @@ const StyledEditorContainer = styled.div`
export const SettingsIntegrationMCP = () => {
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const generateMcpContent = (pathSuffix: string, serverName: string) => {
return JSON.stringify(
@@ -125,14 +125,10 @@ export const SettingsIntegrationMCP = () => {
<Button
Icon={IconCopy}
onClick={() => {
enqueueSuccessSnackBar({
message: t`MCP Configuration copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(selectedOption.content);
copyToClipboard(
selectedOption.content,
t`MCP Configuration copied to clipboard`,
);
}}
type="button"
/>
@@ -1,8 +1,6 @@
/* @license Enterprise */
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form';
@@ -10,6 +8,7 @@ import { H2Title, IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledInputsContainer = styled.div`
display: flex;
@@ -36,8 +35,7 @@ const StyledButtonCopy = styled.div`
export const SettingsSSOOIDCForm = () => {
const { control } = useFormContext();
const { enqueueSuccessSnackBar } = useSnackBar();
const theme = useTheme();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
const authorizedUrl = window.location.origin;
@@ -66,14 +64,10 @@ export const SettingsSSOOIDCForm = () => {
Icon={IconCopy}
title={t`Copy`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`Authorized URL copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(authorizedUrl);
copyToClipboard(
authorizedUrl,
t`Authorized URL copied to clipboard`,
);
}}
type="button"
/>
@@ -94,14 +88,10 @@ export const SettingsSSOOIDCForm = () => {
Icon={IconCopy}
title={t`Copy`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`Redirect Url copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(redirectionUrl);
copyToClipboard(
redirectionUrl,
t`Redirect Url copied to clipboard`,
);
}}
type="button"
/>
@@ -20,6 +20,7 @@ import {
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledUploadFileContainer = styled.div`
align-items: center;
@@ -55,10 +56,11 @@ const StyledButtonCopy = styled.div`
`;
export const SettingsSSOSAMLForm = () => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { enqueueErrorSnackBar } = useSnackBar();
const theme = useTheme();
const { setValue, getValues, watch, trigger } = useFormContext();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files)) {
@@ -179,16 +181,9 @@ export const SettingsSSOSAMLForm = () => {
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
title={t`Copy`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`ACS Url copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(acsUrl);
copyToClipboard(acsUrl, t`ACS Url copied to clipboard`);
}}
type="button"
/>
@@ -209,14 +204,7 @@ export const SettingsSSOSAMLForm = () => {
Icon={IconCopy}
title={t`Copy`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`Entity ID copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(entityID);
copyToClipboard(entityID, t`Entity ID copied to clipboard`);
}}
type="button"
/>
@@ -1,7 +1,6 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
@@ -18,7 +17,6 @@ import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
@@ -26,6 +24,7 @@ import { isDefined } from 'twenty-shared/utils';
import { IconCopy, useIcons } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type WorkflowEditTriggerWebhookFormProps = {
trigger: WorkflowWebhookTrigger;
@@ -51,9 +50,8 @@ export const WorkflowEditTriggerWebhookForm = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerWebhookFormProps) => {
const { enqueueSuccessSnackBar } = useSnackBar();
const theme = useTheme();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [errorMessages, setErrorMessages] = useState<FormErrorMessages>({});
const [errorMessagesVisible, setErrorMessagesVisible] = useState(false);
const { getIcon } = useIcons();
@@ -74,17 +72,10 @@ export const WorkflowEditTriggerWebhookForm = ({
const webhookUrl = `${REACT_APP_SERVER_BASE_URL}/webhooks/workflows/${currentWorkspace?.id}/${workflowVisualizerWorkflowId}`;
const displayWebhookUrl = webhookUrl.replace(/^(https?:\/\/)?(www\.)?/, '');
const copyToClipboard = async () => {
await navigator.clipboard.writeText(webhookUrl);
enqueueSuccessSnackBar({
message: t`Copied to clipboard!`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
},
});
};
const copyToClipboardDebounced = useDebouncedCallback(copyToClipboard, 200);
const copyToClipboardDebounced = useDebouncedCallback(
() => copyToClipboard(webhookUrl),
200,
);
if (!isDefined(currentWorkspace)) {
return <></>;
@@ -1,11 +1,10 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { useLingui } from '@lingui/react/macro';
import { IconCopy, IconLink } from 'twenty-ui/display';
import { IconLink } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
align-items: center;
@@ -26,9 +25,8 @@ export const WorkspaceInviteLink = ({
inviteLink,
}: WorkspaceInviteLinkProps) => {
const { t } = useLingui();
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const { copyToClipboard } = useCopyToClipboard();
return (
<StyledContainer data-chromatic="ignore">
@@ -46,14 +44,7 @@ export const WorkspaceInviteLink = ({
accent="blue"
title={t`Copy link`}
onClick={() => {
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
navigator.clipboard.writeText(inviteLink);
copyToClipboard(inviteLink, t`Link copied to clipboard`);
}}
/>
</StyledContainer>
@@ -8,7 +8,6 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -26,6 +25,7 @@ import { IconCopy, SeparatorLineText } from 'twenty-ui/display';
import { LightButton, MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { z } from 'zod';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useCreateWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledAnimatedContainer = styled.div`
@@ -63,7 +63,7 @@ type FormInput = z.infer<typeof validationSchema>;
export const InviteTeam = () => {
const { t } = useLingui();
const theme = useTheme();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar } = useSnackBar();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
@@ -118,14 +118,7 @@ export const InviteTeam = () => {
const copyInviteLink = () => {
if (isDefined(currentWorkspace?.inviteHash)) {
const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`;
navigator.clipboard.writeText(inviteLink);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
copyToClipboard(inviteLink, t`Link copied to clipboard`);
}
};
@@ -1,16 +1,13 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
import { CustomDomainValidRecords } from '~/generated/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledTable = styled(Table)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -45,23 +42,12 @@ export const SettingsCustomDomainRecords = ({
}: {
records: CustomDomainValidRecords['records'];
}) => {
const { enqueueSuccessSnackBar } = useSnackBar();
const { copyToClipboard } = useCopyToClipboard();
const theme = useTheme();
const { t } = useLingui();
const copyToClipboard = (value: string) => {
navigator.clipboard.writeText(value);
enqueueSuccessSnackBar({
message: t`Copied to clipboard!`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
},
});
};
const copyToClipboardDebounced = useDebouncedCallback(copyToClipboard, 200);
const copyToClipboardDebounced = useDebouncedCallback(
(value: string) => copyToClipboard(value),
200,
);
return (
<StyledTable>