Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {getIsMobileView} from 'selectors/views/browser';
export function showActionsDropdownPulsatingDot(state: GlobalState): boolean {
if (getIsMobileView(state)) {
return false;
}
const actionsMenuTutorialState = get(state, Preferences.CATEGORY_ACTIONS_MENU, Preferences.NAME_ACTIONS_MENU_TUTORIAL_STATE, false);
const modalAlreadyViewed = actionsMenuTutorialState && JSON.parse(actionsMenuTutorialState)[Preferences.ACTIONS_MENU_VIEWED];
return !modalAlreadyViewed;
}

Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {cloneDeep} from 'lodash';
import {createSelector} from 'reselect';
import {getMySystemPermissions} from 'mattermost-redux/selectors/entities/roles_helpers';
import {ResourceToSysConsolePermissionsTable, RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole';
import AdminDefinition from 'components/admin_console/admin_definition.jsx';
export const getAdminDefinition = createSelector(
'getAdminDefinition',
() => AdminDefinition,
(state) => state.plugins.adminConsoleReducers,
(adminDefinition, reducers) => {
let result = cloneDeep(AdminDefinition);
for (const reducer of Object.values(reducers)) {
result = reducer(result);
}
return result;
},
);
export const getAdminConsoleCustomComponents = (state, pluginId) =>
state.plugins.adminConsoleCustomComponents[pluginId] || {};
export const getConsoleAccess = createSelector(
'getConsoleAccess',
getAdminDefinition,
getMySystemPermissions,
(adminDefinition, mySystemPermissions) => {
const consoleAccess = {read: {}, write: {}};
const addEntriesForKey = (entryKey) => {
const permissions = ResourceToSysConsolePermissionsTable[entryKey].filter((x) => mySystemPermissions.has(x));
consoleAccess.read[entryKey] = permissions.length !== 0;
consoleAccess.write[entryKey] = permissions.some((permission) => permission.startsWith('sysconsole_write_'));
};
const mapAccessValuesForKey = ([key]) => {
if (typeof RESOURCE_KEYS[key.toUpperCase()] === 'object') {
Object.values(RESOURCE_KEYS[key.toUpperCase()]).forEach((entry) => {
addEntriesForKey(entry);
});
} else {
addEntriesForKey(key);
}
};
Object.entries(adminDefinition).forEach(mapAccessValuesForKey);
return consoleAccess;
},
);

Просмотреть файл

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AdminDefinition from 'components/admin_console/admin_definition.jsx';
import {getAdminDefinition} from 'selectors/admin_console.jsx';
describe('Selectors.AdminConsole', () => {
describe('get admin definitions', () => {
it('should return the default admin definition if there is not plugins', () => {
const state = {plugins: {adminConsoleReducers: {}}};
expect(getAdminDefinition(state)).toEqual(AdminDefinition);
});
it('should allow to remove everything with a plugin', () => {
const result = getAdminDefinition({
plugins: {
adminConsoleReducers: {clean: () => ({})},
},
});
expect(result).toEqual({});
});
it('should allow to add a value to the existing definition', () => {
const result = getAdminDefinition({
plugins: {
adminConsoleReducers: {
'add-something': (data) => {
data.something = 'test';
return data;
},
},
},
});
expect(result.something).toEqual('test');
});
it('should allow to use multiple plugins', () => {
const result = getAdminDefinition({
plugins: {
adminConsoleReducers: {
'add-something': (data) => {
data.something = 'test';
return data;
},
'add-other-thing': (data) => {
data.otherThing = 'other-thing';
return data;
},
},
},
});
expect(result.something).toEqual('test');
expect(result.otherThing).toEqual('other-thing');
});
});
});

86
webapp/channels/src/selectors/cloud.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Invoice, Subscription} from '@mattermost/types/cloud';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {createSelector} from 'reselect';
import {GlobalState} from 'types/store';
export enum InquiryType {
Technical = 'technical',
Sales = 'sales',
Billing = 'billing',
}
export enum TechnicalInquiryIssue {
AdminConsole = 'admin_console',
MattermostMessaging = 'mm_messaging',
DataExport = 'data_export',
Other = 'other',
}
export enum SalesInquiryIssue {
AboutPurchasing = 'about_purchasing',
CancelAccount = 'cancel_account',
PurchaseNonprofit = 'purchase_nonprofit',
TrialQuestions = 'trial_questions',
UpgradeEnterprise = 'upgrade_enterprise',
SomethingElse = 'something_else',
}
type Issue = SalesInquiryIssue | TechnicalInquiryIssue
export const getCloudContactUsLink: (state: GlobalState) => (inquiry: InquiryType, inquiryIssue?: Issue) => string = createSelector(
'getCloudContactUsLink',
getConfig,
getCurrentUser,
(config, user) => {
// cloud/contact-us with query params for name, email and inquiry
const cwsUrl = config.CWSURL;
const fullName = `${user.first_name} ${user.last_name}`;
return (inquiry: InquiryType, inquiryIssue?: Issue) => {
const inquiryIssueQuery = inquiryIssue ? `&inquiry-issue=${inquiryIssue}` : '';
return `${cwsUrl}/cloud/contact-us?email=${encodeURIComponent(user.email)}&name=${encodeURIComponent(fullName)}&inquiry=${inquiry}${inquiryIssueQuery}`;
};
},
);
export const getExpandSeatsLink: (state: GlobalState) => (licenseId: string) => string = createSelector(
'getExpandSeatsLink',
getConfig,
(config) => {
const cwsUrl = config.CWSURL;
return (licenseId: string) => {
return `${cwsUrl}/subscribe/expand?licenseId=${licenseId}`;
};
},
);
export const getCloudDelinquentInvoices = createSelector(
'getCloudDelinquentInvoices',
(state: GlobalState) => state.entities.cloud.invoices as Record<string, Invoice>,
(invoices: Record<string, Invoice>) => {
if (!invoices) {
return [];
}
return Object.values(invoices || []).filter((invoice) => invoice.status !== 'paid' && invoice.total > 0);
},
);
export const isCloudDelinquencyGreaterThan90Days = createSelector(
'isCloudDelinquencyGreaterThan90Days',
(state: GlobalState) => state.entities.cloud.subscription as Subscription,
(subscription: Subscription) => {
if (!subscription || !subscription.delinquent_since) {
return false;
}
const now = new Date();
const delinquentDate = new Date(subscription.delinquent_since * 1000);
return (Math.floor((now.getTime() - delinquentDate.getTime()) / (1000 * 60 * 60 * 24)) >= 90);
},
);

157
webapp/channels/src/selectors/drafts.test.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {GlobalState} from 'types/store';
import {StoragePrefixes} from 'utils/constants';
import {makeGetDrafts, makeGetDraftsByPrefix, makeGetDraftsCount} from './drafts';
const currentUserId = 'currentUserId';
const currentChannelId = 'channelId';
const rootId = 'rootId';
const currentTeamId = 'teamId';
const initialState = {
entities: {
users: {
currentUserId,
profiles: {
currentUserId: {
id: currentUserId,
roles: 'system_role',
},
},
},
channels: {
currentChannelId,
channels: {
currentChannelId: {id: currentChannelId, team_id: currentTeamId},
},
channelsInTeam: {
currentTeamId: [currentChannelId],
},
myMembers: {
currentChannelId: {
channel_id: currentChannelId,
user_id: currentUserId,
roles: 'channel_role',
mention_count: 1,
msg_count: 9,
},
},
},
teams: {
currentTeamId,
teams: {
currentTeamId: {
id: currentTeamId,
name: 'team-1',
displayName: 'Team 1',
},
},
myMembers: {
currentTeamId: {roles: 'team_role'},
},
},
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState;
const commentDrafts = [
{
message: 'comment_draft',
fileInfos: [],
uploadsInProgress: [],
channelId: currentChannelId,
rootId,
show: true,
},
];
const channelDrafts = [
{
message: 'channel_draft',
fileInfos: [],
uploadsInProgress: [],
channelId: currentChannelId,
show: true,
},
];
const state = mergeObjects(initialState, {
storage: {
storage: {
[`${StoragePrefixes.COMMENT_DRAFT}${rootId}`]: {
value: commentDrafts[0],
timestamp: new Date('2022-11-20T23:21:53.552Z'),
},
[`${StoragePrefixes.DRAFT}${currentChannelId}`]: {
value: channelDrafts[0],
timestamp: new Date('2022-11-20T23:21:53.552Z'),
},
},
},
});
const expectedCommentDrafts = [
{
id: rootId,
key: `${StoragePrefixes.COMMENT_DRAFT}${rootId}`,
type: 'thread',
timestamp: new Date('2022-11-20T23:21:53.552Z'),
value: commentDrafts[0],
},
];
const expectedChannelDrafts = [
{
id: currentChannelId,
key: `${StoragePrefixes.DRAFT}${currentChannelId}`,
type: 'channel',
timestamp: new Date('2022-11-20T23:21:53.552Z'),
value: channelDrafts[0],
},
];
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getMyActiveChannelIds: () => currentChannelId,
}));
describe('makeGetDraftsByPrefix', () => {
it('should return comment drafts when given the comment_draft prefix', () => {
const getDraftsByPrefix = makeGetDraftsByPrefix(StoragePrefixes.COMMENT_DRAFT);
const drafts = getDraftsByPrefix(state);
expect(drafts).toEqual(expectedCommentDrafts);
});
it('should return channel drafts when given the comment_draft prefix', () => {
const getDraftsByPrefix = makeGetDraftsByPrefix(StoragePrefixes.DRAFT);
const drafts = getDraftsByPrefix(state);
expect(drafts).toEqual(expectedChannelDrafts);
});
});
describe('makeGetDrafts', () => {
it('should return all drafts', () => {
const getDrafts = makeGetDrafts();
const drafts = getDrafts(state);
expect(drafts).toEqual([...expectedChannelDrafts, ...expectedCommentDrafts]);
});
});
describe('makeGetDraftsCount', () => {
it('should return drafts count', () => {
const getDraftsCount = makeGetDraftsCount();
const draftCount = getDraftsCount(state);
expect(draftCount).toEqual([...expectedChannelDrafts, ...expectedCommentDrafts].length);
});
});

108
webapp/channels/src/selectors/drafts.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {getMyActiveChannelIds} from 'mattermost-redux/selectors/entities/channels';
import {get, onboardingTourTipsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {GlobalState} from 'types/store';
import {DraftInfo, PostDraft} from 'types/store/draft';
import {StoragePrefixes} from 'utils/constants';
import {getDraftInfoFromKey} from 'utils/storage_utils';
import {getIsMobileView} from 'selectors/views/browser';
export type Draft = DraftInfo & {
key: keyof GlobalState['storage']['storage'];
value: PostDraft;
timestamp: Date;
}
export type DraftSelector = (state: GlobalState) => Draft[];
export type DraftCountSelector = (state: GlobalState) => number;
export function showDraftsPulsatingDotAndTourTip(state: GlobalState): boolean {
if (!onboardingTourTipsEnabled(state) || getIsMobileView(state)) {
return false;
}
const draftsTourTipShowed = get(state, Preferences.CATEGORY_DRAFTS, Preferences.DRAFTS_TOUR_TIP_SHOWED, '');
const draftsAlreadyViewed = draftsTourTipShowed && JSON.parse(draftsTourTipShowed)[Preferences.DRAFTS_TOUR_TIP_SHOWED];
return !draftsAlreadyViewed;
}
export function makeGetDraftsByPrefix(prefix: string): DraftSelector {
return createSelector(
'makeGetDraftsByPrefix',
(state: GlobalState) => state.storage?.storage,
(storage) => {
if (!storage) {
return [];
}
return Object.keys(storage).flatMap((key) => {
const item = storage[key];
if (
key.startsWith(prefix) &&
item != null &&
item.value != null &&
(item.value.message || item.value.fileInfos?.length > 0) &&
item.value.show
) {
const info = getDraftInfoFromKey(key, prefix);
if (info === null || !info.id) {
return [];
}
return {
...item,
key,
id: info.id,
type: info.type,
};
}
return [];
});
},
);
}
/**
* Gets all local drafts in storage.
* @param excludeInactive determines if we filter drafts based on active channels.
*/
export function makeGetDrafts(excludeInactive = true): DraftSelector {
const getChannelDrafts = makeGetDraftsByPrefix(StoragePrefixes.DRAFT);
const getRHSDrafts = makeGetDraftsByPrefix(StoragePrefixes.COMMENT_DRAFT);
return createSelector(
'makeGetDrafts',
getChannelDrafts,
getRHSDrafts,
getMyActiveChannelIds,
(channelDrafts, rhsDrafts, myChannels) => (
[...channelDrafts, ...rhsDrafts]
).
filter((draft) => (excludeInactive ? myChannels.indexOf(draft.value.channelId) !== -1 : true)).
sort((a, b) => b.value.updateAt - a.value.updateAt),
);
}
export function makeGetDraftsCount(): DraftCountSelector {
const getChannelDrafts = makeGetDraftsByPrefix(StoragePrefixes.DRAFT);
const getRHSDrafts = makeGetDraftsByPrefix(StoragePrefixes.COMMENT_DRAFT);
return createSelector(
'makeGetDraftsCount',
getChannelDrafts,
getRHSDrafts,
getMyActiveChannelIds,
(channelDrafts, rhsDrafts, myChannels) => [...channelDrafts, ...rhsDrafts].
filter((draft) => myChannels.indexOf(draft.value.channelId) !== -1).length,
);
}

416
webapp/channels/src/selectors/emojis.test.js Обычный файл
Просмотреть файл

@@ -0,0 +1,416 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import mergeObjects from 'mattermost-redux/test/merge_objects';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import Constants, {Preferences} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import * as Selectors from './emojis';
function makeRecentEmojisPreferences(recentEmojis) {
const userId = 'currentUserId';
return {
[getPreferenceKey(Constants.Preferences.RECENT_EMOJIS, userId)]: {
category: Constants.Preferences.RECENT_EMOJIS,
name: userId,
user_id: userId,
value: JSON.stringify(recentEmojis),
},
};
}
describe('getRecentEmojisData', () => {
const currentUserId = 'currentUserId';
const baseState = {
entities: {
emojis: {
customEmoji: {},
},
general: {
config: {
EnableCustomEmojis: 'true',
},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId,
},
},
};
test('should return an empty array when there are no recent emojis in storage', () => {
expect(Selectors.getRecentEmojisData(baseState)).toEqual([]);
});
test('should return the names of recent system emojis', () => {
const recentEmojis = [
{name: 'rage', usageCount: 1},
{name: 'nauseated_face', usageCount: 2},
{name: 'innocent', usageCount: 3},
{name: '+1', usageCount: 4},
{name: 'sob', usageCount: 5},
{name: 'grinning', usageCount: 6},
{name: 'mm', usageCount: 7},
];
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual(recentEmojis);
});
test('should return the names of recent custom emojis', () => {
const recentEmojis = [
{name: 'strawberry', usageCount: 1},
{name: 'flag-au', usageCount: 1},
{name: 'kappa', usageCount: 1},
{name: 'gitlab', usageCount: 1},
{name: 'thanks', usageCount: 1},
];
const state = mergeObjects(baseState, {
entities: {
emojis: {
customEmojis: {
kappa: TestHelper.getCustomEmojiMock({name: 'kappa'}),
gitlab: TestHelper.getCustomEmojiMock({name: 'gitlab'}),
thanks: TestHelper.getCustomEmojiMock({name: 'thanks'}),
},
},
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual(recentEmojis);
});
test('should return the names of missing emojis so that they can be loaded later', () => {
const recentEmojis = [
{name: 'strawberry', usageCount: 1},
{name: 'flag-au', usageCount: 1},
{name: 'kappa', usageCount: 1},
{name: 'gitlab', usageCount: 1},
{name: 'thanks', usageCount: 1},
];
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual(recentEmojis);
});
describe('should return skin toned emojis in the user\'s current skin tone', () => {
const recentEmojis = [
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_dark_skin_tone', usageCount: 2},
{name: 'male-teacher', usageCount: 3},
{name: 'nose_light_skin_tone', usageCount: 4},
{name: 'red_haired_woman_medium_light_skin_tone', usageCount: 5},
{name: 'point_up_medium_dark_skin_tone', usageCount: 6},
];
test('with no skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut', usageCount: 2},
{name: 'male-teacher', usageCount: 3},
{name: 'nose', usageCount: 4},
{name: 'red_haired_woman', usageCount: 5},
{name: 'point_up', usageCount: 6},
]);
});
test('with default skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: 'default'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut', usageCount: 2},
{name: 'male-teacher', usageCount: 3},
{name: 'nose', usageCount: 4},
{name: 'red_haired_woman', usageCount: 5},
{name: 'point_up', usageCount: 6},
]);
});
test('with light skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FB'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_light_skin_tone', usageCount: 2},
{name: 'male-teacher_light_skin_tone', usageCount: 3},
{name: 'nose_light_skin_tone', usageCount: 4},
{name: 'red_haired_woman_light_skin_tone', usageCount: 5},
{name: 'point_up_light_skin_tone', usageCount: 6},
]);
});
test('with medium light skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FC'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_medium_light_skin_tone', usageCount: 2},
{name: 'male-teacher_medium_light_skin_tone', usageCount: 3},
{name: 'nose_medium_light_skin_tone', usageCount: 4},
{name: 'red_haired_woman_medium_light_skin_tone', usageCount: 5},
{name: 'point_up_medium_light_skin_tone', usageCount: 6},
]);
});
test('with medium skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FD'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_medium_skin_tone', usageCount: 2},
{name: 'male-teacher_medium_skin_tone', usageCount: 3},
{name: 'nose_medium_skin_tone', usageCount: 4},
{name: 'red_haired_woman_medium_skin_tone', usageCount: 5},
{name: 'point_up_medium_skin_tone', usageCount: 6},
]);
});
test('with medium dark skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FE'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_medium_dark_skin_tone', usageCount: 2},
{name: 'male-teacher_medium_dark_skin_tone', usageCount: 3},
{name: 'nose_medium_dark_skin_tone', usageCount: 4},
{name: 'red_haired_woman_medium_dark_skin_tone', usageCount: 5},
{name: 'point_up_medium_dark_skin_tone', usageCount: 6},
]);
});
test('with dark skin tone set', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
...makeRecentEmojisPreferences(recentEmojis),
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FF'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'strawberry', usageCount: 1},
{name: 'astronaut_dark_skin_tone', usageCount: 2},
{name: 'male-teacher_dark_skin_tone', usageCount: 3},
{name: 'nose_dark_skin_tone', usageCount: 4},
{name: 'red_haired_woman_dark_skin_tone', usageCount: 5},
{name: 'point_up_dark_skin_tone', usageCount: 6},
]);
});
});
test('should not change skin tone of emojis with multiple skin tones', () => {
const recentEmojis = [
{name: 'strawberry', usageCount: 1},
{name: 'man_and_woman_holding_hands_medium_light_skin_tone_medium_dark_skin_tone', usageCount: 1},
];
let state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual(recentEmojis);
state = mergeObjects(state, {
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FB'},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual(recentEmojis);
});
test('should de-duplicate results', () => {
const recentEmojis = [
{name: 'banana', usageCount: 1},
{name: 'banana', usageCount: 1},
{name: 'apple', usageCount: 1},
{name: 'banana', usageCount: 1},
];
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'apple', usageCount: 1},
{name: 'banana', usageCount: 3},
]);
});
test('should de-duplicate results with different skin tones', () => {
const recentEmojis = [
{name: 'ear', usageCount: 1},
{name: 'ear_light_skin_tone', usageCount: 1},
{name: 'ear_medium_light_skin_tone', usageCount: 1},
{name: 'nose_dark_skin_tone', usageCount: 1},
{name: 'nose_medium_dark_skin_tone', usageCount: 1},
{name: 'nose_light_skin_tone', usageCount: 1},
];
let state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'ear', usageCount: 3},
{name: 'nose', usageCount: 3},
]);
state = mergeObjects(state, {
entities: {
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FE'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toEqual([
{name: 'ear_medium_dark_skin_tone', usageCount: 3},
{name: 'nose_medium_dark_skin_tone', usageCount: 3},
]);
});
test('should only recalculate if relevant preferences change', () => {
const recentEmojis = [
{name: 'apple', usageCount: 1},
{name: 'banana', usageCount: 1},
];
let state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: makeRecentEmojisPreferences(recentEmojis),
},
},
});
const previousResult = Selectors.getRecentEmojisData(state);
expect(Selectors.getRecentEmojisData(state)).toBe(previousResult);
state = mergeObjects(state, {
preferences: {
emojis: {
customEmoji: {
someNewEmoji: TestHelper.getCustomEmojiMock({name: 'someNewCustomEmoji'}),
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toBe(previousResult);
state = mergeObjects(state, {
preferences: {
myPreferences: {
some_preference: {value: 'some value'},
},
},
});
expect(Selectors.getRecentEmojisData(state)).toBe(previousResult);
state = mergeObjects(state, {
entities: {
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE)]: {value: '1F3FE'},
},
},
},
});
expect(Selectors.getRecentEmojisData(state)).not.toBe(previousResult);
});
});

119
webapp/channels/src/selectors/emojis.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'utils/constants';
import EmojiMap from 'utils/emoji_map';
import {EmojiIndicesByAlias, Emojis} from 'utils/emoji';
import {GlobalState} from 'types/store';
import {RecentEmojiData} from '@mattermost/types/emojis';
import {convertEmojiSkinTone} from 'utils/emoji_utils';
export const getEmojiMap = createSelector(
'getEmojiMap',
getCustomEmojisByName,
(customEmojisByName) => {
return new EmojiMap(customEmojisByName);
},
);
export const getShortcutReactToLastPostEmittedFrom = (state: GlobalState) =>
state.views.emoji.shortcutReactToLastPostEmittedFrom;
export const getRecentEmojisData = createSelector(
'getRecentEmojisData',
(state: GlobalState) => {
return get(
state,
Preferences.RECENT_EMOJIS,
getCurrentUserId(state),
'[]',
);
},
getUserSkinTone,
(recentEmojis: string, userSkinTone: string) => {
if (!recentEmojis) {
return [];
}
const parsedEmojiData: RecentEmojiData[] = JSON.parse(recentEmojis);
return normalizeRecentEmojisData(parsedEmojiData, userSkinTone);
},
);
export function normalizeRecentEmojisData(data: RecentEmojiData[], userSkinTone: string) {
const usageCounts = new Map<string, number>();
for (const recentEmoji of data) {
const emojiIndex = EmojiIndicesByAlias.get(recentEmoji.name) ?? -1;
const systemEmoji = Emojis[emojiIndex];
let normalizedName;
if (systemEmoji) {
// This is a system emoji, so we may need to change its skin tone
normalizedName = convertEmojiSkinTone(systemEmoji, userSkinTone).short_name;
} else {
// This is a custom emoji, so its name will never change
normalizedName = recentEmoji.name;
}
// Dedupe and sum up the usage counts of any duplicated entries
const currentCount = usageCounts.get(normalizedName) ?? 0;
usageCounts.set(normalizedName, currentCount + recentEmoji.usageCount);
}
const normalizedData = [];
for (const [name, usageCount] of usageCounts.entries()) {
normalizedData.push({name, usageCount});
}
// Sort emojis by count in the ascending order, matching addRecentEmoji
normalizedData.sort((emojiA: RecentEmojiData, emojiB: RecentEmojiData) => emojiA.usageCount - emojiB.usageCount);
return normalizedData;
}
export const getRecentEmojisNames = createSelector(
'getRecentEmojisNames',
getRecentEmojisData,
(recentEmojisData: RecentEmojiData[]) => {
return recentEmojisData.map((emoji) => emoji.name);
},
);
export function getUserSkinTone(state: GlobalState): string {
return get(state, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE, 'default');
}
export function isCustomEmojiEnabled(state: GlobalState) {
const config = getConfig(state);
return config && config.EnableCustomEmoji === 'true';
}
export const getOneClickReactionEmojis = createSelector(
'getOneClickReactionEmojis',
getEmojiMap,
getRecentEmojisNames,
(emojiMap, recentEmojis: string[]) => {
if (recentEmojis.length === 0) {
return [];
}
return (recentEmojis).
map((recentEmoji) => emojiMap.get(recentEmoji)).
filter(isDefined).
slice(-3).
reverse();
},
);
function isDefined<T>(t: T | undefined): t is T {
return Boolean(t);
}

62
webapp/channels/src/selectors/general.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import * as UserAgent from 'utils/user_agent';
import type {GlobalState} from 'types/store';
declare global {
interface Window {
basename: string;
}
}
export function areTimezonesEnabledAndSupported(state: GlobalState) {
if (UserAgent.isInternetExplorer()) {
return false;
}
const config = getConfig(state);
return config.ExperimentalTimezone === 'true';
}
export function getBasePath(state: GlobalState) {
const config = getConfig(state) || {};
if (config.SiteURL) {
return new URL(config.SiteURL).pathname;
}
return window.basename || '/';
}
export const getCurrentUserTimezone = createSelector(
'getCurrentUserTimezone',
getCurrentUser,
areTimezonesEnabledAndSupported,
(user, enabledTimezone) => {
let timezone;
if (enabledTimezone) {
const userTimezone = getTimezoneForUserProfile(user);
timezone = userTimezone.useAutomaticTimezone ? userTimezone.automaticTimezone : userTimezone.manualTimezone;
}
return timezone;
},
);
export function getConnectionId(state: GlobalState) {
return state.websocket.connectionId;
}
export function isDevModeEnabled(state: GlobalState) {
const config = getConfig(state);
const EnableDeveloper = config && config.EnableDeveloper ? config.EnableDeveloper === 'true' : false;
return EnableDeveloper;
}

179
webapp/channels/src/selectors/i18n.test.js Обычный файл
Просмотреть файл

@@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from 'mattermost-redux/constants';
import {getCurrentLocale, getTranslations} from 'selectors/i18n';
describe('selectors/i18n', () => {
describe('getCurrentLocale', () => {
test('not logged in', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'fr',
},
},
users: {
currentUserId: '',
profiles: {},
},
},
};
expect(getCurrentLocale(state)).toEqual('fr');
});
test('logged in', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'fr',
},
},
users: {
currentUserId: 'abcd',
profiles: {
abcd: {
locale: 'de',
},
},
},
},
};
expect(getCurrentLocale(state)).toEqual('de');
});
test('returns default locale when invalid user locale specified', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
},
users: {
currentUserId: 'abcd',
profiles: {
abcd: {
locale: 'not_valid',
},
},
},
},
};
expect(getCurrentLocale(state)).toEqual(General.DEFAULT_LOCALE);
});
describe('locale from query parameter', () => {
// Helper function to mock window.location.search with locale query parameter
const setWindowLocaleQueryParameter = (locale) => {
window.location.search = `?locale=${locale}`;
};
// Helper function to reset window.location.search
const resetWindowLocationSearch = () => {
window.location.search = '';
};
afterEach(() => {
resetWindowLocationSearch();
});
test('returns locale from query parameter if provided and not logged in', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'fr',
},
},
users: {
currentUserId: '',
profiles: {},
},
},
};
setWindowLocaleQueryParameter('ko');
expect(getCurrentLocale(state)).toEqual('ko');
});
test('returns DefaultClientLocale if locale from query parameter is not valid', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'fr',
},
},
users: {
currentUserId: '',
profiles: {},
},
},
};
setWindowLocaleQueryParameter('invalid_locale');
expect(getCurrentLocale(state)).toEqual('fr');
});
test('returns user locale when logged in and locale is provided in query parameter', () => {
const state = {
entities: {
general: {
config: {
DefaultClientLocale: 'fr',
},
},
users: {
currentUserId: 'abcd',
profiles: {
abcd: {
locale: 'de',
},
},
},
},
};
setWindowLocaleQueryParameter('ko');
expect(getCurrentLocale(state)).toEqual('de');
});
});
});
describe('getTranslations', () => {
const state = {
views: {
i18n: {
translations: {
en: {
'test.hello_world': 'Hello, World!',
},
},
},
},
};
test('returns loaded translations', () => {
expect(getTranslations(state, 'en')).toBe(state.views.i18n.translations.en);
});
test('returns null for unloaded translations', () => {
expect(getTranslations(state, 'fr')).toEqual(undefined);
});
test('returns English translations for unsupported locale', () => {
// This test will have to be changed if we add support for Gaelic
expect(getTranslations(state, 'gd')).toBe(state.views.i18n.translations.en);
});
});
});

39
webapp/channels/src/selectors/i18n.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n';
import {General} from 'mattermost-redux/constants';
import * as I18n from 'i18n/i18n';
import {GlobalState} from 'types/store';
import {Translations} from 'types/store/i18n';
// This is a placeholder for if we ever implement browser-locale detection
export function getCurrentLocale(state: GlobalState): string {
// If locale is provided in query parameter and the user is not logged in, we try get locale from param
const localeFromParam: string | null = (new URLSearchParams(window.location?.search)).get('locale');
const defaultLocale: string | undefined =
localeFromParam && I18n.isLanguageAvailable(localeFromParam) ? localeFromParam : getConfig(state).DefaultClientLocale;
const currentLocale: string = getCurrentUserLocale(state, defaultLocale);
if (I18n.isLanguageAvailable(currentLocale)) {
return currentLocale;
}
return General.DEFAULT_LOCALE;
}
export function getTranslations(state: GlobalState, locale: string): Translations {
const localeInfo = I18n.getLanguageInfo(locale);
let translations;
if (localeInfo) {
translations = state.views.i18n.translations[locale];
} else {
// Default to English if an unsupported locale is specified
translations = state.views.i18n.translations.en;
}
return translations;
}

16
webapp/channels/src/selectors/insights.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {getIsMobileView} from 'selectors/views/browser';
export function showInsightsPulsatingDot(state: GlobalState): boolean {
if (getIsMobileView(state)) {
return false;
}
const insightsTutorialState = get(state, Preferences.CATEGORY_INSIGHTS, Preferences.NAME_INSIGHTS_TUTORIAL_STATE, false);
const modalAlreadyViewed = insightsTutorialState && JSON.parse(insightsTutorialState)[Preferences.INSIGHTS_VIEWED];
return !modalAlreadyViewed;
}

118
webapp/channels/src/selectors/lhs.test.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as PreferencesSelectors from 'mattermost-redux/selectors/entities/preferences';
import {GlobalState} from 'types/store';
import * as Lhs from './lhs';
jest.mock('selectors/drafts', () => ({
makeGetDraftsCount: jest.fn().mockImplementation(() => jest.fn()),
}));
jest.mock('mattermost-redux/selectors/entities/preferences', () => ({
insightsAreEnabled: jest.fn(),
isCollapsedThreadsEnabled: jest.fn(),
localDraftsAreEnabled: jest.fn(),
}));
beforeEach(() => {
jest.resetModules();
});
describe('Selectors.Lhs', () => {
let state: unknown;
beforeEach(() => {
state = {};
});
describe('should return the open state of the sidebar menu', () => {
[true, false].forEach((expected) => {
it(`when open is ${expected}`, () => {
state = {
views: {
lhs: {
isOpen: expected,
},
},
};
expect(Lhs.getIsLhsOpen(state as GlobalState)).toEqual(expected);
});
});
});
describe('getVisibleLhsStaticPages', () => {
beforeEach(() => {
state = {
views: {
lhs: {
isOpen: false,
currentStaticPageId: '',
},
},
};
});
it('handles nothing enabled', () => {
jest.spyOn(PreferencesSelectors, 'insightsAreEnabled').mockImplementationOnce(() => false);
jest.spyOn(PreferencesSelectors, 'isCollapsedThreadsEnabled').mockImplementationOnce(() => false);
jest.spyOn(PreferencesSelectors, 'localDraftsAreEnabled').mockImplementationOnce(() => false);
jest.spyOn(Lhs, 'getDraftsCount').mockImplementationOnce(() => 0);
const items = Lhs.getVisibleStaticPages(state as GlobalState);
expect(items).toEqual([]);
});
it('handles insights', () => {
jest.spyOn(PreferencesSelectors, 'insightsAreEnabled').mockImplementation(() => true);
jest.spyOn(PreferencesSelectors, 'isCollapsedThreadsEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'localDraftsAreEnabled').mockImplementation(() => false);
jest.spyOn(Lhs, 'getDraftsCount').mockImplementationOnce(() => 0);
const items = Lhs.getVisibleStaticPages(state as GlobalState);
expect(items).toEqual([
{
id: 'activity-and-insights',
isVisible: true,
},
]);
});
it('handles threads - default off', () => {
jest.spyOn(PreferencesSelectors, 'insightsAreEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'isCollapsedThreadsEnabled').mockImplementation(() => true);
jest.spyOn(PreferencesSelectors, 'localDraftsAreEnabled').mockImplementation(() => false);
jest.spyOn(Lhs, 'getDraftsCount').mockImplementationOnce(() => 0);
const items = Lhs.getVisibleStaticPages(state as GlobalState);
expect(items).toEqual([
{
id: 'threads',
isVisible: true,
},
]);
});
it('should not return drafts when empty', () => {
jest.spyOn(PreferencesSelectors, 'insightsAreEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'isCollapsedThreadsEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'localDraftsAreEnabled').mockImplementation(() => true);
jest.spyOn(Lhs, 'getDraftsCount').mockImplementationOnce(() => 0);
const items = Lhs.getVisibleStaticPages(state as GlobalState);
expect(items).toEqual([]);
});
it('should return drafts when there are available', () => {
jest.spyOn(PreferencesSelectors, 'insightsAreEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'isCollapsedThreadsEnabled').mockImplementation(() => false);
jest.spyOn(PreferencesSelectors, 'localDraftsAreEnabled').mockImplementation(() => true);
jest.spyOn(Lhs, 'getDraftsCount').mockImplementationOnce(() => 1);
const items = Lhs.getVisibleStaticPages(state as GlobalState);
expect(items).toEqual([
{
id: 'drafts',
isVisible: true,
},
]);
});
});
});

56
webapp/channels/src/selectors/lhs.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {GlobalState} from 'types/store';
import {StaticPage} from 'types/store/lhs';
import {makeGetDraftsCount} from 'selectors/drafts';
import {
insightsAreEnabled,
isCollapsedThreadsEnabled,
localDraftsAreEnabled,
} from 'mattermost-redux/selectors/entities/preferences';
export function getIsLhsOpen(state: GlobalState): boolean {
return state.views.lhs.isOpen;
}
export function getCurrentStaticPageId(state: GlobalState): string {
return state.views.lhs.currentStaticPageId;
}
export const getDraftsCount = makeGetDraftsCount();
export const getVisibleStaticPages = createSelector(
'getVisibleSidebarStaticPages',
insightsAreEnabled,
isCollapsedThreadsEnabled,
localDraftsAreEnabled,
getDraftsCount,
(insightsEnabled, collapsedThreadsEnabled, localDraftsEnabled, draftsCount) => {
const staticPages: StaticPage[] = [];
if (insightsEnabled) {
staticPages.push({
id: 'activity-and-insights',
isVisible: true,
});
}
if (collapsedThreadsEnabled) {
staticPages.push({
id: 'threads',
isVisible: true,
});
}
if (localDraftsEnabled) {
staticPages.push({
id: 'drafts',
isVisible: draftsCount > 0,
});
}
return staticPages.filter((item) => item.isVisible);
},
);

Просмотреть файл

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import localStorageStore from 'stores/local_storage_store';
import type {GlobalState} from '@mattermost/types/store';
// getLastViewedChannelName combines data from the Redux store and localStorage to return the
// previously selected channel name, returning the default channel if none exists.
//
// See LocalStorageStore for context.
export const getLastViewedChannelName = (state: GlobalState) => {
const userId = getCurrentUserId(state);
const teamId = getCurrentTeamId(state);
return localStorageStore.getPreviousChannelName(userId, teamId);
};
export const getPenultimateViewedChannelName = (state: GlobalState) => {
const userId = getCurrentUserId(state);
const teamId = getCurrentTeamId(state);
return localStorageStore.getPenultimateChannelName(userId, teamId);
};
// getLastViewedChannelNameByTeamName combines data from the Redux store and localStorage to return
// the url to the previously selected channel, returning the path to the default channel if none
// exists.
//
// See LocalStorageStore for context.
export const getLastViewedChannelNameByTeamName = (state: GlobalState, teamName: string) => {
const userId = getCurrentUserId(state);
const team = getTeamByName(state, teamName);
const teamId = team && team.id;
return localStorageStore.getPreviousChannelName(userId, teamId || '');
};
export const getLastViewedTypeByTeamName = (state: GlobalState, teamName: string) => {
const userId = getCurrentUserId(state);
const team = getTeamByName(state, teamName);
const teamId = team && team.id;
return localStorageStore.getPreviousViewedType(userId, teamId || '');
};
export const getPreviousTeamId = (state: GlobalState) => {
const userId = getCurrentUserId(state);
return localStorageStore.getPreviousTeamId(userId);
};
export const getPreviousTeamLastViewedType = (state: GlobalState) => {
const previousTeamID = getPreviousTeamId(state);
const userId = getCurrentUserId(state);
return localStorageStore.getPreviousViewedType(userId, previousTeamID || '', state);
};

Просмотреть файл

@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import TestHelper from 'packages/mattermost-redux/test/test_helper';
import {OnboardingTaskCategory, OnboardingTaskList} from 'components/onboarding_tasks';
import {RecommendedNextStepsLegacy, Preferences} from 'utils/constants';
import {getShowTaskListBool} from 'selectors/onboarding';
import {GlobalState} from 'types/store';
describe('selectors/onboarding', () => {
describe('getShowTaskListBool', () => {
test('first time user logs in aka firstTimeOnboarding', () => {
const user = TestHelper.fakeUserWithId();
const profiles = {
[user.id]: user,
};
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId: user.id,
profiles,
},
},
} as unknown as GlobalState;
const [showTaskList, firstTimeOnboarding] = getShowTaskListBool(state);
expect(showTaskList).toBeTruthy();
expect(firstTimeOnboarding).toBeTruthy();
});
test('previous user skipped legacy next steps so not show the tasklist', () => {
const prefSkip = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.SKIP, value: 'true'};
const prefHide = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.HIDE, value: 'false'};
const user = TestHelper.fakeUserWithId();
const profiles = {
[user.id]: user,
};
const state = {
entities: {
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.HIDE)]: prefHide,
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.SKIP)]: prefSkip,
},
},
users: {
currentUserId: user.id,
profiles,
},
},
} as unknown as GlobalState;
const [showTaskList, firstTimeOnboarding] = getShowTaskListBool(state);
expect(showTaskList).toBeFalsy();
expect(firstTimeOnboarding).toBeFalsy();
});
test('previous user hided legacy next steps so not show the tasklist', () => {
const prefSkip = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.SKIP, value: 'false'};
const prefHide = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.HIDE, value: 'true'};
const user = TestHelper.fakeUserWithId();
const profiles = {
[user.id]: user,
};
const state = {
entities: {
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.HIDE)]: prefHide,
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.SKIP)]: prefSkip,
},
},
users: {
currentUserId: user.id,
profiles,
},
},
} as unknown as GlobalState;
const [showTaskList, firstTimeOnboarding] = getShowTaskListBool(state);
expect(showTaskList).toBeFalsy();
expect(firstTimeOnboarding).toBeFalsy();
});
test('user has preferences set to true for showing the tasklist', () => {
const prefShow = {category: OnboardingTaskCategory, name: OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW, value: 'true'};
const prefOpen = {category: OnboardingTaskCategory, name: OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN, value: 'true'};
const user = TestHelper.fakeUserWithId();
const profiles = {
[user.id]: user,
};
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {
[getPreferenceKey(OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW)]: prefShow,
[getPreferenceKey(OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN)]: prefOpen,
},
},
users: {
currentUserId: user.id,
profiles,
},
},
} as unknown as GlobalState;
const [showTaskList, firstTimeOnboarding] = getShowTaskListBool(state);
expect(showTaskList).toBeTruthy();
expect(firstTimeOnboarding).toBeFalsy();
});
test('user has preferences set to false for showing the tasklist', () => {
const prefSkip = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.SKIP, value: 'true'};
const prefHide = {category: Preferences.RECOMMENDED_NEXT_STEPS, name: RecommendedNextStepsLegacy.HIDE, value: 'false'};
const prefShow = {category: OnboardingTaskCategory, name: OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW, value: 'false'};
const prefOpen = {category: OnboardingTaskCategory, name: OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN, value: 'false'};
const user = TestHelper.fakeUserWithId();
const profiles = {
[user.id]: user,
};
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {
[getPreferenceKey(OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW)]: prefShow,
[getPreferenceKey(OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN)]: prefOpen,
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.SKIP)]: prefSkip,
[getPreferenceKey(Preferences.RECOMMENDED_NEXT_STEPS, RecommendedNextStepsLegacy.HIDE)]: prefHide,
},
},
users: {
currentUserId: user.id,
profiles,
},
},
} as unknown as GlobalState;
const [showTaskList, firstTimeOnboarding] = getShowTaskListBool(state);
expect(showTaskList).toBeFalsy();
expect(firstTimeOnboarding).toBeFalsy();
});
});
});

174
webapp/channels/src/selectors/onboarding.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isMobile} from 'utils/utils';
import {createSelector} from 'reselect';
import {makeGetCategory, getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {OnboardingTaskCategory, OnboardingTaskList} from 'components/onboarding_tasks';
import {GlobalState} from 'types/store';
import {RecommendedNextStepsLegacy, Preferences} from 'utils/constants';
const getCategory = makeGetCategory();
export const getABTestPreferences = (() => {
return (state: GlobalState) => getCategory(state, Preferences.AB_TEST_PREFERENCE_VALUE);
})();
const getFirstChannelNamePref = createSelector(
'getFirstChannelNamePref',
getABTestPreferences,
(preferences) => {
return preferences.find((pref) => pref.name === RecommendedNextStepsLegacy.CREATE_FIRST_CHANNEL);
},
);
export function getFirstChannelNameViews(state: GlobalState) {
return state.views.channelSidebar.firstChannelName;
}
export function getFirstChannelName(state: GlobalState) {
return getFirstChannelNameViews(state) || getFirstChannelNamePref(state)?.value || '';
}
export function getShowLaunchingWorkspace(state: GlobalState) {
return state.views.modals.showLaunchingWorkspace;
}
// Legacy nextSteps section used to determine when to hide the onboarding to end users who have already completed/unfinished it
export type StepType = {
id: string;
// An array of all roles a user must have in order to see the step e.g. admins are both system_admin and system_user
// so you would require ['system_admin','system_user'] to match.
// to show step for all roles, leave the roles array blank.
// for a step that must be shown only to the first admin, add the first_admin role to that step
roles: string[];
};
export const Steps: StepType[] = [
{
id: RecommendedNextStepsLegacy.COMPLETE_PROFILE,
roles: [],
},
{
id: RecommendedNextStepsLegacy.TEAM_SETUP,
roles: ['first_admin'],
},
{
id: RecommendedNextStepsLegacy.NOTIFICATION_SETUP,
roles: ['system_user'],
},
{
id: RecommendedNextStepsLegacy.PREFERENCES_SETUP,
roles: ['system_user'],
},
{
id: RecommendedNextStepsLegacy.INVITE_MEMBERS,
roles: ['system_admin', 'system_user'],
},
{
id: RecommendedNextStepsLegacy.DOWNLOAD_APPS,
roles: [],
},
];
// Filter the steps shown by checking if our user has any of the required roles for that step
export function isStepForUser(step: StepType, roles: string): boolean {
const userRoles = roles?.split(' ');
return (
userRoles?.some((role) => step.roles.includes(role)) ||
step.roles.length === 0
);
}
const getSteps = createSelector(
'getSteps',
(state: GlobalState) => getCurrentUser(state),
(state: GlobalState) => isFirstAdmin(state),
(currentUser, firstAdmin) => {
const roles = firstAdmin ? `first_admin ${currentUser?.roles}` : currentUser?.roles;
return Steps.filter((step) => isStepForUser(step, roles));
},
);
// Loop through all Steps. For each step, check that
export const legacyNextStepsNotFinished = createSelector(
'legacyNextStepsNotFinished',
(state: GlobalState) => getCategory(state, Preferences.RECOMMENDED_NEXT_STEPS),
(state: GlobalState) => getCurrentUser(state),
(state: GlobalState) => isFirstAdmin(state),
(state: GlobalState) => getSteps(state),
(stepPreferences, currentUser, firstAdmin, mySteps) => {
const roles = firstAdmin ? `first_admin ${currentUser?.roles}` : currentUser?.roles;
const checkPref = (step: StepType) => stepPreferences.some((pref) => (pref.name === step.id && pref.value === 'true') || !isStepForUser(step, roles));
return !mySteps.every(checkPref);
},
);
// Loop through all Steps. For each step, check that
export const hasLegacyNextStepsPreferences = createSelector(
'hasLegacyNextStepsPreferences',
(state: GlobalState) => getCategory(state, Preferences.RECOMMENDED_NEXT_STEPS),
(state: GlobalState) => getSteps(state),
(stepPreferences, mySteps) => {
const checkPref = (step: StepType) => stepPreferences.some((pref) => (pref.name === step.id));
return mySteps.some(checkPref);
},
);
export const getShowTaskListBool = createSelector(
'getShowTaskListBool',
(state: GlobalState) => state,
(state: GlobalState) => getCategory(state, OnboardingTaskCategory),
(state: GlobalState) => getCategory(state, Preferences.RECOMMENDED_NEXT_STEPS),
(state, onboardingPreferences, legacyStepsPreferences) => {
const isMobileView = isMobile();
// conditions to validate scenario where users (initially first_admins) had already set any of the onboarding task list preferences values.
// We check wether the preference value exists meaning the onboarding tasks list already started no matter what the state of the process is
const hasUserStartedOnboardingTaskListProcess = onboardingPreferences?.some((pref) =>
pref.name === OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW || pref.name === OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN);
const taskListStatus = getBool(state, OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_TASK_LIST_SHOW);
if (hasUserStartedOnboardingTaskListProcess) {
return [(taskListStatus && !isMobileView), false];
}
// validate is a new user that must do the first time onboarding by checking that:
// 1. has not preferences related to the new onboarding task list.
// 2. has no legacy skip preference
// 3. has no legacy steps preferences
// 4. has completed legacy next steps (hide value for recommended_next_steps category set to false)
// This condition verifies existing users hasn't finished nor skipped legacy next steps or there are still steps not completed
const hasSkipLegacyStepsPreference = legacyStepsPreferences.some((pref) => (pref.name === RecommendedNextStepsLegacy.SKIP));
const hideLegacyStepsSetToFalse = legacyStepsPreferences.some((pref) => (pref.name === RecommendedNextStepsLegacy.HIDE && pref.value === 'false'));
const hasAnyOfTheLegacyStepsPreferences = hasLegacyNextStepsPreferences(state);
const areFirstUserPrefs = !hasSkipLegacyStepsPreference && hideLegacyStepsSetToFalse && !hasAnyOfTheLegacyStepsPreferences;
const completelyNewUserForOnboarding = !hasUserStartedOnboardingTaskListProcess && areFirstUserPrefs;
if (completelyNewUserForOnboarding) {
return [(!isMobileView), true];
}
// If none of the previous conditions matched, then it is an existing user with legacy prefs.
// To determine if we show the new onboarding task list we need to validate:
// has not skipped nor completed the legacy steps
const hasSkippedLegacySteps = legacyStepsPreferences.some((pref) => (pref.name === RecommendedNextStepsLegacy.SKIP && pref.value === 'true'));
const hasCompletedLegacySteps = legacyStepsPreferences.some((pref) => (pref.name === RecommendedNextStepsLegacy.HIDE && pref.value === 'true'));
const existingUserHasntFinishedNorSkippedLegacyNextSteps = !hasSkippedLegacySteps && !hasCompletedLegacySteps;
const showTaskList = existingUserHasntFinishedNorSkippedLegacyNextSteps && !isMobileView;
const firstTimeOnboarding = existingUserHasntFinishedNorSkippedLegacyNextSteps;
return [showTaskList, firstTimeOnboarding];
},
);

183
webapp/channels/src/selectors/plugins.test.js Обычный файл
Просмотреть файл

@@ -0,0 +1,183 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getChannelHeaderMenuPluginComponents} from 'selectors/plugins';
describe('selectors/plugins', () => {
describe('getChannelHeaderMenuPluginComponents', () => {
test('no channel header components found', () => {
const expectedComponents = [];
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
plugins: {
components: {
ChannelHeader: expectedComponents,
},
},
};
const components = getChannelHeaderMenuPluginComponents(state);
expect(components).toEqual(expectedComponents);
});
test('one channel header component found as shouldRender returns true', () => {
const expectedComponents = [
{
shouldRender: () => true,
},
];
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
plugins: {
components: {
ChannelHeader: expectedComponents,
},
},
};
const components = getChannelHeaderMenuPluginComponents(state);
expect(components).toEqual(expectedComponents);
});
test('one channel header component found as shouldRender is not defined', () => {
const expectedComponents = [
{
id: 'testId',
},
];
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
plugins: {
components: {
ChannelHeader: expectedComponents,
},
},
};
const components = getChannelHeaderMenuPluginComponents(state);
expect(components).toEqual(expectedComponents);
});
test('no channel header components found as shouldRender returns false', () => {
const expectedComponents = [];
const state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
plugins: {
components: {
ChannelHeader: [{
shouldRender: () => false,
}],
},
},
};
const components = getChannelHeaderMenuPluginComponents(state);
expect(components).toEqual(expectedComponents);
});
test('memoization', () => {
let shouldRenderResult = false;
let state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
plugins: {
components: {
ChannelHeader: [{
shouldRender: () => shouldRenderResult,
}],
},
},
};
const firstResult = getChannelHeaderMenuPluginComponents(state);
expect(firstResult).toEqual([]);
// No changes to state
const secondResult = getChannelHeaderMenuPluginComponents(state);
expect(secondResult).toBe(firstResult);
// Something unrelated changed in state
state = {...state};
const thirdResult = getChannelHeaderMenuPluginComponents(state);
expect(thirdResult).toBe(firstResult);
// shouldRender changed because something else in state changed
state = {...state};
shouldRenderResult = true;
const fourthResult = getChannelHeaderMenuPluginComponents(state);
expect(fourthResult).not.toBe(firstResult);
expect(fourthResult).toEqual([
state.plugins.components.ChannelHeader[0],
]);
// A new plugin was added
state = {
...state,
plugins: {
...state.plugins,
components: {
...state.plugins.components,
ChannelHeader: [
...state.plugins.components.ChannelHeader,
{
id: 'anotherPlugin',
},
],
},
},
};
const fifthResult = getChannelHeaderMenuPluginComponents(state);
expect(fifthResult).not.toBe(fourthResult);
expect(fifthResult).toEqual([
state.plugins.components.ChannelHeader[0],
state.plugins.components.ChannelHeader[1],
]);
});
});
});

116
webapp/channels/src/selectors/plugins.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {appBarEnabled, getAppBarAppBindings} from 'mattermost-redux/selectors/entities/apps';
import {createShallowSelector} from 'mattermost-redux/utils/helpers';
import {autoShowLinkedBoardFFEnabled, get, getBool} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {OnboardingTaskCategory, OnboardingTaskList} from 'components/onboarding_tasks';
import {GlobalState} from 'types/store';
import {AppBinding} from '@mattermost/types/apps';
import {FileDropdownPluginComponent, PluginComponent} from '../types/store/plugins';
export const getFilesDropdownPluginMenuItems = createSelector(
'getFilesDropdownPluginMenuItems',
(state: GlobalState) => state.plugins.components.FilesDropdown,
(components) => {
return (components || []) as unknown as FileDropdownPluginComponent[];
},
);
export const getUserGuideDropdownPluginMenuItems = createSelector(
'getUserGuideDropdownPluginMenuItems',
(state: GlobalState) => state.plugins.components.UserGuideDropdown,
(components) => {
return components;
},
);
export const getChannelHeaderPluginComponents = createSelector(
'getChannelHeaderPluginComponents',
(state: GlobalState) => appBarEnabled(state),
(state: GlobalState) => state.plugins.components.ChannelHeaderButton,
(state: GlobalState) => state.plugins.components.AppBar,
(enabled, channelHeaderComponents = [], appBarComponents = []) => {
if (!enabled || !appBarComponents.length) {
return channelHeaderComponents as unknown as PluginComponent[];
}
// Remove channel header icons for plugins that have also registered an app bar component
const appBarPluginIds = appBarComponents.map((appBarComponent) => appBarComponent.pluginId);
return channelHeaderComponents.filter((channelHeaderComponent) => !appBarPluginIds.includes(channelHeaderComponent.pluginId));
},
);
const getChannelHeaderMenuPluginComponentsShouldRender = createSelector(
'getChannelHeaderMenuPluginComponentsShouldRender',
(state: GlobalState) => state,
(state: GlobalState) => state.plugins.components.ChannelHeader,
(state, channelHeaderMenuComponents = []) => {
return channelHeaderMenuComponents.map((component) => {
if (typeof component.shouldRender === 'function') {
return component.shouldRender(state);
}
return true;
});
},
);
export const getChannelHeaderMenuPluginComponents = createShallowSelector(
'getChannelHeaderMenuPluginComponents',
getChannelHeaderMenuPluginComponentsShouldRender,
(state: GlobalState) => state.plugins.components.ChannelHeader,
(componentShouldRender = [], channelHeaderMenuComponents = []) => {
return channelHeaderMenuComponents.filter((component, idx) => componentShouldRender[idx]);
},
);
export const getChannelIntroPluginButtons = createSelector(
'getChannelIntroPluginButtons',
(state: GlobalState) => state.plugins.components.ChannelIntroButton,
(components = []) => {
return components;
},
);
export const getAppBarPluginComponents = createSelector(
'getAppBarPluginComponents',
(state: GlobalState) => state.plugins.components.AppBar,
(components = []) => {
return components;
},
);
export const shouldShowAppBar = createSelector(
'shouldShowAppBar',
appBarEnabled,
getAppBarAppBindings,
getAppBarPluginComponents,
getChannelHeaderPluginComponents,
(enabled: boolean, bindings: AppBinding[], appBarComponents: PluginComponent[], channelHeaderComponents) => {
return enabled && Boolean(bindings.length || appBarComponents.length || channelHeaderComponents.length);
},
);
export function showNewChannelWithBoardPulsatingDot(state: GlobalState): boolean {
const pulsatingDotState = get(state, Preferences.APP_BAR, Preferences.NEW_CHANNEL_WITH_BOARD_TOUR_SHOWED, '');
const showPulsatingDot = pulsatingDotState !== '' && JSON.parse(pulsatingDotState)[Preferences.NEW_CHANNEL_WITH_BOARD_TOUR_SHOWED] === false;
return showPulsatingDot;
}
export const shouldShowAutoLinkedBoard = createSelector(
'shouldShowAutoLinkedBoard',
(state: GlobalState) => getBool(state, OnboardingTaskCategory, OnboardingTaskList.ONBOARDING_LINKED_BOARD_AUTO_SHOWN),
(state: GlobalState) => autoShowLinkedBoardFFEnabled(state),
(showAutoLinkedBoardPref: boolean, showAutoLinkedBoardFFEnabled: boolean) => {
return !showAutoLinkedBoardPref && showAutoLinkedBoardFFEnabled;
},
);

54
webapp/channels/src/selectors/posts.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {Post} from '@mattermost/types/posts';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getGlobalItem} from 'selectors/storage';
import {arePreviewsCollapsed} from 'selectors/preferences';
import {StoragePrefixes} from 'utils/constants';
import type {GlobalState} from 'types/store';
export function getIsPostBeingEdited(state: GlobalState, postId: string) {
return state.views.posts.editingPost.postId === postId && state.views.posts.editingPost.show;
}
export function getIsPostBeingEditedInRHS(state: GlobalState, postId: string) {
const editingPost = getEditingPost(state);
return editingPost.isRHS && editingPost.postId === postId && state.views.posts.editingPost.show;
}
export function getPostEditHistory(state: GlobalState): Post[] {
return state.entities.posts.postEditHistory;
}
export const getEditingPost = createSelector(
'getEditingPost',
(state: GlobalState) => state.views.posts.editingPost,
(state: GlobalState) => getPost(state, state.views.posts.editingPost.postId),
(editingPost, post) => {
return {
...editingPost,
post,
};
},
);
export function isEmbedVisible(state: GlobalState, postId: string) {
const currentUserId = getCurrentUserId(state);
const previewCollapsed = arePreviewsCollapsed(state);
return getGlobalItem(state, StoragePrefixes.EMBED_VISIBLE + currentUserId + '_' + postId, !previewCollapsed);
}
export function isInlineImageVisible(state: GlobalState, postId: string, imageKey: string) {
const currentUserId = getCurrentUserId(state);
const imageCollapsed = arePreviewsCollapsed(state);
return getGlobalItem(state, StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !imageCollapsed);
}

Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getBool as getBoolPreference} from 'mattermost-redux/selectors/entities/preferences';
import {GlobalState} from 'types/store';
import {Preferences} from 'utils/constants';
export const arePreviewsCollapsed = (state: GlobalState) => {
return getBoolPreference(
state,
Preferences.CATEGORY_DISPLAY_SETTINGS,
Preferences.COLLAPSE_DISPLAY,
Preferences.COLLAPSE_DISPLAY_DEFAULT !== 'false',
);
};

19
webapp/channels/src/selectors/products.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ProductIdentifier} from '@mattermost/types/products';
import {GlobalState} from 'types/store';
import type {ProductComponent} from '../types/store/plugins';
import {getCurrentProduct} from 'utils/products';
export function selectCurrentProduct(state: GlobalState, pathname: string): ProductComponent | null {
return getCurrentProduct(selectProducts(state), pathname);
}
export function selectCurrentProductId(state: GlobalState, pathname: string): ProductIdentifier {
return selectCurrentProduct(state, pathname)?.id ?? null;
}
export const selectProducts = (state: GlobalState) => state.plugins.components.Product;

67
webapp/channels/src/selectors/rhs.test.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as Selectors from 'selectors/rhs';
import {GlobalState} from 'types/store';
describe('Selectors.Rhs', () => {
describe('should return the last time a post was selected', () => {
[0, 1000000, 2000000].forEach((expected) => {
it(`when open is ${expected}`, () => {
const state = {views: {rhs: {
selectedPostFocussedAt: expected,
}}} as GlobalState;
expect(Selectors.getSelectedPostFocussedAt(state)).toEqual(expected);
});
});
});
describe('should return the open state of the sidebar', () => {
[true, false].forEach((expected) => {
it(`when open is ${expected}`, () => {
const state = {views: {rhs: {
isSidebarOpen: expected,
}}} as GlobalState;
expect(Selectors.getIsRhsOpen(state)).toEqual(expected);
});
});
});
describe('should return the open state of the sidebar menu', () => {
[true, false].forEach((expected) => {
it(`when open is ${expected}`, () => {
const state = {views: {rhs: {
isMenuOpen: expected,
}}} as GlobalState;
expect(Selectors.getIsRhsMenuOpen(state)).toEqual(expected);
});
});
});
describe('should return the highlighted reply\'s id', () => {
test.each(['42', ''])('when id is %s', (expected) => {
const state = {views: {rhs: {
highlightedPostId: expected,
}}} as GlobalState;
expect(Selectors.getHighlightedPostId(state)).toEqual(expected);
});
});
describe('should return the previousRhsState', () => {
test.each([
[[], null],
[['channel-info'], 'channel-info'],
[['channel-info', 'pinned'], 'pinned'],
])('%p gives %p', (previousArray, previous) => {
const state = {
views: {rhs: {
previousRhsStates: previousArray,
}}} as GlobalState;
expect(Selectors.getPreviousRhsState(state)).toEqual(previous);
});
});
});

187
webapp/channels/src/selectors/rhs.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {Post, PostType} from '@mattermost/types/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {Channel} from '@mattermost/types/channels';
import {makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage';
import {PostTypes, StoragePrefixes} from 'utils/constants';
import {localizeMessage} from 'utils/utils';
import {GlobalState} from 'types/store';
import {RhsState, FakePost, SearchType} from 'types/store/rhs';
import {PostDraft} from 'types/store/draft';
export function getSelectedPostId(state: GlobalState): Post['id'] {
return state.views.rhs.selectedPostId;
}
export function getSelectedPostFocussedAt(state: GlobalState): number {
return state.views.rhs.selectedPostFocussedAt;
}
export function getSelectedPostCardId(state: GlobalState): Post['id'] {
return state.views.rhs.selectedPostCardId;
}
export function getHighlightedPostId(state: GlobalState): Post['id'] {
return state.views.rhs.highlightedPostId;
}
export function getFilesSearchExtFilter(state: GlobalState): string[] {
return state.views.rhs.filesSearchExtFilter;
}
export function getSelectedPostCard(state: GlobalState) {
return state.entities.posts.posts[getSelectedPostCardId(state)];
}
export function getSelectedChannelId(state: GlobalState) {
return state.views.rhs.selectedChannelId;
}
export const getSelectedChannel = (() => {
const getChannel = makeGetChannel();
return (state: GlobalState) => {
const channelId = getSelectedChannelId(state);
return getChannel(state, {id: channelId});
};
})();
export function getPluggableId(state: GlobalState) {
return state.views.rhs.pluggableId;
}
export function getActiveRhsComponent(state: GlobalState) {
const pluggableId = getPluggableId(state);
const components = state.plugins.components.RightHandSidebarComponent;
return components.find((c) => c.id === pluggableId);
}
function getRealSelectedPost(state: GlobalState) {
return state.entities.posts.posts[getSelectedPostId(state)];
}
export const getSelectedPost = createSelector(
'getSelectedPost',
getSelectedPostId,
getRealSelectedPost,
getSelectedChannelId,
getCurrentUserId,
(selectedPostId: Post['id'], selectedPost: Post, selectedPostChannelId: Channel['id'], currentUserId): Post|FakePost => {
if (selectedPost) {
return selectedPost;
}
// If there is no root post found, assume it has been deleted by data retention policy, and create a fake one.
return {
id: selectedPostId,
exists: false,
type: PostTypes.FAKE_PARENT_DELETED as PostType,
message: localizeMessage('rhs_thread.rootPostDeletedMessage.body', 'Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.'),
channel_id: selectedPostChannelId,
user_id: currentUserId,
};
},
);
export function getRhsState(state: GlobalState): RhsState {
return state.views.rhs.rhsState;
}
export function getPreviousRhsState(state: GlobalState): RhsState {
if (state.views.rhs.previousRhsStates === null || state.views.rhs.previousRhsStates.length === 0) {
return null;
}
return state.views.rhs.previousRhsStates[state.views.rhs.previousRhsStates.length - 1];
}
export function getSearchTerms(state: GlobalState): string {
return state.views.rhs.searchTerms;
}
export function getSearchType(state: GlobalState): SearchType {
return state.views.rhs.searchType;
}
export function getSearchResultsTerms(state: GlobalState): string {
return state.views.rhs.searchResultsTerms;
}
export function getIsSearchingTerm(state: GlobalState): boolean {
return state.entities.search.isSearchingTerm;
}
export function getIsSearchingFlaggedPost(state: GlobalState): boolean {
return state.views.rhs.isSearchingFlaggedPost;
}
export function getIsSearchingPinnedPost(state: GlobalState): boolean {
return state.views.rhs.isSearchingPinnedPost;
}
export function getIsSearchGettingMore(state: GlobalState): boolean {
return state.entities.search.isSearchGettingMore;
}
export function makeGetChannelDraft() {
const defaultDraft = Object.freeze({message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''});
const getDraft = makeGetGlobalItemWithDefault(defaultDraft);
return (state: GlobalState, channelId: string): PostDraft => {
const draft = getDraft(state, StoragePrefixes.DRAFT + channelId);
if (
typeof draft.message !== 'undefined' &&
typeof draft.uploadsInProgress !== 'undefined' &&
typeof draft.fileInfos !== 'undefined'
) {
return draft;
}
return defaultDraft;
};
}
export function getPostDraft(state: GlobalState, prefixId: string, suffixId: string): PostDraft {
const defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''};
if (prefixId === StoragePrefixes.COMMENT_DRAFT) {
defaultDraft.rootId = suffixId;
}
const draft = makeGetGlobalItem(prefixId + suffixId, defaultDraft)(state);
if (
typeof draft.message !== 'undefined' &&
typeof draft.uploadsInProgress !== 'undefined' &&
typeof draft.fileInfos !== 'undefined'
) {
return draft;
}
return defaultDraft;
}
export function getIsRhsSuppressed(state: GlobalState): boolean {
return state.views.rhsSuppressed;
}
export function getIsRhsOpen(state: GlobalState): boolean {
return state.views.rhs.isSidebarOpen && !state.views.rhsSuppressed;
}
export function getIsRhsMenuOpen(state: GlobalState): boolean {
return state.views.rhs.isMenuOpen;
}
export function getIsRhsExpanded(state: GlobalState): boolean {
return state.views.rhs.isSidebarExpanded;
}
export function getIsEditingMembers(state: GlobalState): boolean {
return state.views.rhs.editChannelMembers === true;
}

Просмотреть файл

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPrefix} from 'utils/storage_utils';
import * as Selectors from 'selectors/storage';
import {GlobalState} from 'types/store';
describe('Selectors.Storage', () => {
const testState = {
entities: {
users: {
currentUserId: 'user_id',
profiles: {
user_id: {
id: 'user_id',
},
},
},
},
storage: {
storage: {
'global-item': {value: 'global-item-value', timestamp: new Date()},
user_id_item: {value: 'item-value', timestamp: new Date()},
},
},
} as unknown as GlobalState;
it('getPrefix', () => {
expect(getPrefix({} as GlobalState)).toEqual('unknown_');
expect(getPrefix({entities: {}} as GlobalState)).toEqual('unknown_');
expect(getPrefix({entities: {users: {currentUserId: 'not-exists'}}} as GlobalState)).toEqual('unknown_');
expect(getPrefix({entities: {users: {currentUserId: 'not-exists', profiles: {}}}} as GlobalState)).toEqual('unknown_');
expect(getPrefix({entities: {users: {currentUserId: 'exists', profiles: {exists: {id: 'user_id'}}}}} as unknown as GlobalState)).toEqual('user_id_');
});
it('makeGetGlobalItem', () => {
expect(Selectors.makeGetGlobalItem('not-existing-global-item', undefined)(testState)).toEqual(undefined);
expect(Selectors.makeGetGlobalItem('not-existing-global-item', 'default')(testState)).toEqual('default');
expect(Selectors.makeGetGlobalItem('global-item', undefined)(testState)).toEqual('global-item-value');
});
it('makeGetItem', () => {
expect(Selectors.makeGetItem('not-existing-item', undefined)(testState)).toEqual(undefined);
expect(Selectors.makeGetItem('not-existing-item', 'default')(testState)).toEqual('default');
expect(Selectors.makeGetItem('item', undefined)(testState)).toEqual('item-value');
});
});

38
webapp/channels/src/selectors/storage.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPrefix} from 'utils/storage_utils';
import type {GlobalState} from 'types/store';
export const getGlobalItem = <T = any>(state: GlobalState, name: string, defaultValue: T) => {
const storage = state && state.storage && state.storage.storage;
return getItemFromStorage(storage, name, defaultValue);
};
export const getItem = <T = any>(state: GlobalState, name: string, defaultValue: T) => {
return getGlobalItem(state, getPrefix(state) + name, defaultValue);
};
export const makeGetItem = <T = any>(name: string, defaultValue: T) => {
return (state: GlobalState) => {
return getItem(state, name, defaultValue);
};
};
export const makeGetGlobalItem = <T = any>(name: string, defaultValue: T) => {
return (state: GlobalState) => {
return getGlobalItem(state, name, defaultValue);
};
};
export const getItemFromStorage = <T = any>(storage: Record<string, any>, name: string, defaultValue: T) => {
return storage[name]?.value ?? defaultValue;
};
export const makeGetGlobalItemWithDefault = <T = any>(defaultValue: T) => {
return (state: GlobalState, name: string) => {
return getGlobalItem(state, name, defaultValue);
};
};

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
export function isAddChannelDropdownOpen(state: GlobalState) {
return state.views.addChannelDropdown.isOpen;
}

Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function getNavigationBlocked(state: GlobalState) {
return state.views.admin.navigationBlock.blocked;
}
export function showNavigationPrompt(state: GlobalState) {
return state.views.admin.navigationBlock.showNavigationPrompt;
}
export function getOnNavigationConfirmed(state: GlobalState) {
return state.views.admin.navigationBlock.onNavigationConfirmed;
}
export function getNeedsLoggedInLimitReachedCheck(state: GlobalState): boolean {
return state.views.admin.needsLoggedInLimitReachedCheck;
}

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function getAnnouncementBarCount(state: GlobalState) {
return state.views.announcementBar.announcementBarState.announcementBarCount;
}

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {WindowSizes} from 'utils/constants';
import {GlobalState} from 'types/store';
export function getIsDesktopView(state: GlobalState): boolean {
const windowSize = state.views.browser.windowSize;
return windowSize === WindowSizes.DESKTOP_VIEW;
}
export function getIsSmallDesktopView(state: GlobalState): boolean {
const windowSize = state.views.browser.windowSize;
return windowSize === WindowSizes.SMALL_DESKTOP_VIEW;
}
export function getIsTabletView(state: GlobalState): boolean {
const windowSize = state.views.browser.windowSize;
return windowSize === WindowSizes.TABLET_VIEW;
}
export function getIsMobileView(state: GlobalState): boolean {
const windowSize = state.views.browser.windowSize;
return windowSize === WindowSizes.MOBILE_VIEW;
}

Просмотреть файл

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
export const getLastPostsApiTimeForChannel = (state: GlobalState, channelId: string) => state.views.channel.lastGetPosts[channelId];
export const getToastStatus = (state: GlobalState) => state.views.channel.toastStatus;

Просмотреть файл

@@ -0,0 +1,747 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Preferences} from 'mattermost-redux/constants';
import mergeObjects from 'mattermost-redux/test/merge_objects';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {TestHelper} from 'utils/test_helper';
import * as Selectors from './channel_sidebar';
describe('isUnreadFilterEnabled', () => {
const preferenceKey = getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION);
const baseState = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {
[preferenceKey]: {value: 'false'},
},
},
},
views: {
channelSidebar: {
unreadFilterEnabled: false,
},
},
};
test('should return false when filter is disabled', () => {
const state = baseState;
expect(Selectors.isUnreadFilterEnabled(state)).toBe(false);
});
test('should return true when filter is enabled and unreads aren\'t separate', () => {
const state = mergeObjects(baseState, {
views: {
channelSidebar: {
unreadFilterEnabled: true,
},
},
});
expect(Selectors.isUnreadFilterEnabled(state)).toBe(true);
});
test('should return false when unreads are separate', () => {
const state = mergeObjects(baseState, {
entities: {
preferences: {
myPreferences: {
[preferenceKey]: {value: 'true'},
},
},
},
views: {
channelSidebar: {
unreadFilterEnabled: true,
},
},
});
expect(Selectors.isUnreadFilterEnabled(state)).toBe(false);
});
});
describe('getUnreadChannels', () => {
const currentChannel = TestHelper.getChannelMock({id: 'currentChannel', delete_at: 0, last_post_at: 0});
const readChannel = {id: 'readChannel', delete_at: 0, last_post_at: 300};
const unreadChannel1 = {id: 'unreadChannel1', delete_at: 0, last_post_at: 100};
const unreadChannel2 = {id: 'unreadChannel2', delete_at: 0, last_post_at: 200};
const baseState = {
entities: {
channels: {
channels: {
currentChannel,
readChannel,
unreadChannel1,
unreadChannel2,
},
channelsInTeam: {
team1: ['unreadChannel1', 'unreadChannel2', 'readChannel'],
},
currentChannelId: 'currentChannel',
messageCounts: {
currentChannel: {total: 0},
readChannel: {total: 10},
unreadChannel1: {total: 10},
unreadChannel2: {total: 10},
},
myMembers: {
currentChannel: {notify_props: {}, mention_count: 0, msg_count: 0},
readChannel: {notify_props: {}, mention_count: 0, msg_count: 10},
unreadChannel1: {notify_props: {}, mention_count: 0, msg_count: 8},
unreadChannel2: {notify_props: {}, mention_count: 0, msg_count: 8},
},
},
posts: {
postsInChannel: {},
},
teams: {
currentTeamId: 'team1',
},
general: {
config: {},
},
preferences: {
myPreferences: {},
},
},
views: {
channel: {
lastUnreadChannel: {
id: 'currentChannel',
hadMentions: false,
},
},
channelSidebar: {
unreadFilterEnabled: true,
},
},
};
test('should return channels sorted by recency', () => {
expect(Selectors.getUnreadChannels(baseState)).toEqual([unreadChannel2, unreadChannel1, currentChannel]);
});
test('should return channels with mentions before those without', () => {
let state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
myMembers: {
...baseState.entities.channels.myMembers,
unreadChannel1: {notify_props: {}, mention_count: 2, msg_count: 8},
},
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel1, unreadChannel2, currentChannel]);
state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
myMembers: {
...baseState.entities.channels.myMembers,
unreadChannel1: {notify_props: {}, mention_count: 2, msg_count: 8},
unreadChannel2: {notify_props: {}, mention_count: 1, msg_count: 8},
},
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel2, unreadChannel1, currentChannel]);
});
test('with the unread filter enabled, should always return the current channel, even if it is not unread', () => {
const state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
currentChannelId: 'readChannel',
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
views: {
...baseState.views,
channel: {
...baseState.views.channel,
lastUnreadChannel: {
id: 'readChannel',
hasMentions: true,
},
},
channelSidebar: {
...baseState.views.channelSidebar,
unreadFilterEnabled: true,
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([readChannel, unreadChannel2, unreadChannel1]);
});
test('with the unreads category enabled, should only return the current channel if it is lastUnreadChannel', () => {
let state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
currentChannelId: 'readChannel',
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
views: {
...baseState.views,
channel: {
...baseState.views.channel,
lastUnreadChannel: null,
},
channelSidebar: {
...baseState.views.channelSidebar,
unreadFilterEnabled: false,
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel2, unreadChannel1]);
state = {
...state,
views: {
...state.views,
channel: {
...state.views.channels,
lastUnreadChannel: {
id: 'readChannel',
},
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([readChannel, unreadChannel2, unreadChannel1]);
});
test('should look at lastUnreadChannel to determine if the current channel had mentions before it was read', () => {
let state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
currentChannelId: 'readChannel',
myMembers: {
...baseState.entities.channels.myMembers,
unreadChannel1: {notify_props: {}, mention_count: 2, msg_count: 8},
},
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
views: {
...baseState.views,
channel: {
...baseState.views.channel,
lastUnreadChannel: {
id: 'readChannel',
hadMentions: false,
},
},
},
};
// readChannel previously had no mentions, so it should be sorted with the non-mentions
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel1, readChannel, unreadChannel2]);
state = {
...state,
views: {
...state.views,
channel: {
...state.views.channel,
lastUnreadChannel: {
id: 'readChannel',
hadMentions: true,
},
},
},
};
// readChannel previously had a mention, so it should be sorted with the mentions
expect(Selectors.getUnreadChannels(state)).toEqual([readChannel, unreadChannel1, unreadChannel2]);
});
test('should sort muted channels last', () => {
let state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
myMembers: {
...baseState.entities.channels.myMembers,
unreadChannel2: {notify_props: {mark_unread: 'all'}, msg_count: 10, mention_count: 2},
},
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
};
// No channels are muted
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel2, unreadChannel1, currentChannel]);
state = {
...state,
entities: {
...state.entities,
channels: {
...state.entities.channels,
myMembers: {
...state.entities.channels.myMembers,
unreadChannel2: {notify_props: {mark_unread: 'mention'}, msg_count: 10, mention_count: 2},
},
},
general: {
...baseState.entities.general,
},
preferences: {
...baseState.entities.preferences,
},
},
};
// unreadChannel2 is muted and has a mention
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel1, currentChannel, unreadChannel2]);
});
test('should not show archived channels unless they are the current channel', () => {
const archivedChannel = {id: 'archivedChannel', delete_at: 1, last_post_at: 400};
let state = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
channels: {
...baseState.entities.channels.channels,
archivedChannel,
},
channelsInTeam: {
...baseState.entities.channels.channelsInTeam,
team1: [
...baseState.entities.channels.channelsInTeam.team1,
'archivedChannel',
],
},
messageCounts: {
...baseState.entities.channels.messageCounts,
archivedChannel: {total: 10},
},
myMembers: {
...baseState.entities.channels.myMembers,
archivedChannel: {notify_props: {}, mention_count: 0, msg_count: 0},
},
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([unreadChannel2, unreadChannel1, currentChannel]);
state = {
...state,
entities: {
...state.entities,
channels: {
...state.entities.channels,
currentChannelId: 'archivedChannel',
},
},
views: {
...state.views,
channel: {
...state.views.channel,
lastUnreadChannel: null,
},
},
};
expect(Selectors.getUnreadChannels(state)).toEqual([archivedChannel, unreadChannel2, unreadChannel1]);
});
});
describe('getDisplayedChannels', () => {
const currentChannel = TestHelper.getChannelMock({id: 'currentChannel', delete_at: 0, last_post_at: 0});
const readChannel = {id: 'readChannel', delete_at: 0, last_post_at: 300};
const unreadChannel1 = {id: 'unreadChannel1', delete_at: 0, last_post_at: 100};
const unreadChannel2 = {id: 'unreadChannel2', delete_at: 0, last_post_at: 200};
const category1 = {id: 'category1', team_id: 'team1', channel_ids: [currentChannel.id, unreadChannel1.id]};
const category2 = {id: 'category2', team_id: 'team1', channel_ids: [readChannel.id, unreadChannel2.id]};
const baseState = {
entities: {
channels: {
channels: {
currentChannel,
readChannel,
unreadChannel1,
unreadChannel2,
},
channelsInTeam: {
team1: ['unreadChannel1', 'unreadChannel2', 'readChannel'],
},
currentChannelId: 'currentChannel',
messageCounts: {
currentChannel: {total: 0},
readChannel: {total: 10},
unreadChannel1: {total: 10},
unreadChannel2: {total: 10},
},
myMembers: {
currentChannel: {notify_props: {}, mention_count: 0, msg_count: 0},
readChannel: {notify_props: {}, mention_count: 0, msg_count: 10},
unreadChannel1: {notify_props: {}, mention_count: 0, msg_count: 8},
unreadChannel2: {notify_props: {}, mention_count: 0, msg_count: 8},
},
},
channelCategories: {
byId: {
category1,
category2,
},
orderByTeam: {
team1: [category1.id, category2.id],
},
},
general: {
config: {
ExperimentalGroupUnreadChannels: 'true',
},
},
posts: {
postsInChannel: {},
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'false'},
},
},
teams: {
currentTeamId: 'team1',
},
users: {
profiles: {},
},
},
storage: {
storage: {},
},
views: {
channel: {
lastUnreadChannel: null,
},
channelSidebar: {
unreadFilterEnabled: false,
},
},
};
test('should return channels in the order that they appear in each category', () => {
const state = baseState;
expect(Selectors.getDisplayedChannels(state)).toEqual([
currentChannel,
unreadChannel1,
readChannel,
unreadChannel2,
]);
});
test('with unread filter enabled, should not return read channels', () => {
const state = {
...baseState,
views: {
...baseState.views,
channelSidebar: {
unreadFilterEnabled: true,
},
},
};
expect(Selectors.getDisplayedChannels(state)).toEqual([
unreadChannel2,
unreadChannel1,
currentChannel,
]);
});
test('with unreads section enabled, should have unread channels first', () => {
const state = {
...baseState,
entities: {
...baseState.entities,
preferences: {
...baseState.preferences,
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'true'},
},
},
},
};
expect(Selectors.getDisplayedChannels(state)).toEqual([
unreadChannel2,
unreadChannel1,
currentChannel,
readChannel,
]);
});
describe('memoization', () => {
test('should return the same result when called with the same state', () => {
expect(Selectors.getDisplayedChannels(baseState)).toBe(Selectors.getDisplayedChannels(baseState));
});
test('should return the same result when called with identical state', () => {
const modifiedState = {...baseState};
expect(Selectors.getDisplayedChannels(baseState)).toBe(Selectors.getDisplayedChannels(modifiedState));
});
test('should return the same result when called with unrelated state changing', () => {
const modifiedState = {
...baseState,
entities: {
...baseState.entities,
users: {
...baseState.entities.users,
profiles: {
someUser: {id: 'someUser'},
},
},
},
};
expect(Selectors.getDisplayedChannels(baseState)).toBe(Selectors.getDisplayedChannels(modifiedState));
});
test('should return a new result when the unreads section is enabled', () => {
const modifiedState = {
...baseState,
entities: {
...baseState.entities,
preferences: {
...baseState.preferences,
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'true'},
},
},
},
};
expect(Selectors.getDisplayedChannels(baseState)).not.toBe(Selectors.getDisplayedChannels(modifiedState));
expect(Selectors.getDisplayedChannels(modifiedState)).toBe(Selectors.getDisplayedChannels(modifiedState));
});
});
});
describe('makeGetFilteredChannelIdsForCategory', () => {
const currentChannel = TestHelper.getChannelMock({id: 'currentChannel', delete_at: 0, last_post_at: 0});
const readChannel = {id: 'readChannel', delete_at: 0, last_post_at: 300};
const unreadChannel1 = {id: 'unreadChannel1', delete_at: 0, last_post_at: 100};
const unreadChannel2 = {id: 'unreadChannel2', delete_at: 0, last_post_at: 200};
const baseState = {
entities: {
channels: {
channels: {
currentChannel,
readChannel,
unreadChannel1,
unreadChannel2,
},
channelsInTeam: {
team1: ['unreadChannel1', 'unreadChannel2', 'readChannel'],
},
currentChannelId: 'currentChannel',
messageCounts: {
currentChannel: {total: 0},
readChannel: {total: 10},
unreadChannel1: {total: 10},
unreadChannel2: {total: 10},
},
myMembers: {
currentChannel: {notify_props: {}, mention_count: 0, msg_count: 0},
readChannel: {notify_props: {}, mention_count: 0, msg_count: 10},
unreadChannel1: {notify_props: {}, mention_count: 0, msg_count: 8},
unreadChannel2: {notify_props: {}, mention_count: 0, msg_count: 8},
},
},
general: {
config: {
ExperimentalGroupUnreadChannels: 'true',
},
},
posts: {
postsInChannel: {},
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'false'},
},
},
teams: {
currentTeamId: 'team1',
},
users: {
profiles: {},
},
},
views: {
channel: {
lastUnreadChannel: null,
},
},
};
test('should only include channels in the given category', () => {
const category1 = {id: 'category1', team_id: 'team1', channel_ids: [currentChannel.id, unreadChannel1.id]};
const category2 = {id: 'category2', team_id: 'team1', channel_ids: [readChannel.id, unreadChannel2.id]};
const state = {
...baseState,
entities: {
...baseState.entities,
channelCategories: {
...baseState.entities.channelCategories,
byId: {
category1,
category2,
},
orderByTeam: {
team1: [category1, category2],
},
},
},
};
const getFilteredChannelIdsForCategory = Selectors.makeGetFilteredChannelIdsForCategory();
expect(getFilteredChannelIdsForCategory(state, category1)).toEqual([currentChannel.id, unreadChannel1.id]);
expect(getFilteredChannelIdsForCategory(state, category2)).toEqual([readChannel.id, unreadChannel2.id]);
});
test('with the unreads category enabled, should not include unread channels', () => {
const category1 = {id: 'category1', team_id: 'team1', channel_ids: [currentChannel.id, readChannel.id, unreadChannel1.id, unreadChannel2.id]};
const state = {
...baseState,
entities: {
...baseState.entities,
channelCategories: {
...baseState.entities.channelCategories,
byId: {
category1,
},
orderByTeam: {
team1: [category1],
},
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'true'},
},
},
},
};
const getFilteredChannelIdsForCategory = Selectors.makeGetFilteredChannelIdsForCategory();
expect(getFilteredChannelIdsForCategory(state, category1)).toEqual([currentChannel.id, readChannel.id]);
});
test('with the unreads category enabled, should not include the current channel if it was previously unread', () => {
const category1 = {id: 'category1', team_id: 'team1', channel_ids: [currentChannel.id, readChannel.id, unreadChannel1.id, unreadChannel2.id]};
const state = {
...baseState,
entities: {
...baseState.entities,
channelCategories: {
...baseState.entities.channelCategories,
byId: {
category1,
},
orderByTeam: {
team1: [category1],
},
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION)]: {value: 'true'},
},
},
},
views: {
...baseState.views,
channel: {
...baseState.views.channel,
lastUnreadChannel: {
id: currentChannel.id,
},
},
},
};
const getFilteredChannelIdsForCategory = Selectors.makeGetFilteredChannelIdsForCategory();
expect(getFilteredChannelIdsForCategory(state, category1)).toEqual([readChannel.id]);
});
});

Просмотреть файл

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {
getAllChannels,
getCurrentChannelId,
getMyChannelMemberships,
getUnreadChannelIds,
sortUnreadChannels,
} from 'mattermost-redux/selectors/entities/channels';
import {
makeGetCategoriesForTeam,
makeGetChannelsByCategory,
makeGetChannelIdsForCategory,
} from 'mattermost-redux/selectors/entities/channel_categories';
import {shouldShowUnreadsCategory, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {Channel} from '@mattermost/types/channels';
import {CategorySorting, ChannelCategory} from '@mattermost/types/channel_categories';
import {RelationOneToOne} from '@mattermost/types/utilities';
import {memoizeResult} from 'mattermost-redux/utils/helpers';
import {DraggingState, GlobalState} from 'types/store';
export function isUnreadFilterEnabled(state: GlobalState): boolean {
return state.views.channelSidebar.unreadFilterEnabled && !shouldShowUnreadsCategory(state);
}
export const getCategoriesForCurrentTeam: (state: GlobalState) => ChannelCategory[] = (() => {
const getCategoriesForTeam = makeGetCategoriesForTeam();
return memoizeResult((state: GlobalState) => {
const currentTeamId = getCurrentTeamId(state);
return getCategoriesForTeam(state, currentTeamId);
});
})();
export const getAutoSortedCategoryIds: (state: GlobalState) => Set<string> = (() => createSelector(
'getAutoSortedCategoryIds',
(state: GlobalState) => getCategoriesForCurrentTeam(state),
(categories) => {
return new Set(categories.filter((category) =>
category.sorting === CategorySorting.Alphabetical ||
category.sorting === CategorySorting.Recency).map((category) => category.id));
},
))();
export const getChannelsByCategoryForCurrentTeam: (state: GlobalState) => RelationOneToOne<ChannelCategory, Channel[]> = (() => {
const getChannelsByCategory = makeGetChannelsByCategory();
return memoizeResult((state: GlobalState) => {
const currentTeamId = getCurrentTeamId(state);
return getChannelsByCategory(state, currentTeamId);
});
})();
const getUnreadChannelIdsSet = createSelector(
'getUnreadChannelIdsSet',
(state: GlobalState) => getUnreadChannelIds(state, state.views.channel.lastUnreadChannel),
(unreadChannelIds) => {
return new Set(unreadChannelIds);
},
);
// getChannelsInCategoryOrder returns an array of channels on the current team that are currently visible in the sidebar.
// Channels are returned in the same order as in the sidebar. Channels in the Unreads category are not included.
export const getChannelsInCategoryOrder = (() => {
return createSelector(
'getChannelsInCategoryOrder',
getCategoriesForCurrentTeam,
getChannelsByCategoryForCurrentTeam,
getCurrentChannelId,
getUnreadChannelIdsSet,
shouldShowUnreadsCategory,
(categories, channelsByCategory, currentChannelId, unreadChannelIds, showUnreadsCategory) => {
return categories.map((category) => {
const channels = channelsByCategory[category.id];
return channels.filter((channel: Channel) => {
const isUnread = unreadChannelIds.has(channel.id);
if (showUnreadsCategory) {
// Filter out channels that have been moved to the Unreads category
if (isUnread) {
return false;
}
}
if (category.collapsed) {
// Filter out channels that would be hidden by a collapsed category
if (!isUnread && currentChannelId !== channel.id) {
return false;
}
}
return true;
});
}).flat();
},
);
})();
// getUnreadChannels returns an array of all unread channels on the current team for display with the unread filter
// enabled. Channels are sorted by recency with channels containing a mention grouped first.
export const getUnreadChannels = (() => {
const getUnsortedUnreadChannels = createSelector(
'getUnreadChannels',
getAllChannels,
getUnreadChannelIdsSet,
getCurrentChannelId,
isUnreadFilterEnabled,
(allChannels, unreadChannelIds, currentChannelId, unreadFilterEnabled) => {
const unreadChannels: Channel[] = [];
for (const channelId of unreadChannelIds) {
const channel = allChannels[channelId];
if (channel) {
// Only include an archived channel if it's the current channel
if (channel.delete_at > 0 && channel.id !== currentChannelId) {
continue;
}
unreadChannels.push(channel);
}
}
// This selector is used for both the unread filter and the unreads category which treat the current
// channel differently
if (unreadFilterEnabled) {
// The current channel is already in unreadChannels if it was previously unread but we need to add it
// if it wasn't previously unread
if (currentChannelId && unreadChannels.findIndex((channel) => channel.id === currentChannelId) === -1) {
if (allChannels[currentChannelId]) {
unreadChannels.push(allChannels[currentChannelId]);
}
}
}
return unreadChannels;
},
);
const sortChannels = createSelector(
'sortChannels',
(_: GlobalState, channels: Channel[]) => channels,
getMyChannelMemberships,
(state: GlobalState) => state.views.channel.lastUnreadChannel,
isCollapsedThreadsEnabled,
(channels, myMembers, lastUnreadChannel, crtEnabled) => {
return sortUnreadChannels(channels, myMembers, lastUnreadChannel, crtEnabled);
},
);
return (state: GlobalState) => {
const channels = getUnsortedUnreadChannels(state);
return sortChannels(state, channels);
};
})();
// WARNING: below functions are used in getDisplayedChannels only. Do not use it elsewhere.
function concatChannels(channelsA: Channel[], channelsB: Channel[]) {
return [...channelsA, ...channelsB];
}
const memoizedConcatChannels = memoizeResult(concatChannels);
// Returns an array of channels in the order that they currently appear in the sidebar. Channels are filtered out if they
// are hidden such as by a collapsed category or the unread filter.
export const getDisplayedChannels = createSelector(
'getDisplayedChannels',
isUnreadFilterEnabled,
getUnreadChannels,
shouldShowUnreadsCategory,
getChannelsInCategoryOrder,
(unreadFilterEnabled, unreadChannels, showUnreadsCategory, channelsInCategoryOrder) => {
if (unreadFilterEnabled) {
return unreadChannels;
}
if (showUnreadsCategory) {
return memoizedConcatChannels(unreadChannels, channelsInCategoryOrder);
}
return channelsInCategoryOrder;
},
);
// Returns a selector that, given a category, returns the ids of channels visible in that category. The returned channels do not
// include unread channels when the Unreads category is enabled.
export function makeGetFilteredChannelIdsForCategory(): (state: GlobalState, category: ChannelCategory) => string[] {
const getChannelIdsForCategory = makeGetChannelIdsForCategory();
return createSelector(
'makeGetFilteredChannelIdsForCategory',
getChannelIdsForCategory,
getUnreadChannelIdsSet,
shouldShowUnreadsCategory,
(channelIds, unreadChannelIdsSet, showUnreadsCategory) => {
if (!showUnreadsCategory) {
return channelIds;
}
const filtered = channelIds.filter((id) => !unreadChannelIdsSet.has(id));
return filtered.length === channelIds.length ? channelIds : filtered;
},
);
}
export function getDraggingState(state: GlobalState): DraggingState {
return state.views.channelSidebar.draggingState;
}
export function isChannelSelected(state: GlobalState, channelId: string): boolean {
return state.views.channelSidebar.multiSelectedChannelIds.indexOf(channelId) !== -1;
}

Просмотреть файл

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as UserSelectors from 'mattermost-redux/selectors/entities/users';
import * as GeneralSelectors from 'mattermost-redux/selectors/entities/general';
import * as PreferenceSelectors from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import configureStore from 'store';
import {makeGetCustomStatus, getRecentCustomStatuses, isCustomStatusEnabled, showStatusDropdownPulsatingDot, showPostHeaderUpdateStatusButton} from 'selectors/views/custom_status';
import {TestHelper} from 'utils/test_helper';
import {CustomStatusDuration} from '@mattermost/types/users';
jest.mock('mattermost-redux/selectors/entities/users');
jest.mock('mattermost-redux/selectors/entities/general');
jest.mock('mattermost-redux/selectors/entities/preferences');
const customStatus = {
emoji: 'speech_balloon',
text: 'speaking',
duration: CustomStatusDuration.DONT_CLEAR,
};
describe('getCustomStatus', () => {
const user = TestHelper.getUserMock();
const getCustomStatus = makeGetCustomStatus();
it('should return undefined when current user has no custom status set', async () => {
const store = await configureStore();
(UserSelectors.getCurrentUser as jest.Mock).mockReturnValue(user);
expect(getCustomStatus(store.getState())).toBeUndefined();
});
it('should return undefined when user with given id has no custom status set', async () => {
const store = await configureStore();
(UserSelectors.getUser as jest.Mock).mockReturnValue(user);
expect(getCustomStatus(store.getState(), user.id)).toBeUndefined();
});
it('should return customStatus object when there is custom status set', async () => {
const store = await configureStore();
const newUser = {...user};
newUser.props.customStatus = JSON.stringify(customStatus);
(UserSelectors.getCurrentUser as jest.Mock).mockReturnValue(newUser);
expect(getCustomStatus(store.getState())).toStrictEqual(customStatus);
});
});
describe('getRecentCustomStatuses', () => {
const preference = {
myPreference: {
value: JSON.stringify([]),
},
};
it('should return empty arr if there are no recent custom statuses', async () => {
const store = await configureStore();
(PreferenceSelectors.get as jest.Mock).mockReturnValue(preference.myPreference.value);
expect(getRecentCustomStatuses(store.getState())).toStrictEqual([]);
});
it('should return arr of custom statuses if there are recent custom statuses', async () => {
const store = await configureStore();
preference.myPreference.value = JSON.stringify([customStatus]);
(PreferenceSelectors.get as jest.Mock).mockReturnValue(preference.myPreference.value);
expect(getRecentCustomStatuses(store.getState())).toStrictEqual([customStatus]);
});
});
describe('isCustomStatusEnabled', () => {
const config = {
EnableCustomUserStatuses: 'true',
};
it('should return false if EnableCustomUserStatuses is false in the config', async () => {
const store = await configureStore();
expect(isCustomStatusEnabled(store.getState())).toBeFalsy();
});
it('should return true if EnableCustomUserStatuses is true in the config', async () => {
const store = await configureStore();
(GeneralSelectors.getConfig as jest.Mock).mockReturnValue(config);
expect(isCustomStatusEnabled(store.getState())).toBeTruthy();
});
});
describe('showStatusDropdownPulsatingDot and showPostHeaderUpdateStatusButton', () => {
const preference = {
myPreference: {
value: '',
},
};
it('should return true if user has not opened the custom status modal before', async () => {
const store = await configureStore();
(PreferenceSelectors.get as jest.Mock).mockReturnValue(preference.myPreference.value);
expect(showStatusDropdownPulsatingDot(store.getState())).toBeTruthy();
});
it('should return false if user has opened the custom status modal before', async () => {
const store = await configureStore();
preference.myPreference.value = JSON.stringify({[Preferences.CUSTOM_STATUS_MODAL_VIEWED]: true});
(PreferenceSelectors.get as jest.Mock).mockReturnValue(preference.myPreference.value);
expect(showPostHeaderUpdateStatusButton(store.getState())).toBeFalsy();
});
});

Просмотреть файл

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {createSelector} from 'reselect';
import {getCurrentUser, getUser} from 'mattermost-redux/selectors/entities/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {CustomStatusDuration, UserCustomStatus} from '@mattermost/types/users';
import {GlobalState} from 'types/store';
import {getCurrentUserTimezone} from 'selectors/general';
import {getCurrentMomentForTimezone} from 'utils/timezone';
export function makeGetCustomStatus(): (state: GlobalState, userID?: string) => UserCustomStatus {
return createSelector(
'makeGetCustomStatus',
(state: GlobalState, userID?: string) => (userID ? getUser(state, userID) : getCurrentUser(state)),
(user) => {
const userProps = user?.props || {};
return userProps.customStatus ? JSON.parse(userProps.customStatus) : undefined;
},
);
}
export function isCustomStatusExpired(state: GlobalState, customStatus?: UserCustomStatus) {
if (!customStatus) {
return true;
}
if (customStatus.duration === CustomStatusDuration.DONT_CLEAR) {
return false;
}
const expiryTime = moment(customStatus.expires_at);
const timezone = getCurrentUserTimezone(state);
const currentTime = getCurrentMomentForTimezone(timezone);
return currentTime.isSameOrAfter(expiryTime);
}
export const getRecentCustomStatuses = createSelector(
'getRecentCustomStatuses',
(state: GlobalState) => get(state, Preferences.CATEGORY_CUSTOM_STATUS, Preferences.NAME_RECENT_CUSTOM_STATUSES),
(value) => {
return value ? JSON.parse(value) : [];
},
);
export function isCustomStatusEnabled(state: GlobalState) {
const config = getConfig(state);
return config && config.EnableCustomUserStatuses === 'true';
}
function showCustomStatusPulsatingDotAndPostHeader(state: GlobalState) {
const customStatusTutorialState = get(state, Preferences.CATEGORY_CUSTOM_STATUS, Preferences.NAME_CUSTOM_STATUS_TUTORIAL_STATE);
const modalAlreadyViewed = customStatusTutorialState && JSON.parse(customStatusTutorialState)[Preferences.CUSTOM_STATUS_MODAL_VIEWED];
return !modalAlreadyViewed;
}
export function showStatusDropdownPulsatingDot(state: GlobalState) {
return showCustomStatusPulsatingDotAndPostHeader(state);
}
export function showPostHeaderUpdateStatusButton(state: GlobalState) {
return showCustomStatusPulsatingDotAndPostHeader(state);
}

Просмотреть файл

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AuthorType, MarketplaceApp, MarketplacePlugin, ReleaseStage} from '@mattermost/types/marketplace';
import {
getPlugins,
getListing,
getInstalledListing,
getApp,
getPlugin,
getFilter,
getInstalling,
getError,
} from 'selectors/views/marketplace';
import {GlobalState} from 'types/store';
describe('marketplace', () => {
const samplePlugin: MarketplacePlugin = {
homepage_url: 'https://github.com/mattermost/mattermost-plugin-nps',
download_url: 'https://github.com/mattermost/mattermost-plugin-nps/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz',
author_type: AuthorType.Mattermost,
release_stage: ReleaseStage.Production,
enterprise: false,
manifest: {
id: 'com.mattermost.nps',
name: 'User Satisfaction Surveys',
description: 'This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost',
version: '1.0.3',
min_server_version: '5.14.0',
},
installed_version: '',
};
const sampleInstalledPlugin: MarketplacePlugin = {
homepage_url: 'https://github.com/mattermost/mattermost-test',
download_url: 'https://github.com/mattermost/mattermost-test/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz',
author_type: AuthorType.Mattermost,
release_stage: ReleaseStage.Production,
enterprise: false,
manifest: {
id: 'com.mattermost.test',
name: 'Test',
description: 'This plugin is to test',
version: '1.0.3',
min_server_version: '5.14.0',
},
installed_version: '1.0.3',
};
const sampleApp: MarketplaceApp = {
installed: false,
author_type: AuthorType.Mattermost,
release_stage: ReleaseStage.Production,
enterprise: false,
manifest: {
app_id: 'some.id',
display_name: 'Some App',
},
};
const sampleInstalledApp: MarketplaceApp = {
installed: true,
author_type: AuthorType.Mattermost,
release_stage: ReleaseStage.Production,
enterprise: false,
manifest: {
app_id: 'some.other.id',
display_name: 'Some other App',
},
};
const state = {
views: {
marketplace: {
plugins: [samplePlugin, sampleInstalledPlugin],
apps: [sampleApp, sampleInstalledApp],
installing: {'com.mattermost.nps': true},
errors: {'com.mattermost.test': 'An error occurred'},
filter: 'existing',
},
},
} as unknown as GlobalState;
it('getListing should return all plugins and apps', () => {
expect(getListing(state)).toEqual([samplePlugin, sampleInstalledPlugin, sampleApp, sampleInstalledApp]);
});
it('getInstalledListing should return only installed plugins and apps', () => {
expect(getInstalledListing(state)).toEqual([sampleInstalledPlugin, sampleInstalledApp]);
});
it('getPlugins should return all plugins', () => {
expect(getPlugins(state)).toEqual([samplePlugin, sampleInstalledPlugin]);
});
describe('getPlugin', () => {
it('should return samplePlugin', () => {
expect(getPlugin(state, 'com.mattermost.nps')).toEqual(samplePlugin);
});
it('should return sampleInstalledPlugin', () => {
expect(getPlugin(state, 'com.mattermost.test')).toEqual(sampleInstalledPlugin);
});
it('should return undefined for unknown plugin', () => {
expect(getPlugin(state, 'unknown')).toBeUndefined();
});
});
describe('getApp', () => {
it('should return sampleApp', () => {
expect(getApp(state, 'some.id')).toEqual(sampleApp);
});
it('should return sampleInstalledApp', () => {
expect(getApp(state, 'some.other.id')).toEqual(sampleInstalledApp);
});
it('should return undefined for unknown app', () => {
expect(getApp(state, 'unknown')).toBeUndefined();
});
});
it('getFilter should return the active filter', () => {
expect(getFilter(state)).toEqual('existing');
});
describe('getInstalling', () => {
it('should return true for samplePlugin', () => {
expect(getInstalling(state, 'com.mattermost.nps')).toBe(true);
});
it('should return false for sampleInstalledPlugin', () => {
expect(getInstalling(state, 'com.mattermost.test')).toBe(false);
});
it('should return false for unknown plugin', () => {
expect(getInstalling(state, 'unknown')).toBe(false);
});
});
describe('getError', () => {
it('should return undefined for samplePlugin', () => {
expect(getError(state, 'com.mattermost.nps')).toBeUndefined();
});
it('should return error value for sampleInstalledPlugin', () => {
expect(getError(state, 'com.mattermost.test')).toBe('An error occurred');
});
it('should return undefined for unknown plugin', () => {
expect(getError(state, 'unknown')).toBeUndefined();
});
});
});

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {isPlugin} from 'mattermost-redux/utils/marketplace';
import type {MarketplaceApp, MarketplacePlugin} from '@mattermost/types/marketplace';
import {GlobalState} from 'types/store';
export const getPlugins = (state: GlobalState): MarketplacePlugin[] => state.views.marketplace.plugins;
export const getApps = (state: GlobalState): MarketplaceApp[] => state.views.marketplace.apps;
export const getListing = createSelector(
'getListing',
getPlugins,
getApps,
(plugins, apps) => {
if (plugins) {
return (plugins as Array<MarketplacePlugin | MarketplaceApp>).concat(apps);
}
return apps;
},
);
export const getInstalledListing = createSelector(
'getInstalledListing',
getListing,
(listing) => listing.filter((i) => {
if (isPlugin(i)) {
return i.installed_version !== '';
}
return i.installed;
}),
);
export const getPlugin = (state: GlobalState, id: string): MarketplacePlugin | undefined =>
getPlugins(state).find(((p) => p.manifest && p.manifest.id === id));
export const getApp = (state: GlobalState, id: string): MarketplaceApp | undefined =>
getApps(state).find(((p) => p.manifest && p.manifest.app_id === id));
export const getFilter = (state: GlobalState): string => state.views.marketplace.filter;
export const getInstalling = (state: GlobalState, id: string): boolean => Boolean(state.views.marketplace.installing[id]);
export const getError = (state: GlobalState, id: string): string => state.views.marketplace.errors[id];

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isModalOpen} from 'selectors/views/modals';
describe('modals selector', () => {
const state = {
views: {
modals: {
modalState: {
someModalId: {
open: true,
},
},
},
},
};
it('should return the isOpen value from the state for the given modalId', () => {
expect(isModalOpen(state, 'someModalId')).toBeTruthy();
});
it('should return false when the given ModalId is not in state', () => {
expect(isModalOpen(state, 'unknownModalId')).toBeFalsy();
});
});

Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function isModalOpen(state: GlobalState, modalId: string) {
return Boolean(state.views.modals.modalState[modalId] && state.views.modals.modalState[modalId].open);
}
export function isAnyModalOpen(state: GlobalState) {
return Boolean(state.views.modals.modalState && findOpenModal(state));
}
function findOpenModal(state: GlobalState) {
let isOpen = false;
const modalStateObject = state.views.modals.modalState;
for (const modal in modalStateObject) {
if (modal && modalStateObject[modal].open) {
isOpen = true;
break;
}
}
return isOpen;
}

Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
export function isShowOnboardingTaskCompletion(state: GlobalState) {
return state.views.onboardingTasks.isShowOnboardingTaskCompletion;
}
export function isShowOnboardingCompleteProfileTour(state: GlobalState) {
return state.views.onboardingTasks.isShowOnboardingCompleteProfileTour;
}
export function isShowOnboardingVisitConsoleTour(state: GlobalState) {
return state.views.onboardingTasks.isShowOnboardingVisitConsoleTour;
}

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function isSwitcherOpen(state: GlobalState): boolean {
return state.views.productMenu.switcherOpen;
}

Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
import {setStatusDropdown} from 'actions/views/status_dropdown';
import configureStore from 'store';
describe('status_dropdown selector', () => {
it('should return the isOpen value from the state', async () => {
const store = await configureStore();
expect(isStatusDropdownOpen(store.getState())).toBeFalsy();
});
it('should return true if statusDropdown in explicitly opened', async () => {
const store = await configureStore();
await store.dispatch(setStatusDropdown(true));
expect(isStatusDropdownOpen(store.getState())).toBeTruthy();
});
});

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
export function isStatusDropdownOpen(state: GlobalState) {
return state.views.statusDropdown.isOpen;
}

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function connectionErrorCount(state: GlobalState) {
return state.views.system.websocketConnectionErrorCount;
}

Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function showPreviewOnCreateComment(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnCreateComment;
}
export function showPreviewOnCreatePost(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnCreatePost;
}
export function showPreviewOnEditChannelHeaderModal(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnEditChannelHeaderModal;
}

Просмотреть файл

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from 'types/store';
import * as selectors from './threads';
describe('selectors/views/threads', () => {
const makeState = (selectedThreadId: string|null, selectedPostId: string, isSidebarOpen = true) => ({
entities: {
teams: {
currentTeamId: 'current_team_id',
},
threads: {
selected_thread_id: {
id: 'selected_thread_id',
},
selected_post_id: {
id: 'selected_post_id',
},
post_id: {
id: 'post_id',
},
},
},
views: {
threads: {
selectedThreadIdInTeam: {
current_team_id: selectedThreadId,
},
},
rhs: {
selectedPostId,
isSidebarOpen,
},
rhsSuppressed: false,
},
}) as unknown as GlobalState;
describe('isThreadOpen', () => {
test('should return true when a specific thread is open', () => {
const state = makeState('selected_thread_id', '');
expect(selectors.isThreadOpen(state, 'selected_thread_id')).toBe(true);
});
test('should return false when another thread is open', () => {
const state = makeState(null, 'selected_post_id');
expect(selectors.isThreadOpen(state, 'selected_thread_id')).toBe(false);
});
test('should return false when no threads are open', () => {
const state = makeState(null, '');
expect(selectors.isThreadOpen(state, 'post_id')).toBe(false);
});
test('should return true for either thread with both threads are open', () => {
const state = makeState('selected_thread_id', 'selected_post_id');
expect(selectors.isThreadOpen(state, 'selected_thread_id')).toBe(true);
expect(selectors.isThreadOpen(state, 'selected_post_id')).toBe(true);
});
});
});

Просмотреть файл

@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import {createSelector} from 'reselect';
import {makeGetPostsForIds} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getThreads} from 'mattermost-redux/selectors/entities/threads';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {Team} from '@mattermost/types/teams';
import {UserThread} from '@mattermost/types/threads';
import {Post} from '@mattermost/types/posts';
import {DATE_LINE, makeCombineUserActivityPosts, START_OF_NEW_MESSAGES, CREATE_COMMENT} from 'mattermost-redux/utils/post_list';
import {createIdsSelector} from 'mattermost-redux/utils/helpers';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
import {GlobalState} from 'types/store';
import {ViewsState} from 'types/store/views';
import {getIsRhsOpen, getSelectedPostId} from 'selectors/rhs';
import {isFromWebhook} from 'utils/post_utils';
interface PostFilterOptions {
postIds: Array<Post['id']>;
showDate: boolean;
lastViewedAt?: number;
}
export function getSelectedThreadIdInTeam(state: GlobalState): ViewsState['threads']['selectedThreadIdInTeam'] {
return state.views.threads.selectedThreadIdInTeam;
}
export const getSelectedThreadIdInCurrentTeam: (state: GlobalState) => ViewsState['threads']['selectedThreadIdInTeam'][Team['id']] = createSelector(
'getSelectedThreadIdInCurrentTeam',
getCurrentTeamId,
getSelectedThreadIdInTeam,
(
currentTeamId,
selectedThreadIdInTeam,
) => {
return selectedThreadIdInTeam?.[currentTeamId] ?? null;
},
);
export const getSelectedThreadInCurrentTeam: (state: GlobalState) => UserThread | null = createSelector(
'getSelectedThreadInCurrentTeam',
getCurrentTeamId,
getSelectedThreadIdInTeam,
getThreads,
(
currentTeamId,
selectedThreadIdInTeam,
threads,
) => {
const threadId = selectedThreadIdInTeam?.[currentTeamId];
return threadId ? threads[threadId] : null;
},
);
export function makeGetThreadLastViewedAt(): (state: GlobalState, threadId: Post['id']) => number {
return createSelector(
'makeGetThreadLastViewedAt',
(state: GlobalState, threadId: Post['id']) => state.views.threads.lastViewedAt[threadId],
getThreads,
(_state, threadId) => threadId,
(lastViewedAt, threads, threadId) => {
if (typeof lastViewedAt === 'number') {
return lastViewedAt;
}
return threads[threadId]?.last_viewed_at;
},
);
}
export const isThreadOpen = (state: GlobalState, threadId: UserThread['id']): boolean => {
return (
threadId === getSelectedThreadIdInCurrentTeam(state) ||
(getIsRhsOpen(state) && threadId === getSelectedPostId(state))
);
};
export const isThreadManuallyUnread = (state: GlobalState, threadId: UserThread['id']): boolean => {
return state.views.threads.manuallyUnread[threadId] || false;
};
// Returns a selector that, given the state and an object containing an array of postIds and an optional
// timestamp of when the channel was last read, returns a memoized array of postIds interspersed with
// day indicators, an optional new message indicator and create comment.
export function makeFilterRepliesAndAddSeparators() {
const getPostsForIds = makeGetPostsForIds();
return createIdsSelector(
'makeFilterPostsAndAddSeparators',
(state: GlobalState, {postIds}: PostFilterOptions) => getPostsForIds(state, postIds),
(_state: GlobalState, {lastViewedAt}: PostFilterOptions) => lastViewedAt,
(_state: GlobalState, {showDate}: PostFilterOptions) => showDate,
getCurrentUser,
isTimezoneEnabled,
(posts, lastViewedAt, showDate, currentUser, timeZoneEnabled) => {
if (posts.length === 0 || !currentUser) {
return [];
}
const out: string[] = [];
let lastDate;
let addedNewMessagesIndicator = false;
// Iterating through the posts from oldest to newest
for (let i = posts.length - 1; i >= 0; i--) {
const post = posts[i];
if (!post) {
continue;
}
if (showDate) {
// Push on a date header if the last post was on a different day than the current one
const postDate = new Date(post.create_at);
if (timeZoneEnabled) {
const currentOffset = postDate.getTimezoneOffset() * 60 * 1000;
const timezone = getUserCurrentTimezone(currentUser.timezone);
if (timezone) {
const zone = moment.tz.zone(timezone);
if (zone) {
const timezoneOffset = zone.utcOffset(post.create_at) * 60 * 1000;
postDate.setTime(post.create_at + (currentOffset - timezoneOffset));
}
}
}
if ((!lastDate || lastDate.toDateString() !== postDate.toDateString())) {
out.push(DATE_LINE + postDate.getTime());
lastDate = postDate;
}
}
if (
typeof lastViewedAt === 'number' &&
post.create_at >= lastViewedAt &&
(i < posts.length - 1) &&
(post.user_id !== currentUser.id || isFromWebhook(post)) &&
!addedNewMessagesIndicator
) {
out.push(START_OF_NEW_MESSAGES);
addedNewMessagesIndicator = true;
}
out.push(post.id);
}
out.push(CREATE_COMMENT);
// Flip it back to newest to oldest
return out.reverse();
},
);
}
export function makePrepareReplyIdsForThreadViewer() {
const filterRepliesAndAddSeparators = makeFilterRepliesAndAddSeparators();
const combineUserActivityPosts = makeCombineUserActivityPosts();
return (state: GlobalState, options: PostFilterOptions) => {
const postIds = filterRepliesAndAddSeparators(state, options);
return combineUserActivityPosts(state, postIds);
};
}
export function getThreadToastStatus(state: GlobalState) {
return state.views.threads.toastStatus;
}

Просмотреть файл

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export const getSocketStatus = (state: GlobalState) => state.websocket;

Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from 'types/store';
export const areWorkTemplatesEnabled = createSelector(
'areWorktemplatesEnabled',
(state: GlobalState) => getFeatureFlagValue(state, 'WorkTemplate') === 'true',
(state: GlobalState) => getFeatureFlagValue(state, 'BoardsProduct') === 'true',
(state: GlobalState) => Boolean(state.plugins.plugins?.playbooks),
(workTemplateFF, boardProductEnabled, pluginPlaybooksInstalled) => {
return workTemplateFF && boardProductEnabled && pluginPlaybooksInstalled;
},
);