diff --git a/packages/twenty-front/src/modules/accounts/types/BlocklistItem.ts b/packages/twenty-front/src/modules/accounts/types/BlocklistItem.ts index 0b16357b7098..2d65802e1024 100644 --- a/packages/twenty-front/src/modules/accounts/types/BlocklistItem.ts +++ b/packages/twenty-front/src/modules/accounts/types/BlocklistItem.ts @@ -1,6 +1,9 @@ +import { BlocklistItemScope } from '@/settings/accounts/types/BlocklistItemScope'; + export type BlocklistItem = { id: string; handle: string; + scopes?: BlocklistItemScope[]; workspaceMemberId: string; createdAt: string; __typename: 'BlocklistItem'; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistSection.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistSection.tsx deleted file mode 100644 index 259b3f87ae2b..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistSection.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useRecoilValue } from 'recoil'; -import { H2Title, Section } from 'twenty-ui'; - -import { BlocklistItem } from '@/accounts/types/BlocklistItem'; -import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { SettingsAccountsBlocklistInput } from '@/settings/accounts/components/SettingsAccountsBlocklistInput'; -import { SettingsAccountsBlocklistTable } from '@/settings/accounts/components/SettingsAccountsBlocklistTable'; - -export const SettingsAccountsBlocklistSection = () => { - const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - - const { records: blocklist } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Blocklist, - }); - - const { createOneRecord: createBlocklistItem } = - useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Blocklist, - }); - - const { deleteOneRecord: deleteBlocklistItem } = useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.Blocklist, - }); - - const handleBlockedEmailRemove = (id: string) => { - deleteBlocklistItem(id); - }; - - const updateBlockedEmailList = (handle: string) => { - createBlocklistItem({ - handle, - workspaceMemberId: currentWorkspaceMember?.id, - }); - }; - - return ( -
- - item.handle)} - updateBlockedEmailList={updateBlockedEmailList} - /> - -
- ); -}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistSection.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistSection.stories.tsx deleted file mode 100644 index bc3f9cb721e1..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistSection.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { ComponentDecorator } from 'twenty-ui'; - -import { SettingsAccountsBlocklistInput } from '@/settings/accounts/components/SettingsAccountsBlocklistInput'; -import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/SettingsAccountsBlocklistSection'; - -const meta: Meta = { - title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistSection', - component: SettingsAccountsBlocklistInput, - decorators: [ComponentDecorator], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistContainer.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistContainer.stories.tsx new file mode 100644 index 000000000000..0f93ddc3ee5f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistContainer.stories.tsx @@ -0,0 +1,34 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { mockedBlocklist } from '@/settings/accounts/components/blocklist/__stories__/mockedBlocklist'; +import { SettingsAccountsBlocklistContactRow } from '@/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContactRow'; +import { Meta, StoryFn } from '@storybook/react'; + +export default { + title: 'Settings/Accounts/BlocklistContactRow', + component: SettingsAccountsBlocklistContactRow, + argTypes: { + item: { + control: 'object', + description: 'Blocklist item data to display', + }, + }, +} as Meta; + +const Template: StoryFn = ( + args, +) => ; + +export const Default = Template.bind({}); +Default.args = { + item: undefined, +}; + +export const WithItem = Template.bind({}); +WithItem.args = { + item: mockedBlocklist[0], +}; + +export const UpdatingItem = Template.bind({}); +UpdatingItem.args = { + item: mockedBlocklist[2], +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistDropdownComponent.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistDropdownComponent.stories.tsx new file mode 100644 index 000000000000..805c1725459b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingAccountsBlocklistDropdownComponent.stories.tsx @@ -0,0 +1,86 @@ +import { Decorator, Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; + +import { SettingsAccountsBlocklistDropdownComponent } from '@/settings/accounts/components/blocklist/components/SettingAccountsBlocklistDropdownComponent'; +import { BLOCKLIST_SCOPE_DROPDOWN_ITEMS } from '@/settings/accounts/constants/BlocklistScopeDropdownItems'; + +const handleMultiSelectChangeJestFn = fn(); +const setDropdownSearchTextJestFn = fn(); + +const ClearMocksDecorator: Decorator = (Story, context) => { + if (context.parameters.clearMocks === true) { + handleMultiSelectChangeJestFn.mockClear(); + setDropdownSearchTextJestFn.mockClear(); + } + return ; +}; + +const meta: Meta = { + title: + 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistDropdownComponent', + component: SettingsAccountsBlocklistDropdownComponent, + decorators: [ClearMocksDecorator], + args: { + handleMultiSelectChange: handleMultiSelectChangeJestFn, + setDropdownSearchText: setDropdownSearchTextJestFn, + dropdownSearchText: '', + selectedBlocklistScopes: [], + }, + argTypes: { + handleMultiSelectChange: { control: false }, + setDropdownSearchText: { control: false }, + }, + parameters: { + clearMocks: true, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SearchAndSelect: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(handleMultiSelectChangeJestFn).toHaveBeenCalledTimes(0); + expect(setDropdownSearchTextJestFn).toHaveBeenCalledTimes(0); + + const searchInput = canvas.getByPlaceholderText('Search'); + await userEvent.type(searchInput, 'domain'); + expect(setDropdownSearchTextJestFn).toHaveBeenCalledWith('domain'); + + const firstItem = canvas.getByText(BLOCKLIST_SCOPE_DROPDOWN_ITEMS[0].label); + await userEvent.click(firstItem); + expect(handleMultiSelectChangeJestFn).toHaveBeenCalledWith( + BLOCKLIST_SCOPE_DROPDOWN_ITEMS[0].id, + ); + }, +}; + +export const SelectMultipleItems: Story = { + args: { + selectedBlocklistScopes: [BLOCKLIST_SCOPE_DROPDOWN_ITEMS[0].id], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(handleMultiSelectChangeJestFn).toHaveBeenCalledTimes(0); + + const secondItem = canvas.getByText( + BLOCKLIST_SCOPE_DROPDOWN_ITEMS[1].label, + ); + await userEvent.click(secondItem); + expect(handleMultiSelectChangeJestFn).toHaveBeenCalledWith( + BLOCKLIST_SCOPE_DROPDOWN_ITEMS[1].id, + ); + + const thirdItem = canvas.getByText(BLOCKLIST_SCOPE_DROPDOWN_ITEMS[2].label); + await userEvent.click(thirdItem); + expect(handleMultiSelectChangeJestFn).toHaveBeenCalledWith( + BLOCKLIST_SCOPE_DROPDOWN_ITEMS[2].id, + ); + }, +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistInput.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistInput.stories.tsx similarity index 96% rename from packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistInput.stories.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistInput.stories.tsx index 48a52896b524..8032d566a753 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistInput.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistInput.stories.tsx @@ -2,7 +2,7 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; -import { SettingsAccountsBlocklistInput } from '@/settings/accounts/components/SettingsAccountsBlocklistInput'; +import { SettingsAccountsBlocklistInput } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistInput'; const updateBlockedEmailListJestFn = fn(); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistSection.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistSection.stories.tsx new file mode 100644 index 000000000000..cd5552f4b336 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistSection.stories.tsx @@ -0,0 +1,77 @@ +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { mockedBlocklist } from '@/settings/accounts/components/blocklist/__stories__/mockedBlocklist'; +import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistSection'; +import { Decorator, Meta, StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; +import { ComponentDecorator } from 'twenty-ui'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: jest.fn(), +})); + +const mockUseFindManyRecords = useFindManyRecords as jest.Mock; + +const ClearMocksDecorator: Decorator = (Story, context) => { + if (Boolean(context.parameters.clearMocks) === true) { + mockUseFindManyRecords.mockClear(); + } + return ; +}; + +const meta: Meta = { + title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistSection', + component: SettingsAccountsBlocklistSection, + decorators: [ComponentDecorator, ClearMocksDecorator], + parameters: { + clearMocks: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const title = canvas.getByText('Blocklist'); + expect(title).toBeInTheDocument(); + + const description = canvas.getByText( + 'Exclude the following people and domains from my email sync', + ); + expect(description).toBeInTheDocument(); + }, +}; + +export const WithBlocklistItems: Story = { + parameters: { + clearMocks: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + mockUseFindManyRecords.mockReturnValue({ records: mockedBlocklist }); + + mockedBlocklist.forEach((item) => { + const blocklistItem = canvas.getByText(item.handle); + expect(blocklistItem).toBeInTheDocument(); + }); + }, +}; + +export const EmptyBlocklist: Story = { + parameters: { + clearMocks: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + mockUseFindManyRecords.mockReturnValue({ records: [] }); + + mockedBlocklist.forEach((item) => { + const blocklistItem = canvas.queryByText(item.handle); + expect(blocklistItem).not.toBeInTheDocument(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTable.stories.tsx similarity index 95% rename from packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTable.stories.tsx index 71126ba4e285..74fc00a0e302 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTable.stories.tsx @@ -2,8 +2,8 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; -import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist'; -import { SettingsAccountsBlocklistTable } from '@/settings/accounts/components/SettingsAccountsBlocklistTable'; +import { mockedBlocklist } from '@/settings/accounts/components/blocklist/__stories__/mockedBlocklist'; +import { SettingsAccountsBlocklistTable } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTable'; import { formatToHumanReadableDate } from '~/utils/date-utils'; const handleBlockedEmailRemoveJestFn = fn(); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx index b4a1991e65b4..f4efe104ec9d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx @@ -2,8 +2,8 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; -import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist'; -import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/SettingsAccountsBlocklistTableRow'; +import { mockedBlocklist } from '@/settings/accounts/components/blocklist/__stories__/mockedBlocklist'; +import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTableRow'; import { formatToHumanReadableDate } from '~/utils/date-utils'; const onRemoveJestFn = fn(); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/mockedBlocklist.ts similarity index 78% rename from packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/mockedBlocklist.ts index a03fa5207c52..0d659df5bcd0 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/__stories__/mockedBlocklist.ts @@ -1,6 +1,7 @@ import { DateTime } from 'luxon'; import { BlocklistItem } from '@/accounts/types/BlocklistItem'; +import { BlocklistItemScope } from '@/settings/accounts/types/BlocklistItemScope'; export const mockedBlocklist: BlocklistItem[] = [ { @@ -22,6 +23,7 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: 'test3@twenty.com', workspaceMemberId: '1', createdAt: DateTime.now().minus({ days: 3 }).toISO() ?? '', + scopes: [BlocklistItemScope.CC], __typename: 'BlocklistItem', }, { @@ -29,6 +31,11 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: '@twenty.com', workspaceMemberId: '1', createdAt: DateTime.now().minus({ days: 4 }).toISO() ?? '', + scopes: [ + BlocklistItemScope.BCC, + BlocklistItemScope.CC, + BlocklistItemScope.FROM_TO, + ], __typename: 'BlocklistItem', }, ]; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContactRow.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContactRow.tsx new file mode 100644 index 000000000000..8c5cb21ab9f6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContactRow.tsx @@ -0,0 +1,278 @@ +import styled from '@emotion/styled'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { Key } from 'ts-key-enum'; + +import { BlocklistItem } from '@/accounts/types/BlocklistItem'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { SettingsAccountsBlocklistDropdownComponent } from '@/settings/accounts/components/blocklist/components/SettingAccountsBlocklistDropdownComponent'; +import { useValidateForm } from '@/settings/accounts/components/blocklist/hooks/useValidateForm'; +import { BLOCKLIST_CONTEXT_DROPDOWN_ID } from '@/settings/accounts/constants/BlocklistContextDropdownId'; +import { BLOCKLIST_SCOPE_DROPDOWN_ITEMS } from '@/settings/accounts/constants/BlocklistScopeDropdownItems'; +import { BlocklistItemScope } from '@/settings/accounts/types/BlocklistItemScope'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useRecoilValue } from 'recoil'; +import { IconChevronDown, IconTrash } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; + +type SettingsAccountsBlocklistContactRowProps = { + item?: BlocklistItem; +}; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledLinkContainer = styled.div` + flex: 1; +`; + +const StyledEmptyBox = styled.div` + width: ${({ theme }) => theme.spacing(8)}; +`; + +const StyledRemoveButton = styled(IconTrash)` + color: ${({ theme }) => theme.font.color.light}; + cursor: pointer; + height: ${({ theme }) => theme.spacing(4)}; + width: ${({ theme }) => theme.spacing(4)}; + margin-left: ${({ theme }) => theme.spacing(2)}; + margin-right: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledClickableComponent = styled.div` + align-items: center; + cursor: pointer; + display: flex; + justify-content: space-between; + position: relative; +`; + +const StyledInputButton = styled(TextInput)` + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: 4px; +`; + +const StyledIconChevronDown = styled(IconChevronDown)` + color: ${({ theme }) => theme.font.color.light}; + position: absolute; + height: 16px; + width: 16px; + right: 8px; + top: 50%; + transform: translateY(-50%); +`; + +type FormInput = { + emailOrDomain: string; +}; + +export const SettingsAccountsBlocklistContactRow = ({ + item, +}: SettingsAccountsBlocklistContactRowProps) => { + const [dropdownSearchText, setDropdownSearchText] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + const [dropdownValue, setDropdownValue] = useState( + !item?.id + ? '' + : !item?.scopes || + item?.scopes.length === BLOCKLIST_SCOPE_DROPDOWN_ITEMS.length + ? BlocklistItemScope.ALL + : item?.scopes?.join(', '), + ); + + const [selectedBlocklistScopes, setSelectedBlocklistScopes] = useState< + BlocklistItemScope[] + >(item?.scopes || []); + + const { validationSchema } = useValidateForm(); + + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const { records: blocklist } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Blocklist, + }); + + const { createOneRecord: createBlocklistItem } = + useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Blocklist, + }); + + const { deleteOneRecord: deleteBlocklistItem } = useDeleteOneRecord({ + objectNameSingular: CoreObjectNameSingular.Blocklist, + }); + + const { updateOneRecord: updateBlocklistEmail } = + useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Blocklist, + }); + + const handleBlockedEmailRemove = (id: string) => { + deleteBlocklistItem(id); + }; + + const addNewBlockedEmail = (contact: BlocklistItem) => { + createBlocklistItem({ + scopes: contact.scopes, + handle: contact.handle, + workspaceMemberId: currentWorkspaceMember?.id, + }); + }; + + const updateBlockedEmail = (contact: BlocklistItem) => { + updateBlocklistEmail({ + idToUpdate: contact.id, + updateOneRecordInput: { + scopes: contact.scopes, + }, + }); + }; + + const { reset, handleSubmit, control, formState } = useForm({ + mode: 'onSubmit', + resolver: zodResolver(validationSchema(blocklist)), + defaultValues: { + emailOrDomain: '', + }, + }); + + const submit = handleSubmit((data) => { + addNewBlockedEmail({ + scopes: selectedBlocklistScopes, + handle: data.emailOrDomain, + } as BlocklistItem); + }); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === Key.Enter) { + setSelectedBlocklistScopes([]); + setDropdownValue(''); + submit(); + } + }; + + const { isSubmitSuccessful } = formState; + + useEffect(() => { + if (isSubmitSuccessful) { + reset(); + } + }, [isSubmitSuccessful, reset]); + + const handleMultiSelectChange = (id: BlocklistItemScope) => { + const getNewselectedBlocklistScopes = (prev: BlocklistItemScope[]) => { + if (!prev || !prev.length) { + return [id]; + } + + if (prev.includes(id)) { + return prev.filter((item) => item !== id); + } + + return [...prev, id]; + }; + + const newselectedBlocklistScopes = getNewselectedBlocklistScopes( + selectedBlocklistScopes, + ); + + setSelectedBlocklistScopes(newselectedBlocklistScopes); + + setDropdownValue( + newselectedBlocklistScopes.length === + BLOCKLIST_SCOPE_DROPDOWN_ITEMS.length + ? BlocklistItemScope.ALL + : newselectedBlocklistScopes.join(', '), + ); + }; + + const resetDropdownSearchText = () => { + setDropdownSearchText(''); + }; + + const handleOnDropdownClickOutside = () => { + resetDropdownSearchText(); + if (isDefined(item?.id) && isUpdating) { + updateBlockedEmail({ + id: item?.id as string, + handle: item?.handle as string, + scopes: selectedBlocklistScopes, + } as BlocklistItem); + } + }; + + return ( +
+ + + + + + } + dropdownComponents={ + + } + onClickOutside={handleOnDropdownClickOutside} + onClose={() => + item?.id ? setIsUpdating(false) : resetDropdownSearchText() + } + onOpen={() => + item?.id ? setIsUpdating(true) : resetDropdownSearchText() + } + /> + + ( + + )} + /> + + {item?.id ? ( + handleBlockedEmailRemove(item.id)} + /> + ) : ( + + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContainer.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContainer.tsx new file mode 100644 index 000000000000..f1f8f0037887 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContainer.tsx @@ -0,0 +1,29 @@ +import { BlocklistItem } from '@/accounts/types/BlocklistItem'; +import { SettingsAccountsBlocklistContactRow } from '@/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContactRow'; +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +type SettingAccountsBlocklistContainerProps = { + blocklist: BlocklistItem[]; +}; + +export const SettingAccountsBlocklistContainer = ({ + blocklist, +}: SettingAccountsBlocklistContainerProps) => { + return ( + + + {blocklist.map((blocklistItem) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistDropdownComponent.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistDropdownComponent.tsx new file mode 100644 index 000000000000..98f8518446b8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingAccountsBlocklistDropdownComponent.tsx @@ -0,0 +1,43 @@ +import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect'; +import { BLOCKLIST_SCOPE_DROPDOWN_ITEMS } from '@/settings/accounts/constants/BlocklistScopeDropdownItems'; +import { BlocklistItemScope } from '@/settings/accounts/types/BlocklistItemScope'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { MenuItemMultiSelect } from 'twenty-ui'; + +type SettingsAccountsBlocklistDropdownComponentProps = { + handleMultiSelectChange: (id: BlocklistItemScope) => void; + dropdownSearchText: string; + setDropdownSearchText: (text: string) => void; + selectedBlocklistScopes: BlocklistItemScope[]; +}; + +export const SettingsAccountsBlocklistDropdownComponent = ({ + handleMultiSelectChange, + setDropdownSearchText, + dropdownSearchText, + selectedBlocklistScopes, +}: SettingsAccountsBlocklistDropdownComponentProps) => { + return ( + + ) => { + setDropdownSearchText(event.target.value.toLowerCase()); + }} + /> + {BLOCKLIST_SCOPE_DROPDOWN_ITEMS.filter((item) => + item.label.toLowerCase().includes(dropdownSearchText), + ).map((item) => ( + handleMultiSelectChange(item.id)} + text={item.label} + selected={selectedBlocklistScopes.includes(item.id)} + className={''} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistInput.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistInput.tsx diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistSection.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistSection.tsx new file mode 100644 index 000000000000..de549f80a560 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistSection.tsx @@ -0,0 +1,21 @@ +import { BlocklistItem } from '@/accounts/types/BlocklistItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { SettingAccountsBlocklistContainer } from '@/settings/accounts/components/blocklist/components/SettingAccountsBlocklistContainer'; +import { H2Title, Section } from 'twenty-ui'; + +export const SettingsAccountsBlocklistSection = () => { + const { records: blocklist } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Blocklist, + }); + + return ( +
+ + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistTable.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTable.tsx similarity index 95% rename from packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistTable.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTable.tsx index 3d513dc1eff4..3cb3003b655e 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistTable.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTable.tsx @@ -1,5 +1,5 @@ import { BlocklistItem } from '@/accounts/types/BlocklistItem'; -import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/SettingsAccountsBlocklistTableRow'; +import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTableRow'; import { Table } from '@/ui/layout/table/components/Table'; import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistTableRow.tsx b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTableRow.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistTableRow.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistTableRow.tsx diff --git a/packages/twenty-front/src/modules/settings/accounts/components/blocklist/hooks/useValidateForm.ts b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/hooks/useValidateForm.ts new file mode 100644 index 000000000000..9441d4dfd04f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/blocklist/hooks/useValidateForm.ts @@ -0,0 +1,31 @@ +import { BlocklistItem } from '@/accounts/types/BlocklistItem'; +import { z } from 'zod'; +import { isDomain } from '~/utils/is-domain'; + +export const useValidateForm = () => { + const validationSchema = (blocklist: BlocklistItem[]) => + z + .object({ + emailOrDomain: z + .string() + .trim() + .email('Invalid email or domain') + .or( + z + .string() + .refine( + (value) => value.startsWith('@') && isDomain(value.slice(1)), + 'Invalid email or domain', + ), + ) + .refine( + (value) => !blocklist.map((item) => item.handle).includes(value), + 'Email or domain is already in blocklist', + ), + }) + .required(); + + return { + validationSchema, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistContextDropdownId.ts b/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistContextDropdownId.ts new file mode 100644 index 000000000000..bde819193287 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistContextDropdownId.ts @@ -0,0 +1 @@ +export const BLOCKLIST_CONTEXT_DROPDOWN_ID = 'blocklist-context-dropdown'; diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistScopeDropdownItems.ts b/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistScopeDropdownItems.ts new file mode 100644 index 000000000000..a49ef20f676b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/constants/BlocklistScopeDropdownItems.ts @@ -0,0 +1,21 @@ +import { BlocklistItemScope } from '@/settings/accounts/types/BlocklistItemScope'; + +type BlocklistScopeDropdownItem = { + id: BlocklistItemScope; + label: string; +}; + +export const BLOCKLIST_SCOPE_DROPDOWN_ITEMS: BlocklistScopeDropdownItem[] = [ + { + id: BlocklistItemScope.FROM_TO, + label: 'From/To', + }, + { + id: BlocklistItemScope.CC, + label: 'Cc', + }, + { + id: BlocklistItemScope.BCC, + label: 'Bcc', + }, +]; diff --git a/packages/twenty-front/src/modules/settings/accounts/types/BlocklistItemScope.ts b/packages/twenty-front/src/modules/settings/accounts/types/BlocklistItemScope.ts new file mode 100644 index 000000000000..fb9e9c4f7de7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/types/BlocklistItemScope.ts @@ -0,0 +1,6 @@ +export enum BlocklistItemScope { + ALL = 'All', + FROM_TO = 'From/To', + CC = 'Cc', + BCC = 'Bcc', +} diff --git a/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx b/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx index 65ec8c7d75a0..374696442b49 100644 --- a/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx +++ b/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx @@ -7,8 +7,8 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/blocklist/components/SettingsAccountsBlocklistSection'; import { SettingsAccountLoader } from '@/settings/accounts/components/SettingsAccountLoader'; -import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/SettingsAccountsBlocklistSection'; import { SettingsAccountsConnectedAccountsListCard } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsListCard'; import { SettingsAccountsSettingsSection } from '@/settings/accounts/components/SettingsAccountsSettingsSection'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 8573722ea777..9465243c8a2a 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -1,23 +1,23 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { H2Title, Section } from 'twenty-ui'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInputV2 } from '@/ui/input/components/TextInputV2'; -import { Controller, useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import styled from '@emotion/styled'; -import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { H2Title, Section } from 'twenty-ui'; +import { z } from 'zod'; import { useUpdateWorkspaceMutation } from '~/generated/graphql'; -import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { isDefined } from '~/utils/isDefined'; -import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; const validationSchema = z .object({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 48a5f85bf92d..a41e5c26f9fc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -58,6 +58,7 @@ export const BASE_OBJECT_STANDARD_FIELD_IDS = { export const BLOCKLIST_STANDARD_FIELD_IDS = { handle: '20202020-eef3-44ed-aa32-4641d7fd4a3e', + scopes: '20202020-4b3b-4b3b-8b3b-7f8d6a1d7d5b', workspaceMember: '20202020-548d-4084-a947-fa20a39f7c06', }; diff --git a/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts b/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts index da8adfddf3af..8c75026ec0a5 100644 --- a/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts +++ b/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts @@ -7,6 +7,7 @@ import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; +import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; @@ -15,6 +16,13 @@ import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sy import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +export enum BlocklistItemScope { + ALL = 'All', + FROM_TO = 'From/To', + CC = 'Cc', + BCC = 'Bcc', +} + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.blocklist, namePlural: 'blocklists', @@ -36,6 +44,16 @@ export class BlocklistWorkspaceEntity extends BaseWorkspaceEntity { }) handle: string; + @WorkspaceField({ + standardId: BLOCKLIST_STANDARD_FIELD_IDS.scopes, + type: FieldMetadataType.ARRAY, + label: 'Scopes', + description: 'Blocklist Scopes', + icon: 'IconMail', + }) + @WorkspaceIsNullable() + scopes: BlocklistItemScope[]; + @WorkspaceRelation({ standardId: BLOCKLIST_STANDARD_FIELD_IDS.workspaceMember, type: RelationMetadataType.MANY_TO_ONE,