Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Global notifications #970

Merged
merged 5 commits into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions database/022-create-maintenance-tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ) <[email protected]>
-- SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-- SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
-- SPDX-FileCopyrightText: 2023 Netherlands eScience Center
--
-- SPDX-License-Identifier: Apache-2.0

CREATE TABLE global_announcement (
-- trick to only have one row in this table:
id BOOLEAN DEFAULT TRUE PRIMARY KEY CHECK (id),
text VARCHAR(2000),
enabled BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE FUNCTION sanitise_insert_global_announcement () RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
NEW.id = TRUE;
NEW.created_at = LOCALTIMESTAMP;
NEW.updated_at = NEW.created_at;
return NEW;
END
$$;

CREATE TRIGGER sanitise_insert_global_announcement BEFORE INSERT ON global_announcement FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_global_announcement();

CREATE FUNCTION sanitise_update_global_announcement() RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
NEW.id = OLD.id;
NEW.created_at = OLD.created_at;
NEW.updated_at = LOCALTIMESTAMP;
return NEW;
END
$$;

CREATE TRIGGER sanitise_update_global_announcement BEFORE UPDATE ON global_announcement FOR EACH ROW EXECUTE PROCEDURE sanitise_update_global_announcement();

ALTER TABLE global_announcement ENABLE ROW LEVEL SECURITY;

CREATE POLICY anyone_can_read ON global_announcement FOR SELECT TO rsd_web_anon, rsd_user USING (TRUE);

CREATE POLICY admin_all_rights ON global_announcement TO rsd_admin USING (TRUE) WITH CHECK (TRUE);

INSERT INTO global_announcement (id) VALUES (TRUE)
35 changes: 35 additions & 0 deletions frontend/components/Announcement/Announcement.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

import {fireEvent, render, screen} from '@testing-library/react'

import Announcement from './Announcement'

const mockAnnoucement = 'Test annoucement'

it('renders announcement component with text', () => {
render(
<Announcement announcement={mockAnnoucement} />
)
// has text
screen.getByText(mockAnnoucement)
// has button
screen.getByRole('button')
})

it('hide announcement when close button is used', () => {
render(
<Announcement announcement={mockAnnoucement} />
)
// has text
const announcement = screen.getByText(mockAnnoucement)
// has button
const closeBtn = screen.getByRole('button')
// close
fireEvent.click(closeBtn)
// verify is hidden
expect(announcement).not.toBeInTheDocument()
// screen.debug()
})
36 changes: 36 additions & 0 deletions frontend/components/Announcement/Announcement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ) <[email protected]>
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

import {useState} from 'react'
import IconButton from '@mui/material/IconButton'
import CancelIcon from '@mui/icons-material/Cancel'
import ErrorIcon from '@mui/icons-material/Error'

export default function Announcement({announcement}: {announcement: string | null}) {
const [open, setOpen] = useState(true)

// do not show if no content or close icon is clicked
if (typeof(announcement) == 'undefined' || announcement === null || open===false) return null

return (
<div
className="flex justify-center items-center fixed bottom-0 right-0 w-full bg-warning text-warning-content text-xl px-4"
>
<ErrorIcon/>
<span className='flex-1 py-8 ml-2'>{announcement}</span>
<IconButton
size='large'
onClick={() => {setOpen(false)}}
sx={{
color: 'warning.contrastText'
}}
>
<CancelIcon />
</IconButton>
</div>
)
}
9 changes: 9 additions & 0 deletions frontend/components/admin/AdminNav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ) <[email protected]>
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
Expand All @@ -19,6 +21,7 @@ import SpellcheckIcon from '@mui/icons-material/Spellcheck'
import DomainAddIcon from '@mui/icons-material/DomainAdd'
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import FluorescentIcon from '@mui/icons-material/Fluorescent'
import CampaignIcon from '@mui/icons-material/Campaign'

export const adminPages = {
pages:{
Expand Down Expand Up @@ -63,6 +66,12 @@ export const adminPages = {
icon: <SpellcheckIcon />,
path: '/admin/keywords',
},
announcements: {
title: 'Announcement',
subtitle: 'Notification to all users',
icon: <CampaignIcon />,
path: '/admin/announcements',
}
}

// extract page types from the object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

import {fireEvent, render, screen, waitFor, waitForElementToBeRemoved} from '@testing-library/react'
import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'

import AdminAnnoucementsPage from '~/components/admin/announcements/index'
import {Session} from '~/auth'

// MOCKS
import {mockAnnoucement} from './__mocks__/apiAnnouncement'
// api mock
jest.mock('~/components/admin/announcements/apiAnnouncement')

const testSession = {
...mockSession,
user: {
...mockSession.user,
role: 'rsd_admin'
}
} as Session


describe('components/admin/announcements/index.tsx', () => {

beforeEach(() => {
jest.resetAllMocks()
})

it('shows progressbar initialy', () => {
render(
<WithAppContext options={{session: testSession}}>
<AdminAnnoucementsPage />
</WithAppContext>
)
screen.getByRole('progressbar')
// screen.debug()
})


it('shows announcement returned from api', async() => {
render(
<WithAppContext options={{session: testSession}}>
<AdminAnnoucementsPage />
</WithAppContext>
)
// wait for loader to be removed
await waitForElementToBeRemoved(screen.getByRole('progressbar'))

// get switch
const visible = screen.getByRole('checkbox')
// validate is ON
expect(visible).toBeChecked()
// get text
const announcement = screen.getByRole<HTMLInputElement>('textbox')
// validate text returmed from mocked api
expect(announcement.value).toEqual(mockAnnoucement.text)

const saveBtn = screen.getByRole('button', {name: 'Save'})
expect(saveBtn).toBeDisabled()
// screen.debug()
})

it('can turn off announcement', async () => {

render(
<WithAppContext options={{session: testSession}}>
<AdminAnnoucementsPage />
</WithAppContext>
)
// wait for loader to be removed
await waitForElementToBeRemoved(screen.getByRole('progressbar'))

// get switch
const visible = screen.getByRole('checkbox')
// validate is ON
expect(visible).toBeChecked()

// initially save button is disabled
const saveBtn = screen.getByRole('button', {name: 'Save'})
expect(saveBtn).toBeDisabled()

// uncheck visible switch
fireEvent.click(visible)

await waitFor(() => {
// save button should be enabled
expect(saveBtn).toBeEnabled()
// click on save button
fireEvent.click(saveBtn)
})
})
})
101 changes: 101 additions & 0 deletions frontend/components/admin/announcements/AnnouncementsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ) <[email protected]>
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0

import {useForm} from 'react-hook-form'
import {useSession} from '~/auth'
import useSnackbar from '~/components/snackbar/useSnackbar'
import SubmitButtonWithListener from '~/components/form/SubmitButtonWithListener'
import ControlledSwitch from '~/components/form/ControlledSwitch'
import ControlledTextField from '~/components/form/ControlledTextField'
import {AnnouncementItem, saveAnnouncement} from './apiAnnouncement'

const formId = 'announcements-form'

export default function AnnouncementsForm({data}: { data: AnnouncementItem|null }) {
const {token} = useSession()
const {showErrorMessage} = useSnackbar()
const {handleSubmit, register, control, reset, formState} = useForm<AnnouncementItem>({
defaultValues: {
...data
},
mode: 'onChange'
})

// track form state
const {isValid, isDirty} = formState

async function onSubmit(item: AnnouncementItem) {
const resp = await saveAnnouncement({
id: item.id,
enabled: item.enabled,
text: item.text
}, token)

if (resp.status === 200) {
// use values returned from api
const update = {
id: resp.message?.id ?? true,
enabled: resp.message.enabled ?? false,
text: resp.message.text ?? null
}
// will reset form state
reset(update)
} else {
showErrorMessage(`Failed to save announcement. ${resp.message}`)
}
}

function isSaveDisabled() {
if (isValid === false) return true
if (isDirty === false) return true
return false
}

return (
<form
id={formId}
onSubmit={handleSubmit(onSubmit)}
className="flex-1"
>
{/* id */}
<input type="hidden" {...register('id')} />
{/* active/visible */}
<ControlledSwitch
label='Visible'
name='enabled'
control={control}
/>
<div className="flex justify-between items-center gap-8 py-4">
<ControlledTextField
control={control}
options={{
name: 'text',
label: 'Announcement',
multiline: true
}}
rules={{
required: 'Announcement text is required',
minLength: {
value: 3,
message: 'Minimum length is 3.'
},
maxLength: {
value: 2000,
message: 'Maximum length is 2000.'
}
}}
/>
<SubmitButtonWithListener
disabled={isSaveDisabled()}
formId={formId}
/>
</div>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

export type NewAnnouncement = {
enabled: boolean
text: string | null
}

export type AnnouncementItem = NewAnnouncement & {
id: string
}

export const mockAnnoucement = {
id: 'test-uuid-announcement',
enabled: true,
text: 'Test annoucement text'
}

export async function getAnnouncement(token?: string) {
// console.log('getAnnouncement...default MOCK...')
return mockAnnoucement
}


export async function saveAnnouncement(item: AnnouncementItem, token: string) {
// console.log('saveAnnouncement...default MOCK...')
return jest.fn(()=>({
status: 200,
message: mockAnnoucement
}))
}
Loading