diff --git a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx new file mode 100644 index 000000000..37e0dc87a --- /dev/null +++ b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import * as useCreateChannelModule from '../../../context/useCreateChannel'; +import { CHANNEL_TYPE } from '../../../types'; +import { act, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import CreateChannelUI from '../index'; + +jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + userStore: { + user: { + userId: ' test-user-id', + }, + }, + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + createApplicationUserListQuery: () => ({ + next: () => Promise.resolve([{ userId: 'test-user-id' }]), + isLoading: false, + }), + }, + initialized: true, + }, + }, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { + enableMention: true, + }, + isOnline: true, + }, + })), +})); +jest.mock('../../../context/useCreateChannel'); + +const mockStringSet = { + MODAL__CREATE_CHANNEL__TITLE: 'CREATE_CHANNEL', + MODAL__INVITE_MEMBER__SELECTED: 'USERS_SELECTED', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const defaultMockActions = { + setPageStep: jest.fn(), + setType: jest.fn(), +}; + +describe('CreateChannelUI Integration Tests', () => { + const mockUseCreateChannel = useCreateChannelModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseCreateChannel.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ` +
+ `; + }); + + it('display initial state correctly', () => { + renderComponent(); + + expect(screen.getByText('CREATE_CHANNEL')).toBeInTheDocument(); + }); + + it('display SelectChannelType when pageStep is 0', () => { + renderComponent({ pageStep: 0 }); + + expect(screen.getByText('CREATE_CHANNEL')).toBeInTheDocument(); + }); + + it('display InviteUsers when pageStep is 1', async () => { + await act(async () => { + renderComponent({ pageStep: 1 }); + }); + + expect(screen.getByText('0 USERS_SELECTED')).toBeInTheDocument(); + }); + +}); diff --git a/src/modules/CreateChannel/components/CreateChannelUI/index.tsx b/src/modules/CreateChannel/components/CreateChannelUI/index.tsx index e96258f2b..8469efc7f 100644 --- a/src/modules/CreateChannel/components/CreateChannelUI/index.tsx +++ b/src/modules/CreateChannel/components/CreateChannelUI/index.tsx @@ -2,10 +2,10 @@ import './create-channel-ui.scss'; import React from 'react'; -import { useCreateChannelContext } from '../../context/CreateChannelProvider'; import InviteUsers from '../InviteUsers'; import SelectChannelType from '../SelectChannelType'; +import useCreateChannel from '../../context/useCreateChannel'; export interface CreateChannelUIProps { onCancel?(): void; @@ -16,15 +16,19 @@ const CreateChannel: React.FC = (props: CreateChannelUIPro const { onCancel, renderStepOne } = props; const { - step, - setStep, - userListQuery, - } = useCreateChannelContext(); + state: { + pageStep, + userListQuery, + }, + actions: { + setPageStep, + }, + } = useCreateChannel(); return ( <> { - step === 0 && ( + pageStep === 0 && ( renderStepOne?.() || ( = (props: CreateChannelUIPro ) } { - step === 1 && ( + pageStep === 1 && ( { - setStep(0); + setPageStep(0); onCancel?.(); }} /> diff --git a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx index 7cafa9820..c16ff47bf 100644 --- a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx @@ -1,21 +1,29 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import '@testing-library/jest-dom/matchers'; import InviteUsers from '../index'; import { ApplicationUserListQuery } from '@sendbird/chat'; -import { SendbirdSdkContext } from '../../../../../lib/SendbirdSdkContext'; -import { SendBirdState } from '../../../../../lib/types'; - -jest.mock('../../../context/CreateChannelProvider', () => ({ - useCreateChannelContext: jest.fn(() => ({ - onBeforeCreateChannel: jest.fn(), - onCreateChannel: jest.fn(), - overrideInviteUser: jest.fn(), - createChannel: jest.fn().mockResolvedValue({}), - type: 'group', +import { CHANNEL_TYPE } from '../../../types'; +import * as useCreateChannelModule from '../../../context/useCreateChannel'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, })), })); +jest.mock('../../../context/useCreateChannel'); // Mock createPortal function to render content directly without portal jest.mock('react-dom', () => ({ @@ -23,7 +31,60 @@ jest.mock('react-dom', () => ({ createPortal: (node) => node, })); +const mockStringSet = { + MODAL__CREATE_CHANNEL__TITLE: 'CREATE_CHANNEL', + MODAL__INVITE_MEMBER__SELECTED: 'USERS_SELECTED', + BUTTON__CREATE: 'CREATE', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + sdk: undefined, + createChannel: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + step: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const defaultMockActions = { + setStep: jest.fn(), + setType: jest.fn(), +}; + +const defaultMockInvitUserState = { + user: { userId: 'test-user-id' }, +}; + describe('InviteUsers', () => { + const mockUseCreateChannel = useCreateChannelModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}, mockInviteUsersState = {}) => { + mockUseCreateChannel.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + const inviteUserProps = { ...defaultMockInvitUserState, ...mockInviteUsersState }; + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should enable the modal submit button when there is only the logged-in user is in the user list', async () => { const userListQuery = jest.fn( () => ({ @@ -32,13 +93,9 @@ describe('InviteUsers', () => { } as unknown as ApplicationUserListQuery), ); - render( - - - , - ); + renderComponent({}, {}, { userListQuery }); - expect(await screen.findByText('Create')).toBeEnabled(); + expect(await screen.findByText('CREATE')).toBeEnabled(); }); // TODO: add this case too diff --git a/src/modules/CreateChannel/components/InviteUsers/index.tsx b/src/modules/CreateChannel/components/InviteUsers/index.tsx index 2cb75a0b3..04965d726 100644 --- a/src/modules/CreateChannel/components/InviteUsers/index.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/index.tsx @@ -4,7 +4,6 @@ import type { GroupChannelCreateParams } from '@sendbird/chat/groupChannel'; import './invite-users.scss'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { useCreateChannelContext } from '../../context/CreateChannelProvider'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import Modal from '../../../../ui/Modal'; @@ -15,6 +14,7 @@ import UserListItem from '../../../../ui/UserListItem'; import { createDefaultUserListQuery, filterUser, setChannelType } from './utils'; import { noop } from '../../../../utils/utils'; import { UserListQuery } from '../../../../types'; +import useCreateChannel from '../../context/useCreateChannel'; export interface InviteUsersProps { onCancel?: () => void; @@ -28,14 +28,18 @@ const InviteUsers: React.FC = ({ userListQuery, }: InviteUsersProps) => { const { - onCreateChannelClick, - onBeforeCreateChannel, - onChannelCreated, - createChannel, - onCreateChannel, - overrideInviteUser, - type, - } = useCreateChannelContext(); + state: { + onCreateChannelClick, + onBeforeCreateChannel, + onChannelCreated, + onCreateChannel, + overrideInviteUser, + type, + }, + actions: { + createChannel, + }, + } = useCreateChannel(); const globalStore = useSendbirdStateContext(); const userId = globalStore?.config?.userId; diff --git a/src/modules/CreateChannel/components/SelectChannelType.tsx b/src/modules/CreateChannel/components/SelectChannelType.tsx index 9394d7667..9414aa979 100644 --- a/src/modules/CreateChannel/components/SelectChannelType.tsx +++ b/src/modules/CreateChannel/components/SelectChannelType.tsx @@ -3,8 +3,6 @@ import React, { useContext } from 'react'; import * as sendbirdSelectors from '../../../lib/selectors'; import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { useCreateChannelContext } from '../context/CreateChannelProvider'; - import { LocalizationContext } from '../../../lib/LocalizationContext'; import Label, { LabelColors, LabelTypography } from '../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../ui/Icon'; @@ -16,6 +14,7 @@ import { isSuperGroupChannelEnabled, } from '../utils'; import { CHANNEL_TYPE } from '../types'; +import useCreateChannel from '../context/useCreateChannel'; export interface SelectChannelTypeProps { onCancel?(): void; @@ -27,11 +26,12 @@ const SelectChannelType: React.FC = (props: SelectChanne const sdk = sendbirdSelectors.getSdk(store); - const createChannelProps = useCreateChannelContext(); const { - setStep, - setType, - } = createChannelProps; + actions: { + setPageStep, + setType, + }, + } = useCreateChannel(); const { stringSet } = useContext(LocalizationContext); @@ -50,13 +50,13 @@ const SelectChannelType: React.FC = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.GROUP); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.GROUP); - setStep(1); + setPageStep(1); }} > = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.SUPERGROUP); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.SUPERGROUP); - setStep(1); + setPageStep(1); }} > = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.BROADCAST); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.BROADCAST); - setStep(1); + setPageStep(1); }} > (null); +import { createStore } from '../../../utils/storeManager'; +import { useStore } from '../../../hooks/useStore'; +import useCreateChannel from './useCreateChannel'; + +const CreateChannelContext = React.createContext> | null>(null); + +const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; export interface UserListQuery { hasNext?: boolean; @@ -54,11 +68,8 @@ export interface CreateChannelProviderProps { overrideInviteUser?(params: OverrideInviteUserType): void; } -type CreateChannel = (channelParams: GroupChannelCreateParams) => Promise; - -export interface CreateChannelContextInterface { +export interface CreateChannelState { sdk: SendbirdChatType; - createChannel: CreateChannel; userListQuery?(): UserListQuery; /** @@ -75,10 +86,8 @@ export interface CreateChannelContextInterface { * */ onBeforeCreateChannel?(users: Array): GroupChannelCreateParams; - step: number, - setStep: React.Dispatch>, + pageStep: number, type: CHANNEL_TYPE, - setType: React.Dispatch>, /** * @deprecated * Use the onChannelCreated instead @@ -91,9 +100,8 @@ export interface CreateChannelContextInterface { overrideInviteUser?(params: OverrideInviteUserType): void; } -const CreateChannelProvider: React.FC = (props: CreateChannelProviderProps) => { +const CreateChannelManager: React.FC = (props: CreateChannelProviderProps) => { const { - children, onCreateChannelClick, onBeforeCreateChannel, onChannelCreated, @@ -102,39 +110,64 @@ const CreateChannelProvider: React.FC = (props: Crea overrideInviteUser, } = props; + const { updateState } = useCreateChannelStore(); const store = useSendbirdStateContext(); const _userListQuery = userListQuery ?? store?.config?.userListQuery; - const [step, setStep] = useState(0); - const [type, setType] = useState(CHANNEL_TYPE.GROUP); - - return ( - { + updateState({ onCreateChannelClick, onBeforeCreateChannel, onChannelCreated, userListQuery: _userListQuery, - step, - setStep, - type, - setType, onCreateChannel, overrideInviteUser, - }}> + }); + }, [ + onCreateChannelClick, + onBeforeCreateChannel, + onChannelCreated, + userListQuery, + onCreateChannel, + overrideInviteUser, + _userListQuery, + ]); + + return null; +}; +const CreateChannelProvider: React.FC = (props: CreateChannelProviderProps) => { + const { children } = props; + + return ( + + + {children} + + ); +}; + +const createCreateChannelStore = () => createStore(initialState); +const InternalCreateChannelProvider: React.FC> = ({ children }) => { + const storeRef = useRef(createCreateChannelStore()); + + return ( + {children} ); }; +const useCreateChannelStore = () => { + return useStore(CreateChannelContext, state => state, initialState); +}; + const useCreateChannelContext = () => { - const context = React.useContext(CreateChannelContext); - if (!context) throw new Error('CreateChannelContext not found. Use within the CreateChannel module.'); - return context; + const { state, actions } = useCreateChannel(); + return { ...state, ...actions }; }; export { CreateChannelProvider, + CreateChannelContext, useCreateChannelContext, }; diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx new file mode 100644 index 000000000..0be826a28 --- /dev/null +++ b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { act, waitFor } from '@testing-library/react'; +import { CreateChannelProvider } from '../CreateChannelProvider'; +import { CHANNEL_TYPE } from '../../types'; +import useCreateChannel from '../useCreateChannel'; +import { renderHook } from '@testing-library/react-hooks'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +describe('CreateChannelProvider', () => { + const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: expect.any(Function), + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, + }; + + it('provide the correct initial state', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.state).toEqual(initialState); + }); + + it('provides correct actions through useCreateChannel hook', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.actions).toHaveProperty('setPageStep'); + expect(result.current.actions).toHaveProperty('setType'); + }); + + it('update state correctly when setPageStep is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + await act(async () => { + result.current.actions.setPageStep(1); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.pageStep).toEqual(1); + }); + }); + }); + + it('update state correctly when setType is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + await act(async () => { + result.current.actions.setType(CHANNEL_TYPE.BROADCAST); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.type).toEqual(CHANNEL_TYPE.BROADCAST); + }); + }); + }); + +}); diff --git a/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx new file mode 100644 index 000000000..4a479958b --- /dev/null +++ b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { CHANNEL_TYPE } from '../../types'; +import { CreateChannelProvider } from '../CreateChannelProvider'; +import { renderHook } from '@testing-library/react'; +import useCreateChannel from '../useCreateChannel'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: expect.any(Function), + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const wrapper = ({ children }) => ( + jest.fn()}> + {children} + +); + +describe('useCreateChannel', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if used outside of GroupChannelListProvider', () => { + expect(() => { + renderHook(() => useCreateChannel()); + }).toThrow(new Error('useCreateChannel must be used within a CreateChannelProvider')); + }); + + it('provide the correct initial state', () => { + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.state).toEqual(expect.objectContaining(initialState)); + }); + +}); diff --git a/src/modules/CreateChannel/context/useCreateChannel.ts b/src/modules/CreateChannel/context/useCreateChannel.ts new file mode 100644 index 000000000..b65d42a80 --- /dev/null +++ b/src/modules/CreateChannel/context/useCreateChannel.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useContext, useMemo } from 'react'; +import { CreateChannelContext, CreateChannelState } from './CreateChannelProvider'; +import { CHANNEL_TYPE } from '../types'; +import { getCreateGroupChannel } from '../../../lib/selectors'; +import { useSendbirdStateContext } from '../../../index'; + +const useCreateChannel = () => { + const store = useContext(CreateChannelContext); + const sendbirdStore = useSendbirdStateContext(); + if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); + + const state: CreateChannelState = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + setPageStep: (pageStep: number) => store.setState(state => ({ + ...state, + pageStep, + })), + + setType: (type: CHANNEL_TYPE) => store.setState(state => ({ + ...state, + type, + })), + + createChannel: getCreateGroupChannel(sendbirdStore), + }), [store]); + + return { state, actions }; +}; + +export default useCreateChannel; diff --git a/src/ui/Modal/index.tsx b/src/ui/Modal/index.tsx index 45482746e..03aeb8ba6 100644 --- a/src/ui/Modal/index.tsx +++ b/src/ui/Modal/index.tsx @@ -12,8 +12,8 @@ import IconButton from '../IconButton'; import Button, { ButtonTypes } from '../Button'; import Icon, { IconTypes, IconColors } from '../Icon'; import Label, { LabelTypography, LabelColors } from '../Label'; -import { useSendbirdStateContext } from '../../lib/Sendbird'; import uuidv4 from '../../utils/uuid'; +import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; export interface ModalHeaderProps { titleText: string; diff --git a/src/ui/UserListItem/index.tsx b/src/ui/UserListItem/index.tsx index f584f35f8..7eb2157f6 100644 --- a/src/ui/UserListItem/index.tsx +++ b/src/ui/UserListItem/index.tsx @@ -3,7 +3,7 @@ import type { User } from '@sendbird/chat'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import './index.scss'; -import { useSendbirdStateContext } from '../../lib/Sendbird'; +import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { useUserProfileContext } from '../../lib/UserProfileContext'; import { useLocalization } from '../../lib/LocalizationContext';