[MM-36431] Logic to support multiple configurable tabs per server (#1655)
* Updated config, added types and classes for messaging tab * Working app with tabs and servers * Remainder of logic * Make base tab abstract class * Account for new app case * Merge'd * PR feedback
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const SWITCH_SERVER = 'switch-server';
|
||||
export const SET_SERVER_KEY = 'set-server-key';
|
||||
export const SWITCH_TAB = 'switch-tab';
|
||||
export const SET_ACTIVE_VIEW = 'set-active-view';
|
||||
export const MARK_READ = 'mark-read';
|
||||
export const FOCUS_BROWSERVIEW = 'focus-browserview';
|
||||
export const ZOOM = 'zoom';
|
||||
@@ -62,7 +63,7 @@ export const SESSION_EXPIRED = 'session_expired';
|
||||
export const UPDATE_TRAY = 'update_tray';
|
||||
export const UPDATE_BADGE = 'update_badge';
|
||||
|
||||
export const SET_SERVER_NAME = 'set-server-name';
|
||||
export const SET_VIEW_NAME = 'set-view-name';
|
||||
export const REACT_APP_INITIALIZED = 'react-app-initialized';
|
||||
|
||||
export const TOGGLE_BACK_BUTTON = 'toggle-back-button';
|
||||
|
@@ -10,14 +10,14 @@ import os from 'os';
|
||||
* @param {number} version - Scheme version. (Not application version)
|
||||
*/
|
||||
|
||||
import {ConfigV2} from 'types/config';
|
||||
import {ConfigV3} from 'types/config';
|
||||
|
||||
export const getDefaultDownloadLocation = (): string => {
|
||||
return path.join(os.homedir(), 'Downloads');
|
||||
};
|
||||
|
||||
const defaultPreferences: ConfigV2 = {
|
||||
version: 2,
|
||||
const defaultPreferences: ConfigV3 = {
|
||||
version: 3,
|
||||
teams: [],
|
||||
showTrayIcon: true,
|
||||
trayIconTheme: 'light',
|
||||
|
@@ -16,12 +16,13 @@ import {
|
||||
Config as ConfigType,
|
||||
LocalConfiguration,
|
||||
RegistryConfig as RegistryConfigType,
|
||||
Team,
|
||||
TeamWithTabs,
|
||||
} from 'types/config';
|
||||
|
||||
import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION} from 'common/communication';
|
||||
|
||||
import * as Validator from '../../main/Validator';
|
||||
import * as Validator from 'main/Validator';
|
||||
import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView';
|
||||
|
||||
import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences';
|
||||
import upgradeConfigData from './upgradePreferences';
|
||||
@@ -263,6 +264,9 @@ export default class Config extends EventEmitter {
|
||||
|
||||
// validate based on config file version
|
||||
switch (configData.version) {
|
||||
case 3:
|
||||
configData = Validator.validateV3ConfigData(configData)!;
|
||||
break;
|
||||
case 2:
|
||||
configData = Validator.validateV2ConfigData(configData)!;
|
||||
break;
|
||||
@@ -317,16 +321,16 @@ export default class Config extends EventEmitter {
|
||||
delete this.combinedData!.defaultTeams;
|
||||
|
||||
// IMPORTANT: properly combine teams from all sources
|
||||
let combinedTeams = [];
|
||||
let combinedTeams: TeamWithTabs[] = [];
|
||||
|
||||
// - start by adding default teams from buildConfig, if any
|
||||
if (this.buildConfigData?.defaultTeams?.length) {
|
||||
combinedTeams.push(...this.buildConfigData.defaultTeams);
|
||||
combinedTeams.push(...this.buildConfigData.defaultTeams.map((team) => getDefaultTeamWithTabsFromTeam(team)));
|
||||
}
|
||||
|
||||
// - add registry defined teams, if any
|
||||
if (this.registryConfigData?.teams?.length) {
|
||||
combinedTeams.push(...this.registryConfigData.teams);
|
||||
combinedTeams.push(...this.registryConfigData.teams.map((team) => getDefaultTeamWithTabsFromTeam(team)));
|
||||
}
|
||||
|
||||
// - add locally defined teams only if server management is enabled
|
||||
@@ -352,7 +356,7 @@ export default class Config extends EventEmitter {
|
||||
*
|
||||
* @param {array} teams array of teams to check for duplicates
|
||||
*/
|
||||
filterOutDuplicateTeams = (teams: Team[]) => {
|
||||
filterOutDuplicateTeams = (teams: TeamWithTabs[]) => {
|
||||
let newTeams = teams;
|
||||
const uniqueURLs = new Set();
|
||||
newTeams = newTeams.filter((team) => {
|
||||
@@ -365,7 +369,7 @@ export default class Config extends EventEmitter {
|
||||
* Returns the provided array fo teams with existing teams filtered out
|
||||
* @param {array} teams array of teams to check for already defined teams
|
||||
*/
|
||||
filterOutPredefinedTeams = (teams: Team[]) => {
|
||||
filterOutPredefinedTeams = (teams: TeamWithTabs[]) => {
|
||||
let newTeams = teams;
|
||||
|
||||
// filter out predefined teams
|
||||
@@ -380,7 +384,7 @@ export default class Config extends EventEmitter {
|
||||
* Apply a default sort order to the team list, if no order is specified.
|
||||
* @param {array} teams to sort
|
||||
*/
|
||||
sortUnorderedTeams = (teams: Team[]) => {
|
||||
sortUnorderedTeams = (teams: TeamWithTabs[]) => {
|
||||
// We want to preserve the array order of teams in the config, otherwise a lot of bugs will occur
|
||||
const mappedTeams = teams.map((team, index) => ({team, originalOrder: index}));
|
||||
|
||||
@@ -470,7 +474,7 @@ export default class Config extends EventEmitter {
|
||||
return config;
|
||||
}
|
||||
|
||||
handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: Team[]) => {
|
||||
handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: TeamWithTabs[]) => {
|
||||
this.set('teams', newTeams);
|
||||
return this.combinedData!.teams;
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {ConfigV0, ConfigV1} from 'types/config';
|
||||
import {ConfigV0, ConfigV1, ConfigV2} from 'types/config';
|
||||
|
||||
import defaultPreferences from './defaultPreferences';
|
||||
import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences';
|
||||
|
||||
const pastDefaultPreferences = {
|
||||
0: {
|
||||
@@ -26,7 +26,26 @@ const pastDefaultPreferences = {
|
||||
autostart: true,
|
||||
spellCheckerLocale: 'en-US',
|
||||
} as ConfigV1,
|
||||
2: defaultPreferences,
|
||||
2: {
|
||||
version: 2,
|
||||
teams: [],
|
||||
showTrayIcon: true,
|
||||
trayIconTheme: 'light',
|
||||
minimizeToTray: true,
|
||||
notifications: {
|
||||
flashWindow: 2,
|
||||
bounceIcon: true,
|
||||
bounceIconType: 'informational',
|
||||
},
|
||||
showUnreadBadge: true,
|
||||
useSpellChecker: true,
|
||||
enableHardwareAcceleration: true,
|
||||
autostart: true,
|
||||
spellCheckerLocale: 'en-US',
|
||||
darkMode: false,
|
||||
downloadLocation: getDefaultDownloadLocation(),
|
||||
} as ConfigV2,
|
||||
3: defaultPreferences,
|
||||
};
|
||||
|
||||
export default pastDefaultPreferences;
|
||||
|
@@ -1,7 +1,10 @@
|
||||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {ConfigV2, ConfigV1, ConfigV0, AnyConfig} from 'types/config';
|
||||
|
||||
import {ConfigV3, ConfigV2, ConfigV1, ConfigV0, AnyConfig} from 'types/config';
|
||||
|
||||
import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView';
|
||||
|
||||
import pastDefaultPreferences from './pastDefaultPreferences';
|
||||
|
||||
@@ -30,10 +33,24 @@ function upgradeV1toV2(configV1: ConfigV1) {
|
||||
return config;
|
||||
}
|
||||
|
||||
export default function upgradeToLatest(config: AnyConfig): ConfigV2 {
|
||||
function upgradeV2toV3(configV2: ConfigV2) {
|
||||
const config: ConfigV3 = Object.assign({}, deepCopy<ConfigV3>(pastDefaultPreferences[3]), configV2);
|
||||
config.version = 3;
|
||||
config.teams = configV2.teams.map((value) => {
|
||||
return {
|
||||
...getDefaultTeamWithTabsFromTeam(value),
|
||||
lastActiveTab: 0,
|
||||
};
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export default function upgradeToLatest(config: AnyConfig): ConfigV3 {
|
||||
switch (config.version) {
|
||||
case 3:
|
||||
return config as ConfigV3;
|
||||
case 2:
|
||||
return config as ConfigV2;
|
||||
return upgradeToLatest(upgradeV2toV3(config as ConfigV2));
|
||||
case 1:
|
||||
return upgradeToLatest(upgradeV1toV2(config as ConfigV1));
|
||||
default:
|
||||
|
32
src/common/servers/MattermostServer.ts
Normal file
32
src/common/servers/MattermostServer.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import urlUtils from 'common/utils/url';
|
||||
|
||||
export class MattermostServer {
|
||||
name: string;
|
||||
url: URL;
|
||||
constructor(name: string, serverUrl: string) {
|
||||
this.name = name;
|
||||
this.url = urlUtils.parseURL(serverUrl)!;
|
||||
if (!this.url) {
|
||||
throw new Error('Invalid url for creating a server');
|
||||
}
|
||||
}
|
||||
|
||||
getServerInfo = () => {
|
||||
// does the server have a subpath?
|
||||
const normalizedPath = this.url.pathname.toLowerCase();
|
||||
const subpath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
||||
return {origin: this.url.origin, subpath, url: this.url.toString()};
|
||||
}
|
||||
|
||||
sameOrigin = (otherURL: string) => {
|
||||
const parsedUrl = urlUtils.parseURL(otherURL);
|
||||
return parsedUrl && this.url.origin === parsedUrl.origin;
|
||||
}
|
||||
|
||||
equals = (otherServer: MattermostServer) => {
|
||||
return (this.name === otherServer.name) && (this.url.toString() === otherServer.url.toString());
|
||||
}
|
||||
}
|
23
src/common/tabs/BaseTabView.ts
Normal file
23
src/common/tabs/BaseTabView.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {MattermostServer} from 'common/servers/MattermostServer';
|
||||
|
||||
import {getTabViewName, TabType, TabView} from './TabView';
|
||||
|
||||
export default abstract class BaseTabView implements TabView {
|
||||
server: MattermostServer;
|
||||
|
||||
constructor(server: MattermostServer) {
|
||||
this.server = server;
|
||||
}
|
||||
get name(): string {
|
||||
return getTabViewName(this.server.name, this.type);
|
||||
}
|
||||
get url(): URL {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
get type(): TabType {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
15
src/common/tabs/FocalboardTabView.ts
Normal file
15
src/common/tabs/FocalboardTabView.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import BaseTabView from './BaseTabView';
|
||||
import {TabType, TAB_FOCALBOARD} from './TabView';
|
||||
|
||||
export default class FocalboardTabView extends BaseTabView {
|
||||
get url(): URL {
|
||||
return this.server.url;
|
||||
}
|
||||
|
||||
get type(): TabType {
|
||||
return TAB_FOCALBOARD;
|
||||
}
|
||||
}
|
15
src/common/tabs/MessagingTabView.ts
Normal file
15
src/common/tabs/MessagingTabView.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import BaseTabView from './BaseTabView';
|
||||
import {TabType, TAB_MESSAGING} from './TabView';
|
||||
|
||||
export default class MessagingTabView extends BaseTabView {
|
||||
get url(): URL {
|
||||
return this.server.url;
|
||||
}
|
||||
|
||||
get type(): TabType {
|
||||
return TAB_MESSAGING;
|
||||
}
|
||||
}
|
15
src/common/tabs/PlaybooksTabView.ts
Normal file
15
src/common/tabs/PlaybooksTabView.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import BaseTabView from './BaseTabView';
|
||||
import {TabType, TAB_PLAYBOOKS} from './TabView';
|
||||
|
||||
export default class PlaybooksTabView extends BaseTabView {
|
||||
get url(): URL {
|
||||
return this.server.url;
|
||||
}
|
||||
|
||||
get type(): TabType {
|
||||
return TAB_PLAYBOOKS;
|
||||
}
|
||||
}
|
61
src/common/tabs/TabView.ts
Normal file
61
src/common/tabs/TabView.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Tab, Team} from 'types/config';
|
||||
|
||||
import {MattermostServer} from 'common/servers/MattermostServer';
|
||||
|
||||
import MessagingTabView from './MessagingTabView';
|
||||
import FocalboardTabView from './FocalboardTabView';
|
||||
import PlaybooksTabView from './PlaybooksTabView';
|
||||
|
||||
export const TAB_MESSAGING = 'TAB_MESSAGING';
|
||||
export const TAB_FOCALBOARD = 'TAB_FOCALBOARD';
|
||||
export const TAB_PLAYBOOKS = 'TAB_PLAYBOOKS';
|
||||
export type TabType = typeof TAB_MESSAGING | typeof TAB_FOCALBOARD | typeof TAB_PLAYBOOKS;
|
||||
|
||||
export interface TabView {
|
||||
server: MattermostServer;
|
||||
|
||||
get name(): string;
|
||||
get type(): TabType;
|
||||
get url(): URL;
|
||||
}
|
||||
|
||||
export function getDefaultTeamWithTabsFromTeam(team: Team) {
|
||||
return {
|
||||
...team,
|
||||
tabs: [
|
||||
{
|
||||
name: TAB_MESSAGING,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
name: TAB_FOCALBOARD,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
name: TAB_PLAYBOOKS,
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Might need to move this out
|
||||
export function getServerView(srv: MattermostServer, tab: Tab) {
|
||||
switch (tab.name) {
|
||||
case TAB_MESSAGING:
|
||||
return new MessagingTabView(srv);
|
||||
case TAB_FOCALBOARD:
|
||||
return new FocalboardTabView(srv);
|
||||
case TAB_PLAYBOOKS:
|
||||
return new PlaybooksTabView(srv);
|
||||
default:
|
||||
throw new Error('Not implemeneted');
|
||||
}
|
||||
}
|
||||
|
||||
export function getTabViewName(serverName: string, tabType: string) {
|
||||
return `${serverName}___${tabType}`;
|
||||
}
|
@@ -1,11 +1,14 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Team} from 'types/config';
|
||||
import {ServerFromURL} from 'types/utils';
|
||||
import {isHttpsUri, isHttpUri, isUri} from 'valid-url';
|
||||
|
||||
import {TeamWithTabs} from 'types/config';
|
||||
import {ServerFromURL} from 'types/utils';
|
||||
|
||||
import buildConfig from '../config/buildConfig';
|
||||
import {MattermostServer} from '../servers/MattermostServer';
|
||||
import {getServerView} from '../tabs/TabView';
|
||||
|
||||
// supported custom login paths (oath, saml)
|
||||
const customLoginRegexPaths = [
|
||||
@@ -156,32 +159,35 @@ function isManagedResource(serverUrl: URL | string, inputURL: URL | string) {
|
||||
managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`))));
|
||||
}
|
||||
|
||||
function getServer(inputURL: URL | string, teams: Team[], ignoreScheme = false): ServerFromURL | undefined {
|
||||
function getView(inputURL: URL | string, teams: TeamWithTabs[], ignoreScheme = false): ServerFromURL | undefined {
|
||||
const parsedURL = parseURL(inputURL);
|
||||
if (!parsedURL) {
|
||||
return undefined;
|
||||
}
|
||||
let parsedServerUrl;
|
||||
let firstOption;
|
||||
let secondOption;
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
parsedServerUrl = parseURL(teams[i].url);
|
||||
if (!parsedServerUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check server and subpath matches (without subpath pathname is \ so it always matches)
|
||||
if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) {
|
||||
return {name: teams[i].name, url: parsedServerUrl, index: i};
|
||||
}
|
||||
if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL, ignoreScheme)) {
|
||||
// in case the user added something on the path that doesn't really belong to the server
|
||||
// there might be more than one that matches, but we can't differentiate, so last one
|
||||
// is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin)
|
||||
// e.g.: https://community.mattermost.com/core
|
||||
secondOption = {name: teams[i].name, url: parsedServerUrl, index: i};
|
||||
}
|
||||
}
|
||||
return secondOption;
|
||||
teams.forEach((team) => {
|
||||
const srv = new MattermostServer(team.name, team.url);
|
||||
team.tabs.forEach((tab) => {
|
||||
const tabView = getServerView(srv, tab);
|
||||
parsedServerUrl = parseURL(tabView.url);
|
||||
if (parsedServerUrl) {
|
||||
// check server and subpath matches (without subpath pathname is \ so it always matches)
|
||||
if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) {
|
||||
firstOption = {name: tabView.name, url: parsedServerUrl};
|
||||
}
|
||||
if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL, ignoreScheme)) {
|
||||
// in case the user added something on the path that doesn't really belong to the server
|
||||
// there might be more than one that matches, but we can't differentiate, so last one
|
||||
// is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin)
|
||||
// e.g.: https://community.mattermost.com/core
|
||||
secondOption = {name: tabView.name, url: parsedServerUrl};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return firstOption || secondOption;
|
||||
}
|
||||
|
||||
// next two functions are defined to clarify intent
|
||||
@@ -199,15 +205,15 @@ function equalUrlsIgnoringSubpath(url1: URL, url2: URL, ignoreScheme?: boolean)
|
||||
return url1.origin.toLowerCase() === url2.origin.toLowerCase();
|
||||
}
|
||||
|
||||
function isTrustedURL(url: URL | string, teams: Team[]) {
|
||||
function isTrustedURL(url: URL | string, teams: TeamWithTabs[]) {
|
||||
const parsedURL = parseURL(url);
|
||||
if (!parsedURL) {
|
||||
return false;
|
||||
}
|
||||
return getServer(parsedURL, teams) !== null;
|
||||
return getView(parsedURL, teams) !== null;
|
||||
}
|
||||
|
||||
function isCustomLoginURL(url: URL | string, server: ServerFromURL, teams: Team[]): boolean {
|
||||
function isCustomLoginURL(url: URL | string, server: ServerFromURL, teams: TeamWithTabs[]): boolean {
|
||||
const subpath = server ? server.url.pathname : '';
|
||||
const parsedURL = parseURL(url);
|
||||
if (!parsedURL) {
|
||||
@@ -242,7 +248,7 @@ export default {
|
||||
isValidURI,
|
||||
isInternalURL,
|
||||
parseURL,
|
||||
getServer,
|
||||
getView,
|
||||
getServerInfo,
|
||||
isAdminUrl,
|
||||
isTeamUrl,
|
||||
|
Reference in New Issue
Block a user