From 4e1e7334d8664a1c481ba3f1e842e74dc1247b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 12 Dec 2024 10:03:13 +0100 Subject: [PATCH] Migrate remaining javascript files in webapp/channels/src/plugins to typescript (#29386) * feat: Migrate actions.js to TypeScript with type annotations * feat: Migrate actions.js to TypeScript with correct action types * feat: Add actions plugin for webapp channels * feat: Migrate actions.js to TypeScript with type annotations * feat: Add interactive dialog plugin for webapp channels * feat: Migrate interactive_dialog.js to TypeScript with type definitions * feat: Add export plugin module for webapp channels * feat: Migrate export.js to TypeScript with type declarations * feat: Add initial plugin index file for webapp channels * feat: migrate plugins/index.js to TypeScript with type definitions * test: Add export plugin test file * feat: Migrate export.test.js to TypeScript with type assertions * feat: Add emoji actions file to webapp channels * feat: Migrate emoji_actions.js to TypeScript with full type support * Fixing some issues * Revert "feat: Migrate emoji_actions.js to TypeScript with full type support" This reverts commit e64aabe9fc6d36938cbaa40b7acb3356729a6686. * fixing linter errors * Fixing CI * Addressing pr review comments * Apply suggestions from code review Co-authored-by: Harrison Healey * Fixing linter errors * Fixing CI --------- Co-authored-by: Harrison Healey --- webapp/channels/package.json | 1 + webapp/channels/src/components/root/root.tsx | 2 +- .../src/plugins/{actions.js => actions.ts} | 10 ++- .../{export.test.js => export.test.ts} | 8 +-- .../src/plugins/{export.js => export.ts} | 65 ++++++++++++++++-- .../src/plugins/{index.js => index.ts} | 68 +++++++++++++------ ...active_dialog.js => interactive_dialog.ts} | 6 +- webapp/package-lock.json | 7 ++ 8 files changed, 130 insertions(+), 37 deletions(-) rename webapp/channels/src/plugins/{actions.js => actions.ts} (73%) rename webapp/channels/src/plugins/{export.test.js => export.test.ts} (80%) rename webapp/channels/src/plugins/{export.js => export.ts} (66%) rename webapp/channels/src/plugins/{index.js => index.ts} (78%) rename webapp/channels/src/plugins/{interactive_dialog.js => interactive_dialog.ts} (92%) diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 1c4dd860ec..dbf705ec82 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -136,6 +136,7 @@ "@types/react-window": "1.8.5", "@types/react-window-infinite-loader": "1.0.6", "@types/redux-mock-store": "1.0.3", + "@types/regenerator-runtime": "0.13.8", "@types/semver": "7.5.8", "@types/shallow-equals": "1.0.3", "@types/styled-components": "5.1.32", diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index de30d7626b..5c914320ce 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -47,7 +47,7 @@ import RootRedirect from './root_redirect'; import type {PropsFromRedux} from './index'; -import 'plugins/export.js'; +import 'plugins/export'; const MobileViewWatcher = makeAsyncComponent('MobileViewWatcher', lazy(() => import('components/mobile_view_watcher'))); const WindowSizeObserver = makeAsyncComponent('WindowSizeObserver', lazy(() => import('components/window_size_observer/WindowSizeObserver'))); diff --git a/webapp/channels/src/plugins/actions.js b/webapp/channels/src/plugins/actions.ts similarity index 73% rename from webapp/channels/src/plugins/actions.js rename to webapp/channels/src/plugins/actions.ts index 46b50ed6aa..d062c4eec0 100644 --- a/webapp/channels/src/plugins/actions.js +++ b/webapp/channels/src/plugins/actions.ts @@ -1,20 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {PluginManifest} from '@mattermost/types/plugins'; + import {hideRHSPlugin as hideRHSPluginAction} from 'actions/views/rhs'; import {getPluggableId} from 'selectors/rhs'; import {ActionTypes} from 'utils/constants'; -export const removeWebappPlugin = (manifest) => { +import type {GlobalState, ActionFunc} from 'types/store'; + +export const removeWebappPlugin = (manifest: PluginManifest): ActionFunc => { return (dispatch) => { dispatch(hideRHSPlugin(manifest.id)); dispatch({type: ActionTypes.REMOVED_WEBAPP_PLUGIN, data: manifest}); + return {data: true}; }; }; // hideRHSPlugin closes the RHS if currently showing this plugin. -const hideRHSPlugin = (manifestId) => { +const hideRHSPlugin = (manifestId: string): ActionFunc => { return (dispatch, getState) => { const state = getState(); const rhsPlugins = state.plugins.components.RightHandSidebarComponent || []; @@ -25,5 +30,6 @@ const hideRHSPlugin = (manifestId) => { if (pluginComponent) { dispatch(hideRHSPluginAction(pluggableId)); } + return {data: true}; }; }; diff --git a/webapp/channels/src/plugins/export.test.js b/webapp/channels/src/plugins/export.test.ts similarity index 80% rename from webapp/channels/src/plugins/export.test.js rename to webapp/channels/src/plugins/export.test.ts index d8f7f3bb67..b32d94ba30 100644 --- a/webapp/channels/src/plugins/export.test.js +++ b/webapp/channels/src/plugins/export.test.ts @@ -13,25 +13,25 @@ describe('messageHtmlToComponent wrapper', () => { const isRHS = false; test('should call messageHtmlToComponent properly with only message', () => { - window.PostUtils.messageHtmlToComponent(message); + (window as any).PostUtils.messageHtmlToComponent(message); expect(messageHtmlToComponent).toHaveBeenCalledWith(message, undefined); }); test('should call messageHtmlToComponent properly with message and options', () => { - window.PostUtils.messageHtmlToComponent(message, options); + (window as any).PostUtils.messageHtmlToComponent(message, options); expect(messageHtmlToComponent).toHaveBeenCalledWith(message, options); }); test('should call messageHtmlToComponent properly with only message when deprecated isRHS parameter is passed', () => { - window.PostUtils.messageHtmlToComponent(message, isRHS); + (window as any).PostUtils.messageHtmlToComponent(message, isRHS); expect(messageHtmlToComponent).toHaveBeenCalledWith(message, undefined); }); test('should call messageHtmlToComponent properly with message and options when deprecated isRHS parameter is passed', () => { - window.PostUtils.messageHtmlToComponent(message, isRHS, options); + (window as any).PostUtils.messageHtmlToComponent(message, isRHS, options); expect(messageHtmlToComponent).toHaveBeenCalledWith(message, options); }); diff --git a/webapp/channels/src/plugins/export.js b/webapp/channels/src/plugins/export.ts similarity index 66% rename from webapp/channels/src/plugins/export.js rename to webapp/channels/src/plugins/export.ts index 1ce7e0fc5e..5c81379ffe 100644 --- a/webapp/channels/src/plugins/export.js +++ b/webapp/channels/src/plugins/export.ts @@ -31,6 +31,63 @@ import {imageURLForUser} from 'utils/utils'; import {openInteractiveDialog} from './interactive_dialog'; // This import has intentional side effects. Do not remove without research. import Textbox from './textbox'; +interface WindowWithLibraries { + React: typeof import('react'); + ReactDOM: typeof import('react-dom'); + ReactIntl: typeof import('react-intl'); + Redux: typeof import('redux'); + ReactRedux: typeof import('react-redux'); + ReactBootstrap: typeof import('react-bootstrap'); + ReactRouterDom: typeof import('react-router-dom'); + PropTypes: typeof import('prop-types'); + Luxon: typeof import('luxon'); + StyledComponents: typeof import('styled-components'); + PostUtils: { + formatText: typeof formatText; + messageHtmlToComponent: (html: string, ...args: any[]) => JSX.Element; + }; + openInteractiveDialog: typeof openInteractiveDialog; + useNotifyAdmin: typeof useNotifyAdmin; + WebappUtils: { + modals: { + openModal: typeof openModal; + ModalIdentifiers: typeof ModalIdentifiers; + }; + notificationSounds: { + ring: typeof NotificationSounds.ring; + stopRing: typeof NotificationSounds.stopRing; + }; + sendDesktopNotificationToMe: typeof notifyMe; + openUserSettings: (dialogProps: any) => void; + browserHistory: ReturnType; + }; + openPricingModal: () => typeof openPricingModal; + Components: { + Textbox: typeof Textbox; + Timestamp: typeof Timestamp; + ChannelInviteModal: typeof ChannelInviteModal; + ChannelMembersModal: typeof ChannelMembersModal; + Avatar: typeof Avatar; + imageURLForUser: typeof imageURLForUser; + BotBadge: typeof BotTag; + StartTrialFormModal: typeof StartTrialFormModal; + ThreadViewer: typeof ThreadViewer; + PostMessagePreview: typeof PostMessagePreview; + AdvancedTextEditor: typeof AdvancedTextEditor; + }; + ProductApi: { + useWebSocket: typeof useWebSocket; + useWebSocketClient: typeof useWebSocketClient; + WebSocketProvider: typeof WebSocketContext; + closeRhs: typeof closeRightHandSide; + selectRhsPost: typeof selectPostById; + getRhsSelectedPostId: typeof getSelectedPostId; + getIsRhsOpen: typeof getIsRhsOpen; + }; + DesktopApp: typeof DesktopApp; +} +declare let window: WindowWithLibraries; + // Common libraries exposed on window for plugins to use as Webpack externals. window.React = require('react'); window.ReactDOM = require('react-dom'); @@ -46,7 +103,7 @@ window.StyledComponents = require('styled-components'); // Functions exposed on window for plugins to use. window.PostUtils = { formatText, - messageHtmlToComponent: (html, ...otherArgs) => { + messageHtmlToComponent: (html: string, ...otherArgs: any[]) => { // Previously, this function took an extra isRHS argument as the second parameter. For backwards compatibility, // support calling this as either messageHtmlToComponent(html, options) or messageHtmlToComponent(html, isRhs, options) @@ -63,6 +120,9 @@ window.PostUtils = { window.openInteractiveDialog = openInteractiveDialog; window.useNotifyAdmin = useNotifyAdmin; window.WebappUtils = { + get browserHistory() { + return getHistory(); + }, modals: {openModal, ModalIdentifiers}, notificationSounds: {ring: NotificationSounds.ring, stopRing: NotificationSounds.stopRing}, sendDesktopNotificationToMe: notifyMe, @@ -72,9 +132,6 @@ window.WebappUtils = { dialogProps, }), }; -Object.defineProperty(window.WebappUtils, 'browserHistory', { - get: () => getHistory(), -}); // This need to be a function because `openPricingModal` // is initialized when `UpgradeCloudButton` is loaded. diff --git a/webapp/channels/src/plugins/index.js b/webapp/channels/src/plugins/index.ts similarity index 78% rename from webapp/channels/src/plugins/index.js rename to webapp/channels/src/plugins/index.ts index de28c58601..0a9526db40 100644 --- a/webapp/channels/src/plugins/index.js +++ b/webapp/channels/src/plugins/index.ts @@ -3,6 +3,8 @@ import regeneratorRuntime from 'regenerator-runtime'; +import type {PluginManifest} from '@mattermost/types/plugins'; + import {Client4} from 'mattermost-redux/client'; import {Preferences} from 'mattermost-redux/constants'; import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general'; @@ -18,6 +20,8 @@ import PluginRegistry from 'plugins/registry'; import {ActionTypes} from 'utils/constants'; import {getSiteURL} from 'utils/url'; +import type {GlobalState, ActionFuncAsync} from 'types/store'; + import {removeWebappPlugin} from './actions'; // Including the fullscreen modal css to make it available to the plugins @@ -25,6 +29,26 @@ import {removeWebappPlugin} from './actions'; // have all plugins migrated to common components that can be reused there. import 'components/widgets/modals/full_screen_modal.scss'; +interface Plugin { + initialize?: (registry: PluginRegistry, store: any) => void; + uninitialize?: () => void; + + /** + * @deprecated Define an uninitialize method instead. + */ + deinitialize?: () => void; +} + +interface WindowWithPlugins extends Window { + plugins: { + [key: string]: Plugin; + }; + registerPlugin: (id: string, plugin: Plugin) => void; + regeneratorRuntime: typeof regeneratorRuntime; +} + +declare let window: WindowWithPlugins; + // Plugins may have been compiled with the regenerator runtime. Ensure this remains available // as a global export even though the webapp does not depend on same. window.regeneratorRuntime = regeneratorRuntime; @@ -37,7 +61,7 @@ window.plugins = {}; // // During the beta, plugins manipulated the global window.plugins data structure directly. This // remains possible, but is officially deprecated and may be removed in a future release. -function registerPlugin(id, plugin) { +function registerPlugin(id: string, plugin: Plugin): void { const oldPlugin = window.plugins[id]; if (oldPlugin && oldPlugin.uninitialize) { oldPlugin.uninitialize(); @@ -47,7 +71,7 @@ function registerPlugin(id, plugin) { } window.registerPlugin = registerPlugin; -function arePluginsEnabled(state) { +function arePluginsEnabled(state: GlobalState): boolean { if (getConfig(state).PluginsEnabled !== 'true') { return false; } @@ -63,7 +87,7 @@ function arePluginsEnabled(state) { } // initializePlugins queries the server for all enabled plugins and loads each in turn. -export async function initializePlugins() { +export async function initializePlugins(): Promise { if (!arePluginsEnabled(store.getState())) { return; } @@ -78,8 +102,8 @@ export async function initializePlugins() { return; } - await Promise.all(data.map((m) => { - return loadPlugin(m).catch((loadErr) => { + await Promise.all(data.map((m: PluginManifest) => { + return loadPlugin(m).catch((loadErr: Error) => { console.error(loadErr.message); //eslint-disable-line no-console }); })); @@ -88,7 +112,7 @@ export async function initializePlugins() { } // getPlugins queries the server for all enabled plugins -export function getPlugins() { +export function getPlugins(): ActionFuncAsync { return async (dispatch) => { let plugins; try { @@ -104,16 +128,16 @@ export function getPlugins() { } // loadedPlugins tracks which plugins have been added as script tags to the page -const loadedPlugins = {}; +const loadedPlugins: { [key: string]: PluginManifest } = {}; // describePlugin takes a manifest and spits out a string suitable for console.log messages. -const describePlugin = (manifest) => ( +const describePlugin = (manifest: PluginManifest): string => ( 'plugin ' + manifest.id + ', version ' + manifest.version ); // loadPlugin fetches the web app bundle described by the given manifest, waits for the bundle to // load, and then ensures the plugin has been initialized. -export function loadPlugin(manifest) { +export function loadPlugin(manifest: PluginManifest): Promise { return new Promise((resolve, reject) => { if (!arePluginsEnabled(store.getState())) { return; @@ -121,7 +145,7 @@ export function loadPlugin(manifest) { // Don't load it again if previously loaded const oldManifest = loadedPlugins[manifest.id]; - if (oldManifest && oldManifest.webapp.bundle_path === manifest.webapp.bundle_path) { + if (oldManifest && oldManifest.webapp?.bundle_path === manifest.webapp?.bundle_path) { resolve(); return; } @@ -142,8 +166,8 @@ export function loadPlugin(manifest) { } // Backwards compatibility for old plugins - let bundlePath = manifest.webapp.bundle_path; - if (bundlePath.includes('/static/') && !bundlePath.includes('/static/plugins/')) { + let bundlePath = manifest.webapp?.bundle_path; + if (bundlePath && bundlePath.includes('/static/') && !bundlePath.includes('/static/plugins/')) { bundlePath = bundlePath.replace('/static/', '/static/plugins/'); } @@ -164,7 +188,7 @@ export function loadPlugin(manifest) { // initializePlugin creates a registry specific to the plugin and invokes any initialize function // on the registered plugin class. -function initializePlugin(manifest) { +function initializePlugin(manifest: PluginManifest): void { // Initialize the plugin const plugin = window.plugins[manifest.id]; const registry = new PluginRegistry(manifest.id); @@ -176,7 +200,7 @@ function initializePlugin(manifest) { // removePlugin triggers any uninitialize callback on the registered plugin, unregisters any // event handlers, and removes the plugin script from the DOM entirely. The plugin is responsible // for removing any of its registered components. -export function removePlugin(manifest) { +export function removePlugin(manifest: PluginManifest): void { if (!loadedPlugins[manifest.id]) { return; } @@ -190,7 +214,7 @@ export function removePlugin(manifest) { if (plugin && plugin.uninitialize) { plugin.uninitialize(); - // Support the deprecated deinitialize callback from the plugins beta. + // Support the deprecated deinitialize callback from the plugins beta. } else if (plugin && plugin.deinitialize) { plugin.deinitialize(); } @@ -202,18 +226,18 @@ export function removePlugin(manifest) { if (!script) { return; } - script.parentNode.removeChild(script); + script.parentNode?.removeChild(script); console.log('Removed ' + describePlugin(manifest)); //eslint-disable-line no-console } // loadPluginsIfNecessary synchronizes the current state of loaded plugins with that of the server, // loading any newly added plugins and unloading any removed ones. -export async function loadPluginsIfNecessary() { +export async function loadPluginsIfNecessary(): Promise { if (!arePluginsEnabled(store.getState())) { return; } - const oldManifests = store.getState().plugins.plugins; + const oldManifests = store.getState().plugins.plugins as { [key: string]: PluginManifest }; const {error} = await store.dispatch(getPlugins()); if (error) { @@ -221,20 +245,20 @@ export async function loadPluginsIfNecessary() { return; } - const newManifests = store.getState().plugins.plugins; + const newManifests = store.getState().plugins.plugins as { [key: string]: PluginManifest }; // Get new plugins and update existing plugins if version changed - Object.values(newManifests).forEach((newManifest) => { + Object.values(newManifests).forEach((newManifest: PluginManifest) => { const oldManifest = oldManifests[newManifest.id]; if (!oldManifest || oldManifest.version !== newManifest.version) { - loadPlugin(newManifest).catch((loadErr) => { + loadPlugin(newManifest).catch((loadErr: Error) => { console.error(loadErr.message); //eslint-disable-line no-console }); } }); // Remove old plugins - Object.keys(oldManifests).forEach((id) => { + Object.keys(oldManifests).forEach((id: string) => { if (!Object.hasOwn(newManifests, id)) { const oldManifest = oldManifests[id]; removePlugin(oldManifest); diff --git a/webapp/channels/src/plugins/interactive_dialog.js b/webapp/channels/src/plugins/interactive_dialog.ts similarity index 92% rename from webapp/channels/src/plugins/interactive_dialog.js rename to webapp/channels/src/plugins/interactive_dialog.ts index 5e7276a097..1107644e5f 100644 --- a/webapp/channels/src/plugins/interactive_dialog.js +++ b/webapp/channels/src/plugins/interactive_dialog.ts @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import { - IntegrationTypes, -} from 'mattermost-redux/action_types'; +import {IntegrationTypes} from 'mattermost-redux/action_types'; import {openModal} from 'actions/views/modals'; import store from 'stores/redux_store'; @@ -12,7 +10,7 @@ import InteractiveDialog from 'components/interactive_dialog'; import {ModalIdentifiers} from 'utils/constants'; -export function openInteractiveDialog(dialog) { +export function openInteractiveDialog(dialog: any): void { store.dispatch({type: IntegrationTypes.RECEIVED_DIALOG, data: dialog}); store.dispatch(openModal({modalId: ModalIdentifiers.INTERACTIVE_DIALOG, dialogType: InteractiveDialog})); diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 1878f826c2..a93c1be0f8 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -187,6 +187,7 @@ "@types/react-window": "1.8.5", "@types/react-window-infinite-loader": "1.0.6", "@types/redux-mock-store": "1.0.3", + "@types/regenerator-runtime": "0.13.8", "@types/semver": "7.5.8", "@types/shallow-equals": "1.0.3", "@types/styled-components": "5.1.32", @@ -7147,6 +7148,12 @@ "redux": "^4.0.5" } }, + "node_modules/@types/regenerator-runtime": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/@types/regenerator-runtime/-/regenerator-runtime-0.13.8.tgz", + "integrity": "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==", + "dev": true + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",