Fix CSV import select field matching (#11361)

This PR fixes a bug that prevented to do the matching of an imported CSV
file that contains a SELECT type column.

Fixes https://github.com/twentyhq/twenty/issues/11220

## Stacking context improvement 

During the development it was clear that we lacked a reliable way to
understand our own z indices for components like modal, portaled
dropdown, overlay background, etc.

So in this PR we introduce a new enum RootStackingContextZIndices, this
enum allows to keep track of our root stacking context component
z-index, and because it is an enum, it prevents any conflict.

See
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context
for reference.

## Component cleaning

Components have been reorganized in a SubMatchingSelectRow component

The Dropdown component has been used to replace the SelectInput
component which doesn't fit this use case because we are not in a cell,
we just need a simple standalone dropdown, though it would be
interesting to extract the UI part of the SelectInput, to share it here,
the benefit is not obvious since we already have good shared components
like Tag and Dropdown to implement this specific use case.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-04-03 16:28:15 +02:00
committed by GitHub
parent 752eb93836
commit 5e43839efb
20 changed files with 446 additions and 135 deletions

View File

@ -14,9 +14,9 @@ import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/Spreadshee
import { SelectInput } from '@/ui/input/components/SelectInput';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useEffect, useState } from 'react';
import { Tag, TagColor } from 'twenty-ui/components';
import { IconChevronDown } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { Tag, TagColor } from 'twenty-ui/components';
const StyledContainer = styled.div`
align-items: center;

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
const StyledControlContainer = styled.div<{ cursor: string }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: ${({ cursor }) => cursor};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SubMatchingSelectControlContainer = StyledControlContainer;

View File

@ -0,0 +1,54 @@
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SubMatchingSelectControlContainer } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectControlContainer';
import {
SpreadsheetMatchedSelectColumn,
SpreadsheetMatchedSelectOptionsColumn,
} from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Tag, TagColor } from 'twenty-ui/components';
import { IconChevronDown } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
const StyledIconChevronDown = styled(IconChevronDown)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export type SubMatchingSelectDropdownButtonProps<T> = {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>;
column:
| SpreadsheetMatchedSelectColumn<T>
| SpreadsheetMatchedSelectOptionsColumn<T>;
placeholder: string;
};
export const SubMatchingSelectDropdownButton = <T extends string>({
option,
column,
placeholder,
}: SubMatchingSelectDropdownButtonProps<T>) => {
const { openDropdown } = useDropdown();
const { fields } = useSpreadsheetImportInternal<T>();
const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value);
const theme = useTheme();
return (
<SubMatchingSelectControlContainer
cursor="pointer"
onClick={() => openDropdown()}
id="control"
>
<Tag
text={value?.label ?? placeholder}
color={value?.color as TagColor}
/>
<StyledIconChevronDown size={theme.icon.size.md} />
</SubMatchingSelectControlContainer>
);
};

View File

@ -0,0 +1,72 @@
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 { useMemo, useRef, useState } from 'react';
import { TagColor } from 'twenty-ui/components';
import { SelectOption } from 'twenty-ui/input';
import { MenuItemSelectTag } from 'twenty-ui/navigation';
interface SubMatchingSelectInputProps {
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
defaultOption?: SelectOption;
}
export const SubMatchingSelectInput = ({
onOptionSelected,
options,
defaultOption,
}: SubMatchingSelectInputProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchFilter, setSearchFilter] = useState('');
const [selectedOption, setSelectedOption] = useState<
SelectOption | undefined
>(defaultOption);
const optionsToSelect = useMemo(
() =>
options.filter((option) => {
return (
option.value !== selectedOption?.value &&
option.label.toLowerCase().includes(searchFilter.toLowerCase())
);
}) || [],
[options, searchFilter, selectedOption?.value],
);
const optionsInDropDown = useMemo(
() =>
selectedOption ? [selectedOption, ...optionsToSelect] : optionsToSelect,
[optionsToSelect, selectedOption],
);
const handleOptionChange = (option: SelectOption) => {
setSelectedOption(option);
onOptionSelected(option);
};
return (
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => (
<MenuItemSelectTag
key={option.value}
selected={selectedOption?.value === option.value}
text={option.label}
color={(option.color as TagColor) ?? 'transparent'}
onClick={() => handleOptionChange(option)}
LeftIcon={option.Icon}
/>
))}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -0,0 +1,46 @@
import { SubMatchingSelectRowLeftSelect } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectRowLeftSelect';
import { SubMatchingSelectRowRightDropdown } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectRowRightDropdown';
import {
SpreadsheetMatchedSelectColumn,
SpreadsheetMatchedSelectOptionsColumn,
} from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
import styled from '@emotion/styled';
const StyledRowContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: space-between;
padding-bottom: ${({ theme }) => theme.spacing(1)};
`;
interface SubMatchingSelectRowProps<T> {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>;
column:
| SpreadsheetMatchedSelectColumn<T>
| SpreadsheetMatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
placeholder: string;
selectedOption?:
| SpreadsheetMatchedOptions<T>
| Partial<SpreadsheetMatchedOptions<T>>;
}
export const SubMatchingSelectRow = <T extends string>({
option,
column,
onSubChange,
placeholder,
}: SubMatchingSelectRowProps<T>) => {
return (
<StyledRowContainer>
<SubMatchingSelectRowLeftSelect option={option} />
<SubMatchingSelectRowRightDropdown
column={column}
onSubChange={onSubChange}
option={option}
placeholder={placeholder}
/>
</StyledRowContainer>
);
};

View File

@ -0,0 +1,44 @@
import { SubMatchingSelectControlContainer } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectControlContainer';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from 'twenty-ui/display';
const StyledIconChevronDown = styled(IconChevronDown)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.regular};
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export type SubMatchingSelectRowLeftSelectProps<T> = {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>;
};
export const SubMatchingSelectRowLeftSelect = <T extends string>({
option,
}: SubMatchingSelectRowLeftSelectProps<T>) => {
const theme = useTheme();
return (
<SubMatchingSelectControlContainer cursor="default">
<StyledControlLabel>
<StyledLabel>{option.entry}</StyledLabel>
</StyledControlLabel>
<StyledIconChevronDown
size={theme.font.size.md}
color={theme.font.color.tertiary}
/>
</SubMatchingSelectControlContainer>
);
};

View File

@ -0,0 +1,75 @@
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
import { SubMatchingSelectDropdownButton } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectDropdownButton';
import { SubMatchingSelectInput } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectInput';
import {
SpreadsheetMatchedSelectColumn,
SpreadsheetMatchedSelectOptionsColumn,
} from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import styled from '@emotion/styled';
import { SelectOption } from 'twenty-ui/input';
const StyledDropdownContainer = styled.div`
width: 100%;
`;
interface SubMatchingSelectRowRightDropdownProps<T> {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>;
column:
| SpreadsheetMatchedSelectColumn<T>
| SpreadsheetMatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
placeholder: string;
selectedOption?:
| SpreadsheetMatchedOptions<T>
| Partial<SpreadsheetMatchedOptions<T>>;
}
export const SubMatchingSelectRowRightDropdown = <T extends string>({
option,
column,
onSubChange,
placeholder,
}: SubMatchingSelectRowRightDropdownProps<T>) => {
const dropdownId = `sub-matching-select-dropdown-${option.entry}`;
const { closeDropdown } = useDropdown(dropdownId);
const { fields } = useSpreadsheetImportInternal<T>();
const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value);
const handleSelect = (selectedOption: SelectOption) => {
onSubChange(selectedOption.value as T, column.index, option.entry ?? '');
closeDropdown();
};
return (
<StyledDropdownContainer>
<Dropdown
dropdownHotkeyScope={{ scope: dropdownId }}
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<SubMatchingSelectDropdownButton
column={column}
option={option}
placeholder={placeholder}
/>
}
dropdownComponents={
<SubMatchingSelectInput
defaultOption={value}
options={options}
onOptionSelected={handleSelect}
/>
}
/>
</StyledDropdownContainer>
);
};

View File

@ -2,8 +2,8 @@ import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { useLingui } from '@lingui/react/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { IconForbid } from 'twenty-ui/display';
@ -75,7 +75,7 @@ export const TemplateColumn = <T extends string>({
value={isIgnored ? ignoreValue : selectValue}
onChange={(value) => onChange(value?.value as T, column.index)}
options={selectOptions}
name={column.header}
columnIndex={column.index.toString()}
/>
</StyledContainer>
);

View File

@ -1,5 +1,5 @@
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SubMatchingSelect } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect';
import { SubMatchingSelectRow } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectRow';
import { UnmatchColumnBanner } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumnBanner';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
@ -71,7 +71,7 @@ export const UnmatchColumn = <T extends string>({
>
<StyledContentWrapper>
{column.matchedOptions.map((option) => (
<SubMatchingSelect
<SubMatchingSelectRow
option={option}
column={column}
onSubChange={onSubChange}

View File

@ -1,5 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { Banner, IconChevronDown, IconInfoCircle } from 'twenty-ui/display';
const StyledBanner = styled(Banner)`
@ -27,6 +28,16 @@ const StyledTransitionedIconChevronDown = styled(IconChevronDown)<{
cursor: pointer;
`;
const StyledClickableContainer = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
width: 100%;
justify-content: center;
align-items: center;
`;
export const UnmatchColumnBanner = ({
message,
isExpanded,
@ -37,16 +48,20 @@ export const UnmatchColumnBanner = ({
buttonOnClick?: () => void;
}) => {
const theme = useTheme();
return (
<StyledBanner>
<IconInfoCircle color={theme.color.blue} size={theme.icon.size.md} />
<StyledText>{message}</StyledText>
{buttonOnClick && (
<StyledTransitionedIconChevronDown
isExpanded={isExpanded}
onClick={buttonOnClick}
size={theme.icon.size.md}
/>
{isDefined(buttonOnClick) ? (
<StyledClickableContainer onClick={buttonOnClick}>
<StyledText>{message}</StyledText>
<StyledTransitionedIconChevronDown
isExpanded={isExpanded}
size={theme.icon.size.md}
/>
</StyledClickableContainer>
) : (
<StyledText>{message}</StyledText>
)}
</StyledBanner>
);

View File

@ -3,13 +3,13 @@ import styled from '@emotion/styled';
import { Column, useRowSelection } from 'react-data-grid';
import { createPortal } from 'react-dom';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import {
ImportedStructuredRow,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
import { TextInput } from '@/ui/input/components/TextInput';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { isDefined } from 'twenty-shared/utils';
import { ImportedStructuredRowMetadata } from '../types';
import { AppTooltip } from 'twenty-ui/display';
@ -148,6 +148,7 @@ export const generateColumns = <T extends string>(
onRowChange({ ...row, [columnKey]: value?.value }, true);
}}
options={column.fieldType.options}
columnIndex={column.key}
/>
);
break;