Skip to content

Commit

Permalink
Merge pull request #150 from Tampere/feature/admin-instructions
Browse files Browse the repository at this point in the history
Feature/admin instructions
  • Loading branch information
mmoila committed Aug 11, 2023
2 parents b269165 + c6febd6 commit 0dadca0
Show file tree
Hide file tree
Showing 17 changed files with 415 additions and 43 deletions.
24 changes: 12 additions & 12 deletions client/src/components/LanguageMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,19 @@ const useStyles = makeStyles({
},
});

export default function LanguageMenu({
style,
}: Props) {
const { tr, setLanguage, languages, language } =
useTranslations();
export default function LanguageMenu({ style }: Props) {
const { tr, setLanguage, languages, language } = useTranslations();
const classes = useStyles();

return (
<div className={classes.root} style={style}>
<Tooltip
arrow
placement='left'
placement="left-end"
title={tr.LanguageMenu.changeLanguage}
>
<Select
inputProps={{"aria-label": tr.LanguageMenu.languageControl}}
inputProps={{ 'aria-label': tr.LanguageMenu.languageControl }}
value={language}
onChange={(event) => {
const targetLanguage = event.target.value as LanguageCode;
Expand All @@ -42,17 +39,20 @@ export default function LanguageMenu({
IconComponent={LanguageIcon}
sx={{
color: 'inherit',
'&>.MuiSelect-select': { // Accommodate the larger globe icon
'&>.MuiSelect-select': {
// Accommodate the larger globe icon
paddingRight: '38px !important',
},
'&>fieldset': { // Visual label not used, hide border and legend
'&>fieldset': {
// Visual label not used, hide border and legend
borderWidth: 0,
'&>legend': {display: 'none'},
'&>legend': { display: 'none' },
},
'& svg': { // The component is used in admin panel and survey, must adapt
'& svg': {
// The component is used in admin panel and survey, must adapt
color: 'inherit',
fill: 'currentColor',
}
},
}}
>
{languages.map((lang, index) => (
Expand Down
16 changes: 9 additions & 7 deletions client/src/components/SurveyLanguageMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ export default function SurveyLanguageMenu({
<div className={classes.root} style={style}>
<Tooltip
arrow
placement='left'
placement="left-end"
title={tr.SurveyLanguageMenu.changeSurveyLanguage}
>
<Select
inputProps={{ "aria-label": tr.SurveyLanguageMenu.languageControl }}
size='small'
inputProps={{ 'aria-label': tr.SurveyLanguageMenu.languageControl }}
size="small"
value={surveyLanguage}
onChange={(event) => {
const targetLanguage = event.target.value as LanguageCode;
Expand All @@ -46,16 +46,18 @@ export default function SurveyLanguageMenu({
IconComponent={LanguageIcon}
sx={{
color: 'inherit',
'&>.MuiSelect-select': { // Accommodate the larger globe icon
'&>.MuiSelect-select': {
// Accommodate the larger globe icon
paddingRight: '38px !important',
},
'&>fieldset': {
'&>fieldset': {
display: 'none',
},
'& svg': { // The component is used in admin panel and survey, must adapt
'& svg': {
// The component is used in admin panel and survey, must adapt
color: 'inherit',
fill: 'currentColor',
}
},
}}
>
{languages.map((lang, index) => (
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/admin/AdminFrontPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from '@src/stores/TranslationContext';
import AppBarUserMenu from './AppBarUserMenu';
import LanguageMenu from '../LanguageMenu';
import SurveyLanguageMenu from '../SurveyLanguageMenu';
import { AdminInstructionButton } from './AdminInstructionButton';

export default function AdminFrontPage() {
const { tr } = useTranslations();
Expand All @@ -24,6 +25,7 @@ export default function AdminFrontPage() {
>
<SurveyLanguageMenu />
<LanguageMenu />
<AdminInstructionButton />
<AppBarUserMenu />
</div>
</Toolbar>
Expand Down
16 changes: 16 additions & 0 deletions client/src/components/admin/AdminInstructionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import { IconButton, Tooltip } from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import { useTranslations } from '@src/stores/TranslationContext';

export function AdminInstructionButton() {
const { tr } = useTranslations();

return (
<Tooltip arrow title={tr.adminInstructions}>
<IconButton href="/api/file/instructions" target="_blank">
<InfoIcon sx={{ color: 'white' }} />
</IconButton>
</Tooltip>
);
}
48 changes: 31 additions & 17 deletions client/src/components/admin/AppBarUserMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { IconButton, Menu, MenuItem } from '@mui/material';
import { AccountCircle } from '@mui/icons-material';
import { IconButton, Menu, MenuItem, Tooltip } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import { makeStyles } from '@mui/styles';
import { useTranslations } from '@src/stores/TranslationContext';
import { InstructionsDialog } from './InstructionsDialog';

const useStyles = makeStyles({
root: {
Expand All @@ -14,35 +15,40 @@ const useStyles = makeStyles({
export default function AppBarUserMenu() {
const [menuOpen, setMenuOpen] = useState(false);
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement>(null);
const [instructionsDialogOpen, setInstructionsDialogOpen] = useState(false);

const classes = useStyles();
const { tr } = useTranslations();

return (
<div className={classes.root}>
<IconButton
aria-label={tr.AppBarUserMenu.label}
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={(event) => {
setMenuOpen(!menuOpen);
setMenuAnchorEl(event.currentTarget);
}}
color="inherit"
>
<AccountCircle />
</IconButton>
<Tooltip arrow title={tr.AppBarUserMenu.label}>
<IconButton
aria-label={tr.AppBarUserMenu.label}
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={(event) => {
setMenuOpen(!menuOpen);
setMenuAnchorEl(event.currentTarget);
}}
color="inherit"
>
<SettingsIcon />
</IconButton>
</Tooltip>

<Menu
sx={{ padding: '4px', transform: 'translateX(15px)' }}
id="menu-appbar"
anchorEl={menuAnchorEl}
anchorOrigin={{
vertical: 'top',
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
vertical: 0,
horizontal: 0,
}}
open={menuOpen}
onClose={() => {
Expand All @@ -57,7 +63,15 @@ export default function AppBarUserMenu() {
>
{tr.AppBarUserMenu.logout}
</MenuItem>
<MenuItem onClick={() => setInstructionsDialogOpen(true)}>
{tr.AppBarUserMenu.updateInstructions}
</MenuItem>
</Menu>
<InstructionsDialog
isOpen={instructionsDialogOpen}
setIsOpen={setInstructionsDialogOpen}
setMenuOpen={setMenuOpen}
/>
</div>
);
}
175 changes: 175 additions & 0 deletions client/src/components/admin/InstructionsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Button,
Typography,
} from '@mui/material';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { useToasts } from '@src/stores/ToastContext';
import DropZone from '../DropZone';
import { useTranslations } from '@src/stores/TranslationContext';
import {
getInstructionFilename,
storeAdminInstructions,
} from '@src/controllers/AdminFileController';

interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const MEGAS = 10;
const MAX_FILE_SIZE = MEGAS * 1000 * 1000; // ten megabytes

export function InstructionsDialog({ isOpen, setIsOpen, setMenuOpen }: Props) {
const { showToast } = useToasts();
const { tr } = useTranslations();
const [instructionFileName, setInstructionFileName] = useState(null);
const [stagedFile, setStagedFile] = useState<File>(null);

useEffect(() => {
if (!isOpen) {
return;
}
setStagedFile(null);
setInstructionFileName(null);

async function setFileName() {
try {
const filename = await getInstructionFilename();
setInstructionFileName(filename);
} catch (error) {
showToast({
severity: 'error',
message: tr.InstructionsDialog.fetchingError,
});
}
}
setFileName();
}, [isOpen]);

const allowedFilesRegex = /^data:(application)\/(pdf);base64,/;

function readFileAsync(file: File) {
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};

reader.onerror = (error) => {
reject(error);
};
});
}

return (
<Dialog open={isOpen}>
<DialogTitle>{tr.InstructionsDialog.uploadNewInstructions}</DialogTitle>
<DialogContent>
<DropZone
maxFiles={1}
fileCallback={async (files: File[]) => {
try {
const filesSize = files
.map((file) => file.size)
.reduce(
(prevValue, currentValue) => prevValue + currentValue,
0
);
if (filesSize > MAX_FILE_SIZE) {
showToast({
severity: 'error',
message: tr.InstructionsDialog.fileSizeLimitError.replace(
'{x}',
String(MEGAS)
),
});
return;
}
const fileStrings = (await Promise.all(
files.map((file: any) => readFileAsync(file))
)) as string[];

const filesAreValid = !fileStrings
.map((fileString: string) => allowedFilesRegex.test(fileString))
.includes(false);

if (filesAreValid) {
setStagedFile(files[0]);
} else {
showToast({
severity: 'error',
message: tr.InstructionsDialog.wrongFileFormat,
});
}
} catch (err) {
showToast({
severity: 'error',
message: err?.message ?? tr.InstructionsDialog.fileUploadError,
});
}
}}
>
{stagedFile && (
<div style={{ display: 'flex', alignItems: 'center' }}>
<AttachFileIcon />
<p>{stagedFile.name}</p>
</div>
)}
</DropZone>
{instructionFileName && (
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: '20px',
justifyContent: 'center',
gap: '20px',
}}
>
<Typography>
{' '}
{tr.InstructionsDialog.currentInstructions}:{' '}
</Typography>
<Button
variant="outlined"
startIcon={<PictureAsPdfIcon sx={{ color: 'inherit' }} />}
href="/api/file/instructions"
download={instructionFileName}
>
{instructionFileName}
</Button>
</div>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setIsOpen(false);
setStagedFile(null);
setInstructionFileName(null);
}}
>
{tr.InstructionsDialog.cancel}
</Button>
<Button
onClick={async () => {
if (stagedFile) {
await storeAdminInstructions(stagedFile);
setIsOpen(false);
setMenuOpen(false);
}
}}
>
{tr.InstructionsDialog.submit}
</Button>
</DialogActions>
</Dialog>
);
}
13 changes: 13 additions & 0 deletions client/src/controllers/AdminFileController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const apiURL = '/api/file/instructions';

export async function getInstructionFilename() {
const response = await fetch(apiURL, { method: 'HEAD' });
return JSON.parse(response.headers.get('File-details')).name;
}

export async function storeAdminInstructions(file: File) {
const formData = new FormData();
formData.append('file', file);

await fetch(apiURL, { method: 'POST', body: formData });
}
Loading

0 comments on commit 0dadca0

Please sign in to comment.