Skip to content

Commit

Permalink
chore: Mattermost plugin module federation (#10517)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dschoordsch authored Dec 17, 2024
1 parent ecff092 commit 79fd8a2
Show file tree
Hide file tree
Showing 55 changed files with 3,567 additions and 490 deletions.
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"git-url-parse": "12.0.0",
"fbjs": "^3.0.0",
"parse-url": "^8.1.0",
"recursive-readdir": "^2.2.3"
"recursive-readdir": "^2.2.3",
"axios": "^1.7.8"
},
"devDependencies": {
"@babel/core": "^7.20.12",
Expand Down Expand Up @@ -117,7 +118,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.5.13",
"raw-loader": "^4.0.2",
"relay-compiler": "^18.0.0",
"relay-compiler": "^18.2.0",
"relay-config": "^12.0.1",
"sucrase": "^3.35.0",
"tailwindcss": "^3.2.7",
Expand All @@ -127,12 +128,13 @@
"typescript-eslint": "^8.3.0",
"vscode-apollo-relay": "^1.5.0",
"webpack": "^5.89.0",
"webpack-cli": "4.9.1",
"webpack-cli": "5.1.4",
"workbox-webpack-plugin": "^6.5.4",
"yargs": "^17.7.2",
"yarn-deduplicate": "^3.1.0"
},
"dependencies": {
"@module-federation/enhanced": "^0.7.6",
"dotenv": "8.0.0",
"dotenv-expand": "5.1.0",
"lodash.toarray": "^4.4.0",
Expand Down
1 change: 1 addition & 0 deletions packages/mattermost-plugin/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__generated__/*
97 changes: 97 additions & 0 deletions packages/mattermost-plugin/Atmosphere.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {Variables} from 'react-relay'
import {
Environment,
Network,
Observable,
RecordSource,
RelayFeatureFlags,
RelayFieldLogger,
RequestParameters
} from 'relay-runtime'
import RelayModernStore from 'relay-runtime/lib/store/RelayModernStore'

import {AnyAction, Store} from '@reduxjs/toolkit'
import {Client4} from 'mattermost-redux/client'
import {GlobalState} from 'mattermost-redux/types/store'
RelayFeatureFlags.ENABLE_RELAY_RESOLVERS = true

type State = {
authToken: string | null
serverUrl: string
store: Store<GlobalState, AnyAction>
}

const fetchFunction = (state: State) => (params: RequestParameters, variables: Variables) => {
const {serverUrl, authToken} = state
const response = fetch(
serverUrl,
Client4.getOptions({
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'x-application-authorization': authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
type: 'start',
payload: {
documentId: params.id,
query: params.text,
variables
}
})
})
)

return Observable.from(
response.then(async (data) => {
const json = await data.json()
return json.payload
})
)
}

const relayFieldLogger: RelayFieldLogger = (event) => {
if (event.kind === 'relay_resolver.error') {
console.warn(`Resolver error encountered in ${event.owner}.${event.fieldPath}`)
console.warn(event.error)
}
}

export type ResolverContext = {
serverUrl: string
store: Store<GlobalState, AnyAction>
}

export class Atmosphere extends Environment {
state: State

constructor(serverUrl: string, reduxStore: Store<GlobalState, AnyAction>) {
const state = {
serverUrl: serverUrl + '/graphql',
store: reduxStore,
authToken: null
}

const network = Network.create(fetchFunction(state))
const relayStore = new RelayModernStore(new RecordSource(), {
resolverContext: {
store: reduxStore,
serverUrl
}
})
super({
store: relayStore,
network,
relayFieldLogger
})
this.state = state
}
}

/**
* Creates a new Relay environment instance for managing (fetching, storing) GraphQL data.
*/
export function createEnvironment(serverUrl: string, reduxStore: Store<GlobalState, AnyAction>) {
return new Atmosphere(serverUrl, reduxStore)
}
41 changes: 41 additions & 0 deletions packages/mattermost-plugin/AtmosphereProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {Client4} from 'mattermost-redux/client'
import {ReactNode, useCallback, useEffect} from 'react'
import {useSelector} from 'react-redux'
import {RelayEnvironmentProvider} from 'react-relay'
import {Atmosphere} from './Atmosphere'
import {getPluginServerRoute} from './selectors'

type Props = {
environment: Atmosphere
children: ReactNode
}

export default function AtmosphereProvider({environment, children}: Props) {
const pluginServerRoute = useSelector(getPluginServerRoute)
const serverUrl = `${pluginServerRoute}/login`
const login = useCallback(async () => {
const response = await fetch(
serverUrl,
Client4.getOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
)
const body = await response.json()
environment.state.authToken = body.authToken
}, [serverUrl])

useEffect(() => {
if (!environment.state.authToken) {
login()
}
}, [environment.state.authToken, login])

if (!environment.state.authToken) {
return null
}

return <RelayEnvironmentProvider environment={environment}>{children}</RelayEnvironmentProvider>
}
6 changes: 6 additions & 0 deletions packages/mattermost-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# `Mattermost Plugin`

The Mattermost plugin itself lives in [ParabolInc/parabol-mattermost-plugin](https://github.com/ParabolInc/parabol-mattermost-plugin).
It needs to be installed to the Mattermost server and configured to point to the Parabol instance.
It then loads the code in this package via Webpacks module federation.

116 changes: 116 additions & 0 deletions packages/mattermost-plugin/components/LinkTeamModal/LinkTeamModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import graphql from 'babel-plugin-relay/macro'
import React, {useEffect} from 'react'
import {Modal} from 'react-bootstrap'
import {useDispatch, useSelector} from 'react-redux'

import {useLazyLoadQuery} from 'react-relay'
import {LinkTeamModalQuery} from '../../__generated__/LinkTeamModalQuery.graphql'
import {useConfig} from '../../hooks/useConfig'
import {useCurrentChannel} from '../../hooks/useCurrentChannel'
import {useLinkTeam} from '../../hooks/useLinkTeam'
import {closeLinkTeamModal} from '../../reducers'
import {getAssetsUrl, isLinkTeamModalVisible} from '../../selectors'
import Select from '../Select'

const LinkTeamModal = () => {
const isVisible = useSelector(isLinkTeamModalVisible)
const channel = useCurrentChannel()
const config = useConfig()
const data = useLazyLoadQuery<LinkTeamModalQuery>(
graphql`
query LinkTeamModalQuery($channel: ID!) {
viewer {
linkedTeamIds(channel: $channel)
teams {
id
name
}
}
}
`,
{
channel: channel.id
}
)
const viewer = data.viewer
const unlinkedTeams = viewer.teams.filter((team) => !viewer.linkedTeamIds?.includes(team.id))
const linkTeam = useLinkTeam()

const [selectedTeam, setSelectedTeam] = React.useState<(typeof data.viewer.teams)[number]>()

useEffect(() => {
if (!selectedTeam && unlinkedTeams && unlinkedTeams.length > 0) {
setSelectedTeam(unlinkedTeams[0])
}
}, [unlinkedTeams, selectedTeam])

const dispatch = useDispatch()

const handleClose = () => {
dispatch(closeLinkTeamModal())
}

const handleLink = async () => {
if (!selectedTeam) {
return
}
await linkTeam(selectedTeam.id)
handleClose()
}

const assetsPath = useSelector(getAssetsUrl)

if (!isVisible) {
return null
}

return (
<Modal
dialogClassName='modal--scroll'
show={true}
onHide={handleClose}
onExited={handleClose}
bsSize='large'
backdrop='static'
>
<Modal.Header closeButton={true}>
<Modal.Title>
<img width={36} height={36} src={`${assetsPath}/parabol.png`} />
{` Link a Parabol Team to ${channel.name}`}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{unlinkedTeams && unlinkedTeams.length > 0 ? (
<>
<Select
label='Choose Parabol Team'
required={true}
value={selectedTeam}
options={unlinkedTeams}
onChange={setSelectedTeam}
/>
</>
) : (
<>
<div>
<p>
All your teams are already linked to this channel. Visit{' '}
<a href={`${config?.parabolUrl}/newteam/`}>Parabol</a> to create new teams.
</p>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
<button className='btn btn-tertiary cancel-button' onClick={handleClose}>
Cancel
</button>
<button className='btn btn-primary save-button' onClick={handleLink}>
Link Team
</button>
</Modal.Footer>
</Modal>
)
}

export default LinkTeamModal
21 changes: 21 additions & 0 deletions packages/mattermost-plugin/components/LinkTeamModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {lazy, Suspense} from 'react'
import {useSelector} from 'react-redux'

import {isLinkTeamModalVisible} from '../../selectors'

const LinkTeamModal = lazy(() => import(/* webpackChunkName: 'LinkTeamModal' */ './LinkTeamModal'))

const LinkTeamModalRoot = () => {
const isVisible = useSelector(isLinkTeamModalVisible)
if (!isVisible) {
return null
}

return (
<Suspense fallback={null}>
<LinkTeamModal />
</Suspense>
)
}

export default LinkTeamModalRoot
22 changes: 22 additions & 0 deletions packages/mattermost-plugin/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//import classNames from 'classnames'
import React from 'react'

type Props = {
text?: React.ReactNode
style?: React.CSSProperties
}
const LoadingSpinner = ({text, style}: Props) => {
return (
<span
id='loadingSpinner'
//className={classNames('LoadingSpinner', {'with-text': Boolean(text)})}
style={style}
data-testid='loadingSpinner'
>
<span className='fa fa-spinner fa-fw fa-pulse spinner' />
{text}
</span>
)
}

export default LoadingSpinner
Loading

0 comments on commit 79fd8a2

Please sign in to comment.