diff --git a/ui/src/redux/main.ts b/ui/src/redux/main.ts index a703ca3d..004dadbf 100644 --- a/ui/src/redux/main.ts +++ b/ui/src/redux/main.ts @@ -39,12 +39,6 @@ const initialState: MainState = { reviews: [], } -const runningDeploymentStatus: DeploymentStatusEnum[] = [ - DeploymentStatusEnum.Waiting, - DeploymentStatusEnum.Created, - DeploymentStatusEnum.Running, -] - export const apiMiddleware: Middleware = (api: MiddlewareAPI) => ( next ) => (action) => { @@ -81,11 +75,18 @@ export const init = createAsyncThunk } ) +/** + * Search all processing deployments that the user can access. + */ export const searchDeployments = createAsyncThunk( "main/searchDeployments", async (_, { rejectWithValue }) => { try { - const deployments = await _searchDeployments(runningDeploymentStatus, false) + const deployments = await _searchDeployments([ + DeploymentStatusEnum.Waiting, + DeploymentStatusEnum.Created, + DeploymentStatusEnum.Running, + ], false) return deployments } catch (e) { return rejectWithValue(e) @@ -93,6 +94,9 @@ export const searchDeployments = createAsyncThunk( "main/searchReviews", async (_, { rejectWithValue }) => { @@ -117,6 +121,87 @@ export const fetchLicense = createAsyncThunk { + if (!("Notification" in window)) { + console.log("This browser doesn't support the notification.") + return + } + + if (Notification.permission === "default") { + Notification.requestPermission() + } + + new Notification(title, options) +} + +/** + * The browser notifies only the user who triggers the deployment. + */ +export const notifyDeploymentEvent = createAsyncThunk( + "main/notifyDeploymentEvent", + async (event, { getState }) => { + const { user } = getState().main + + if (event.kind !== EventKindEnum.Deployment) { + return + } + + if (event.deployment?.deployer?.id !== user?.id) { + return + } + + if (event.type === EventTypeEnum.Created) { + notify(`New Deployment #${event.deployment?.number}`, { + icon: "/logo192.png", + body: `Start to deploy ${event.deployment?.ref.substring(0, 7)} to the ${event.deployment?.env} environment of ${event.deployment?.repo?.namespace}/${event.deployment?.repo?.name}.`, + tag: String(event.id), + }) + return + } + + notify(`Deployment Updated #${event.deployment?.number}`, { + icon: "/logo192.png", + body: `The deployment ${event.deployment?.number} of ${event.deployment?.repo?.namespace}/${event.deployment?.repo?.name} is updated ${event.deployment?.status}.`, + tag: String(event.id), + }) + } +) + +/** + * The browser notifies the requester when the review is responded to, + * but it should notify the reviewer when the review is requested. + */ +export const notifyReviewmentEvent = createAsyncThunk( + "main/notifyReviewmentEvent", + async (event, { getState }) => { + const { user } = getState().main + if (event.kind !== EventKindEnum.Review) { + return + } + + if (event.type === EventTypeEnum.Created + && event.review?.user?.id === user?.id) { + notify(`Review Requested`, { + icon: "/logo192.png", + body: `${event.review?.deployment?.deployer?.login} requested the review for the deployment ${event.review?.deployment?.number} of ${event.review?.deployment?.repo?.namespace}/${event.review?.deployment?.repo?.name}`, + tag: String(event.id), + }) + return + } + + if (event.type === EventTypeEnum.Updated + && event.review?.deployment?.deployer?.id === user?.id) { + notify(`Review Responded`, { + icon: "/logo192.png", + body: `${event.review?.user?.login} ${event.review?.status} the deployment ${event.review?.deployment?.number} of ${event.review?.deployment?.repo?.namespace}/${event.review?.deployment?.repo?.name}`, + tag: String(event.id), + }) + return + } + } +) + + export const mainSlice = createSlice({ name: "main", initialState, @@ -130,26 +215,31 @@ export const mainSlice = createSlice({ setExpired: (state, action: PayloadAction) => { state.expired = action.payload }, + /** + * Handle all deployment events that the user can access. + * Note that some deployments are triggered by others. + */ handleDeploymentEvent: (state, action: PayloadAction) => { - const user = state.user - if (!user) { - throw new Error("Unauthorized user.") - } - const event = action.payload + if (event.kind !== EventKindEnum.Deployment) { + return + } - // Handling the event when the owner is same. - if (event.deployment?.deployer?.id !== user.id) { + if (event.type === EventTypeEnum.Created + && event.deployment) { + state.deployments.unshift(event.deployment) return } + // Update the deployment if it exist. const idx = state.deployments.findIndex((deployment) => { return event.deployment?.id === deployment.id }) if (idx !== -1 ) { - // Remove from the list when the status is not one of 'waiting', 'created', and 'running'. - if (!runningDeploymentStatus.includes(event.deployment.status)) { + if (!(event.deployment?.status === DeploymentStatusEnum.Waiting + || event.deployment?.status === DeploymentStatusEnum.Created + || event.deployment?.status === DeploymentStatusEnum.Running)) { state.deployments.splice(idx, 1) return } @@ -157,27 +247,16 @@ export const mainSlice = createSlice({ state.deployments[idx] = event.deployment return } - - state.deployments.unshift(event.deployment) }, handleReviewEvent: (state, action: PayloadAction) => { const event = action.payload - if (action.payload.kind !== EventKindEnum.Review) { return - } + } - const user = state.user - if (!user) { - throw new Error("Unauthorized user.") - } - - // Handling the event when the user own the event. - if (event.review?.user?.id !== user.id) { - return - } - - if (event.type === EventTypeEnum.Created) { + if (event.type === EventTypeEnum.Created + && event.review + && event.review?.user?.id === state.user?.id) { state.reviews.unshift(event.review) return } diff --git a/ui/src/views/Main.tsx b/ui/src/views/Main.tsx index f2d40439..84d8a846 100644 --- a/ui/src/views/Main.tsx +++ b/ui/src/views/Main.tsx @@ -5,7 +5,7 @@ import { SettingFilled } from "@ant-design/icons" import { Helmet } from "react-helmet" import { useAppSelector, useAppDispatch } from "../redux/hooks" -import { init, searchDeployments, searchReviews, fetchLicense, mainSlice as slice } from "../redux/main" +import { init, searchDeployments, searchReviews, fetchLicense, notifyDeploymentEvent, notifyReviewmentEvent, mainSlice as slice } from "../redux/main" import { subscribeEvents } from "../apis" import RecentActivities from "../components/RecentActivities" @@ -37,6 +37,8 @@ export default function Main(props: any) { const sub = subscribeEvents((event) => { dispatch(slice.actions.handleDeploymentEvent(event)) dispatch(slice.actions.handleReviewEvent(event)) + dispatch(notifyDeploymentEvent(event)) + dispatch(notifyReviewmentEvent(event)) }) return () => {