Data spillage caching (#35016)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

* Elliminated loadFlaggedPost

* Elliminated loadChannel

* WIP

* Cached teams data

* Always use Set in dataloader

* minor cleanup

* test: add tests for content flagging actions

* Added tests

* Handled user logout event in content flagging reducers

* Fixed content flagging playwrite tests

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Harshil Sharma 2026-02-17 11:07:25 +05:30 committed by GitHub
parent 0ec4a474d5
commit a69653ce2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 938 additions and 189 deletions

View file

@ -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();

View file

@ -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<Post | undefined>({
name: 'useGetFlaggedPost',
fetch: loadFlaggedPost,
selector: getFlaggedPost,
});
export const useGetContentFlaggingChannel = makeUseEntity<Channel | undefined, ContentFlaggingChannelRequestIdentifier>({
name: 'useGetContentFlaggingChannel',
fetch: loadContentFlaggingChannel,
selector: getContentFlaggingChannel,
});
export const useGetContentFlaggingTeam = makeUseEntity<Team | undefined, ContentFlaggingTeamRequestIdentifier>({
name: 'useGetContentFlaggingTeam',
fetch: loadContentFlaggingTeam,
selector: getContentFlaggingTeam,
});

View file

@ -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,
},
},
},
};

View file

@ -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<Post>();
const [channel, setChannel] = useState<Channel | undefined>();
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<PropertiesCardViewMetadata>(() => {
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);
}

View file

@ -19,10 +19,10 @@ import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer
import './properties_card_view.scss';
export type PostPreviewFieldMetadata = {
getPost?: (postId: string) => Promise<Post>;
post?: Post;
fetchDeletedPost?: boolean;
getChannel?: (channelId: string) => Promise<Channel>;
getTeam?: (teamId: string) => Promise<Team>;
channel?: Channel;
team?: Team;
generateFileDownloadUrl?: (fileId: string) => string;
};
@ -36,11 +36,11 @@ export type TextFieldMetadata = {
};
export type ChannelFieldMetadata = {
getChannel?: (channelId: string) => Promise<Channel>;
channel?: Channel;
};
export type TeamFieldMetadata = {
getTeam?: (teamId: string) => Promise<Team>;
team?: Team;
};
export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata | UserPropertyMetadata | ChannelFieldMetadata | TeamFieldMetadata;

View file

@ -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<typeof import('components/common/hooks/useChannel').useChannel>;
describe('ChannelPropertyRenderer', () => {
const mockChannel: Channel = {
...TestHelper.getChannelMock({
@ -30,34 +26,23 @@ describe('ChannelPropertyRenderer', () => {
} as PropertyValue<string>;
it('should render channel name and icon when channel exists', () => {
mockUseChannel.mockReturnValue(mockChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
<ChannelPropertyRenderer
value={mockValue}
metadata={{channel: mockChannel}}
/>,
);
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(
<ChannelPropertyRenderer value={mockValue}/>,
);
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(
<ChannelPropertyRenderer value={mockValue}/>,
<ChannelPropertyRenderer
value={mockValue}
metadata={{channel: undefined}}
/>,
);
expect(screen.getByTestId('channel-property')).toBeInTheDocument();
@ -70,10 +55,12 @@ describe('ChannelPropertyRenderer', () => {
type: 'P' as const,
display_name: 'Private Channel',
};
mockUseChannel.mockReturnValue(privateChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
<ChannelPropertyRenderer
value={mockValue}
metadata={{channel: privateChannel}}
/>,
);
expect(screen.getByText('Private Channel')).toBeInTheDocument();
@ -85,10 +72,12 @@ describe('ChannelPropertyRenderer', () => {
type: 'D' as const,
display_name: 'Direct Message',
};
mockUseChannel.mockReturnValue(dmChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
<ChannelPropertyRenderer
value={mockValue}
metadata={{channel: dmChannel}}
/>,
);
expect(screen.getByText('Direct Message')).toBeInTheDocument();

View file

@ -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 (
<div

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react';
import {waitFor} from '@testing-library/react';
import cloneDeep from 'lodash/cloneDeep';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
@ -13,6 +14,8 @@ import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import type {PostPreviewFieldMetadata} from 'components/properties_card_view/properties_card_view';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
@ -20,16 +23,6 @@ import type {GlobalState} from 'types/store';
import PostPreviewPropertyRenderer from './post_preview_property_renderer';
jest.mock('components/common/hooks/useChannel');
jest.mock('components/common/hooks/use_team');
jest.mock('components/common/hooks/usePost');
jest.mock('mattermost-redux/client');
const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
const mockUseTeam = require('components/common/hooks/use_team').useTeam as jest.MockedFunction<any>;
const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
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<GlobalState> = {
@ -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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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(
<PostPreviewPropertyRenderer {...defaultProps}/>,
<PostPreviewPropertyRenderer {...props}/>,
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();

View file

@ -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<unknown>;
value?: PropertyValue<unknown>;
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();

View file

@ -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<string>,
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(
<TeamPropertyRenderer {...defaultProps}/>,
state,
);
renderWithContext(<TeamPropertyRenderer {...props}/>, 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(
<TeamPropertyRenderer {...defaultProps}/>,
state,

View file

@ -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 (
<div

View file

@ -8,4 +8,7 @@ export default keyMirror({
RECEIVED_POST_CONTENT_FLAGGING_FIELDS: null,
RECEIVED_POST_CONTENT_FLAGGING_VALUES: null,
CONTENT_FLAGGING_REPORT_VALUE_UPDATED: null,
RECEIVED_FLAGGED_POST: null,
RECEIVED_CONTENT_FLAGGING_CHANNEL: null,
RECEIVED_CONTENT_FLAGGING_TEAM: null,
});

View file

@ -1,8 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {waitFor} from '@testing-library/react';
import nock from 'nock';
import type {GlobalState} from '@mattermost/types/store';
import * as Actions from 'mattermost-redux/actions/content_flagging';
import {Client4} from 'mattermost-redux/client';
@ -40,3 +43,127 @@ describe('Actions.getTeamContentFlaggingStatus', () => {
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);
});
});
});

View file

@ -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<NameMappedPrope
};
}
function getFlaggedPost(flaggedPostId: string): ActionFuncAsync<Post> {
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<Post> {
return async (dispatch, getState, {loaders}: any) => {
if (!loaders.flaggedPostLoader) {
loaders.flaggedPostLoader = new DelayedDataLoader<Post['id']>({
fetchBatch: ([postId]) => dispatch(getFlaggedPost(postId)),
maxBatchSize: 1,
wait: 200,
});
}
const loader = loaders.flaggedPostLoader as DelayedDataLoader<Post['id']>;
loader.queue([flaggedPostId]);
return {};
};
}
function getContentFlaggingChannel(channelId: string, flaggedPostId: string): ActionFuncAsync<Channel> {
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<Channel> {
return async (dispatch, getState, {loaders}: any) => {
if (!loaders.contentFlaggingChannelLoader) {
loaders.contentFlaggingChannelLoader =
new DelayedDataLoader<ContentFlaggingChannelRequestIdentifier>({
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<ContentFlaggingChannelRequestIdentifier>;
loader.queue([identifier]);
return {};
};
}
function getContentFlaggingTeam(teamId: string, flaggedPostId: string): ActionFuncAsync<Team> {
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<Team> {
return async (dispatch, getState, {loaders}: any) => {
if (!loaders.contentFlaggingTeamLoader) {
loaders.contentFlaggingTeamLoader =
new DelayedDataLoader<ContentFlaggingTeamRequestIdentifier>({
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<ContentFlaggingTeamRequestIdentifier>;
loader.queue([identifier]);
return {};
};
}
export function getPostContentFlaggingValues(postId: string): ActionFuncAsync<Array<PropertyValue<unknown>>> {
return async (dispatch, getState) => {
let response;

View file

@ -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,
});

View file

@ -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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
entities: {
contentFlagging: {
flaggedPosts: {},
},
},
};
expect(getFlaggedPost(state as GlobalState, 'nonexistent_post')).toBeUndefined();
});
test('should return undefined when flaggedPosts is undefined', () => {
const state: DeepPartial<GlobalState> = {
entities: {
contentFlagging: {},
},
};
expect(getFlaggedPost(state as GlobalState, 'post_id')).toBeUndefined();
});
});

View file

@ -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];
};

View file

@ -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<ObjectIdentifier>({
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<ObjectIdentifier>({
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<ObjectIdentifier>({
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<ObjectIdentifier>({
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<ObjectIdentifier>({
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'},
]);
});
});

View file

@ -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<Identifier, Result = unknown> {
protected readonly fetchBatch: (identifiers: Identifier[]) => Result;
private readonly maxBatchSize: number;
private readonly comparator?: Comparator;
protected readonly pendingIdentifiers = new Set<Identifier>();
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<Identifier, Result = unknown> {
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<Identifier, Result = unknown> {
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<Identifier, Result = unknown> {
}
}
} 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<Identifier> extends DataLoader<Identifier, Promis
fetchBatch: (identifiers: Identifier[]) => Promise<unknown>;
maxBatchSize: number;
wait: number;
comparator?: Comparator;
}) {
super(args);

View file

@ -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<PropertyValue<unknown>>;
};
postValues?: {[key: Post['id']]: Array<PropertyValue<unknown>>};
flaggedPosts?: {[key: Post['id']]: Post};
channels?: {[key: Channel['id']]: Channel};
teams?: {[key: Team['id']]: Team};
};
export enum ContentFlaggingStatus {