martmull
2024-05-03 15:03:06 +02:00
committed by GitHub
parent 1351a95754
commit 87994c26ff
51 changed files with 687 additions and 405 deletions

View File

@ -11,11 +11,11 @@ import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
import { Authorize } from '~/pages/auth/Authorize';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan';
import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { PasswordReset } from '~/pages/auth/PasswordReset';
import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx';
import { PaymentSuccess } from '~/pages/auth/PaymentSuccess';
import { SignInUp } from '~/pages/auth/SignInUp';
import { DefaultHomePage } from '~/pages/DefaultHomePage';
import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect';
@ -46,7 +46,7 @@ import { SettingsIntegrationNewDatabaseConnection } from '~/pages/settings/integ
import { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations';
import { SettingsIntegrationShowDatabaseConnection } from '~/pages/settings/integrations/SettingsIntegrationShowDatabaseConnection';
import { SettingsAppearance } from '~/pages/settings/SettingsAppearance';
import { SettingsBilling } from '~/pages/settings/SettingsBilling.tsx';
import { SettingsBilling } from '~/pages/settings/SettingsBilling';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';

View File

@ -1,14 +1,15 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconCheck, IconQuestionMark, IconX } from 'twenty-ui';
import { v4 } from 'uuid';
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { ParticipantChip } from '@/activities/components/ParticipantChip';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { ExpandableList } from '@/ui/display/expandable-list/ExpandableList';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompleted';
const StyledInlineCellBaseContainer = styled.div`
align-items: center;
@ -55,6 +56,9 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
font-size: ${({ theme }) => theme.font.size.sm};
width: ${({ width }) => width}px;
`;
const StyledDiv = styled.div`
max-width: 70%;
`;
export const CalendarEventParticipantsResponseStatusField = ({
responseStatus,
@ -64,6 +68,9 @@ export const CalendarEventParticipantsResponseStatusField = ({
participants: CalendarEventParticipant[];
}) => {
const theme = useTheme();
const isRightDrawerAnimationCompleted = useRecoilValue(
isRightDrawerAnimationCompletedState,
);
const Icon = {
Yes: <IconCheck stroke={theme.icon.stroke.sm} />,
@ -81,9 +88,9 @@ export const CalendarEventParticipantsResponseStatusField = ({
];
const participantsContainerRef = useRef<HTMLDivElement>(null);
const StyledChips = orderedParticipants.map((participant) => (
<ParticipantChip participant={participant} />
const [isHovered, setIsHovered] = useState(false);
const styledChips = orderedParticipants.map((participant, index) => (
<ParticipantChip key={index} participant={participant} />
));
return (
@ -96,12 +103,21 @@ export const CalendarEventParticipantsResponseStatusField = ({
<EllipsisDisplay>{responseStatus}</EllipsisDisplay>
</StyledLabelContainer>
</StyledLabelAndIconContainer>
<ExpandableList
listItems={StyledChips}
id={v4()}
rootRef={participantsContainerRef}
/>
<StyledDiv
ref={participantsContainerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isRightDrawerAnimationCompleted && (
<ExpandableList
isHovered={isHovered}
reference={participantsContainerRef.current || undefined}
forceDisplayHiddenCount
>
{styledChips}
</ExpandableList>
)}
</StyledDiv>
</StyledInlineCellBaseContainer>
</StyledPropertyBox>
);

View File

@ -14,6 +14,7 @@ import {
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompleted';
import { isDefined } from '~/utils/isDefined';
const StyledPropertyBox = styled(PropertyBox)`
@ -27,6 +28,10 @@ export const ActivityEditorFields = ({
}) => {
const { upsertActivity } = useUpsertActivity();
const isRightDrawerAnimationCompleted = useRecoilValue(
isRightDrawerAnimationCompletedState,
);
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.Activity,
});
@ -93,11 +98,16 @@ export const ActivityEditorFields = ({
</AssigneeFieldContextProvider>
</>
)}
{ActivityTargetsContextProvider && isDefined(activityFromCache) && (
<ActivityTargetsContextProvider>
<ActivityTargetsInlineCell activity={activityFromCache} />
</ActivityTargetsContextProvider>
)}
{ActivityTargetsContextProvider &&
isDefined(activityFromCache) &&
isRightDrawerAnimationCompleted && (
<ActivityTargetsContextProvider>
<ActivityTargetsInlineCell
activity={activityFromCache}
maxWidth={340}
/>
</ActivityTargetsContextProvider>
)}
</StyledPropertyBox>
);
};

View File

@ -1,91 +1,47 @@
import { useMemo } from 'react';
import styled from '@emotion/styled';
import { Chip, ChipVariant } from 'twenty-ui';
import { v4 } from 'uuid';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { RecordChip } from '@/object-record/components/RecordChip';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import {
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
const MAX_RECORD_CHIPS_DISPLAY = 2;
const StyledContainer = styled.div`
const StyledContainer = styled.div<{ maxWidth?: number }>`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
max-width: ${({ maxWidth }) => `${maxWidth}px` || 'none'};
`;
const StyledRelationsListContainer = styled(StyledContainer)`
padding: ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.spacing(1)};
background-color: ${({ theme }) => theme.background.secondary};
box-shadow: '0px 2px 4px ${({ theme }) =>
theme.boxShadow.light}, 2px 4px 16px ${({ theme }) =>
theme.boxShadow.strong}';
backdrop-filter: ${({ theme }) => theme.blur.strong};
`;
const showMoreRelationsHandler = (event?: React.MouseEvent<HTMLDivElement>) => {
event?.preventDefault();
event?.stopPropagation();
};
export const ActivityTargetChips = ({
activityTargetObjectRecords,
isHovered,
reference,
maxWidth,
}: {
activityTargetObjectRecords: ActivityTargetWithTargetRecord[];
}) => {
const dropdownId = useMemo(() => `multiple-relations-dropdown-${v4()}`, []);
maxWidth?: number;
} & ExpandableListProps) => {
return (
<StyledContainer>
{activityTargetObjectRecords
?.slice(0, MAX_RECORD_CHIPS_DISPLAY)
.map((activityTargetObjectRecord) => (
<RecordChip
key={activityTargetObjectRecord.targetObject.id}
record={activityTargetObjectRecord.targetObject}
objectNameSingular={
activityTargetObjectRecord.targetObjectMetadataItem.nameSingular
}
/>
))}
{activityTargetObjectRecords.length > MAX_RECORD_CHIPS_DISPLAY && (
<div onClick={showMoreRelationsHandler}>
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<Chip
label={`+${
activityTargetObjectRecords.length - MAX_RECORD_CHIPS_DISPLAY
}`}
variant={ChipVariant.Highlighted}
/>
}
dropdownOffset={{ x: 0, y: -20 }}
dropdownComponents={
<StyledRelationsListContainer>
{activityTargetObjectRecords.map(
(activityTargetObjectRecord) => (
<RecordChip
key={activityTargetObjectRecord.targetObject.id}
record={activityTargetObjectRecord.targetObject}
objectNameSingular={
activityTargetObjectRecord.targetObjectMetadataItem
.nameSingular
}
/>
),
)}
</StyledRelationsListContainer>
}
/>
</div>
)}
<StyledContainer maxWidth={maxWidth}>
<ExpandableList
isHovered={isHovered}
reference={reference}
forceDisplayHiddenCount
>
{activityTargetObjectRecords.map(
(activityTargetObjectRecord, index) => (
<RecordChip
key={index}
record={activityTargetObjectRecord.targetObject}
objectNameSingular={
activityTargetObjectRecord.targetObjectMetadataItem.nameSingular
}
/>
),
)}
</ExpandableList>
</StyledContainer>
);
};

View File

@ -39,6 +39,7 @@ const StyledChip = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
height: 20px;
box-sizing: border-box;
white-space: nowrap;
`;
type ParticipantChipVariant = 'default' | 'bold';

View File

@ -13,10 +13,16 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type ActivityTargetsInlineCellProps = {
activity: Activity;
showLabel?: boolean;
maxWidth?: number;
readonly?: boolean;
};
export const ActivityTargetsInlineCell = ({
activity,
showLabel = true,
maxWidth,
readonly,
}: ActivityTargetsInlineCellProps) => {
const { activityTargetObjectRecords } =
useActivityTargetObjectRecords(activity);
@ -37,8 +43,9 @@ export const ActivityTargetsInlineCell = ({
customEditHotkeyScope={{
scope: ActivityEditorHotkeyScope.ActivityTargets,
}}
IconLabel={IconArrowUpRight}
showLabel={true}
IconLabel={showLabel ? IconArrowUpRight : undefined}
showLabel={showLabel}
readonly={readonly}
editModeContent={
<ActivityTargetInlineCellEditMode
activity={activity}
@ -49,6 +56,7 @@ export const ActivityTargetsInlineCell = ({
displayModeContent={
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth}
/>
}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}

View File

@ -100,7 +100,7 @@ export const NoteCard = ({
<StyledCardContent>{body}</StyledCardContent>
</StyledCardDetailsContainer>
<StyledFooter>
<ActivityTargetsInlineCell activity={note} />
<ActivityTargetsInlineCell activity={note} readonly />
{note.comments && note.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />

View File

@ -6,9 +6,8 @@ import {
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Activity } from '@/activities/types/Activity';
import { getActivitySummary } from '@/activities/utils/getActivitySummary';
import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox';
@ -18,7 +17,7 @@ import { useCompleteTask } from '../hooks/useCompleteTask';
const StyledContainer = styled.div`
align-items: center;
align-self: stretch;
justify-content: space-between;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
cursor: pointer;
display: inline-flex;
@ -34,9 +33,6 @@ const StyledContainer = styled.div`
const StyledTaskBody = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-direction: row;
flex-grow: 1;
width: 1px;
`;
const StyledTaskTitle = styled.div<{
@ -64,9 +60,10 @@ const StyledDueDate = styled.div<{
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
`;
const StyledFieldsContainer = styled.div`
const StyledRightSideContainer = styled.div`
display: flex;
`;
@ -74,6 +71,14 @@ const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledLeftSideContainer = styled.div`
display: flex;
`;
const StyledCheckboxContainer = styled.div`
display: flex;
`;
export const TaskRow = ({ task }: { task: Activity }) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
@ -81,39 +86,42 @@ export const TaskRow = ({ task }: { task: Activity }) => {
const body = getActivitySummary(task.body);
const { completeTask } = useCompleteTask(task);
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(task);
return (
<StyledContainer
onClick={() => {
openActivityRightDrawer(task.id);
}}
>
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<Checkbox
checked={!!task.completedAt}
shape={CheckboxShape.Rounded}
onCheckedChange={completeTask}
/>
</div>
<StyledTaskTitle completed={task.completedAt !== null}>
{task.title || <StyledPlaceholder>Task title</StyledPlaceholder>}
</StyledTaskTitle>
<StyledTaskBody>
<OverflowingTextWithTooltip text={body} />
{task.comments && task.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
</StyledCommentIcon>
)}
</StyledTaskBody>
<StyledFieldsContainer>
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
<StyledLeftSideContainer>
<StyledCheckboxContainer
onClick={(e) => {
e.stopPropagation();
}}
>
<Checkbox
checked={!!task.completedAt}
shape={CheckboxShape.Rounded}
onCheckedChange={completeTask}
/>
</StyledCheckboxContainer>
<StyledTaskTitle completed={task.completedAt !== null}>
{task.title || <StyledPlaceholder>Task title</StyledPlaceholder>}
</StyledTaskTitle>
<StyledTaskBody>
<OverflowingTextWithTooltip text={body} />
{task.comments && task.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
</StyledCommentIcon>
)}
</StyledTaskBody>
</StyledLeftSideContainer>
<StyledRightSideContainer>
<ActivityTargetsInlineCell
activity={task}
showLabel={false}
maxWidth={200}
readonly
/>
<StyledDueDate
isPast={
@ -123,7 +131,7 @@ export const TaskRow = ({ task }: { task: Activity }) => {
<IconCalendar size={theme.icon.size.md} />
{task.dueAt && beautifyExactDate(task.dueAt)}
</StyledDueDate>
</StyledFieldsContainer>
</StyledRightSideContainer>
</StyledContainer>
);
};

View File

@ -70,7 +70,7 @@ export const EventsGroup = ({
{group.items.map((event, index) => (
<EventRow
mainObjectMetadataItem={mainObjectMetadataItem}
key={event.id}
key={index}
event={event}
isLastEvent={index === group.items.length - 1}
/>

View File

@ -17,7 +17,7 @@ import { captchaProviderState } from '@/client-config/states/captchaProviderStat
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { isDefined } from '~/utils/isDefined';

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql.tsx';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql';
export const useHandleResetPassword = () => {
const { enqueueSnackBar } = useSnackBar();

View File

@ -6,7 +6,7 @@ import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { previousUrlState } from '@/auth/states/previousUrlState';
import { billingState } from '@/client-config/states/billingState';
import { AppPath } from '@/types/AppPath';
import { WorkspaceMember } from '~/generated/graphql.tsx';
import { WorkspaceMember } from '~/generated/graphql';
export const useNavigateAfterSignInUp = () => {
const navigate = useNavigate();

View File

@ -1,6 +1,6 @@
import { useParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth.ts';
import { useAuth } from '@/auth/hooks/useAuth';
export const useSignInWithMicrosoft = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;

View File

@ -1,6 +1,6 @@
import { useParams } from 'react-router-dom';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql.tsx';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
export const useWorkspaceFromInviteHash = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { SubscriptionCardPrice } from '@/billing/components/SubscriptionCardPrice.tsx';
import { SubscriptionCardPrice } from '@/billing/components/SubscriptionCardPrice';
import { capitalize } from '~/utils/string/capitalize';
type SubscriptionCardProps = {

View File

@ -2,6 +2,7 @@ import { useContext } from 'react';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@ -13,7 +14,7 @@ import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDi
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay';
import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay';
import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx';
import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay';
import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay';
import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay';
import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay';
@ -36,7 +37,13 @@ import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText';
import { isFieldUuid } from '../types/guards/isFieldUuid';
export const FieldDisplay = () => {
type FieldDisplayProps = ExpandableListProps;
export const FieldDisplay = ({
isHovered,
reference,
fromTableCell,
}: FieldDisplayProps & { fromTableCell?: boolean }) => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
const isChipDisplay =
@ -74,7 +81,11 @@ export const FieldDisplay = () => {
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay />
<MultiSelectFieldDisplay
isHovered={isHovered}
reference={reference}
withDropDownBorder={fromTableCell}
/>
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? (

View File

@ -4,7 +4,7 @@ import { AddressFieldInput } from '@/object-record/record-field/meta-types/input
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';

View File

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { IconComponent, IconPencil } from 'twenty-ui';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -17,7 +18,8 @@ export const useGetButtonIcon = (): IconComponent | undefined => {
if (
isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition)
isFieldPhone(fieldDefinition) ||
isFieldMultiSelect(fieldDefinition)
) {
return IconPencil;
}

View File

@ -10,7 +10,7 @@ import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';

View File

@ -1,13 +1,16 @@
import styled from '@emotion/styled';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { Tag } from '@/ui/display/tag/components/Tag';
import {
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
const StyledTagContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const MultiSelectFieldDisplay = () => {
type MultiSelectFieldDisplayProps = ExpandableListProps;
export const MultiSelectFieldDisplay = ({
isHovered,
reference,
withDropDownBorder,
}: MultiSelectFieldDisplayProps) => {
const { fieldValues, fieldDefinition } = useMultiSelectField();
const selectedOptions = fieldValues
@ -17,7 +20,11 @@ export const MultiSelectFieldDisplay = () => {
: [];
return selectedOptions ? (
<StyledTagContainer>
<ExpandableList
isHovered={isHovered}
reference={reference}
withDropDownBorder={withDropDownBorder}
>
{selectedOptions.map((selectedOption, index) => (
<Tag
key={index}
@ -25,7 +32,7 @@ export const MultiSelectFieldDisplay = () => {
text={selectedOption.label}
/>
))}
</StyledTagContainer>
</ExpandableList>
) : (
<></>
);

View File

@ -9,7 +9,7 @@ import { assertFieldMetadata } from '@/object-record/record-field/types/guards/a
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated/graphql.tsx';
import { FieldMetadataType } from '~/generated/graphql';
export const useMultiSelectField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);

View File

@ -7,7 +7,7 @@ import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx';
import { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';

View File

@ -1,12 +1,12 @@
import { useContext, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Tooltip } from 'react-tooltip';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconComponent } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useInlineCell } from '../hooks/useInlineCell';
@ -48,11 +48,6 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
width: ${({ width }) => width}px;
`;
const StyledEditButtonContainer = styled(motion.div)`
align-items: center;
display: flex;
`;
const StyledClickableContainer = styled.div<{ readonly?: boolean }>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
@ -119,18 +114,24 @@ export const RecordInlineCellContainer = ({
disableHoverEffect,
}: RecordInlineCellContainerProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const reference = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isHoveredForDisplayMode, setIsHoveredForDisplayMode] = useState(false);
const [newDisplayModeContent, setNewDisplayModeContent] =
useState<React.ReactNode>(displayModeContent);
const handleContainerMouseEnter = () => {
if (!readonly) {
setIsHovered(true);
}
setIsHoveredForDisplayMode(true);
};
const handleContainerMouseLeave = () => {
if (!readonly) {
setIsHovered(false);
}
setIsHoveredForDisplayMode(false);
};
const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
@ -151,6 +152,17 @@ export const RecordInlineCellContainer = ({
const theme = useTheme();
const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`;
useEffect(() => {
if (React.isValidElement<ExpandableListProps>(displayModeContent)) {
setNewDisplayModeContent(
React.cloneElement(displayModeContent, {
isHovered: isHoveredForDisplayMode,
reference: reference.current || undefined,
}),
);
}
}, [isHoveredForDisplayMode, displayModeContent, reference]);
return (
<StyledInlineCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
@ -181,7 +193,7 @@ export const RecordInlineCellContainer = ({
)}
</StyledLabelAndIconContainer>
)}
<StyledValueContainer>
<StyledValueContainer ref={reference}>
{!readonly && isInlineCellInEditMode ? (
<RecordInlineCellEditMode>{editModeContent}</RecordInlineCellEditMode>
) : editModeContentOnly ? (
@ -208,18 +220,9 @@ export const RecordInlineCellContainer = ({
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{displayModeContent}
{newDisplayModeContent}
</RecordInlineCellDisplayMode>
{showEditButton && (
<StyledEditButtonContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<RecordInlineCellButton Icon={buttonIcon} />
</StyledEditButtonContainer>
)}
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
)}
</StyledValueContainer>

View File

@ -1,13 +1,23 @@
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
const StyledInlineCellButtonContainer = styled.div`
align-items: center;
display: flex;
`;
export const RecordInlineCellButton = ({ Icon }: { Icon: IconComponent }) => {
return (
<FloatingIconButton
size="small"
Icon={Icon}
data-testid="inline-cell-edit-mode-container"
/>
<AnimatedContainer>
<StyledInlineCellButtonContainer>
<FloatingIconButton
size="small"
Icon={Icon}
data-testid="inline-cell-edit-mode-container"
/>
</StyledInlineCellButtonContainer>
</AnimatedContainer>
);
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledAnimatedChipContainer = styled(motion.div)``;
export const AnimatedContainer = ({
children,
}: {
children: React.ReactNode;
}) => (
<StyledAnimatedChipContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
{children}
</StyledAnimatedChipContainer>
);

View File

@ -102,7 +102,7 @@ export const RecordTableCell = ({
isReadOnly={isReadOnly}
/>
}
nonEditModeContent={<FieldDisplay />}
nonEditModeContent={<FieldDisplay fromTableCell />}
/>
);
};

View File

@ -1,12 +1,11 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconComponent } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
const StyledEditButtonContainer = styled(motion.div)`
position: absolute;
right: 5px;
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(1)};
`;
type RecordTableCellButtonProps = {
@ -18,12 +17,9 @@ export const RecordTableCellButton = ({
onClick,
Icon,
}: RecordTableCellButtonProps) => (
<StyledEditButtonContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledEditButtonContainer>
<AnimatedContainer>
<StyledButtonContainer>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledButtonContainer>
</AnimatedContainer>
);

View File

@ -1,4 +1,10 @@
import React, { ReactElement, useContext, useState } from 'react';
import React, {
ReactElement,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconArrowUpRight } from 'twenty-ui';
@ -14,6 +20,7 @@ import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/rec
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
@ -33,7 +40,7 @@ const StyledTd = styled.td<{ isSelected: boolean; isInEditMode: boolean }>`
z-index: ${({ isInEditMode }) => (isInEditMode ? '4 !important' : '3')};
`;
const StyledCellBaseContainer = styled.div`
const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>`
align-items: center;
box-sizing: border-box;
cursor: pointer;
@ -41,6 +48,12 @@ const StyledCellBaseContainer = styled.div`
height: 32px;
position: relative;
user-select: none;
${(props) =>
props.softFocus
? `background: ${props.theme.background.transparent.secondary};
border-radius: ${props.theme.border.radius.sm};
outline: 1px solid ${props.theme.font.color.extraLight};`
: ''}
`;
export type RecordTableCellContainerProps = {
@ -63,6 +76,10 @@ export const RecordTableCellContainer = ({
editHotkeyScope,
}: RecordTableCellContainerProps) => {
const { columnIndex } = useContext(RecordTableCellContext);
const reference = useRef<HTMLTableCellElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [newNonEditModeContent, setNewNonEditModeContent] =
useState<ReactElement>(nonEditModeContent);
const { isReadOnly, isSelected, recordId } = useContext(
RecordTableRowContext,
);
@ -71,8 +88,6 @@ export const RecordTableCellContainer = ({
const cellPosition = useCurrentTableCellPosition();
const [isHovered, setIsHovered] = useState(false);
const { openTableCell } = useOpenRecordTableCellFromCell();
const tableScopeId = useAvailableScopeIdOrThrow(
@ -135,8 +150,20 @@ export const RecordTableCellContainer = ({
(!isFirstColumn || !isEmpty) &&
!isReadOnly;
useEffect(() => {
if (React.isValidElement<ExpandableListProps>(nonEditModeContent)) {
setNewNonEditModeContent(
React.cloneElement(nonEditModeContent, {
isHovered: showButton,
reference: reference.current || undefined,
}),
);
}
}, [nonEditModeContent, showButton, reference]);
return (
<StyledTd
ref={reference}
isSelected={isSelected}
onContextMenu={handleContextMenu}
isInEditMode={isCurrentTableCellInEditMode}
@ -147,34 +174,37 @@ export const RecordTableCellContainer = ({
<StyledCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
softFocus={hasSoftFocus}
>
{isCurrentTableCellInEditMode ? (
<RecordTableCellEditMode>{editModeContent}</RecordTableCellEditMode>
) : hasSoftFocus ? (
<>
<RecordTableCellSoftFocusMode>
{editModeContentOnly ? editModeContent : newNonEditModeContent}
</RecordTableCellSoftFocusMode>
{showButton && (
<RecordTableCellButton
onClick={handleButtonClick}
Icon={buttonIcon}
/>
)}
<RecordTableCellSoftFocusMode>
{editModeContentOnly ? editModeContent : nonEditModeContent}
</RecordTableCellSoftFocusMode>
</>
) : (
<>
{!isEmpty && (
<RecordTableCellDisplayMode>
{editModeContentOnly
? editModeContent
: newNonEditModeContent}
</RecordTableCellDisplayMode>
)}
{showButton && (
<RecordTableCellButton
onClick={handleButtonClick}
Icon={buttonIcon}
/>
)}
{!isEmpty && (
<RecordTableCellDisplayMode>
{editModeContentOnly ? editModeContent : nonEditModeContent}
</RecordTableCellDisplayMode>
)}
</>
)}
</StyledCellBaseContainer>

View File

@ -1,29 +0,0 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconComponent } from 'twenty-ui';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
const StyledEditButtonContainer = styled(motion.div)`
position: absolute;
right: 5px;
`;
type RecordTableCellEditButtonProps = {
onClick?: () => void;
Icon: IconComponent;
};
export const RecordTableCellEditButton = ({
onClick,
Icon,
}: RecordTableCellEditButtonProps) => (
<StyledEditButtonContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledEditButtonContainer>
);

View File

@ -101,7 +101,6 @@ export const RecordTableCellSoftFocusMode = ({
return (
<RecordTableCellDisplayContainer
onClick={handleClick}
softFocus
scrollRef={scrollRef}
>
{children}

View File

@ -1,104 +0,0 @@
import React, { ReactElement, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import { Chip, ChipVariant } from 'twenty-ui';
import { IntersectionObserverWrapper } from '@/ui/display/expandable-list/IntersectionObserverWrapper';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
white-space: nowrap;
overflow-x: hidden;
`;
const StyledExpendableCell = styled.div`
align-content: center;
align-items: center;
backdrop-filter: ${({ theme }) => theme.blur.strong};
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.light};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const ExpandableList = ({
listItems,
rootRef,
id,
}: {
listItems: ReactElement[];
rootRef: React.RefObject<HTMLElement>;
id: string;
}) => {
const [listItemsInView, setListItemsInView] = useState(new Set<number>());
const firstListItem = listItems[0];
const dropdownId = `expandable-list-dropdown-${id}`;
const containerRef = useRef<HTMLDivElement>(null);
const divRef = useRef<HTMLDivElement>(null);
return (
<StyledContainer ref={containerRef}>
{firstListItem}
{listItems.slice(1).map((listItem, index) => (
<React.Fragment key={index}>
<IntersectionObserverWrapper
set={setListItemsInView}
id={index}
rootRef={rootRef}
>
{listItem}
</IntersectionObserverWrapper>
{index === listItemsInView.size - 1 &&
listItems.length - listItemsInView.size - 1 !== 0 && (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<Chip
label={`+${listItems.length - listItemsInView.size - 1}`}
variant={ChipVariant.Highlighted}
/>
}
dropdownComponents={
<>
{divRef.current &&
createPortal(
<StyledExpendableCell>
{listItems}
</StyledExpendableCell>,
divRef.current as HTMLElement,
)}
</>
}
/>
)}
</React.Fragment>
))}
<div
ref={divRef}
style={{
position: 'absolute',
top: '100%',
zIndex: 1,
boxSizing: 'border-box',
}}
></div>
</StyledContainer>
);
};

View File

@ -1,47 +0,0 @@
import React from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
const StyledDiv = styled.div<{ inView?: boolean }>`
opacity: ${({ inView }) => (inView === undefined || inView ? 1 : 0)};
`;
export const IntersectionObserverWrapper = ({
set,
id,
rootRef,
children,
}: {
set: React.Dispatch<React.SetStateAction<Set<number>>>;
id: number;
rootRef?: React.RefObject<HTMLElement>;
children: React.ReactNode;
}) => {
const { ref, inView } = useInView({
threshold: 1,
onChange: (inView) => {
if (inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.add(id);
return newSet;
});
}
if (!inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
},
root: rootRef?.current,
rootMargin: '0px 0px -50px 0px',
});
return (
<StyledDiv ref={ref} inView={inView}>
{children}
</StyledDiv>
);
};

View File

@ -3,7 +3,7 @@ import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconInfoCircle } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button.tsx';
import { Button } from '@/ui/input/button/components/Button';
export type InfoAccent = 'blue' | 'danger';
export type InfoProps = {

View File

@ -1,7 +1,8 @@
import React from 'react';
import styled from '@emotion/styled';
import { Radio } from '@/ui/input/components/Radio.tsx';
import { Radio } from '@/ui/input/components/Radio';
const StyledSubscriptionCardContainer = styled.button`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};

View File

@ -0,0 +1,57 @@
import { Dispatch, ReactElement, SetStateAction } from 'react';
import styled from '@emotion/styled';
import { ChildrenProperty } from '@/ui/layout/expandable-list/components/ExpandableList';
const StyledChildContainer = styled.div<{
shrink?: number;
isVisible?: boolean;
displayHiddenCount?: boolean;
}>`
display: ${({ isVisible = true }) => (isVisible ? 'flex' : 'none')};
flex-shrink: ${({ shrink = 1 }) => shrink};
overflow: ${({ displayHiddenCount }) =>
displayHiddenCount ? 'hidden' : 'none'};
`;
const StyledChildrenContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
`;
export const ChildrenContainer = ({
children,
childrenProperties,
setChildrenWidths,
isFocusedMode,
}: {
children: ReactElement[];
childrenProperties: Record<number, ChildrenProperty>;
setChildrenWidths: Dispatch<SetStateAction<Record<number, number>>>;
isFocusedMode: boolean;
}) => {
return (
<StyledChildrenContainer>
{children.map((child, index) => {
return (
<StyledChildContainer
ref={(el) => {
if (!el || isFocusedMode) return;
setChildrenWidths((prevState) => {
prevState[index] = el.getBoundingClientRect().width;
return prevState;
});
}}
key={index}
displayHiddenCount={isFocusedMode}
isVisible={childrenProperties[index]?.isVisible}
shrink={childrenProperties[index]?.shrink}
>
{child}
</StyledChildContainer>
);
})}
</StyledChildrenContainer>
);
};

View File

@ -0,0 +1,147 @@
import { ReactElement, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { offset, useFloating } from '@floating-ui/react';
import { Chip, ChipVariant } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { ChildrenContainer } from '@/ui/layout/expandable-list/components/ChildrenContainer';
import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties';
import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth';
export const GAP_WIDTH = 4;
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: space-between;
width: 100%;
`;
const StyledRelationsListContainer = styled.div<{
withDropDownBorder?: boolean;
}>`
backdrop-filter: ${({ theme }) => theme.blur.strong};
background-color: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: '0px 2px 4px ${({ theme }) =>
theme.boxShadow.light}, 2px 4px 16px ${({ theme }) =>
theme.boxShadow.strong}';
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
outline: ${(props) =>
props.withDropDownBorder
? `1px solid ${props.theme.font.color.extraLight}`
: 'none'};
`;
export type ExpandableListProps = {
isHovered?: boolean;
reference?: HTMLDivElement;
forceDisplayHiddenCount?: boolean;
withDropDownBorder?: boolean;
};
export type ChildrenProperty = {
shrink: number;
isVisible: boolean;
};
export const ExpandableList = ({
children,
isHovered,
reference,
forceDisplayHiddenCount = false,
withDropDownBorder = false,
}: {
children: ReactElement[];
} & ExpandableListProps) => {
const [containerWidth, setContainerWidth] = useState(0);
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
const [childrenWidths, setChildrenWidths] = useState<Record<number, number>>(
{},
);
// Because Chip width depends on the number of hidden children which depends on the Chip width, we have a circular dependency
// To avoid it, we set the Chip width and make sure it can display its content (a number greater than 1)
const chipContentWidth = getChipContentWidth(children.length);
const chipContainerWidth = chipContentWidth + 2 * GAP_WIDTH; // Because Chip component has 4px padding-left and right
const availableWidth = containerWidth - (chipContainerWidth + GAP_WIDTH); // Because there is a 4px gap between ChildrenContainer and ChipContainer
const isFocusedMode =
(isHovered || forceDisplayHiddenCount) &&
Object.values(childrenWidths).length > 0;
const childrenProperties = getChildrenProperties(
isFocusedMode,
availableWidth,
childrenWidths,
);
const hiddenChildrenCount = Object.values(childrenProperties).filter(
(childProperties) => !childProperties.isVisible,
).length;
const displayHiddenCountChip = isFocusedMode && hiddenChildrenCount > 0;
const { refs, floatingStyles } = useFloating({
// @ts-expect-error placement accepts 'start' as value even if the typing does not permit it
placement: 'start',
middleware: [offset({ mainAxis: -1, crossAxis: -1 })],
elements: { reference },
});
const openDropdownMenu = (event: React.MouseEvent) => {
event.stopPropagation();
setIsDropdownMenuOpen(true);
};
useEffect(() => {
if (!isHovered) {
setIsDropdownMenuOpen(false);
}
}, [isHovered]);
return (
<StyledContainer
ref={(el) => {
if (!el) return;
setContainerWidth(el.getBoundingClientRect().width);
}}
>
<ChildrenContainer
childrenProperties={childrenProperties}
setChildrenWidths={setChildrenWidths}
isFocusedMode={isFocusedMode}
>
{children}
</ChildrenContainer>
{displayHiddenCountChip && (
<AnimatedContainer>
<Chip
label={`+${hiddenChildrenCount}`}
variant={ChipVariant.Highlighted}
onClick={openDropdownMenu}
/>
</AnimatedContainer>
)}
{isDropdownMenuOpen && (
<DropdownMenu
ref={refs.setFloating}
style={floatingStyles}
width={
reference
? Math.max(220, reference.getBoundingClientRect().width)
: undefined
}
>
<StyledRelationsListContainer withDropDownBorder={withDropDownBorder}>
{children}
</StyledRelationsListContainer>
</DropdownMenu>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,74 @@
import { ReactElement, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'packages/twenty-ui';
import { Tag } from '@/ui/display/tag/components/Tag';
import {
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
width: 300px;
`;
type RenderProps = ExpandableListProps & {
children: ReactElement[];
};
const Render = (args: RenderProps) => {
const [isHovered, setIsHovered] = useState(false);
const reference = useRef<HTMLDivElement>(null);
return (
<StyledContainer
ref={reference}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ExpandableList
reference={reference.current || undefined}
forceDisplayHiddenCount={args.forceDisplayHiddenCount}
withDropDownBorder={args.withDropDownBorder}
isHovered={isHovered}
>
{args.children}
</ExpandableList>
</StyledContainer>
);
};
const meta: Meta<typeof ExpandableList> = {
title: 'UI/Layout/ExpandableList/ExpandableList',
component: ExpandableList,
decorators: [ComponentDecorator],
args: {
children: [
<Tag key={1} text={'Option 1'} color={MAIN_COLOR_NAMES[0]} />,
<Tag key={2} text={'Option 2'} color={MAIN_COLOR_NAMES[1]} />,
<Tag key={3} text={'Option 3'} color={MAIN_COLOR_NAMES[2]} />,
<Tag key={4} text={'Option 4'} color={MAIN_COLOR_NAMES[3]} />,
<Tag key={5} text={'Option 5'} color={MAIN_COLOR_NAMES[4]} />,
<Tag key={6} text={'Option 6'} color={MAIN_COLOR_NAMES[5]} />,
<Tag key={7} text={'Option 7'} color={MAIN_COLOR_NAMES[6]} />,
],
isHovered: undefined,
reference: undefined,
forceDisplayHiddenCount: false,
withDropDownBorder: false,
},
argTypes: {
children: { control: false },
isHovered: { control: false },
reference: { control: false },
},
render: Render,
};
export default meta;
type Story = StoryObj<typeof ExpandableList>;
export const Default: Story = {};

View File

@ -0,0 +1,41 @@
import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties';
describe('getChildrenProperties', () => {
it('should return default value when isFocused is False', () => {
const isFocused = false;
const availableWidth = 100;
expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({});
expect(
getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }),
).toEqual({});
});
it('should return proper value when isFocused is True', () => {
const isFocused = true;
const availableWidth = 100;
expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({});
expect(
getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }),
).toEqual({
0: { shrink: 0, isVisible: true },
1: { shrink: 0, isVisible: true },
});
expect(
getChildrenProperties(isFocused, availableWidth, {
0: 40,
1: 40,
2: 40,
3: 40,
4: 40,
}),
).toEqual({
0: { shrink: 0, isVisible: true },
1: { shrink: 0, isVisible: true },
2: { shrink: 1, isVisible: true },
3: { shrink: 1, isVisible: false },
4: { shrink: 1, isVisible: false },
});
});
});

View File

@ -0,0 +1,13 @@
import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth';
describe('getChipContentWidth', () => {
it('should return proper value', () => {
expect(getChipContentWidth(0)).toEqual(0);
expect(getChipContentWidth(1)).toEqual(0);
expect(getChipContentWidth(2)).toEqual(17);
expect(getChipContentWidth(20)).toEqual(25);
expect(getChipContentWidth(200)).toEqual(33);
expect(getChipContentWidth(2000)).toEqual(41);
expect(getChipContentWidth(20000)).toEqual(49);
});
});

View File

@ -0,0 +1,30 @@
import {
ChildrenProperty,
GAP_WIDTH,
} from '@/ui/layout/expandable-list/components/ExpandableList';
export const getChildrenProperties = (
isFocusedMode: boolean,
availableWidth: number,
childrenWidths: Record<number, number>,
) => {
if (!isFocusedMode) {
return {};
}
let cumulatedChildrenWidth = 0;
const result: Record<number, ChildrenProperty> = {};
Object.values(childrenWidths).forEach((width, index) => {
// Because there is a 4px gap between children
const childWidth = width + GAP_WIDTH;
let shrink = 1;
let isVisible = true;
if (cumulatedChildrenWidth > availableWidth) {
isVisible = false;
} else if (cumulatedChildrenWidth + childWidth <= availableWidth) {
shrink = 0;
}
result[index] = { shrink, isVisible };
cumulatedChildrenWidth += childWidth;
});
return result;
};

View File

@ -0,0 +1,6 @@
export const getChipContentWidth = (numberOfChildren: number) => {
if (numberOfChildren <= 1) {
return 0;
}
return 17 + 8 * Math.trunc(Math.log10(numberOfChildren));
};

View File

@ -6,6 +6,7 @@ import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompleted';
import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/rightDrawerCloseEventsState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
@ -46,6 +47,9 @@ export const RightDrawer = () => {
);
const isRightDrawerExpanded = useRecoilValue(isRightDrawerExpandedState);
const [, setIsRightDrawerAnimationCompleted] = useRecoilState(
isRightDrawerAnimationCompletedState,
);
const rightDrawerPage = useRecoilValue(rightDrawerPageState);
@ -112,6 +116,9 @@ export const RightDrawer = () => {
x: '100%',
},
};
const handleAnimationComplete = () => {
setIsRightDrawerAnimationCompleted(isRightDrawerOpen);
};
return (
<StyledContainer
@ -121,6 +128,7 @@ export const RightDrawer = () => {
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationComplete={handleAnimationComplete}
>
<StyledRightDrawer ref={rightDrawerRef}>
{isRightDrawerOpen && <RightDrawerRouter />}

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isRightDrawerAnimationCompletedState = createState<boolean>({
key: 'isRightDrawerAnimationCompletedState',
defaultValue: false,
});

View File

@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react';
import { IconBrandGithub } from 'twenty-ui';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
import packageJson from '../../../../../../package.json';
import { GITHUB_LINK } from '../constants/GithubLink';

View File

@ -1,7 +1,7 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
const meta: Meta<typeof ActionLink> = {
title: 'UI/navigation/link/ActionLink',

View File

@ -3,21 +3,21 @@ import styled from '@emotion/styled';
import { isNonEmptyString, isNumber } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { SubTitle } from '@/auth/components/SubTitle.tsx';
import { Title } from '@/auth/components/Title.tsx';
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit.tsx';
import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
import { SubscriptionCard } from '@/billing/components/SubscriptionCard';
import { billingState } from '@/client-config/states/billingState';
import { AppPath } from '@/types/AppPath';
import { Loader } from '@/ui/feedback/loader/components/Loader.tsx';
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { CardPicker } from '@/ui/input/components/CardPicker';
import {
ProductPriceEntity,
useCheckoutSessionMutation,
useGetProductPricesQuery,
} from '~/generated/graphql.tsx';
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
const StyledChoosePlanContainer = styled.div`

View File

@ -17,7 +17,7 @@ import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetada
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Loader } from '@/ui/feedback/loader/components/Loader.tsx';
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';

View File

@ -4,12 +4,12 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck } from 'twenty-ui';
import { SubTitle } from '@/auth/components/SubTitle.tsx';
import { Title } from '@/auth/components/Title.tsx';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { AppPath } from '@/types/AppPath';
import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { RGBA } from '@/ui/theme/constants/Rgba';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn.tsx';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
const StyledCheckContainer = styled.div`
align-items: center;

View File

@ -9,7 +9,7 @@
"esModuleInterop": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,

View File

@ -1,4 +1,4 @@
import { ThemeType } from './src/modules/ui/theme/constants/ThemeLight';
import { ThemeType } from '@/ui/theme/constants/ThemeLight';
export { ThemeProvider } from '@emotion/react';
@ -21,7 +21,7 @@ export * from './src/modules/ui/input/button/components/FloatingButtonGroup'
export * from './src/modules/ui/input/button/components/FloatingIconButton'
export * from './src/modules/ui/input/button/components/FloatingIconButtonGroup'
export * from './src/modules/ui/input/button/components/LightButton'
export * from './src/modules/ui/navigation/link/components/ActionLink.tsx'
export * from './src/modules/ui/navigation/link/components/ActionLink'
export * from './src/modules/ui/input/button/components/LightIconButton'
export * from './src/modules/ui/input/button/components/MainButton'
export * from './src/modules/ui/input/button/components/RoundedIconButton'

View File

@ -51,6 +51,8 @@ const StyledContainer = styled.div<
cursor: ${({ clickable, disabled }) =>
clickable ? 'pointer' : disabled ? 'not-allowed' : 'inherit'};
display: inline-flex;
flex-direction: row-reverse;
justify-content: center;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(3)};
max-width: ${({ maxWidth }) =>