[Study programme] Rewrite Tags tab with MUI

This commit is contained in:
Riku Rauhala
2025-03-03 21:27:18 +02:00
parent b29eb0c8e6
commit 0faef48179
8 changed files with 352 additions and 268 deletions
@@ -0,0 +1,76 @@
import { Add as AddIcon } from '@mui/icons-material'
import { Button, FormControlLabel, Stack, Switch, TextField } from '@mui/material'
import { DatePicker } from '@mui/x-date-pickers'
import { Moment } from 'moment'
import { useState } from 'react'
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
import { Section } from '@/components/material/Section'
import { YEAR_DATE_FORMAT } from '@/constants/date'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useCreateTagMutation } from '@/redux/tags'
import { Tag } from '@/shared/types'
import { reformatDate } from '@/util/timeAndDate'
export const CreateNewTagSection = ({ studyTrack, tags }: { studyTrack: string; tags: Tag[] }) => {
const [tagName, setTagName] = useState<string>('')
const [year, setYear] = useState<Moment | null>(null)
const [personal, setPersonal] = useState(false)
const { id: userId } = useGetAuthorizedUserQuery()
const [createTag] = useCreateTagMutation()
const handleSubmit = event => {
event.preventDefault()
const newTag = {
name: tagName.trim(),
personalUserId: personal ? userId : null,
studyTrack,
year: year ? reformatDate(year.toDate(), YEAR_DATE_FORMAT) : null,
}
void createTag(newTag)
setTagName('')
setYear(null)
setPersonal(false)
}
const handleChange = ({ target }) => {
setTagName(target.value)
}
return (
<Section cypress="create-new-tag" infoBoxContent={studyProgrammeToolTips.tags} title="Create new tag">
<Stack direction="column">
<Stack alignItems="center" direction="row" gap={1}>
<TextField
label="Tag name"
onChange={handleChange}
placeholder="Enter a name for the new tag"
sx={{ width: 320 }}
value={tagName}
/>
<DatePicker
label="Associated start year (optional)"
onChange={newYear => setYear(newYear)}
sx={{
width: 320,
}}
value={year}
views={['year']}
/>
<Button
disabled={!tagName.trim() || tags.some(tag => tag.name === tagName.trim())}
endIcon={<AddIcon />}
onClick={handleSubmit}
variant="contained"
>
Create
</Button>
</Stack>
<FormControlLabel
control={<Switch checked={personal} onChange={() => setPersonal(!personal)} />}
label="Personal tag"
/>
</Stack>
</Section>
)
}
@@ -1,95 +0,0 @@
import { useEffect, useState } from 'react'
import { Button, Dropdown, Form, Message, Modal, TextArea } from 'semantic-ui-react'
import { extractItems } from '@/common'
import { useCreateStudentTagsMutation } from '@/redux/tags'
export const TagModal = ({ combinedProgramme, studytrack, tags }) => {
const [modalOpen, setModalOpen] = useState(false)
const [input, setInput] = useState('')
const [selectedValue, setSelected] = useState('')
const [createStudentTags, { isError, isLoading, isSuccess }] = useCreateStudentTagsMutation()
const parsedStudentNumbers = extractItems(input)
useEffect(() => {
if (!isLoading) {
if (isSuccess) {
setSelected('')
setInput('')
setTimeout(() => setModalOpen(false), 2000)
}
}
}, [isLoading])
const handleClick = async event => {
event.preventDefault()
await createStudentTags({
tags: parsedStudentNumbers.map(studentNumber => ({
tag_id: selectedValue,
studentnumber: studentNumber,
})),
studytrack,
combinedProgramme,
})
}
const handleChange = (event, { value }) => {
event.preventDefault()
setSelected(value)
}
const createdOptions = tags.map(tag => ({ key: tag.tag_id, text: tag.tagname, value: tag.tag_id }))
return (
<Modal
closeOnEscape={false}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="small"
trigger={
<Button
color="blue"
content="Add a tag to students"
disabled={!tags.length}
onClick={() => setModalOpen(true)}
size="small"
/>
}
>
<Modal.Content>
<Form>
<h2>Add a tag to students</h2>
<Message content="Failed to add tags to students." hidden={!isError} negative />
<Message content="Successfully added tags to students." hidden={!isSuccess} positive />
<Form.Field>
<label>Select a tag to add</label>
<Dropdown
onChange={handleChange}
options={createdOptions}
search
selectOnBlur={false}
selectOnNavigation={false}
selection
value={selectedValue}
/>
</Form.Field>
<Form.Field>
<label>
Insert student numbers you wish to tag. Separate each number with a comma, semicolon, space, or newline.
</label>
<TextArea onChange={(_, { value }) => setInput(value)} placeholder="011111111" rows={10} value={input} />
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button content="Cancel" negative onClick={() => setModalOpen(false)} />
<Button
content="Add tags"
disabled={isLoading || selectedValue.length === 0 || !parsedStudentNumbers.length}
onClick={event => handleClick(event)}
positive
/>
</Modal.Actions>
</Modal>
)
}
@@ -0,0 +1,88 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Stack,
TextField,
} from '@mui/material'
import { useEffect, useState } from 'react'
import { extractItems } from '@/common'
import { useCreateStudentTagsMutation } from '@/redux/tags'
import { Tag } from '@/shared/types'
export const AddStudentsDialog = ({
combinedProgramme,
open,
setOpen,
studyTrack,
tag,
}: {
combinedProgramme: string
open: boolean
setOpen: (open: boolean) => void
studyTrack: string
tag: Tag
}) => {
const [input, setInput] = useState('')
const parsedStudentNumbers = extractItems(input)
const [createStudentTags, { isError, isLoading, isSuccess }] = useCreateStudentTagsMutation()
useEffect(() => {
if (!isLoading) {
if (isSuccess) {
setInput('')
setTimeout(() => setOpen(false), 2000)
}
}
}, [isLoading, isSuccess, setOpen])
const handleClick = async event => {
event.preventDefault()
await createStudentTags({
combinedProgramme,
studentTags: parsedStudentNumbers.map(studentNumber => ({
studentNumber,
tagId: tag.id,
})),
studyTrack,
})
setInput('')
setOpen(false)
}
return (
<Dialog open={open}>
<DialogTitle>Add students to tag {tag.name}</DialogTitle>
<DialogContent>
<Stack gap={1}>
<DialogContentText>Insert student numbers you wish to tag</DialogContentText>
<TextField
minRows={3}
multiline
onChange={event => setInput(event.target.value)}
placeholder="Separate each student number with a comma, semicolon, space, or newline"
sx={{ width: 400 }}
value={input}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} variant="outlined">
Cancel
</Button>
<Button
color="success"
disabled={isError || isLoading || !parsedStudentNumbers.length}
onClick={handleClick}
variant="contained"
>
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,42 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'
import { useDeleteTagMutation } from '@/redux/tags'
import { Tag } from '@/shared/types'
export const DeleteTagDialog = ({
setTagToDelete,
tag,
tagToDelete,
}: {
setTagToDelete: (value: Tag | null) => void
tag: Tag
tagToDelete: Tag | null
}) => {
const [deleteTag] = useDeleteTagMutation()
const handleDeleteTag = (event, tag: Tag) => {
event.preventDefault()
void deleteTag(tag)
setTagToDelete(null)
}
return (
<Dialog open={tagToDelete?.id === tag.id}>
<DialogTitle>Delete tag</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete tag <b>{tag.name}</b>? If you press confirm you will delete it from all
students that have it. You and other users won't be able to use this tag again.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setTagToDelete(null)} variant="outlined">
Cancel
</Button>
<Button color="error" onClick={event => handleDeleteTag(event, tag)} variant="contained">
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,81 @@
import {
Delete as DeleteIcon,
PersonAddAlt1 as PersonAddAlt1Icon,
Sell as SellIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material'
import { IconButton, ListItem, ListItemIcon, ListItemText, Stack, Tooltip } from '@mui/material'
import { useState } from 'react'
import { PopulationLinkWithTag } from '@/components/material/PopulationLink'
import { Tag } from '@/shared/types'
import { AddStudentsDialog } from './AddStudentsDialog'
import { DeleteTagDialog } from './DeleteTagDialog'
export const TagItem = ({
combinedProgramme,
studyProgramme,
studyTrack,
tag,
}: {
combinedProgramme: string
studyProgramme: string
studyTrack: string
tag: Tag
}) => {
const [studentsToAddDialogOpen, setStudentsToAddDialogOpen] = useState<boolean>(false)
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null)
return (
<>
<ListItem
secondaryAction={
<Stack direction="row">
<Tooltip arrow placement="top" title="Add students to tag">
<IconButton onClick={() => setStudentsToAddDialogOpen(true)}>
<PersonAddAlt1Icon />
</IconButton>
</Tooltip>
<Tooltip arrow placement="top" title="Delete tag">
<IconButton onClick={() => setTagToDelete(tag)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
}
>
<ListItemIcon>
<SellIcon />
</ListItemIcon>
<ListItemText
primary={
<Stack direction="row" sx={{ alignItems: 'center' }}>
{tag.name}
{tag.year && (
<PopulationLinkWithTag
combinedProgramme={combinedProgramme}
studyProgramme={studyProgramme}
tag={tag}
/>
)}
{tag.personalUserId && (
<Tooltip arrow placement="right" title="Only you can see this tag">
<VisibilityIcon fontSize="small" />
</Tooltip>
)}
</Stack>
}
secondary={tag.year ? `Associated start year ${tag.year}` : 'No associated start year'}
/>
</ListItem>
<AddStudentsDialog
combinedProgramme={combinedProgramme}
open={studentsToAddDialogOpen}
setOpen={setStudentsToAddDialogOpen}
studyTrack={studyTrack}
tag={tag}
/>
<DeleteTagDialog setTagToDelete={setTagToDelete} tag={tag} tagToDelete={tagToDelete} />
</>
)
}
@@ -0,0 +1,40 @@
import { List, Stack, Typography } from '@mui/material'
import { Section } from '@/components/material/Section'
import { Tag } from '@/shared/types'
import { TagItem } from './TagItem'
export const TagsSection = ({
combinedProgramme,
studyProgramme,
studyTrack,
tags,
}: {
combinedProgramme: string
studyProgramme: string
studyTrack: string
tags: Tag[]
}) => {
return (
<Section title="Tags">
{tags.length === 0 ? (
<Typography color="text.secondary">This study programme does not have any tags yet</Typography>
) : (
<Stack gap={1}>
<Typography>Tags associated with this study programme</Typography>
<List>
{tags.map(tag => (
<TagItem
combinedProgramme={combinedProgramme}
key={tag.id}
studyProgramme={studyProgramme}
studyTrack={studyTrack}
tag={tag}
/>
))}
</List>
</Stack>
)}
</Section>
)
}
@@ -1,180 +1,32 @@
import moment from 'moment'
import { useState } from 'react'
import Datetime from 'react-datetime'
import { Link } from 'react-router'
import { Button, Confirm, Form, Header, Icon, Item, List, Message, Popup, Segment } from 'semantic-ui-react'
import { Stack } from '@mui/material'
import { SortableTable } from '@/components/SortableTable'
import { YEAR_DATE_FORMAT } from '@/constants/date'
import { TagModal } from '@/pages/StudyProgramme/TagsTab/TagModal'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useCreateTagMutation, useGetTagsByStudyTrackQuery, useDeleteTagMutation } from '@/redux/tags'
import { reformatDate } from '@/util/timeAndDate'
import { useGetTagsByStudyTrackQuery } from '@/redux/tags'
import { CreateNewTagSection } from './CreateNewTagSection'
import { TagsSection } from './TagsSection'
export const TagsTab = ({ studyprogramme, combinedProgramme }) => {
const [tagname, setTagname] = useState('')
const [confirm, setConfirm] = useState(null)
const [year, setYear] = useState(null)
const [personal, setPersonal] = useState(false)
const { id: userId } = useGetAuthorizedUserQuery()
const studytrack = combinedProgramme ? `${studyprogramme}-${combinedProgramme}` : studyprogramme
const { data: tags } = useGetTagsByStudyTrackQuery(studytrack)
const [createTag] = useCreateTagMutation()
const [deleteTag] = useDeleteTagMutation()
export const TagsTab = ({
combinedProgramme,
studyProgramme,
}: {
combinedProgramme: string
studyProgramme: string
}) => {
const studyTrack = combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme
const { data: tags } = useGetTagsByStudyTrackQuery(studyTrack)
if (!tags) return null
const handleDeleteTag = (event, tag) => {
event.preventDefault()
deleteTag(tag)
setConfirm(null)
if (!tags) {
return null
}
const handleTagYearSelect = momentYear => {
setYear(momentYear)
}
const handleSubmit = event => {
event.preventDefault()
const newTag = {
tagname: tagname.trim(),
studytrack: combinedProgramme ? `${studyprogramme}-${combinedProgramme}` : studyprogramme,
year: year ? reformatDate(year, YEAR_DATE_FORMAT) : null,
personal_user_id: personal ? userId : null,
}
createTag(newTag)
setTagname('')
setYear(null)
setPersonal(false)
}
const handleChange = ({ target }) => {
setTagname(target.value)
}
const deleteButton = tag => <Button content="Delete" negative onClick={() => setConfirm(tag)} />
const populationUrl = tag => {
if (!tag.year) {
const year = new Date().getFullYear()
const months = Math.ceil(moment.duration(moment().diff(`${year}-08-01`)).asMonths())
const href = combinedProgramme
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"combinedProgramme"%3A"${combinedProgramme}"%7D&year=${year}&tag=${tag.tag_id}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${year}&tag=${tag.tag_id}`
return href
}
const months = Math.ceil(moment.duration(moment().diff(`${tag.year}-08-01`)).asMonths())
const href = combinedProgramme
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"combinedProgramme"%3A"${combinedProgramme}"%7D&year=${tag.year}&tag=${tag.tag_id}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${tag.year}&tag=${tag.tag_id}`
return href
}
const decorateTagName = tag => {
if (tag.personal_user_id)
return (
<>
{tag.tagname}
<Item as={Link} to={populationUrl(tag)}>
<Icon name="level up alternate" />
</Item>
<Popup
content="Only you can see this tag."
trigger={<Icon color="purple" name="eye" style={{ marginLeft: '1em' }} />}
/>
</>
)
return (
<>
{tag.tagname}
<Item as={Link} to={populationUrl(tag)}>
<Icon name="level up alternate" />
</Item>
</>
)
}
const columns = [
{
key: 'name',
title: 'Name',
getRowVal: tag => tag.tagname,
getRowContent: tag => decorateTagName(tag),
},
{
key: 'year',
title: 'Associated start year',
getRowVal: tag => tag.year,
},
{
key: 'delete',
title: 'Delete',
export: false,
getRowContent: tag => (
<Form.Field>
{deleteButton(tag.tag_id)}
<Confirm
cancelButton="Cancel"
confirmButton="Confirm"
content={`Are you sure you want to delete tag "${tag.tagname}"? If you press confirm you will delete it from all students that have it. You and other users won't be able to use this tag again.`}
onCancel={() => setConfirm(null)}
onConfirm={event => handleDeleteTag(event, tag)}
open={confirm === tag.tag_id}
/>
</Form.Field>
),
},
]
return (
<List>
<Form>
<Message
content="Here you can create tags for study programme. You can either create public tags or personal tags.
Tags can be used to combine students from other starting years. 'Associated start year' means what year you want to use as a start year for the students in that tag.
For example with this you can move student from earlier starting year to next year if the student was absent during first year. However this is optional and you can
create a new tag without selecting year."
header="Create tags for study programme"
/>
<Segment>
<Form.Group>
<Form.Field>
<label>Tag name</label>
<Form.Input className="tagNameSelectInput" onChange={handleChange} value={tagname} />
</Form.Field>
<Form.Field>
<label>Associated start year</label>
<Datetime
className="yearSelectInput"
closeOnSelect
control={Datetime}
dateFormat={YEAR_DATE_FORMAT}
onChange={handleTagYearSelect}
renderYear={(props, selectableYear) => <td {...props}>{selectableYear}</td>}
timeFormat={false}
value={year}
/>
</Form.Field>
<Form.Field>
<label>Personal tag</label>
<Form.Checkbox checked={personal} onClick={() => setPersonal(!personal)} toggle />
</Form.Field>
<Button
color="green"
content="Create a new tag"
disabled={!tagname.trim() || tags.some(tag => tag.tagname === tagname.trim())}
onClick={handleSubmit}
/>
<TagModal combinedProgramme={combinedProgramme} studytrack={studyprogramme} tags={tags} />
</Form.Group>
</Segment>
</Form>
<Header size="medium">Study programme tags</Header>
<SortableTable columns={columns} data={tags} />
</List>
<Stack gap={2}>
<CreateNewTagSection studyTrack={studyTrack} tags={tags} />
<TagsSection
combinedProgramme={combinedProgramme}
studyProgramme={studyProgramme}
studyTrack={studyTrack}
tags={tags}
/>
</Stack>
)
}
@@ -143,7 +143,7 @@ export const StudyProgramme = () => {
key: 'TagsTab',
cypress: 'tags-tab',
label: 'Tags',
component: <TagsTab combinedProgramme={secondProgrammeId} studyprogramme={programmeId} />,
component: <TagsTab combinedProgramme={secondProgrammeId} studyProgramme={programmeId} />,
}
)
}