Feature 15797 add secondary action button (#16582)

This is a fix for #15797 

This pull request is to replace PR #16307 and to extend #16265 

Just to repeat, this PR does the following -> 
**Table Cell Button and Edit Button Improvements**
- Enhanced RecordTableCellButton to support a secondary action and icon,
enabling both the primary and secondary actions based on the selected
action mode.
- Updated RecordTableCellEditButton to determine the action mode for
actionable fields, and provide both copy and navigate actions as
primary/secondary buttons, with appropriate feedback.

## When primary function is to copy
<img width="1817" height="939" alt="image"
src="https://github.com/user-attachments/assets/7ec6c6aa-80d8-402b-a210-519163d39ef6"
/>


## When primary function is to open link
<img width="1784" height="942" alt="image"
src="https://github.com/user-attachments/assets/dfe0fcf1-ba72-4083-a5f9-7165a03db3df"
/>

Hey @etiennejouan, please have a look!
Thank you

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Lakshay Manchanda
2025-12-17 00:13:00 -08:00
committed by GitHub
parent 0145920d7e
commit d43e21b695
5 changed files with 139 additions and 35 deletions
@@ -1,29 +0,0 @@
import styled from '@emotion/styled';
import { AnimatedContainer } from 'twenty-ui/utilities';
import { FloatingIconButton } from 'twenty-ui/input';
import { type IconComponent } from 'twenty-ui/display';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(1)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
position: relative;
right: 7px;
}
`;
type RecordTableCellButtonProps = {
onClick?: () => void;
Icon: IconComponent;
};
export const RecordTableCellButton = ({
onClick,
Icon,
}: RecordTableCellButtonProps) => (
<AnimatedContainer>
<StyledButtonContainer>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledButtonContainer>
</AnimatedContainer>
);
@@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/display';
import { LightIconButtonGroup } from 'twenty-ui/input';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { AnimatedContainer } from 'twenty-ui/utilities';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(1)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
position: relative;
right: 7px;
}
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid ${({ theme }) => theme.border.color.strong};
`;
type RecordTableCellButtonsProps = {
onClick?: () => void;
Icon: IconComponent;
}[];
export const RecordTableCellButtons = ({
buttons,
}: {
buttons: RecordTableCellButtonsProps;
}) => {
return (
<AnimatedContainer>
<StyledButtonContainer>
<LightIconButtonGroup size="small" iconButtons={buttons} />
</StyledButtonContainer>
</AnimatedContainer>
);
};
@@ -1,7 +1,9 @@
import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon';
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableCellButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButton';
import { RecordTableCellButtons } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButtons';
import { useGetSecondaryRecordTableCellButton } from '@/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -9,20 +11,20 @@ import { IconArrowUpRight, IconPencil } from 'twenty-ui/display';
export const RecordTableCellEditButton = () => {
const { cellPosition } = useContext(RecordTableCellContext);
const { openTableCell } = useOpenRecordTableCellFromCell();
const isFieldInputOnly = useIsFieldInputOnly();
const isFirstColumn = cellPosition.column === 0;
const customButtonIcon = useGetButtonIcon();
const buttonIcon = isFirstColumn
const secondaryButton = useGetSecondaryRecordTableCellButton();
const mainButtonIcon = isFirstColumn
? IconArrowUpRight
: isDefined(customButtonIcon)
? customButtonIcon
: IconPencil;
const handleButtonClick = () => {
const handleMainButtonClick = () => {
if (!isFieldInputOnly && isFirstColumn) {
openTableCell(undefined, false, true);
} else {
@@ -31,6 +33,14 @@ export const RecordTableCellEditButton = () => {
};
return (
<RecordTableCellButton onClick={handleButtonClick} Icon={buttonIcon} />
<RecordTableCellButtons
buttons={[
...secondaryButton,
{
onClick: handleMainButtonClick,
Icon: mainButtonIcon,
},
]}
/>
);
};
@@ -0,0 +1,88 @@
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import {
type FieldEmailsValue,
type FieldLinksValue,
type FieldPhonesValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
import { getAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const useGetSecondaryRecordTableCellButton = () => {
const { fieldDefinition, recordId } = useContext(FieldContext);
const { copyToClipboard } = useCopyToClipboard();
const fieldValue = useRecordFieldValue<
FieldPhonesValue | FieldEmailsValue | FieldLinksValue | undefined
>(recordId, fieldDefinition.metadata.fieldName, fieldDefinition);
if (
(!isFieldPhones(fieldDefinition) &&
!isFieldLinks(fieldDefinition) &&
!isFieldEmails(fieldDefinition)) ||
!isDefined(fieldValue)
) {
return [];
}
const secondaryActionOnClick =
fieldDefinition.metadata.settings?.clickAction ===
FieldMetadataSettingsOnClickAction.OPEN_LINK
? FieldMetadataSettingsOnClickAction.COPY
: FieldMetadataSettingsOnClickAction.OPEN_LINK;
let openLinkOnClick: () => void = () => {};
let copyOnClick: () => void = () => {};
if (isFieldPhones(fieldDefinition)) {
const { primaryPhoneCallingCode = '', primaryPhoneNumber = '' } =
fieldValue as FieldPhonesValue;
const phoneNumber = `${primaryPhoneCallingCode}${primaryPhoneNumber}`;
openLinkOnClick = () => {
window.open(`tel:${phoneNumber}`, '_blank');
};
copyOnClick = () => {
copyToClipboard(phoneNumber, t`Phone number copied to clipboard`);
};
}
if (isFieldEmails(fieldDefinition)) {
const email = (fieldValue as FieldEmailsValue).primaryEmail ?? '';
openLinkOnClick = () => {
window.open(`mailto:${email}`, '_blank');
};
copyOnClick = () => {
copyToClipboard(email, t`Email copied to clipboard`);
};
}
if (isFieldLinks(fieldDefinition)) {
const url = (fieldValue as FieldLinksValue).primaryLinkUrl ?? '';
openLinkOnClick = () => {
window.open(getAbsoluteUrl(url), '_blank');
};
copyOnClick = () => {
copyToClipboard(url, t`Link copied to clipboard`);
};
}
return [
{
onClick:
secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
? openLinkOnClick
: copyOnClick,
Icon:
secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
? IconArrowUpRight
: IconCopy,
},
];
};
@@ -30,6 +30,7 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewWidget> = {
MemoryRouterDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
I18nFrontDecorator,
SnackBarDecorator,
],
args: {