diff --git a/.github/workflows/on-release.yaml b/.github/workflows/on-release.yaml index 1a557dfcc6..57632b65b4 100644 --- a/.github/workflows/on-release.yaml +++ b/.github/workflows/on-release.yaml @@ -52,17 +52,6 @@ jobs: #git tag -f -a ${{ github.ref_name }} -m '${{ github.ref_name }}' #git push origin ${{ github.ref_name }} --force - - name: Create Pull Request - uses: peter-evans/create-pull-request@v4 - with: - token: ${{ secrets.PUSH_TO_PROTECTED_BRANCH_PARODOS }} - base: main - commit-message: 'Bump version to ${{ steps.new-version.outputs.new_version }}' - title: 'Bump version to ${{ steps.new-version.outputs.new_version }}' - body: > - This PR is auto-generated - branch: 'release/${{ steps.new-version.outputs.new_version }}' - - name: Install dependencies run: yarn install --frozen-lockfile @@ -87,3 +76,14 @@ jobs: run: | cd plugins/parodos yarn publish --no-git-tag-version + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v4 + with: + token: ${{ secrets.PUSH_TO_PROTECTED_BRANCH_PARODOS }} + base: main + commit-message: 'Bump version to ${{ steps.new-version.outputs.new_version }}' + title: 'Bump version to ${{ steps.new-version.outputs.new_version }}' + body: > + This PR is auto-generated + branch: 'release/${{ steps.new-version.outputs.new_version }}' diff --git a/README.md b/README.md index a18ef9e8f7..e8396df1ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # [Backstage](https://backstage.io) -This is your newly scaffolded Backstage App, Good Luck! - To start the app, run: ```sh @@ -11,14 +9,7 @@ yarn dev ## Local development -For local development set the `PARODOS_AUTH_KEY` environment variable to 'Basic dGVzdDp0ZXN0'. This token is base64 encoded string containing `test:test`. You can also use `PARODOS_AUTH_KEY="Basic dGVzdDp0ZXN0" yarn dev` to start development environment with the test token. You can also create an `app-config.local.yaml` file with the following content to automatically include the token. - -```yaml -proxy: - '/parodos': - headers: - Authorization: 'Basic dGVzdDp0ZXN0' -``` +The Parodos username is `test`, password `test`. ## Distribution diff --git a/app-config.yaml b/app-config.yaml index cb142c447b..ac7929c37a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -58,6 +58,16 @@ proxy: accept: 'application/json' # Authorization: ${PARODOS_AUTH_KEY} + '/parodos-notifications': + target: 'http://localhost:8081/api/v1' + changeOrigin: true + redirect: follow + cache: 'no-cache' + headers: + Content-Type: 'application/json' + accept: 'application/json' + Authorization: ${PARODOS_NOTIFICATION_AUTH_KEY} + # Reference documentation http://backstage.io/docs/features/techdocs/configuration # Note: After experimenting with basic setup, use CI/CD to generate docs # and an external cloud storage when deploying TechDocs for production use-case. diff --git a/packages/app/package.json b/packages/app/package.json index 677efda58d..a80bcf4e40 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -45,7 +45,7 @@ "@backstage/theme": "^0.2.17", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@parodos/plugin-parodos": "0.4.2", + "@parodos/plugin-parodos": "0.4.3", "@parodos/plugin-parodos-auth": "^0.1.0", "history": "^5.0.0", "react": "^17.0.2", diff --git a/plugins/parodos/README.md b/plugins/parodos/README.md index a3d1357a0b..b33a465bf2 100644 --- a/plugins/parodos/README.md +++ b/plugins/parodos/README.md @@ -48,19 +48,20 @@ const routes = ( headers: Content-Type: 'application/json' accept: 'application/json' - Authorization: ${PARODOS_AUTH_KEY} + +'/parodos-notifications': + target: 'http://localhost:8081/api/v1' + changeOrigin: true + redirect: follow + cache: 'no-cache' + headers: + Content-Type: 'application/json' + accept: 'application/json' ``` ## Local development -For local development set the `PARODOS_AUTH_KEY` environment variable to 'Basic dGVzdDp0ZXN0'. This token is base64 encoded string containing `test:test`. You can also use `PARODOS_AUTH_KEY="Basic dGVzdDp0ZXN0" yarn dev` to start development environment with the test token. You can also create an `app-config.local.yaml` file with the following content to automatically include the token. - -```yaml -proxy: - '/parodos': - headers: - Authorization: 'Basic dGVzdDp0ZXN0' -``` +For local development, the application username is `test`, password `test`. ## Release diff --git a/plugins/parodos/src/components/App.tsx b/plugins/parodos/src/components/App.tsx index 1a10f63922..9dc431fbf2 100644 --- a/plugins/parodos/src/components/App.tsx +++ b/plugins/parodos/src/components/App.tsx @@ -17,6 +17,8 @@ export const App = () => { setBaseUrl(backendUrl); async function initialiseStore() { + // We do not pre-fetch notifications, let's do that on demand. + // TODO: fetch unread notificaionts count and keep it updated to render te tip to the user. await Promise.all([fetchProjects(fetch), fetchDefinitions(fetch)]); } diff --git a/plugins/parodos/src/components/Loading.tsx b/plugins/parodos/src/components/Loading.tsx new file mode 100644 index 0000000000..040805d1d2 --- /dev/null +++ b/plugins/parodos/src/components/Loading.tsx @@ -0,0 +1,4 @@ +import { Progress } from '@backstage/core-components'; +import React from 'react'; + +export const Loading: React.FC = () => ; diff --git a/plugins/parodos/src/components/notification/NotificationList.tsx b/plugins/parodos/src/components/notification/NotificationList.tsx index a9f6970604..be4abacdc8 100644 --- a/plugins/parodos/src/components/notification/NotificationList.tsx +++ b/plugins/parodos/src/components/notification/NotificationList.tsx @@ -1,31 +1,69 @@ +import React, { useEffect, useState } from 'react'; import { Accordion, AccordionDetails, AccordionSummary, - Checkbox, + Button, + ButtonGroup, + Chip, Grid, TablePagination, Typography, withStyles, } from '@material-ui/core'; -import { Select, SelectedItems, SelectItem } from '@backstage/core-components'; -import React, { useEffect, useState } from 'react'; -import type { Notification } from './type/Notification'; -import { mockNotifications } from './mock/mockNotifications'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { grey } from '@material-ui/core/colors'; +import { Progress, Select, SelectedItems } from '@backstage/core-components'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +import { NotificationOperation, NotificationState } from '../../stores/types'; +import { useStore } from '../../stores/workflowStore/workflowStore'; +import { getHumanReadableDate } from '../converters'; +import { NotificationContent } from '../../models/notification'; +import { errorApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; + +const ParodosAccordion = withStyles({ + root: { + border: '1px solid', + borderLeftWidth: '0', + borderRightWidth: '0', + borderColor: grey.A100, + background: 'transparent', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 1, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto', + }, + }, + expanded: {}, +})(Accordion); + +const isNotificationArchived = (notification: NotificationContent) => + notification.folder === 'archive'; +const isNotificationRead = (notification: NotificationContent) => + notification.read; + +export const NotificationList: React.FC = () => { + const notifications = useStore(state => state.notifications); + const fetchNotifications = useStore(state => state.fetchNotifications); + const deleteNotification = useStore(state => state.deleteNotification); + const setNotificationState = useStore(state => state.setNotificationState); + const notificationsCount = useStore(state => state.notificationsCount); + const loading = useStore(state => state.notificationsLoading); + const { fetch } = useApi(fetchApiRef); + + const [notificationFilter, setNotificationFilter] = + useState('ALL'); -export const NotificationList = () => { - const [notifications, setNotifications] = useState([]); - const [projectFilterItems, setProjectFilterItems] = useState( - [], - ); - const [filteredNotifications, setFilteredNotifications] = useState< - Notification[] - >([]); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); + const errorApi = useApi(errorApiRef); + const handleChangePage = ( _event: React.MouseEvent | null, newPage: number, @@ -33,6 +71,10 @@ export const NotificationList = () => { setPage(newPage); }; + useEffect(() => { + fetchNotifications({ state: notificationFilter, page, rowsPerPage, fetch }); + }, [fetch, notificationFilter, page, rowsPerPage, fetchNotifications]); + const handleChangeRowsPerPage = ( event: React.ChangeEvent, ) => { @@ -40,109 +82,171 @@ export const NotificationList = () => { setPage(0); }; - const onFilterProjects = (arg: SelectedItems) => { - setFilteredNotifications( - arg === 'All Messages' - ? notifications - : notifications.filter(notification => notification.subject === arg), - ); + const onFilterNotifications = (arg: SelectedItems) => { + setNotificationFilter(arg as NotificationState); + setPage(0); }; - useEffect(() => { - setNotifications(mockNotifications); - setFilteredNotifications(mockNotifications); - setProjectFilterItems([]); - }, []); + const getOnDelete = + ( + notification: NotificationContent, + ): React.MouseEventHandler => + async e => { + e.stopPropagation(); + try { + await deleteNotification({ fetch, id: notification.id }); + await fetchNotifications({ + fetch, + state: notificationFilter, + page, + rowsPerPage, + }); + } catch (_) { + errorApi.post( + new Error( + `Failed to delete notification: ${JSON.stringify(notification)}`, + ), + ); + } + }; - const ParodosAccordion = withStyles({ - root: { - border: '1px solid', - borderLeftWidth: '0', - borderRightWidth: '0', - borderColor: grey.A100, - background: 'transparent', - boxShadow: 'none', - '&:not(:last-child)': { - borderBottom: 1, - }, - '&:before': { - display: 'none', - }, - '&$expanded': { - margin: 'auto', - }, - }, - expanded: {}, - })(Accordion); + const getSetNotificationState = + ( + notification: NotificationContent, + newState: NotificationOperation, + ): React.MouseEventHandler => + async e => { + e.stopPropagation(); + try { + await setNotificationState({ fetch, id: notification.id, newState }); + await fetchNotifications({ + fetch, + state: notificationFilter, + page, + rowsPerPage, + }); + } catch (_) { + errorApi.post( + new Error( + `Failed to set notification to "${newState}": ${JSON.stringify( + notification, + )}`, + ), + ); + } + }; return ( - - - + + + {loading && } + + {(notifications || []).map(notification => ( + + } + aria-controls="panel1bh-content" + id={`panel1bh-header-${notification.id}`} > - - {notification.body} + + + + {notification.subject} + + + + + {getHumanReadableDate(notification.createdOn)} + + + + {(notification.tags || []).map((tag, idx) => ( + + ))} + + + + + + + + - - - - ))} - - - + + + + + {notification.body} + + + {notification.fromuser} + + + {notification.messageType} + + + {notification.folder} + + + + + ))} + + + + - + ); }; diff --git a/plugins/parodos/src/components/notification/mock/mockNotifications.ts b/plugins/parodos/src/components/notification/mock/mockNotifications.ts deleted file mode 100644 index 0c7535f418..0000000000 --- a/plugins/parodos/src/components/notification/mock/mockNotifications.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Notification } from '../type/Notification'; - -export const mockNotifications: Notification[] = [ - { - date: 'Dec 17, 2021', - subject: 'org/repo-name Pipeline creation', - body: 'We are pleased to inform you that a pipeline for org/repo-name has been successfully created! You are now able to view and manage the deployment stages of your application.', - from: 'John Doe', - isRead: false, - }, - { - date: 'Jan 24, 2022', - subject: 'Lorem ipsum dolor sit amet', - body: 'Lorem ipsum dolor sit amet', - from: 'Paul Dubois', - isRead: false, - }, - { - date: 'Feb 4, 2023', - subject: 'Excepteur sint occaecat cupidatat non proident', - body: 'Excepteur sint occaecat cupidatat non proident', - from: 'Alice Adele', - isRead: false, - }, -]; diff --git a/plugins/parodos/src/components/projectOverview/ProjectOverview.tsx b/plugins/parodos/src/components/projectOverview/ProjectOverview.tsx index b542f3389d..960bb3838b 100644 --- a/plugins/parodos/src/components/projectOverview/ProjectOverview.tsx +++ b/plugins/parodos/src/components/projectOverview/ProjectOverview.tsx @@ -13,6 +13,7 @@ import { ParodosPage } from '../ParodosPage'; import { ProjectsTable } from './ProjectsTable'; import { useStore } from '../../stores/workflowStore/workflowStore'; import { ProjectStatus } from '../../models/project'; +import { Loading } from '../Loading'; type ProjectFilters = ProjectStatus | 'all-projects'; @@ -64,7 +65,7 @@ export const ProjectOverviewPage = (): JSX.Element => { let content: ReactElement | null = null; if (loading) { - content =
Loading...
; + content = ; } else if (allProjects.length > 0) { content = ( diff --git a/plugins/parodos/src/models/notification.test.ts b/plugins/parodos/src/models/notification.test.ts new file mode 100644 index 0000000000..6a361f626c --- /dev/null +++ b/plugins/parodos/src/models/notification.test.ts @@ -0,0 +1,71 @@ +import { notificationsSchema } from './notification'; + +describe('notification', () => { + it('parses the notification response', () => { + // Following data are based on swagger and not recent API response (TODO: https://issues.redhat.com/browse/FLPATH-260) + const result = notificationsSchema.safeParse({ + links: [ + { + rel: 'myrel', + href: 'http://link.to/something', + hreflang: 'en', + media: 'text/html', + title: 'My title', + type: 'my-type', + deprecation: 'no', + profile: 'my profile', + name: 'My name', + }, + ], + content: [ + { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + subject: 'My subject', + createdOn: '2023-03-24T08:16:53.048Z', + messageType: 'message', + body: 'COntent of the message', + fromuser: 'my-user', + read: false, + tags: ['tag1', 'tag2'], + folder: 'my-folder', + links: [ + { + rel: 'myrel2', + href: 'http://link.to/something/foo', + hreflang: 'cz', + media: 'text/html', + title: 'My next title', + type: 'my-type', + deprecation: 'no', + profile: 'my profile', + name: 'My next name', + }, + ], + }, + ], + page: { + size: 1, + totalElements: 1, + totalPages: 1, + number: 0, + }, + }); + + expect(result.success).toBe(true); + }); + + it('parses empty notification response', () => { + const result = notificationsSchema.safeParse({ + links: [], + content: [], + page: { + size: 0, + totalElements: 0, + totalPages: 0, + number: 0, + }, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/plugins/parodos/src/models/notification.ts b/plugins/parodos/src/models/notification.ts new file mode 100644 index 0000000000..ade8624391 --- /dev/null +++ b/plugins/parodos/src/models/notification.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const notificationLinkSchema = z.object({ + name: z.string(), + title: z.string(), + href: z.string(), + rel: z.string(), + hreflang: z.string(), + media: z.string(), + type: z.string(), + deprecation: z.string(), + profile: z.string(), +}); + +export const notificationContentSchema = z.object({ + id: z.string(), + subject: z.string(), + body: z.string(), + fromuser: z.string(), + read: z.boolean(), + createdOn: z.coerce.date(), + messageType: z.string(), + tags: z.array(z.string()), + folder: z.string(), +}); + +export const notificationsSchema = z.object({ + content: z.array(notificationContentSchema), + page: z.object({ + size: z.number(), + totalElements: z.number(), + totalPages: z.number(), + number: z.number(), + }), +}); + +export type NotificationLink = z.infer; +export type NotificationContent = z.infer; +export type Notifications = z.infer; diff --git a/plugins/parodos/src/stores/slices/notificationsSlice.ts b/plugins/parodos/src/stores/slices/notificationsSlice.ts new file mode 100644 index 0000000000..007ca8baf5 --- /dev/null +++ b/plugins/parodos/src/stores/slices/notificationsSlice.ts @@ -0,0 +1,85 @@ +import type { StateCreator } from 'zustand'; +import { unstable_batchedUpdates } from 'react-dom'; +import type { NotificationsSlice, State, StateMiddleware } from '../types'; +import { Notifications } from '../../models/notification'; +import * as urls from '../../urls'; + +export const createNotificationsSlice: StateCreator< + State, + StateMiddleware, + [], + NotificationsSlice +> = (set, get) => ({ + notificationsLoading: true, + notificationsError: undefined, + notifications: [], + notificationsCount: 0, + async fetchNotifications({ state: stateParam, page, rowsPerPage, fetch }) { + set(state => { + state.notificationsLoading = true; + }); + + try { + // TODO: we can leverage searchTerm param later + let urlQuery = `?page=${page}&size=${rowsPerPage}&sort=NotificationMessage_createdOn,desc`; + if (stateParam && stateParam !== 'ALL') { + urlQuery += `&state=${stateParam}`; + } + + const response = await fetch( + `${get().baseUrl}${urls.Notifications}${urlQuery}`, + ); + + const notifications = (await response.json()) || ({} as Notifications); + + const totalElements = notifications.page?.totalElements || 0; + set(state => { + unstable_batchedUpdates(() => { + state.notifications = + notifications.content || + /* Hack: response does not conform swagger, TODO: https://issues.redhat.com/browse/FLPATH-260 */ + notifications?._embedded?.notificationrecords || + []; + state.notificationsLoading = false; + state.notificationsCount = totalElements; + }); + }); + } catch (e: unknown) { + // eslint-disable-next-line no-console + console.error('Error fetching notifications', e); + set(state => { + state.notifications = []; + state.notificationsError = e as Error; + }); + } + }, + async deleteNotification({ id, fetch }) { + try { + await fetch(`${get().baseUrl}${urls.Notifications}/${id}`, { + method: 'DELETE', + }); + } catch (e: unknown) { + set(state => { + // eslint-disable-next-line no-console + console.error('Error fetching notifications', e); + state.notificationsError = e as Error; + }); + } + }, + async setNotificationState({ id, newState, fetch }) { + try { + await fetch( + `${get().baseUrl}${urls.Notifications}/${id}?operation=${newState}`, + { + method: 'PUT', + }, + ); + } catch (e: unknown) { + // eslint-disable-next-line no-console + console.error('Error setting notification "', id, '" to: ', newState, e); + set(state => { + state.notificationsError = e as Error; + }); + } + }, +}); diff --git a/plugins/parodos/src/stores/types.ts b/plugins/parodos/src/stores/types.ts index b6313fd45e..f6ceef8ae9 100644 --- a/plugins/parodos/src/stores/types.ts +++ b/plugins/parodos/src/stores/types.ts @@ -1,6 +1,7 @@ import { FetchApi } from '@backstage/core-plugin-api'; import type { Project } from '../models/project'; import type { WorkflowDefinition } from '../models/workflowDefinitionSchema'; +import type { NotificationContent } from '../models/notification'; export interface UISlice { baseUrl: string | undefined; @@ -38,9 +39,36 @@ export interface ProjectsSlice { projectsError: Error | undefined; } +export type NotificationState = 'ALL' | 'UNREAD' | 'ARCHIVED'; +export type NotificationOperation = 'READ' | 'ARCHIVE'; +export interface NotificationsSlice { + notifications: NotificationContent[]; + notificationsCount: number; + fetchNotifications(params: { + fetch: FetchApi['fetch']; + state: NotificationState; + page: number; + rowsPerPage: number; + }): Promise; + deleteNotification(params: { + fetch: FetchApi['fetch']; + id: string; + }): Promise; + setNotificationState(params: { + fetch: FetchApi['fetch']; + id: string; + newState: NotificationOperation; + }): Promise; + notificationsLoading: boolean; + notificationsError: Error | undefined; +} + export type StateMiddleware = [ ['zustand/immer', never], ['zustand/devtools', never], ]; -export type State = UISlice & WorkflowSlice & ProjectsSlice; +export type State = UISlice & + WorkflowSlice & + ProjectsSlice & + NotificationsSlice; diff --git a/plugins/parodos/src/stores/workflowStore/workflowStore.ts b/plugins/parodos/src/stores/workflowStore/workflowStore.ts index 5f9046b831..5ba1ed760e 100644 --- a/plugins/parodos/src/stores/workflowStore/workflowStore.ts +++ b/plugins/parodos/src/stores/workflowStore/workflowStore.ts @@ -5,6 +5,7 @@ import { createProjectsSlice } from '../slices/projectsSlice'; import { State } from '../types'; import { createUISlice } from '../slices/uiSlice'; import { createWorkflowSlice } from '../slices/workflowSlice'; +import { createNotificationsSlice } from '../slices/notificationsSlice'; export const useStore = create()( devtools( @@ -12,6 +13,7 @@ export const useStore = create()( ...createUISlice(...args), ...createProjectsSlice(...args), ...createWorkflowSlice(...args), + ...createNotificationsSlice(...args), })), ), ); diff --git a/plugins/parodos/src/urls.ts b/plugins/parodos/src/urls.ts index 11bd3cd942..a55645e294 100644 --- a/plugins/parodos/src/urls.ts +++ b/plugins/parodos/src/urls.ts @@ -1,4 +1,10 @@ +// Backstage proxy prefix, see app-config.yaml const base = '/api/proxy/parodos'; + +// The Workflow service export const Workflows = `${base}/workflows`; export const Projects = `${base}/projects`; export const WorkflowDefinitions = `${base}/workflowdefinitions`; + +// The Notification service +export const Notifications = `${base}-notifications/notifications`;