[Issue-5772] Add sort feature on settings tables (#5787)
## Proposed Changes - Introduce a new custom hook - useTableSort to sort table content - Add test cases for the new custom hook - Integrate useTableSort hook on to the table in settings object and settings object field pages ## Related Issue https://github.com/twentyhq/twenty/issues/5772 ## Evidence https://github.com/twentyhq/twenty/assets/87609792/8be456ce-2fa5-44ec-8bbd-70fb6c8fdb30 ## Evidence after addressing review comments https://github.com/twentyhq/twenty/assets/87609792/c267e3da-72f9-4c0e-8c94-a38122d6395e ## Further comments Apologies for the large PR. Looking forward for the review --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
committed by
GitHub
parent
0f75e14ab2
commit
59e14fabb4
@ -1,9 +1,9 @@
|
||||
import { Reference, StoreObject } from '@apollo/client';
|
||||
import { ReadFieldFunction } from '@apollo/client/cache/core/types/common';
|
||||
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
|
||||
import { getOrderByFieldForObjectMetadataItem } from '@/object-metadata/utils/getObjectOrderByField';
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
|
||||
export const useGetObjectOrderByField = ({
|
||||
objectNameSingular,
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const getOrderByFieldForObjectMetadataItem = (
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getOrderByForFieldMetadataType = (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
|
||||
export type RecordGqlOperationOrderBy = Array<{
|
||||
[fieldName: string]: OrderBy | { [subFieldName: string]: OrderBy };
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { useQuery } from '@apollo/client';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
|
||||
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
|
||||
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
|
||||
import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
|
||||
|
||||
export const useCombinedGetTotalCount = ({
|
||||
objectMetadataItems,
|
||||
skip = false,
|
||||
}: {
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
skip?: boolean;
|
||||
}) => {
|
||||
const operationSignatures = objectMetadataItems.map(
|
||||
(objectMetadataItem) =>
|
||||
({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
variables: {},
|
||||
fields: {
|
||||
id: true,
|
||||
},
|
||||
}) satisfies RecordGqlOperationSignature,
|
||||
);
|
||||
|
||||
const findManyQuery = useGenerateCombinedFindManyRecordsQuery({
|
||||
operationSignatures,
|
||||
});
|
||||
|
||||
const { data } = useQuery<MultiObjectRecordQueryResult>(
|
||||
findManyQuery ?? EMPTY_QUERY,
|
||||
{
|
||||
skip,
|
||||
},
|
||||
);
|
||||
|
||||
const totalCountByObjectMetadataItemNamePlural = Object.fromEntries(
|
||||
Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [
|
||||
namePlural,
|
||||
objectRecordConnection.totalCount,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
totalCountByObjectMetadataItemNamePlural,
|
||||
};
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
|
||||
import { hasPositionField } from '@/object-metadata/utils/hasPositionField';
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
|
||||
@ -8,6 +8,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { Sort } from '../types/Sort';
|
||||
|
||||
export const turnSortsIntoOrderBy = (
|
||||
|
||||
@ -2,13 +2,14 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField';
|
||||
import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
|
||||
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
|
||||
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
|
||||
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
|
||||
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
|
||||
export const useRecordsForSelect = ({
|
||||
searchFilterText,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier';
|
||||
import { OrderBy } from '@/object-metadata/types/OrderBy';
|
||||
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
|
||||
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
@ -10,6 +9,7 @@ import { EntityForSelect } from '@/object-record/relation-picker/types/EntityFor
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
|
||||
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
|
||||
@ -1,28 +1,36 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { Nullable, useIcons } from 'twenty-ui';
|
||||
import { useMemo } from 'react';
|
||||
import { IconMinus, IconPlus, isDefined, useIcons } from 'twenty-ui';
|
||||
|
||||
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
|
||||
import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType';
|
||||
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
|
||||
import { RELATION_TYPES } from '../../constants/RelationTypes';
|
||||
|
||||
import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes';
|
||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown';
|
||||
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
|
||||
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
|
||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { RelationMetadataType } from '~/generated-metadata/graphql';
|
||||
import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
|
||||
import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType';
|
||||
|
||||
type SettingsObjectFieldItemTableRowProps = {
|
||||
ActionIcon: ReactNode;
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
identifierType?: Nullable<FieldIdentifierType>;
|
||||
variant?: 'field-type' | 'identifier';
|
||||
isRemoteObjectField?: boolean;
|
||||
to?: string;
|
||||
settingsObjectDetailTableItem: SettingsObjectDetailTableItem;
|
||||
status: 'active' | 'disabled';
|
||||
mode: 'view' | 'new-field';
|
||||
};
|
||||
|
||||
export const StyledObjectFieldTableRow = styled(TableRow)`
|
||||
@ -40,13 +48,19 @@ const StyledIconTableCell = styled(TableCell)`
|
||||
`;
|
||||
|
||||
export const SettingsObjectFieldItemTableRow = ({
|
||||
ActionIcon,
|
||||
fieldMetadataItem,
|
||||
identifierType,
|
||||
variant = 'field-type',
|
||||
isRemoteObjectField,
|
||||
to,
|
||||
settingsObjectDetailTableItem,
|
||||
mode,
|
||||
status,
|
||||
}: SettingsObjectFieldItemTableRowProps) => {
|
||||
const { fieldMetadataItem, identifierType, objectMetadataItem } =
|
||||
settingsObjectDetailTableItem;
|
||||
|
||||
const isRemoteObjectField = objectMetadataItem.isRemote;
|
||||
|
||||
const variant = objectMetadataItem.isCustom ? 'identifier' : 'field-type';
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const theme = useTheme();
|
||||
const { getIcon } = useIcons();
|
||||
const Icon = getIcon(fieldMetadataItem.icon);
|
||||
@ -62,31 +76,94 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
const fieldType = fieldMetadataItem.type;
|
||||
const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType);
|
||||
|
||||
if (!isFieldTypeSupported) return null;
|
||||
|
||||
const RelationIcon = relationType
|
||||
? RELATION_TYPES[relationType].Icon
|
||||
: undefined;
|
||||
|
||||
const isLabelIdentifier = isLabelIdentifierField({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const canToggleField = !isLabelIdentifier;
|
||||
|
||||
const canBeSetAsLabelIdentifier =
|
||||
objectMetadataItem.isCustom &&
|
||||
!isLabelIdentifier &&
|
||||
LABEL_IDENTIFIER_FIELD_METADATA_TYPES.includes(fieldMetadataItem.type);
|
||||
|
||||
const linkToNavigate = `./${getFieldSlug(fieldMetadataItem)}`;
|
||||
|
||||
const {
|
||||
activateMetadataField,
|
||||
deactivateMetadataField,
|
||||
deleteMetadataField,
|
||||
} = useFieldMetadataItem();
|
||||
|
||||
const handleDisableField = (activeFieldMetadatItem: FieldMetadataItem) => {
|
||||
deactivateMetadataField(activeFieldMetadatItem);
|
||||
};
|
||||
|
||||
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
|
||||
|
||||
const handleSetLabelIdentifierField = (
|
||||
activeFieldMetadatItem: FieldMetadataItem,
|
||||
) =>
|
||||
updateOneObjectMetadataItem({
|
||||
idToUpdate: objectMetadataItem.id,
|
||||
updatePayload: {
|
||||
labelIdentifierFieldMetadataId: activeFieldMetadatItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [, setActiveSettingsObjectFields] = useRecoilState(
|
||||
settingsObjectFieldsFamilyState({
|
||||
objectMetadataItemId: objectMetadataItem.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleToggleField = () => {
|
||||
setActiveSettingsObjectFields((previousFields) => {
|
||||
const newFields = isDefined(previousFields)
|
||||
? previousFields?.map((field) =>
|
||||
field.id === fieldMetadataItem.id
|
||||
? { ...field, isActive: !field.isActive }
|
||||
: field,
|
||||
)
|
||||
: null;
|
||||
|
||||
return newFields;
|
||||
});
|
||||
};
|
||||
|
||||
const typeLabel =
|
||||
variant === 'field-type'
|
||||
? isRemoteObjectField
|
||||
? 'Remote'
|
||||
: fieldMetadataItem.isCustom
|
||||
? 'Custom'
|
||||
: 'Standard'
|
||||
: variant === 'identifier'
|
||||
? isDefined(identifierType)
|
||||
? identifierType === 'label'
|
||||
? 'Record text'
|
||||
: 'Record image'
|
||||
: ''
|
||||
: '';
|
||||
|
||||
if (!isFieldTypeSupported) return null;
|
||||
|
||||
return (
|
||||
<StyledObjectFieldTableRow to={to}>
|
||||
<StyledObjectFieldTableRow
|
||||
to={mode === 'view' ? linkToNavigate : undefined}
|
||||
>
|
||||
<StyledNameTableCell>
|
||||
{!!Icon && (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
)}
|
||||
{fieldMetadataItem.label}
|
||||
</StyledNameTableCell>
|
||||
<TableCell>
|
||||
{variant === 'field-type' &&
|
||||
(isRemoteObjectField
|
||||
? 'Remote'
|
||||
: fieldMetadataItem.isCustom
|
||||
? 'Custom'
|
||||
: 'Standard')}
|
||||
{variant === 'identifier' &&
|
||||
!!identifierType &&
|
||||
(identifierType === 'label' ? 'Record text' : 'Record image')}
|
||||
</TableCell>
|
||||
<TableCell>{typeLabel}</TableCell>
|
||||
<TableCell>
|
||||
<SettingsObjectFieldDataType
|
||||
Icon={RelationIcon}
|
||||
@ -105,7 +182,48 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
value={fieldType}
|
||||
/>
|
||||
</TableCell>
|
||||
<StyledIconTableCell>{ActionIcon}</StyledIconTableCell>
|
||||
<StyledIconTableCell>
|
||||
{status === 'active' ? (
|
||||
mode === 'view' ? (
|
||||
<SettingsObjectFieldActiveActionDropdown
|
||||
isCustomField={fieldMetadataItem.isCustom === true}
|
||||
scopeKey={fieldMetadataItem.id}
|
||||
onEdit={() => navigate(linkToNavigate)}
|
||||
onSetAsLabelIdentifier={
|
||||
canBeSetAsLabelIdentifier
|
||||
? () => handleSetLabelIdentifierField(fieldMetadataItem)
|
||||
: undefined
|
||||
}
|
||||
onDeactivate={
|
||||
isLabelIdentifier
|
||||
? undefined
|
||||
: () => handleDisableField(fieldMetadataItem)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
canToggleField && (
|
||||
<LightIconButton
|
||||
Icon={IconMinus}
|
||||
accent="tertiary"
|
||||
onClick={handleToggleField}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : mode === 'view' ? (
|
||||
<SettingsObjectFieldInactiveActionDropdown
|
||||
isCustomField={fieldMetadataItem.isCustom === true}
|
||||
scopeKey={fieldMetadataItem.id}
|
||||
onActivate={() => activateMetadataField(fieldMetadataItem)}
|
||||
onDelete={() => deleteMetadataField(fieldMetadataItem)}
|
||||
/>
|
||||
) : (
|
||||
<LightIconButton
|
||||
Icon={IconPlus}
|
||||
accent="tertiary"
|
||||
onClick={handleToggleField}
|
||||
/>
|
||||
)}
|
||||
</StyledIconTableCell>
|
||||
</StyledObjectFieldTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ReactNode } from 'react';
|
||||
import { useIcons } from 'twenty-ui';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/SettingsDataModelObjectTypeTag';
|
||||
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
|
||||
type SettingsObjectItemTableRowProps = {
|
||||
export type SettingsObjectMetadataItemTableRowProps = {
|
||||
action: ReactNode;
|
||||
objectItem: ObjectMetadataItem;
|
||||
to?: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
link?: string;
|
||||
totalObjectCount: number;
|
||||
};
|
||||
|
||||
export const StyledObjectTableRow = styled(TableRow)`
|
||||
@ -30,35 +30,33 @@ const StyledActionTableCell = styled(TableCell)`
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const SettingsObjectItemTableRow = ({
|
||||
export const SettingsObjectMetadataItemTableRow = ({
|
||||
action,
|
||||
objectItem,
|
||||
to,
|
||||
}: SettingsObjectItemTableRowProps) => {
|
||||
objectMetadataItem,
|
||||
link,
|
||||
totalObjectCount,
|
||||
}: SettingsObjectMetadataItemTableRowProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { totalCount } = useFindManyRecords({
|
||||
objectNameSingular: objectItem.nameSingular,
|
||||
});
|
||||
const { getIcon } = useIcons();
|
||||
const Icon = getIcon(objectItem.icon);
|
||||
const objectTypeLabel = getObjectTypeLabel(objectItem);
|
||||
const Icon = getIcon(objectMetadataItem.icon);
|
||||
const objectTypeLabel = getObjectTypeLabel(objectMetadataItem);
|
||||
|
||||
return (
|
||||
<StyledObjectTableRow key={objectItem.namePlural} to={to}>
|
||||
<StyledObjectTableRow key={objectMetadataItem.namePlural} to={link}>
|
||||
<StyledNameTableCell>
|
||||
{!!Icon && (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
)}
|
||||
{objectItem.labelPlural}
|
||||
{objectMetadataItem.labelPlural}
|
||||
</StyledNameTableCell>
|
||||
<TableCell>
|
||||
<SettingsDataModelObjectTypeTag objectTypeLabel={objectTypeLabel} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{objectItem.fields.filter((field) => !field.isSystem).length}
|
||||
{objectMetadataItem.fields.filter((field) => !field.isSystem).length}
|
||||
</TableCell>
|
||||
<TableCell align="right">{totalCount}</TableCell>
|
||||
<TableCell align="right">{totalObjectCount}</TableCell>
|
||||
<StyledActionTableCell>{action}</StyledActionTableCell>
|
||||
</StyledObjectTableRow>
|
||||
);
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
|
||||
|
||||
export type SortedFieldByTableFamilyStateKey = {
|
||||
objectMetadataItemId: string;
|
||||
};
|
||||
|
||||
export const settingsObjectFieldsFamilyState = createFamilyState<
|
||||
FieldMetadataItem[] | null,
|
||||
SortedFieldByTableFamilyStateKey
|
||||
>({
|
||||
key: 'settingsObjectFieldsFamilyState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
// @ts-expect-error // Todo: remove usage of react-data-grid
|
||||
import DataGrid, { DataGridProps } from 'react-data-grid';
|
||||
import styled from '@emotion/styled';
|
||||
// @ts-expect-error // Todo: remove usage of react-data-grid
|
||||
import DataGrid, { DataGridProps } from 'react-data-grid';
|
||||
import { RGBA } from 'twenty-ui';
|
||||
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
@ -107,12 +107,12 @@ const StyledDataGrid = styled(DataGrid)`
|
||||
}
|
||||
` as typeof DataGrid;
|
||||
|
||||
type TableProps<Data> = DataGridProps<Data> & {
|
||||
type SpreadsheetImportTableProps<Data> = DataGridProps<Data> & {
|
||||
rowHeight?: number;
|
||||
hiddenHeader?: boolean;
|
||||
};
|
||||
|
||||
export const Table = <Data,>({
|
||||
export const SpreadsheetImportTable = <Data,>({
|
||||
className,
|
||||
columns,
|
||||
components,
|
||||
@ -123,7 +123,7 @@ export const Table = <Data,>({
|
||||
onRowsChange,
|
||||
onSelectedRowsChange,
|
||||
selectedRows,
|
||||
}: TableProps<Data>) => {
|
||||
}: SpreadsheetImportTableProps<Data>) => {
|
||||
const { rtl } = useSpreadsheetImportInternal();
|
||||
|
||||
if (!rows?.length || !columns?.length) return null;
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Table } from '@/spreadsheet-import/components/Table';
|
||||
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
|
||||
import { ImportedRow } from '@/spreadsheet-import/types';
|
||||
|
||||
import { generateSelectionColumns } from './SelectColumn';
|
||||
@ -22,7 +22,7 @@ export const SelectHeaderTable = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
<SpreadsheetImportTable
|
||||
// Todo: remove usage of react-data-grid
|
||||
rowKeyGetter={(row: any) => importedRows.indexOf(row)}
|
||||
rows={importedRows}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Table } from '@/spreadsheet-import/components/Table';
|
||||
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
|
||||
import { Fields } from '@/spreadsheet-import/types';
|
||||
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
|
||||
|
||||
@ -16,5 +16,11 @@ export const ExampleTable = <T extends string>({
|
||||
const data = useMemo(() => generateExampleRow(fields), [fields]);
|
||||
const columns = useMemo(() => generateColumns(fields), [fields]);
|
||||
|
||||
return <Table rows={data} columns={columns} className={'rdg-example'} />;
|
||||
return (
|
||||
<SpreadsheetImportTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
className={'rdg-example'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,8 +5,8 @@ import { RowsChangeData } from 'react-data-grid';
|
||||
import { IconTrash } from 'twenty-ui';
|
||||
|
||||
import { Heading } from '@/spreadsheet-import/components/Heading';
|
||||
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
|
||||
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
||||
import { Table } from '@/spreadsheet-import/components/Table';
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
import {
|
||||
Columns,
|
||||
@ -277,7 +277,7 @@ export const ValidationStep = <T extends string>({
|
||||
/>
|
||||
</StyledToolbar>
|
||||
<StyledScrollContainer>
|
||||
<Table
|
||||
<SpreadsheetImportTable
|
||||
rowKeyGetter={rowKeyGetter}
|
||||
rows={tableData}
|
||||
onRowsChange={updateRow}
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
Placement,
|
||||
useFloating,
|
||||
} from '@floating-ui/react';
|
||||
import { useRef } from 'react';
|
||||
import { MouseEvent, useRef } from 'react';
|
||||
import { Keys } from 'react-hotkeys-hook';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
@ -93,6 +93,14 @@ export const Dropdown = ({
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClickableComponentClick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
toggleDropdown();
|
||||
onClickOutside?.();
|
||||
};
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [refs.floating],
|
||||
callback: () => {
|
||||
@ -126,10 +134,7 @@ export const Dropdown = ({
|
||||
{clickableComponent && (
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
onClick={() => {
|
||||
toggleDropdown();
|
||||
onClickOutside?.();
|
||||
}}
|
||||
onClick={handleClickableComponentClick}
|
||||
className={className}
|
||||
>
|
||||
{clickableComponent}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
|
||||
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
|
||||
|
||||
export const SortableTableHeader = ({
|
||||
tableId,
|
||||
fieldName,
|
||||
label,
|
||||
align = 'left',
|
||||
initialSort,
|
||||
}: {
|
||||
tableId: string;
|
||||
fieldName: string;
|
||||
label: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
initialSort?: TableSortValue;
|
||||
}) => {
|
||||
const [sortedFieldByTable, setSortedFieldByTable] = useRecoilState(
|
||||
sortedFieldByTableFamilyState({ tableId }),
|
||||
);
|
||||
|
||||
const sortValue = sortedFieldByTable ?? initialSort;
|
||||
|
||||
const isSortOnThisField = sortValue?.fieldName === fieldName;
|
||||
|
||||
const sortDirection = isSortOnThisField ? sortValue.orderBy : null;
|
||||
|
||||
const isAsc =
|
||||
sortDirection === 'AscNullsLast' || sortDirection === 'AscNullsFirst';
|
||||
const isDesc =
|
||||
sortDirection === 'DescNullsLast' || sortDirection === 'DescNullsFirst';
|
||||
|
||||
const isSortActive = isAsc || isDesc;
|
||||
|
||||
const handleClick = () => {
|
||||
setSortedFieldByTable({
|
||||
fieldName,
|
||||
orderBy: isSortOnThisField
|
||||
? sortValue.orderBy === 'AscNullsLast'
|
||||
? 'DescNullsLast'
|
||||
: 'AscNullsLast'
|
||||
: 'DescNullsLast',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHeader align={align} onClick={handleClick}>
|
||||
{isSortActive && align === 'right' ? (
|
||||
isAsc ? (
|
||||
<IconArrowUp size="14" />
|
||||
) : (
|
||||
<IconArrowDown size="14" />
|
||||
)
|
||||
) : null}
|
||||
{label}
|
||||
{isSortActive && align === 'left' ? (
|
||||
isAsc ? (
|
||||
<IconArrowUp size="14" />
|
||||
) : (
|
||||
<IconArrowDown size="14" />
|
||||
)
|
||||
) : null}
|
||||
</TableHeader>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,9 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledTableHeader = styled.div<{ align?: 'left' | 'center' | 'right' }>`
|
||||
const StyledTableHeader = styled.div<{
|
||||
align?: 'left' | 'center' | 'right';
|
||||
onClick?: () => void;
|
||||
}>`
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
@ -15,6 +18,7 @@ const StyledTableHeader = styled.div<{ align?: 'left' | 'center' | 'right' }>`
|
||||
: 'flex-start'};
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
text-align: ${({ align }) => align ?? 'left'};
|
||||
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
|
||||
`;
|
||||
|
||||
export { StyledTableHeader as TableHeader };
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { MutableSnapshot, RecoilRoot } from 'recoil';
|
||||
|
||||
import {
|
||||
mockedTableMetadata,
|
||||
MockedTableType,
|
||||
mockedTableData as tableData,
|
||||
tableDataSortedByFieldsCountInAscendingOrder,
|
||||
tableDataSortedByFieldsCountInDescendingOrder,
|
||||
tableDataSortedBylabelInAscendingOrder,
|
||||
tableDataSortedBylabelInDescendingOrder,
|
||||
} from '~/testing/mock-data/tableData';
|
||||
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
|
||||
|
||||
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
|
||||
|
||||
interface WrapperProps {
|
||||
children: ReactNode;
|
||||
initializeState?: (mutableSnapshot: MutableSnapshot) => void;
|
||||
}
|
||||
|
||||
const Wrapper: React.FC<WrapperProps> = ({ children, initializeState }) => (
|
||||
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useSortedArray hook', () => {
|
||||
const initializeState =
|
||||
(fieldName: keyof MockedTableType, orderBy: OrderBy) =>
|
||||
({ set }: MutableSnapshot) => {
|
||||
set(
|
||||
sortedFieldByTableFamilyState({
|
||||
tableId: mockedTableMetadata.tableId,
|
||||
}),
|
||||
{
|
||||
fieldName,
|
||||
orderBy,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
test('initial sorting behavior for string fields - Ascending', () => {
|
||||
const { result } = renderHook(
|
||||
() => useSortedArray(tableData, mockedTableMetadata),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<Wrapper
|
||||
initializeState={initializeState('labelPlural', 'AscNullsLast')}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const sortedData = result.current;
|
||||
|
||||
expect(sortedData).toEqual(tableDataSortedBylabelInAscendingOrder);
|
||||
});
|
||||
|
||||
test('initial sorting behavior for string fields - Descending', () => {
|
||||
const { result } = renderHook(
|
||||
() => useSortedArray(tableData, mockedTableMetadata),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<Wrapper
|
||||
initializeState={initializeState('labelPlural', 'DescNullsLast')}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const sortedData = result.current;
|
||||
|
||||
expect(sortedData).toEqual(tableDataSortedBylabelInDescendingOrder);
|
||||
});
|
||||
|
||||
test('initial sorting behavior for number fields - Ascending', () => {
|
||||
const { result } = renderHook(
|
||||
() => useSortedArray(tableData, mockedTableMetadata),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<Wrapper
|
||||
initializeState={initializeState('fieldsCount', 'AscNullsLast')}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const sortedData = result.current;
|
||||
|
||||
expect(sortedData).toEqual(tableDataSortedByFieldsCountInAscendingOrder);
|
||||
});
|
||||
|
||||
test('initial sorting behavior for number fields - Descending', () => {
|
||||
const { result } = renderHook(
|
||||
() => useSortedArray(tableData, mockedTableMetadata),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<Wrapper
|
||||
initializeState={initializeState('fieldsCount', 'DescNullsLast')}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const sortedData = result.current;
|
||||
|
||||
expect(sortedData).toEqual(tableDataSortedByFieldsCountInDescendingOrder);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
|
||||
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const useSortedArray = <T>(
|
||||
arrayToSort: T[],
|
||||
tableMetadata: TableMetadata<T>,
|
||||
): T[] => {
|
||||
const sortedFieldByTable = useRecoilValue(
|
||||
sortedFieldByTableFamilyState({ tableId: tableMetadata.tableId }),
|
||||
);
|
||||
|
||||
const initialSort = tableMetadata.initialSort;
|
||||
|
||||
const sortedArray = useMemo(() => {
|
||||
const sortValueToUse = isDefined(sortedFieldByTable)
|
||||
? sortedFieldByTable
|
||||
: initialSort;
|
||||
|
||||
if (!isDefined(sortValueToUse)) {
|
||||
return arrayToSort;
|
||||
}
|
||||
|
||||
const sortFieldName = sortValueToUse.fieldName as keyof T;
|
||||
const sortFieldType = tableMetadata.fields.find(
|
||||
(field) => field.fieldName === sortFieldName,
|
||||
)?.fieldType;
|
||||
const sortOrder = sortValueToUse.orderBy;
|
||||
|
||||
return [...arrayToSort].sort((a: T, b: T) => {
|
||||
if (sortFieldType === 'string') {
|
||||
return sortOrder === 'AscNullsLast' || sortOrder === 'AscNullsFirst'
|
||||
? (a[sortFieldName] as string)?.localeCompare(
|
||||
b[sortFieldName] as string,
|
||||
)
|
||||
: (b[sortFieldName] as string)?.localeCompare(
|
||||
a[sortFieldName] as string,
|
||||
);
|
||||
} else if (sortFieldType === 'number') {
|
||||
return sortOrder === 'AscNullsLast' || sortOrder === 'AscNullsFirst'
|
||||
? (a[sortFieldName] as number) - (b[sortFieldName] as number)
|
||||
: (b[sortFieldName] as number) - (a[sortFieldName] as number);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [arrayToSort, tableMetadata, initialSort, sortedFieldByTable]);
|
||||
|
||||
return sortedArray;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
|
||||
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
|
||||
|
||||
export type SortedFieldByTableFamilyStateKey = {
|
||||
tableId: string;
|
||||
};
|
||||
|
||||
export const sortedFieldByTableFamilyState = createFamilyState<
|
||||
TableSortValue | null,
|
||||
SortedFieldByTableFamilyStateKey
|
||||
>({
|
||||
key: 'sortedFieldByTableFamilyState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
export type TableFieldMetadata<ItemType> = {
|
||||
fieldLabel: string;
|
||||
fieldName: keyof ItemType;
|
||||
fieldType: 'string' | 'number';
|
||||
align: 'left' | 'right';
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { TableFieldMetadata } from '@/ui/layout/table/types/TableFieldMetadata';
|
||||
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
|
||||
|
||||
export type TableMetadata<ItemType> = {
|
||||
tableId: string;
|
||||
fields: TableFieldMetadata<ItemType>[];
|
||||
initialSort?: TableSortValue;
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { OrderBy } from '@/types/OrderBy';
|
||||
|
||||
export type TableSortValue = {
|
||||
fieldName: string;
|
||||
orderBy: OrderBy;
|
||||
};
|
||||
Reference in New Issue
Block a user