[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:
Anand Krishnan M J 2024-08-14 20:41:17 +05:30 committed by GitHub
parent 0f75e14ab2
commit 59e14fabb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1229 additions and 445 deletions

View File

@ -59,7 +59,7 @@ import { SettingsAccountsEmails } from '~/pages/settings/accounts/SettingsAccoun
import { SettingsNewAccount } from '~/pages/settings/accounts/SettingsNewAccount';
import { SettingsCRMMigration } from '~/pages/settings/crm-migration/SettingsCRMMigration';
import { SettingsNewObject } from '~/pages/settings/data-model/SettingsNewObject';
import { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail';
import { SettingsObjectDetailPage } from '~/pages/settings/data-model/SettingsObjectDetailPage';
import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit';
import { SettingsObjectFieldEdit } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
import { SettingsObjectNewFieldStep1 } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1';
@ -218,7 +218,7 @@ const createRouter = (
/>
<Route
path={SettingsPath.ObjectDetail}
element={<SettingsObjectDetail />}
element={<SettingsObjectDetailPage />}
/>
<Route
path={SettingsPath.ObjectEdit}

View File

@ -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';

View File

@ -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,

View File

@ -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 = (

View File

@ -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 = (

View File

@ -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 };

View File

@ -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,
};
};

View File

@ -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 = (

View File

@ -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,

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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,
});

View File

@ -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;

View File

@ -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}

View File

@ -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'}
/>
);
};

View File

@ -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}

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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,
});

View File

@ -0,0 +1,6 @@
export type TableFieldMetadata<ItemType> = {
fieldLabel: string;
fieldName: keyof ItemType;
fieldType: 'string' | 'number';
align: 'left' | 'right';
};

View File

@ -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;
};

View File

@ -0,0 +1,6 @@
import { OrderBy } from '@/types/OrderBy';
export type TableSortValue = {
fieldName: string;
orderBy: OrderBy;
};

View File

@ -1,237 +0,0 @@
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown';
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
const StyledDiv = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsObjectDetail = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
const {
activateMetadataField,
deactivateMetadataField,
deleteMetadataField,
} = useFieldMetadataItem();
if (!activeObjectMetadataItem) return null;
const activeMetadataFields = getActiveFieldMetadataItems(
activeObjectMetadataItem,
);
const deactivatedMetadataFields = getDisabledFieldMetadataItems(
activeObjectMetadataItem,
);
const handleDisableObject = async () => {
await updateOneObjectMetadataItem({
idToUpdate: activeObjectMetadataItem.id,
updatePayload: { isActive: false },
});
navigate(getSettingsPagePath(SettingsPath.Objects));
};
const handleDisableField = (activeFieldMetadatItem: FieldMetadataItem) => {
deactivateMetadataField(activeFieldMetadatItem);
};
const handleSetLabelIdentifierField = (
activeFieldMetadatItem: FieldMetadataItem,
) =>
updateOneObjectMetadataItem({
idToUpdate: activeObjectMetadataItem.id,
updatePayload: {
labelIdentifierFieldMetadataId: activeFieldMetadatItem.id,
},
});
const shouldDisplayAddFieldButton = !activeObjectMetadataItem.isRemote;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: activeObjectMetadataItem.labelPlural },
]}
/>
<Section>
<H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard
iconKey={activeObjectMetadataItem.icon ?? undefined}
name={activeObjectMetadataItem.labelPlural || ''}
objectMetadataItem={activeObjectMetadataItem}
onDeactivate={handleDisableObject}
onEdit={() => navigate('./edit')}
/>
</Section>
<Section>
<H2Title
title="Fields"
description={`Customise the fields available in the ${activeObjectMetadataItem.labelSingular} views and their display order in the ${activeObjectMetadataItem.labelSingular} detail view and menus.`}
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>
{activeObjectMetadataItem.isCustom
? 'Identifier'
: 'Field type'}
</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection title="Active">
{activeMetadataFields.map((activeMetadataField) => {
const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
objectMetadataItem: activeObjectMetadataItem,
});
const canBeSetAsLabelIdentifier =
activeObjectMetadataItem.isCustom &&
!isLabelIdentifier &&
LABEL_IDENTIFIER_FIELD_METADATA_TYPES.includes(
activeMetadataField.type,
);
return (
<SettingsObjectFieldItemTableRow
key={activeMetadataField.id}
identifierType={getFieldIdentifierType(
activeMetadataField,
activeObjectMetadataItem,
)}
variant={
activeObjectMetadataItem.isCustom
? 'identifier'
: 'field-type'
}
fieldMetadataItem={activeMetadataField}
isRemoteObjectField={activeObjectMetadataItem.isRemote}
// to={`./${getFieldSlug(activeMetadataField)}`}
ActionIcon={
<SettingsObjectFieldActiveActionDropdown
isCustomField={!!activeMetadataField.isCustom}
scopeKey={activeMetadataField.id}
onEdit={() =>
navigate(`./${getFieldSlug(activeMetadataField)}`)
}
onSetAsLabelIdentifier={
canBeSetAsLabelIdentifier
? () =>
handleSetLabelIdentifierField(
activeMetadataField,
)
: undefined
}
onDeactivate={
isLabelIdentifier
? undefined
: () => handleDisableField(activeMetadataField)
}
/>
}
/>
);
})}
</TableSection>
)}
{!!deactivatedMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Inactive">
{deactivatedMetadataFields.map((deactivatedMetadataField) => (
<SettingsObjectFieldItemTableRow
key={deactivatedMetadataField.id}
variant={
activeObjectMetadataItem.isCustom
? 'identifier'
: 'field-type'
}
fieldMetadataItem={deactivatedMetadataField}
ActionIcon={
<SettingsObjectFieldInactiveActionDropdown
isCustomField={!!deactivatedMetadataField.isCustom}
scopeKey={deactivatedMetadataField.id}
onActivate={() =>
activateMetadataField(deactivatedMetadataField)
}
onDelete={() =>
deleteMetadataField(deactivatedMetadataField)
}
/>
}
/>
))}
</TableSection>
)}
</Table>
{shouldDisplayAddFieldButton && (
<StyledDiv>
<UndecoratedLink
to={
deactivatedMetadataFields.length
? './new-field/step-1'
: './new-field/step-2'
}
>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-ui';
import { SettingsObjectDetailPageContent } from '~/pages/settings/data-model/SettingsObjectDetailPageContent';
export const SettingsObjectDetailPage = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
if (!isDefined(activeObjectMetadataItem)) return <></>;
return (
<SettingsObjectDetailPageContent
objectMetadataItem={activeObjectMetadataItem}
/>
);
};

View File

@ -0,0 +1,101 @@
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isNonEmptyArray } from '@sniptt/guards';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledDiv = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export type SettingsObjectDetailPageContentProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsObjectDetailPageContent = ({
objectMetadataItem,
}: SettingsObjectDetailPageContentProps) => {
const navigate = useNavigate();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const handleDisableObject = async () => {
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: { isActive: false },
});
navigate(getSettingsPagePath(SettingsPath.Objects));
};
const disabledFieldMetadataItems =
getDisabledFieldMetadataItems(objectMetadataItem);
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: objectMetadataItem.labelPlural },
]}
/>
<Section>
<H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard
iconKey={objectMetadataItem.icon ?? undefined}
name={objectMetadataItem.labelPlural || ''}
objectMetadataItem={objectMetadataItem}
onDeactivate={handleDisableObject}
onEdit={() => navigate('./edit')}
/>
</Section>
<Section>
<H2Title
title="Fields"
description={`Customise the fields available in the ${objectMetadataItem.labelSingular} views and their display order in the ${objectMetadataItem.labelSingular} detail view and menus.`}
/>
<SettingsObjectFieldTable
objectMetadataItem={objectMetadataItem}
mode="view"
/>
{shouldDisplayAddFieldButton && (
<StyledDiv>
<UndecoratedLink
to={
isNonEmptyArray(disabledFieldMetadataItems)
? './new-field/step-1'
: './new-field/step-2'
}
>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,194 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { isNonEmptyArray } from '@sniptt/guards';
import { useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useMapFieldMetadataItemToSettingsObjectDetailTableItem } from '~/pages/settings/data-model/hooks/useMapFieldMetadataItemToSettingsObjectDetailTableItem';
import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
{
fieldLabel: 'Name',
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Field type',
fieldName: 'fieldType',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Data type',
fieldName: 'dataType',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
{
fieldLabel: 'Name',
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Identifier',
fieldName: 'identifierType',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Data type',
fieldName: 'dataType',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
export type SettingsObjectFieldTableProps = {
objectMetadataItem: ObjectMetadataItem;
mode: 'view' | 'new-field';
};
// TODO: find another way than using mode which feels like it could be replaced by another pattern
export const SettingsObjectFieldTable = ({
objectMetadataItem,
mode,
}: SettingsObjectFieldTableProps) => {
const tableMetadata = objectMetadataItem.isCustom
? SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM
: SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD;
const { mapFieldMetadataItemToSettingsObjectDetailTableItem } =
useMapFieldMetadataItemToSettingsObjectDetailTableItem(objectMetadataItem);
const [settingsObjectFields, setSettingsObjectFields] = useRecoilState(
settingsObjectFieldsFamilyState({
objectMetadataItemId: objectMetadataItem.id,
}),
);
useEffect(() => {
setSettingsObjectFields(objectMetadataItem.fields);
}, [objectMetadataItem, setSettingsObjectFields]);
const activeObjectSettingsDetailItems = useMemo(() => {
const activeMetadataFields = settingsObjectFields?.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
);
return (
activeMetadataFields?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const disabledObjectSettingsDetailItems = useMemo(() => {
const disabledFieldMetadataItems = settingsObjectFields?.filter(
(fieldMetadataItem) =>
!fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
);
return (
disabledFieldMetadataItems?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const sortedActiveObjectSettingsDetailItems = useSortedArray(
activeObjectSettingsDetailItems,
tableMetadata,
);
const sortedDisabledObjectSettingsDetailItems = useSortedArray(
disabledObjectSettingsDetailItems,
tableMetadata,
);
return (
<Table>
<StyledObjectFieldTableRow>
{tableMetadata.fields.map((item) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={item.fieldLabel}
tableId={tableMetadata.tableId}
initialSort={tableMetadata.initialSort}
/>
))}
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{isNonEmptyArray(sortedActiveObjectSettingsDetailItems) && (
<TableSection title="Active">
{sortedActiveObjectSettingsDetailItems.map(
(objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="active"
mode={mode}
/>
),
)}
</TableSection>
)}
{isNonEmptyArray(sortedDisabledObjectSettingsDetailItems) && (
<TableSection
isInitiallyExpanded={mode === 'new-field' ? true : false}
title="Inactive"
>
{sortedDisabledObjectSettingsDetailItems.map(
(objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="disabled"
mode={mode}
/>
),
)}
</TableSection>
)}
</Table>
);
};

View File

@ -1,27 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { H2Title, IconMinus, IconPlus, IconSettings } from 'twenty-ui';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { AppPath } from '@/types/AppPath';
import { Button } from '@/ui/input/button/components/Button';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useRecoilState } from 'recoil';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledSection = styled(Section)`
display: flex;
@ -43,62 +38,52 @@ export const SettingsObjectNewFieldStep1 = () => {
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const [settingsObjectFields] = useRecoilState(
settingsObjectFieldsFamilyState({
objectMetadataItemId: activeObjectMetadataItem?.id,
}),
);
const { activateMetadataField, deactivateMetadataField } =
useFieldMetadataItem();
const [metadataFields, setMetadataFields] = useState(
activeObjectMetadataItem?.fields ?? [],
);
const activeMetadataFields = metadataFields.filter((field) => field.isActive);
const deactivatedMetadataFields = metadataFields.filter(
(field) => !field.isActive,
);
const canSave = metadataFields.some(
const canSave = settingsObjectFields?.some(
(field, index) =>
field.isActive !== activeObjectMetadataItem?.fields[index].isActive,
);
const handleSave = async () => {
if (!activeObjectMetadataItem || !settingsObjectFields) {
return;
}
await Promise.all(
settingsObjectFields.map((fieldMetadataItem, index) => {
if (
fieldMetadataItem.isActive ===
activeObjectMetadataItem.fields[index].isActive
) {
return undefined;
}
return fieldMetadataItem.isActive
? activateMetadataField(fieldMetadataItem)
: deactivateMetadataField(fieldMetadataItem);
}),
);
navigate(`/settings/objects/${objectSlug}`);
};
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
if (!metadataFields.length)
setMetadataFields(activeObjectMetadataItem.fields);
}, [activeObjectMetadataItem, metadataFields.length, navigate]);
}, [activeObjectMetadataItem, navigate]);
if (!activeObjectMetadataItem) return null;
const handleToggleField = (fieldMetadataId: string) =>
setMetadataFields((previousFields) =>
previousFields.map((field) =>
field.id === fieldMetadataId
? { ...field, isActive: !field.isActive }
: field,
),
);
const handleSave = async () => {
await Promise.all(
metadataFields.map((metadataField, index) => {
if (
metadataField.isActive ===
activeObjectMetadataItem.fields[index].isActive
) {
return undefined;
}
return metadataField.isActive
? activateMetadataField(metadataField)
: deactivateMetadataField(metadataField);
}),
);
navigate(`/settings/objects/${objectSlug}`);
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -126,58 +111,10 @@ export const SettingsObjectNewFieldStep1 = () => {
title="Check deactivated fields"
description="Before creating a custom field, check if it already exists in the deactivated section."
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Field type</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Active">
{activeMetadataFields.map((activeMetadataField) => (
<SettingsObjectFieldItemTableRow
key={activeMetadataField.id}
fieldMetadataItem={activeMetadataField}
isRemoteObjectField={activeObjectMetadataItem.isRemote}
ActionIcon={
isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
objectMetadataItem: activeObjectMetadataItem,
}) ? undefined : (
<LightIconButton
Icon={IconMinus}
accent="tertiary"
onClick={() =>
handleToggleField(activeMetadataField.id)
}
/>
)
}
/>
))}
</TableSection>
)}
{!!deactivatedMetadataFields.length && (
<TableSection title="Disabled">
{deactivatedMetadataFields.map((deactivatedMetadataField) => (
<SettingsObjectFieldItemTableRow
key={deactivatedMetadataField.name}
fieldMetadataItem={deactivatedMetadataField}
ActionIcon={
<LightIconButton
Icon={IconPlus}
accent="tertiary"
onClick={() =>
handleToggleField(deactivatedMetadataField.id)
}
/>
}
/>
))}
</TableSection>
)}
</Table>
<SettingsObjectFieldTable
objectMetadataItem={activeObjectMetadataItem}
mode="new-field"
/>
<StyledAddCustomFieldButton
Icon={IconPlus}
title="Add Custom Field"

View File

@ -12,23 +12,31 @@ import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDelet
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useCombinedGetTotalCount } from '@/object-record/multiple-objects/hooks/useCombinedGetTotalCount';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectItemTableRow,
SettingsObjectMetadataItemTableRow,
StyledObjectTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRow';
import { SettingsObjectCoverImage } from '@/settings/data-model/objects/SettingsObjectCoverImage';
import { SettingsObjectInactiveMenuDropDown } from '@/settings/data-model/objects/SettingsObjectInactiveMenuDropDown';
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isNonEmptyArray } from '@sniptt/guards';
import { useMemo } from 'react';
import { SETTINGS_OBJECT_TABLE_METADATA } from '~/pages/settings/data-model/constants/SettingsObjectTableMetadata';
import { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
@ -41,11 +49,71 @@ const StyledH1Title = styled(H1Title)`
export const SettingsObjects = () => {
const theme = useTheme();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount(
{
objectMetadataItems: [
...activeObjectMetadataItems,
...inactiveObjectMetadataItems,
],
},
);
const activeObjectSettingsArray = useMemo(
() =>
activeObjectMetadataItems.map(
(objectMetadataItem) =>
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getObjectTypeLabel(objectMetadataItem).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
totalObjectCount:
totalCountByObjectMetadataItemNamePlural[
objectMetadataItem.namePlural
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[activeObjectMetadataItems, totalCountByObjectMetadataItemNamePlural],
);
const inactiveObjectSettingsArray = useMemo(
() =>
inactiveObjectMetadataItems.map(
(objectMetadataItem) =>
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getObjectTypeLabel(objectMetadataItem).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
totalObjectCount:
totalCountByObjectMetadataItemNamePlural[
objectMetadataItem.namePlural
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[inactiveObjectMetadataItems, totalCountByObjectMetadataItemNamePlural],
);
const sortedActiveObjectSettingsItems = useSortedArray(
activeObjectSettingsArray,
SETTINGS_OBJECT_TABLE_METADATA,
);
const sortedInactiveObjectSettingsItems = useSortedArray(
inactiveObjectSettingsArray,
SETTINGS_OBJECT_TABLE_METADATA,
);
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -66,51 +134,67 @@ export const SettingsObjects = () => {
<H2Title title="Existing objects" />
<Table>
<StyledObjectTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Fields</TableHeader>
<TableHeader align="right">Instances</TableHeader>
{SETTINGS_OBJECT_TABLE_METADATA.fields.map(
(settingsObjectsTableMetadataField) => (
<SortableTableHeader
fieldName={settingsObjectsTableMetadataField.fieldName}
label={settingsObjectsTableMetadataField.fieldLabel}
tableId={SETTINGS_OBJECT_TABLE_METADATA.tableId}
align={settingsObjectsTableMetadataField.align}
initialSort={SETTINGS_OBJECT_TABLE_METADATA.initialSort}
/>
),
)}
<TableHeader></TableHeader>
</StyledObjectTableRow>
{!!activeObjectMetadataItems.length && (
{isNonEmptyArray(sortedActiveObjectSettingsItems) && (
<TableSection title="Active">
{activeObjectMetadataItems.map((activeObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={activeObjectMetadataItem.namePlural}
objectItem={activeObjectMetadataItem}
{sortedActiveObjectSettingsItems.map((objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={objectSettingsItem.objectMetadataItem}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
to={`/settings/objects/${getObjectSlug(
activeObjectMetadataItem,
link={`/settings/objects/${getObjectSlug(
objectSettingsItem.objectMetadataItem,
)}`}
/>
))}
</TableSection>
)}
{!!inactiveObjectMetadataItems.length && (
{isNonEmptyArray(inactiveObjectMetadataItems) && (
<TableSection title="Inactive">
{inactiveObjectMetadataItems.map(
(inactiveObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={inactiveObjectMetadataItem.namePlural}
objectItem={inactiveObjectMetadataItem}
{sortedInactiveObjectSettingsItems.map(
(objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={
objectSettingsItem.objectMetadataItem
}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
<SettingsObjectInactiveMenuDropDown
isCustomObject={inactiveObjectMetadataItem.isCustom}
scopeKey={inactiveObjectMetadataItem.namePlural}
isCustomObject={
objectSettingsItem.objectMetadataItem.isCustom
}
scopeKey={
objectSettingsItem.objectMetadataItem.namePlural
}
onActivate={() =>
updateOneObjectMetadataItem({
idToUpdate: inactiveObjectMetadataItem.id,
idToUpdate:
objectSettingsItem.objectMetadataItem.id,
updatePayload: { isActive: true },
})
}
onDelete={() =>
deleteOneObjectMetadataItem(
inactiveObjectMetadataItem.id,
objectSettingsItem.objectMetadataItem.id,
)
}
/>

View File

@ -8,11 +8,11 @@ import {
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/utils/sleep';
import { SettingsObjectDetail } from '../SettingsObjectDetail';
import { SettingsObjectDetailPage } from '../SettingsObjectDetailPage';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjectDetail',
component: SettingsObjectDetail,
component: SettingsObjectDetailPage,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug',
@ -25,7 +25,7 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta;
export type Story = StoryObj<typeof SettingsObjectDetail>;
export type Story = StoryObj<typeof SettingsObjectDetailPage>;
export const StandardObject: Story = {
play: async () => {

View File

@ -0,0 +1,37 @@
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
export const SETTINGS_OBJECT_TABLE_METADATA: TableMetadata<SettingsObjectTableItem> =
{
tableId: 'settingsObject',
fields: [
{
fieldLabel: 'Name',
fieldName: 'labelPlural',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Type',
fieldName: 'objectTypeLabel',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Fields',
fieldName: 'fieldsCount',
fieldType: 'number',
align: 'right',
},
{
fieldLabel: 'Instances',
fieldName: 'totalObjectCount',
fieldType: 'number',
align: 'right',
},
],
initialSort: {
fieldName: 'labelPlural',
orderBy: 'AscNullsLast',
},
};

View File

@ -0,0 +1,46 @@
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { getSettingsObjectFieldType } from '~/pages/settings/data-model/utils/getSettingsObjectFieldType';
export const useMapFieldMetadataItemToSettingsObjectDetailTableItem = (
objectMetadataItem: ObjectMetadataItem,
) => {
const getRelationMetadata = useGetRelationMetadata();
const mapFieldMetadataItemToSettingsObjectDetailTableItem = (
fieldMetadataItem: FieldMetadataItem,
): SettingsObjectDetailTableItem => {
const fieldType = getSettingsObjectFieldType(
objectMetadataItem,
fieldMetadataItem,
);
const { relationObjectMetadataItem } =
getRelationMetadata({
fieldMetadataItem,
}) ?? {};
const identifierType = getFieldIdentifierType(
fieldMetadataItem,
objectMetadataItem,
);
return {
fieldMetadataItem,
fieldType: fieldType ?? '',
dataType:
relationObjectMetadataItem?.labelPlural ??
getSettingsFieldTypeConfig(fieldMetadataItem.type)?.label ??
'',
label: fieldMetadataItem.label,
identifierType: identifierType,
objectMetadataItem,
} satisfies SettingsObjectDetailTableItem;
};
return { mapFieldMetadataItemToSettingsObjectDetailTableItem };
};

View File

@ -0,0 +1,12 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType';
export type SettingsObjectDetailTableItem = {
fieldMetadataItem: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
fieldType: string | boolean;
label: string;
dataType: string;
identifierType?: FieldIdentifierType;
};

View File

@ -0,0 +1,9 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export type SettingsObjectTableItem = {
objectMetadataItem: ObjectMetadataItem;
totalObjectCount: number;
fieldsCount: number;
objectTypeLabel: string;
labelPlural: string;
};

View File

@ -0,0 +1,30 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { isDefined } from 'twenty-ui';
export const getSettingsObjectFieldType = (
objectMetadataItem: ObjectMetadataItem,
fieldMetadataItem: FieldMetadataItem,
) => {
const variant = objectMetadataItem.isCustom ? 'identifier' : 'field-type';
const identifierType = getFieldIdentifierType(
fieldMetadataItem,
objectMetadataItem,
);
if (variant === 'field-type') {
return objectMetadataItem.isRemote
? 'Remote'
: fieldMetadataItem.isCustom
? 'Custom'
: 'Standard';
} else {
return isDefined(identifierType)
? identifierType === 'label'
? 'Record text'
: 'Record image'
: null;
}
};

View File

@ -0,0 +1,71 @@
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
export type MockedTableType = {
labelPlural: string;
fieldsCount: number;
};
export const mockedTableMetadata: TableMetadata<MockedTableType> = {
tableId: 'SettingsObjectDetail',
fields: [
{
fieldName: 'labelPlural',
fieldType: 'string',
align: 'left',
fieldLabel: 'Name',
},
{
fieldName: 'fieldsCount',
fieldType: 'number',
align: 'right',
fieldLabel: 'Fields Count',
},
],
};
export const mockedTableData = [
{
labelPlural: 'Opportunities',
fieldsCount: 11,
},
{
labelPlural: 'Contact',
fieldsCount: 3,
},
{
labelPlural: 'Leads',
fieldsCount: 4,
},
{
labelPlural: 'Tasks',
fieldsCount: 5,
},
];
export const tableDataSortedBylabelInAscendingOrder = [
{ labelPlural: 'Contact', fieldsCount: 3 },
{ labelPlural: 'Leads', fieldsCount: 4 },
{ labelPlural: 'Opportunities', fieldsCount: 11 },
{ labelPlural: 'Tasks', fieldsCount: 5 },
];
export const tableDataSortedBylabelInDescendingOrder = [
{ labelPlural: 'Tasks', fieldsCount: 5 },
{ labelPlural: 'Opportunities', fieldsCount: 11 },
{ labelPlural: 'Leads', fieldsCount: 4 },
{ labelPlural: 'Contact', fieldsCount: 3 },
];
export const tableDataSortedByFieldsCountInAscendingOrder = [
{ labelPlural: 'Contact', fieldsCount: 3 },
{ labelPlural: 'Leads', fieldsCount: 4 },
{ labelPlural: 'Tasks', fieldsCount: 5 },
{ labelPlural: 'Opportunities', fieldsCount: 11 },
];
export const tableDataSortedByFieldsCountInDescendingOrder = [
{ labelPlural: 'Opportunities', fieldsCount: 11 },
{ labelPlural: 'Tasks', fieldsCount: 5 },
{ labelPlural: 'Leads', fieldsCount: 4 },
{ labelPlural: 'Contact', fieldsCount: 3 },
];