mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
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:
parent
9e0e1e9c93
commit
22d0e66fbe
73 changed files with 4227 additions and 4123 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -13,4 +13,10 @@
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.singleSelect {
|
||||
.UserMultiSelector__value-container {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
207
webapp/channels/src/components/common/hooks/useChannel.test.ts
Normal file
207
webapp/channels/src/components/common/hooks/useChannel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
webapp/channels/src/components/common/hooks/useChannel.ts
Normal file
15
webapp/channels/src/components/common/hooks/useChannel.ts
Normal 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,
|
||||
});
|
||||
229
webapp/channels/src/components/common/hooks/use_team.test.ts
Normal file
229
webapp/channels/src/components/common/hooks/use_team.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
15
webapp/channels/src/components/common/hooks/use_team.ts
Normal file
15
webapp/channels/src/components/common/hooks/use_team.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.DataSpillageAction {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const renderWithContext = (
|
|||
|
||||
results.rerender(renderState.component);
|
||||
},
|
||||
store: testStore,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'>>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue