Skip to content

Commit

Permalink
Add no value column on Kanban (#6252)
Browse files Browse the repository at this point in the history
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/9fcdd5ca-4329-467c-ada8-4dd5d45be259">

Open questions:
- the Tag component does not match Figma in term of style and API for
"transparent" | "outline". We need to discuss with @Bonapara what is the
desired behavior here
- right now opportunity.stage is not nullable. We need to discuss with
@FelixMalfait and @Bonapara what we want here. I would advocate to make
a it nullable for now until we introduce settings on select fields.
custom select are nullable and it could be confusing for the user

Follow up:
- enhance tests on Tags
- add story to cover the No Value column on record board
  • Loading branch information
charlesBochet authored Jul 15, 2024
1 parent aed0bf4 commit 2cd624a
Show file tree
Hide file tree
Showing 18 changed files with 269 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
targetFieldMetadataName:
field.relationDefinition?.targetFieldMetadata?.name ?? '',
options: field.options,
isNullable: field.isNullable,
};

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useContext, useRef } from 'react';
import styled from '@emotion/styled';
import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
import { useContext, useRef } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useContext, useState } from 'react';
import styled from '@emotion/styled';
import { useContext, useState } from 'react';
import { IconDotsVertical, Tag } from 'twenty-ui';

import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';

Expand Down Expand Up @@ -79,14 +80,23 @@ export const RecordBoardColumnHeader = () => {
>
<Tag
onClick={handleBoardColumnMenuOpen}
color={columnDefinition.color}
variant={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
/>
{!!boardColumnTotal && <StyledAmount>${boardColumnTotal}</StyledAmount>}
{!isHeaderHovered && (
<StyledNumChildren>{recordCount}</StyledNumChildren>
)}
{isHeaderHovered && (
{isHeaderHovered && columnDefinition.actions.length > 0 && (
<StyledHeaderActions>
<LightIconButton
accent="tertiary"
Expand All @@ -96,7 +106,7 @@ export const RecordBoardColumnHeader = () => {
</StyledHeaderActions>
)}
</StyledHeader>
{isBoardColumnMenuOpen && (
{isBoardColumnMenuOpen && columnDefinition.actions.length > 0 && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
stageId={columnDefinition.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,30 @@ import { ThemeColor } from 'twenty-ui';

import { RecordBoardColumnAction } from '@/object-record/record-board/types/RecordBoardColumnAction';

export type RecordBoardColumnDefinition = {
export const enum RecordBoardColumnDefinitionType {
Value = 'value',
NoValue = 'no-value',
}

export type RecordBoardColumnDefinitionNoValue = {
id: 'no-value';
type: RecordBoardColumnDefinitionType.NoValue;
title: 'No Value';
position: number;
value: null;
actions: RecordBoardColumnAction[];
};

export type RecordBoardColumnDefinitionValue = {
id: string;
type: RecordBoardColumnDefinitionType.Value;
title: string;
value: string;
position: number;
color: ThemeColor;
position: number;
actions: RecordBoardColumnAction[];
};

export type RecordBoardColumnDefinition =
| RecordBoardColumnDefinitionValue
| RecordBoardColumnDefinitionNoValue;
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
FieldSelectMetadata,
FieldTextMetadata,
} from '@/object-record/record-field/types/FieldMetadata';
import { type } from 'os';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
mockedCompanyObjectMetadataItem,
Expand Down Expand Up @@ -44,6 +43,7 @@ export const selectFieldDefinition: FieldDefinition<FieldSelectMetadata> = {
metadata: {
fieldName: 'accountOwner',
options: [{ label: 'Elon Musk', color: 'blue', value: 'userId' }],
isNullable: true,
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';

import { useClearField } from '@/object-record/record-field/hooks/useClearField';
Expand Down Expand Up @@ -98,14 +98,16 @@ export const SelectFieldInput = ({
<DropdownMenuSeparator />

<DropdownMenuItemsContainer hasMaxHeight>
<MenuItemSelectTag
key={`No ${fieldDefinition.label}`}
selected={false}
text={`No ${fieldDefinition.label}`}
color="transparent"
variant="outline"
onClick={handleClearField}
/>
{fieldDefinition.metadata.isNullable && (
<MenuItemSelectTag
key={`No ${fieldDefinition.label}`}
selected={false}
text={`No ${fieldDefinition.label}`}
color="transparent"
variant="outline"
onClick={handleClearField}
/>
)}

{optionsInDropDown.map((option) => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export type FieldSelectMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
options: { label: string; color: ThemeColor; value: string }[];
isNullable: boolean;
};

export type FieldMultiSelectMetadata = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const RecordIndexBoardColumnLoaderEffect = ({
}: {
recordBoardId: string;
objectNameSingular: string;
boardFieldSelectValue: string;
boardFieldSelectValue: string | null;
boardFieldMetadataId: string | null;
columnId: string;
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export const RecordIndexBoardDataLoader = ({
columnId={columnIds[index]}
/>
))}
{recordIndexKanbanFieldMetadataItem?.isNullable && (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
boardFieldMetadataId={recordIndexKanbanFieldMetadataId}
boardFieldSelectValue={null}
recordBoardId={recordBoardId}
columnId={'no-value'}
/>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hook
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from '~/utils/isDefined';

type UseLoadRecordIndexBoardProps = {
objectNameSingular: string;
boardFieldMetadataId: string | null;
recordBoardId: string;
columnFieldSelectValue: string;
columnFieldSelectValue: string | null;
columnId: string;
};

Expand Down Expand Up @@ -51,9 +52,11 @@ export const useLoadRecordIndexBoardColumn = ({

const filter = {
...requestFilters,
[recordIndexKanbanFieldMetadataItem?.name ?? '']: {
in: [columnFieldSelectValue],
},
[recordIndexKanbanFieldMetadataItem?.name ?? '']: isDefined(
columnFieldSelectValue,
)
? { in: [columnFieldSelectValue] }
: { is: 'NULL' },
};

const {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { IconPencil } from 'twenty-ui';

import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import {
RecordBoardColumnDefinition,
RecordBoardColumnDefinitionNoValue,
RecordBoardColumnDefinitionType,
RecordBoardColumnDefinitionValue,
} from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { FieldMetadataType } from '~/generated-metadata/graphql';

export const computeRecordBoardColumnDefinitionsFromObjectMetadata = (
Expand All @@ -25,20 +30,42 @@ export const computeRecordBoardColumnDefinitionsFromObjectMetadata = (
);
}

return selectFieldMetadataItem.options.map((selectOption) => ({
id: selectOption.id,
title: selectOption.label,
value: selectOption.value,
color: selectOption.color,
position: selectOption.position,
actions: [
{
id: 'edit',
label: 'Edit from settings',
icon: IconPencil,
position: 0,
callback: navigateToSelectSettings,
},
],
}));
const valueColumns = selectFieldMetadataItem.options.map(
(selectOption) =>
({
id: selectOption.id,
type: RecordBoardColumnDefinitionType.Value,
title: selectOption.label,
value: selectOption.value,
color: selectOption.color,
position: selectOption.position,
actions: [
{
id: 'edit',
label: 'Edit from settings',
icon: IconPencil,
position: 0,
callback: navigateToSelectSettings,
},
],
}) satisfies RecordBoardColumnDefinitionValue,
);

const noValueColumn = {
id: 'no-value',
title: 'No Value',
type: RecordBoardColumnDefinitionType.NoValue,
value: null,
actions: [],
position:
selectFieldMetadataItem.options
.map((option) => option.position)
.reduce((a, b) => Math.max(a, b), 0) + 1,
} satisfies RecordBoardColumnDefinitionNoValue;

if (selectFieldMetadataItem.isNullable === true) {
return [...valueColumns, noValueColumn];
}

return valueColumns;
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export const ViewBar = ({
const sortDropdownId = 'view-sort';

const loading = useIsPrefetchLoading();

if (!objectNamePlural) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { isUndefined } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';

import { useViewStates } from '@/views/hooks/internal/useViewStates';
Expand Down
11 changes: 7 additions & 4 deletions packages/twenty-front/src/testing/decorators/PageDecorator.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ApolloProvider } from '@apollo/client';
import { loadDevMessages } from '@apollo/client/dev';
import { Decorator } from '@storybook/react';
import { HelmetProvider } from 'react-helmet-async';
import {
createMemoryRouter,
Expand All @@ -6,9 +9,6 @@ import {
Route,
RouterProvider,
} from 'react-router-dom';
import { ApolloProvider } from '@apollo/client';
import { loadDevMessages } from '@apollo/client/dev';
import { Decorator } from '@storybook/react';
import { RecoilRoot } from 'recoil';

import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
Expand All @@ -21,6 +21,7 @@ import { DefaultLayout } from '~/modules/ui/layout/page/DefaultLayout';
import { UserProvider } from '~/modules/users/components/UserProvider';
import { mockedApolloClient } from '~/testing/mockedApolloClient';

import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout';

export type PageDecoratorArgs = {
Expand Down Expand Up @@ -73,7 +74,9 @@ const Providers = () => {
<HelmetProvider>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<ObjectMetadataItemsProvider>
<Outlet />
<PrefetchDataProvider>
<Outlet />
</PrefetchDataProvider>
</ObjectMetadataItemsProvider>
</SnackBarProviderScope>
</HelmetProvider>
Expand Down
51 changes: 51 additions & 0 deletions packages/twenty-front/src/testing/graphqlMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,57 @@ export const graphqlMocks = {
},
});
}),
graphql.query('CombinedFindManyRecords', () => {
return HttpResponse.json({
data: {
views: {
edges: mockedViewsData.map((view) => ({
node: {
...view,
viewFilters: {
edges: [],
totalCount: 0,
},
viewSorts: {
edges: [],
totalCount: 0,
},
viewFields: {
edges: mockedViewFieldsData
.filter((viewField) => viewField.viewId === view.id)
.map((viewField) => ({
node: viewField,
cursor: null,
})),
totalCount: mockedViewFieldsData.filter(
(viewField) => viewField.viewId === view.id,
).length,
},
},
cursor: null,
})),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
totalCount: mockedViewsData.length,
},
totalCount: mockedViewsData.length,
},
},
favorites: {
edges: [],
totalCount: 0,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
});
}),
graphql.query('FindManyCompanies', ({ variables }) => {
const mockedData = variables.limit
? companiesMock.slice(0, variables.limit)
Expand Down
Loading

0 comments on commit 2cd624a

Please sign in to comment.