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 (
-
-
-
-
-
- {filteredNotifications.map((notification, i) => (
-
- }
- aria-controls="panel1bh-content"
- id="panel1bh-header"
- >
-
-
-
- // NotificationListItemUtils.handleSelectNotification({
- // notificationIsSelected,
- // notificationsContext,
- // notification,
- // })
- // }
- />
-
-
-
- {notification.subject}
-
-
-
- {notification.date}
-
-
-
-
-
+
+
+
+
+
+ {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`;