diff --git a/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts b/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts index 7346c5911fd..998dd0f1456 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts @@ -55,9 +55,6 @@ export default class ContentReviewPage { } async waitForPageLoaded() { - await this.page.waitForResponse( - (res) => res.url().includes('as_content_reviewer=true') && res.status() === 200, - ); await this.page.waitForTimeout(1000); await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); this.ensureReportCardSet(); @@ -129,10 +126,6 @@ export default class ContentReviewPage { } async waitForRHSVisible() { - await this.page.waitForResponse( - (res) => res.url().includes('as_content_reviewer=true') && res.status() === 200, - ); - const gotIt = this.page.getByRole('button', {name: 'Got it'}); if (await gotIt.isVisible()) { await gotIt.click(); diff --git a/webapp/channels/src/components/common/hooks/content_flagging.ts b/webapp/channels/src/components/common/hooks/content_flagging.ts new file mode 100644 index 00000000000..ff0da3571ea --- /dev/null +++ b/webapp/channels/src/components/common/hooks/content_flagging.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Channel} from '@mattermost/types/channels'; +import type {Post} from '@mattermost/types/posts'; +import type {Team} from '@mattermost/types/teams'; + +import type { + ContentFlaggingChannelRequestIdentifier, + ContentFlaggingTeamRequestIdentifier, +} from 'mattermost-redux/actions/content_flagging'; +import { + loadContentFlaggingTeam, + loadContentFlaggingChannel, + loadFlaggedPost, +} from 'mattermost-redux/actions/content_flagging'; +import { + getContentFlaggingChannel, + getContentFlaggingTeam, + getFlaggedPost, +} from 'mattermost-redux/selectors/entities/content_flagging'; + +import {makeUseEntity} from 'components/common/hooks/useEntity'; + +export const useGetFlaggedPost = makeUseEntity({ + name: 'useGetFlaggedPost', + fetch: loadFlaggedPost, + selector: getFlaggedPost, +}); + +export const useGetContentFlaggingChannel = makeUseEntity({ + name: 'useGetContentFlaggingChannel', + fetch: loadContentFlaggingChannel, + selector: getContentFlaggingChannel, +}); + +export const useGetContentFlaggingTeam = makeUseEntity({ + name: 'useGetContentFlaggingTeam', + fetch: loadContentFlaggingTeam, + selector: getContentFlaggingTeam, +}); diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx index e1599711ec8..ea08f966e5c 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx @@ -96,6 +96,17 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => { [reportedPostTeam.id]: reportedPostTeam, }, }, + contentFlagging: { + flaggedPosts: { + [reportedPost.id]: reportedPost, + }, + channels: { + [reportedPostChannel.id]: reportedPostChannel, + }, + teams: { + [reportedPostTeam.id]: reportedPostTeam, + }, + }, }, }; diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx index 4f8809e7e70..39799e1fac0 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx @@ -1,11 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {useDispatch} from 'react-redux'; -import type {Channel} from '@mattermost/types/channels'; import {ContentFlaggingStatus} from '@mattermost/types/content_flagging'; import type {Post} from '@mattermost/types/posts'; import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties'; @@ -14,6 +12,7 @@ import {Client4} from 'mattermost-redux/client'; import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils'; import AtMention from 'components/at_mention'; +import {useGetContentFlaggingChannel, useGetContentFlaggingTeam, useGetFlaggedPost} from 'components/common/hooks/content_flagging'; import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components/common/hooks/useContentFlaggingFields'; import {useUser} from 'components/common/hooks/useUser'; import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions'; @@ -59,44 +58,15 @@ type Props = { export function DataSpillageReport({post, isRHS}: Props) { const {formatMessage} = useIntl(); - const loaded = useRef(false); - const dispatch = useDispatch(); const reportedPostId = post.props.reported_post_id as string; const naturalPropertyFields = useContentFlaggingFields('fetch'); const naturalPropertyValues = usePostContentFlaggingValues(reportedPostId); - const [reportedPost, setReportedPost] = useState(); - const [channel, setChannel] = useState(); - - useEffect(() => { - const fetchChannel = async () => { - if (reportedPost && reportedPost.channel_id) { - const fetchedChannel = await getChannel(reportedPostId)(reportedPost.channel_id); - setChannel(fetchedChannel); - } - }; - - fetchChannel(); - }, [reportedPost, reportedPostId]); - - useEffect(() => { - const work = async () => { - if (!loaded.current && !reportedPost) { - // We need to obtain the post directly from action bypassing the selectors - // because the post might be soft-deleted and the post reducers do not store deleted posts - // in the store. - const post = await loadFlaggedPost(reportedPostId); - if (post) { - setReportedPost(post); - loaded.current = true; - } - } - }; - - work(); - }, [dispatch, reportedPost, reportedPostId]); + const reportedPost = useGetFlaggedPost(reportedPostId); + const channel = useGetContentFlaggingChannel({flaggedPostId: reportedPostId, channelId: reportedPost?.channel_id}); + const team = useGetContentFlaggingTeam({flaggedPostId: reportedPostId, teamId: channel?.team_id}); const propertyFields = useMemo((): NameMappedPropertyFields => { if (!naturalPropertyFields || !Object.keys(naturalPropertyFields).length) { @@ -139,22 +109,26 @@ export function DataSpillageReport({post, isRHS}: Props) { const mode = isRHS ? 'full' : 'short'; const metadata = useMemo(() => { - const fieldMetadata = { + const fieldMetadata: PropertiesCardViewMetadata = { post_preview: { - getPost: loadFlaggedPost, + post: reportedPost, fetchDeletedPost: true, - getChannel: getChannel(reportedPostId), - getTeam: getTeam(reportedPostId), - generateFileDownloadUrl: generateFileDownloadUrl(reportedPostId), + channel, + team, + generateFileDownloadUrl: + generateFileDownloadUrl(reportedPostId), }, reporting_comment: { - placeholder: formatMessage({id: 'data_spillage_report_post.reporting_comment.placeholder', defaultMessage: 'No comment'}), + placeholder: formatMessage({ + id: 'data_spillage_report_post.reporting_comment.placeholder', + defaultMessage: 'No comment', + }), }, team: { - getTeam: getTeam(reportedPostId), + team, }, channel: { - getChannel: getChannel(reportedPostId), + channel, }, }; @@ -168,7 +142,7 @@ export function DataSpillageReport({post, isRHS}: Props) { } return fieldMetadata; - }, [channel, formatMessage, reportedPostId]); + }, [channel, formatMessage, reportedPost, reportedPostId, team]); const footer = useMemo(() => { if (isRHS) { @@ -219,10 +193,6 @@ export function DataSpillageReport({post, isRHS}: Props) { ); } -async function loadFlaggedPost(flaggedPostId: string) { - return Client4.getFlaggedPost(flaggedPostId); -} - function getSearchContentReviewersFunction(teamId: string) { return (term: string) => { return Client4.searchContentFlaggingReviewers(term, teamId); @@ -235,18 +205,6 @@ function saveReviewerSelection(flaggedPostId: string) { }; } -function getChannel(flaggedPostId: string) { - return (channelId: string) => { - return Client4.getChannel(channelId, true, flaggedPostId); - }; -} - -function getTeam(flaggedPostId: string) { - return (teamId: string) => { - return Client4.getTeam(teamId, true, flaggedPostId); - }; -} - function generateFileDownloadUrl(flaggedPostId: string) { return (fileId: string) => getFileDownloadUrl(fileId, true, flaggedPostId); } diff --git a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx index 497fe0a8970..a55ddb8104f 100644 --- a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx +++ b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx @@ -19,10 +19,10 @@ import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer import './properties_card_view.scss'; export type PostPreviewFieldMetadata = { - getPost?: (postId: string) => Promise; + post?: Post; fetchDeletedPost?: boolean; - getChannel?: (channelId: string) => Promise; - getTeam?: (teamId: string) => Promise; + channel?: Channel; + team?: Team; generateFileDownloadUrl?: (fileId: string) => string; }; @@ -36,11 +36,11 @@ export type TextFieldMetadata = { }; export type ChannelFieldMetadata = { - getChannel?: (channelId: string) => Promise; + channel?: Channel; }; export type TeamFieldMetadata = { - getTeam?: (teamId: string) => Promise; + team?: Team; }; export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata | UserPropertyMetadata | ChannelFieldMetadata | TeamFieldMetadata; diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.test.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.test.tsx index 2ddf48fd2d4..46645dcd488 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.test.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.test.tsx @@ -12,10 +12,6 @@ import {TestHelper} from 'utils/test_helper'; import ChannelPropertyRenderer from './channel_property_renderer'; -jest.mock('components/common/hooks/useChannel'); - -const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction; - describe('ChannelPropertyRenderer', () => { const mockChannel: Channel = { ...TestHelper.getChannelMock({ @@ -30,34 +26,23 @@ describe('ChannelPropertyRenderer', () => { } as PropertyValue; it('should render channel name and icon when channel exists', () => { - mockUseChannel.mockReturnValue(mockChannel); - renderWithContext( - , + , ); expect(screen.getByTestId('channel-property')).toBeInTheDocument(); expect(screen.getByText('Test Channel')).toBeInTheDocument(); - expect(mockUseChannel).toHaveBeenCalledWith('channel-id-123'); }); it('should render deleted channel message when channel does not exist', () => { - mockUseChannel.mockReturnValue(undefined); - renderWithContext( - , - ); - - expect(screen.getByTestId('channel-property')).toBeInTheDocument(); - expect(screen.getByText(/Deleted channel ID: channel-id-123/)).toBeInTheDocument(); - expect(mockUseChannel).toHaveBeenCalledWith('channel-id-123'); - }); - - it('should render deleted channel message when channel is undefined', () => { - mockUseChannel.mockReturnValue(undefined); - - renderWithContext( - , + , ); expect(screen.getByTestId('channel-property')).toBeInTheDocument(); @@ -70,10 +55,12 @@ describe('ChannelPropertyRenderer', () => { type: 'P' as const, display_name: 'Private Channel', }; - mockUseChannel.mockReturnValue(privateChannel); renderWithContext( - , + , ); expect(screen.getByText('Private Channel')).toBeInTheDocument(); @@ -85,10 +72,12 @@ describe('ChannelPropertyRenderer', () => { type: 'D' as const, display_name: 'Direct Message', }; - mockUseChannel.mockReturnValue(dmChannel); renderWithContext( - , + , ); expect(screen.getByText('Direct Message')).toBeInTheDocument(); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.tsx index 4de2c5d2f74..011c3f42245 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/channel_property_renderer/channel_property_renderer.tsx @@ -6,7 +6,6 @@ import {FormattedMessage} from 'react-intl'; import type {PropertyValue} from '@mattermost/types/properties'; -import {usePropertyCardViewChannelLoader} from 'components/common/hooks/usePropertyCardViewChannelLoader'; import type {ChannelFieldMetadata} from 'components/properties_card_view/properties_card_view'; import SidebarBaseChannelIcon from 'components/sidebar/sidebar_channel/sidebar_base_channel/sidebar_base_channel_icon'; @@ -19,7 +18,7 @@ type Props = { export default function ChannelPropertyRenderer({value, metadata}: Props) { const channelId = value.value as string; - const channel = usePropertyCardViewChannelLoader(channelId, metadata?.getChannel); + const channel = metadata?.channel; return (
; -const mockUseTeam = require('components/common/hooks/use_team').useTeam as jest.MockedFunction; -const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction; -const mockedClient4 = jest.mocked(Client4); - describe('PostPreviewPropertyRenderer', () => { const mockUser: UserProfile = { ...TestHelper.getUserMock(), @@ -70,7 +63,10 @@ describe('PostPreviewPropertyRenderer', () => { metadata: { fetchDeletedPost: true, getPost: (postId: string) => Client4.getFlaggedPost(postId), - }, + post: mockPost, + channel: mockChannel, + team: mockTeam, + } as PostPreviewFieldMetadata, }; const baseState: DeepPartial = { @@ -101,15 +97,7 @@ describe('PostPreviewPropertyRenderer', () => { }, }; - beforeEach(() => { - mockedUsePost.mockReturnValue(null); - mockedClient4.getFlaggedPost.mockResolvedValue(mockPost); - }); - it('should render PostMessagePreview when all data is available', async () => { - mockUseChannel.mockReturnValue(mockChannel); - mockUseTeam.mockReturnValue(mockTeam); - const {getByTestId, getByText} = renderWithContext( , baseState, @@ -124,42 +112,38 @@ describe('PostPreviewPropertyRenderer', () => { }); it('should return null when post is not found', async () => { - mockUseChannel.mockReturnValue(mockChannel); - mockUseTeam.mockReturnValue(mockTeam); - mockedClient4.getFlaggedPost.mockRejectedValue({message: 'Post not found'}); + const props = cloneDeep(defaultProps); + props.metadata.post = undefined; const {container} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(container.firstChild).toBeNull(); }); it('should return null when channel is not found', async () => { - mockUseChannel.mockReturnValue(null); - mockUseTeam.mockReturnValue(mockTeam); + const props = cloneDeep(defaultProps); + props.metadata.channel = undefined; const {container} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(container.firstChild).toBeNull(); }); it('should return null when team is not found', async () => { - mockUseChannel.mockReturnValue(mockChannel); - mockUseTeam.mockReturnValue(null); + const props = cloneDeep(defaultProps); + props.metadata.team = undefined; const {container} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(container.firstChild).toBeNull(); }); @@ -169,15 +153,14 @@ describe('PostPreviewPropertyRenderer', () => { type: 'P' as const, }; - mockUseChannel.mockReturnValue(privateChannel); - mockUseTeam.mockReturnValue(mockTeam); + const props = cloneDeep(defaultProps); + props.metadata.channel = privateChannel; const {getByTestId, getByText} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(getByTestId('post-preview-property')).toBeVisible(); expect(getByText('Test post message')).toBeVisible(); expect(getByText('Originally posted in ~Test Channel')).toBeVisible(); @@ -194,15 +177,15 @@ describe('PostPreviewPropertyRenderer', () => { name: '', }; - mockUseChannel.mockReturnValue(channelWithoutDisplayName); - mockUseTeam.mockReturnValue(teamWithoutName); + const props = cloneDeep(defaultProps); + props.metadata.channel = channelWithoutDisplayName; + props.metadata.team = teamWithoutName; const {getByTestId, getByText} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(getByTestId('post-preview-property')).toBeVisible(); expect(getByText('Test post message')).toBeVisible(); expect(getByText('Originally posted in ~')).toBeVisible(); @@ -233,16 +216,14 @@ describe('PostPreviewPropertyRenderer', () => { }, } as Post; - mockedClient4.getFlaggedPost.mockResolvedValue(postWithAttachments); - mockUseChannel.mockReturnValue(mockChannel); - mockUseTeam.mockReturnValue(mockTeam); + const props = cloneDeep(defaultProps); + props.metadata.post = postWithAttachments; const {getByTestId, getByText} = renderWithContext( - , + , baseState, ); - await act(async () => {}); expect(getByTestId('post-preview-property')).toBeVisible(); expect(getByText('Post with file attachment')).toBeVisible(); expect(getByText('Originally posted in ~Test Channel')).toBeVisible(); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx index 17f39772ceb..fb2232233df 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx @@ -7,25 +7,20 @@ import {useIntl} from 'react-intl'; import type {PostPreviewMetadata} from '@mattermost/types/posts'; import type {PropertyValue} from '@mattermost/types/properties'; -import {usePropertyCardViewChannelLoader} from 'components/common/hooks/usePropertyCardViewChannelLoader'; -import {usePropertyCardViewPostLoader} from 'components/common/hooks/usePropertyCardViewPostLoader'; -import {usePropertyCardViewTeamLoader} from 'components/common/hooks/usePropertyCardViewTeamLoader'; import PostMessagePreview from 'components/post_view/post_message_preview'; import type {PostPreviewFieldMetadata} from 'components/properties_card_view/properties_card_view'; const noop = () => {}; type Props = { - value: PropertyValue; + value?: PropertyValue; metadata?: PostPreviewFieldMetadata; } -export default function PostPreviewPropertyRenderer({value, metadata}: Props) { - const postId = value.value as string; - - const post = usePropertyCardViewPostLoader(postId, metadata?.getPost, true); - const channel = usePropertyCardViewChannelLoader(post?.channel_id, metadata?.getChannel); - const team = usePropertyCardViewTeamLoader(channel?.team_id, metadata?.getTeam); +export default function PostPreviewPropertyRenderer({metadata}: Props) { + const post = metadata?.post; + const channel = metadata?.channel; + const team = metadata?.team; const {formatMessage} = useIntl(); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.test.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.test.tsx index 8431052ccba..ca3449f45ff 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.test.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.test.tsx @@ -2,11 +2,16 @@ // See LICENSE.txt for license information. import {screen} from '@testing-library/react'; +import cloneDeep from 'lodash/cloneDeep'; import React from 'react'; import type {PropertyValue} from '@mattermost/types/properties'; import type {Team} from '@mattermost/types/teams'; +import state from 'mattermost-redux/store/initial_state'; + +import type {TeamFieldMetadata} from 'components/properties_card_view/properties_card_view'; + import {renderWithContext} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; @@ -23,23 +28,14 @@ describe('TeamPropertyRenderer', () => { value: { value: 'team-id-123', } as PropertyValue, + metadata: {} as TeamFieldMetadata, }; test('should render team name and icon when team exists', () => { - const state = { - entities: { - teams: { - teams: { - 'team-id-123': mockTeam, - }, - }, - }, - }; + const props = cloneDeep(defaultProps); + props.metadata.team = mockTeam; - renderWithContext( - , - state, - ); + renderWithContext(, state); expect(screen.getByTestId('team-property')).toBeVisible(); expect(screen.getByText('Test Team')).toBeVisible(); @@ -48,14 +44,6 @@ describe('TeamPropertyRenderer', () => { }); test('should render deleted team message when team does not exist', () => { - const state = { - entities: { - teams: { - teams: {}, - }, - }, - }; - renderWithContext( , state, diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.tsx index 165d3977783..fcd7db7d829 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/team_property_renderer/team_property_renderer.tsx @@ -6,7 +6,6 @@ import {FormattedMessage, useIntl} from 'react-intl'; import type {PropertyValue} from '@mattermost/types/properties'; -import {usePropertyCardViewTeamLoader} from 'components/common/hooks/usePropertyCardViewTeamLoader'; import type {TeamFieldMetadata} from 'components/properties_card_view/properties_card_view'; import {TeamIcon} from 'components/widgets/team_icon/team_icon'; @@ -22,7 +21,7 @@ type Props = { export default function TeamPropertyRenderer({value, metadata}: Props) { const intl = useIntl(); const teamId = value.value as string; - const team = usePropertyCardViewTeamLoader(teamId, metadata?.getTeam); + const team = metadata?.team; return (
{ expect(enabled).toEqual(false); }); }); + +describe('Actions.loadContentFlaggingTeam', () => { + const store = configureStore(); + beforeAll(() => { + TestHelper.initBasic(Client4); + }); + + afterAll(() => { + TestHelper.tearDown(); + }); + + it('should dispatch RECEIVED_CONTENT_FLAGGING_TEAM on success', async () => { + const mockTeam = { + id: 'team_id', + name: 'test-team', + display_name: 'Test Team', + }; + + nock(Client4.getTeamsRoute()). + get('/team_id'). + query({ + flagged_post_id: 'post_id', + as_content_reviewer: true, + }). + reply(200, mockTeam); + + await store.dispatch(Actions.loadContentFlaggingTeam({teamId: 'team_id', flaggedPostId: 'post_id'})); + + await waitFor(() => { + const state = store.getState() as GlobalState; + const teams = state.entities.contentFlagging.teams; + expect(teams?.team_id).toEqual(mockTeam); + }); + }); + + it('should not make API call when teamId or flaggedPostId is missing', async () => { + await store.dispatch(Actions.loadContentFlaggingTeam({teamId: 'team_id'})); + await store.dispatch(Actions.loadContentFlaggingTeam({flaggedPostId: 'post_id'})); + + // Wait for the data loader to process + await new Promise((resolve) => setTimeout(resolve, 250)); + + // No API calls should have been made, so no new teams should be added + const initialTeamCount = Object.keys(store.getState().entities.teams.teams).length; + expect(initialTeamCount).toBeGreaterThanOrEqual(0); + }); +}); + +describe('Actions.loadContentFlaggingChannel', () => { + const store = configureStore(); + beforeAll(() => { + TestHelper.initBasic(Client4); + }); + + afterAll(() => { + TestHelper.tearDown(); + }); + + it('should dispatch RECEIVED_CONTENT_FLAGGING_CHANNEL on success', async () => { + const mockChannel = { + id: 'channel_id', + name: 'test-channel', + display_name: 'Test Channel', + team_id: 'team_id', + }; + + nock(Client4.getChannelsRoute()). + get('/channel_id'). + query({flagged_post_id: 'post_id', as_content_reviewer: true}). + reply(200, mockChannel); + + await store.dispatch(Actions.loadContentFlaggingChannel({channelId: 'channel_id', flaggedPostId: 'post_id'})); + + await waitFor(() => { + const state = store.getState() as GlobalState; + const channels = state.entities.contentFlagging.channels; + expect(channels?.channel_id).toEqual(mockChannel); + }); + }); + + it('should not make API call when channelId or flaggedPostId is missing', async () => { + await store.dispatch(Actions.loadContentFlaggingChannel({channelId: 'channel_id'})); + await store.dispatch(Actions.loadContentFlaggingChannel({flaggedPostId: 'post_id'})); + + // Wait for the data loader to process + await new Promise((resolve) => setTimeout(resolve, 250)); + + // No API calls should have been made, so no new channels should be added + const initialChannelCount = Object.keys(store.getState().entities.channels.channels).length; + expect(initialChannelCount).toBeGreaterThanOrEqual(0); + }); +}); + +describe('Actions.loadFlaggedPost', () => { + const store = configureStore(); + beforeAll(() => { + TestHelper.initBasic(Client4); + }); + + afterAll(() => { + TestHelper.tearDown(); + }); + + it('should dispatch RECEIVED_FLAGGED_POST on success', async () => { + const mockPost = { + id: 'post_id', + message: 'Test post message', + channel_id: 'channel_id', + user_id: 'user_id', + }; + + nock(Client4.getContentFlaggingRoute()). + get('/post/post_id'). + reply(200, mockPost); + + await store.dispatch(Actions.loadFlaggedPost('post_id')); + + await waitFor(() => { + const state = store.getState() as GlobalState; + const posts = state.entities.contentFlagging.flaggedPosts; + expect(posts?.post_id).toEqual(mockPost); + }); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts index 317e56da8c5..04ba86d3407 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts @@ -1,8 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {Channel} from '@mattermost/types/channels'; import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging'; +import type {Post} from '@mattermost/types/posts'; import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties'; +import type {Team} from '@mattermost/types/teams'; import {TeamTypes, ContentFlaggingTypes} from 'mattermost-redux/action_types'; import {logError} from 'mattermost-redux/actions/errors'; @@ -11,6 +14,24 @@ import {Client4} from 'mattermost-redux/client'; import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader'; +export type ContentFlaggingChannelRequestIdentifier = { + channelId?: string; + flaggedPostId?: string; +} + +export type ContentFlaggingTeamRequestIdentifier = { + teamId?: string; + flaggedPostId?: string; +} + +function channelComparator(a: ContentFlaggingChannelRequestIdentifier, b: ContentFlaggingChannelRequestIdentifier) { + return a.channelId === b.channelId; +} + +function teamComparator(a: ContentFlaggingTeamRequestIdentifier, b: ContentFlaggingTeamRequestIdentifier) { + return a.teamId === b.teamId; +} + export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{enabled: boolean}> { return async (dispatch, getState) => { let response; @@ -94,6 +115,132 @@ export function loadPostContentFlaggingFields(): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.getFlaggedPost(flaggedPostId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({ + type: ContentFlaggingTypes.RECEIVED_FLAGGED_POST, + data, + }); + + return {data}; + }; +} + +export function loadFlaggedPost(flaggedPostId: string): ActionFuncAsync { + return async (dispatch, getState, {loaders}: any) => { + if (!loaders.flaggedPostLoader) { + loaders.flaggedPostLoader = new DelayedDataLoader({ + fetchBatch: ([postId]) => dispatch(getFlaggedPost(postId)), + maxBatchSize: 1, + wait: 200, + }); + } + + const loader = loaders.flaggedPostLoader as DelayedDataLoader; + loader.queue([flaggedPostId]); + return {}; + }; +} + +function getContentFlaggingChannel(channelId: string, flaggedPostId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.getChannel(channelId, true, flaggedPostId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({ + type: ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CHANNEL, + data, + }); + + return {data}; + }; +} + +export function loadContentFlaggingChannel(identifier: ContentFlaggingChannelRequestIdentifier): ActionFuncAsync { + return async (dispatch, getState, {loaders}: any) => { + if (!loaders.contentFlaggingChannelLoader) { + loaders.contentFlaggingChannelLoader = + new DelayedDataLoader({ + fetchBatch: ([{flaggedPostId, channelId}]) => { + if (channelId && flaggedPostId) { + return dispatch(getContentFlaggingChannel(channelId, flaggedPostId)); + } + + return Promise.resolve(null); + }, + maxBatchSize: 1, + wait: 200, + comparator: channelComparator, + }); + } + + const loader = loaders.contentFlaggingChannelLoader as DelayedDataLoader; + loader.queue([identifier]); + + return {}; + }; +} + +function getContentFlaggingTeam(teamId: string, flaggedPostId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.getTeam(teamId, true, flaggedPostId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({ + type: ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_TEAM, + data, + }); + + return {data}; + }; +} + +export function loadContentFlaggingTeam(identifier: ContentFlaggingTeamRequestIdentifier): ActionFuncAsync { + return async (dispatch, getState, {loaders}: any) => { + if (!loaders.contentFlaggingTeamLoader) { + loaders.contentFlaggingTeamLoader = + new DelayedDataLoader({ + fetchBatch: ([{flaggedPostId, teamId}]) => { + if (teamId && flaggedPostId) { + return dispatch(getContentFlaggingTeam(teamId, flaggedPostId)); + } + + return Promise.resolve(null); + }, + maxBatchSize: 1, + wait: 200, + comparator: teamComparator, + }); + } + + const loader = loaders.contentFlaggingTeamLoader as DelayedDataLoader; + loader.queue([identifier]); + + return {}; + }; +} + export function getPostContentFlaggingValues(postId: string): ActionFuncAsync>> { return async (dispatch, getState) => { let response; diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts index 4f69dd102ad..92627b6935b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts @@ -13,7 +13,7 @@ import type { } from '@mattermost/types/properties'; import type {MMReduxAction} from 'mattermost-redux/action_types'; -import {ContentFlaggingTypes} from 'mattermost-redux/action_types'; +import {ContentFlaggingTypes, UserTypes} from 'mattermost-redux/action_types'; function settings(state: ContentFlaggingState['settings'] = {} as ContentFlaggingConfig, action: MMReduxAction) { switch (action.type) { @@ -23,6 +23,8 @@ function settings(state: ContentFlaggingState['settings'] = {} as ContentFlaggin ...action.data, }; } + case UserTypes.LOGOUT_SUCCESS: + return {}; default: return state; } @@ -36,6 +38,8 @@ function fields(state: ContentFlaggingState['fields'] = {} as NameMappedProperty ...action.data, }; } + case UserTypes.LOGOUT_SUCCESS: + return {}; default: return state; } @@ -67,6 +71,53 @@ function postValues(state: ContentFlaggingState['postValues'] = {}, action: MMRe [postId]: Object.values(valuesByFieldId), }; } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function flaggedPosts(state: ContentFlaggingState['flaggedPosts'] = {}, action: MMReduxAction) { + switch (action.type) { + case ContentFlaggingTypes.RECEIVED_FLAGGED_POST: { + return { + ...state, + [action.data.id]: action.data, + }; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function channels(state: ContentFlaggingState['channels'] = {}, action: MMReduxAction) { + switch (action.type) { + case ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CHANNEL: { + return { + ...state, + [action.data.id]: action.data, + }; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function teams(state: ContentFlaggingState['teams'] = {}, action: MMReduxAction) { + switch (action.type) { + case ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_TEAM: { + return { + ...state, + [action.data.id]: action.data, + }; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; default: return state; } @@ -76,4 +127,7 @@ export default combineReducers({ settings, fields, postValues, + flaggedPosts, + channels, + teams, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts index e4eb5dc62f1..ec5e6ace5c2 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts @@ -4,7 +4,7 @@ import type {GlobalState} from '@mattermost/types/store'; import type {DeepPartial} from '@mattermost/types/utilities'; -import {contentFlaggingFeatureEnabled} from './content_flagging'; +import {contentFlaggingFeatureEnabled, getContentFlaggingChannel, getContentFlaggingTeam, getFlaggedPost} from './content_flagging'; describe('Selectors.ContentFlagging', () => { test('should return true when config and feature flag both are set', () => { @@ -50,3 +50,270 @@ describe('Selectors.ContentFlagging', () => { expect(contentFlaggingFeatureEnabled(state as GlobalState)).toBe(false); }); }); + +describe('getContentFlaggingChannel', () => { + test('should return undefined when channelId is not provided', () => { + const state: DeepPartial = { + entities: { + channels: { + channels: {}, + }, + contentFlagging: { + channels: {}, + }, + }, + }; + + expect(getContentFlaggingChannel(state as GlobalState, {})).toBeUndefined(); + }); + + test('should return channel from regular channels store when available', () => { + const mockChannel = { + id: 'channel_id', + name: 'test-channel', + display_name: 'Test Channel', + team_id: 'team_id', + }; + + const state: DeepPartial = { + entities: { + channels: { + channels: { + channel_id: mockChannel, + }, + }, + contentFlagging: { + channels: {}, + }, + }, + }; + + expect(getContentFlaggingChannel(state as GlobalState, {channelId: 'channel_id'})).toEqual(mockChannel); + }); + + test('should return channel from content flagging store when not in regular store', () => { + const mockChannel = { + id: 'channel_id', + name: 'flagged-channel', + display_name: 'Flagged Channel', + team_id: 'team_id', + }; + + const state: DeepPartial = { + entities: { + channels: { + channels: {}, + }, + contentFlagging: { + channels: { + channel_id: mockChannel, + }, + }, + }, + }; + + expect(getContentFlaggingChannel(state as GlobalState, {channelId: 'channel_id'})).toEqual(mockChannel); + }); + + test('should prefer regular channels store over content flagging store', () => { + const regularChannel = { + id: 'channel_id', + name: 'regular-channel', + display_name: 'Regular Channel', + team_id: 'team_id', + }; + + const contentFlaggingChannel = { + id: 'channel_id', + name: 'flagged-channel', + display_name: 'Flagged Channel', + team_id: 'team_id', + }; + + const state: DeepPartial = { + entities: { + channels: { + channels: { + channel_id: regularChannel, + }, + }, + contentFlagging: { + channels: { + channel_id: contentFlaggingChannel, + }, + }, + }, + }; + + expect(getContentFlaggingChannel(state as GlobalState, {channelId: 'channel_id'})).toEqual(regularChannel); + }); + + test('should return undefined when channel does not exist in either store', () => { + const state: DeepPartial = { + entities: { + channels: { + channels: {}, + }, + contentFlagging: { + channels: {}, + }, + }, + }; + + expect(getContentFlaggingChannel(state as GlobalState, {channelId: 'nonexistent_channel'})).toBeUndefined(); + }); +}); + +describe('getContentFlaggingTeam', () => { + test('should return undefined when teamId is not provided', () => { + const state: DeepPartial = { + entities: { + teams: { + teams: {}, + }, + contentFlagging: { + teams: {}, + }, + }, + }; + + expect(getContentFlaggingTeam(state as GlobalState, {})).toBeUndefined(); + }); + + test('should return team from regular teams store when available', () => { + const mockTeam = { + id: 'team_id', + name: 'test-team', + display_name: 'Test Team', + }; + + const state: DeepPartial = { + entities: { + teams: { + teams: { + team_id: mockTeam, + }, + }, + contentFlagging: { + teams: {}, + }, + }, + }; + + expect(getContentFlaggingTeam(state as GlobalState, {teamId: 'team_id'})).toEqual(mockTeam); + }); + + test('should return team from content flagging store when not in regular store', () => { + const mockTeam = { + id: 'team_id', + name: 'flagged-team', + display_name: 'Flagged Team', + }; + + const state: DeepPartial = { + entities: { + teams: { + teams: {}, + }, + contentFlagging: { + teams: { + team_id: mockTeam, + }, + }, + }, + }; + + expect(getContentFlaggingTeam(state as GlobalState, {teamId: 'team_id'})).toEqual(mockTeam); + }); + + test('should prefer regular teams store over content flagging store', () => { + const regularTeam = { + id: 'team_id', + name: 'regular-team', + display_name: 'Regular Team', + }; + + const contentFlaggingTeam = { + id: 'team_id', + name: 'flagged-team', + display_name: 'Flagged Team', + }; + + const state: DeepPartial = { + entities: { + teams: { + teams: { + team_id: regularTeam, + }, + }, + contentFlagging: { + teams: { + team_id: contentFlaggingTeam, + }, + }, + }, + }; + + expect(getContentFlaggingTeam(state as GlobalState, {teamId: 'team_id'})).toEqual(regularTeam); + }); + + test('should return undefined when team does not exist in either store', () => { + const state: DeepPartial = { + entities: { + teams: { + teams: {}, + }, + contentFlagging: { + teams: {}, + }, + }, + }; + + expect(getContentFlaggingTeam(state as GlobalState, {teamId: 'nonexistent_team'})).toBeUndefined(); + }); +}); + +describe('getFlaggedPost', () => { + test('should return flagged post when it exists', () => { + const mockPost = { + id: 'post_id', + message: 'Test post message', + channel_id: 'channel_id', + user_id: 'user_id', + }; + + const state: DeepPartial = { + entities: { + contentFlagging: { + flaggedPosts: { + post_id: mockPost, + }, + }, + }, + }; + + expect(getFlaggedPost(state as GlobalState, 'post_id')).toEqual(mockPost); + }); + + test('should return undefined when flagged post does not exist', () => { + const state: DeepPartial = { + entities: { + contentFlagging: { + flaggedPosts: {}, + }, + }, + }; + + expect(getFlaggedPost(state as GlobalState, 'nonexistent_post')).toBeUndefined(); + }); + + test('should return undefined when flaggedPosts is undefined', () => { + const state: DeepPartial = { + entities: { + contentFlagging: {}, + }, + }; + + expect(getFlaggedPost(state as GlobalState, 'post_id')).toBeUndefined(); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts index 37aa20facfa..82137196937 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts @@ -3,6 +3,7 @@ import type {GlobalState} from '@mattermost/types/store'; +import type {ContentFlaggingChannelRequestIdentifier, ContentFlaggingTeamRequestIdentifier} from 'mattermost-redux/actions/content_flagging'; import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; export const contentFlaggingFeatureEnabled = (state: GlobalState): boolean => { @@ -26,3 +27,34 @@ export const postContentFlaggingValues = (state: GlobalState, postId: string) => const values = state.entities.contentFlagging.postValues || {}; return values[postId]; }; + +export const getFlaggedPost = (state: GlobalState, flaggedPostId: string) => { + return state.entities.contentFlagging.flaggedPosts?.[flaggedPostId]; +}; + +export const getContentFlaggingChannel = (state: GlobalState, {channelId}: ContentFlaggingChannelRequestIdentifier) => { + // Return channel from the regular channel store if available, else get it from the content flagging store + if (!channelId) { + return undefined; + } + + const channel = state.entities.channels.channels[channelId]; + if (channel) { + return channel; + } + + return state.entities.contentFlagging.channels?.[channelId]; +}; + +export const getContentFlaggingTeam = (state: GlobalState, {teamId}: ContentFlaggingTeamRequestIdentifier) => { + if (!teamId) { + return undefined; + } + + const team = state.entities.teams.teams[teamId]; + if (team) { + return team; + } + + return state.entities.contentFlagging.teams?.[teamId]; +}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.test.ts index 3bdabad841e..012a2876b76 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.test.ts @@ -160,6 +160,66 @@ describe('BackgroundDataLoader', () => { expect(fetchBatch).toHaveBeenCalledTimes(1); }); + + test('should dedupe object identifiers using custom comparator', () => { + type ObjectIdentifier = {id: string; extra?: string}; + const fetchBatch = jest.fn(); + const comparator = (a: ObjectIdentifier, b: ObjectIdentifier) => a.id === b.id; + + const objectLoader = new BackgroundDataLoader({ + fetchBatch, + maxBatchSize: 10, + comparator, + }); + + objectLoader.startIntervalIfNeeded(period); + + // Queue objects with the same id but different references + objectLoader.queue([{id: 'obj1', extra: 'a'}]); + objectLoader.queue([{id: 'obj1', extra: 'b'}]); // Should be deduped + objectLoader.queue([{id: 'obj2'}]); + objectLoader.queue([{id: 'obj2', extra: 'c'}]); // Should be deduped + + jest.advanceTimersToNextTimer(); + + expect(fetchBatch).toHaveBeenCalledTimes(1); + expect(fetchBatch).toHaveBeenCalledWith([ + {id: 'obj1', extra: 'a'}, + {id: 'obj2'}, + ]); + + objectLoader.stopInterval(); + }); + + test('should not dedupe different objects when using custom comparator', () => { + type ObjectIdentifier = {id: string; type: string}; + const fetchBatch = jest.fn(); + const comparator = (a: ObjectIdentifier, b: ObjectIdentifier) => a.id === b.id && a.type === b.type; + + const objectLoader = new BackgroundDataLoader({ + fetchBatch, + maxBatchSize: 10, + comparator, + }); + + objectLoader.startIntervalIfNeeded(period); + + // Queue objects with different ids or types + objectLoader.queue([{id: 'obj1', type: 'channel'}]); + objectLoader.queue([{id: 'obj1', type: 'team'}]); // Different type, should not be deduped + objectLoader.queue([{id: 'obj2', type: 'channel'}]); + + jest.advanceTimersToNextTimer(); + + expect(fetchBatch).toHaveBeenCalledTimes(1); + expect(fetchBatch).toHaveBeenCalledWith([ + {id: 'obj1', type: 'channel'}, + {id: 'obj1', type: 'team'}, + {id: 'obj2', type: 'channel'}, + ]); + + objectLoader.stopInterval(); + }); }); describe('DelayedDataLoader', () => { @@ -526,4 +586,85 @@ describe('DelayedDataLoader', () => { expect(fourthResolved).toBe(true); expect(fifthResolved).toBe(true); }); + + test('should dedupe object identifiers using custom comparator', () => { + type ObjectIdentifier = {id: string; extra?: string}; + const fetchBatch = jest.fn(() => Promise.resolve()); + const comparator = (a: ObjectIdentifier, b: ObjectIdentifier) => a.id === b.id; + + const objectLoader = new DelayedDataLoader({ + fetchBatch, + maxBatchSize: 10, + wait, + comparator, + }); + + // Queue objects with the same id but different references + objectLoader.queue([{id: 'obj1', extra: 'a'}]); + objectLoader.queue([{id: 'obj1', extra: 'b'}]); // Should be deduped + objectLoader.queue([{id: 'obj2'}]); + objectLoader.queue([{id: 'obj2', extra: 'c'}]); // Should be deduped + + jest.advanceTimersToNextTimer(); + + expect(fetchBatch).toHaveBeenCalledTimes(1); + expect(fetchBatch).toHaveBeenCalledWith([ + {id: 'obj1', extra: 'a'}, + {id: 'obj2'}, + ]); + }); + + test('should not dedupe different objects when using custom comparator', () => { + type ObjectIdentifier = {id: string; type: string}; + const fetchBatch = jest.fn(() => Promise.resolve()); + const comparator = (a: ObjectIdentifier, b: ObjectIdentifier) => a.id === b.id && a.type === b.type; + + const objectLoader = new DelayedDataLoader({ + fetchBatch, + maxBatchSize: 10, + wait, + comparator, + }); + + // Queue objects with different ids or types + objectLoader.queue([{id: 'obj1', type: 'channel'}]); + objectLoader.queue([{id: 'obj1', type: 'team'}]); // Different type, should not be deduped + objectLoader.queue([{id: 'obj2', type: 'channel'}]); + + jest.advanceTimersToNextTimer(); + + expect(fetchBatch).toHaveBeenCalledTimes(1); + expect(fetchBatch).toHaveBeenCalledWith([ + {id: 'obj1', type: 'channel'}, + {id: 'obj1', type: 'team'}, + {id: 'obj2', type: 'channel'}, + ]); + }); + + test('should dedupe across multiple queue calls using custom comparator', () => { + type ObjectIdentifier = {channelId: string; flaggedPostId?: string}; + const fetchBatch = jest.fn(() => Promise.resolve()); + const comparator = (a: ObjectIdentifier, b: ObjectIdentifier) => a.channelId === b.channelId; + + const objectLoader = new DelayedDataLoader({ + fetchBatch, + maxBatchSize: 10, + wait, + comparator, + }); + + // Simulate real-world usage pattern from content flagging + objectLoader.queue([{channelId: 'ch1', flaggedPostId: 'post1'}]); + objectLoader.queue([{channelId: 'ch1', flaggedPostId: 'post2'}]); // Same channel, should be deduped + objectLoader.queue([{channelId: 'ch2', flaggedPostId: 'post3'}]); + objectLoader.queue([{channelId: 'ch1'}]); // Same channel, should be deduped + + jest.advanceTimersToNextTimer(); + + expect(fetchBatch).toHaveBeenCalledTimes(1); + expect(fetchBatch).toHaveBeenCalledWith([ + {channelId: 'ch1', flaggedPostId: 'post1'}, + {channelId: 'ch2', flaggedPostId: 'post3'}, + ]); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.ts index a588449c843..9352c442ea0 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/data_loader.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export type Comparator = (a: any, b: any) => boolean; + /** * A DataLoader is an object that can be used to batch requests for fetching objects from the server for performance * reasons. @@ -8,15 +10,18 @@ abstract class DataLoader { protected readonly fetchBatch: (identifiers: Identifier[]) => Result; private readonly maxBatchSize: number; + private readonly comparator?: Comparator; protected readonly pendingIdentifiers = new Set(); constructor(args: { fetchBatch: (identifiers: Identifier[]) => Result; maxBatchSize: number; + comparator?: Comparator; }) { this.fetchBatch = args.fetchBatch; this.maxBatchSize = args.maxBatchSize; + this.comparator = args.comparator; } public queue(identifiersToLoad: Identifier[]): void { @@ -25,7 +30,22 @@ abstract class DataLoader { continue; } - this.pendingIdentifiers.add(identifier); + // If a custom comparator is provided, manually check for duplicates + if (this.comparator) { + let exists = false; + for (const existing of this.pendingIdentifiers) { + if (this.comparator(existing, identifier)) { + exists = true; + break; + } + } + if (!exists) { + this.pendingIdentifiers.add(identifier); + } + } else { + // Without a comparator, Set automatically handles uniqueness + this.pendingIdentifiers.add(identifier); + } } } @@ -37,12 +57,12 @@ abstract class DataLoader { protected prepareBatch(): {identifiers: Identifier[]; moreToLoad: boolean} { let nextBatch; - // Since we can only fetch a defined number of user statuses at a time, we need to batch the requests + // Since we can only fetch a defined number of identifiers at a time, we need to batch the requests if (this.pendingIdentifiers.size >= this.maxBatchSize) { nextBatch = []; // We use temp buffer here to store up until max buffer size - // and clear out processed user ids + // and clear out processed identifiers for (const identifier of this.pendingIdentifiers) { nextBatch.push(identifier); this.pendingIdentifiers.delete(identifier); @@ -52,7 +72,7 @@ abstract class DataLoader { } } } else { - // If we have less than max buffer size, we can directly fetch the statuses + // If we have less than max buffer size, we can directly fetch the data nextBatch = Array.from(this.pendingIdentifiers); this.pendingIdentifiers.clear(); } @@ -136,6 +156,7 @@ export class DelayedDataLoader extends DataLoader Promise; maxBatchSize: number; wait: number; + comparator?: Comparator; }) { super(args); diff --git a/webapp/platform/types/src/content_flagging.ts b/webapp/platform/types/src/content_flagging.ts index 47fb48d5f10..37be359525f 100644 --- a/webapp/platform/types/src/content_flagging.ts +++ b/webapp/platform/types/src/content_flagging.ts @@ -1,11 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {Channel} from './channels'; import type {Post} from './posts'; import type { NameMappedPropertyFields, PropertyValue, } from './properties'; +import type {Team} from './teams'; export type ContentFlaggingEvent = 'flagged' | 'assigned' | 'removed' | 'dismissed'; @@ -22,9 +24,10 @@ export type ContentFlaggingConfig = { export type ContentFlaggingState = { settings?: ContentFlaggingConfig; fields?: NameMappedPropertyFields; - postValues?: { - [key: Post['id']]: Array>; - }; + postValues?: {[key: Post['id']]: Array>}; + flaggedPosts?: {[key: Post['id']]: Post}; + channels?: {[key: Channel['id']]: Channel}; + teams?: {[key: Team['id']]: Team}; }; export enum ContentFlaggingStatus {