Data spillage card (#33646)

* WIP

* Added post flagging properties setup

* Added tests

* Removed error in app startup when content flaghging setup fails

* Updated sync condition:

* WIP

* MOved to data migration

* lint fix

* CI

* added new migration mocks

* Used setup for tests

* some comment

* removed empty files

* Added another property field

* WIP

* Updated test

* WIP

* Added card component

* WIP

* Displayed post preview

* WIP

* WIP

* Added team property:

* Adde post author field

* displayed post creation time

* WIP

* Added user selector

* refactored to use field sub types

* migration post types

* Added actions

* Added isRHS prop

* bvase finished

* Created separate single select

* Making common selector

* lint fixes

* i18n fixes

* cleanup

* fix: correct UserSelector mock import and props in test file

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* fixed a test

* Added tests

* Added tests

* test: mock PostPreviewPropertyRenderer component in test file

* Fix tests

* Fix tests

* Stored version in system key

* test: add initial test file for use_team hook

* feat: add tests for useTeam hook based on usePost hook tests

* test: add useChannel hook test file

* feat: Add tests for useChannel hook

* test: add tests for DataSpillageAction component

* Handled dleted channel and team

* test: add comprehensive tests for ChannelPropertyRenderer

* Added ChannelPropertyRenderer tests

* test: add empty test file for post preview property renderer

* test: add comprehensive tests for PostPreviewPropertyRenderer

* test: update PostPreviewPropertyRenderer tests to use toBeVisible and assert content

* Added p[ost property renderer test

* test: remove PostMessagePreview mock and define base state for rendering

* Added p[ost property renderer test

* test: add test case for post with file attachments

* test: add assertions for file attachments visibility in post preview

* Added post property renderer test

* test: add empty test file for select property renderer

* test: add comprehensive tests for SelectPropertyRenderer

* Added base tests for select property renderer

* Added base tests for select property renderer

* test: add empty test file for team property renderer

* test: add comprehensive tests for TeamPropertyRenderer

* test: add assertion for TeamIcon rendering in TeamPropertyRenderer test

* test: use toBeVisible instead of toBeInTheDocument in team property renderer tests

* test: replace toBeInTheDocument with toBeVisible for team name assertion

* Added TeamPropertyRenderer tests

* test: add test file for text property renderer

* test: add comprehensive tests for TextPropertyRenderer

* Added TextPropertyRenderer tests

* test: add empty test file for timestamp property renderer

* test: add comprehensive tests for TimestampPropertyRenderer

* test: verify timestamp rendering with actual date and time values

* test: remove redundant test id visibility check in timestamp property renderer test

* feat: Add base state and test cases for 12 and 24 hour time formats

* Added TimestampPropertyRenderer tests

* test: add empty test file for user property renderer

* test: add comprehensive tests for UserPropertyRenderer

* WIP

* test: improve user property renderer test assertions

* Added UserPropertyRenderer tests

* test: add empty test file for propertyValueRenderer

* test: add comprehensive tests for PropertyValueRenderer with mocked components

* test: update text property rendering test assertion

* feat: add PropertyValue<null> type casting in test files

* Added PropertyValueRenderer test

* lint fix

* fixed tests

* refactor: Update ChannelNotificationsModal tests to remove snapshot testing and improve assertions

* refactor: replace fireEvent with userEvent in channel notifications modal tests

* Updated test to not use snapshots and use deep rendering

* refactor: Update DotMenu tests to use renderWithContext and userEvent

* Updating tests

* Updating tests

* Updating tests

* lint fix

* CI

* removed unused snapshots

* Updated text colot and removed hover color effect

* Lint fixes

* SCSS lint fix

* fixed a test

* Used useUser gook

---------

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
This commit is contained in:
Harshil Sharma 2025-08-27 10:33:57 +05:30 committed by GitHub
parent 9e0e1e9c93
commit 22d0e66fbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 4227 additions and 4123 deletions

View file

@ -640,7 +640,7 @@ func (s *Server) doSetupContentFlaggingProperties() error {
contentFlaggingPropertyNameStatus: {
GroupID: group.ID,
Name: contentFlaggingPropertyNameStatus,
Type: model.PropertyFieldTypeText,
Type: model.PropertyFieldTypeSelect,
},
contentFlaggingPropertyNameReportingUserID: {
GroupID: group.ID,
@ -650,7 +650,7 @@ func (s *Server) doSetupContentFlaggingProperties() error {
contentFlaggingPropertyNameReportingReason: {
GroupID: group.ID,
Name: contentFlaggingPropertyNameReportingReason,
Type: model.PropertyFieldTypeText,
Type: model.PropertyFieldTypeSelect,
},
contentFlaggingPropertyNameReportingComment: {
GroupID: group.ID,

View file

@ -14,15 +14,16 @@ import ContentFlaggingContentReviewers from './content_reviewers';
// Mock the UserMultiSelector component
jest.mock('../../content_flagging/user_multiselector/user_multiselector', () => ({
UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (userIds: string[]) => void}) => (
__esModule: true,
UserSelector: ({id, multiSelectInitialValue, multiSelectOnChange}: {id: string; multiSelectInitialValue: string[]; multiSelectOnChange: (userIds: string[]) => void}) => (
<div data-testid={`user-multi-selector-${id}`}>
<button
onClick={() => onChange(['user1', 'user2'])}
onClick={() => multiSelectOnChange(['user1', 'user2'])}
data-testid={`${id}-change-users`}
>
{'Change Users'}
</button>
<span data-testid={`${id}-initial-value`}>{initialValue.join(',')}</span>
<span data-testid={`${id}-initial-value`}>{multiSelectInitialValue.join(',')}</span>
</div>
),
}));

View file

@ -16,7 +16,7 @@ import {
SectionHeader,
} from 'components/admin_console/system_properties/controls';
import {UserMultiSelector} from '../../content_flagging/user_multiselector/user_multiselector';
import {UserSelector} from '../../content_flagging/user_multiselector/user_multiselector';
import type {SystemConsoleCustomSettingsComponentProps} from '../../schema_admin_settings';
import './content_reviewers.scss';
@ -148,10 +148,11 @@ export default function ContentFlaggingContentReviewers(props: SystemConsoleCust
</div>
<div className='setting-content'>
<UserMultiSelector
<UserSelector
isMulti={true}
id='content_reviewers_common_reviewers'
initialValue={reviewerSetting.CommonReviewerIds}
onChange={handleCommonReviewersChange}
multiSelectInitialValue={reviewerSetting.CommonReviewerIds}
multiSelectOnChange={handleCommonReviewersChange}
/>
</div>
</div>

View file

@ -18,21 +18,6 @@ jest.mock('mattermost-redux/actions/teams', () => ({
searchTeams: jest.fn(),
}));
jest.mock('../../user_multiselector/user_multiselector', () => ({
UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (ids: string[]) => void}) => (
<div data-testid={`user-multi-selector-${id}`}>
<span>{`Selected: {${initialValue.join(', ')}`}</span>
<button onClick={() => onChange(['user1', 'user2'])}>
{'Change Reviewers'}
</button>
</div>
),
}));
jest.mock('components/widgets/team_icon/team_icon', () => ({
TeamIcon: ({content}: {content: string}) => <div data-testid='team-icon'>{content}</div>,
}));
const mockSearchTeams = jest.mocked(searchTeams);
describe('TeamReviewersSection', () => {
@ -265,40 +250,6 @@ describe('TeamReviewersSection', () => {
});
});
test('should handle reviewer selection changes', async () => {
const onChange = jest.fn();
renderWithContext(
<TeamReviewersSection
{...defaultProps}
onChange={onChange}
/>,
);
await waitFor(() => {
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
});
await waitFor(() => {
const teamNameCells = screen.getAllByTestId('teamName');
expect(teamNameCells).toHaveLength(2);
expect(teamNameCells[0]).toBeVisible();
expect(teamNameCells[0]).toHaveTextContent('Team One');
expect(teamNameCells[1]).toBeVisible();
expect(teamNameCells[1]).toHaveTextContent('Team Two');
});
const changeReviewersButton = screen.getAllByText('Change Reviewers')[0];
fireEvent.click(changeReviewersButton);
expect(onChange).toHaveBeenCalledWith({
team1: {
Enabled: false,
ReviewerIds: ['user1', 'user2'],
},
});
});
test('should display existing reviewer settings', async () => {
const teamReviewersSetting = {
team1: {
@ -354,17 +305,6 @@ describe('TeamReviewersSection', () => {
});
test('should reset page to 0 when searching', async () => {
// mockSearchTeams.
// mockReturnValueOnce({
// data: {teams: mockTeams, total_count: 20},
// } as never).
// mockResolvedValueOnce({
// data: {teams: mockTeams, total_count: 20},
// } as never).
// mockResolvedValueOnce({
// data: {teams: mockTeams, total_count: 5},
// } as never);
mockSearchTeams.mockReturnValueOnce(async () => ({
data: {teams: mockTeams, total_count: 20},
})).
@ -480,10 +420,6 @@ describe('TeamReviewersSection', () => {
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
});
await waitFor(() => {
expect(screen.getAllByText('Team One')).toHaveLength(4);
});
const updatedToggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0];
// Second click - disable

View file

@ -16,7 +16,7 @@ import {TeamIcon} from 'components/widgets/team_icon/team_icon';
import * as Utils from 'utils/utils';
import {UserMultiSelector} from '../../user_multiselector/user_multiselector';
import {UserSelector} from '../../user_multiselector/user_multiselector';
import './team_reviewers_section.scss';
@ -141,10 +141,11 @@ export default function TeamReviewers({teamReviewersSetting, onChange}: Props):
</div>
),
reviewers: (
<UserMultiSelector
<UserSelector
isMulti={true}
id={`team_content_reviewer_${team.id}`}
initialValue={teamReviewersSetting[team.id]?.ReviewerIds || []}
onChange={getHandleReviewersChange(team.id)}
multiSelectInitialValue={teamReviewersSetting[team.id]?.ReviewerIds || []}
multiSelectOnChange={getHandleReviewersChange(team.id)}
/>
),
enabled: (

View file

@ -13,4 +13,10 @@
display: flex;
gap: 8px;
}
&.singleSelect {
.UserMultiSelector__value-container {
display: flex;
}
}
}

View file

@ -5,7 +5,7 @@ import classNames from 'classnames';
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {MultiValue} from 'react-select';
import type {MultiValue, SingleValue} from 'react-select';
import AsyncSelect from 'react-select/async';
import type {UserProfile} from '@mattermost/types/users';
@ -16,9 +16,9 @@ import {getUsersByIDs} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import {UserProfilePill} from './user_profile_pill';
import {MultiUserProfilePill, SingleUserProfilePill} from './user_profile_pill';
import {UserOptionComponent} from '../../content_flagging/user_multiselector/user_profile_option';
import {MultiUserOptionComponent, SingleUserOptionComponent} from '../../content_flagging/user_multiselector/user_profile_option';
import {LoadingIndicator} from '../../system_users/system_users_filters_popover/system_users_filter_team';
import './user_multiselect.scss';
@ -29,29 +29,51 @@ export type AutocompleteOptionType<T> = {
raw?: T;
}
type Props = {
id: string;
className?: string;
onChange: (selectedUserIds: string[]) => void;
initialValue?: string[];
hasError?: boolean;
const BASE_SELECT_COMPONENTS = {
LoadingIndicator,
DropdownIndicator: () => null,
IndicatorSeparator: () => null,
};
type MultiSelectProps = {
multiSelectOnChange?: (selectedUserIds: string[]) => void;
multiSelectInitialValue?: string[];
}
export function UserMultiSelector({id, className, onChange, initialValue, hasError}: Props) {
type SingleSelectProps = {
singleSelectOnChange?: (selectedUserIds: string) => void;
singleSelectInitialValue?: string;
}
type Props = MultiSelectProps & SingleSelectProps & {
id: string;
isMulti: boolean;
className?: string;
hasError?: boolean;
placeholder?: React.ReactNode;
showDropdownIndicator?: boolean;
};
export function UserSelector({id, isMulti, className, multiSelectOnChange, multiSelectInitialValue, singleSelectOnChange, singleSelectInitialValue, hasError, placeholder, showDropdownIndicator}: Props) {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const initialDataLoaded = useRef<boolean>(false);
const initialValue = useMemo(() => {
return isMulti ? multiSelectInitialValue : [singleSelectInitialValue || ''];
}, [isMulti, multiSelectInitialValue, singleSelectInitialValue]);
useEffect(() => {
const fetchInitialData = async () => {
await dispatch(getMissingProfilesByIds(initialValue || []));
const param = isMulti ? multiSelectInitialValue : [singleSelectInitialValue || ''];
await dispatch(getMissingProfilesByIds(param || []));
initialDataLoaded.current = true;
};
if (initialValue && !initialDataLoaded.current) {
if (Boolean(initialValue) && !initialDataLoaded.current) {
fetchInitialData();
}
}, [dispatch, initialValue]);
}, [dispatch, initialValue, isMulti, multiSelectInitialValue, singleSelectInitialValue]);
const initialUsers = useSelector((state: GlobalState) => getUsersByIDs(state, initialValue || []));
const selectInitialValue = initialUsers.
@ -64,7 +86,7 @@ export function UserMultiSelector({id, className, onChange, initialValue, hasErr
const userLoadingMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.loading', defaultMessage: 'Loading users'}), [formatMessage]);
const noUsersMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.noUsers', defaultMessage: 'No users found'}), [formatMessage]);
const placeholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'});
const defaultPlaceholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'});
const searchUsers = useMemo(() => debounce(async (searchTerm: string, callback) => {
try {
@ -89,35 +111,87 @@ export function UserMultiSelector({id, className, onChange, initialValue, hasErr
}
}, 200), [dispatch]);
function handleOnChange(value: MultiValue<AutocompleteOptionType<UserProfile>>) {
const multiSelectHandleOnChange = useCallback((value: MultiValue<AutocompleteOptionType<UserProfile>>) => {
const selectedUserIds = value.map((option) => option.value);
onChange?.(selectedUserIds);
multiSelectOnChange?.(selectedUserIds);
}, [multiSelectOnChange]);
const singleSelectHandleOnChange = useCallback((value: SingleValue<AutocompleteOptionType<UserProfile>>) => {
const selectedUserIds = value?.value || '';
singleSelectOnChange?.(selectedUserIds);
}, [singleSelectOnChange]);
const multiSelectComponents = useMemo(() => {
const componentObj = {
...BASE_SELECT_COMPONENTS,
Option: MultiUserOptionComponent,
MultiValue: MultiUserProfilePill,
};
if (showDropdownIndicator) {
// @ts-expect-error doing this any other way runs into TypeScript nightmares due to very complex ReactSelect types
delete componentObj.DropdownIndicator;
}
return componentObj;
}, [showDropdownIndicator]);
const singleSelectComponents = useMemo(() => {
const componentObj = {
...BASE_SELECT_COMPONENTS,
Option: SingleUserOptionComponent,
SingleValue: SingleUserProfilePill,
};
if (showDropdownIndicator) {
// @ts-expect-error doing this any other way runs into TypeScript nightmares due to very complex ReactSelect types
delete componentObj.DropdownIndicator;
}
return componentObj;
}, [showDropdownIndicator]);
const baseProps = useMemo(() => {
return {
id,
inputId: `${id}_input`,
classNamePrefix: 'UserMultiSelector',
className: classNames('Input Input__focus', className, {error: hasError}),
isClearable: false,
hideSelectedOptions: true,
cacheOptions: true,
placeholder: placeholder || defaultPlaceholder,
loadingMessage: userLoadingMessage,
noOptionsMessage: noUsersMessage,
loadOptions: searchUsers,
menuPortalTarget: document.body,
};
}, [className, defaultPlaceholder, hasError, id, noUsersMessage, placeholder, searchUsers, userLoadingMessage]);
const containerClassName = classNames('UserMultiSelector', {multiSelect: isMulti, singleSelect: !isMulti});
if (isMulti) {
return (
<div className={containerClassName}>
<AsyncSelect
{...baseProps}
isMulti={true}
onChange={multiSelectHandleOnChange}
value={selectInitialValue}
components={multiSelectComponents}
/>
</div>
);
}
return (
<div className='UserMultiSelector'>
<div className={containerClassName}>
<AsyncSelect
id={id}
inputId={`${id}_input`}
classNamePrefix='UserMultiSelector'
className={classNames('Input Input__focus', className, {error: hasError})}
isMulti={true}
isClearable={false}
hideSelectedOptions={true}
cacheOptions={true}
placeholder={placeholder}
loadingMessage={userLoadingMessage}
noOptionsMessage={noUsersMessage}
loadOptions={searchUsers}
onChange={handleOnChange}
value={selectInitialValue}
components={{
LoadingIndicator,
DropdownIndicator: () => null,
IndicatorSeparator: () => null,
Option: UserOptionComponent,
MultiValue: UserProfilePill,
}}
{...baseProps}
isMulti={false}
onChange={singleSelectHandleOnChange}
value={selectInitialValue ? selectInitialValue[0] : null}
components={singleSelectComponents}
/>
</div>
);

View file

@ -17,7 +17,29 @@ import type {AutocompleteOptionType} from './user_multiselector';
import './user_profile_option.scss';
export function UserOptionComponent(props: OptionProps<AutocompleteOptionType<UserProfile>, true>) {
export function MultiUserOptionComponent(props: OptionProps<AutocompleteOptionType<UserProfile>, true>) {
const {data, innerProps} = props;
const userProfile = data.raw;
const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile));
return (
<div
className='UserOptionComponent'
{...innerProps}
>
<Avatar
size='xxs'
username={userProfile?.username}
url={imageURLForUser(data.value)}
/>
{userDisplayName}
</div>
);
}
export function SingleUserOptionComponent(props: OptionProps<AutocompleteOptionType<UserProfile>, false>) {
const {data, innerProps} = props;
const userProfile = data.raw;

View file

@ -10,7 +10,7 @@ import {fireEvent, renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {AutocompleteOptionType} from './user_multiselector';
import {UserProfilePill} from './user_profile_pill';
import {MultiUserProfilePill} from './user_profile_pill';
describe('components/admin_console/content_flagging/user_multiselector/UserProfilePill', () => {
const baseProps = {
@ -70,7 +70,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
test('should render user profile pill with avatar and display name', () => {
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
initialState,
);
@ -79,7 +79,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
test('should render with correct user display name', () => {
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
initialState,
);
@ -90,7 +90,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
test('should render Avatar component with correct props', () => {
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
initialState,
);
@ -100,7 +100,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
test('should render Remove component with close icon', () => {
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
initialState,
);
@ -118,7 +118,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
};
const {container} = renderWithContext(
<UserProfilePill {...propsWithClick}/>,
<MultiUserProfilePill {...propsWithClick}/>,
initialState,
);
@ -143,7 +143,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
} as unknown as MultiValueProps<AutocompleteOptionType<UserProfile>, true>;
const {container} = renderWithContext(
<UserProfilePill {...propsWithoutUsername}/>,
<MultiUserProfilePill {...propsWithoutUsername}/>,
initialState,
);
@ -165,7 +165,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
};
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
stateWithUsernameDisplay,
);
@ -175,7 +175,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi
test('should apply correct CSS classes', () => {
const {container} = renderWithContext(
<UserProfilePill {...baseProps}/>,
<MultiUserProfilePill {...baseProps}/>,
initialState,
);

View file

@ -1,8 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {JSX} from 'react';
import React from 'react';
import {useSelector} from 'react-redux';
import type {SingleValueProps} from 'react-select';
import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue';
import type {UserProfile} from '@mattermost/types/users';
@ -32,9 +34,39 @@ function Remove(props: any) {
);
}
export function UserProfilePill(props: MultiValueProps<AutocompleteOptionType<UserProfile>, true>) {
export function MultiUserProfilePill(props: MultiValueProps<AutocompleteOptionType<UserProfile>, true>) {
const {data, innerProps, selectProps, removeProps} = props;
return (
<BaseUserProfilePill
data={data}
innerProps={innerProps}
selectProps={selectProps}
removeProps={removeProps}
/>
);
}
export function SingleUserProfilePill(props: SingleValueProps<AutocompleteOptionType<UserProfile>, false>) {
const {data, innerProps, selectProps} = props;
return (
<BaseUserProfilePill
data={data}
innerProps={innerProps}
selectProps={selectProps}
/>
);
}
type Props = {
data: AutocompleteOptionType<UserProfile>;
innerProps: JSX.IntrinsicElements['div'];
selectProps: unknown;
removeProps?: JSX.IntrinsicElements['div'];
}
function BaseUserProfilePill({data, innerProps, selectProps, removeProps}: Props) {
const userProfile = data.raw;
const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile));
@ -51,12 +83,15 @@ export function UserProfilePill(props: MultiValueProps<AutocompleteOptionType<Us
{userDisplayName}
<Remove
data={data}
innerProps={innerProps}
selectProps={selectProps}
{...removeProps}
/>
{
removeProps &&
<Remove
data={data}
innerProps={innerProps}
selectProps={selectProps}
{...removeProps}
/>
}
</div>
);
}

View file

@ -184,6 +184,13 @@ Object {
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unmount": [Function],
"updateStoreState": [Function],
}

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, fireEvent, waitFor} from '@testing-library/react';
import {act, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {ChannelMembership} from '@mattermost/types/channels';
@ -53,23 +54,25 @@ describe('ChannelNotificationsModal', () => {
};
test('should not show other settings if channel is mute', async () => {
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...baseProps}/>,
);
const muteChannel = screen.getByTestId('muteChannel');
fireEvent.click(muteChannel);
await userEvent.click(muteChannel);
expect(muteChannel).toBeChecked();
const AlertBanner = screen.queryByText('This channel is muted');
expect(AlertBanner).toBeVisible();
expect(screen.queryByText('Desktop Notifications')).toBeNull();
const alertBanner = screen.getByText('This channel is muted');
expect(alertBanner).toBeInTheDocument();
expect(screen.queryByText('Mobile Notifications')).toBeNull();
expect(screen.queryByText('Follow all threads in this channel')).not.toBeNull();
expect(screen.queryByText('Desktop Notifications')).not.toBeInTheDocument();
expect(screen.queryByText('Mobile Notifications')).not.toBeInTheDocument();
expect(screen.getByText('Follow all threads in this channel')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
@ -88,18 +91,25 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
test('should save correctly for \'Ignore mentions for @channel, @here and @all\'', async () => {
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...baseProps}/>,
);
const ignoreChannel = screen.getByTestId('ignoreMentions');
fireEvent.click(ignoreChannel);
await act(async () => {
await userEvent.click(ignoreChannel);
});
expect(ignoreChannel).toBeChecked();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
// Verify the checkbox label is present
expect(screen.getByText('Ignore mentions for @channel, @here and @all')).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
'current_user_id',
@ -117,35 +127,50 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
test('should check the options in the desktop notifications', async () => {
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...baseProps}/>,
);
expect(screen.queryByText('Desktop Notifications')).toBeVisible();
expect(screen.getByText('Desktop Notifications')).toBeInTheDocument();
const AlllabelRadio: HTMLInputElement = screen.getByTestId(
'desktopNotification-all',
);
fireEvent.click(AlllabelRadio);
expect(AlllabelRadio.checked).toEqual(true);
const allRadio: HTMLInputElement = screen.getByTestId('desktopNotification-all');
const mentionsRadio: HTMLInputElement = screen.getByTestId('desktopNotification-mention');
const nothingRadio: HTMLInputElement = screen.getByTestId('desktopNotification-none');
const MentionslabelRadio: HTMLInputElement = screen.getByTestId(
'desktopNotification-mention',
);
fireEvent.click(MentionslabelRadio);
expect(MentionslabelRadio.checked).toEqual(true);
// Verify all radio options are present
expect(allRadio).toBeInTheDocument();
expect(mentionsRadio).toBeInTheDocument();
expect(nothingRadio).toBeInTheDocument();
const NothinglabelRadio: HTMLInputElement = screen.getByTestId(
'desktopNotification-none',
);
fireEvent.click(NothinglabelRadio);
expect(NothinglabelRadio.checked).toEqual(true);
// Test clicking through the options
await act(async () => {
await userEvent.click(allRadio);
});
expect(allRadio).toBeChecked();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
await act(async () => {
await userEvent.click(mentionsRadio);
});
expect(mentionsRadio).toBeChecked();
expect(allRadio).not.toBeChecked();
await act(async () => {
await userEvent.click(nothingRadio);
});
expect(nothingRadio).toBeChecked();
expect(mentionsRadio).not.toBeChecked();
// Verify the labels are present
expect(screen.getByText(/All new messages/)).toBeInTheDocument();
expect(screen.getByText(/Mentions, direct messages, and keywords only/)).toBeInTheDocument();
expect(screen.getByText(/Nothing/)).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
'current_user_id',
@ -163,17 +188,20 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
test('should disable message notification sound if option is unchecked', async () => {
renderWithContext(<ChannelNotificationsModal {...baseProps}/>);
// Since the default value is checked, we will uncheck the checkbox
fireEvent.click(screen.getByTestId('desktopNotificationSoundsCheckbox'));
await act(async () => {
await userEvent.click(screen.getByTestId('desktopNotificationSoundsCheckbox'));
});
expect(screen.getByTestId('desktopNotificationSoundsCheckbox')).not.toBeChecked();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() => {
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
'current_user_id',
@ -197,31 +225,36 @@ describe('ChannelNotificationsModal', () => {
renderWithContext(<ChannelNotificationsModal {...baseProps}/>);
// Since the default value is on, we will uncheck the checkbox
fireEvent.click(screen.getByTestId('desktopNotificationSoundsCheckbox'));
await userEvent.click(screen.getByTestId('desktopNotificationSoundsCheckbox'));
expect(screen.getByTestId('desktopNotificationSoundsCheckbox')).not.toBeChecked();
// Reset to default button is clicked
fireEvent.click(screen.getByTestId('resetToDefaultButton-desktop'));
await userEvent.click(screen.getByTestId('resetToDefaultButton-desktop'));
// Verify that the checkbox is checked to default to user desktop notification sound
expect(screen.getByTestId('desktopNotificationSoundsCheckbox')).toBeChecked();
});
test('should save the options exactly same as Desktop for mobile if use same as desktop checkbox is checked', async () => {
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...baseProps}/>,
);
expect(screen.queryByText('Desktop Notifications')).toBeVisible();
expect(screen.getByText('Desktop Notifications')).toBeInTheDocument();
expect(screen.getByText('Mobile Notifications')).toBeInTheDocument();
const sameAsDesktop: HTMLInputElement = screen.getByTestId(
'sameMobileSettingsDesktop',
);
expect(sameAsDesktop.checked).toEqual(true);
const sameAsDesktop: HTMLInputElement = screen.getByTestId('sameMobileSettingsDesktop');
expect(sameAsDesktop).toBeChecked();
expect(screen.queryByText('All new messages')).toBeNull();
// Verify the checkbox label is present
expect(screen.getByText('Use the same notification settings as desktop')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
// When "same as desktop" is checked, mobile-specific options should not be visible
expect(screen.queryByTestId('mobile-notify-me-radio-section')).not.toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
'current_user_id',
@ -239,7 +272,6 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
test('should check the options in the mobile notifications', async () => {
@ -252,20 +284,42 @@ describe('ChannelNotificationsModal', () => {
},
} as unknown as ChannelMembership,
};
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...props}/>,
);
fireEvent.click(screen.getByTestId('MobileNotification-all'));
expect(screen.getByTestId('MobileNotification-all')).toBeChecked();
// First uncheck "same as desktop" to show mobile options
const sameAsDesktop = screen.getByTestId('sameMobileSettingsDesktop');
expect(sameAsDesktop).not.toBeChecked();
fireEvent.click(screen.getByTestId('MobileNotification-mention'));
expect(screen.getByTestId('MobileNotification-mention')).toBeChecked();
// Now mobile notification options should be visible
expect(screen.getByTestId('mobile-notify-me-radio-section')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('MobileNotification-none'));
expect(screen.getByTestId('MobileNotification-none')).toBeChecked();
const allRadio = screen.getByTestId('MobileNotification-all');
const mentionRadio = screen.getByTestId('MobileNotification-mention');
const noneRadio = screen.getByTestId('MobileNotification-none');
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
// Test clicking through the mobile notification options
await act(async () => {
await userEvent.click(allRadio);
});
expect(allRadio).toBeChecked();
await act(async () => {
await userEvent.click(mentionRadio);
});
expect(mentionRadio).toBeChecked();
expect(allRadio).not.toBeChecked();
await act(async () => {
await userEvent.click(noneRadio);
});
expect(noneRadio).toBeChecked();
expect(mentionRadio).not.toBeChecked();
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
'current_user_id',
@ -283,21 +337,28 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
test('should show not auto follow, desktop threads and mobile threads settings if collapsed reply threads is enabled', async () => {
test('should not show auto follow threads section if collapsed reply threads is disabled', async () => {
const props = {
...baseProps,
collapsedReplyThreads: false,
};
const wrapper = renderWithContext(
renderWithContext(
<ChannelNotificationsModal {...props}/>,
);
expect(screen.queryByText('Follow all threads in this channel')).toBeNull();
// Auto follow threads section should not be visible when collapsed threads is disabled
expect(screen.queryByText('Follow all threads in this channel')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /Save/i}));
// But other sections should still be present
expect(screen.getByText('Desktop Notifications')).toBeInTheDocument();
expect(screen.getByText('Mobile Notifications')).toBeInTheDocument();
expect(screen.getByText('Mute or ignore')).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: /Save/i}));
});
await waitFor(() =>
expect(baseProps.actions.updateChannelNotifyProps).toHaveBeenCalledWith(
@ -316,7 +377,6 @@ describe('ChannelNotificationsModal', () => {
},
),
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import * as ReactRedux from 'react-redux';
import {Client4} from 'mattermost-redux/client';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {useChannel} from './useChannel';
describe('useChannel', () => {
const channel1 = TestHelper.getChannelMock({id: 'channel1'});
const channel2 = TestHelper.getChannelMock({id: 'channel2'});
describe('with fake dispatch', () => {
const dispatchMock = jest.fn();
beforeAll(() => {
jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => dispatchMock);
});
afterAll(() => {
jest.restoreAllMocks();
});
test("should return the channel if it's already in the store", () => {
const {result} = renderHookWithContext(
() => useChannel('channel1'),
{
entities: {
channels: {
channels: {
channel1,
},
},
},
},
);
expect(result.current).toBe(channel1);
expect(dispatchMock).not.toHaveBeenCalled();
});
test("should fetch the channel if it's not in the store", () => {
const {result} = renderHookWithContext(
() => useChannel('channel1'),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
test('should only attempt to fetch the channel once regardless of how many times the hook is used', () => {
const {result, rerender} = renderHookWithContext(
() => useChannel('channel1'),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
for (let i = 0; i < 10; i++) {
rerender();
}
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
test('should attempt to fetch different channels if the channel ID changes', () => {
let channelId = 'channel1';
const {result, rerender} = renderHookWithContext(
() => useChannel(channelId),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
channelId = 'channel2';
rerender();
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(2);
});
test("should only attempt to fetch each channel once when they aren't loaded", () => {
let channelId = 'channel1';
const {result, replaceStoreState, rerender} = renderHookWithContext(
() => useChannel(channelId),
);
// Initial state without channel1 loaded
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
// Simulate the response to loading channel1
replaceStoreState({
entities: {
channels: {
channels: {
channel1,
},
},
},
});
expect(result.current).toBe(channel1);
expect(dispatchMock).toHaveBeenCalledTimes(1);
// Switch to channel2
channelId = 'channel2';
rerender();
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(2);
// Simulate the response to loading channel2
replaceStoreState({
entities: {
channels: {
channels: {
channel1,
channel2,
},
},
},
});
expect(result.current).toBe(channel2);
expect(dispatchMock).toHaveBeenCalledTimes(2);
// Switch back to channel1 which has already been loaded
channelId = 'channel1';
rerender();
expect(result.current).toBe(channel1);
expect(dispatchMock).toHaveBeenCalledTimes(2);
});
test("shouldn't attempt to load anything when given an empty channel ID", () => {
const {result} = renderHookWithContext(
() => useChannel(''),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
});
});
describe('with real dispatch', () => {
beforeAll(() => {
Client4.setUrl('http://localhost:8065');
});
test("should only attempt to fetch each channel once when they aren't loaded", async () => {
const channel1Mock = nock(Client4.getBaseRoute()).
get(`/channels/${channel1.id}`).
once().
reply(200, channel1);
const channel2Mock = nock(Client4.getBaseRoute()).
get(`/channels/${channel2.id}`).
once().
reply(200, channel2);
let channelId = 'channel1';
const {result, rerender, waitForNextUpdate} = renderHookWithContext(
() => useChannel(channelId),
);
// Initial state without channel1 loaded
expect(result.current).toEqual(undefined);
expect(channel1Mock.isDone()).toBe(false);
expect(channel2Mock.isDone()).toBe(false);
// Wait for the response with channel1
await waitForNextUpdate();
expect(channel1Mock.isDone()).toBe(true);
expect(channel2Mock.isDone()).toBe(false);
expect(result.current).toEqual(channel1);
// Switch to channel2
channelId = 'channel2';
rerender();
expect(result.current).toEqual(undefined);
// Wait for the response with channel2
await waitForNextUpdate();
expect(channel1Mock.isDone()).toBe(true);
expect(channel2Mock.isDone()).toBe(true);
expect(result.current).toEqual(channel2);
// Switch back to channel1 which has already been loaded
channelId = 'channel1';
rerender();
expect(result.current).toEqual(channel1);
});
});
});

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {makeUseEntity} from 'components/common/hooks/useEntity';
export const useChannel = makeUseEntity<Channel>({
name: 'useChannel',
fetch: (channelId: string) => fetchMissingChannels([channelId]),
selector: getChannel,
});

View file

@ -0,0 +1,229 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import * as ReactRedux from 'react-redux';
import {Client4} from 'mattermost-redux/client';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {useTeam} from './use_team';
describe('useTeam', () => {
const team1 = TestHelper.getTeamMock({id: 'team1'});
const team2 = TestHelper.getTeamMock({id: 'team2'});
describe('with fake dispatch', () => {
const dispatchMock = jest.fn();
beforeAll(() => {
jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => dispatchMock);
});
afterAll(() => {
jest.restoreAllMocks();
});
test("should return the team if it's already in the store", () => {
const {result} = renderHookWithContext(
() => useTeam('team1'),
{
entities: {
teams: {
teams: {
team1,
},
},
},
},
);
expect(result.current).toBe(team1);
expect(dispatchMock).not.toHaveBeenCalled();
});
test("should fetch the team if it's not in the store", () => {
const {result} = renderHookWithContext(
() => useTeam('team1'),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
test('should only attempt to fetch the team once regardless of how many times the hook is used', () => {
const {result, rerender} = renderHookWithContext(
() => useTeam('team1'),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
for (let i = 0; i < 10; i++) {
rerender();
}
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
test('should attempt to fetch different teams if the team ID changes', () => {
let teamId = 'team1';
const {result, rerender} = renderHookWithContext(
() => useTeam(teamId),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
teamId = 'team2';
rerender();
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(2);
});
test("should only attempt to fetch each team once when they aren't loaded", () => {
let teamId = 'team1';
const {result, replaceStoreState, rerender} = renderHookWithContext(
() => useTeam(teamId),
);
// Initial state without team1 loaded
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(1);
// Simulate the response to loading team1
replaceStoreState({
entities: {
teams: {
teams: {
team1,
},
},
},
});
expect(result.current).toBe(team1);
expect(dispatchMock).toHaveBeenCalledTimes(1);
// Switch to team2
teamId = 'team2';
rerender();
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(2);
// Simulate the response to loading team2
replaceStoreState({
entities: {
teams: {
teams: {
team1,
team2,
},
},
},
});
expect(result.current).toBe(team2);
expect(dispatchMock).toHaveBeenCalledTimes(2);
// Switch back to team1 which has already been loaded
teamId = 'team1';
rerender();
expect(result.current).toBe(team1);
expect(dispatchMock).toHaveBeenCalledTimes(2);
});
test("shouldn't attempt to load anything when given an empty team ID", () => {
const {result} = renderHookWithContext(
() => useTeam(''),
);
expect(result.current).toBe(undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
});
});
describe('with real dispatch', () => {
beforeAll(() => {
Client4.setUrl('http://localhost:8065');
});
test("should fetch team when it's not loaded", async () => {
const teamMock = nock(Client4.getBaseRoute()).
get(`/teams/${team1.id}`).
once().
reply(200, team1);
const {result, waitForNextUpdate} = renderHookWithContext(
() => useTeam('team1'),
);
// Initial state without team1 loaded
expect(result.current).toEqual(undefined);
expect(teamMock.isDone()).toBe(false);
// Wait for the response with team1
await waitForNextUpdate();
expect(teamMock.isDone()).toBe(true);
expect(result.current).toEqual(team1);
});
test("should only attempt to fetch each team once when they aren't loaded", async () => {
const team1Mock = nock(Client4.getBaseRoute()).
get(`/teams/${team1.id}`).
once().
reply(200, team1);
const team2Mock = nock(Client4.getBaseRoute()).
get(`/teams/${team2.id}`).
once().
reply(200, team2);
let teamId = 'team1';
const {result, rerender, waitForNextUpdate} = renderHookWithContext(
() => useTeam(teamId),
);
// Initial state without team1 loaded
expect(result.current).toEqual(undefined);
expect(team1Mock.isDone()).toBe(false);
expect(team2Mock.isDone()).toBe(false);
// Wait for the response with team1
await waitForNextUpdate();
expect(team1Mock.isDone()).toBe(true);
expect(team2Mock.isDone()).toBe(false);
expect(result.current).toEqual(team1);
// Switch to team2
teamId = 'team2';
rerender();
expect(result.current).toEqual(undefined);
// Wait for the response with team2
await waitForNextUpdate();
expect(team1Mock.isDone()).toBe(true);
expect(team2Mock.isDone()).toBe(true);
expect(result.current).toEqual(team2);
// Switch back to team1 which has already been loaded
teamId = 'team1';
rerender();
expect(result.current).toEqual(team1);
// We know there's no second call because nock is set to only mock the first request for each team
});
});
});

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Team} from '@mattermost/types/teams';
import {getTeam as getTeamAction} from 'mattermost-redux/actions/teams';
import {getTeam as getTeamSelector} from 'mattermost-redux/selectors/entities/teams';
import {makeUseEntity} from 'components/common/hooks/useEntity';
export const useTeam = makeUseEntity<Team>({
name: 'useTeam',
fetch: getTeamAction,
selector: getTeamSelector,
});

View file

@ -1,607 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/dot_menu/DotMenu should match snapshot, can move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, canDelete 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, cannot move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
<Menu
menu={
Object {
"aria-label": "Post extra options",
"id": "CENTER_dropdown_post_id_1",
"onKeyDown": [Function],
"onToggle": [Function],
"width": "264px",
}
}
menuButton={
Object {
"aria-label": "more",
"children": <DotsHorizontalIcon
size={16}
/>,
"class": "post-menu__item",
"dataTestId": "PostDotMenu-Button-post_id_1",
"id": "CENTER_button_post_id_1",
}
}
menuButtonTooltip={
Object {
"class": "hidden-xs",
"text": "More",
}
}
>
<MenuItem
data-testid="reply_to_post_post_id_1"
id="reply_to_post_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Reply"
id="post_info.reply"
/>
}
leadingElement={
<ReplyOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="R"
/>
}
/>
<MenuItem
data-testid="forward_post_post_id_1"
id="forward_post_post_id_1"
isLabelsRowLayout={true}
labels={
<span
className="dot-menu__item-new-badge"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Forward"
id="forward_post_button.label"
/>
</span>
}
leadingElement={
<ArrowRightBoldOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="Shift + F"
/>
}
/>
<MenuItem
data-testid="follow_post_thread_post_id_1"
id="follow_post_thread_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Follow message"
id="threading.threadMenu.followMessage"
/>
}
leadingElement={
<MessageCheckOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="F"
/>
}
/>
<MenuItem
data-testid="unread_post_post_id_1"
id="unread_post_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Mark as Unread"
id="post_info.unread"
/>
}
leadingElement={
<MarkAsUnreadIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="U"
/>
}
/>
<Memo(PostReminderSubmenu)
isMilitaryTime={false}
post={
Object {
"channel_id": "",
"create_at": 0,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "post_id_1",
"is_pinned": false,
"message": "post message",
"metadata": Object {
"embeds": Array [],
"emojis": Array [],
"files": Array [],
"images": Object {},
"reactions": Array [],
},
"original_id": "",
"pending_post_id": "",
"props": Object {},
"reply_count": 0,
"root_id": "",
"type": "",
"update_at": 0,
"user_id": "user_id",
}
}
userId="user_id_1"
/>
<MenuItem
data-testid="save_post_post_id_1"
id="save_post_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="rhs_root.mobile.flag"
/>
}
leadingElement={
<BookmarkOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="S"
/>
}
/>
<MenuItem
data-testid="pin_post_post_id_1"
id="pin_post_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Pin"
id="post_info.pin"
/>
}
leadingElement={
<PinOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="P"
/>
}
/>
<MenuItem
id="move_thread_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Move Thread"
id="post_info.move_thread"
/>
}
leadingElement={
<MessageArrowRightOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="W"
/>
}
/>
<MenuItemSeparator />
<MenuItem
data-testid="permalink_post_id_1"
id="permalink_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Copy Link"
id="post_info.permalink"
/>
}
leadingElement={
<LinkVariantIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="K"
/>
}
/>
<MenuItemSeparator />
<MenuItem
data-testid="edit_post_post_id_1"
id="edit_post_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit"
id="post_info.edit"
/>
}
leadingElement={
<PencilOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="E"
/>
}
/>
<MenuItem
data-testid="copy_post_id_1"
id="copy_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Copy Text"
id="post_info.copy"
/>
}
leadingElement={
<ContentCopyIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="C"
/>
}
/>
</Menu>
`;

View file

@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/dot_menu/DotMenu returning empty ("") should match snapshot, return empty ("") on Center 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;

View file

@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/dot_menu/DotMenu on mobile view should match snapshot 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;

View file

@ -1,21 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import type {Post, PostType} from '@mattermost/types/posts';
import type {DeepPartial} from '@mattermost/types/utilities';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import {renderWithContext} from 'tests/react_testing_utils';
import {Locations} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import DotMenu from './dot_menu';
import type {DotMenuClass} from './dot_menu';
import DotMenuRoot from './index';
@ -210,65 +210,79 @@ describe('components/dot_menu/DotMenu', () => {
canMove: true,
};
test('should match snapshot, on Center', () => {
test('should show edit menu, on Center', async () => {
const props = {
...baseProps,
canEdit: true,
};
const wrapper = shallowWithIntl(
renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-label', 'more');
const instance = wrapper.instance();
const setStateMock = jest.fn();
instance.setState = setStateMock;
(wrapper.instance() as DotMenuClass).handleEditDisable();
expect(setStateMock).toBeCalledWith({canEdit: false});
await userEvent.click(button);
// Check that edit menu item is present when canEdit is true
expect(screen.getByTestId(`edit_post_${baseProps.post.id}`)).toBeInTheDocument();
});
test('should match snapshot, canDelete', () => {
test('should show delete menu, canDelete', async () => {
const props = {
...baseProps,
canEdit: true,
canDelete: true,
};
const wrapper = renderWithContext(
renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
await userEvent.click(button);
// Check that delete menu item is present when canDelete is true
expect(screen.getByTestId(`delete_post_${baseProps.post.id}`)).toBeInTheDocument();
});
test('should match snapshot, can move', () => {
test('should show move thread menu, can move', async () => {
const props = {
...baseProps,
canMove: true,
};
const wrapper = renderWithContext(
renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
await userEvent.click(button);
// Check that move thread menu item is present when canMove is true
expect(screen.getByText('Move Thread')).toBeInTheDocument();
});
test('should match snapshot, cannot move', () => {
test('should not show move thread menu when canMove is false, cannot move', async () => {
const props = {
...baseProps,
canMove: false,
};
const wrapper = renderWithContext(
renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
await userEvent.click(button);
// Check that move thread menu item is not present when canMove is false
expect(screen.queryByText('Move Thread')).not.toBeInTheDocument();
});
test('should show mark as unread when channel is not archived', () => {
test('should show mark as unread when channel is not archived', async () => {
const props = {
...baseProps,
location: Locations.CENTER,
@ -278,12 +292,12 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.getByTestId(`unread_post_${baseProps.post.id}`);
expect(menuItem).toBeVisible();
});
test('should not show mark as unread when channel is archived', () => {
test('should not show mark as unread when channel is archived', async () => {
const props = {
...baseProps,
channelIsArchived: true,
@ -293,12 +307,12 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.queryByTestId(`unread_post_${baseProps.post.id}`);
expect(menuItem).toBeNull();
});
test('should not show mark as unread in search', () => {
test('should not show mark as unread in search', async () => {
const props = {
...baseProps,
location: Locations.SEARCH,
@ -308,7 +322,7 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.queryByTestId(`unread_post_${baseProps.post.id}`);
expect(menuItem).toBeNull();
});
@ -318,7 +332,7 @@ describe('components/dot_menu/DotMenu', () => {
[true, {location: Locations.RHS_ROOT, isCollapsedThreadsEnabled: true}],
[true, {location: Locations.RHS_COMMENT, isCollapsedThreadsEnabled: true}],
[true, {location: Locations.CENTER, isCollapsedThreadsEnabled: true}],
])('follow message/thread menu item should be shown only in RHS and center channel when CRT is enabled', (showing, caseProps) => {
])('follow message/thread menu item should be shown only in RHS and center channel when CRT is enabled', async (showing, caseProps) => {
const props = {
...baseProps,
...caseProps,
@ -328,7 +342,7 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.getByTestId(`follow_post_thread_${baseProps.post.id}`);
expect(menuItem).toBeVisible();
});
@ -339,7 +353,7 @@ describe('components/dot_menu/DotMenu', () => {
[false, {location: Locations.CENTER, isCollapsedThreadsEnabled: false}],
[false, {location: Locations.SEARCH, isCollapsedThreadsEnabled: true}],
[false, {location: Locations.NO_WHERE, isCollapsedThreadsEnabled: true}],
])('follow message/thread menu item should be shown only in RHS and center channel when CRT is enabled', (showing, caseProps) => {
])('follow message/thread menu item should be shown only in RHS and center channel when CRT is enabled', async (showing, caseProps) => {
const props = {
...baseProps,
...caseProps,
@ -349,7 +363,7 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.queryByTestId(`follow_post_thread_${baseProps.post.id}`);
expect(menuItem).toBeNull();
});
@ -359,7 +373,7 @@ describe('components/dot_menu/DotMenu', () => {
['Unfollow message', {isFollowingThread: true, threadReplyCount: 0}],
['Follow thread', {isFollowingThread: false, threadReplyCount: 1}],
['Unfollow thread', {isFollowingThread: true, threadReplyCount: 1}],
])('should show correct text', (text, caseProps) => {
])('should show correct text', async (text, caseProps) => {
const props = {
...baseProps,
...caseProps,
@ -370,7 +384,7 @@ describe('components/dot_menu/DotMenu', () => {
initialState,
);
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
fireEvent.click(button);
await userEvent.click(button);
const menuItem = screen.getByTestId(`follow_post_thread_${baseProps.post.id}`);
expect(menuItem).toBeVisible();
expect(menuItem).toHaveTextContent(text);

View file

@ -1,13 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {screen} from '@testing-library/react';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import DotMenu from 'components/dot_menu/dot_menu';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
jest.mock('utils/utils', () => {
return {
localizeMessage: jest.fn().mockReturnValue(''),
@ -23,6 +28,52 @@ jest.mock('utils/post_utils', () => {
});
describe('components/dot_menu/DotMenu returning empty ("")', () => {
const initialState: DeepPartial<GlobalState> = {
entities: {
general: {
config: {},
},
channels: {
myMembers: {},
channels: {},
messageCounts: {},
},
preferences: {
myPreferences: {},
},
users: {
profiles: {},
currentUserId: 'current_user_id',
profilesInChannel: {},
},
teams: {
currentTeamId: 'currentTeamId',
teams: {
currentTeamId: {
id: 'currentTeamId',
display_name: 'test',
type: 'O',
},
},
},
posts: {
posts: {},
postsInChannel: {},
postsInThread: {},
},
},
views: {
browser: {
focused: false,
windowSize: 'desktopView',
},
modals: {
modalState: {},
showLaunchingWorkspace: false,
},
},
};
test('should match snapshot, return empty ("") on Center', () => {
const baseProps = {
post: TestHelper.getPostMock({id: 'post_id_1'}),
@ -63,10 +114,13 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => {
canMove: true,
};
const wrapper = shallow(
renderWithContext(
<DotMenu {...baseProps}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-label', 'more');
});
});

View file

@ -1,13 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {screen} from '@testing-library/react';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import DotMenu from 'components/dot_menu/dot_menu';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
jest.mock('utils/utils', () => {
return {
localizeMessage: jest.fn(),
@ -23,6 +28,52 @@ jest.mock('utils/post_utils', () => {
});
describe('components/dot_menu/DotMenu on mobile view', () => {
const initialState: DeepPartial<GlobalState> = {
entities: {
general: {
config: {},
},
channels: {
myMembers: {},
channels: {},
messageCounts: {},
},
preferences: {
myPreferences: {},
},
users: {
profiles: {},
currentUserId: 'current_user_id',
profilesInChannel: {},
},
teams: {
currentTeamId: 'currentTeamId',
teams: {
currentTeamId: {
id: 'currentTeamId',
display_name: 'test',
type: 'O',
},
},
},
posts: {
posts: {},
postsInChannel: {},
postsInThread: {},
},
},
views: {
browser: {
focused: false,
windowSize: 'mobileView',
},
modals: {
modalState: {},
showLaunchingWorkspace: false,
},
},
};
test('should match snapshot', () => {
const baseProps = {
post: TestHelper.getPostMock({id: 'post_id_1'}),
@ -63,10 +114,13 @@ describe('components/dot_menu/DotMenu on mobile view', () => {
canMove: true,
};
const wrapper = shallow(
renderWithContext(
<DotMenu {...baseProps}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
const button = screen.getByTestId(`PostDotMenu-Button-${baseProps.post.id}`);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-label', 'more');
});
});

View file

@ -9,10 +9,15 @@ import type {Post, PostType} from '@mattermost/types/posts';
import {Posts} from 'mattermost-redux/constants';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {PostTypes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import PostMarkdown from './post_markdown';
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
});
describe('components/PostMarkdown', () => {
const baseProps: ComponentProps<typeof PostMarkdown> = {
imageProps: {} as Record<string, unknown>,
@ -283,4 +288,22 @@ describe('components/PostMarkdown', () => {
expect(screen.queryByText('world', {exact: true})).not.toBeInTheDocument();
expect(screen.queryByText('world!', {exact: true})).toBeInTheDocument();
});
test('should render data spillage card', () => {
const dataSpillageReportPost = TestHelper.getPostMock({
type: PostTypes.CUSTOM_DATA_SPILLAGE_REPORT as PostType,
props: {
reported_post_id: 'reported_post_id',
},
});
const props = {
...baseProps,
message: 'See ~test',
post: dataSpillageReportPost,
};
renderWithContext(<PostMarkdown {...props}/>, state);
expect(screen.queryByTestId('data-spillage-report')).toBeInTheDocument();
});
});

View file

@ -9,7 +9,9 @@ import type {Post} from '@mattermost/types/posts';
import {Posts} from 'mattermost-redux/constants';
import Markdown from 'components/markdown';
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
import {PostTypes} from 'utils/constants';
import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting';
import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers';
@ -45,6 +47,8 @@ export type OwnProps = {
* Whether or not to render text emoticons (:D) as emojis
*/
renderEmoticonsAsEmoji?: boolean;
isRHS?: boolean;
};
type Props = PropsFromRedux & OwnProps;
@ -96,6 +100,17 @@ export default class PostMarkdown extends React.PureComponent<Props> {
return <div>{renderedWranglerMessage}</div>;
}
if (this.props.post && this.props.post.type === PostTypes.CUSTOM_DATA_SPILLAGE_REPORT) {
return (
<div>
<DataSpillageReport
post={this.props.post}
isRHS={this.props.isRHS}
/>
</div>
);
}
// Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs.
const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source;
const channelNamesMap = isChannelNamesMap(this.props.post?.props?.channel_mentions) ? this.props.post?.props?.channel_mentions : undefined;

View file

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.DataSpillageAction {
display: flex;
gap: 4px;
}

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import DataSpillageAction from './data_spillage_actions';
describe('DataSpillageAction', () => {
test('should render both action buttons', () => {
renderWithContext(<DataSpillageAction/>);
expect(screen.getByTestId('data-spillage-action')).toBeInTheDocument();
expect(screen.getByTestId('data-spillage-action-remove-message')).toBeInTheDocument();
expect(screen.getByTestId('data-spillage-action-keep-message')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import './data_spillage_actions.scss';
export default function DataSpillageAction() {
return (
<div
className='DataSpillageAction'
data-testid='data-spillage-action'
>
<button
className='btn btn-danger btn-sm'
data-testid='data-spillage-action-remove-message'
>
<FormattedMessage
id='data_spillage_report.remove_message.button_text'
defaultMessage='Remove message'
/>
</button>
<button
className='btn btn-tertiary btn-sm'
data-testid='data-spillage-action-keep-message'
>
<FormattedMessage
id='data_spillage_report.keep_message.button_text'
defaultMessage='Keep message'
/>
</button>
</div>
);
}

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.DataSpillageReport {
max-width: 700px;
border-radius: 4px;
background: var(--center-channel-bg, #FFF);
box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08);
#rhsContainer & {
box-shadow: none;
}
&.mode_short {
padding: 16px 19px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-left: 3px solid var(--error-text, #5D89EA);
}
}
// This is needed to make the DataSpillageReport component full width in the RHS.
#rhsContainer .post__body:has(.DataSpillageReport) {
position: relative;
left: -44px;
width: calc(100% + 44px) !important;
margin-top: 25px;
}
// This is needed to override the hover effect on posts in the RHS as otherwise it interferes
// with the appearance of select options and badges and other texts in the card.
#rhsContainer .post:not(.post--editing):not(.post--editing):has(.DataSpillageReport):hover {
background-color: unset;
}

View file

@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Post} from '@mattermost/types/posts';
import type {DeepPartial} from '@mattermost/types/utilities';
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
const reportingUser = TestHelper.getUserMock({
id: 'ewgposajm3fwpjbqu1t6scncia',
username: 'reporting_user',
});
const reportedPostTeam = TestHelper.getTeamMock({
id: 'reported_post_team_id',
display_name: 'Reported Post Team',
});
const reportedPostChannel = TestHelper.getChannelMock({
id: 'reported_post_channel_id',
display_name: 'reported-post-channel',
team_id: reportedPostTeam.id,
});
const reportedPostAuthor = TestHelper.getUserMock({
id: 'reported_post_author_id',
username: 'reported_post_author',
});
const reportedPost = TestHelper.getPostMock({
id: 'reported_post_id',
message: 'Hello, world!',
channel_id: reportedPostChannel.id,
user_id: reportedPostAuthor.id,
create_at: new Date(2025, 0, 1, 0, 1, 0, 0).getMilliseconds(),
});
const post: Post = TestHelper.getPostMock({
props: {
reported_post_id: reportedPost.id,
},
});
const baseState: DeepPartial<GlobalState> = {
entities: {
users: {
profiles: {
[reportingUser.id]: reportingUser,
[reportedPostAuthor.id]: reportedPostAuthor,
},
},
posts: {
posts: {
[reportedPost.id]: reportedPost,
},
},
channels: {
channels: {
[reportedPostChannel.id]: reportedPostChannel,
},
},
teams: {
teams: {
[reportedPostTeam.id]: reportedPostTeam,
},
},
},
};
it('should render selected fields when not in RHS', async () => {
renderWithContext(
<DataSpillageReport
post={post}
isRHS={false}
/>,
baseState,
);
// validate title
const title = screen.queryByTestId('property-card-title');
expect(title).toBeVisible();
expect(title).toHaveTextContent('@reporting_user flagged a message for review');
expect(screen.queryAllByTestId('property-card-row')).toHaveLength(4);
expect(screen.queryAllByTestId('select-property')).toHaveLength(2);
const statusFieldValue = screen.queryAllByTestId('select-property')[0];
expect(statusFieldValue).toHaveTextContent('Flag dismissed');
const reasonFieldValue = screen.queryAllByTestId('select-property')[1];
expect(reasonFieldValue).toHaveTextContent('Inappropriate content');
const postPreview = screen.queryByTestId('post-preview-property');
expect(postPreview).toBeVisible();
expect(postPreview).toHaveTextContent('Hello, world!');
const assignee = screen.queryByTestId('selectable-user-property');
expect(assignee).toBeVisible();
// actions are not visible when not in RHS
expect(screen.queryByTestId('data-spillage-action')).not.toBeInTheDocument();
});
it('should render all fields when in RHS', async () => {
renderWithContext(
<DataSpillageReport
post={post}
isRHS={true}
/>,
baseState,
);
const flaggedBy = screen.queryAllByTestId('user-property')[0];
expect(flaggedBy).toBeVisible();
expect(flaggedBy).toHaveTextContent('reporting_user');
const comment = screen.queryByTestId('text-property');
expect(comment).toBeVisible();
expect(comment).toHaveTextContent('Please review this post for potential violations');
const channel = screen.queryByTestId('channel-property');
expect(channel).toBeVisible();
expect(channel).toHaveTextContent('reported-post-channel');
const team = screen.queryByTestId('team-property');
expect(team).toBeVisible();
expect(team).toHaveTextContent('Reported Post Team');
const postedBy = screen.queryAllByTestId('user-property')[1];
expect(postedBy).toBeVisible();
expect(postedBy).toHaveTextContent('reported_post_author');
const reportedAt = screen.queryAllByTestId('timestamp-property')[0];
expect(reportedAt).toBeVisible();
const postedAt = screen.queryAllByTestId('timestamp-property')[1];
expect(postedAt).toBeVisible();
// actions are visible when in RHS
expect(screen.queryByTestId('data-spillage-action')).toBeVisible();
expect(screen.queryByTestId('data-spillage-action-remove-message')).toBeVisible();
expect(screen.queryByTestId('data-spillage-action-keep-message')).toBeVisible();
});
});

View file

@ -0,0 +1,424 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import AtMention from 'components/at_mention';
import {useChannel} from 'components/common/hooks/useChannel';
import {usePost} from 'components/common/hooks/usePost';
import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions';
import PropertiesCardView from 'components/properties_card_view/properties_card_view';
import {DataSpillagePropertyNames} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './data_spillage_report.scss';
// TODO: this function will be replaced with actual data fetched from API in a later PR
function getDummyPropertyFields(): PropertyField[] {
return [
{
id: 'status_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Status,
type: 'select',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
attrs: {
editable: false,
options: [
{
id: 'option_pending_review',
name: 'Pending review',
color: 'light_gray',
},
{
id: 'option_reviewer_assigned',
name: 'Reviewer assigned',
color: 'light_blue',
},
{
id: 'option_dismissed',
name: 'Flag dismissed',
color: 'dark_blue',
},
{
id: 'option_removed',
name: 'Removed',
color: 'dark_red',
},
],
},
},
{
id: 'reporting_user_id_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.FlaggedBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reason_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Reason,
type: 'select',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'comment_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Comment,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ReportingTime,
type: 'text',
attrs: {subType: 'timestamp'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reviewer_user_id_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ReviewingUser,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
attrs: {
editable: true,
},
},
{
id: 'actor_user_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'actor_comment_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionComment,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'action_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionTime,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_preview_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Message,
type: 'text',
attrs: {subType: 'post'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedIn,
type: 'text',
attrs: {subType: 'channel'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'team_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Team,
type: 'text',
attrs: {subType: 'team'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_author_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_creation_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedAt,
type: 'text',
attrs: {subType: 'timestamp'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
}
// TODO: this function will be replaced with actual data fetched from API in a later PR
function getDummyPropertyValues(postId: string, channelId: string, teamId: string, authorId: string, postCreateAt: number): Array<PropertyValue<unknown>> {
return [
{
id: 'status_value_id',
field_id: 'status_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Flag dismissed',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_user_value_id',
field_id: 'reporting_user_id_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'ewgposajm3fwpjbqu1t6scncia',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reason_value_id',
field_id: 'reason_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Inappropriate content',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'comment_value_id',
field_id: 'comment_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Please review this post for potential violations.',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_time_value_id',
field_id: 'reporting_time_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: new Date(2025, 0, 1, 0, 1, 0, 0).getTime(),
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_preview_value_id',
field_id: 'post_preview_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: postId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_value_id',
field_id: 'channel_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: channelId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'team_value_id',
field_id: 'team_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: teamId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_author_value_id',
field_id: 'post_author_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: authorId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_creation_time_value_id',
field_id: 'post_creation_time_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: postCreateAt,
create_at: 0,
update_at: 0,
delete_at: 0,
},
// No reviewer assigned yet
{
id: 'reviewer_user_value_id',
field_id: 'reviewer_user_id_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: '',
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
}
const fieldOrder = [
'status_field_id',
'reason_field_id',
'post_preview_field_id',
'reporting_user_id_field_id',
'comment_field_id',
'reporting_time_field_id',
'reviewer_user_id_field_id',
'actor_user_field_id',
'actor_comment_field_id',
'action_time_field_id',
'channel_field_id',
'team_field_id',
'post_author_field_id',
'post_creation_time_field_id',
];
const shortModeFieldOrder = [
'status_field_id',
'reason_field_id',
'post_preview_field_id',
'reviewer_user_id_field_id',
];
type Props = {
post: Post;
isRHS?: boolean;
};
export default function DataSpillageReport({post, isRHS}: Props) {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const [propertyFields, setPropertyFields] = useState<PropertyField[]>([]);
const [propertyValues, setPropertyValues] = useState<Array<PropertyValue<unknown>>>([]);
const reportedPostId = post.props.reported_post_id as string;
const reportedPost = usePost(reportedPostId);
const channel = useChannel(reportedPost?.channel_id || '');
const reportingUserFieldId = propertyFields.find((field) => field.name === DataSpillagePropertyNames.FlaggedBy);
const reportingUserIdValue = propertyValues.find((value) => value.field_id === reportingUserFieldId?.id);
const reportingUser = useSelector((state: GlobalState) => getUser(state, reportingUserIdValue ? reportingUserIdValue.value as string : ''));
useEffect(() => {
if (!reportingUser && reportingUserIdValue && reportedPost) {
dispatch(getMissingProfilesByIds([
reportingUserIdValue.value as string,
reportedPost.user_id,
]));
}
}, [dispatch, reportedPost, reportingUser, reportingUserIdValue]);
useEffect(() => {
if (reportedPost && channel) {
// TODO: this function will be replaced with actual data fetched from API in a later PR
setPropertyFields(getDummyPropertyFields());
setPropertyValues(getDummyPropertyValues(reportedPostId, reportedPost.channel_id, channel.team_id, reportedPost.user_id, post.create_at));
}
}, [reportedPost, reportedPostId, channel, post.create_at]);
const title = formatMessage({
id: 'data_spillage_report_post.title',
defaultMessage: '{user} flagged a message for review',
}, {
user: (<AtMention mentionName={reportingUser?.username || ''}/>),
});
const mode = isRHS ? 'full' : 'short';
return (
<div
className={`DataSpillageReport mode_${mode}`}
data-testid='data-spillage-report'
onClick={(e) => e.stopPropagation()}
>
<PropertiesCardView
title={title}
propertyFields={propertyFields}
propertyValues={propertyValues}
fieldOrder={fieldOrder}
shortModeFieldOrder={shortModeFieldOrder}
actionsRow={<DataSpillageAction/>}
mode={mode}
/>
</div>
);
}

View file

@ -20,11 +20,22 @@ exports[`PostMessagePreview direct and group messages should render preview for
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -106,11 +117,22 @@ exports[`PostMessagePreview direct and group messages should render preview for
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -192,11 +214,26 @@ exports[`PostMessagePreview nested previews should render file preview 1`] = `
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"file_ids": Array [
"file_1",
"file_2",
],
"id": "post_id",
"message": "post message",
"metadata": Object {},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -298,11 +335,29 @@ exports[`PostMessagePreview nested previews should render opengraph preview 1`]
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {
"embeds": Array [
Object {
"type": "opengraph",
"url": "https://example.com",
},
],
},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -412,11 +467,27 @@ exports[`PostMessagePreview should not render bot icon 1`] = `
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
"props": Object {
"from_webhook": "false",
"override_icon_url": "https://fakeicon.com/image.jpg",
"use_user_icon": "false",
},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -503,11 +574,27 @@ exports[`PostMessagePreview should render bot icon 1`] = `
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="https://fakeicon.com/image.jpg"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={true}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
"props": Object {
"from_webhook": "true",
"override_icon_url": "https://fakeicon.com/image.jpg",
"use_user_icon": false,
},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -594,11 +681,22 @@ exports[`PostMessagePreview should render correctly 1`] = `
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>
@ -682,11 +780,22 @@ exports[`PostMessagePreview show render without preview when preview posts becom
<span
className="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_1/image?_=0"
username="username1"
<PreviewPostAvatar
enablePostIconOverride={false}
hasImageProxy={false}
post={
Object {
"id": "post_id",
"message": "post message",
"metadata": Object {},
}
}
user={
Object {
"id": "user_1",
"username": "username1",
}
}
/>
</span>
</div>

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {ensureString} from 'mattermost-redux/utils/post_utils';
import MattermostLogo from 'components/widgets/icons/mattermost_logo';
import Avatar from 'components/widgets/users/avatar';
import {Constants} from 'utils/constants';
import * as PostUtils from 'utils/post_utils';
import {imageURLForUser} from 'utils/utils';
type Props = {
post?: Post;
user: UserProfile;
enablePostIconOverride?: boolean;
hasImageProxy?: boolean;
}
export default function PreviewPostAvatar({post, user, enablePostIconOverride, hasImageProxy}: Props) {
const isBot = Boolean(user && user.is_bot);
const isSystemMessage = post ? PostUtils.isSystemMessage(post) : false;
const fromWebhook = post ? PostUtils.isFromWebhook(post) : false;
const fromAutoResponder = post ? PostUtils.fromAutoResponder(post) : false;
const src = useMemo(() => {
const postProps = post?.props;
const postIconOverrideURL = ensureString(postProps?.override_icon_url);
const useUserIcon = ensureString(postProps?.use_user_icon);
if (!fromAutoResponder && fromWebhook && !useUserIcon && enablePostIconOverride) {
if (postIconOverrideURL && postIconOverrideURL !== '') {
return PostUtils.getImageSrc(postIconOverrideURL, hasImageProxy);
}
return Constants.DEFAULT_WEBHOOK_LOGO;
}
return imageURLForUser(user?.id ?? '');
}, [enablePostIconOverride, fromAutoResponder, fromWebhook, hasImageProxy, post?.props, user?.id]);
let avatar = (
<Avatar
size={'sm'}
url={src}
className={'avatar-post-preview'}
/>
);
if (isSystemMessage && !fromWebhook && !isBot) {
avatar = (<MattermostLogo className='icon'/>);
} else if (user?.id) {
avatar = (
<Avatar
username={user.username}
size={'sm'}
url={src}
className={'avatar-post-preview'}
/>
);
}
return avatar;
}

View file

@ -13,6 +13,10 @@ import {General} from 'mattermost-redux/constants';
import PostMessagePreview from './post_message_preview';
import type {Props} from './post_message_preview';
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
});
describe('PostMessagePreview', () => {
const previewPost = {
id: 'post_id',

View file

@ -17,12 +17,8 @@ import PostAttachmentOpenGraph from 'components/post_view/post_attachment_opengr
import PostMessageView from 'components/post_view/post_message_view';
import Timestamp from 'components/timestamp';
import UserProfileComponent from 'components/user_profile';
import MattermostLogo from 'components/widgets/icons/mattermost_logo';
import Avatar from 'components/widgets/users/avatar';
import {Constants} from 'utils/constants';
import * as PostUtils from 'utils/post_utils';
import * as Utils from 'utils/utils';
import PreviewPostAvatar from './avatar/avatar';
import PostAttachmentContainer from '../post_attachment_container/post_attachment_container';
@ -53,53 +49,10 @@ const PostMessagePreview = (props: Props) => {
}
};
const getPostIconURL = (defaultURL: string, fromAutoResponder: boolean, fromWebhook: boolean): string => {
const {enablePostIconOverride, hasImageProxy, previewPost} = props;
const postProps = previewPost?.props;
const postIconOverrideURL = ensureString(postProps?.override_icon_url);
const useUserIcon = ensureString(postProps?.use_user_icon);
if (!fromAutoResponder && fromWebhook && !useUserIcon && enablePostIconOverride) {
if (postIconOverrideURL && postIconOverrideURL !== '') {
return PostUtils.getImageSrc(postIconOverrideURL, hasImageProxy);
}
return Constants.DEFAULT_WEBHOOK_LOGO;
}
return defaultURL;
};
if (!previewPost) {
return null;
}
const isBot = Boolean(user && user.is_bot);
const isSystemMessage = PostUtils.isSystemMessage(previewPost);
const fromWebhook = PostUtils.isFromWebhook(previewPost);
const fromAutoResponder = PostUtils.fromAutoResponder(previewPost);
const profileSrc = Utils.imageURLForUser(user?.id ?? '');
const src = getPostIconURL(profileSrc, fromAutoResponder, fromWebhook);
let avatar = (
<Avatar
size={'sm'}
url={src}
className={'avatar-post-preview'}
/>
);
if (isSystemMessage && !fromWebhook && !isBot) {
avatar = (<MattermostLogo className='icon'/>);
} else if (user?.id) {
avatar = (
<Avatar
username={user.username}
size={'sm'}
url={src}
className={'avatar-post-preview'}
/>
);
}
let fileAttachmentPreview = null;
if (((previewPost.file_ids && previewPost.file_ids.length > 0) || (previewPost.filenames && previewPost.filenames.length > 0))) {
@ -166,7 +119,15 @@ const PostMessagePreview = (props: Props) => {
<div className='col col__name'>
<div className='post__img'>
<span className='profile-icon'>
{avatar}
{
user &&
<PreviewPostAvatar
post={previewPost}
user={user}
enablePostIconOverride={props.enablePostIconOverride}
hasImageProxy={props.hasImageProxy}
/>
}
</span>
</div>
</div>

View file

@ -18,6 +18,7 @@ exports[`components/post_view/PostAttachment should match snapshot 1`] = `
"onImageLoaded": [Function],
}
}
isRHS={false}
message="post message"
options={Object {}}
post={
@ -54,6 +55,7 @@ exports[`components/post_view/PostAttachment should match snapshot, on Show Less
"onImageLoaded": [Function],
}
}
isRHS={false}
message="post message"
options={Object {}}
post={
@ -90,6 +92,7 @@ exports[`components/post_view/PostAttachment should match snapshot, on Show More
"onImageLoaded": [Function],
}
}
isRHS={false}
message="post message"
options={Object {}}
post={
@ -144,6 +147,7 @@ exports[`components/post_view/PostAttachment should match snapshot, on edited po
"onImageLoaded": [Function],
}
}
isRHS={false}
message="post message"
options={Object {}}
post={
@ -181,6 +185,7 @@ exports[`components/post_view/PostAttachment should match snapshot, on ephemeral
"onImageLoaded": [Function],
}
}
isRHS={false}
message="post message"
options={Object {}}
post={

View file

@ -11,6 +11,10 @@ import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import PostMessageView from 'components/post_view/post_message_view/post_message_view';
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
});
describe('components/post_view/PostAttachment', () => {
const post = {
id: 'post_id',

View file

@ -170,6 +170,7 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
post={post}
channelId={post.channel_id}
showPostEditedIndicator={this.props.showPostEditedIndicator}
isRHS={isRHS}
/>
</div>
{(!isSharedChannel || this.props.sharedChannelsPluginsEnabled) && (

View file

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.PropertyCardView {
display: flex;
flex-direction: column;
gap: 20px;
.PropertyCardView_title {
color: var(--center-channel-color, #3F4350);
font-size: 25px;
font-weight: 600;
}
.PropertyCardView_fields {
display: flex;
flex-direction: column;
gap: 8px;
> .row {
display: flex;
min-height: 32px;
flex-direction: row;
align-items: start;
margin: 0;
gap: 16px;
&::before {
content: none;
}
> .field {
display: flex;
min-width: 110px;
max-width: 110px;
align-items: center;
color: var(--center-channel-color, #3F4350);
font-size: 12px;
font-weight: 600;
}
> .value {
display: flex;
min-height: 32px;
align-items: center;
}
}
}
}

View file

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer';
import './properties_card_view.scss';
type Props = {
title: React.ReactNode;
propertyFields: PropertyField[];
fieldOrder: Array<PropertyField['id']>;
shortModeFieldOrder: Array<PropertyField['id']>;
propertyValues: Array<PropertyValue<unknown>>;
mode?: 'short' | 'full';
actionsRow?: React.ReactNode;
}
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow}: Props) {
const orderedRows = useMemo<Array<{field: PropertyField; value: PropertyValue<unknown>}>>(() => {
if (!propertyFields.length || !fieldOrder.length || !propertyValues.length) {
return [];
}
const fieldsById = propertyFields.reduce((acc, field) => {
acc[field.id] = field;
return acc;
}, {} as {[key: string]: PropertyField});
const valuesByFieldId = propertyValues.reduce((acc, value) => {
acc[value.field_id] = value;
return acc;
}, {} as {[key: string]: PropertyValue<unknown>});
const fieldOrderToUse = mode === 'short' ? shortModeFieldOrder : fieldOrder;
return fieldOrderToUse.map((fieldId) => {
const field = fieldsById[fieldId];
const value = valuesByFieldId[fieldId];
return {
field,
value,
};
}).filter((entry) => Boolean(entry.value));
}, [fieldOrder, mode, propertyFields, propertyValues, shortModeFieldOrder]);
if (orderedRows.length === 0) {
return null;
}
return (
<div
className='PropertyCardView'
data-testid='property-card-view'
>
<div
className='PropertyCardView_title'
data-testid='property-card-title'
>
{title}
</div>
<div className='PropertyCardView_fields'>
{
orderedRows.map(({field, value}) => {
return (
<div
key={field.id}
className='row'
data-testid='property-card-row'
>
<div className='field'>
{field.name}
</div>
<div className='value'>
<PropertyValueRenderer
field={field}
value={value}
/>
</div>
</div>
);
})
}
{
mode === 'full' && actionsRow &&
<div className='row'>
<div className='field'>
<FormattedMessage
id='property_card.actions_row.label'
defaultMessage='Actions'
/>
</div>
<div className='value'>
{actionsRow}
</div>
</div>
}
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ChannelPropertyRenderer {
display: flex;
flex-direction: row;
gap: 8px;
}

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {PropertyValue} from '@mattermost/types/properties';
import {renderWithContext} from 'tests/react_testing_utils';
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({
id: 'channel-id-123',
display_name: 'Test Channel',
type: 'O',
}),
};
const mockValue = {
value: 'channel-id-123',
} as PropertyValue<string>;
beforeEach(() => {
jest.clearAllMocks();
});
it('should render channel name and icon when channel exists', () => {
mockUseChannel.mockReturnValue(mockChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
);
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}/>,
);
expect(screen.getByTestId('channel-property')).toBeInTheDocument();
expect(screen.getByText(/Deleted channel ID: channel-id-123/)).toBeInTheDocument();
});
it('should handle different channel types', () => {
const privateChannel = {
...mockChannel,
type: 'P' as const,
display_name: 'Private Channel',
};
mockUseChannel.mockReturnValue(privateChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
);
expect(screen.getByText('Private Channel')).toBeInTheDocument();
});
it('should handle direct message channels', () => {
const dmChannel = {
...mockChannel,
type: 'D' as const,
display_name: 'Direct Message',
};
mockUseChannel.mockReturnValue(dmChannel);
renderWithContext(
<ChannelPropertyRenderer value={mockValue}/>,
);
expect(screen.getByText('Direct Message')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {PropertyValue} from '@mattermost/types/properties';
import {useChannel} from 'components/common/hooks/useChannel';
import SidebarBaseChannelIcon from 'components/sidebar/sidebar_channel/sidebar_base_channel/sidebar_base_channel_icon';
import './channel_property_renderer.scss';
type Props = {
value: PropertyValue<unknown>;
}
export default function ChannelPropertyRenderer({value}: Props) {
const channelId = value.value as string;
const channel = useChannel(channelId);
return (
<div
className='ChannelPropertyRenderer'
data-testid='channel-property'
>
{
channel &&
(
<>
<SidebarBaseChannelIcon
channelType={channel.type}
/>
{channel.display_name}
</>
)
}
{
!channel &&
<FormattedMessage
id='post_card.channel_property.deleted_channel'
defaultMessage='Deleted channel ID: {channelId}'
values={{channelId}}
/>
}
</div>
);
}

View file

@ -0,0 +1,274 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import type {PropertyValue} from '@mattermost/types/properties';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import PostPreviewPropertyRenderer from './post_preview_property_renderer';
jest.mock('components/common/hooks/usePost');
jest.mock('components/common/hooks/useChannel');
jest.mock('components/common/hooks/use_team');
const mockUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
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>;
describe('PostPreviewPropertyRenderer', () => {
const mockUser: UserProfile = {
...TestHelper.getUserMock(),
id: 'user-id-123',
username: 'testuser',
first_name: 'Test',
last_name: 'User',
};
const mockPost: Post = {
...TestHelper.getPostMock(),
id: 'post-id-123',
channel_id: 'channel-id-123',
user_id: 'user-id-123',
message: 'Test post message',
create_at: 1234567890,
};
const mockChannel: Channel = {
...TestHelper.getChannelMock(),
id: 'channel-id-123',
team_id: 'team-id-123',
display_name: 'Test Channel',
type: 'O',
};
const mockTeam: Team = {
...TestHelper.getTeamMock(),
id: 'team-id-123',
name: 'test-team',
display_name: 'Test Team',
};
const defaultProps = {
value: {
value: 'post-id-123',
} as PropertyValue<string>,
};
const baseState = {
entities: {
users: {
profiles: {
[mockUser.id]: mockUser,
},
currentUserId: mockUser.id,
},
posts: {
posts: {
[mockPost.id]: mockPost,
},
},
channels: {
channels: {
[mockChannel.id]: mockChannel,
},
},
teams: {
teams: {
[mockTeam.id]: mockTeam,
},
},
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render PostMessagePreview when all data is available', () => {
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
const {getByTestId, getByText} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
});
it('should return null when post is not found', () => {
mockUsePost.mockReturnValue(null);
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
const {container} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(container.firstChild).toBeNull();
});
it('should return null when channel is not found', () => {
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(null);
mockUseTeam.mockReturnValue(mockTeam);
const {container} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(container.firstChild).toBeNull();
});
it('should return null when team is not found', () => {
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(null);
const {container} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(container.firstChild).toBeNull();
});
it('should handle private channel', () => {
const privateChannel = {
...mockChannel,
type: 'P' as const,
};
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(privateChannel);
mockUseTeam.mockReturnValue(mockTeam);
const {getByTestId, getByText} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
});
it('should handle missing display names gracefully', () => {
const channelWithoutDisplayName = {
...mockChannel,
display_name: '',
};
const teamWithoutName = {
...mockTeam,
name: '',
};
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(channelWithoutDisplayName);
mockUseTeam.mockReturnValue(teamWithoutName);
const {getByTestId, getByText} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~')).toBeVisible();
});
it('should handle post with file attachments', () => {
const postWithAttachments = {
...mockPost,
message: 'Post with file attachment',
file_ids: ['file-id-1', 'file-id-2'],
metadata: {
files: [
{
id: 'file-id-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024000,
mime_type: 'application/pdf',
},
{
id: 'file-id-2',
name: 'image.jpg',
extension: 'jpg',
size: 512000,
mime_type: 'image/jpeg',
},
],
},
};
const stateWithFiles = {
...baseState,
entities: {
...baseState.entities,
posts: {
posts: {
[postWithAttachments.id]: postWithAttachments,
},
},
files: {
fileIdsByPostId: {
[postWithAttachments.id]: ['file-id-1', 'file-id-2'],
},
files: {
'file-id-1': {
id: 'file-id-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024000,
mime_type: 'application/pdf',
},
'file-id-2': {
id: 'file-id-2',
name: 'image.jpg',
extension: 'jpg',
size: 512000,
mime_type: 'image/jpeg',
},
},
},
},
};
mockUsePost.mockReturnValue(postWithAttachments);
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
const {getByTestId, getByText} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
stateWithFiles,
);
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Post with file attachment')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
// Assert that file attachments are visible
expect(getByText('document.pdf')).toBeVisible();
expect(getByText('image.jpg')).toBeVisible();
});
});

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {PostPreviewMetadata} from '@mattermost/types/posts';
import type {PropertyValue} from '@mattermost/types/properties';
import {useTeam} from 'components/common/hooks/use_team';
import {useChannel} from 'components/common/hooks/useChannel';
import {usePost} from 'components/common/hooks/usePost';
import PostMessagePreview from 'components/post_view/post_message_preview';
const noop = () => {};
type Props = {
value: PropertyValue<unknown>;
}
export default function PostPreviewPropertyRenderer({value}: Props) {
const post = usePost(value.value as string);
const channel = useChannel(post?.channel_id || '');
const team = useTeam(channel?.team_id || '');
const {formatMessage} = useIntl();
if (!post || !channel || !team) {
return null;
}
const previewMetaData: PostPreviewMetadata = {
post,
post_id: post.id,
team_name: team?.name || '',
channel_display_name: channel?.display_name || '',
channel_type: channel?.type || 'O',
channel_id: post.channel_id,
};
const postPreviewFooterMessage = formatMessage({
id: 'forward_post_modal.preview.footer_message',
defaultMessage: 'Originally posted in ~{channel}',
},
{
channel: channel?.display_name || '',
});
return (
<div
className='PostPreviewPropertyRenderer'
data-testid='post-preview-property'
>
<PostMessagePreview
metadata={previewMetaData}
handleFileDropdownOpened={noop}
preventClickAction={true}
previewFooterMessage={postPreviewFooterMessage}
/>
</div>
);
}

View file

@ -0,0 +1,444 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {PropertyField, PropertyValue, SelectPropertyField} from '@mattermost/types/properties';
import {renderWithContext} from 'tests/react_testing_utils';
import PropertyValueRenderer from './propertyValueRenderer';
// Mock all child components
jest.mock('./text_property_renderer/textPropertyRenderer', () => {
return function MockTextPropertyRenderer({value}: {value: PropertyValue<unknown>}) {
return <div data-testid='mock-text-property'>{String(value.value)}</div>;
};
});
jest.mock('./user_property_renderer/userPropertyRenderer', () => {
return function MockUserPropertyRenderer({value}: {field: PropertyField; value: PropertyValue<unknown>}) {
return <div data-testid='mock-user-property'>{String(value.value)}</div>;
};
});
jest.mock('./select_property_renderer/selectPropertyRenderer', () => {
return function MockSelectPropertyRenderer({value}: {field: PropertyField; value: PropertyValue<unknown>}) {
return <div data-testid='mock-select-property'>{String(value.value)}</div>;
};
});
jest.mock('./post_preview_property_renderer/post_preview_property_renderer', () => {
return function MockPostPreviewPropertyRenderer({value}: {value: PropertyValue<unknown>}) {
return <div data-testid='mock-post-preview-property'>{String(value.value)}</div>;
};
});
jest.mock('./channel_property_renderer/channel_property_renderer', () => {
return function MockChannelPropertyRenderer({value}: {value: PropertyValue<unknown>}) {
return <div data-testid='mock-channel-property'>{String(value.value)}</div>;
};
});
jest.mock('./team_property_renderer/team_property_renderer', () => {
return function MockTeamPropertyRenderer({value}: {value: PropertyValue<unknown>}) {
return <div data-testid='mock-team-property'>{String(value.value)}</div>;
};
});
jest.mock('./timestamp_property_renderer/timestamp_property_renderer', () => {
return function MockTimestampPropertyRenderer({value}: {value: PropertyValue<unknown>}) {
return <div data-testid='mock-timestamp-property'>{String(value.value)}</div>;
};
});
describe('PropertyValueRenderer', () => {
describe('text field type', () => {
it('should render TextPropertyRenderer for text subtype', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
attrs: {
subType: 'text',
},
} as PropertyField;
const value = {
value: 'test text',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByText('test text')).toBeInTheDocument();
});
it('should render TextPropertyRenderer for text field without subType', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
attrs: {},
} as PropertyField;
const value = {
value: 'test text',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByText('test text')).toBeInTheDocument();
});
it('should render PostPreviewPropertyRenderer for post subtype', () => {
const field = {
id: 'field-1',
name: 'Post Field',
type: 'text',
attrs: {
subType: 'post',
},
} as PropertyField;
const value = {
value: 'post-id-123',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-post-preview-property')).toBeInTheDocument();
expect(screen.getByText('post-id-123')).toBeInTheDocument();
});
it('should render ChannelPropertyRenderer for channel subtype', () => {
const field = {
id: 'field-1',
name: 'Channel Field',
type: 'text',
attrs: {
subType: 'channel',
},
} as PropertyField;
const value = {
value: 'channel-id-123',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-channel-property')).toBeInTheDocument();
expect(screen.getByText('channel-id-123')).toBeInTheDocument();
});
it('should render TeamPropertyRenderer for team subtype', () => {
const field = {
id: 'field-1',
name: 'Team Field',
type: 'text',
attrs: {
subType: 'team',
},
} as PropertyField;
const value = {
value: 'team-id-123',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-team-property')).toBeInTheDocument();
expect(screen.getByText('team-id-123')).toBeInTheDocument();
});
it('should render TimestampPropertyRenderer for timestamp subtype', () => {
const field = {
id: 'field-1',
name: 'Timestamp Field',
type: 'text',
attrs: {
subType: 'timestamp',
},
} as PropertyField;
const value = {
value: 1642694400000,
} as PropertyValue<number>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-timestamp-property')).toBeInTheDocument();
expect(screen.getByText('1642694400000')).toBeInTheDocument();
});
it('should return null for unknown text subtype', () => {
const field = {
id: 'field-1',
name: 'Unknown Field',
type: 'text',
attrs: {
subType: 'unknown' as unknown,
},
} as PropertyField;
const value = {
value: 'test value',
} as PropertyValue<string>;
const {container} = renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(container.firstChild).toBeNull();
});
});
describe('user field type', () => {
it('should render UserPropertyRenderer for user field', () => {
const field = {
id: 'field-1',
name: 'User Field',
type: 'user',
attrs: {},
} as PropertyField;
const value = {
value: 'user-id-123',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-user-property')).toBeInTheDocument();
expect(screen.getByText('user-id-123')).toBeInTheDocument();
});
});
describe('select field type', () => {
it('should render SelectPropertyRenderer for select field', () => {
const field = {
id: 'field-1',
name: 'Select Field',
type: 'select',
attrs: {
options: [
{id: 'option1', name: 'Option 1', color: 'blue'},
],
},
} as SelectPropertyField;
const value = {
value: 'option1',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-select-property')).toBeInTheDocument();
expect(screen.getByText('option1')).toBeInTheDocument();
});
});
describe('unsupported field types', () => {
it('should return null for unsupported field type', () => {
const field = {
id: 'field-1',
name: 'Unsupported Field',
type: 'unsupported' as unknown,
attrs: {},
} as PropertyField;
const value = {
value: 'test value',
} as PropertyValue<string>;
const {container} = renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(container.firstChild).toBeNull();
});
it('should return null for multiselect field type', () => {
const field = {
id: 'field-1',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {},
} as PropertyField;
const value = {
value: ['option1', 'option2'],
} as PropertyValue<string[]>;
const {container} = renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(container.firstChild).toBeNull();
});
it('should return null for date field type', () => {
const field = {
id: 'field-1',
name: 'Date Field',
type: 'date',
attrs: {},
} as PropertyField;
const value = {
value: 1642694400000,
} as PropertyValue<number>;
const {container} = renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(container.firstChild).toBeNull();
});
});
describe('edge cases', () => {
it('should handle text field without attrs', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
} as PropertyField;
const value = {
value: 'test text',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByText('test text')).toBeInTheDocument();
});
it('should handle empty string value', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
attrs: {},
} as PropertyField;
const value = {
value: '',
} as PropertyValue<string>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByTestId('mock-text-property')).toHaveTextContent('');
});
it('should handle null value', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
attrs: {},
} as PropertyField;
const value: PropertyValue<null> = {
value: null,
} as PropertyValue<null>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByText('null')).toBeInTheDocument();
});
it('should handle undefined value', () => {
const field = {
id: 'field-1',
name: 'Text Field',
type: 'text',
attrs: {},
} as PropertyField;
const value = {
value: undefined,
} as PropertyValue<unknown>;
renderWithContext(
<PropertyValueRenderer
field={field}
value={value}
/>,
);
expect(screen.getByTestId('mock-text-property')).toBeInTheDocument();
expect(screen.getByText('undefined')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import ChannelPropertyRenderer from './channel_property_renderer/channel_property_renderer';
import PostPreviewPropertyRenderer from './post_preview_property_renderer/post_preview_property_renderer';
import SelectPropertyRenderer from './select_property_renderer/selectPropertyRenderer';
import TeamPropertyRenderer from './team_property_renderer/team_property_renderer';
import TextPropertyRenderer from './text_property_renderer/textPropertyRenderer';
import TimestampPropertyRenderer from './timestamp_property_renderer/timestamp_property_renderer';
import UserPropertyRenderer from './user_property_renderer/userPropertyRenderer';
import './property_value_renderer.scss';
type Props = {
field: PropertyField;
value: PropertyValue<unknown>;
};
export default function PropertyValueRenderer({field, value}: Props) {
switch (field.type) {
case 'text':
return (
<RenderTextSubtype
field={field}
value={value}
/>
);
case 'user':
return (
<UserPropertyRenderer
field={field}
value={value}
/>
);
case 'select':
return (
<SelectPropertyRenderer
value={value}
field={field}
/>
);
default:
return null;
}
}
function RenderTextSubtype({field, value}: Props) {
if (field.type !== 'text') {
return null;
}
const subType = field.attrs?.subType ?? 'text';
switch (subType) {
case 'text':
return <TextPropertyRenderer value={value}/>;
case 'post':
return <PostPreviewPropertyRenderer value={value}/>;
case 'channel':
return <ChannelPropertyRenderer value={value}/>;
case 'team':
return <TeamPropertyRenderer value={value}/>;
case 'timestamp':
return <TimestampPropertyRenderer value={value}/>;
default:
return null;
}
}

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.field {
display: flex;
flex: 1 0 0;
padding: 6px 8px;
}

View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.SelectProperty {
display: flex;
align-items: center;
padding: 3px 12px;
border-radius: 4px;
font-weight: 600;
gap: 6px;
}

View file

@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyField, PropertyValue, SelectPropertyField} from '@mattermost/types/properties';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import SelectPropertyRenderer from './selectPropertyRenderer';
describe('SelectPropertyRenderer', () => {
const baseField = {
id: 'test-field',
name: 'Test Field',
type: 'select',
attrs: {
editable: true,
options: [
{id: 'option1', name: 'option1', color: 'light_blue'},
{id: 'option2', name: 'option2', color: 'dark_blue'},
{id: 'option3', name: 'option3', color: 'dark_red'},
{id: 'option4', name: 'option4', color: 'light_gray'},
],
},
} as SelectPropertyField;
it('should render select property with light_blue color', () => {
const field = baseField;
const value = {value: 'option1'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toBeInTheDocument();
expect(element).toHaveTextContent('option1');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--button-bg-rgb), 0.08)',
color: '#FFF',
});
});
it('should render select property with dark_blue color', () => {
const field = baseField;
const value = {value: 'option2'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('option2');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--sidebar-text-active-border-rgb), 0.92)',
color: '#FFF',
});
});
it('should render select property with dark_red color', () => {
const field = baseField;
const value = {value: 'option3'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('option3');
expect(element).toHaveStyle({
backgroundColor: 'var(--error-text)',
color: '#FFF',
});
});
it('should render select property with default light_gray color', () => {
const field = baseField;
const value = {value: 'option4'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('option4');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
});
});
it('should use default color when option color is not found', () => {
const field: SelectPropertyField = {
...baseField,
attrs: {
editable: false,
options: [
{id: 'option1', name: 'option1', color: 'unknown_color'},
],
},
};
const value = {value: 'option1'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
});
});
it('should use default color when option is not found in field options', () => {
const field = baseField;
const value = {value: 'nonexistent_option'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('nonexistent_option');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
});
});
it('should use default color when field has no options', () => {
const field: SelectPropertyField = {
...baseField,
attrs: {
editable: false,
options: [],
},
};
const value = {value: 'some_value'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('some_value');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
});
});
it('should use default color when field attrs is undefined', () => {
const field = {
id: 'test-field',
name: 'Test Field',
type: 'select',
} as PropertyField;
const value = {value: 'some_value'} as PropertyValue<string>;
renderWithContext(
<SelectPropertyRenderer
field={field}
value={value}
/>,
);
const element = screen.getByTestId('select-property');
expect(element).toHaveTextContent('some_value');
expect(element).toHaveStyle({
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
});
});
});

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyField, PropertyValue, SelectPropertyField} from '@mattermost/types/properties';
import './selectPropertyRenderer.scss';
const DEFAULT_BACKGROUND_COLOR = 'light_gray';
type Props = {
field: PropertyField;
value: PropertyValue<unknown>;
}
export default function SelectPropertyRenderer({field, value}: Props) {
const valueConfig = (field as SelectPropertyField).attrs?.options?.find((option) => option.name === value.value);
const {backgroundColor, color} = getOptionColors(valueConfig?.color || DEFAULT_BACKGROUND_COLOR);
return (
<div
className='SelectProperty'
data-testid='select-property'
style={{
backgroundColor,
color,
}}
>
{value.value}
</div>
);
}
function getOptionColors(colorName: string): {backgroundColor: string; color: string} {
switch (colorName) {
case 'light_blue':
return {
backgroundColor: 'rgba(var(--button-bg-rgb), 0.08)',
color: '#FFF',
};
case 'dark_blue':
return {
backgroundColor: 'rgba(var(--sidebar-text-active-border-rgb), 0.92)',
color: '#FFF',
};
case 'dark_red':
return {
backgroundColor: 'var(--error-text)',
color: '#FFF',
};
default:
// Default is light grey color
return {
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
};
}
}

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.TeamPropertyRenderer {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 8px;
.TeamIcon {
width: 20px;
height: 20px;
background: var(--center-channel-color-08);
font-size: 12px;
}
}

View file

@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
import type {Team} from '@mattermost/types/teams';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import TeamPropertyRenderer from './team_property_renderer';
describe('TeamPropertyRenderer', () => {
const mockTeam: Team = TestHelper.getTeamMock({
id: 'team-id-123',
display_name: 'Test Team',
name: 'test-team',
});
const defaultProps = {
value: {
value: 'team-id-123',
} as PropertyValue<string>,
};
test('should render team name and icon when team exists', () => {
const state = {
entities: {
teams: {
teams: {
'team-id-123': mockTeam,
},
},
},
};
renderWithContext(
<TeamPropertyRenderer {...defaultProps}/>,
state,
);
expect(screen.getByTestId('team-property')).toBeVisible();
expect(screen.getByText('Test Team')).toBeVisible();
expect(screen.queryByTestId('teamIconInitial')).toBeVisible();
});
test('should render deleted team message when team does not exist', () => {
const state = {
entities: {
teams: {
teams: {},
},
},
};
renderWithContext(
<TeamPropertyRenderer {...defaultProps}/>,
state,
);
expect(screen.getByTestId('team-property')).toBeVisible();
expect(screen.getByText(/Deleted team ID: team-id-123/)).toBeInTheDocument();
expect(screen.queryByText('Test Team')).not.toBeInTheDocument();
});
test('should handle empty team id', () => {
const propsWithEmptyId = {
value: {
value: '',
} as PropertyValue<string>,
};
const state = {
entities: {
teams: {
teams: {},
},
},
};
renderWithContext(
<TeamPropertyRenderer {...propsWithEmptyId}/>,
state,
);
expect(screen.getByTestId('team-property')).toBeVisible();
expect(screen.getByText(/Deleted team ID:/)).toBeInTheDocument();
});
test('should handle null team id', () => {
const propsWithNullId = {
value: {
value: null,
} as PropertyValue<null>,
};
const state = {
entities: {
teams: {
teams: {},
},
},
};
renderWithContext(
<TeamPropertyRenderer {...propsWithNullId}/>,
state,
);
expect(screen.getByTestId('team-property')).toBeVisible();
expect(screen.getByText(/Deleted team ID:/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {PropertyValue} from '@mattermost/types/properties';
import {useTeam} from 'components/common/hooks/use_team';
import {TeamIcon} from 'components/widgets/team_icon/team_icon';
import {imageURLForTeam} from 'utils/utils';
import './team_property_renderer.scss';
type Props = {
value: PropertyValue<unknown>;
}
export default function TeamPropertyRenderer({value}: Props) {
const intl = useIntl();
const teamId = value.value as string;
const team = useTeam(teamId);
return (
<div
className='TeamPropertyRenderer'
data-testid='team-property'
>
{
team &&
<>
<TeamIcon
size='xxs'
content={team.display_name}
intl={intl}
url={imageURLForTeam(team)}
/>
{team.display_name}
</>
}
{
!team &&
<FormattedMessage
id='post_card.channel_property.deleted_team'
defaultMessage='Deleted team ID: {teamId}'
values={{teamId}}
/>
}
</div>
);
}

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
import {renderWithContext} from 'tests/react_testing_utils';
import TextPropertyRenderer from './textPropertyRenderer';
describe('TextPropertyRenderer', () => {
const baseProps = {
value: {
value: 'Test text value',
} as PropertyValue<string>,
};
test('should render text property value', () => {
renderWithContext(<TextPropertyRenderer {...baseProps}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('Test text value');
expect(textElement).toHaveClass('TextProperty');
});
test('should render empty string value', () => {
const props = {
value: {
value: '',
} as PropertyValue<string>,
};
renderWithContext(<TextPropertyRenderer {...props}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('');
expect(textElement).toHaveClass('TextProperty');
});
test('should render numeric value as string', () => {
const props = {
value: {
value: 123,
} as PropertyValue<number>,
};
renderWithContext(<TextPropertyRenderer {...props}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('123');
expect(textElement).toHaveClass('TextProperty');
});
test('should render null value', () => {
const props = {
value: {
value: null,
} as PropertyValue<null>,
};
renderWithContext(<TextPropertyRenderer {...props}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('');
expect(textElement).toHaveClass('TextProperty');
});
test('should render undefined value', () => {
const props = {
value: {
value: undefined,
} as PropertyValue<undefined>,
};
renderWithContext(<TextPropertyRenderer {...props}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('');
expect(textElement).toHaveClass('TextProperty');
});
test('should render special characters', () => {
const props = {
value: {
value: '!@#$%^&*()_+-=[]{}|;:,.<>?',
} as PropertyValue<string>,
};
renderWithContext(<TextPropertyRenderer {...props}/>);
const textElement = screen.getByTestId('text-property');
expect(textElement).toBeVisible();
expect(textElement).toHaveTextContent('!@#$%^&*()_+-=[]{}|;:,.<>?');
expect(textElement).toHaveClass('TextProperty');
});
});

View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
type Props = {
value: PropertyValue<unknown>;
}
export default function TextPropertyRenderer({value}: Props) {
return (
<span
className='TextProperty'
data-testid='text-property'
>
{value.value}
</span>
);
}

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import TimestampPropertyRenderer from './timestamp_property_renderer';
describe('TimestampPropertyRenderer', () => {
const mockValue = {
value: 1642694400000, // January 20, 2022 12:00:00 PM UTC
} as PropertyValue<number>;
const baseState = {
entities: {
preferences: {
myPreferences: {},
},
users: {
currentUserId: 'user-id',
profiles: {
'user-id': {
id: 'user-id',
timezone: {
useAutomaticTimezone: false,
manualTimezone: 'UTC',
},
},
},
},
},
};
it('should render timestamp component with the provided value', () => {
renderWithContext(
<TimestampPropertyRenderer value={mockValue}/>,
baseState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Thursday, January 20, 2022 at 4:00:00 PM');
});
it('should handle zero timestamp value', () => {
const zeroValue = {
value: 0,
} as PropertyValue<number>;
renderWithContext(
<TimestampPropertyRenderer value={zeroValue}/>,
baseState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Thursday, January 1, 1970 at 12:00:00 AM');
});
it('should handle negative timestamp value', () => {
const negativeValue = {
value: -86400000, // One day before epoch
} as PropertyValue<number>;
renderWithContext(
<TimestampPropertyRenderer value={negativeValue}/>,
baseState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Wednesday, December 31, 1969 at 12:00:00 AM');
});
it('should handle future timestamp value', () => {
const futureValue = {
value: 2000000000000, // May 18, 2033
} as PropertyValue<number>;
renderWithContext(
<TimestampPropertyRenderer value={futureValue}/>,
baseState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Wednesday, May 18, 2033 at 3:33:20 AM');
});
it('should render in 12-hour format by default', () => {
const timeValue = {
value: 1642701600000, // January 20, 2022 2:00:00 PM UTC
} as PropertyValue<number>;
renderWithContext(
<TimestampPropertyRenderer value={timeValue}/>,
baseState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Thursday, January 20, 2022 at 6:00:00 PM');
});
it('should render in 24-hour format when military time preference is enabled', () => {
const timeValue = {
value: 1642701600000, // January 20, 2022 2:00:00 PM UTC
} as PropertyValue<number>;
const militaryTimeState = {
...baseState,
entities: {
...baseState.entities,
preferences: {
myPreferences: {
'display_settings--use_military_time': {
category: 'display_settings',
name: 'use_military_time',
user_id: 'user-id',
value: 'true',
},
},
},
},
};
renderWithContext(
<TimestampPropertyRenderer value={timeValue}/>,
militaryTimeState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Thursday, January 20, 2022 at 18:00:00');
});
it('should render in 12-hour format when military time preference is disabled', () => {
const timeValue = {
value: 1642701600000, // January 20, 2022 2:00:00 PM UTC
} as PropertyValue<number>;
const twelveHourState = {
...baseState,
entities: {
...baseState.entities,
preferences: {
myPreferences: {
'display_settings--use_military_time': {
category: 'display_settings',
name: 'use_military_time',
user_id: 'user-id',
value: 'false',
},
},
},
},
};
renderWithContext(
<TimestampPropertyRenderer value={timeValue}/>,
twelveHourState,
);
const timestampElement = screen.getByTestId('timestamp-property');
expect(timestampElement).toBeVisible();
expect(timestampElement).toHaveTextContent('Thursday, January 20, 2022 at 6:00:00 PM');
});
});

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
import Timestamp from 'components/timestamp';
const getTimeFormat: ComponentProps<typeof Timestamp>['useTime'] = (_, {hour, minute, second}) => ({hour, minute, second});
const getDateFormat: ComponentProps<typeof Timestamp>['useDate'] = {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'};
type Props = {
value: PropertyValue<unknown>;
}
export default function TimestampPropertyRenderer({value}: Props) {
return (
<div
className='TimestampPropertyRenderer'
data-testid='timestamp-property'
>
<Timestamp
value={value.value as number}
useSemanticOutput={false}
useDate={getDateFormat}
useTime={getTimeFormat}
/>
</div>
);
}

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.SelectableUserPropertyRenderer {
.UserMultiSelector {
.UserMultiSelector__control {
height: 32px;
min-height: unset;
align-items: baseline;
border: none !important;
box-shadow: none !important;
.UserMultiSelector__indicators {
height: 32px;
}
.UserMultiSelector__value-container {
padding: 0;
.SelectableUserPropertyRenderer_placeholder {
display: flex;
align-items: center;
gap: 8px;
i.icon.icon-account-outline {
width: 24px;
min-width: 24px;
height: 24px;
padding: 0;
border-radius: 50%;
margin: 0;
background: rgba(var(--center-channel-color-rgb), 0.12);
font-size: 12px;
}
}
}
}
}
}
.UserMultiSelector__menu-portal {
z-index: 22 !important;
.UserMultiSelector__menu {
min-width: 200px;
max-width: 400px;
margin-top: 0 !important;
.UserMultiSelector__control {
min-height: unset;
max-height: 32px;
align-items: baseline;
}
.UserMultiSelector__indicators {
height: 32px;
}
}
}

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {PropertyField} from '@mattermost/types/properties';
import './selectable_user_property_renderer.scss';
import {UserSelector} from 'components/admin_console/content_flagging/user_multiselector/user_multiselector';
type Props = {
field: PropertyField;
}
export function SelectableUserPropertyRenderer({field}: Props) {
const {formatMessage} = useIntl();
const placeholder = (
<span className='SelectableUserPropertyRenderer_placeholder'>
<i className='icon icon-account-outline'/>
{formatMessage({id: 'generic.unassigned', defaultMessage: 'Unassigned'})}
</span>
);
return (
<div
className='SelectableUserPropertyRenderer'
data-testid='selectable-user-property'
>
<UserSelector
isMulti={false}
id={`selectable-user-property-renderer-${field.id}`}
placeholder={placeholder}
showDropdownIndicator={true}
/>
</div>
);
}

View file

@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {UserProfile} from '@mattermost/types/users';
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import UserPropertyRenderer from './userPropertyRenderer';
describe('UserPropertyRenderer', () => {
const mockUser: UserProfile = TestHelper.getUserMock({
id: 'user-id-123',
username: 'testuser',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
});
const baseState: DeepPartial<GlobalState> = {
entities: {
users: {
profiles: {
'user-id-123': mockUser,
},
profilesInChannel: {},
profilesNotInChannel: {},
profilesWithoutTeam: new Set(),
profilesInTeam: {},
profilesNotInTeam: {},
statuses: {},
myUserAccessTokens: {},
stats: {},
filteredStats: {},
},
general: {
config: {},
license: {},
},
preferences: {
myPreferences: {},
},
teams: {
currentTeamId: 'team-id',
teams: {},
myMembers: {},
membersInTeam: {},
stats: {},
},
channels: {
currentChannelId: 'channel-id',
channels: {},
channelsInTeam: {},
myMembers: {},
stats: {},
groupsAssociatedToChannel: {},
totalCount: 0,
manuallyUnread: {},
channelModerations: {},
channelMemberCountsByGroup: {},
},
},
};
const mockField = {
id: 'field-id',
name: 'Assignee',
type: 'user',
attrs: {},
} as PropertyField;
const mockValue = {
value: 'user-id-123',
} as PropertyValue<string>;
describe('when field is not editable', () => {
it('should render user avatar and profile component', () => {
const field = {...mockField, attrs: {editable: false}};
renderWithContext(
<UserPropertyRenderer
field={field}
value={mockValue}
/>,
baseState,
);
const userProperty = screen.getByTestId('user-property');
expect(userProperty).toBeVisible();
const userProfile = screen.getByText('testuser');
expect(userProfile).toBeVisible();
expect(screen.queryByAltText('testuser profile image')).toBeVisible();
});
});
describe('when field is editable', () => {
it('should render selectable user property renderer', () => {
const editableField = {...mockField, attrs: {editable: true}};
renderWithContext(
<UserPropertyRenderer
field={editableField}
value={mockValue}
/>,
baseState,
);
const selectableUserProperty = screen.getByTestId('selectable-user-property');
expect(selectableUserProperty).toBeVisible();
// Check that the placeholder text is present
const placeholder = screen.getByText('Unassigned');
expect(placeholder).toBeVisible();
// Check that the UserSelector component is rendered
const userSelector = screen.getByRole('combobox');
expect(userSelector).toBeVisible();
});
});
});

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import {useUser} from 'components/common/hooks/useUser';
import PreviewPostAvatar from 'components/post_view/post_message_preview/avatar/avatar';
import UserProfileComponent from 'components/user_profile';
import {SelectableUserPropertyRenderer} from './selectable_user_property_renderer';
import './user_property_renderer.scss';
type Props = {
field: PropertyField;
value: PropertyValue<unknown>;
}
export default function UserPropertyRenderer({field, value}: Props) {
const userId = value.value as string;
const user = useUser(userId);
if (field.attrs?.editable) {
return (
<SelectableUserPropertyRenderer
field={field}
/>
);
}
return (
<div
className='UserPropertyRenderer'
data-testid='user-property'
>
{
user &&
<PreviewPostAvatar
user={user}
/>
}
<UserProfileComponent
userId={user?.id || ''}
/>
</div>
);
}

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.UserPropertyRenderer {
display: flex;
flex-direction: row;
gap: 8px;
}

View file

@ -431,6 +431,13 @@ Object {
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unmount": [Function],
"updateStoreState": [Function],
}

View file

@ -676,6 +676,13 @@ Object {
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unmount": [Function],
"updateStoreState": [Function],
}
@ -1361,6 +1368,13 @@ Object {
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unmount": [Function],
"updateStoreState": [Function],
}
@ -1970,6 +1984,13 @@ Object {
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unmount": [Function],
"updateStoreState": [Function],
}

View file

@ -3965,6 +3965,9 @@
"custom_status.suggestions.recent_title": "RECENT",
"custom_status.suggestions.title": "SUGGESTIONS",
"custom_status.suggestions.working_from_home": "Working from home",
"data_spillage_report_post.title": "{user} flagged a message for review",
"data_spillage_report.keep_message.button_text": "Keep message",
"data_spillage_report.remove_message.button_text": "Remove message",
"date_separator.today": "Today",
"date_separator.tomorrow": "Tomorrow",
"date_separator.yesterday": "Yesterday",
@ -4361,6 +4364,7 @@
"generic.okay": "Okay",
"generic.previous": "Previous",
"generic.submit": "Submit",
"generic.unassigned": "Unassigned",
"get_app.continueToBrowser": "View in Browser",
"get_app.dontHaveTheDesktopApp": "Don't have the Desktop App?",
"get_app.dontHaveTheMobileApp": "Don't have the Mobile App?",
@ -5073,6 +5077,8 @@
"post_body.commentedOn.loadingMessage": "Loading…",
"post_body.deleted": "(message deleted)",
"post_body.plusMore": " plus {count, number} other {count, plural, one {file} other {files}}",
"post_card.channel_property.deleted_channel": "Deleted channel ID: {channelId}",
"post_card.channel_property.deleted_team": "Deleted team ID: {teamId}",
"post_delete.notPosted": "Comment could not be posted",
"post_delete.okay": "Okay",
"post_delete.someone": "Someone deleted the message on which you tried to post a comment.",
@ -5185,6 +5191,7 @@
"promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?",
"promote_to_user_modal.promote": "Promote",
"promote_to_user_modal.title": "Promote guest {username} to member",
"property_card.actions_row.label": "Actions",
"public_private_selector.private.description": "Only invited members",
"public_private_selector.private.title": "Private",
"public_private_selector.public.description": "Anyone",

View file

@ -172,7 +172,7 @@ export function setThemeDefaults(theme: Partial<Theme>): Theme {
}
// getContrastingSimpleColor returns a contrasting color - either black or white, depending on the luminance
// of the supplied color. Both input and outpur colors are in hexadecimal color code.
// of the supplied color. Both input and output colors are in hexadecimal color code.
export function getContrastingSimpleColor(colorHexCode: string): string {
const color = colorHexCode.startsWith('#') ? colorHexCode.slice(1) : colorHexCode;

View file

@ -121,6 +121,7 @@ exports[`plugins/PostMessageView should match snapshot with no extended post typ
"onImageLoaded": [Function],
}
}
isRHS={false}
message="this is some text"
options={Object {}}
post={

View file

@ -13,6 +13,10 @@ const PostTypePlugin = () => (
<span id='pluginId'>{'PostTypePlugin'}</span>
);
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
});
describe('plugins/PostMessageView', () => {
const post = {type: 'testtype', message: 'this is some text', id: 'post_id'} as any;

View file

@ -90,6 +90,7 @@ export const renderWithContext = (
results.rerender(renderState.component);
},
store: testStore,
};
};

View file

@ -858,6 +858,7 @@ export const PostTypes = {
WRANGLER: 'system_wrangler',
CUSTOM_CALLS: 'custom_calls',
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
CUSTOM_DATA_SPILLAGE_REPORT: 'custom_spillage_report',
};
export const StatTypes = keyMirror({
@ -1512,6 +1513,23 @@ export const ZoomSettings = {
MAX_SCALE: 3.0,
};
export const DataSpillagePropertyNames = {
Status: 'Status',
FlaggedBy: 'Flagged by',
Reason: 'Reason',
Comment: 'Comment',
ReportingTime: 'Reporting Time',
ReviewingUser: 'Reviewing User',
ActionBy: 'Action By',
ActionComment: 'Action Comment',
ActionTime: 'Action Time',
Message: 'Message',
PostedIn: 'Posted in',
Team: 'Team',
PostedBy: 'Posted by',
PostedAt: 'Posted at',
};
export const Constants = {
SettingsTypes,
JobTypes,

View file

@ -32,6 +32,7 @@ export type PostType = 'system_add_remove' |
'system_generic' |
'reminder' |
'system_wrangler' |
'custom_spillage_report' |
'';
export type PostEmbedType = 'image' | 'link' | 'message_attachment' | 'opengraph' | 'permalink';

View file

@ -15,7 +15,10 @@ export type PropertyField = {
group_id: string;
name: string;
type: FieldType;
attrs?: {[key: string]: unknown};
attrs?: {
subType?: string;
[key: string]: unknown;
};
target_id?: string;
target_type?: string;
create_at: number;
@ -28,6 +31,7 @@ export type PropertyValue<T> = {
target_id: string;
target_type: string;
group_id: string;
field_id: string;
value: T;
create_at: number;
update_at: number;
@ -63,4 +67,11 @@ export type UserPropertyField = PropertyField & {
};
};
export type SelectPropertyField = PropertyField & {
attrs?: {
editable?: boolean;
options?: PropertyFieldOption[];
};
}
export type UserPropertyFieldPatch = Partial<Pick<UserPropertyField, 'name' | 'attrs' | 'type'>>;