diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 277d2c05c9..4853cdf37e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -837,6 +837,7 @@ enum ViewType { TABLE KANBAN CALENDAR + LIST FIELDS_WIDGET TABLE_WIDGET KANBAN_WIDGET @@ -1805,6 +1806,7 @@ enum FeatureFlagKey { IS_CALENDAR_WEEK_VIEW_ENABLED IS_EMAIL_GROUP_ENABLED IS_JUNCTION_RELATIONS_ENABLED + IS_LIST_VIEW_ENABLED IS_REST_METADATA_API_NEW_FORMAT_DIRECT IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED IS_SETTINGS_DISCOVERY_HERO_ENABLED diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 7d8181496f..cd418a4803 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -585,7 +585,7 @@ export interface View { __typename: 'View' } -export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'FIELDS_WIDGET' | 'TABLE_WIDGET' | 'KANBAN_WIDGET' | 'CALENDAR_WIDGET' +export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'LIST' | 'FIELDS_WIDGET' | 'TABLE_WIDGET' | 'KANBAN_WIDGET' | 'CALENDAR_WIDGET' export type ViewKey = 'INDEX' @@ -1446,7 +1446,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' | 'IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED' +export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_LIST_VIEW_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' | 'IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -9440,6 +9440,7 @@ export const enumViewType = { TABLE: 'TABLE' as const, KANBAN: 'KANBAN' as const, CALENDAR: 'CALENDAR' as const, + LIST: 'LIST' as const, FIELDS_WIDGET: 'FIELDS_WIDGET' as const, TABLE_WIDGET: 'TABLE_WIDGET' as const, KANBAN_WIDGET: 'KANBAN_WIDGET' as const, @@ -9680,6 +9681,7 @@ export const enumFeatureFlagKey = { IS_CALENDAR_WEEK_VIEW_ENABLED: 'IS_CALENDAR_WEEK_VIEW_ENABLED' as const, IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const, IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const, + IS_LIST_VIEW_ENABLED: 'IS_LIST_VIEW_ENABLED' as const, IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const, IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const, IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const, diff --git a/packages/twenty-docs/developers/extend/apps/layout/views.mdx b/packages/twenty-docs/developers/extend/apps/layout/views.mdx index a24aa06329..5f034c3f2f 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/views.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/views.mdx @@ -43,7 +43,7 @@ export default defineView({ | Property | Values | Description | |----------|--------|-------------| -| `type` | `ViewType.TABLE` (default), `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, and `CALENDAR_WIDGET` also exist but are used internally by page-layout widgets.) | +| `type` | `ViewType.TABLE` (default), `ViewType.LIST`, `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, and `CALENDAR_WIDGET` also exist but are used internally by page-layout widgets.) | | `visibility` | `ViewVisibility.WORKSPACE` (default), `ViewVisibility.UNLISTED` | Whether the view is listed for the whole workspace or hidden from pickers. | | `openRecordIn` | deprecated | No longer read: where records open is now a property of the [object](/developers/extend/apps/data/objects) (`openRecordIn` on `defineObject()`), falling back to each member's own preference. | | `sorts` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | Default sort order. | diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index e72a841bc1..115f44fb70 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -373,6 +373,7 @@ export enum FeatureFlagKey { IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', + IS_LIST_VIEW_ENABLED = 'IS_LIST_VIEW_ENABLED', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6a3d069019..073c484220 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1819,6 +1819,7 @@ export enum FeatureFlagKey { IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', + IS_LIST_VIEW_ENABLED = 'IS_LIST_VIEW_ENABLED', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', @@ -6486,6 +6487,7 @@ export enum ViewType { FIELDS_WIDGET = 'FIELDS_WIDGET', KANBAN = 'KANBAN', KANBAN_WIDGET = 'KANBAN_WIDGET', + LIST = 'LIST', TABLE = 'TABLE', TABLE_WIDGET = 'TABLE_WIDGET' } diff --git a/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts b/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts index b432217c4f..92ad843f16 100644 --- a/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts +++ b/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts @@ -9,5 +9,11 @@ export const getCommandMenuItemLabel = ( return ''; } - return i18n._(label); + if (typeof label === 'string') { + return i18n._(label); + } + + const { id, values, ...options } = label; + + return i18n._(id, values, options); }; diff --git a/packages/twenty-front/src/modules/dropdown-context-state-management/hooks/useDropdownContextStateManagement.ts b/packages/twenty-front/src/modules/dropdown-context-state-management/hooks/useDropdownContextStateManagement.ts index 659084a37d..7010b6e11e 100644 --- a/packages/twenty-front/src/modules/dropdown-context-state-management/hooks/useDropdownContextStateManagement.ts +++ b/packages/twenty-front/src/modules/dropdown-context-state-management/hooks/useDropdownContextStateManagement.ts @@ -1,6 +1,6 @@ import { isDefined } from 'twenty-shared/utils'; import { type ObjectOptionsDropdownContextValue } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; -import { type RecordBoardColumnHeaderAggregateDropdownContextValue } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; +import { type RecordGroupAggregateDropdownContextValue } from '@/object-record/record-group/types/RecordGroupAggregateDropdownContextValue'; import { type RecordTableColumnAggregateFooterDropdownContextValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { type Context, useCallback, useContext } from 'react'; @@ -13,7 +13,7 @@ import { type Context, useCallback, useContext } from 'react'; */ export const useDropdownContextStateManagement = < T extends - | RecordBoardColumnHeaderAggregateDropdownContextValue + | RecordGroupAggregateDropdownContextValue | RecordTableColumnAggregateFooterDropdownContextValue | ObjectOptionsDropdownContextValue, >({ diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts index ad41cff66a..0dbbe8fe32 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts @@ -43,7 +43,12 @@ const PAGE_LAYOUTS_GROUP_KEYS: MetadataEntityKey[] = [ 'pageLayoutWidgets', ]; -const INDEX_VIEW_TYPES = [ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR]; +const INDEX_VIEW_TYPES = [ + ViewType.TABLE, + ViewType.KANBAN, + ViewType.CALENDAR, + ViewType.LIST, +]; const FIELDS_WIDGET_VIEW_TYPES = [ViewType.FIELDS_WIDGET]; const WIDGET_VIEW_TYPES = [ ViewType.TABLE_WIDGET, diff --git a/packages/twenty-front/src/modules/object-record/components/RecordComponentInstanceContextsWrapper.tsx b/packages/twenty-front/src/modules/object-record/components/RecordComponentInstanceContextsWrapper.tsx index 52f23dcdd8..03f54f30a0 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordComponentInstanceContextsWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordComponentInstanceContextsWrapper.tsx @@ -1,5 +1,6 @@ import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { RecordFieldsComponentInstanceContext } from '@/object-record/record-field/states/context/RecordFieldsComponentInstanceContext'; +import { RecordListComponentInstanceContext } from '@/object-record/record-list/states/contexts/RecordListComponentInstanceContext'; import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext'; import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext'; @@ -29,7 +30,11 @@ export const RecordComponentInstanceContextsWrapper = ({ - {children} + + {children} + diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx index c1c2b67b27..a79333fdac 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx @@ -67,6 +67,9 @@ export const ObjectOptionsDropdownLayoutContent = () => { const recordIndexCalendarLayout = useAtomComponentStateValue( recordIndexCalendarLayoutComponentState, ); + const isListViewEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_LIST_VIEW_ENABLED, + ); const isCalendarWeekViewEnabled = useIsFeatureEnabled( FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED, ); @@ -124,8 +127,9 @@ export const ObjectOptionsDropdownLayoutContent = () => { const selectableItemIdArray = [ ViewType.TABLE, - ...(isDefaultView ? [] : [ViewType.KANBAN]), + ...(isListViewEnabled ? [ViewType.LIST] : []), ...(!isDefaultView ? [ViewType.CALENDAR] : []), + ...(isDefaultView ? [] : [ViewType.KANBAN]), ...(currentView?.type === ViewType.KANBAN ? ['Group'] : []), ...(currentView?.type === ViewType.CALENDAR ? [ @@ -135,7 +139,10 @@ export const ObjectOptionsDropdownLayoutContent = () => { : 'CalendarDateField', ] : []), - ...(currentView?.type !== ViewType.TABLE ? ['Compact view'] : []), + ...(currentView?.type !== ViewType.TABLE && + currentView?.type !== ViewType.LIST + ? ['Compact view'] + : []), ]; const selectedItemId = useAtomComponentStateValue( @@ -181,6 +188,26 @@ export const ObjectOptionsDropdownLayoutContent = () => { }} /> + {isListViewEnabled && ( + { + setAndPersistViewType(ViewType.LIST); + }} + > + { + if (currentView?.type !== ViewType.LIST) { + await setAndPersistViewType(ViewType.LIST); + } + }} + /> + + )} { @@ -305,31 +332,32 @@ export const ObjectOptionsDropdownLayoutContent = () => { /> )} - {currentView?.type !== ViewType.TABLE && ( - { - setAndPersistIsCompactModeActive( - !isCompactModeActive, - currentView, - ); - }} - > - + {currentView?.type !== ViewType.TABLE && + currentView?.type !== ViewType.LIST && ( + { setAndPersistIsCompactModeActive( !isCompactModeActive, currentView, - ) - } - toggled={isCompactModeActive} - text={t`Compact view`} - toggleSize="small" - /> - - )} + ); + }} + > + + setAndPersistIsCompactModeActive( + !isCompactModeActive, + currentView, + ) + } + toggled={isCompactModeActive} + text={t`Compact view`} + toggleSize="small" + /> + + )} )} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts index 12987c1c62..9ed7397b52 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts @@ -73,7 +73,8 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { await updateCurrentView(updateCurrentViewParams); return; } - case ViewType.TABLE: { + case ViewType.TABLE: + case ViewType.LIST: { if (shouldChangeIcon(currentView.icon, currentView.type)) { updateCurrentViewParams.icon = viewTypeIconMapping(viewType).displayName; @@ -158,6 +159,12 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ) { return true; } + if ( + oldViewType === ViewType.LIST && + oldIcon === viewTypeIconMapping(ViewType.LIST).displayName + ) { + return true; + } return false; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx index efab2d7b59..990814b675 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx @@ -19,6 +19,7 @@ import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFi import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; export const RecordBoardCardBody = () => { const { recordId, isRecordReadOnly, isDragOverlay } = useContext( @@ -70,6 +71,10 @@ export const RecordBoardCardBody = () => { const correspondingFieldDefinition = fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId]; + if (!isDefined(correspondingFieldDefinition)) { + return null; + } + return ( { isNonInteractive={isRecordBoardViewSettingsReadOnly} inert={isRecordBoardViewSettingsReadOnly || undefined} > - void; - resetContent: () => void; - previousContentId: RecordBoardColumnHeaderAggregateContentId | null; - dropdownId: string; -}; - -export const RecordBoardColumnHeaderAggregateDropdownContext = - createContext( - {} as RecordBoardColumnHeaderAggregateDropdownContextValue, - ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdown.tsx similarity index 52% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdown.tsx index ab1cbe6430..632f4b9045 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdown.tsx @@ -1,16 +1,16 @@ import { useDropdownContextCurrentContentId } from '@/dropdown-context-state-management/hooks/useDropdownContextCurrentContentId'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; -import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext'; -import { RecordBoardColumnHeaderAggregateDropdownButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton'; -import { AggregateDropdownContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent'; -import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { type RecordBoardColumnHeaderAggregateContentId } from '@/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId'; +import { RecordGroupAggregateDropdownButton } from '@/object-record/record-group/components/RecordGroupAggregateDropdownButton'; +import { RecordGroupAggregateDropdownContent } from '@/object-record/record-group/components/RecordGroupAggregateDropdownContent'; +import { RecordGroupAggregateDropdownComponentInstanceContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext'; +import { RecordGroupAggregateDropdownContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownContext'; +import { type RecordGroupAggregateContentId } from '@/object-record/record-group/types/RecordGroupAggregateContentId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DROPDOWN_OFFSET_Y } from '@/ui/layout/dropdown/constants/DropdownOffsetY'; import { styled } from '@linaria/react'; import { type Nullable } from 'twenty-shared/types'; -type RecordBoardColumnHeaderAggregateDropdownProps = { +type RecordGroupAggregateDropdownProps = { aggregateValue?: Nullable; aggregateLabel?: Nullable; objectMetadataItem: EnrichedObjectMetadataItem; @@ -21,22 +21,21 @@ const StyledContainer = styled.div` flex-shrink: 0; `; -export const RecordBoardColumnHeaderAggregateDropdown = ({ +export const RecordGroupAggregateDropdown = ({ objectMetadataItem, aggregateValue, aggregateLabel, dropdownId, -}: RecordBoardColumnHeaderAggregateDropdownProps) => { +}: RecordGroupAggregateDropdownProps) => { const { currentContentId, handleContentChange, handleResetContent, previousContentId, - } = - useDropdownContextCurrentContentId(); + } = useDropdownContextCurrentContentId(); return ( - @@ -45,14 +44,14 @@ export const RecordBoardColumnHeaderAggregateDropdown = ({ dropdownId={dropdownId} dropdownOffset={{ y: DROPDOWN_OFFSET_Y }} clickableComponent={ - } dropdownComponents={ - - - + + } /> - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownButton.tsx similarity index 96% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownButton.tsx index 606c7f82ff..d256601ea7 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownButton.tsx @@ -17,7 +17,7 @@ const StyledHeaderContainer = styled.div` } `; -export const RecordBoardColumnHeaderAggregateDropdownButton = ({ +export const RecordGroupAggregateDropdownButton = ({ dropdownId, value, tooltip, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownContent.tsx similarity index 75% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownContent.tsx index fb85e08551..1f7aad578d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownContent.tsx @@ -1,10 +1,10 @@ import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement'; import { getNonReadableFieldMetadataIdsFromObjectPermissions } from '@/object-metadata/utils/getNonReadableFieldMetadataIdsFromObjectPermissions'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { RecordBoardColumnHeaderAggregateDropdownFieldsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent'; -import { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent'; -import { RecordBoardColumnHeaderAggregateDropdownOptionsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent'; +import { RecordGroupAggregateDropdownFieldsContent } from '@/object-record/record-group/components/RecordGroupAggregateDropdownFieldsContent'; +import { RecordGroupAggregateDropdownMenuContent } from '@/object-record/record-group/components/RecordGroupAggregateDropdownMenuContent'; +import { RecordGroupAggregateDropdownOptionsContent } from '@/object-record/record-group/components/RecordGroupAggregateDropdownOptionsContent'; +import { RecordGroupAggregateDropdownContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownContext'; import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations'; import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions'; @@ -14,10 +14,10 @@ import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-re import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { t } from '@lingui/core/macro'; -export const AggregateDropdownContent = () => { +export const RecordGroupAggregateDropdownContent = () => { const { currentContentId, objectMetadataItem } = useDropdownContextStateManagement({ - context: RecordBoardColumnHeaderAggregateDropdownContext, + context: RecordGroupAggregateDropdownContext, }); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); @@ -42,7 +42,7 @@ export const AggregateDropdownContent = () => { targetAggregateOperations: COUNT_AGGREGATE_OPERATION_OPTIONS, }); return ( - @@ -55,7 +55,7 @@ export const AggregateDropdownContent = () => { targetAggregateOperations: PERCENT_AGGREGATE_OPERATION_OPTIONS, }); return ( - @@ -71,7 +71,7 @@ export const AggregateDropdownContent = () => { ], }); return ( - @@ -84,15 +84,15 @@ export const AggregateDropdownContent = () => { targetAggregateOperations: NON_STANDARD_AGGREGATE_OPERATION_OPTIONS, }); return ( - ); } case 'aggregateFields': - return ; + return ; default: - return ; + return ; } }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownFieldsContent.tsx similarity index 88% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownFieldsContent.tsx index 4675c5fe64..5cbaf1e911 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownFieldsContent.tsx @@ -1,8 +1,8 @@ import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement'; -import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState'; -import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; +import { RecordGroupAggregateDropdownContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownContext'; +import { aggregateOperationComponentState } from '@/object-record/record-group/states/aggregateOperationComponentState'; +import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-group/states/availableFieldIdsForAggregateOperationComponentState'; import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState'; import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; @@ -15,7 +15,7 @@ import { isDefined } from 'twenty-shared/utils'; import { Icon123, IconCheck, IconChevronLeft, useIcons } from 'twenty-ui/icon'; import { MenuItem } from 'twenty-ui/navigation'; -export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => { +export const RecordGroupAggregateDropdownFieldsContent = () => { const { closeDropdown, objectMetadataItem, @@ -23,7 +23,7 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => { resetContent, previousContentId, } = useDropdownContextStateManagement({ - context: RecordBoardColumnHeaderAggregateDropdownContext, + context: RecordGroupAggregateDropdownContext, }); const { updateViewAggregate } = useUpdateViewAggregate(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuContent.tsx similarity index 74% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuContent.tsx index 59211df675..f1e08b45bb 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuContent.tsx @@ -1,21 +1,19 @@ import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement'; -import { - RecordBoardColumnHeaderAggregateDropdownContext, - type RecordBoardColumnHeaderAggregateDropdownContextValue, -} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; +import { RecordGroupAggregateDropdownContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownContext'; +import { type RecordGroupAggregateDropdownContextValue } from '@/object-record/record-group/types/RecordGroupAggregateDropdownContextValue'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useLingui } from '@lingui/react/macro'; import { MenuItem } from 'twenty-ui/navigation'; -export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => { +export const RecordGroupAggregateDropdownMenuContent = () => { const { t } = useLingui(); const { onContentChange } = - useDropdownContextStateManagement( + useDropdownContextStateManagement( { - context: RecordBoardColumnHeaderAggregateDropdownContext, + context: RecordGroupAggregateDropdownContext, }, ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuItem.tsx similarity index 86% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuItem.tsx index 1d51ddba4c..c761883cbc 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownMenuItem.tsx @@ -1,7 +1,7 @@ import { type IconComponent } from 'twenty-ui/icon'; import { MenuItem } from 'twenty-ui/navigation'; -export const RecordBoardColumnHeaderAggregateDropdownMenuItem = ({ +export const RecordGroupAggregateDropdownMenuItem = ({ onContentChange, text, hasSubMenu, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownOptionsContent.tsx similarity index 83% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx rename to packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownOptionsContent.tsx index b1366696dd..29eca7e139 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupAggregateDropdownOptionsContent.tsx @@ -1,12 +1,10 @@ import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement'; -import { - RecordBoardColumnHeaderAggregateDropdownContext, - type RecordBoardColumnHeaderAggregateDropdownContextValue, -} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem'; -import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState'; -import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; +import { RecordGroupAggregateDropdownMenuItem } from '@/object-record/record-group/components/RecordGroupAggregateDropdownMenuItem'; +import { RecordGroupAggregateDropdownContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownContext'; +import { type RecordGroupAggregateDropdownContextValue } from '@/object-record/record-group/types/RecordGroupAggregateDropdownContextValue'; +import { aggregateOperationComponentState } from '@/object-record/record-group/states/aggregateOperationComponentState'; +import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-group/states/availableFieldIdsForAggregateOperationComponentState'; import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState'; import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; @@ -21,7 +19,7 @@ import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate'; import isEmpty from 'lodash.isempty'; import { IconCheck, IconChevronLeft } from 'twenty-ui/icon'; -export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({ +export const RecordGroupAggregateDropdownOptionsContent = ({ availableAggregations, title, }: { @@ -29,9 +27,9 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({ title: string; }) => { const { onContentChange, closeDropdown, resetContent, objectMetadataItem } = - useDropdownContextStateManagement( + useDropdownContextStateManagement( { - context: RecordBoardColumnHeaderAggregateDropdownContext, + context: RecordGroupAggregateDropdownContext, }, ); @@ -69,7 +67,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({ availableAggregationOperation, availableAggregationFieldsIdsForOperation, ]) => ( - { if ( diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupSectionHeader.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupSectionHeader.tsx new file mode 100644 index 0000000000..547686aedf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupSectionHeader.tsx @@ -0,0 +1,72 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { RecordGroupChip } from '@/object-record/record-group/components/RecordGroupChip'; +import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { useContext } from 'react'; +import { IconChevronDown } from 'twenty-ui/icon'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledSectionToggle = styled.button` + align-items: center; + background: transparent; + border: none; + cursor: pointer; + display: flex; + gap: ${themeCssVariables.spacing[1]}; + height: 100%; + min-width: 0; + overflow: hidden; + padding: 0; +`; + +const StyledChevronContainer = styled.div<{ isExpanded: boolean }>` + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: center; + transform: rotate(${({ isExpanded }) => (isExpanded ? 0 : -90)}deg); +`; + +type RecordGroupSectionHeaderProps = { + recordGroupDefinition: RecordGroupDefinition; + fieldMetadataItem?: FieldMetadataItem | null; + isExpanded: boolean; + onToggle: () => void; + chevronWidth?: number; +}; + +export const RecordGroupSectionHeader = ({ + recordGroupDefinition, + fieldMetadataItem, + isExpanded, + onToggle, + chevronWidth, +}: RecordGroupSectionHeaderProps) => { + const { theme } = useContext(ThemeContext); + + return ( + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/aggregateOperationComponentState.ts similarity index 63% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-group/states/aggregateOperationComponentState.ts index ab1284914c..083c29a832 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/states/aggregateOperationComponentState.ts @@ -1,4 +1,4 @@ -import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext'; +import { RecordGroupAggregateDropdownComponentInstanceContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; @@ -7,5 +7,5 @@ export const aggregateOperationComponentState = key: 'aggregateOperationComponentFamilyState', defaultValue: null, componentInstanceContext: - RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext, + RecordGroupAggregateDropdownComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/availableFieldIdsForAggregateOperationComponentState.ts similarity index 56% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-group/states/availableFieldIdsForAggregateOperationComponentState.ts index c9fa54076e..bb7204faa0 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/states/availableFieldIdsForAggregateOperationComponentState.ts @@ -1,4 +1,4 @@ -import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext'; +import { RecordGroupAggregateDropdownComponentInstanceContext } from '@/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; export const availableFieldIdsForAggregateOperationComponentState = @@ -6,5 +6,5 @@ export const availableFieldIdsForAggregateOperationComponentState = key: 'availableFieldIdsForAggregateOperationComponentFamilyState', defaultValue: [], componentInstanceContext: - RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext, + RecordGroupAggregateDropdownComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext.ts similarity index 66% rename from packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts rename to packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext.ts index f16fbf6c4e..ef5720131b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownComponentInstanceContext.ts @@ -1,4 +1,4 @@ import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; -export const RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext = +export const RecordGroupAggregateDropdownComponentInstanceContext = createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownContext.tsx b/packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownContext.tsx new file mode 100644 index 0000000000..9e88ee2ef0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/states/context/RecordGroupAggregateDropdownContext.tsx @@ -0,0 +1,7 @@ +import { type RecordGroupAggregateDropdownContextValue } from '@/object-record/record-group/types/RecordGroupAggregateDropdownContextValue'; +import { createContext } from 'react'; + +export const RecordGroupAggregateDropdownContext = + createContext( + {} as RecordGroupAggregateDropdownContextValue, + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateContentId.ts similarity index 78% rename from packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts rename to packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateContentId.ts index d342eade68..e02b817f88 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateContentId.ts @@ -1,4 +1,4 @@ -export type RecordBoardColumnHeaderAggregateContentId = +export type RecordGroupAggregateContentId = | 'aggregateOperations' | 'aggregateFields' | 'countAggregateOperationsOptions' diff --git a/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateDropdownContextValue.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateDropdownContextValue.ts new file mode 100644 index 0000000000..af5fdbd802 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupAggregateDropdownContextValue.ts @@ -0,0 +1,11 @@ +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { type RecordGroupAggregateContentId } from '@/object-record/record-group/types/RecordGroupAggregateContentId'; + +export type RecordGroupAggregateDropdownContextValue = { + objectMetadataItem: EnrichedObjectMetadataItem; + currentContentId: RecordGroupAggregateContentId | null; + onContentChange: (key: RecordGroupAggregateContentId) => void; + resetContent: () => void; + previousContentId: RecordGroupAggregateContentId | null; + dropdownId: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index d1cdc9e9a4..3848536dfc 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -8,6 +8,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordIndexCalendarContainer } from '@/object-record/record-index/components/RecordIndexCalendarContainer'; +import { RecordIndexListContainer } from '@/object-record/record-index/components/RecordIndexListContainer'; import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared'; import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect'; import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields'; @@ -67,6 +68,14 @@ export const RecordIndexContainer = () => { /> )} + {recordIndexViewType === ViewType.LIST && ( + + + + )} )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexListContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexListContainer.tsx new file mode 100644 index 0000000000..cc1571cd9b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexListContainer.tsx @@ -0,0 +1,45 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper'; +import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { RecordList } from '@/object-record/record-list/components/RecordList'; +import { RecordListSSESubscribeEffect } from '@/object-record/record-list/components/RecordListSSESubscribeEffect'; +import { RecordListContextProvider } from '@/object-record/record-list/contexts/RecordListContext'; + +type RecordIndexListContainerProps = { + recordListInstanceId: string; + viewBarInstanceId: string; +}; + +export const RecordIndexListContainer = ({ + recordListInstanceId, + viewBarInstanceId, +}: RecordIndexListContainerProps) => { + const { objectNameSingular } = useRecordIndexContextOrThrow(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectPermissions = useObjectPermissionsForObject( + objectMetadataItem.id, + ); + + return ( + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts index 91deff9c6a..60427ea020 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts @@ -19,6 +19,7 @@ export const useRecordIndexTableQuery = (objectNameSingular: string) => { hasNextPage, queryIdentifier, loading, + error, totalCount, fetchMoreRecords, } = useFindManyRecords({ @@ -29,6 +30,7 @@ export const useRecordIndexTableQuery = (objectNameSingular: string) => { return { records, loading, + error, hasNextPage, queryIdentifier, totalCount, diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordList.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordList.tsx new file mode 100644 index 0000000000..4ca8a14e21 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordList.tsx @@ -0,0 +1,46 @@ +import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector'; +import { RecordListBody } from '@/object-record/record-list/components/RecordListBody'; +import { RecordListFieldTooltip } from '@/object-record/record-list/components/RecordListFieldTooltip'; +import { RecordListRecordGroupsBody } from '@/object-record/record-list/components/RecordListRecordGroupsBody'; +import { RecordListResponsiveFieldCountEffect } from '@/object-record/record-list/components/RecordListResponsiveFieldCountEffect'; +import { RecordListComponentInstanceContext } from '@/object-record/record-list/states/contexts/RecordListComponentInstanceContext'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { styled } from '@linaria/react'; +import { useRef } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + padding: ${themeCssVariables.spacing[2]}; + padding-left: ${themeCssVariables.spacing[1]}; +`; + +export const RecordList = () => { + const recordListId = useAvailableComponentInstanceIdOrThrow( + RecordListComponentInstanceContext, + ); + + const hasRecordGroups = useAtomComponentSelectorValue( + hasRecordGroupsComponentSelector, + ); + + const containerRef = useRef(null); + + return ( + + + + {hasRecordGroups ? : } + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListAddNew.tsx new file mode 100644 index 0000000000..87df7a6a4f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListAddNew.tsx @@ -0,0 +1,110 @@ +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; +import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; +import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; +import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { IconPlus } from 'twenty-ui/icon'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { logError } from '~/utils/logError'; + +const StyledAddNewRow = styled.button` + align-items: center; + background: transparent; + border: none; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.tertiary}; + cursor: pointer; + display: flex; + font-size: ${themeCssVariables.font.size.md}; + gap: ${themeCssVariables.spacing[1]}; + height: 24px; + padding: 0 6px; + text-align: left; + + &:hover { + background: ${themeCssVariables.background.transparent.lighter}; + } +`; + +export const RecordListAddNew = () => { + const { theme } = useContext(ThemeContext); + const { objectMetadataItem } = useRecordListContextOrThrow(); + const { enqueueErrorSnackBar } = useSnackBar(); + + const { createNewIndexRecord } = useCreateNewIndexRecord({ + objectMetadataItem, + }); + + const currentRecordGroupDefinition = useCurrentRecordGroupDefinition(); + + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const groupFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === recordIndexGroupFieldMetadataItem?.id, + ); + + const objectPermissions = useObjectPermissionsForObject( + objectMetadataItem.id, + ); + + const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue( + hasAnySoftDeleteFilterOnViewComponentSelector, + ); + + if (hasAnySoftDeleteFilterOnView) { + return null; + } + + if ( + !canCreateRecordsForObjectMetadataItem({ + objectPermissions, + objectMetadataItem, + }) + ) { + return null; + } + + const handleCreateRecord = async () => { + try { + const groupFieldValues = + isDefined(currentRecordGroupDefinition) && + isDefined(groupFieldMetadataItem) + ? { + [getFieldMetadataItemGqlFieldName(groupFieldMetadataItem)]: + currentRecordGroupDefinition.value, + } + : {}; + + await createNewIndexRecord({ + position: 'last', + ...groupFieldValues, + }); + } catch (error) { + logError(error); + enqueueErrorSnackBar({ message: t`Failed to create record` }); + } + }; + + return ( + + + {t`Add new`} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListBody.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListBody.tsx new file mode 100644 index 0000000000..9a09cfc2fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListBody.tsx @@ -0,0 +1,28 @@ +import { useRecordIndexTableQuery } from '@/object-record/record-index/hooks/useRecordIndexTableQuery'; +import { RecordListRecords } from '@/object-record/record-list/components/RecordListRecords'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { styled } from '@linaria/react'; + +const StyledBody = styled.div` + display: flex; + flex-direction: column; +`; + +export const RecordListBody = () => { + const { objectNameSingular } = useRecordListContextOrThrow(); + + const { records, loading, error, hasNextPage, fetchMoreRecords } = + useRecordIndexTableQuery(objectNameSingular); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListEmptyRecordGroupEffect.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListEmptyRecordGroupEffect.tsx new file mode 100644 index 0000000000..470109875f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListEmptyRecordGroupEffect.tsx @@ -0,0 +1,48 @@ +import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; +import { emptyRecordGroupByIdComponentFamilyState } from '@/object-record/record-group/states/emptyRecordGroupByIdComponentFamilyState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordListEmptyRecordGroupEffectProps = { + loading: boolean; + error?: Error; + records: ObjectRecord[]; +}; + +export const RecordListEmptyRecordGroupEffect = ({ + loading, + error, + records, +}: RecordListEmptyRecordGroupEffectProps) => { + const currentRecordGroupId = useCurrentRecordGroupId(); + + const [, setEmptyRecordGroupById] = useAtomComponentFamilyState( + emptyRecordGroupByIdComponentFamilyState, + currentRecordGroupId, + ); + + const [, setRecordIndexRecordIdsByGroup] = useAtomComponentFamilyState( + recordIndexRecordIdsByGroupComponentFamilyState, + currentRecordGroupId, + ); + + useEffect(() => { + if (loading || isDefined(error)) { + return; + } + + setRecordIndexRecordIdsByGroup(records.map((record) => record.id)); + setEmptyRecordGroupById(records.length === 0); + }, [ + loading, + error, + records, + setEmptyRecordGroupById, + setRecordIndexRecordIdsByGroup, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListFieldTooltip.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListFieldTooltip.tsx new file mode 100644 index 0000000000..1b61f014e8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListFieldTooltip.tsx @@ -0,0 +1,69 @@ +import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { RECORD_LIST_ROW_FIELD_ANCHOR_CLASS_NAME } from '@/object-record/record-list/constants/RecordListRowFieldAnchorClassName'; +import { recordListHoveredFieldMetadataItemIdComponentState } from '@/object-record/record-list/states/recordListHoveredFieldMetadataItemIdComponentState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { styled } from '@linaria/react'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/icon'; +import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledTooltipContent = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +export const RecordListFieldTooltip = () => { + const { theme } = useContext(ThemeContext); + const { getIcon } = useIcons(); + + const { fieldDefinitionByFieldMetadataItemId } = + useRecordIndexContextOrThrow(); + + const recordListHoveredFieldMetadataItemId = useAtomComponentStateValue( + recordListHoveredFieldMetadataItemIdComponentState, + ); + + const visibleRecordFields = useAtomComponentSelectorValue( + visibleRecordFieldsComponentSelector, + ); + + const visibleRecordFieldIds = visibleRecordFields + .map((recordField) => recordField.fieldMetadataItemId) + .join('-'); + + const hoveredFieldDefinition = isDefined(recordListHoveredFieldMetadataItemId) + ? fieldDefinitionByFieldMetadataItemId[recordListHoveredFieldMetadataItemId] + : undefined; + + const FieldIcon = isDefined(hoveredFieldDefinition) + ? getIcon(hoveredFieldDefinition.iconName) + : null; + + return ( + + {isDefined(hoveredFieldDefinition) && ( + + {isDefined(FieldIcon) && ( + + )} + {hoveredFieldDefinition.label} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroup.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroup.tsx new file mode 100644 index 0000000000..588ed4eae6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroup.tsx @@ -0,0 +1,55 @@ +import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; +import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; +import { useRecordIndexTableQuery } from '@/object-record/record-index/hooks/useRecordIndexTableQuery'; +import { RecordListEmptyRecordGroupEffect } from '@/object-record/record-list/components/RecordListEmptyRecordGroupEffect'; +import { RecordListRecordGroupSection } from '@/object-record/record-list/components/RecordListRecordGroupSection'; +import { RecordListRecords } from '@/object-record/record-list/components/RecordListRecords'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { isRecordListGroupSectionToggledComponentState } from '@/object-record/record-list/states/isRecordListGroupSectionToggledComponentState'; +import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; +import { styled } from '@linaria/react'; + +const StyledSection = styled.div` + display: flex; + flex-direction: column; + padding-bottom: 14px; +`; + +export const RecordListRecordGroup = () => { + const { objectNameSingular } = useRecordListContextOrThrow(); + + const currentRecordGroupId = useCurrentRecordGroupId(); + + const shouldHideRecordGroup = useShouldHideRecordGroup(currentRecordGroupId); + + const { records, loading, error, hasNextPage, fetchMoreRecords } = + useRecordIndexTableQuery(objectNameSingular); + + const isRecordListGroupSectionToggled = useAtomComponentFamilyStateValue( + isRecordListGroupSectionToggledComponentState, + currentRecordGroupId, + ); + + if (shouldHideRecordGroup) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupSection.tsx new file mode 100644 index 0000000000..b9a7f84cb6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupSection.tsx @@ -0,0 +1,78 @@ +import { RecordGroupAggregateDropdown } from '@/object-record/record-group/components/RecordGroupAggregateDropdown'; +import { RecordGroupSectionHeader } from '@/object-record/record-group/components/RecordGroupSectionHeader'; +import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; +import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState'; +import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { isRecordListGroupSectionToggledComponentState } from '@/object-record/record-list/states/isRecordListGroupSectionToggledComponentState'; +import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState'; +import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; +import { styled } from '@linaria/react'; +import { isDefined } from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledSectionHeader = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + height: 32px; + margin-bottom: 2px; +`; + +export const RecordListRecordGroupSection = () => { + const { objectMetadataItem } = useRecordListContextOrThrow(); + + const currentRecordGroupId = useCurrentRecordGroupId(); + + const recordGroupDefinition = useAtomFamilyStateValue( + recordGroupDefinitionFamilyState, + currentRecordGroupId, + ); + + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const recordIndexAggregateDisplayValueForGroupValue = + useAtomComponentFamilyStateValue( + recordIndexAggregateDisplayValueForGroupValueComponentFamilyState, + { groupValue: recordGroupDefinition?.value ?? '' }, + ); + + const recordIndexAggregateDisplayLabel = useAtomComponentStateValue( + recordIndexAggregateDisplayLabelComponentState, + ); + + const [isRecordListGroupSectionToggled, setIsRecordListGroupSectionToggled] = + useAtomComponentFamilyState( + isRecordListGroupSectionToggledComponentState, + currentRecordGroupId, + ); + + if (!isDefined(recordGroupDefinition)) { + return null; + } + + return ( + + + setIsRecordListGroupSectionToggled((prevState) => !prevState) + } + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupsBody.tsx new file mode 100644 index 0000000000..4e64806ea8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecordGroupsBody.tsx @@ -0,0 +1,27 @@ +import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader'; +import { RecordListRecordGroup } from '@/object-record/record-list/components/RecordListRecordGroup'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { ViewType } from '@/views/types/ViewType'; + +export const RecordListRecordGroupsBody = () => { + const visibleRecordGroupIds = useAtomComponentFamilySelectorValue( + visibleRecordGroupIdsComponentFamilySelector, + ViewType.LIST, + ); + + return ( + <> + {visibleRecordGroupIds.map((recordGroupId) => ( + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecords.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecords.tsx new file mode 100644 index 0000000000..4d42e8104b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRecords.tsx @@ -0,0 +1,54 @@ +import { RecordListAddNew } from '@/object-record/record-list/components/RecordListAddNew'; +import { RecordListRow } from '@/object-record/record-list/components/RecordListRow'; +import { RecordListUpsertRecordsInStoreEffect } from '@/object-record/record-list/components/RecordListUpsertRecordsInStoreEffect'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { styled } from '@linaria/react'; +import { useInView } from 'react-intersection-observer'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledFetchMoreTrigger = styled.div` + height: 0; +`; + +type RecordListRecordsProps = { + records: ObjectRecord[]; + loading: boolean; + error?: Error; + hasNextPage: boolean; + fetchMoreRecords: () => void; + isVisible?: boolean; +}; + +export const RecordListRecords = ({ + records, + loading, + error, + hasNextPage, + fetchMoreRecords, + isVisible = true, +}: RecordListRecordsProps) => { + const { ref: fetchMoreRef } = useInView({ + onChange: (inView) => { + if (inView && hasNextPage && !loading) { + fetchMoreRecords(); + } + }, + }); + + return ( + <> + + {isVisible && !isDefined(error) && ( + <> + {records.map((record) => ( + + ))} + {hasNextPage && !loading && ( + + )} + {!loading && } + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListResponsiveFieldCountEffect.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListResponsiveFieldCountEffect.tsx new file mode 100644 index 0000000000..04794365f9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListResponsiveFieldCountEffect.tsx @@ -0,0 +1,41 @@ +import { recordListDisplayedFieldCountComponentState } from '@/object-record/record-list/states/recordListDisplayedFieldCountComponentState'; +import { computeRecordListDisplayedFieldCount } from '@/object-record/record-list/utils/computeRecordListDisplayedFieldCount'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { type RefObject, useLayoutEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordListResponsiveFieldCountEffectProps = { + containerRef: RefObject; +}; + +export const RecordListResponsiveFieldCountEffect = ({ + containerRef, +}: RecordListResponsiveFieldCountEffectProps) => { + const setRecordListDisplayedFieldCount = useSetAtomComponentState( + recordListDisplayedFieldCountComponentState, + ); + + useLayoutEffect(() => { + const containerElement = containerRef.current; + + if (!isDefined(containerElement)) { + return; + } + + const updateDisplayedFieldCount = () => { + setRecordListDisplayedFieldCount( + computeRecordListDisplayedFieldCount(containerElement.clientWidth), + ); + }; + + updateDisplayedFieldCount(); + + const resizeObserver = new ResizeObserver(updateDisplayedFieldCount); + + resizeObserver.observe(containerElement); + + return () => resizeObserver.disconnect(); + }, [containerRef, setRecordListDisplayedFieldCount]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRow.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRow.tsx new file mode 100644 index 0000000000..679bdacff2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRow.tsx @@ -0,0 +1,185 @@ +import { RecordChip } from '@/object-record/components/RecordChip'; +import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer'; +import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; +import { isFieldValueEmpty } from '@/object-record/record-field/ui/utils/isFieldValueEmpty'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView'; +import { RecordListRowField } from '@/object-record/record-list/components/RecordListRowField'; +import { RECORD_LIST_ROW_LABEL_IDENTIFIER_WIDTH } from '@/object-record/record-list/constants/RecordListRowLabelIdentifierWidth'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { recordListDisplayedFieldCountComponentState } from '@/object-record/record-list/states/recordListDisplayedFieldCountComponentState'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { ChipVariant } from 'twenty-ui/data-display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledRowContainer = styled.div` + cursor: pointer; + padding-bottom: 2px; + + &:hover > div { + background: ${themeCssVariables.background.transparent.lighter}; + } + + &:active > div { + background: ${themeCssVariables.accent.quaternary}; + } +`; + +const StyledRow = styled.div` + align-items: center; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + gap: ${themeCssVariables.spacing[3]}; + height: 32px; + justify-content: space-between; + padding: 0 6px; +`; + +const StyledRecordChipContainer = styled.div` + display: flex; + min-width: ${RECORD_LIST_ROW_LABEL_IDENTIFIER_WIDTH}px; + overflow: hidden; +`; + +const StyledFieldsContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[3]}; + justify-content: flex-end; + overflow: hidden; +`; + +const StyledHiddenFieldCountChip = styled.div` + align-items: center; + background: ${themeCssVariables.background.transparent.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex-shrink: 0; + font-size: ${themeCssVariables.font.size.md}; + gap: 2px; + height: 20px; + padding: 0 ${themeCssVariables.spacing[1]}; + + &::before { + color: ${themeCssVariables.font.color.secondary}; + content: '+'; + font-size: ${themeCssVariables.font.size.sm}; + } +`; + +type RecordListRowProps = { + recordId: string; +}; + +export const RecordListRow = ({ recordId }: RecordListRowProps) => { + const { objectNameSingular } = useRecordListContextOrThrow(); + const { + labelIdentifierFieldMetadataItem, + fieldDefinitionByFieldMetadataItemId, + } = useRecordIndexContextOrThrow(); + + const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId); + + const visibleRecordFields = useAtomComponentSelectorValue( + visibleRecordFieldsComponentSelector, + ); + + const recordListDisplayedFieldCount = useAtomComponentStateValue( + recordListDisplayedFieldCountComponentState, + ); + + const { openRecordFromIndexView } = useOpenRecordFromIndexView(); + + if (!isDefined(recordStore)) { + return null; + } + + const visibleRecordFieldsExceptLabelIdentifier = visibleRecordFields.filter( + (recordField) => + recordField.fieldMetadataItemId !== labelIdentifierFieldMetadataItem?.id, + ); + + const nonEmptyRecordFields = visibleRecordFieldsExceptLabelIdentifier.flatMap( + (recordField) => { + const fieldDefinition = + fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId]; + + if ( + !isDefined(fieldDefinition) || + isFieldValueEmpty({ + fieldDefinition, + fieldValue: recordStore[fieldDefinition.metadata.fieldName], + }) + ) { + return []; + } + + return [{ recordField, fieldDefinition }]; + }, + ); + + const displayedRecordFields = nonEmptyRecordFields.slice( + 0, + recordListDisplayedFieldCount, + ); + + const hiddenFieldCount = + nonEmptyRecordFields.length - displayedRecordFields.length; + + const openRecord = () => openRecordFromIndexView({ recordId }); + + return ( + { + if (event.target !== event.currentTarget) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openRecord(); + } + }} + > + + + + + + + + {displayedRecordFields.map(({ recordField, fieldDefinition }) => ( + + ))} + {hiddenFieldCount > 0 && ( + + {hiddenFieldCount} + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRowField.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRowField.tsx new file mode 100644 index 0000000000..bdeda95fcf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListRowField.tsx @@ -0,0 +1,75 @@ +import { type RecordField } from '@/object-record/record-field/types/RecordField'; +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { RECORD_LIST_ROW_FIELD_ANCHOR_CLASS_NAME } from '@/object-record/record-list/constants/RecordListRowFieldAnchorClassName'; +import { RECORD_LIST_ROW_FIELD_MAX_WIDTH } from '@/object-record/record-list/constants/RecordListRowFieldMaxWidth'; +import { RECORD_LIST_ROW_INPUT_ID_PREFIX } from '@/object-record/record-list/constants/RecordListRowInputIdPrefix'; +import { recordListHoveredFieldMetadataItemIdComponentState } from '@/object-record/record-list/states/recordListHoveredFieldMetadataItemIdComponentState'; +import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { styled } from '@linaria/react'; + +const StyledFieldContainer = styled.div` + align-items: center; + display: flex; + flex-shrink: 0; + max-width: ${RECORD_LIST_ROW_FIELD_MAX_WIDTH}px; + overflow: hidden; + + & > * { + pointer-events: none; + } +`; + +type RecordListRowFieldProps = { + recordId: string; + recordField: RecordField; + fieldDefinition: ColumnDefinition; +}; + +export const RecordListRowField = ({ + recordId, + recordField, + fieldDefinition, +}: RecordListRowFieldProps) => { + const setRecordListHoveredFieldMetadataItemId = useSetAtomComponentState( + recordListHoveredFieldMetadataItemIdComponentState, + ); + + return ( + + setRecordListHoveredFieldMetadataItemId(recordField.fieldMetadataItemId) + } + > + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListSSESubscribeEffect.tsx new file mode 100644 index 0000000000..a3ab570a10 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListSSESubscribeEffect.tsx @@ -0,0 +1,38 @@ +import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords'; +import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; +import { useRecordListContextOrThrow } from '@/object-record/record-list/contexts/RecordListContext'; +import { RecordListComponentInstanceContext } from '@/object-record/record-list/states/contexts/RecordListComponentInstanceContext'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; + +export const RecordListSSESubscribeEffect = () => { + const recordListId = useAvailableComponentInstanceIdOrThrow( + RecordListComponentInstanceContext, + ); + + const { objectNameSingular, objectMetadataItem } = + useRecordListContextOrThrow(); + + const { filter, orderBy } = + useFindManyRecordIndexTableParams(objectNameSingular); + + const { refetchFindManyRecords } = useRefetchFindManyRecords({ + objectMetadataNamePlural: objectMetadataItem.namePlural, + }); + + const queryId = `record-list-${recordListId}`; + + useListenToEventsForQuery({ + queryId, + operationSignature: { + objectNameSingular: objectMetadataItem.nameSingular, + variables: { + filter, + orderBy, + }, + }, + onSseReconnected: refetchFindManyRecords, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/components/RecordListUpsertRecordsInStoreEffect.tsx b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListUpsertRecordsInStoreEffect.tsx new file mode 100644 index 0000000000..83cc58ff2a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/components/RecordListUpsertRecordsInStoreEffect.tsx @@ -0,0 +1,19 @@ +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useEffect } from 'react'; + +type RecordListUpsertRecordsInStoreEffectProps = { + records: ObjectRecord[]; +}; + +export const RecordListUpsertRecordsInStoreEffect = ({ + records, +}: RecordListUpsertRecordsInStoreEffectProps) => { + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + useEffect(() => { + upsertRecordsInStore({ partialRecords: records }); + }, [records, upsertRecordsInStore]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldAnchorClassName.ts b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldAnchorClassName.ts new file mode 100644 index 0000000000..1cc950c744 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldAnchorClassName.ts @@ -0,0 +1,2 @@ +export const RECORD_LIST_ROW_FIELD_ANCHOR_CLASS_NAME = + 'record-list-row-field-anchor'; diff --git a/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldMaxWidth.ts b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldMaxWidth.ts new file mode 100644 index 0000000000..63a7a47b4b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowFieldMaxWidth.ts @@ -0,0 +1 @@ +export const RECORD_LIST_ROW_FIELD_MAX_WIDTH = 156; diff --git a/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowInputIdPrefix.ts b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowInputIdPrefix.ts new file mode 100644 index 0000000000..65cb9221ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowInputIdPrefix.ts @@ -0,0 +1 @@ +export const RECORD_LIST_ROW_INPUT_ID_PREFIX = 'record-list-row'; diff --git a/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowLabelIdentifierWidth.ts b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowLabelIdentifierWidth.ts new file mode 100644 index 0000000000..990d163f38 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowLabelIdentifierWidth.ts @@ -0,0 +1 @@ +export const RECORD_LIST_ROW_LABEL_IDENTIFIER_WIDTH = 176; diff --git a/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowVisibleFieldLimit.ts b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowVisibleFieldLimit.ts new file mode 100644 index 0000000000..2814d95432 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/constants/RecordListRowVisibleFieldLimit.ts @@ -0,0 +1 @@ +export const RECORD_LIST_ROW_VISIBLE_FIELD_LIMIT = 6; diff --git a/packages/twenty-front/src/modules/object-record/record-list/contexts/RecordListContext.ts b/packages/twenty-front/src/modules/object-record/record-list/contexts/RecordListContext.ts new file mode 100644 index 0000000000..a6873283d6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/contexts/RecordListContext.ts @@ -0,0 +1,13 @@ +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { type ObjectPermission } from '~/generated-metadata/graphql'; +import { createRequiredContext } from '~/utils/createRequiredContext'; + +type RecordListContextValue = { + viewBarInstanceId: string; + objectNameSingular: string; + objectMetadataItem: EnrichedObjectMetadataItem; + objectPermissions: ObjectPermission; +}; + +export const [RecordListContextProvider, useRecordListContextOrThrow] = + createRequiredContext('RecordListContext'); diff --git a/packages/twenty-front/src/modules/object-record/record-list/states/contexts/RecordListComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-list/states/contexts/RecordListComponentInstanceContext.ts new file mode 100644 index 0000000000..0cbe3b2b62 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/states/contexts/RecordListComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const RecordListComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-list/states/isRecordListGroupSectionToggledComponentState.ts b/packages/twenty-front/src/modules/object-record/record-list/states/isRecordListGroupSectionToggledComponentState.ts new file mode 100644 index 0000000000..a66bb49c17 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/states/isRecordListGroupSectionToggledComponentState.ts @@ -0,0 +1,10 @@ +import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const isRecordListGroupSectionToggledComponentState = + createAtomComponentFamilyState({ + key: 'isRecordListGroupSectionToggledComponentState', + defaultValue: true, + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-list/states/recordListDisplayedFieldCountComponentState.ts b/packages/twenty-front/src/modules/object-record/record-list/states/recordListDisplayedFieldCountComponentState.ts new file mode 100644 index 0000000000..27b5b329e0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/states/recordListDisplayedFieldCountComponentState.ts @@ -0,0 +1,10 @@ +import { RECORD_LIST_ROW_VISIBLE_FIELD_LIMIT } from '@/object-record/record-list/constants/RecordListRowVisibleFieldLimit'; +import { RecordListComponentInstanceContext } from '@/object-record/record-list/states/contexts/RecordListComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +export const recordListDisplayedFieldCountComponentState = + createAtomComponentState({ + key: 'recordListDisplayedFieldCountComponentState', + defaultValue: RECORD_LIST_ROW_VISIBLE_FIELD_LIMIT, + componentInstanceContext: RecordListComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-list/states/recordListHoveredFieldMetadataItemIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-list/states/recordListHoveredFieldMetadataItemIdComponentState.ts new file mode 100644 index 0000000000..05d33bb8c1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/states/recordListHoveredFieldMetadataItemIdComponentState.ts @@ -0,0 +1,9 @@ +import { RecordListComponentInstanceContext } from '@/object-record/record-list/states/contexts/RecordListComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +export const recordListHoveredFieldMetadataItemIdComponentState = + createAtomComponentState({ + key: 'recordListHoveredFieldMetadataItemIdComponentState', + defaultValue: null, + componentInstanceContext: RecordListComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-list/utils/__tests__/computeRecordListDisplayedFieldCount.test.ts b/packages/twenty-front/src/modules/object-record/record-list/utils/__tests__/computeRecordListDisplayedFieldCount.test.ts new file mode 100644 index 0000000000..fb5a60de51 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/utils/__tests__/computeRecordListDisplayedFieldCount.test.ts @@ -0,0 +1,16 @@ +import { computeRecordListDisplayedFieldCount } from '@/object-record/record-list/utils/computeRecordListDisplayedFieldCount'; + +describe('computeRecordListDisplayedFieldCount', () => { + it('returns zero when only the record label and hidden count fit', () => { + expect(computeRecordListDisplayedFieldCount(407)).toBe(0); + }); + + it('accounts for row padding when calculating field breakpoints', () => { + expect(computeRecordListDisplayedFieldCount(408)).toBe(1); + expect(computeRecordListDisplayedFieldCount(576)).toBe(2); + }); + + it('caps the displayed field count at the visible field limit', () => { + expect(computeRecordListDisplayedFieldCount(10_000)).toBe(6); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-list/utils/computeRecordListDisplayedFieldCount.ts b/packages/twenty-front/src/modules/object-record/record-list/utils/computeRecordListDisplayedFieldCount.ts new file mode 100644 index 0000000000..68b30d464a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-list/utils/computeRecordListDisplayedFieldCount.ts @@ -0,0 +1,29 @@ +import { RECORD_LIST_ROW_FIELD_MAX_WIDTH } from '@/object-record/record-list/constants/RecordListRowFieldMaxWidth'; +import { RECORD_LIST_ROW_LABEL_IDENTIFIER_WIDTH } from '@/object-record/record-list/constants/RecordListRowLabelIdentifierWidth'; +import { RECORD_LIST_ROW_VISIBLE_FIELD_LIMIT } from '@/object-record/record-list/constants/RecordListRowVisibleFieldLimit'; + +const ROW_HORIZONTAL_PADDING_WIDTH = 12; +const ROW_ITEM_GAP_WIDTH = 12; +const HIDDEN_FIELD_COUNT_CHIP_WIDTH = 40; + +export const computeRecordListDisplayedFieldCount = ( + containerWidth: number, +) => { + const reservedWidth = + ROW_HORIZONTAL_PADDING_WIDTH + + RECORD_LIST_ROW_LABEL_IDENTIFIER_WIDTH + + ROW_ITEM_GAP_WIDTH + + HIDDEN_FIELD_COUNT_CHIP_WIDTH; + + const fieldWidthWithGap = + RECORD_LIST_ROW_FIELD_MAX_WIDTH + ROW_ITEM_GAP_WIDTH; + + const fittingFieldCount = Math.floor( + (containerWidth - reservedWidth) / fieldWidthWithGap, + ); + + return Math.min( + RECORD_LIST_ROW_VISIBLE_FIELD_LIMIT, + Math.max(0, fittingFieldCount), + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector.ts index a2b401b194..6171db9496 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector.ts @@ -4,7 +4,7 @@ import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/ut export const multipleRecordPickerIsSelectedComponentFamilySelector = createAtomComponentFamilySelector({ - key: 'visibleRecordGroupIdsComponentFamilySelector', + key: 'multipleRecordPickerIsSelectedComponentFamilySelector', componentInstanceContext: MultipleRecordPickerComponentInstanceContext, get: ({ instanceId, familyKey: recordId }) => diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx index 5a605bdfea..0bff1ed6b4 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx @@ -1,10 +1,9 @@ import { styled } from '@linaria/react'; -import { useCallback } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; -import { RecordGroupChip } from '@/object-record/record-group/components/RecordGroupChip'; +import { RecordGroupAggregateDropdown } from '@/object-record/record-group/components/RecordGroupAggregateDropdown'; +import { RecordGroupSectionHeader } from '@/object-record/record-group/components/RecordGroupSectionHeader'; import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; @@ -36,12 +35,9 @@ import { isDefined, sumByProperty, } from 'twenty-shared/utils'; -import { IconChevronDown } from 'twenty-ui/icon'; -import { AnimatedLightIconButton } from 'twenty-ui/input'; import { useIsMobile } from 'twenty-ui/utilities'; const StyledTrContainer = styled.div` - cursor: pointer; display: flex; flex-direction: row; @@ -50,48 +46,22 @@ const StyledTrContainer = styled.div` } `; -const StyledChevronContainer = styled.div` - border-right: none; - color: ${themeCssVariables.font.color.secondary}; - display: flex; - left: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; - min-width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; - position: sticky; - text-align: center; - - vertical-align: middle; - width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; - - z-index: ${TABLE_Z_INDEX.groupSection.stickyCell}; -`; - -const StyledAnimatedLightIconButtonContainer = styled.div` - display: block; - margin: auto; - - z-index: ${TABLE_Z_INDEX.groupSection.stickyCell}; -`; - -const StyledRecordGroupSection = styled.div<{ width: number }>` +const StyledRecordGroupHeaderContainer = styled.div<{ width: number }>` align-items: center; border-right: none; display: flex; flex-direction: row; gap: ${themeCssVariables.spacing[1]}; height: ${RECORD_TABLE_ROW_HEIGHT}px; - left: ${`${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px`}; - min-width: ${({ width }) => width}px; + left: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; + min-width: ${({ width }) => width + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; position: sticky; - width: ${({ width }) => width}px; + width: ${({ width }) => width + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; z-index: ${TABLE_Z_INDEX.groupSection.stickyCell}; `; -const StyledTagContainer = styled.div` - flex-shrink: 0; -`; - const StyledAggregateDropdownContainer = styled.div<{ isNonInteractive: boolean; }>` @@ -177,10 +147,6 @@ export const RecordTableRecordGroupSection = () => { isRecordTableCellsNonEditableComponentState, ); - const handleDropdownToggle = useCallback(() => { - setIsRecordGroupTableSectionToggled((prevState) => !prevState); - }, [setIsRecordGroupTableSectionToggled]); - const visibleRecordFieldsWithoutLabelIdentifier = visibleRecordFields.filter( filterOutByProperty( 'fieldMetadataItemId', @@ -206,41 +172,33 @@ export const RecordTableRecordGroupSection = () => { } return ( - + - - - - - - - - - + + setIsRecordGroupTableSectionToggled((prevState) => !prevState) + } + chevronWidth={RECORD_TABLE_COLUMN_CHECKBOX_WIDTH} + /> - - + diff --git a/packages/twenty-front/src/modules/views/types/ViewType.ts b/packages/twenty-front/src/modules/views/types/ViewType.ts index bf14615d1a..d59a868d02 100644 --- a/packages/twenty-front/src/modules/views/types/ViewType.ts +++ b/packages/twenty-front/src/modules/views/types/ViewType.ts @@ -4,6 +4,7 @@ import { IconCalendar, type IconComponent, IconLayoutKanban, + IconList, IconTable, } from 'twenty-ui/icon'; @@ -16,6 +17,7 @@ export const VIEW_TYPE_LABELS = { [ViewType.TABLE]: msg`Table`, [ViewType.KANBAN]: msg`Kanban`, [ViewType.CALENDAR]: msg`Calendar`, + [ViewType.LIST]: msg`List`, [ViewType.FIELDS_WIDGET]: msg`Fields widget`, [ViewType.TABLE_WIDGET]: msg`Table widget`, [ViewType.KANBAN_WIDGET]: msg`Kanban widget`, @@ -31,6 +33,7 @@ const VIEW_TYPE_ICON_MAPPING = [ { icon: IconLayoutKanban, value: ViewType.KANBAN }, { icon: IconTable, value: ViewType.TABLE }, { icon: IconCalendar, value: ViewType.CALENDAR }, + { icon: IconList, value: ViewType.LIST }, ] as const satisfies { icon: IconComponent; value: ViewType; diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx index f8ded3baf8..9dfbd10835 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx @@ -61,6 +61,9 @@ export const ViewPickerContentCreateMode = () => { const isCalendarWeekViewEnabled = useIsFeatureEnabled( FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED, ); + const isListViewEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_LIST_VIEW_ENABLED, + ); const viewObjectMetadataId = useAtomComponentStateValue( viewObjectMetadataIdComponentState, @@ -200,7 +203,9 @@ export const ViewPickerContentCreateMode = () => { setViewPickerIsDirty(true); setViewPickerType(value); }} - options={VIEW_PICKER_TYPE_SELECT_OPTIONS.map((option) => ({ + options={VIEW_PICKER_TYPE_SELECT_OPTIONS.filter( + (option) => option.value !== ViewType.LIST || isListViewEnabled, + ).map((option) => ({ ...option, label: t(option.label), }))} diff --git a/packages/twenty-front/src/modules/views/view-picker/constants/ViewPickerTypeSelectOptions.ts b/packages/twenty-front/src/modules/views/view-picker/constants/ViewPickerTypeSelectOptions.ts index 4c8bbffd15..1420f4ac02 100644 --- a/packages/twenty-front/src/modules/views/view-picker/constants/ViewPickerTypeSelectOptions.ts +++ b/packages/twenty-front/src/modules/views/view-picker/constants/ViewPickerTypeSelectOptions.ts @@ -20,4 +20,9 @@ export const VIEW_PICKER_TYPE_SELECT_OPTIONS = [ label: VIEW_TYPE_LABELS[ViewType.CALENDAR], Icon: viewTypeIconMapping(ViewType.CALENDAR), }, + { + value: ViewType.LIST, + label: VIEW_TYPE_LABELS[ViewType.LIST], + Icon: viewTypeIconMapping(ViewType.LIST), + }, ]; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-30/2-30-instance-command-fast-1785929606728-add-list-view-type.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-30/2-30-instance-command-fast-1785929606728-add-list-view-type.ts new file mode 100644 index 0000000000..13c2f90f39 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-30/2-30-instance-command-fast-1785929606728-add-list-view-type.ts @@ -0,0 +1,35 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.30.0', 1785929606728) +export class AddListViewTypeFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "core"."view_type_enum" ADD VALUE IF NOT EXISTS 'LIST' AFTER 'CALENDAR'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "core"."view" SET "type" = 'TABLE' WHERE "type" = 'LIST'`, + ); + await queryRunner.query( + "CREATE TYPE \"core\".\"view_type_enum_old\" AS ENUM('TABLE', 'KANBAN', 'CALENDAR', 'FIELDS_WIDGET', 'TABLE_WIDGET', 'KANBAN_WIDGET', 'CALENDAR_WIDGET')", + ); + await queryRunner.query( + 'ALTER TABLE "core"."view" ALTER COLUMN "type" DROP DEFAULT', + ); + await queryRunner.query( + 'ALTER TABLE "core"."view" ALTER COLUMN "type" TYPE "core"."view_type_enum_old" USING "type"::"text"::"core"."view_type_enum_old"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."view" ALTER COLUMN "type" SET DEFAULT \'TABLE\'', + ); + await queryRunner.query('DROP TYPE "core"."view_type_enum"'); + await queryRunner.query( + 'ALTER TYPE "core"."view_type_enum_old" RENAME TO "view_type_enum"', + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 05dcfbad14..768ac3cbb6 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -135,6 +135,7 @@ import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/d import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata'; import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table'; import { CreateApplicationAuthorizationCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table'; +import { AddListViewTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-30/2-30-instance-command-fast-1785929606728-add-list-view-type'; import { AddCallRecordingWidgetTypesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-29/2-29-instance-command-fast-1786009374684-add-call-recording-widget-types'; export const INSTANCE_COMMANDS = [ @@ -273,5 +274,6 @@ export const INSTANCE_COMMANDS = [ AddOpenRecordInToObjectMetadataFastInstanceCommand, CreateUserSessionCoreTableFastInstanceCommand, CreateApplicationAuthorizationCoreTableFastInstanceCommand, + AddListViewTypeFastInstanceCommand, AddCallRecordingWidgetTypesFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts index 847c4016d1..f081eac35b 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -28,6 +28,14 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ 'Enable many-to-many relations through junction tables configuration', }, }, + { + key: FeatureFlagKey.IS_LIST_VIEW_ENABLED, + metadata: { + label: 'List View', + description: + 'Display records in a list layout with collapsible groups and inline fields', + }, + }, { key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED, metadata: { diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts index a5b551cf8d..86352b1d9b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts @@ -30,6 +30,7 @@ import { const CREATABLE_VIEW_TYPES = [ ViewType.TABLE, + ViewType.LIST, ViewType.KANBAN, ViewType.CALENDAR, ViewType.TABLE_WIDGET, @@ -671,7 +672,7 @@ DECLARATIVE CHILDREN (replace semantics): fields, filters, and sorts each descri - Omitting the key leaves existing entries untouched. This means you never need to fetch child ids to edit a view — just pass the desired end state. For surgical single-entry edits, the granular tools (create_view_filter, update_view_sort, etc.) remain available. -VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT field), CALENDAR (requires calendarFieldName + calendarLayout).`, +VIEW TYPES: TABLE (default), LIST, KANBAN (requires mainGroupByFieldName, a SELECT field), CALENDAR (requires calendarFieldName + calendarLayout).`, inputSchema: UpsertCompleteViewInputSchema, execute: async (parameters: { id?: string; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 935b924e94..8ec193d46c 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -245,6 +245,7 @@ describe('WorkspaceEntityManager', () => { IS_CALENDAR_WEEK_VIEW_ENABLED: false, IS_EMAIL_GROUP_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, + IS_LIST_VIEW_ENABLED: false, IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false, IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false, IS_SETTINGS_DISCOVERY_HERO_ENABLED: false, diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index 46d9899ca4..2c6521e3f3 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -5,6 +5,7 @@ export enum FeatureFlagKey { IS_CALENDAR_WEEK_VIEW_ENABLED = 'IS_CALENDAR_WEEK_VIEW_ENABLED', IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', + IS_LIST_VIEW_ENABLED = 'IS_LIST_VIEW_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', diff --git a/packages/twenty-shared/src/types/ViewType.ts b/packages/twenty-shared/src/types/ViewType.ts index df6d1b19e3..c36ce999b0 100644 --- a/packages/twenty-shared/src/types/ViewType.ts +++ b/packages/twenty-shared/src/types/ViewType.ts @@ -2,6 +2,7 @@ export enum ViewType { TABLE = 'TABLE', KANBAN = 'KANBAN', CALENDAR = 'CALENDAR', + LIST = 'LIST', FIELDS_WIDGET = 'FIELDS_WIDGET', TABLE_WIDGET = 'TABLE_WIDGET', KANBAN_WIDGET = 'KANBAN_WIDGET', diff --git a/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts b/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts index 419e3e0a7d..7cd7de1579 100644 --- a/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts +++ b/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts @@ -6,6 +6,7 @@ const VIEW_TYPE_TO_LAYOUT_MAPPING: Record = { [ViewType.TABLE]: ViewType.TABLE, [ViewType.KANBAN]: ViewType.KANBAN, [ViewType.CALENDAR]: ViewType.CALENDAR, + [ViewType.LIST]: ViewType.LIST, [ViewType.FIELDS_WIDGET]: ViewType.FIELDS_WIDGET, [ViewType.TABLE_WIDGET]: ViewType.TABLE, [ViewType.KANBAN_WIDGET]: ViewType.KANBAN,