diff --git a/backend/src/app/exceptions/handlers.py b/backend/src/app/exceptions/handlers.py index 9845e4f25..2000aeefe 100644 --- a/backend/src/app/exceptions/handlers.py +++ b/backend/src/app/exceptions/handlers.py @@ -10,7 +10,7 @@ from .crud import DuplicateInsertException from .editions import ReadOnlyEditionException from .parsing import MalformedUUIDError -from .projects import StudentInConflictException, FailedToAddProjectRoleException +from .projects import StudentInConflictException, FailedToAddProjectRoleException, NoStrictlyPositiveNumberOfSlots from .register import FailedToAddNewUserException, InvalidGitHubCode from .students_email import FailedToAddNewEmailException from .webhooks import WebhookProcessException @@ -140,3 +140,10 @@ def failed_to_add_new_email_exception(_request: Request, _exception: FailedToAdd status_code=status.HTTP_400_BAD_REQUEST, content={'message': 'Something went wrong while creating a new email'} ) + + @app.exception_handler(NoStrictlyPositiveNumberOfSlots) + def none_strict_postive_number_of_slots(_request: Request, _exception: NoStrictlyPositiveNumberOfSlots): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={'message': 'The amount of slots per role has to be a strictly positive integer'} + ) diff --git a/backend/src/app/exceptions/projects.py b/backend/src/app/exceptions/projects.py index 9377d1a4c..d1dffc67f 100644 --- a/backend/src/app/exceptions/projects.py +++ b/backend/src/app/exceptions/projects.py @@ -8,3 +8,7 @@ class FailedToAddProjectRoleException(Exception): """ Exception raised when a project_role can't be added for some reason """ + + +class NoStrictlyPositiveNumberOfSlots(Exception): + """Exception raised when roles aren't strictly positive""" diff --git a/backend/src/app/logic/answers.py b/backend/src/app/logic/answers.py new file mode 100644 index 000000000..6ffa51ee6 --- /dev/null +++ b/backend/src/app/logic/answers.py @@ -0,0 +1,22 @@ +from src.database.models import Student +from src.app.schemas.answers import Questions, QuestionAndAnswer, File + + +async def gives_question_and_answers(student: Student) -> Questions: + """transfers the student questions into a return model of Questions""" + # return Questions(questions=student.questions) + q_and_as: list[QuestionAndAnswer] = [] + for question in student.questions: + answers: list[str] = [] + for answer in question.answers: + if answer.answer: + answers.append(answer.answer) + + files: list[File] = [] + for file in question.files: + files.append(File(filename=file.file_name, + mime_type=file.mime_type, url=file.url)) + + q_and_as.append(QuestionAndAnswer( + question=question.question, answers=answers, files=files)) + return Questions(q_and_a=q_and_as) diff --git a/backend/src/app/logic/editions.py b/backend/src/app/logic/editions.py index 26195b277..05fb2bb69 100644 --- a/backend/src/app/logic/editions.py +++ b/backend/src/app/logic/editions.py @@ -24,24 +24,16 @@ async def get_edition_by_name(db: AsyncSession, edition_name: str) -> EditionMod async def create_edition(db: AsyncSession, edition: EditionBase) -> EditionModel: - """ Create a new edition. - - Args: - db (Session): connection with the database. - - Returns: - Edition: the newly made edition object. - """ + """Create a new edition.""" return await crud_editions.create_edition(db, edition) async def delete_edition(db: AsyncSession, edition_name: str): - """Delete an existing edition. + """Delete an existing edition.""" + await crud_editions.delete_edition(db, edition_name) - Args: - db (Session): connection with the database. - edition_name (str): the name of the edition that needs to be deleted, if found. - Returns: nothing - """ - await crud_editions.delete_edition(db, edition_name) +async def patch_edition(db: AsyncSession, edition: EditionModel, readonly: bool) -> EditionModel: + """Edit an existing edition""" + await crud_editions.patch_edition(db, edition, readonly) + return edition diff --git a/backend/src/app/logic/invites.py b/backend/src/app/logic/invites.py index 408b0b374..a4e3f87cb 100644 --- a/backend/src/app/logic/invites.py +++ b/backend/src/app/logic/invites.py @@ -34,7 +34,10 @@ async def create_mailto_link(db: AsyncSession, edition: Edition, email_address: # Create endpoint for the user to click on link = f"{settings.FRONTEND_URL}/register/{encoded_link}" + with open('templates/invites.txt', 'r', encoding="utf-8") as file: + message = file.read().format(invite_link=link) + return NewInviteLink(mail_to=generate_mailto_string( recipient=email_address.email, subject=f"Open Summer Of Code {edition.year} invitation", - body=link + body=message ), invite_link=link) diff --git a/backend/src/app/logic/projects.py b/backend/src/app/logic/projects.py index 603c6fb20..e17c08450 100644 --- a/backend/src/app/logic/projects.py +++ b/backend/src/app/logic/projects.py @@ -1,6 +1,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.app.exceptions.projects import NoStrictlyPositiveNumberOfSlots import src.app.logic.partners as partners_logic import src.database.crud.projects as crud from src.app.schemas.projects import ( @@ -67,13 +68,17 @@ async def get_project_roles(db: AsyncSession, project: Project) -> ProjectRoleRe async def create_project_role(db: AsyncSession, project: Project, input_project_role: InputProjectRole) -> ProjectRole: """Create a project role""" - return await crud.create_project_role(db, project, input_project_role) + if input_project_role.slots > 0: + return await crud.create_project_role(db, project, input_project_role) + raise NoStrictlyPositiveNumberOfSlots async def patch_project_role(db: AsyncSession, project_role_id: int, input_project_role: InputProjectRole) \ -> ProjectRole: """Update a project role""" - return await crud.patch_project_role(db, project_role_id, input_project_role) + if input_project_role.slots > 0: + return await crud.patch_project_role(db, project_role_id, input_project_role) + raise NoStrictlyPositiveNumberOfSlots async def get_conflicts(db: AsyncSession, edition: Edition) -> ConflictStudentList: diff --git a/backend/src/app/logic/students.py b/backend/src/app/logic/students.py index a6c4a10fb..ecb22c9a7 100644 --- a/backend/src/app/logic/students.py +++ b/backend/src/app/logic/students.py @@ -29,6 +29,29 @@ async def remove_student(db: AsyncSession, student: Student) -> None: await delete_student(db, student) +async def _get_student_with_suggestions(db: AsyncSession, student: Student) -> StudentModel: + nr_of_yes_suggestions = len(await get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.YES)) + nr_of_no_suggestions = len(await get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.NO)) + nr_of_maybe_suggestions = len(await get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.MAYBE)) + suggestions = SuggestionsModel( + yes=nr_of_yes_suggestions, no=nr_of_no_suggestions, maybe=nr_of_maybe_suggestions) + return StudentModel(student_id=student.student_id, + first_name=student.first_name, + last_name=student.last_name, + preferred_name=student.preferred_name, + email_address=student.email_address, + phone_number=student.phone_number, + alumni=student.alumni, + finalDecision=student.decision, + wants_to_be_student_coach=student.wants_to_be_student_coach, + edition_id=student.edition_id, + skills=student.skills, + nr_of_suggestions=suggestions) + + async def get_students_search(db: AsyncSession, edition: Edition, commons: CommonQueryParams, user: User) -> ReturnStudentList: """return all students""" @@ -42,33 +65,17 @@ async def get_students_search(db: AsyncSession, edition: Edition, students: list[StudentModel] = [] for student in students_orm: - students.append(StudentModel( - student_id=student.student_id, - first_name=student.first_name, - last_name=student.last_name, - preferred_name=student.preferred_name, - email_address=student.email_address, - phone_number=student.phone_number, - alumni=student.alumni, - finalDecision=student.decision, - wants_to_be_student_coach=student.wants_to_be_student_coach, - edition_id=student.edition_id, - skills=student.skills)) - nr_of_yes_suggestions = len(await get_suggestions_of_student_by_type( - db, student.student_id, DecisionEnum.YES)) - nr_of_no_suggestions = len(await get_suggestions_of_student_by_type( - db, student.student_id, DecisionEnum.NO)) - nr_of_maybe_suggestions = len(await get_suggestions_of_student_by_type( - db, student.student_id, DecisionEnum.MAYBE)) - students[-1].nr_of_suggestions = SuggestionsModel( - yes=nr_of_yes_suggestions, no=nr_of_no_suggestions, maybe=nr_of_maybe_suggestions) + student_model = await _get_student_with_suggestions(db, student) + students.append(student_model) return ReturnStudentList(students=students) -def get_student_return(student: Student, edition: Edition) -> ReturnStudent: + +async def get_student_return(db: AsyncSession, student: Student, edition: Edition) -> ReturnStudent: """return a student""" if student.edition == edition: - return ReturnStudent(student=student) + student_model = await _get_student_with_suggestions(db, student) + return ReturnStudent(student=student_model) raise NoResultFound diff --git a/backend/src/app/logic/users.py b/backend/src/app/logic/users.py index 805f8c705..b0e9bd488 100644 --- a/backend/src/app/logic/users.py +++ b/backend/src/app/logic/users.py @@ -3,7 +3,7 @@ import src.database.crud.users as users_crud from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ FilterParameters, UserRequest -from src.database.models import User +from src.database.models import User, Edition async def get_users_list( @@ -20,9 +20,9 @@ async def get_users_list( return UsersListResponse(users=[user_model_to_schema(user) for user in users_orm]) -async def get_user_editions(db: AsyncSession, user: User) -> list[str]: +async def get_user_editions(db: AsyncSession, user: User) -> list[Edition]: """Get all names of the editions this user is coach in""" - return await users_crud.get_user_edition_names(db, user) + return await users_crud.get_user_editions(db, user) async def edit_admin_status(db: AsyncSession, user_id: int, admin: AdminPatch): diff --git a/backend/src/app/routers/editions/editions.py b/backend/src/app/routers/editions/editions.py index 71c6b6a06..359fb8cc6 100644 --- a/backend/src/app/routers/editions/editions.py +++ b/backend/src/app/routers/editions/editions.py @@ -8,19 +8,19 @@ from src.app.logic import editions as logic_editions from src.app.routers.tags import Tags -from src.app.schemas.editions import EditionBase, Edition, EditionList +from src.app.schemas.editions import EditionBase, Edition, EditionList, EditEdition from src.database.database import get_session -from src.database.models import User +from src.database.models import User, Edition as EditionDB from .invites import invites_router from .projects import projects_router from .register import registration_router from .students import students_router from .webhooks import webhooks_router -from ...utils.dependencies import require_admin, require_auth, require_coach, require_coach_ws -# Don't add the "Editions" tag here, because then it gets applied -# to all child routes as well +from ...utils.dependencies import require_admin, require_auth, require_coach, require_coach_ws, get_edition from ...utils.websockets import DataPublisher, get_publisher +# Don't add the "Editions" tag here, because then it gets applied +# to all child routes as well editions_router = APIRouter(prefix="/editions") # Register all child routers @@ -45,6 +45,17 @@ async def get_editions(db: AsyncSession = Depends(get_session), user: User = Dep return EditionList(editions=user.editions) +@editions_router.patch("/{edition_name}", response_class=Response, tags=[Tags.EDITIONS], + dependencies=[Depends(require_admin)], status_code=status.HTTP_204_NO_CONTENT) +async def patch_edition(edit_edition: EditEdition, edition: EditionDB = Depends(get_edition), + db: AsyncSession = Depends(get_session)): + """Change the readonly status of an edition + Note that this route is not behind "get_editable_edition", because otherwise you'd never be able + to change the status back to False + """ + await logic_editions.patch_edition(db, edition, edit_edition.readonly) + + @editions_router.get( "/{edition_name}", response_model=Edition, diff --git a/backend/src/app/routers/editions/invites/invites.py b/backend/src/app/routers/editions/invites/invites.py index 9cdfab718..ca61acee1 100644 --- a/backend/src/app/routers/editions/invites/invites.py +++ b/backend/src/app/routers/editions/invites/invites.py @@ -5,7 +5,7 @@ from src.app.logic.invites import create_mailto_link, delete_invite_link, get_pending_invites_page from src.app.routers.tags import Tags -from src.app.utils.dependencies import get_edition, get_invite_link, require_admin, get_latest_edition +from src.app.utils.dependencies import get_edition, get_invite_link, require_admin, get_editable_edition from src.app.schemas.invites import InvitesLinkList, EmailAddress, NewInviteLink, InviteLink as InviteLinkModel from src.database.database import get_session from src.database.models import Edition, InviteLink as InviteLinkDB @@ -24,7 +24,7 @@ async def get_invites(db: AsyncSession = Depends(get_session), edition: Edition @invites_router.post("", status_code=status.HTTP_201_CREATED, response_model=NewInviteLink, dependencies=[Depends(require_admin)]) async def create_invite(email: EmailAddress, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """ Create a new invitation link for the current edition. """ diff --git a/backend/src/app/routers/editions/projects/projects.py b/backend/src/app/routers/editions/projects/projects.py index b0e48e6fb..5da2e9fc5 100644 --- a/backend/src/app/routers/editions/projects/projects.py +++ b/backend/src/app/routers/editions/projects/projects.py @@ -11,7 +11,7 @@ from src.app.schemas.projects import ( ProjectList, Project, InputProject, ConflictStudentList, QueryParamsProjects ) -from src.app.utils.dependencies import get_edition, get_project, require_admin, require_coach, get_latest_edition +from src.app.utils.dependencies import get_edition, get_project, require_admin, require_coach, get_editable_edition from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Edition, Project as ProjectModel, User @@ -40,7 +40,7 @@ async def get_projects( async def create_project( input_project: InputProject, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """Create a new project""" return await logic.create_project(db, edition, input_project) @@ -79,7 +79,7 @@ async def get_project_route(project: ProjectModel = Depends(get_project)): @projects_router.patch( "/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(live)] ) async def patch_project( input_project: InputProject, @@ -104,7 +104,8 @@ async def get_project_roles(project: ProjectModel = Depends(get_project), db: As @projects_router.post( "/{project_id}/roles", response_model=ProjectRoleSchema, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(live)], + status_code=status.HTTP_201_CREATED ) async def post_project_role( input_project_role: InputProjectRole, @@ -116,8 +117,9 @@ async def post_project_role( @projects_router.patch( "/{project_id}/roles/{project_role_id}", + status_code=status.HTTP_200_OK, response_model=ProjectRoleSchema, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(get_project), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(get_project), Depends(live)] ) async def patch_project_role( input_project_role: InputProjectRole, @@ -136,4 +138,4 @@ async def delete_project_role( project_role_id: int, db: AsyncSession = Depends(get_session)): """Delete a project role""" - return await logic.delete_project_role(db, project_role_id) + await logic.delete_project_role(db, project_role_id) diff --git a/backend/src/app/routers/editions/projects/students/projects_students.py b/backend/src/app/routers/editions/projects/students/projects_students.py index f69dc34c7..667439913 100644 --- a/backend/src/app/routers/editions/projects/students/projects_students.py +++ b/backend/src/app/routers/editions/projects/students/projects_students.py @@ -7,7 +7,7 @@ from src.app.routers.tags import Tags from src.app.schemas.projects import InputArgumentation, ReturnProjectRoleSuggestion from src.app.utils.dependencies import ( - require_coach, get_latest_edition, get_student, + require_coach, get_editable_edition, get_student, get_project_role, get_edition ) from src.app.utils.websockets import live @@ -35,7 +35,7 @@ async def remove_student_from_project( @project_students_router.patch( "/{student_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(get_editable_edition), Depends(live)] ) async def change_project_role( argumentation: InputArgumentation, @@ -52,7 +52,7 @@ async def change_project_role( @project_students_router.post( "/{student_id}", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(get_latest_edition), Depends(live)], + dependencies=[Depends(get_editable_edition), Depends(live)], response_model=ReturnProjectRoleSuggestion ) async def add_student_to_project( diff --git a/backend/src/app/routers/editions/register/register.py b/backend/src/app/routers/editions/register/register.py index dbb978005..24eab4f0e 100644 --- a/backend/src/app/routers/editions/register/register.py +++ b/backend/src/app/routers/editions/register/register.py @@ -7,7 +7,7 @@ from src.app.logic.register import create_request_email, create_request_github from src.app.routers.tags import Tags from src.app.schemas.register import EmailRegister, GitHubRegister -from src.app.utils.dependencies import get_latest_edition, get_http_session +from src.app.utils.dependencies import get_editable_edition, get_http_session from src.database.database import get_session from src.database.models import Edition @@ -16,7 +16,7 @@ @registration_router.post("/email", status_code=status.HTTP_201_CREATED) async def register_email(register_data: EmailRegister, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """ Register a new account using the email/password format. """ @@ -26,7 +26,7 @@ async def register_email(register_data: EmailRegister, db: AsyncSession = Depend @registration_router.post("/github", status_code=status.HTTP_201_CREATED) async def register_github(register_data: GitHubRegister, db: AsyncSession = Depends(get_session), http_session: ClientSession = Depends(get_http_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """Register a new account using GitHub OAuth.""" access_token_data = await get_github_access_token(http_session, register_data.code) user_email = await get_github_profile(http_session, access_token_data.access_token) diff --git a/backend/src/app/routers/editions/students/answers/__init__.py b/backend/src/app/routers/editions/students/answers/__init__.py new file mode 100644 index 000000000..4549d9bc2 --- /dev/null +++ b/backend/src/app/routers/editions/students/answers/__init__.py @@ -0,0 +1 @@ +from .answers import students_answers_router diff --git a/backend/src/app/routers/editions/students/answers/answers.py b/backend/src/app/routers/editions/students/answers/answers.py new file mode 100644 index 000000000..11b0afe52 --- /dev/null +++ b/backend/src/app/routers/editions/students/answers/answers.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends + +from starlette import status +from src.app.logic.answers import gives_question_and_answers +from src.app.routers.tags import Tags +from src.app.utils.dependencies import get_student, require_coach +from src.app.schemas.answers import Questions +from src.database.models import Student + +students_answers_router = APIRouter( + prefix="/answers", tags=[Tags.STUDENTS]) + + +@students_answers_router.get("/", status_code=status.HTTP_200_OK, response_model=Questions, + dependencies=[Depends(require_coach)]) +async def get_answers(student: Student = Depends(get_student)): + """give answers of a student""" + return await gives_question_and_answers(student=student) diff --git a/backend/src/app/routers/editions/students/students.py b/backend/src/app/routers/editions/students/students.py index ecf83eb29..9902aa1ef 100644 --- a/backend/src/app/routers/editions/students/students.py +++ b/backend/src/app/routers/editions/students/students.py @@ -14,15 +14,18 @@ ReturnStudentMailList, NewEmail, EmailsSearchQueryParams, ListReturnStudentMailList ) -from src.app.utils.dependencies import get_student, get_edition, require_admin, require_auth +from src.app.utils.dependencies import get_editable_edition, get_student, get_edition, require_admin, require_auth from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Student, Edition, User from .suggestions import students_suggestions_router +from .answers import students_answers_router students_router = APIRouter(prefix="/students", tags=[Tags.STUDENTS]) students_router.include_router( students_suggestions_router, prefix="/{student_id}") +students_router.include_router( + students_answers_router, prefix="/{student_id}") @students_router.get("", response_model=ReturnStudentList) @@ -44,7 +47,7 @@ async def get_students(db: AsyncSession = Depends(get_session), async def send_emails( new_email: NewEmail, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_edition)): + edition: Edition = Depends(get_editable_edition)): """ Send a email to a list of students. """ @@ -79,17 +82,18 @@ async def delete_student(student: Student = Depends(get_student), db: AsyncSessi @students_router.get("/{student_id}", dependencies=[Depends(require_auth)], response_model=ReturnStudent) -async def get_student_by_id(edition: Edition = Depends(get_edition), student: Student = Depends(get_student)): +async def get_student_by_id(edition: Edition = Depends(get_edition), student: Student = Depends(get_student), + db: AsyncSession = Depends(get_session)): """ Get information about a specific student. """ - return get_student_return(student, edition) + return await get_student_return(db, student, edition) @students_router.put( "/{student_id}/decision", - dependencies=[Depends(require_admin), Depends(live)], - status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(require_admin), Depends(live), Depends(get_editable_edition)], + status_code=status.HTTP_204_NO_CONTENT ) async def make_decision( decision: NewDecision, diff --git a/backend/src/app/routers/editions/students/suggestions/suggestions.py b/backend/src/app/routers/editions/students/suggestions/suggestions.py index 2e2a48bbf..7479832b6 100644 --- a/backend/src/app/routers/editions/students/suggestions/suggestions.py +++ b/backend/src/app/routers/editions/students/suggestions/suggestions.py @@ -5,7 +5,7 @@ from starlette.responses import Response from src.app.routers.tags import Tags -from src.app.utils.dependencies import require_auth, get_student, get_suggestion +from src.app.utils.dependencies import get_editable_edition, require_auth, get_student, get_suggestion from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Student, User, Suggestion @@ -22,7 +22,7 @@ "", status_code=status.HTTP_201_CREATED, response_model=SuggestionResponse, - dependencies=[Depends(live)] + dependencies=[Depends(live), Depends(get_editable_edition)] ) async def create_suggestion(new_suggestion: NewSuggestion, student: Student = Depends(get_student), db: AsyncSession = Depends(get_session), user: User = Depends(require_auth)): @@ -50,8 +50,8 @@ async def delete_suggestion(db: AsyncSession = Depends(get_session), user: User @students_suggestions_router.put( "/{suggestion_id}", - status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(get_student), Depends(live)] + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_student), Depends(live), Depends(get_editable_edition)] ) async def edit_suggestion(new_suggestion: NewSuggestion, db: AsyncSession = Depends(get_session), user: User = Depends(require_auth), suggestion: Suggestion = Depends(get_suggestion)): diff --git a/backend/src/app/routers/editions/webhooks/webhooks.py b/backend/src/app/routers/editions/webhooks/webhooks.py index 8924eb612..139818aef 100644 --- a/backend/src/app/routers/editions/webhooks/webhooks.py +++ b/backend/src/app/routers/editions/webhooks/webhooks.py @@ -5,7 +5,7 @@ from src.app.logic.webhooks import process_webhook from src.app.routers.tags import Tags from src.app.schemas.webhooks import WebhookEvent, WebhookUrlResponse -from src.app.utils.dependencies import get_edition, require_admin, get_latest_edition +from src.app.utils.dependencies import get_edition, require_admin, get_editable_edition from src.database.crud.webhooks import get_webhook, create_webhook from src.database.database import get_session from src.database.models import Edition @@ -20,7 +20,7 @@ async def valid_uuid(uuid: str, database: AsyncSession = Depends(get_session)): @webhooks_router.post("", response_model=WebhookUrlResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)]) -async def new(edition: Edition = Depends(get_latest_edition), database: AsyncSession = Depends(get_session)): +async def new(edition: Edition = Depends(get_editable_edition), database: AsyncSession = Depends(get_session)): """Create a new webhook for an edition""" return await create_webhook(database, edition) diff --git a/backend/src/app/routers/login/login.py b/backend/src/app/routers/login/login.py index dee681710..d4b33ee78 100644 --- a/backend/src/app/routers/login/login.py +++ b/backend/src/app/routers/login/login.py @@ -9,6 +9,7 @@ from src.app.logic.security import authenticate_user_email, create_tokens, authenticate_user_github from src.app.logic.users import get_user_editions from src.app.routers.tags import Tags +from src.app.schemas.editions import Edition from src.app.schemas.login import Token from src.app.schemas.users import user_model_to_schema from src.app.utils.dependencies import get_user_from_refresh_token, get_http_session @@ -61,7 +62,8 @@ async def generate_token_response_for_user(db: AsyncSession, user: User) -> Toke access_token, refresh_token = create_tokens(user) user_data: dict = user_model_to_schema(user).__dict__ - user_data["editions"] = await get_user_editions(db, user) + editions = await get_user_editions(db, user) + user_data["editions"] = list(map(Edition.from_orm, editions)) return Token( access_token=access_token, diff --git a/backend/src/app/routers/users/users.py b/backend/src/app/routers/users/users.py index 681bc2396..5f815750a 100644 --- a/backend/src/app/routers/users/users.py +++ b/backend/src/app/routers/users/users.py @@ -5,6 +5,7 @@ import src.app.logic.users as logic from src.app.routers.tags import Tags +from src.app.schemas.editions import Edition from src.app.schemas.login import UserData from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ FilterParameters @@ -32,8 +33,8 @@ async def get_users( async def get_current_user(db: AsyncSession = Depends(get_session), user: UserDB = Depends(get_user_from_access_token)): """Get a user based on their authorization credentials""" user_data = user_model_to_schema(user).__dict__ - user_data["editions"] = await logic.get_user_editions(db, user) - + editions = await logic.get_user_editions(db, user) + user_data["editions"] = list(map(Edition.from_orm, editions)) return user_data diff --git a/backend/src/app/schemas/answers.py b/backend/src/app/schemas/answers.py new file mode 100644 index 000000000..2ce370dad --- /dev/null +++ b/backend/src/app/schemas/answers.py @@ -0,0 +1,20 @@ +from src.app.schemas.utils import CamelCaseModel + + +class File(CamelCaseModel): + """question files""" + filename: str + mime_type: str + url: str + + +class QuestionAndAnswer(CamelCaseModel): + """question and answer""" + question: str + answers: list[str] + files: list[File] + + +class Questions(CamelCaseModel): + """return model of questions""" + q_and_a: list[QuestionAndAnswer] diff --git a/backend/src/app/schemas/editions.py b/backend/src/app/schemas/editions.py index 3b3618d0e..8f89ce5ea 100644 --- a/backend/src/app/schemas/editions.py +++ b/backend/src/app/schemas/editions.py @@ -22,6 +22,7 @@ class Edition(CamelCaseModel): edition_id: int name: str year: int + readonly: bool class Config: """Set to ORM mode""" @@ -35,3 +36,10 @@ class EditionList(CamelCaseModel): class Config: """Set to ORM mode""" orm_mode = True + + +class EditEdition(CamelCaseModel): + """Input schema to edit an edition + Only supported operation is patching the readonly status + """ + readonly: bool diff --git a/backend/src/app/schemas/login.py b/backend/src/app/schemas/login.py index b7e735e73..a4ca1e439 100644 --- a/backend/src/app/schemas/login.py +++ b/backend/src/app/schemas/login.py @@ -1,10 +1,11 @@ +from src.app.schemas.editions import Edition from src.app.schemas.users import User from src.app.schemas.utils import BaseModel class UserData(User): """User information that can be passed to frontend""" - editions: list[str] = [] + editions: list[Edition] = [] class Token(BaseModel): diff --git a/backend/src/app/schemas/projects.py b/backend/src/app/schemas/projects.py index beeb9c8d8..d2526880e 100644 --- a/backend/src/app/schemas/projects.py +++ b/backend/src/app/schemas/projects.py @@ -2,9 +2,9 @@ from pydantic import BaseModel, validator -from src.app.exceptions.validation_exception import ValidationException from src.app.schemas.skills import Skill from src.app.schemas.utils import CamelCaseModel +from src.app.schemas.validators import validate_url class User(CamelCaseModel): @@ -164,12 +164,8 @@ class InputProject(BaseModel): @validator('info_url') @classmethod def is_url(cls, info_url: str | None): - """Verify the info_url is actually an url""" - if not info_url: - return None - if info_url.startswith('https://') or info_url.startswith('http://'): - return info_url - raise ValidationException('info_url should be a link starting with http:// or https://') + """Validate url""" + return validate_url(info_url) class InputArgumentation(BaseModel): diff --git a/backend/src/app/schemas/validators.py b/backend/src/app/schemas/validators.py index ef956708d..9064a5a0f 100644 --- a/backend/src/app/schemas/validators.py +++ b/backend/src/app/schemas/validators.py @@ -21,3 +21,12 @@ def validate_edition(edition: str): """ if not re.fullmatch(r"[a-zA-Z0-9_-]+", edition): raise ValidationException("Spaces detected in the edition name") + + +def validate_url(info_url: str | None): + """Verify the info_url is actually an url""" + if not info_url: + return None + if info_url.startswith('https://') or info_url.startswith('http://'): + return info_url + raise ValidationException('info_url should be a link starting with http:// or https://') diff --git a/backend/src/app/utils/dependencies.py b/backend/src/app/utils/dependencies.py index 89c067d59..228608cf4 100644 --- a/backend/src/app/utils/dependencies.py +++ b/backend/src/app/utils/dependencies.py @@ -18,7 +18,7 @@ from src.app.exceptions.editions import ReadOnlyEditionException from src.app.exceptions.util import NotFound from src.app.logic.security import ALGORITHM, TokenType -from src.database.crud.editions import get_edition_by_name, latest_edition +from src.database.crud.editions import get_edition_by_name from src.database.crud.invites import get_invite_link_by_uuid from src.database.crud.students import get_student_by_id from src.database.crud.suggestions import get_suggestion_by_id @@ -32,23 +32,30 @@ async def get_edition(edition_name: str, database: AsyncSession = Depends(get_se return await get_edition_by_name(database, edition_name) -async def get_student(student_id: int, database: AsyncSession = Depends(get_session)) -> Student: +async def get_student(student_id: int, database: AsyncSession = Depends(get_session), + edition: Edition = Depends(get_edition)) -> Student: """Get the student from the database, given the id in the path""" - return await get_student_by_id(database, student_id) + student: Student = await get_student_by_id(database, student_id) + if student.edition != edition: + raise NotFound() + return student -async def get_suggestion(suggestion_id: int, database: AsyncSession = Depends(get_session)) -> Suggestion: +async def get_suggestion(suggestion_id: int, database: AsyncSession = Depends(get_session), + student: Student = Depends(get_student)) -> Suggestion: """Get the suggestion from the database, given the id in the path""" - return await get_suggestion_by_id(database, suggestion_id) + suggestion: Suggestion = await get_suggestion_by_id(database, suggestion_id) + if suggestion.student != student: + raise NotFound() + return suggestion -async def get_latest_edition(edition: Edition = Depends(get_edition), database: AsyncSession = Depends(get_session)) \ +async def get_editable_edition(edition: Edition = Depends(get_edition)) \ -> Edition: - """Checks if the given edition is the latest one (others are read-only) and returns it if it is""" - latest = await latest_edition(database) - if edition != latest: + """Checks if the requested edition is editable, and returns it if it is""" + if edition.readonly: raise ReadOnlyEditionException - return latest + return edition oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login/token/email") diff --git a/backend/src/database/crud/editions.py b/backend/src/database/crud/editions.py index f871b65ea..ac147041e 100644 --- a/backend/src/database/crud/editions.py +++ b/backend/src/database/crud/editions.py @@ -1,4 +1,4 @@ -from sqlalchemy import exc, func, select, desc +from sqlalchemy import exc, select, desc from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import Select @@ -24,7 +24,7 @@ async def get_edition_by_name(db: AsyncSession, edition_name: str) -> Edition: def _get_editions_query() -> Select: - return select(Edition).order_by(desc(Edition.edition_id)) + return select(Edition).order_by(desc(Edition.year), desc(Edition.edition_id)) async def get_editions(db: AsyncSession) -> list[Edition]: @@ -70,12 +70,7 @@ async def delete_edition(db: AsyncSession, edition_name: str): await db.commit() -async def latest_edition(db: AsyncSession) -> Edition: - """Returns the latest edition from the database""" - subquery = select(func.max(Edition.edition_id)) - result = await db.execute(subquery) - max_edition_id = result.scalar() - - query = select(Edition).where(Edition.edition_id == max_edition_id) - result2 = await db.execute(query) - return result2.scalars().one() +async def patch_edition(db: AsyncSession, edition: Edition, readonly: bool): + """Update the readonly status of an edition""" + edition.readonly = readonly + await db.commit() diff --git a/backend/src/database/crud/users.py b/backend/src/database/crud/users.py index c684308c7..21df9a718 100644 --- a/backend/src/database/crud/users.py +++ b/backend/src/database/crud/users.py @@ -9,32 +9,17 @@ from src.database.models import user_editions, User, Edition, CoachRequest, AuthEmail, AuthGitHub, AuthGoogle -async def get_user_edition_names(db: AsyncSession, user: User) -> list[str]: +async def get_user_editions(db: AsyncSession, user: User) -> list[Edition]: """Get all names of the editions this user can see""" # For admins: return all editions - otherwise, all editions this user is verified coach in - source = user.editions if not user.admin else await get_editions(db) - - editions = [] - # Name & year are non-nullable in the database, so it can never be None, - # but MyPy doesn't seem to grasp that concept just yet so we have to check it - # Could be a oneliner/list comp but that's a bit less readable - # Return from newest to oldest - for edition in sorted(source, key=lambda e: e.year or -1, reverse=True): - if edition.name is not None: - editions.append(edition.name) - - return editions + # Sort by year first, id second, descending + return sorted(user.editions, key=lambda x: (x.year, x.edition_id), + reverse=True) if not user.admin else await get_editions(db) async def get_users_filtered_page(db: AsyncSession, params: FilterParameters): """ Get users and filter by optional parameters: - :param admin: only return admins / only return non-admins - :param edition_name: only return users who are coach of the given edition - :param exclude_edition_name: only return users who are not coach of the given edition - :param name: a string which the user's name must contain - :param page: the page to return - Note: When the admin parameter is set, edition_name and exclude_edition_name will be ignored. """ @@ -57,7 +42,7 @@ async def get_users_filtered_page(db: AsyncSession, params: FilterParameters): if params.exclude_edition is not None: exclude_edition = await get_edition_by_name(db, params.exclude_edition) - exclude_user_id = select(user_editions.c.user_id)\ + exclude_user_id = select(user_editions.c.user_id) \ .where(user_editions.c.edition_id == exclude_edition.edition_id) query = query.filter(User.user_id.not_in(exclude_user_id)) @@ -185,7 +170,7 @@ async def reject_request(db: AsyncSession, request_id: int): async def remove_request_if_exists(db: AsyncSession, user_id: int, edition_name: str): """Remove a pending request for a user if there is one, otherwise do nothing""" edition = (await db.execute(select(Edition).where(Edition.name == edition_name))).scalar_one() - delete_query = delete(CoachRequest).where(CoachRequest.user_id == user_id)\ + delete_query = delete(CoachRequest).where(CoachRequest.user_id == user_id) \ .where(CoachRequest.edition_id == edition.edition_id) await db.execute(delete_query) await db.commit() diff --git a/backend/src/database/models.py b/backend/src/database/models.py index 15142ea33..5a904db2e 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -95,6 +95,7 @@ class Edition(Base): edition_id = Column(Integer, primary_key=True) name: str = Column(Text, unique=True, nullable=False) year: int = Column(Integer, nullable=False) + readonly: bool = Column(Boolean, nullable=False, default=False) invite_links: list[InviteLink] = relationship("InviteLink", back_populates="edition", cascade="all, delete-orphan") projects: list[Project] = relationship("Project", back_populates="edition", cascade="all, delete-orphan") diff --git a/backend/templates/invites.txt b/backend/templates/invites.txt new file mode 100644 index 000000000..01972f3cd --- /dev/null +++ b/backend/templates/invites.txt @@ -0,0 +1,11 @@ +Dear future OSOC-coach + +Thank you for your interest in being part of the OSOC-community! With your help and expertise, we can make the students of today become the grand innovators of tomorrow. + +By clicking on the link below you can create an account to access our selections tool: + +{invite_link} + +We hope to see you soon! + +The OSOC-team \ No newline at end of file diff --git a/backend/tests/test_database/test_crud/test_users.py b/backend/tests/test_database/test_crud/test_users.py index c3b683851..c3a336dfc 100644 --- a/backend/tests/test_database/test_crud/test_users.py +++ b/backend/tests/test_database/test_crud/test_users.py @@ -149,7 +149,7 @@ async def test_get_all_admins_paginated_filter_name(database_session: AsyncSessi DB_PAGE_SIZE * 1.5), 0) -async def test_get_user_edition_names_empty(database_session: AsyncSession): +async def test_get_user_editions_empty(database_session: AsyncSession): """Test getting all editions from a user when there are none""" user = models.User(name="test") database_session.add(user) @@ -158,11 +158,11 @@ async def test_get_user_edition_names_empty(database_session: AsyncSession): # query the user to initiate association tables await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 0 -async def test_get_user_edition_names_admin(database_session: AsyncSession): +async def test_get_user_editions_admin(database_session: AsyncSession): """Test getting all editions for an admin""" user = models.User(name="test", admin=True) database_session.add(user) @@ -175,11 +175,11 @@ async def test_get_user_edition_names_admin(database_session: AsyncSession): await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # Not added to edition yet, but admin can see it anyway - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 1 -async def test_get_user_edition_names_coach(database_session: AsyncSession): +async def test_get_user_editions_coach(database_session: AsyncSession): """Test getting all editions for a coach when they aren't empty""" user = models.User(name="test") database_session.add(user) @@ -192,7 +192,7 @@ async def test_get_user_edition_names_coach(database_session: AsyncSession): await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 0 # Add user to a new edition @@ -200,9 +200,8 @@ async def test_get_user_edition_names_coach(database_session: AsyncSession): database_session.add(user) await database_session.commit() - # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) - assert editions == [edition.name] + editions = await users_crud.get_user_editions(database_session, user) + assert editions[0].name == edition.name async def test_get_all_users_from_edition(database_session: AsyncSession, data: dict[str, str]): diff --git a/backend/tests/test_routers/test_editions/test_editions/test_editions.py b/backend/tests/test_routers/test_editions/test_editions/test_editions.py index 792e396b7..a23dd5624 100644 --- a/backend/tests/test_routers/test_editions/test_editions/test_editions.py +++ b/backend/tests/test_routers/test_editions/test_editions/test_editions.py @@ -259,3 +259,18 @@ async def test_get_edition_by_name_coach_not_assigned(database_session: AsyncSes # Make the get request response = await auth_client.get(f"/editions/{edition2.name}") assert response.status_code == status.HTTP_403_FORBIDDEN + + +async def test_patch_edition(database_session: AsyncSession, auth_client: AuthClient): + """Test changing the status of an edition""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + await database_session.commit() + await auth_client.admin() + + async with auth_client: + response = await auth_client.patch(f"/editions/{edition.name}", json={"readonly": True}) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = await auth_client.get(f"/editions/{edition.name}") + assert response.json()["readonly"] diff --git a/backend/tests/test_routers/test_editions/test_invites/test_invites.py b/backend/tests/test_routers/test_editions/test_invites/test_invites.py index 6cf1373ef..ffca102bc 100644 --- a/backend/tests/test_routers/test_editions/test_invites/test_invites.py +++ b/backend/tests/test_routers/test_editions/test_invites/test_invites.py @@ -182,10 +182,10 @@ async def test_get_invite_present(database_session: AsyncSession, auth_client: A assert json["email"] == "test@ema.il" -async def test_create_invite_valid_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_create_invite_valid_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """Test endpoint for creating invites when data is valid, but the edition is read-only""" await auth_client.admin() - edition = Edition(year=2022, name="ed2022") + edition = Edition(year=2022, name="ed2022", readonly=True) edition2 = Edition(year=2023, name="ed2023") database_session.add(edition) database_session.add(edition2) diff --git a/backend/tests/test_routers/test_editions/test_projects/test_projects.py b/backend/tests/test_routers/test_editions/test_projects/test_projects.py index 88227bf69..ad330a011 100644 --- a/backend/tests/test_routers/test_editions/test_projects/test_projects.py +++ b/backend/tests/test_routers/test_editions/test_projects/test_projects.py @@ -117,6 +117,7 @@ async def test_create_project_same_partner(database_session: AsyncSession, auth_ await auth_client.admin() async with auth_client: + await auth_client.post(f"/editions/{edition.name}/projects", json={ "name": "test", "info_url": "https://info.com", @@ -232,7 +233,8 @@ async def test_patch_project(database_session: AsyncSession, auth_client: AuthCl edition: Edition = Edition(year=2022, name="ed2022") partner: Partner = Partner(name="partner 1") user: User = User(name="user 1") - project: Project = Project(name="project 1", edition=edition, partners=[partner], coaches=[user]) + project: Project = Project(name="project 1", edition=edition, partners=[ + partner], coaches=[user]) database_session.add(project) await database_session.commit() @@ -306,9 +308,9 @@ async def test_patch_wrong_project(database_session: AsyncSession, auth_client: assert json['projects'][0]['name'] == project.name -async def test_create_project_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_create_project_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """test create a project for a readonly edition""" - edition_22: Edition = Edition(year=2022, name="ed2022") + edition_22: Edition = Edition(year=2022, name="ed2022", readonly=True) edition_23: Edition = Edition(year=2023, name="ed2023") database_session.add(edition_22) database_session.add(edition_23) @@ -346,7 +348,8 @@ async def test_search_project_coach(database_session: AsyncSession, auth_client: await auth_client.coach(edition) database_session.add(Project(name="project 1", edition=edition)) - database_session.add(Project(name="project 2", edition=edition, coaches=[auth_client.user])) + database_session.add( + Project(name="project 2", edition=edition, coaches=[auth_client.user])) await database_session.commit() async with auth_client: @@ -385,6 +388,258 @@ async def test_delete_project_role(database_session: AsyncSession, auth_client: }) response = await auth_client.get("/editions/ed2022/projects/1/roles") assert len(response.json()["projectRoles"]) == 1 - await auth_client.delete("/editions/ed2022/projects/1/roles/1") + response = await auth_client.delete("/editions/ed2022/projects/1/roles/1") + assert response.status_code == status.HTTP_204_NO_CONTENT response = await auth_client.get("/editions/ed2022/projects/1/roles") assert len(response.json()["projectRoles"]) == 0 + + +async def test_make_project_role(database_session: AsyncSession, auth_client: AuthClient): + """test make a project role""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 1 + }) + assert response.status_code == status.HTTP_201_CREATED + json = response.json() + assert json["projectRoleId"] == 1 + assert json["projectId"] == 1 + assert json["description"] == "description" + assert json["skill"]["skillId"] == 1 + assert json["slots"] == 1 + + +async def test_make_project_role_negative_slots(database_session: AsyncSession, auth_client: AuthClient): + """test make a project role""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": -1 + }) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_make_project_role_zero_slots(database_session: AsyncSession, auth_client: AuthClient): + """test make a project role""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 0 + }) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_update_project_role(database_session: AsyncSession, auth_client: AuthClient): + """test update a project role""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 1 + }) + assert response.status_code == status.HTTP_201_CREATED + response = await auth_client.patch("/editions/ed2022/projects/1/roles/1", json={ + "skill_id": 1, + "description": "changed", + "slots": 2 + }) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert json["projectRoleId"] == 1 + assert json["projectId"] == 1 + assert json["description"] == "changed" + assert json["skill"]["skillId"] == 1 + assert json["slots"] == 2 + response = await auth_client.get("/editions/ed2022/projects/1/roles") + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["projectRoles"]) == 1 + assert json["projectRoles"][0]["projectRoleId"] == 1 + assert json["projectRoles"][0]["projectId"] == 1 + assert json["projectRoles"][0]["description"] == "changed" + assert json["projectRoles"][0]["skill"]["skillId"] == 1 + assert json["projectRoles"][0]["slots"] == 2 + + +async def test_update_project_role_negative_slots(database_session: AsyncSession, auth_client: AuthClient): + """test update a project role with negative slots""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 1 + }) + assert response.status_code == status.HTTP_201_CREATED + response = await auth_client.patch("/editions/ed2022/projects/1/roles/1", json={ + "skill_id": 1, + "description": "description", + "slots": -1 + }) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_update_project_role_zero_slots(database_session: AsyncSession, auth_client: AuthClient): + """test update a project role with zero slots""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 1 + }) + assert response.status_code == status.HTTP_201_CREATED + response = await auth_client.patch("/editions/ed2022/projects/1/roles/1", json={ + "skill_id": 1, + "description": "description", + "slots": 0 + }) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_get_project_role(database_session: AsyncSession, auth_client: AuthClient): + """test get project role""" + edition: Edition = Edition(year=2022, name="ed2022") + user: User = User(name="coach 1") + skill: Skill = Skill(name="Skill1") + database_session.add(edition) + database_session.add(user) + database_session.add(skill) + await database_session.commit() + + await auth_client.admin() + + async with auth_client: + response = await auth_client.post("/editions/ed2022/projects", json={ + "name": "test", + "partners": ["ugent"], + "coaches": [user.user_id] + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["projectId"] == 1 + response = await auth_client.post("/editions/ed2022/projects/1/roles", json={ + "skill_id": 1, + "description": "description", + "slots": 1 + }) + assert response.status_code == status.HTTP_201_CREATED + response = await auth_client.get("/editions/ed2022/projects/1/roles") + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["projectRoles"]) == 1 + assert json["projectRoles"][0]["projectRoleId"] == 1 + assert json["projectRoles"][0]["projectId"] == 1 + assert json["projectRoles"][0]["description"] == "description" + assert json["projectRoles"][0]["skill"]["skillId"] == 1 + assert json["projectRoles"][0]["slots"] == 1 diff --git a/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py index ee557b520..2dffa51c9 100644 --- a/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py +++ b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py @@ -97,9 +97,9 @@ async def test_add_pr_suggestion_non_existing_pr(database_session: AsyncSession, assert len((await database_session.execute(select(ProjectRoleSuggestion))).scalars().all()) == 0 -async def test_add_pr_suggestion_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_add_pr_suggestion_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """tests add a student to a project from an old edition""" - edition: Edition = Edition(year=2022, name="ed2022") + edition: Edition = Edition(year=2022, name="ed2022", readonly=True) project: Project = Project(name="project 1", edition=edition) skill: Skill = Skill(name="skill 1") project_role: ProjectRole = ProjectRole(project=project, skill=skill, slots=1) diff --git a/backend/tests/test_routers/test_editions/test_register/test_register.py b/backend/tests/test_routers/test_editions/test_register/test_register.py index bb6fd9462..af0546bb3 100644 --- a/backend/tests/test_routers/test_editions/test_register/test_register.py +++ b/backend/tests/test_routers/test_editions/test_register/test_register.py @@ -101,9 +101,9 @@ async def test_duplicate_user(database_session: AsyncSession, test_client: Async assert response.status_code == status.HTTP_409_CONFLICT -async def test_old_edition(database_session: AsyncSession, test_client: AsyncClient): +async def test_readonly_edition(database_session: AsyncSession, test_client: AsyncClient): """Tests trying to make a registration for a read-only edition""" - edition: Edition = Edition(year=2022, name="ed2022") + edition: Edition = Edition(year=2022, name="ed2022", readonly=True) edition3: Edition = Edition(year=2023, name="ed2023") invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") diff --git a/backend/tests/test_routers/test_editions/test_students/test_answers/__init__.py b/backend/tests/test_routers/test_editions/test_students/test_answers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_routers/test_editions/test_students/test_answers/test_answers.py b/backend/tests/test_routers/test_editions/test_students/test_answers/test_answers.py new file mode 100644 index 000000000..b63e9c38b --- /dev/null +++ b/backend/tests/test_routers/test_editions/test_students/test_answers/test_answers.py @@ -0,0 +1,110 @@ +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette import status + +from src.database.models import Edition, QuestionFileAnswer, User, Skill, Student, Question, QuestionAnswer +from src.database.enums import QuestionEnum +from tests.utils.authorization import AuthClient + + +@pytest.fixture +async def database_with_data(database_session: AsyncSession) -> AsyncSession: + """fixture for adding data to the database""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + user: User = User(name="coach1") + database_session.add(user) + skill1: Skill = Skill(name="skill1") + skill2: Skill = Skill(name="skill2") + skill3: Skill = Skill(name="skill3") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3]) + student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", + email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill2]) + database_session.add(student01) + database_session.add(student02) + question1: Question = Question( + type=QuestionEnum.INPUT_TEXT, question="Tell me something", student=student01, answers=[], files=[]) + question2: Question = Question( + type=QuestionEnum.MULTIPLE_CHOICE, question="Favorite drink", student=student01, answers=[], files=[]) + database_session.add(question1) + database_session.add(question2) + question_answer1: QuestionAnswer = QuestionAnswer( + answer="I like pizza", question=question1) + question_answer2: QuestionAnswer = QuestionAnswer( + answer="ICE TEA", question=question2) + question_answer3: QuestionAnswer = QuestionAnswer( + answer="Cola", question=question2) + database_session.add(question_answer1) + database_session.add(question_answer2) + database_session.add(question_answer3) + question_file_answer: QuestionFileAnswer = QuestionFileAnswer( + file_name="pizza.txt", url="een/link/naar/pizza.txt", mime_type="text/plain", size=16, question=question1 + ) + database_session.add(question_file_answer) + await database_session.commit() + return database_session + + +@pytest.fixture +async def current_edition(database_with_data: AsyncSession) -> Edition: + """fixture to get the latest edition""" + return (await database_with_data.execute(select(Edition))).scalars().all()[-1] + + +async def test_get_answers_not_logged_in(database_with_data: AsyncSession, auth_client: AuthClient): + """test get answers when not logged in""" + async with auth_client: + response = await auth_client.get("/editions/ed2022/students/1/answers", follow_redirects=True) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +async def test_get_answers_as_coach(database_with_data: AsyncSession, auth_client: AuthClient, + current_edition: Edition): + """test get answers when logged in as coach""" + await auth_client.coach(current_edition) + async with auth_client: + response = await auth_client.get("/editions/ed2022/students/1/answers", follow_redirects=True) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["qAndA"]) == 2 + assert json["qAndA"][0]["question"] == "Tell me something" + assert len(json["qAndA"][0]["answers"]) == 1 + assert json["qAndA"][0]["answers"][0] == "I like pizza" + assert len(json["qAndA"][0]["files"]) == 1 + assert json["qAndA"][0]["files"][0]["filename"] == "pizza.txt" + assert json["qAndA"][0]["files"][0]["mimeType"] == "text/plain" + assert json["qAndA"][0]["files"][0]["url"] == "een/link/naar/pizza.txt" + assert json["qAndA"][1]["question"] == "Favorite drink" + assert len(json["qAndA"][1]["answers"]) == 2 + assert json["qAndA"][1]["answers"][0] == "ICE TEA" + assert json["qAndA"][1]["answers"][1] == "Cola" + assert len(json["qAndA"][1]["files"]) == 0 + + +async def test_get_answers_as_admin(database_with_data: AsyncSession, auth_client: AuthClient): + """test get answers when logged in as coach""" + await auth_client.admin() + async with auth_client: + response = await auth_client.get("/editions/ed2022/students/1/answers", follow_redirects=True) + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert len(json["qAndA"]) == 2 + assert json["qAndA"][0]["question"] == "Tell me something" + assert len(json["qAndA"][0]["answers"]) == 1 + assert json["qAndA"][0]["answers"][0] == "I like pizza" + assert len(json["qAndA"][0]["files"]) == 1 + assert json["qAndA"][0]["files"][0]["filename"] == "pizza.txt" + assert json["qAndA"][0]["files"][0]["mimeType"] == "text/plain" + assert json["qAndA"][0]["files"][0]["url"] == "een/link/naar/pizza.txt" + assert json["qAndA"][1]["question"] == "Favorite drink" + assert len(json["qAndA"][1]["answers"]) == 2 + assert json["qAndA"][1]["answers"][0] == "ICE TEA" + assert json["qAndA"][1]["answers"][1] == "Cola" + assert len(json["qAndA"][1]["files"]) == 0 diff --git a/backend/tests/test_routers/test_editions/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_students/test_students.py index b0309f490..33ef33d5c 100644 --- a/backend/tests/test_routers/test_editions/test_students/test_students.py +++ b/backend/tests/test_routers/test_editions/test_students/test_students.py @@ -587,8 +587,11 @@ async def test_creat_email_for_ghost(database_with_data: AsyncSession, auth_clie assert response.status_code == status.HTTP_404_NOT_FOUND -async def test_creat_email_student_in_other_edition(database_with_data: AsyncSession, auth_client: AuthClient): - """test creat an email for a student not in this edition""" +async def test_create_email_student_in_other_edition_bulk(database_with_data: AsyncSession, auth_client: AuthClient): + """test creating an email for a student not in this edition when sending them in bulk + The expected result is that only the mails to students in that edition are sent, and the + others are ignored + """ edition: Edition = Edition(year=2023, name="ed2023") database_with_data.add(edition) student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", @@ -599,9 +602,30 @@ async def test_creat_email_student_in_other_edition(database_with_data: AsyncSes await auth_client.admin() async with auth_client: response = await auth_client.post("/editions/ed2022/students/emails", - json={"students_id": [3], "email_status": 5}) - assert response.status_code == status.HTTP_201_CREATED - assert len(response.json()["studentEmails"]) == 0 + json={"students_id": [1, student.student_id], "email_status": 5}) + + # When sending a request for students that aren't in this edition, + # it ignores them & creates emails for the rest instead + assert response.status_code == status.HTTP_201_CREATED + assert len(response.json()["studentEmails"]) == 1 + assert response.json()["studentEmails"][0]["student"]["studentId"] == 1 + + +async def test_create_emails_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): + """Test sending emails in a readonly edition""" + edition: Edition = Edition(year=2023, name="ed2023", readonly=True) + database_session.add(edition) + student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", + email_address="mehmet.dizdar@example.com", phone_number="(787)-938-6216", alumni=True, + wants_to_be_student_coach=False, edition=edition, skills=[]) + database_session.add(student) + await database_session.commit() + await auth_client.admin() + + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/students/emails", + json={"students_id": [student.student_id], "email_status": 5}) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED async def test_get_emails_no_authorization(database_with_data: AsyncSession, auth_client: AuthClient): diff --git a/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py index 1e50958e4..debdc7389 100644 --- a/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py +++ b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py @@ -74,6 +74,26 @@ async def test_new_suggestion(database_with_data: AsyncSession, auth_client: Aut "suggestion"]["argumentation"] == suggestions[0].argumentation +async def test_new_suggestion_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): + """Tests creating a new suggestion when the edition is read-only""" + edition = Edition(year=2022, name="ed2022", readonly=True) + await auth_client.admin() + + student: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=False, + decision=DecisionEnum.YES, wants_to_be_student_coach=False, edition=edition, + skills=[]) + + database_session.add(edition) + database_session.add(student) + await database_session.commit() + + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/students/{student.student_id}/suggestions", + json={"suggestion": 1, "argumentation": "test"}) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + async def test_overwrite_suggestion(database_with_data: AsyncSession, auth_client: AuthClient): """Tests that when you've already made a suggestion earlier, the existing one is replaced""" # Create initial suggestion @@ -136,7 +156,7 @@ async def test_get_suggestions_of_student(database_with_data: AsyncSession, auth assert (await auth_client.post("/editions/ed2022/students/2/suggestions", json={ "suggestion": 3, "argumentation": "Neen"})).status_code == status.HTTP_201_CREATED res = await auth_client.get( - "/editions/1/students/2/suggestions") + "/editions/ed2022/students/2/suggestions") assert res.status_code == status.HTTP_200_OK res_json = res.json() assert len(res_json["suggestions"]) == 2 @@ -155,8 +175,8 @@ async def test_delete_ghost_suggestion(database_with_data: AsyncSession, auth_cl "/editions/ed2022/students/1/suggestions/8000")).status_code == status.HTTP_404_NOT_FOUND -async def test_delete_not_autorized(database_with_data: AsyncSession, auth_client: AuthClient): - """Tests that you have to be loged in for deleating a suggestion""" +async def test_delete_not_authorized(database_with_data: AsyncSession, auth_client: AuthClient): + """Tests that you have to be logged in in order to delete a suggestion""" async with auth_client: assert (await auth_client.delete( "/editions/ed2022/students/1/suggestions/8000")).status_code == status.HTTP_401_UNAUTHORIZED @@ -183,12 +203,32 @@ async def test_delete_suggestion_coach_their_review(database_with_data: AsyncSes assert new_suggestion.status_code == status.HTTP_201_CREATED suggestion_id = new_suggestion.json()["suggestion"]["suggestionId"] assert (await auth_client.delete( - f"/editions/ed2022/students/1/suggestions/{suggestion_id}")).status_code == status.HTTP_204_NO_CONTENT + f"/editions/ed2022/students/2/suggestions/{suggestion_id}")).status_code == status.HTTP_204_NO_CONTENT suggestions: list[Suggestion] = (await database_with_data.execute(select( Suggestion).where(Suggestion.suggestion_id == suggestion_id))).unique().scalars().all() assert len(suggestions) == 0 +async def test_delete_suggestion_wrong_student(database_with_data: AsyncSession, auth_client: AuthClient): + """Test you can't delete an suggestion that's don't belong to that student""" + edition: Edition = (await database_with_data.execute(select(Edition))).scalars().all()[0] + await auth_client.coach(edition) + async with auth_client: + new_suggestion = await auth_client.post("/editions/ed2022/students/2/suggestions", + json={"suggestion": 1, "argumentation": "test"}) + assert new_suggestion.status_code == status.HTTP_201_CREATED + suggestion_id = new_suggestion.json()["suggestion"]["suggestionId"] + assert (await auth_client.delete( + f"/editions/ed2022/students/1/suggestions/{suggestion_id}")).status_code == status.HTTP_404_NOT_FOUND + res = await auth_client.get( + "/editions/ed2022/students/2/suggestions") + assert res.status_code == status.HTTP_200_OK + res_json = res.json() + assert len(res_json["suggestions"]) == 1 + assert res_json["suggestions"][0]["suggestion"] == 1 + assert res_json["suggestions"][0]["argumentation"] == "test" + + async def test_delete_suggestion_coach_other_review(database_with_data: AsyncSession, auth_client: AuthClient): """Tests that a coach can't delete other coaches their suggestions""" edition: Edition = (await database_with_data.execute(select(Edition))).scalars().all()[0] @@ -237,7 +277,7 @@ async def test_update_suggestion_coach_their_review(database_with_data: AsyncSes json={"suggestion": 1, "argumentation": "test"}) assert new_suggestion.status_code == status.HTTP_201_CREATED suggestion_id = new_suggestion.json()["suggestion"]["suggestionId"] - assert (await auth_client.put(f"/editions/ed2022/students/1/suggestions/{suggestion_id}", json={ + assert (await auth_client.put(f"/editions/ed2022/students/2/suggestions/{suggestion_id}", json={ "suggestion": 3, "argumentation": "test"})).status_code == status.HTTP_204_NO_CONTENT suggestion: Suggestion = (await database_with_data.execute(select( Suggestion).where(Suggestion.suggestion_id == suggestion_id))).unique().scalar_one() diff --git a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py index 9bd96f440..0dd80a7ee 100644 --- a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py +++ b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py @@ -122,7 +122,8 @@ async def test_webhook_missing_question(test_client: AsyncClient, webhook: Webho assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -async def test_new_webhook_old_edition(database_session: AsyncSession, auth_client: AuthClient, edition: Edition): +async def test_new_webhook_readonly_edition(database_session: AsyncSession, auth_client: AuthClient, edition: Edition): + edition.readonly = True database_session.add(Edition(year=2023, name="ed2023")) await database_session.commit() async with auth_client: diff --git a/backend/tests/test_routers/test_users/test_users.py b/backend/tests/test_routers/test_users/test_users.py index 2f7c1420a..b4a71dcb2 100644 --- a/backend/tests/test_routers/test_users/test_users.py +++ b/backend/tests/test_routers/test_users/test_users.py @@ -6,7 +6,7 @@ from settings import DB_PAGE_SIZE from src.database import models -from src.database.models import user_editions, CoachRequest +from src.database.models import user_editions, CoachRequest, Edition, User from tests.utils.authorization import AuthClient @@ -721,3 +721,20 @@ async def test_reject_request(database_session: AsyncSession, auth_client: AuthC response = await auth_client.post("users/requests/INVALID/reject") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_get_current_user(database_session: AsyncSession, auth_client: AuthClient): + """Test getting the current user from their access token""" + edition = Edition(year=2022, name="ed2022") + user = User(name="Pytest Admin", admin=True, editions=[edition]) + database_session.add(edition) + database_session.add(user) + await database_session.commit() + auth_client.login(user) + + async with auth_client: + response = await auth_client.get("/users/current") + assert response.status_code == status.HTTP_200_OK + assert response.json()["userId"] == auth_client.user.user_id + assert len(response.json()["editions"]) == 1 + assert response.json()["editions"][0]["name"] == edition.name diff --git a/backend/tests/test_schemas/test_validators.py b/backend/tests/test_schemas/test_validators.py index 0b2da8b97..d68d03a0c 100644 --- a/backend/tests/test_schemas/test_validators.py +++ b/backend/tests/test_schemas/test_validators.py @@ -1,7 +1,7 @@ import pytest from src.app.exceptions.validation_exception import ValidationException -from src.app.schemas.validators import validate_email_format, validate_edition +from src.app.schemas.validators import validate_email_format, validate_edition, validate_url def test_email_address(): @@ -42,3 +42,15 @@ def test_edition_name(): validate_edition("edition2022") validate_edition("Edition-2022") + + +def test_validate_url(): + """Test the validation of an url""" + validate_url("https://info.com") + validate_url("http://info") + with pytest.raises(ValidationException): + validate_url("ssh://info.com") + with pytest.raises(ValidationException): + validate_url("http:/info.com") + with pytest.raises(ValidationException): + validate_url("https:/info.com") diff --git a/frontend/src/components/AdminsComponents/AddAdmin.tsx b/frontend/src/components/AdminsComponents/AddAdmin.tsx index 8eae96571..3729dd185 100644 --- a/frontend/src/components/AdminsComponents/AddAdmin.tsx +++ b/frontend/src/components/AdminsComponents/AddAdmin.tsx @@ -2,14 +2,15 @@ import { getUsersNonAdmin, User } from "../../utils/api/users/users"; import { createRef, useEffect, useState } from "react"; import { addAdmin } from "../../utils/api/users/admins"; import { AddButtonDiv, EmailDiv, Warning } from "./styles"; -import { Button, Modal, Spinner } from "react-bootstrap"; +import { Button, Modal } from "react-bootstrap"; import { AsyncTypeahead, Menu } from "react-bootstrap-typeahead"; -import { Error, StyledMenuItem } from "../Common/Users/styles"; +import { StyledMenuItem } from "../Common/Users/styles"; import UserMenuItem from "../Common/Users/MenuItem"; import { EmailAndAuth } from "../Common/Users"; import CreateButton from "../Common/Buttons/CreateButton"; import { ModalContentConfirm } from "../Common/styles"; import Typeahead from "react-bootstrap-typeahead/types/core/Typeahead"; +import { toast } from "react-toastify"; import { StyledInput } from "../Common/Forms/styles"; /** @@ -20,8 +21,6 @@ import { StyledInput } from "../Common/Forms/styles"; export default function AddAdmin(props: { adminAdded: (user: User) => void }) { const [show, setShow] = useState(false); const [selected, setSelected] = useState(undefined); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); const [gettingData, setGettingData] = useState(false); // Waiting for data const [users, setUsers] = useState([]); // All users which are not a coach const [searchTerm, setSearchTerm] = useState(""); // The word set in filter @@ -43,20 +42,16 @@ export default function AddAdmin(props: { adminAdded: (user: User) => void }) { filter = searchTerm; } setGettingData(true); - setError(""); - try { - const response = await getUsersNonAdmin(filter, page); - if (page === 0) { - setUsers(response.users); - } else { - setUsers(users.concat(response.users)); - } - - setGettingData(false); - } catch (exception) { - setError("Oops, something went wrong..."); - setGettingData(false); + const response = await toast.promise(getUsersNonAdmin(filter, page), { + error: "Failed to retrieve users", + }); + if (page === 0) { + setUsers(response.users); + } else { + setUsers(users.concat(response.users)); } + + setGettingData(false); } function filterData(searchTerm: string) { @@ -67,7 +62,6 @@ export default function AddAdmin(props: { adminAdded: (user: User) => void }) { const handleClose = () => { setSelected(undefined); - setError(""); setShow(false); }; const handleShow = () => { @@ -75,44 +69,16 @@ export default function AddAdmin(props: { adminAdded: (user: User) => void }) { }; async function addUserAsAdmin(user: User) { - setLoading(true); - setError(""); - let success = false; - try { - success = await addAdmin(user.userId); - if (!success) { - setError("Something went wrong. Failed to add admin"); - } - } catch (error) { - setError("Something went wrong. Failed to add admin"); - } - setLoading(false); - if (success) { - props.adminAdded(user); - setSearchTerm(""); - getData(0, ""); - setSelected(undefined); - setClearRef(true); - } - } + await toast.promise(addAdmin(user.userId), { + pending: "Adding admin", + success: "Admin successfully added", + error: "Failed to add admin", + }); - let addButton; - if (loading) { - addButton = ; - } else { - addButton = ( - - ); + props.adminAdded(user); + setSearchTerm(""); + setSelected(undefined); + setClearRef(true); } let warning; @@ -150,7 +116,6 @@ export default function AddAdmin(props: { adminAdded: (user: User) => void }) { placeholder={"user's name"} onChange={selected => { setSelected(selected[0] as User); - setError(""); }} renderInput={({ inputRef, referenceElementRef, ...inputProps }) => ( void }) { {warning} - {addButton} + { + if (selected !== undefined) { + addUserAsAdmin(selected); + } + }} + disabled={selected === undefined} + > + Add admin + - {error} diff --git a/frontend/src/components/AdminsComponents/AdminList.tsx b/frontend/src/components/AdminsComponents/AdminList.tsx index b0667c3fc..3c98cf078 100644 --- a/frontend/src/components/AdminsComponents/AdminList.tsx +++ b/frontend/src/components/AdminsComponents/AdminList.tsx @@ -5,6 +5,7 @@ import { AdminListItem } from "./index"; import LoadSpinner from "../Common/LoadSpinner"; import { ListDiv } from "../Common/Users/styles"; import { SpinnerContainer } from "../Common/LoadSpinner/styles"; +import { RemoveTh } from "../Common/Tables/styles"; /** * List of [[AdminListItem]]s which represents all admins. @@ -41,7 +42,7 @@ export default function AdminList(props: { Name Email - Remove + Remove diff --git a/frontend/src/components/AdminsComponents/AdminListItem.tsx b/frontend/src/components/AdminsComponents/AdminListItem.tsx index 2d3bb937f..2d7a5ea03 100644 --- a/frontend/src/components/AdminsComponents/AdminListItem.tsx +++ b/frontend/src/components/AdminsComponents/AdminListItem.tsx @@ -2,6 +2,7 @@ import { User } from "../../utils/api/users/users"; import React from "react"; import { RemoveAdmin } from "./index"; import { EmailAndAuth } from "../Common/Users"; +import { RemoveTd } from "../Common/Tables/styles"; /** * An item from [[AdminList]]. Contains the credentials of an admin and a button to remove the admin. @@ -15,9 +16,9 @@ export default function AdminItem(props: { admin: User; removeAdmin: (user: User - + - + ); } diff --git a/frontend/src/components/AdminsComponents/RemoveAdmin.tsx b/frontend/src/components/AdminsComponents/RemoveAdmin.tsx index 0f45d8cfa..962815015 100644 --- a/frontend/src/components/AdminsComponents/RemoveAdmin.tsx +++ b/frontend/src/components/AdminsComponents/RemoveAdmin.tsx @@ -3,8 +3,9 @@ import React, { useState } from "react"; import { removeAdmin, removeAdminAndCoach } from "../../utils/api/users/admins"; import { Button, Modal } from "react-bootstrap"; import { RemoveAdminBody } from "./styles"; -import { Error } from "../Common/Users/styles"; import { ModalContentWarning } from "../Common/styles"; +import { toast } from "react-toastify"; +import DeleteButton from "../Common/Buttons/DeleteButton"; /** * Button and popup to remove a user as admin (and as coach). @@ -13,38 +14,35 @@ import { ModalContentWarning } from "../Common/styles"; */ export default function RemoveAdmin(props: { admin: User; removeAdmin: (user: User) => void }) { const [show, setShow] = useState(false); - const [error, setError] = useState(""); const handleClose = () => setShow(false); const handleShow = () => { setShow(true); - setError(""); }; async function removeUserAsAdmin(removeCoach: boolean) { - try { - let removed; - if (removeCoach) { - removed = await removeAdminAndCoach(props.admin.userId); - } else { - removed = await removeAdmin(props.admin.userId); - } - - if (removed) { - props.removeAdmin(props.admin); - } else { - setError("Something went wrong. Failed to remove admin"); - } - } catch (error) { - setError("Something went wrong. Failed to remove admin"); + if (removeCoach) { + await toast.promise(removeAdminAndCoach(props.admin.userId), { + pending: "Removing admin", + success: "Admin successfully removed", + error: "Failed to remove admin", + }); + } else { + await toast.promise(removeAdmin(props.admin.userId), { + pending: "Removing admin", + success: "Admin successfully removed", + error: "Failed to remove admin", + }); } + + props.removeAdmin(props.admin); } return ( <> - + @@ -59,32 +57,25 @@ export default function RemoveAdmin(props: { admin: User; removeAdmin: (user: Us - - + - {error} diff --git a/frontend/src/components/Common/Buttons/OrangeButton.tsx b/frontend/src/components/Common/Buttons/OrangeButton.tsx new file mode 100644 index 000000000..5924e338c --- /dev/null +++ b/frontend/src/components/Common/Buttons/OrangeButton.tsx @@ -0,0 +1,19 @@ +import { BasicButton } from "./props"; +import { OrangeButton as StyledOrangeButton } from "./styles"; + +/** + * Orange button + */ +export default function OrangeButton({ + label = "", + showIcon = false, + children, + ...props +}: BasicButton) { + return ( + + {children} + {label} + + ); +} diff --git a/frontend/src/components/Common/Buttons/buttonsStyles.css b/frontend/src/components/Common/Buttons/buttonsStyles.css new file mode 100644 index 000000000..3614d98cf --- /dev/null +++ b/frontend/src/components/Common/Buttons/buttonsStyles.css @@ -0,0 +1,10 @@ +.show > .btn-primary.dropdown-toggle { + background-color: var(--osoc_orange); + border-color: var(--osoc_orange); + color: var(--osoc_blue); + box-shadow: none !important; +} + +.dropdown-menu { + width: 100%; +} diff --git a/frontend/src/components/Common/Buttons/index.ts b/frontend/src/components/Common/Buttons/index.ts index 502fca43f..85cf45cb7 100644 --- a/frontend/src/components/Common/Buttons/index.ts +++ b/frontend/src/components/Common/Buttons/index.ts @@ -1,3 +1,4 @@ export { default as CreateButton } from "./CreateButton"; export { default as DeleteButton } from "./DeleteButton"; +export { default as OrangeButton } from "./OrangeButton"; export { default as WarningButton } from "./WarningButton"; diff --git a/frontend/src/components/Common/Buttons/styles.ts b/frontend/src/components/Common/Buttons/styles.ts index bdd179b02..3fc91679b 100644 --- a/frontend/src/components/Common/Buttons/styles.ts +++ b/frontend/src/components/Common/Buttons/styles.ts @@ -1,9 +1,9 @@ -import Button from "react-bootstrap/Button"; import styled, { css } from "styled-components"; +import "./buttonsStyles.css"; import { HoverAnimation } from "../styles"; import { AnimatedButton } from "./props"; -import { Dropdown } from "react-bootstrap"; +import { Dropdown, DropdownButton, Button } from "react-bootstrap"; export const GreenButton = styled(Button)` ${HoverAnimation}; @@ -28,6 +28,23 @@ export const GreenButton = styled(Button)` } `; +export const OrangeButton = styled(Button)` + ${HoverAnimation}; + + background-color: var(--osoc_orange); + border-color: var(--osoc_orange); + color: var(--osoc_blue); + + &:hover, + &:active, + &:focus { + background-color: var(--osoc_orange_darkened); + border-color: var(--osoc_orange_darkened); + color: var(--osoc_blue); + box-shadow: none !important; + } +`; + export const DropdownToggle = styled(Dropdown.Toggle)` ${HoverAnimation}; @@ -99,3 +116,24 @@ export const RedButton = styled(Button)` box-shadow: none !important; } `; + +export const CommonDropdownButton = styled(DropdownButton).attrs({ + menuVariant: "dark", +})` + & > Button { + ${HoverAnimation}; + + background-color: var(--osoc_green); + border-color: var(--osoc_green); + color: var(--osoc_blue); + + &:hover, + &:active, + &:focus { + background-color: var(--osoc_orange); + border-color: var(--osoc_orange); + color: var(--osoc_blue); + box-shadow: none !important; + } + } +`; diff --git a/frontend/src/components/Common/Forms/CommonMultiselect.tsx b/frontend/src/components/Common/Forms/CommonMultiselect.tsx new file mode 100644 index 000000000..617794c06 --- /dev/null +++ b/frontend/src/components/Common/Forms/CommonMultiselect.tsx @@ -0,0 +1,6 @@ +import { StyledMultiSelect } from "./styles"; +import { IMultiselectProps } from "multiselect-react-dropdown/dist/multiselect/interface"; + +export default function CommonMultiselect(props: IMultiselectProps) { + return ; +} diff --git a/frontend/src/components/Common/Forms/formsStyles.css b/frontend/src/components/Common/Forms/formsStyles.css new file mode 100644 index 000000000..b28b8be4f --- /dev/null +++ b/frontend/src/components/Common/Forms/formsStyles.css @@ -0,0 +1,45 @@ +.optionListContainer { + background-color: var(--osoc_blue); + color: white; +} + +.searchWrapper { + border: 2px solid #323252; +} + +.searchWrapper:hover { + border: 2px solid var(--osoc_green); +} + +.searchWrapper:active { + border: 2px solid var(--osoc_green); +} + +.multiSelectContainer input { + color: white; +} + +.icon_down_dir { + filter: invert(70%); +} + +.multiSelectContainer li:hover { + background-color: #363649 !important; +} + +.multiSelectContainer ul { + border: none; +} + +.chip { + background-color: var(--osoc_green); + color: var(--osoc_blue); +} + +.icon_cancel { + filter: invert(80%); +} + +.highlightOption { + background-color: transparent; +} diff --git a/frontend/src/components/Common/Forms/index.ts b/frontend/src/components/Common/Forms/index.ts index 48333a185..c5c66f801 100644 --- a/frontend/src/components/Common/Forms/index.ts +++ b/frontend/src/components/Common/Forms/index.ts @@ -1,2 +1,3 @@ export { default as FormControl } from "./FormControl"; export { default as SearchBar } from "./SearchBar"; +export { default as CommonMultiselect } from "./CommonMultiselect"; diff --git a/frontend/src/components/Common/Forms/styles.ts b/frontend/src/components/Common/Forms/styles.ts index c18d4a7b3..c65139861 100644 --- a/frontend/src/components/Common/Forms/styles.ts +++ b/frontend/src/components/Common/Forms/styles.ts @@ -1,5 +1,7 @@ import styled from "styled-components"; import Form from "react-bootstrap/Form"; +import Multiselect from "multiselect-react-dropdown"; +import "./formsStyles.css"; import { Input } from "react-bootstrap-typeahead"; export const StyledFormControl = styled(Form.Control)` @@ -25,7 +27,9 @@ export const StyledSearchBar = styled(Form.Control)` color: white; border: 2px solid #323252; - &:focus { + &:focus, + &:hover, + &:active { background-color: var(--osoc_blue); color: white; border-color: var(--osoc_green); @@ -57,3 +61,8 @@ export const StyledInput = styled(Input)` border: 2px solid var(--osoc_red); } `; + +export const StyledMultiSelect = styled(Multiselect).attrs({ variant: "dark" })` + background-color: var(--osoc_blue); + color: white; +`; diff --git a/frontend/src/components/Common/Tables/styles.ts b/frontend/src/components/Common/Tables/styles.ts index 9cc14f420..95b6ded13 100644 --- a/frontend/src/components/Common/Tables/styles.ts +++ b/frontend/src/components/Common/Tables/styles.ts @@ -7,3 +7,13 @@ export const StyledTable = styled(Table).attrs({ variant: "dark", hover: false, })``; + +export const RemoveTh = styled.th` + width: 200px; + text-align: center; +`; + +export const RemoveTd = styled.td` + text-align: center; + vertical-align: middle; +`; diff --git a/frontend/src/components/Common/Users/styles.ts b/frontend/src/components/Common/Users/styles.ts index 1613ecadc..fcfb5ed99 100644 --- a/frontend/src/components/Common/Users/styles.ts +++ b/frontend/src/components/Common/Users/styles.ts @@ -42,12 +42,6 @@ export const ListDiv = styled.div` margin-top: 10px; `; -export const Error = styled.div` - color: var(--osoc_red); - width: 100%; - margin: auto; -`; - export const SearchFieldDiv = styled.div` float: left; width: 15em; diff --git a/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx b/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx index bd9c8ee99..be2d5a749 100644 --- a/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx +++ b/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx @@ -1,16 +1,17 @@ import { Navigate, Outlet, useParams } from "react-router-dom"; -import { useAuth } from "../../contexts/auth-context"; +import { useAuth } from "../../contexts"; import { Role } from "../../data/enums"; +import { isReadonlyEdition } from "../../utils/logic"; /** - * React component for current edition and admin-only routes. + * React component for editable editions and admin-only routes. * Redirects to the [[LoginPage]] (status 401) if not authenticated, - * and to the [[ForbiddenPage]] (status 403) if not admin or not the current edition. + * and to the [[ForbiddenPage]] (status 403) if not admin or read-only. * * Example usage: * ```ts * }> - * // These routes will only render if the user is an admin and is on the current edition + * // These routes will only render if the user is an admin and is not on a read-only edition * * * @@ -22,7 +23,7 @@ export default function CurrentEditionRoute() { const editionId = params.editionId; return !isLoggedIn ? ( - ) : role === Role.COACH || editionId !== editions[0] ? ( + ) : role === Role.COACH || isReadonlyEdition(editionId, editions) ? ( ) : ( diff --git a/frontend/src/components/EditionsPage/DeleteEditionButton.tsx b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx index 298464b7e..ca23830ad 100644 --- a/frontend/src/components/EditionsPage/DeleteEditionButton.tsx +++ b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx @@ -26,7 +26,7 @@ export default function DeleteEditionButton(props: Props) { } return ( - + Delete this edition diff --git a/frontend/src/components/EditionsPage/EditionRow.tsx b/frontend/src/components/EditionsPage/EditionRow.tsx index aba87e499..18fd757ee 100644 --- a/frontend/src/components/EditionsPage/EditionRow.tsx +++ b/frontend/src/components/EditionsPage/EditionRow.tsx @@ -1,9 +1,13 @@ import { Edition } from "../../data/interfaces"; import DeleteEditionButton from "./DeleteEditionButton"; import { RowContainer } from "./styles"; +import MarkReadonlyButton from "./MarkReadonlyButton"; +import React from "react"; +import Col from "react-bootstrap/Col"; interface Props { edition: Edition; + handleClick: (edition: Edition) => Promise; } /** @@ -13,11 +17,21 @@ export default function EditionRow(props: Props) { return ( -
-

{props.edition.name}

- {props.edition.year} -
- + +
+

{props.edition.name}

+ {props.edition.year} +
+ + + await props.handleClick(props.edition)} + /> + + + +
); diff --git a/frontend/src/components/EditionsPage/EditionsTable.tsx b/frontend/src/components/EditionsPage/EditionsTable.tsx index c4284f66b..154a36537 100644 --- a/frontend/src/components/EditionsPage/EditionsTable.tsx +++ b/frontend/src/components/EditionsPage/EditionsTable.tsx @@ -1,8 +1,12 @@ import React, { useEffect, useState } from "react"; -import { StyledTable, LoadingSpinner } from "./styles"; -import { getEditions } from "../../utils/api/editions"; +import { LoadingSpinner, StyledTable } from "./styles"; +import { getEditions, patchEdition } from "../../utils/api/editions"; import EditionRow from "./EditionRow"; import EmptyEditionsTableMessage from "./EmptyEditionsTableMessage"; +import { Edition } from "../../data/interfaces"; +import { toast } from "react-toastify"; +import { updateEditionState, useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; /** * Table on the [[EditionsPage]] that renders a list of all editions @@ -11,14 +15,30 @@ import EmptyEditionsTableMessage from "./EmptyEditionsTableMessage"; * If the user is an admin, this will also render a delete button. */ export default function EditionsTable() { + const authCtx = useAuth(); const [loading, setLoading] = useState(true); const [rows, setRows] = useState([]); + async function handleClick(edition: Edition) { + if (authCtx.role !== Role.ADMIN) return; + + await toast.promise(async () => await patchEdition(edition.name, !edition.readonly), { + pending: "Changing edition status", + error: "Error changing status", + success: `Successfully changed status to ${ + edition.readonly ? "editable" : "read-only" + }.`, + }); + + updateEditionState(authCtx, edition); + await loadEditions(); + } + async function loadEditions() { const response = await getEditions(); const newRows: React.ReactNode[] = response.editions.map(edition => ( - + )); setRows(newRows); @@ -27,6 +47,7 @@ export default function EditionsTable() { useEffect(() => { loadEditions(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Still loading: display a spinner instead diff --git a/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx b/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx new file mode 100644 index 000000000..bb43e7e28 --- /dev/null +++ b/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx @@ -0,0 +1,28 @@ +import { Edition } from "../../data/interfaces"; +import { StyledReadonlyText } from "./styles"; +import { useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; + +interface Props { + edition: Edition; + handleClick: () => void; +} + +/** + * Button on the [[EditionsPage]], displayed in an [[EditionsRow]], to toggle the readonly + * state of an edition. + */ +export default function MarkReadonlyButton({ edition, handleClick }: Props) { + const { role } = useAuth(); + const label = edition.readonly ? "READ-ONLY" : "EDITABLE"; + + return ( + + {label} + + ); +} diff --git a/frontend/src/components/EditionsPage/styles.ts b/frontend/src/components/EditionsPage/styles.ts index adc52230a..34f30c3c5 100644 --- a/frontend/src/components/EditionsPage/styles.ts +++ b/frontend/src/components/EditionsPage/styles.ts @@ -37,3 +37,27 @@ export const StyledNewEditionButton = styled(Button).attrs(() => ({ border-color: var(--osoc_orange); } `; + +interface TextProps { + readonly: boolean; + clickable: boolean; +} + +export const StyledReadonlyText = styled.div` + text-decoration: none; + transition: 200ms ease-out; + color: ${props => (props.readonly ? "var(--osoc_red)" : "var(--osoc_green)")}; + font-weight: bold; + + // Only change style on hover for admins + ${({ clickable }) => + clickable && + ` + &:hover { + text-decoration: underline; + transition: 200ms ease-out; + color: var(--osoc_orange); + cursor: pointer; + } + `}; +`; diff --git a/frontend/src/components/Navbar/EditionDropdown.tsx b/frontend/src/components/Navbar/EditionDropdown.tsx index c6e7ceb78..c7f30cfd7 100644 --- a/frontend/src/components/Navbar/EditionDropdown.tsx +++ b/frontend/src/components/Navbar/EditionDropdown.tsx @@ -1,12 +1,13 @@ import React from "react"; import NavDropdown from "react-bootstrap/NavDropdown"; -import { StyledDropdownItem } from "./styles"; +import { StyledDropdownItem, DropdownLabel } from "./styles"; import { useLocation, useNavigate } from "react-router-dom"; import { getCurrentEdition, setCurrentEdition } from "../../utils/session-storage"; import { getBestRedirect } from "../../utils/logic"; +import { Edition } from "../../data/interfaces"; interface Props { - editions: string[]; + editions: Edition[]; } /** @@ -23,11 +24,17 @@ export default function EditionDropdown(props: Props) { return null; } + // User can only access one edition, just show the label + // Don't make it a dropdown & don't make it clickable + if (props.editions.length === 1) { + return {props.editions[0].name}; + } + // If anything went wrong loading the edition, default to the first one // found in the list of editions // This shouldn't happen, but just in case // The list can never be empty because then we return null above ^ - const currentEdition = getCurrentEdition() || props.editions[0]; + const currentEdition = getCurrentEdition() || props.editions[0].name; /** * Change the route based on the edition @@ -42,17 +49,17 @@ export default function EditionDropdown(props: Props) { } // Load dropdown items dynamically - props.editions.forEach((edition: string) => { + props.editions.forEach((edition: Edition) => { navItems.push( handleSelect(edition)} + key={edition.name} + active={currentEdition === edition.name} + onClick={() => handleSelect(edition.name)} > - {edition} + {edition.name} ); }); - return {navItems}; + return {navItems}; } diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx index 012153f29..c40b6cb88 100644 --- a/frontend/src/components/Navbar/Navbar.tsx +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -42,7 +42,7 @@ export default function Navbar() { // Matched /editions/new path if (editionId === "new") { editionId = null; - } else if (editionId && !editions.includes(editionId)) { + } else if (editionId && !editions.find(e => e.name === editionId)) { // If the edition was not found in the user's list of editions, // don't display it in the navbar! // This will lead to a 404 or 403 re-route either way, so keep @@ -53,7 +53,8 @@ export default function Navbar() { // If the current URL contains an edition, use that // if not (eg. /editions), check SessionStorage // otherwise, use the most-recent edition from the auth response - const currentEdition = editionId || getCurrentEdition() || editions[0]; + const currentEdition = + editionId || getCurrentEdition() || (editions[0] && editions[0].name) || ""; // Set the value of the new edition in SessionStorage if useful if (currentEdition) { diff --git a/frontend/src/components/Navbar/StudentsDropdown.tsx b/frontend/src/components/Navbar/StudentsDropdown.tsx index f0e89268a..5ba5d7ac4 100644 --- a/frontend/src/components/Navbar/StudentsDropdown.tsx +++ b/frontend/src/components/Navbar/StudentsDropdown.tsx @@ -15,7 +15,7 @@ interface Props { * @constructor */ export default function StudentsDropdown(props: Props) { - if (!props.isLoggedIn) return null; + if (!props.isLoggedIn || !props.currentEdition) return null; if (props.role === Role.COACH) { return ( diff --git a/frontend/src/components/Navbar/styles.ts b/frontend/src/components/Navbar/styles.ts index e66d1bf78..6f011e90e 100644 --- a/frontend/src/components/Navbar/styles.ts +++ b/frontend/src/components/Navbar/styles.ts @@ -56,3 +56,7 @@ export const HorizontalSep = styled.hr` export const VerticalSep = styled.div` margin: 0 10px; `; + +export const DropdownLabel = styled.div` + color: rgba(255, 255, 255, 0.55); +`; diff --git a/frontend/src/components/ProjectsComponents/ProjectTable.tsx b/frontend/src/components/ProjectsComponents/ProjectTable.tsx index 17a7f3516..4b6d7e606 100644 --- a/frontend/src/components/ProjectsComponents/ProjectTable.tsx +++ b/frontend/src/components/ProjectsComponents/ProjectTable.tsx @@ -5,7 +5,6 @@ import { Project } from "../../data/interfaces"; import React from "react"; import { MessageDiv } from "./styles"; import LoadSpinner from "../Common/LoadSpinner"; -import { Error } from "../Common/Users/styles"; /** * A table of [[ProjectCard]]s. @@ -23,15 +22,8 @@ export default function ProjectTable(props: { getMoreProjects: () => void; moreProjectsAvailable: boolean; removeProject: (project: Project) => void; - error: string | undefined; }) { - if (props.error) { - return ( - - {props.error} - - ); - } else if (props.gotData && props.projects.length === 0) { + if (props.gotData && props.projects.length === 0) { return (
No projects found.
diff --git a/frontend/src/components/StudentInfoComponents/RemoveStudentButton/RemoveStudentButton.tsx b/frontend/src/components/StudentInfoComponents/RemoveStudentButton/RemoveStudentButton.tsx index 6571d08ca..db8ee1022 100644 --- a/frontend/src/components/StudentInfoComponents/RemoveStudentButton/RemoveStudentButton.tsx +++ b/frontend/src/components/StudentInfoComponents/RemoveStudentButton/RemoveStudentButton.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { StudentRemoveButton } from "../styles"; import { removeStudent } from "../../../utils/api/students"; import { useNavigate } from "react-router-dom"; +import { DeleteButton } from "../../Common/Buttons"; /** * Component that removes the current student. @@ -17,9 +17,5 @@ export default function RemoveStudentButton(props: { editionId: string; studentI navigate(`/editions/${props.editionId}/students/`); } - return ( - handleRemoveStudent()}> - Remove Student - - ); + return handleRemoveStudent()}>Remove Student; } diff --git a/frontend/src/components/StudentInfoComponents/StudentCopyLink/StudentCopyLink.tsx b/frontend/src/components/StudentInfoComponents/StudentCopyLink/StudentCopyLink.tsx new file mode 100644 index 000000000..5bf783740 --- /dev/null +++ b/frontend/src/components/StudentInfoComponents/StudentCopyLink/StudentCopyLink.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { StudentLink, CopyIcon, CopyLinkContainer } from "../StudentInformation/styles"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { faLink } from "@fortawesome/free-solid-svg-icons"; +import { toast } from "react-toastify"; + +/** + * Copy URL of current selected student. + */ +export default function StudentCopyLink() { + function copyStudentLink() { + navigator.clipboard.writeText(window.location.href); + toast.info("Student URL copied to clipboard!"); + } + return ( + + copy link + + + ); +} diff --git a/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.css b/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.css new file mode 100644 index 000000000..717d77b48 --- /dev/null +++ b/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.css @@ -0,0 +1,17 @@ +.CardContainer { + width: 100%; + background-color: var(--card-color) !important; + box-shadow: 5px 5px 15px #131329 !important; + border-radius: 5px !important; + border: 2px solid #1a1a36 !important; + margin-bottom: 2%; +} + +.CardHeader { + background-color: var(--card-color); + font-size: 30px; +} + +.CardBody { + background-color: var(--card-color); +} diff --git a/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.tsx b/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.tsx index f11826520..7b8a7e771 100644 --- a/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.tsx +++ b/frontend/src/components/StudentInfoComponents/StudentInformation/StudentInformation.tsx @@ -3,20 +3,20 @@ import { FullName, FirstName, LastName, - LineBreak, PreferedName, - StudentInfoTitle, SuggestionField, StudentInformationContainer, PersonalInfoFieldValue, PersonalInfoFieldSubject, - RolesField, - RolesValues, RoleValue, - NameAndRemoveButtonContainer, + NameContainer, SubjectFields, SubjectValues, PersonalInformation, + InfoHeadContainer, + AllName, + ActionContainer, + ActionsCard, } from "./styles"; import { AdminDecisionContainer, CoachSuggestionContainer } from "../SuggestionComponents"; import { Suggestion } from "../../../data/interfaces/suggestions"; @@ -28,6 +28,9 @@ import { Student } from "../../../data/interfaces/students"; import { getStudent } from "../../../utils/api/students"; import LoadSpinner from "../../Common/LoadSpinner"; import { toast } from "react-toastify"; +import StudentCopyLink from "../StudentCopyLink/StudentCopyLink"; +import "./StudentInformation.css"; +import { Card } from "react-bootstrap"; /** * Component that renders all information of a student and all buttons to perform actions on this student. @@ -42,14 +45,15 @@ export default function StudentInformation(props: { studentId: number; editionId * Get all the suggestion that were made on this student. */ async function getData() { - try { - const studentResponse = await getStudent(props.editionId, props.studentId); - const suggenstionsResponse = await getSuggestions(props.editionId, props.studentId); - setStudent(studentResponse); - setSuggestions(suggenstionsResponse.suggestions); - } catch (error) { - toast.error("Failed to get details", { toastId: "fetch_student_details_failed" }); - } + const studentResponse = await toast.promise(getStudent(props.editionId, props.studentId), { + error: "Failed to get details", + }); + const suggestionsResponse = await toast.promise( + getSuggestions(props.editionId, props.studentId), + { error: "Failed to get suggestions" } + ); + setStudent(studentResponse); + setSuggestions(suggestionsResponse.suggestions); } /** @@ -81,59 +85,78 @@ export default function StudentInformation(props: { studentId: number; editionId } else { return ( - - - {student.firstName} - {student.lastName} - - - - Preferred name: {student.preferredName} - - Suggestions - {suggestions.map(suggestion => ( - - {suggestion.coach.name}: "{suggestionToText(suggestion.suggestion)}"{" "} - {suggestion.argumentation} - - ))} - - Personal information - - - Email: - Phone number: - Is an alumni?: - - Wants to be student coach?: - - - - {student.emailAddress} - {student.phoneNumber} - - {student.alumni ? "Yes" : "No"} - - - {student.wantsToBeStudentCoach ? "Yes" : "No"} - - - - - Skills - - Roles: - + + + + + {student.firstName} + {student.lastName} + + +
+ {student.preferredName} +
+
+
+ + + Actions + + + {role === Role.ADMIN ? : <>} + + + +
+ + Suggestions + + {suggestions.map(suggestion => ( + + {suggestion.coach.name}: "{suggestionToText(suggestion.suggestion)}"{" "} + {suggestion.argumentation} + + ))} + + + + Personal information + + + + Email + Phone number + Is an alumni? + + Wants to be student coach? + + + + + {student.emailAddress} + + + {student.phoneNumber} + + + {student.alumni ? "Yes" : "No"} + + + {student.wantsToBeStudentCoach ? "Yes" : "No"} + + + + + + + Skills + {student.skills.map(skill => ( {skill.name} ))} -
-
- -
- - {role === Role.ADMIN ? : <>} -
+ + +
); } diff --git a/frontend/src/components/StudentInfoComponents/StudentInformation/styles.ts b/frontend/src/components/StudentInfoComponents/StudentInformation/styles.ts index 9e2b38cb0..2f7c1c773 100644 --- a/frontend/src/components/StudentInfoComponents/StudentInformation/styles.ts +++ b/frontend/src/components/StudentInfoComponents/StudentInformation/styles.ts @@ -1,42 +1,100 @@ import styled from "styled-components"; +import { BsPersonFill } from "react-icons/bs"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Card } from "react-bootstrap"; + +export const InfoHeadContainer = styled.div` + display: flex; + width: 100%; + margin-bottom: 1.5%; +`; export const StudentInformationContainer = styled.div` width: 100%; padding: 20px; `; +export const ActionsCard = styled(Card)` + max-width: 100%; +`; + +export const PersonIcon = styled(BsPersonFill)` + width: 20%; + height: 90%; +`; + +export const NameContainer = styled.div` + display: flex; + align-items: center; + margin-top: 1%; + margin-left: 1%; + width: 100%; +`; + +export const ActionContainer = styled.div` + align-items: flex-end; + flex-direction: column; + display: flex; + margin-top: 1%; + width: 100%; +`; + +export const AllName = styled.div` + display: flex; + flex-direction: column; + margin-left: 2%; +`; + export const FullName = styled.div` display: flex; `; -export const FirstName = styled.h1` +export const FirstName = styled.span` + font-size: 250%; padding-right: 10px; - color: var(--osoc_orange); + color: white; `; -export const LastName = styled.h1` - color: var(--osoc_orange); +export const LastName = styled.span` + font-size: 250%; + padding-right: 5px; + color: white; `; -export const PreferedName = styled.p` - font-size: 20px; +export const CopyLinkContainer = styled.div` + display: flex; + height: 40%; + align-self: center; + align-items: center; + font-size: 12px; + &:hover { + cursor: pointer; + color: var(--osoc_green); + transition: 200ms ease-out; + } `; -export const StudentInfoTitle = styled.h4` - color: var(--osoc_orange); +export const StudentLink = styled.p` + font-size: 12px; + margin-top: 17%; `; -export const SuggestionField = styled.p` +export const CopyIcon = styled(FontAwesomeIcon)` + margin-left: 0.35vh; + margin-bottom: 8%; +`; + +export const PreferedName = styled.p` font-size: 20px; `; -export const PersonalInfoField = styled.div` - width: 50%; - display: flex; +export const SuggestionField = styled.p` + font-size: 20px; + margin-bottom: 1%; `; export const SubjectFields = styled.div` - width: 30vh; + width: 22vh; display: flex; flex-direction: column; `; @@ -56,35 +114,13 @@ export const PersonalInfoFieldSubject = styled.p` min-width: 30%; `; -export const PersonalInfoFieldValue = styled.p` - margin-left: 1vh; -`; +export const PersonalInfoFieldValue = styled.p``; -export const RolesField = styled.div` - display: flex; -`; - -export const RolesValues = styled.ul` - margin-left: 5%; -`; - -export const RoleValue = styled.li``; - -export const LineBreak = styled.div` - background-color: #163542; - height: 3px; - width: 100%; - margin-bottom: 30px; - margin-top: 30px; +export const RoleValue = styled.p` + font-size: 100%; + margin-bottom: 1%; `; export const DefinitiveDecisionContainer = styled.div` width: 40%; `; - -export const NameAndRemoveButtonContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; -`; diff --git a/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/AdminDecisionContainer.tsx b/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/AdminDecisionContainer.tsx index 55081ad4c..51497b3a1 100644 --- a/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/AdminDecisionContainer.tsx +++ b/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/AdminDecisionContainer.tsx @@ -1,9 +1,12 @@ import React, { useState } from "react"; import { Button, Modal } from "react-bootstrap"; import { DefinitiveDecisionContainer } from "../../StudentInformation/styles"; -import { SuggestionButtons, ConfirmButton } from "./styles"; +import { SuggestionButtons, ConfirmActionTitle } from "./styles"; +import { YesButton, MaybeButton, NoButton } from "../CoachSuggestionContainer/styles"; import { confirmStudent } from "../../../../utils/api/suggestions"; import { useParams } from "react-router-dom"; +import { CreateButton } from "../../../Common/Buttons"; +import { toast } from "react-toastify"; /** * Make definitive decision on the current student based on the selected decision value. @@ -50,7 +53,11 @@ export default function AdminDecisionContainer() { } else { decisionNum = 3; } - await confirmStudent(params.editionId!, params.id!, decisionNum); + await toast.promise(confirmStudent(params.editionId!, params.id!, decisionNum), { + error: "Failed to send decision", + pending: "Sending decision", + success: "Decision successfully sent", + }); setClickedButtonText(""); setShow(false); } @@ -64,51 +71,51 @@ export default function AdminDecisionContainer() { Click on one of the buttons to mark your decision - ) => handleClick(e)} > Yes - - + ) => handleClick(e)} > Maybe - - + ) => handleClick(e)} > No - + -
{clickedButtonText ? ( - + ) : ( - + )}
-

Definitive decision by admin

+ Definitive decision by admin - + ); diff --git a/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/styles.ts b/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/styles.ts index b074ecf50..2b0559ea3 100644 --- a/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/styles.ts +++ b/frontend/src/components/StudentInfoComponents/SuggestionComponents/AdminDecisionContainer/styles.ts @@ -9,7 +9,13 @@ export const SuggestionButtons = styled.div` `; export const ConfirmButton = styled(Button)` - width: 29.33%; + width: 30%; + height: 30%; margin-left: 2%; margin-right: 2%; `; + +export const ConfirmActionTitle = styled.p` + margin-top: 2%; + font-size: 20px; +`; diff --git a/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/CoachSuggestionContainer.tsx b/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/CoachSuggestionContainer.tsx index c76455150..25141441c 100644 --- a/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/CoachSuggestionContainer.tsx +++ b/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/CoachSuggestionContainer.tsx @@ -1,8 +1,12 @@ -import { Button, ButtonGroup, Form, Modal } from "react-bootstrap"; +import { Button, ButtonGroup, Modal } from "react-bootstrap"; import React, { useState } from "react"; import { Student } from "../../../../data/interfaces/students"; import { makeSuggestion } from "../../../../utils/api/students"; import { useParams } from "react-router-dom"; +import { SuggestionActionTitle, YesButton, MaybeButton, NoButton } from "./styles"; +import { CreateButton } from "../../../Common/Buttons"; +import { FormControl } from "../../../Common/Forms"; +import { toast } from "react-toastify"; interface Props { student: Student; @@ -45,7 +49,14 @@ export default function CoachSuggestionContainer(props: Props) { } else { suggestionNum = 3; } - await makeSuggestion(params.editionId!, params.id!, suggestionNum, argumentation); + await toast.promise( + makeSuggestion(params.editionId!, params.id!, suggestionNum, argumentation), + { + error: "Failed to send suggestion", + pending: "Sending suggestion", + success: "Suggestion successfully sent", + } + ); setArgumentation(""); setShow(false); } @@ -61,9 +72,8 @@ export default function CoachSuggestionContainer(props: Props) { Why are you giving this decision for this student? - { setArgumentation(e.target.value); @@ -73,25 +83,36 @@ export default function CoachSuggestionContainer(props: Props) { * This field isn't required - - + Save Suggestion -

Make a suggestion on this student

+ Make a suggestion on this student + - - - + ); diff --git a/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/styles.ts b/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/styles.ts new file mode 100644 index 000000000..52b992df0 --- /dev/null +++ b/frontend/src/components/StudentInfoComponents/SuggestionComponents/CoachSuggestionContainer/styles.ts @@ -0,0 +1,32 @@ +import styled from "styled-components"; +import { Button } from "react-bootstrap"; + +export const SuggestionActionTitle = styled.p` + font-size: 20px; +`; + +export const YesButton = styled(Button)` + background-color: var(--osoc_green); + color: black; + height: 100%; + width: 100%; + margin-right: 2%; +`; + +export const MaybeButton = styled(Button)` + background-color: var(--osoc_orange); + color: black; + height: 100%; + width: 100%; + margin-left: 2%; + margin-right: 2%; +`; + +export const NoButton = styled(Button)` + background-color: var(--osoc_red); + color: black; + height: 100%; + width: 100%; + margin-left: 2%; + margin-right: 2%; +`; diff --git a/frontend/src/components/StudentsComponents/StudentList/StudentList.tsx b/frontend/src/components/StudentsComponents/StudentList/StudentList.tsx index f2cf76050..b843d9511 100644 --- a/frontend/src/components/StudentsComponents/StudentList/StudentList.tsx +++ b/frontend/src/components/StudentsComponents/StudentList/StudentList.tsx @@ -21,7 +21,7 @@ export default function StudentList(props: Props) { } + loader={} useWindow={false} initialLoad={true} > diff --git a/frontend/src/components/StudentsComponents/StudentList/styles.ts b/frontend/src/components/StudentsComponents/StudentList/styles.ts index 3f240f5a8..c6cb6f01b 100644 --- a/frontend/src/components/StudentsComponents/StudentList/styles.ts +++ b/frontend/src/components/StudentsComponents/StudentList/styles.ts @@ -4,4 +4,5 @@ export const StudentCardsList = styled.div` height: 60%; overflow-y: scroll; border-bottom: 1px solid white; + margin-top: 2%; `; diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/AlumniFilter/AlumniFilter.tsx b/frontend/src/components/StudentsComponents/StudentListFilters/AlumniFilter/AlumniFilter.tsx index cb4c543e4..ae49bbd90 100644 --- a/frontend/src/components/StudentsComponents/StudentListFilters/AlumniFilter/AlumniFilter.tsx +++ b/frontend/src/components/StudentsComponents/StudentListFilters/AlumniFilter/AlumniFilter.tsx @@ -1,17 +1,21 @@ import { Form } from "react-bootstrap"; import React from "react"; +import { setAlumniFilterStorage } from "../../../../utils/session-storage/student-filters"; /** * Component that filters the students list based on the alumni field. * @param alumniFilter * @param setAlumniFilter + * @param setPage Function to set the page to fetch next */ export default function AlumniFilter({ alumniFilter, setAlumniFilter, + setPage, }: { alumniFilter: boolean; setAlumniFilter: (value: boolean) => void; + setPage: (page: number) => void; }) { return (
@@ -22,7 +26,9 @@ export default function AlumniFilter({ checked={alumniFilter} onChange={e => { setAlumniFilter(e.target.checked); + setAlumniFilterStorage(String(e.target.checked)); e.target.checked = alumniFilter; + setPage(0); }} />
diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/ConfirmFilters/ConfirmFilters.tsx b/frontend/src/components/StudentsComponents/StudentListFilters/ConfirmFilters/ConfirmFilters.tsx new file mode 100644 index 000000000..21a7589e0 --- /dev/null +++ b/frontend/src/components/StudentsComponents/StudentListFilters/ConfirmFilters/ConfirmFilters.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import { FilterConfirmsDropdownContainer, FilterConfirms, ConfirmsTitle } from "../styles"; +import { DropdownRole } from "../RolesFilter/RolesFilter"; +import Select, { MultiValue } from "react-select"; +import { setConfirmFilterStorage } from "../../../../utils/session-storage/student-filters"; + +/** + * Component that filters the students list based on confirmation. + */ +export default function ConfirmFilters({ + confirmFilter, + setConfirmFilter, + setPage, +}: { + confirmFilter: DropdownRole[]; + setConfirmFilter: (value: DropdownRole[]) => void; + setPage: (page: number) => void; +}) { + const [confirms, setConfirms] = useState([]); + + useEffect(() => { + setConfirms([ + { label: "Yes", value: 1 }, + { label: "Maybe", value: 2 }, + { label: "No", value: 3 }, + { label: "Undecided", value: 0 }, + ]); + }, []); + + function handleRolesChange(event: MultiValue): void { + const allCheckedRoles: DropdownRole[] = []; + event.forEach(dropdownRole => allCheckedRoles.push(dropdownRole)); + setConfirmFilter(allCheckedRoles); + setPage(0); + setConfirmFilterStorage(JSON.stringify(allCheckedRoles)); + } + + return ( + + Confirmed + + { + handleRolesChange(e); + setPage(0); + }} /> diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/StudentCoachVolunteerFilter/StudentCoachVolunteerFilter.tsx b/frontend/src/components/StudentsComponents/StudentListFilters/StudentCoachVolunteerFilter/StudentCoachVolunteerFilter.tsx index b41f3528b..3f7d7dbcd 100644 --- a/frontend/src/components/StudentsComponents/StudentListFilters/StudentCoachVolunteerFilter/StudentCoachVolunteerFilter.tsx +++ b/frontend/src/components/StudentsComponents/StudentListFilters/StudentCoachVolunteerFilter/StudentCoachVolunteerFilter.tsx @@ -1,17 +1,21 @@ import { Form } from "react-bootstrap"; import React from "react"; +import { setStudentCoachVolunteerFilterStorage } from "../../../../utils/session-storage/student-filters"; /** * Component that filters the students list based on the student coach field. * @param studentCoachVolunteerFilter * @param setStudentCoachVolunteerFilter + * @param setPage Function to set the page to fetch next */ export default function StudentCoachVolunteerFilter({ studentCoachVolunteerFilter, setStudentCoachVolunteerFilter, + setPage, }: { studentCoachVolunteerFilter: boolean; setStudentCoachVolunteerFilter: (value: boolean) => void; + setPage: (page: number) => void; }) { return (
@@ -22,7 +26,9 @@ export default function StudentCoachVolunteerFilter({ checked={studentCoachVolunteerFilter} onChange={e => { setStudentCoachVolunteerFilter(e.target.checked); + setStudentCoachVolunteerFilterStorage(String(e.target.checked)); e.target.checked = studentCoachVolunteerFilter; + setPage(0); }} />
diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/StudentListFilters.tsx b/frontend/src/components/StudentsComponents/StudentListFilters/StudentListFilters.tsx index 4216ed0c0..53e87a8ea 100644 --- a/frontend/src/components/StudentsComponents/StudentListFilters/StudentListFilters.tsx +++ b/frontend/src/components/StudentsComponents/StudentListFilters/StudentListFilters.tsx @@ -5,13 +5,24 @@ import { StudentListSideMenu, StudentListLinebreak, FilterControls, MessageDiv } import AlumniFilter from "./AlumniFilter/AlumniFilter"; import StudentCoachVolunteerFilter from "./StudentCoachVolunteerFilter/StudentCoachVolunteerFilter"; import NameFilter from "./NameFilter/NameFilter"; -import RolesFilter from "./RolesFilter/RolesFilter"; +import RolesFilter, { DropdownRole } from "./RolesFilter/RolesFilter"; import "./StudentListFilters.css"; import ResetFiltersButton from "./ResetFiltersButton/ResetFiltersButton"; import { Student } from "../../../data/interfaces/students"; import { useParams } from "react-router-dom"; import { toast } from "react-toastify"; import { getStudents } from "../../../utils/api/students"; +import SuggestedForFilter from "./SuggestedForFilter/SuggestedForFilter"; +import { + getAlumniFilter, + getConfirmFilter, + getNameFilter, + getRolesFilter, + getStudentCoachVolunteerFilter, + getSuggestedFilter, +} from "../../../utils/session-storage/student-filters"; +import ConfirmFilters from "./ConfirmFilters/ConfirmFilters"; +import LoadSpinner from "../../Common/LoadSpinner"; /** * Component that shows the sidebar with all the filters and student list. @@ -24,16 +35,21 @@ export default function StudentListFilters() { const [moreDataAvailable, setMoreDataAvailable] = useState(true); const [allDataFetched, setAllDataFetched] = useState(false); const [page, setPage] = useState(0); + const [controller, setController] = useState(undefined); - const [nameFilter, setNameFilter] = useState(""); - const [rolesFilter, setRolesFilter] = useState([]); - const [alumniFilter, setAlumniFilter] = useState(false); - const [studentCoachVolunteerFilter, setStudentCoachVolunteerFilter] = useState(false); + const [nameFilter, setNameFilter] = useState(getNameFilter()); + const [rolesFilter, setRolesFilter] = useState(getRolesFilter()); + const [alumniFilter, setAlumniFilter] = useState(getAlumniFilter()); + const [studentCoachVolunteerFilter, setStudentCoachVolunteerFilter] = useState( + getStudentCoachVolunteerFilter() + ); + const [suggestedFilter, setSuggestedFilter] = useState(getSuggestedFilter()); + const [confirmFilter, setConfirmFilter] = useState(getConfirmFilter()); /** * Request all students with selected filters */ - async function getData(requested: number) { + async function getData(requested: number, edChange: boolean = false) { const filterChanged = requested === -1; const requestedPage = requested === -1 ? 0 : page; @@ -41,12 +57,12 @@ export default function StudentListFilters() { return; } - if (allDataFetched) { + if (allDataFetched && !edChange) { const tempStudents = allStudents .filter(student => (student.firstName + " " + student.lastName) .toUpperCase() - .includes(nameFilter.toUpperCase()) + .includes(nameFilter!.toUpperCase()) ) .filter(student => !alumniFilter || student.alumni === alumniFilter) .filter( @@ -61,10 +77,11 @@ export default function StudentListFilters() { const newStudents: Student[] = []; for (const student of tempStudents) { for (const skill of student.skills) { - if (rolesFilter.includes(skill.skillId)) { - newStudents.push(student); - break; - } + rolesFilter.forEach(dropdownValue => { + if (dropdownValue.value === skill.skillId) { + newStudents.push(student); + } + }); } } setStudents(newStudents); @@ -75,16 +92,28 @@ export default function StudentListFilters() { setLoading(true); - try { - const response = await getStudents( + if (controller !== undefined) { + controller.abort(); + } + const newController = new AbortController(); + setController(newController); + + const response = await toast.promise( + getStudents( params.editionId!, nameFilter, rolesFilter, alumniFilter, studentCoachVolunteerFilter, - requestedPage - ); + suggestedFilter, + confirmFilter, + requestedPage, + newController + ), + { error: "Failed to retrieve students" } + ); + if (response !== null) { if (response.students.length === 0 && !filterChanged) { setMoreDataAvailable(false); } @@ -98,8 +127,10 @@ export default function StudentListFilters() { if ( nameFilter === "" && rolesFilter.length === 0 && + confirmFilter.length === 0 && !alumniFilter && - !studentCoachVolunteerFilter + !studentCoachVolunteerFilter && + !suggestedFilter ) { if (response.students.length === 0) { setAllDataFetched(true); @@ -110,10 +141,9 @@ export default function StudentListFilters() { setAllStudents(allStudents.concat(response.students)); } } - setPage(page + 1); - } catch (error) { - toast.error("Failed to get students.", { toastId: "fetch_students_failed" }); + } else { + setMoreDataAvailable(false); } setLoading(false); } @@ -126,11 +156,32 @@ export default function StudentListFilters() { setMoreDataAvailable(true); getData(-1); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nameFilter, rolesFilter, alumniFilter, studentCoachVolunteerFilter]); + }, [ + nameFilter, + rolesFilter, + alumniFilter, + studentCoachVolunteerFilter, + suggestedFilter, + confirmFilter, + ]); + + useEffect(() => { + setStudents([]); + setAllStudents([]); + setPage(0); + setAllDataFetched(false); + setMoreDataAvailable(true); + getData(-1, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params.editionId]); let list; if (students.length === 0) { - list = No students found; + if (loading) { + list = ; + } else { + list = No students found; + } } else { list = ( - - + + - - + + + - {list} diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/SuggestedForFilter.tsx b/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/SuggestedForFilter.tsx new file mode 100644 index 000000000..910abfcbe --- /dev/null +++ b/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/SuggestedForFilter.tsx @@ -0,0 +1,36 @@ +import { Form } from "react-bootstrap"; +import React from "react"; +import { setSuggestedFilterStorage } from "../../../../utils/session-storage/student-filters"; + +/** + * Component that filters the students list based on the suggested for field. + * @param suggestedFilter + * @param setSuggestedFilter + * @param setPage Function to set the page to fetch next + */ +export default function SuggestedForFilter({ + suggestedFilter, + setSuggestedFilter, + setPage, +}: { + suggestedFilter: boolean; + setSuggestedFilter: (value: boolean) => void; + setPage: (page: number) => void; +}) { + return ( +
+ { + setSuggestedFilter(e.target.checked); + setSuggestedFilterStorage(String(e.target.checked)); + e.target.checked = suggestedFilter; + setPage(0); + }} + /> +
+ ); +} diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/index.ts b/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/index.ts new file mode 100644 index 000000000..2c58462fc --- /dev/null +++ b/frontend/src/components/StudentsComponents/StudentListFilters/SuggestedForFilter/index.ts @@ -0,0 +1 @@ +export { default } from "./SuggestedForFilter"; diff --git a/frontend/src/components/StudentsComponents/StudentListFilters/styles.ts b/frontend/src/components/StudentsComponents/StudentListFilters/styles.ts index fdc4027ae..f33ba394e 100644 --- a/frontend/src/components/StudentsComponents/StudentListFilters/styles.ts +++ b/frontend/src/components/StudentsComponents/StudentListFilters/styles.ts @@ -8,12 +8,6 @@ export const StudentListSideMenu = styled.div` min-width: 35vh; max-width: 50vh; height: 100vh; - border-right: 2px solid #163542; -`; - -export const StudentListTitle = styled.span` - align-self: center; - font-size: 5vh; `; export const StudentListLinebreak = styled.div` @@ -31,58 +25,46 @@ export const FilterStudentName = styled.div` align-items: center; `; -export const FilterStudentNameLabelContainer = styled.div` - display: flex; - background-color: var(--osoc_orange); - border: 2px solid var(--osoc_orange); - width: 30%; - text-align: center; +export const FilterStudentNameInputContainer = styled.div` + width: 100%; `; -export const FilterStudentNameLabel = styled.span` - color: white; - width: 100%; +export const RolesTitle = styled.p` + margin-top: 2%; + margin-right: 2%; + font-size: 1.7vh; `; -export const FilterStudentNameInputContainer = styled.div` - margin-left: 15px; - width: 100%; +export const ConfirmsTitle = styled.p` + margin-top: 2%; + margin-right: 2%; + font-size: 1.7vh; `; export const FilterRoles = styled.div` width: 90%; display: flex; align-self: center; - margin-top: 10px; - margin-bottom: 10px; + margin-top: 2%; + margin-bottom: 2%; align-items: center; `; -export const FilterRolesLabelContainer = styled.div` +export const FilterConfirms = styled.div` + width: 90%; display: flex; - background-color: var(--osoc_green); - border: 2px solid var(--osoc_green); - width: 30%; - text-align: center; -`; - -export const FilterRolesLabel = styled.span` - color: white; - width: 100%; + align-self: center; + margin-top: 2%; + margin-bottom: 2%; + align-items: center; `; export const FilterRolesDropdownContainer = styled.div` - margin-left: 15px; width: 100%; `; -export const FilterResetButton = styled.button` - width: 50%; - align-self: center; - border: none; - height: 3vh; - background-color: var(--osoc_red); - color: white; +export const FilterConfirmsDropdownContainer = styled.div` + width: 100%; `; export const FilterControls = styled.div` @@ -97,3 +79,11 @@ export const MessageDiv = styled.div` text-align: center; margin-top: 20px; `; + +export const ConfirmButtonsContainer = styled.div` + display: flex; + flex-direction: column; + align-self: center; + width: 90%; + margin-bottom: 2%; +`; diff --git a/frontend/src/components/UsersComponents/Coaches/Coaches.tsx b/frontend/src/components/UsersComponents/Coaches/Coaches.tsx index b3fe356c2..13b45ae44 100644 --- a/frontend/src/components/UsersComponents/Coaches/Coaches.tsx +++ b/frontend/src/components/UsersComponents/Coaches/Coaches.tsx @@ -3,7 +3,7 @@ import { CoachesTitle, CoachesContainer } from "./styles"; import { User } from "../../../utils/api/users/users"; import { CoachList, AddCoach } from "./CoachesComponents"; import { SearchBar } from "../../Common/Forms"; -import { Error, SearchFieldDiv, TableDiv } from "../../Common/Users/styles"; +import { SearchFieldDiv, TableDiv } from "../../Common/Users/styles"; /** * List of coaches of the given edition. @@ -13,7 +13,6 @@ import { Error, SearchFieldDiv, TableDiv } from "../../Common/Users/styles"; * @param props.getMoreCoaches A function to load more coaches. * @param props.searchCoaches A function to set the filter for coaches' username. * @param props.gotData All data is received. - * @param props.error An error message. * @param props.moreCoachesAvailable More unfetched coaches available. * @param props.searchTerm Current filter for coaches' names. * @param props.refreshCoaches A function which will be called when a coach is added. @@ -25,16 +24,13 @@ export default function Coaches(props: { getMoreCoaches: () => void; searchCoaches: (word: string) => void; gotData: boolean; - error: string; moreCoachesAvailable: boolean; searchTerm: string; refreshCoaches: () => void; removeCoach: (user: User) => void; }) { let table; - if (props.error) { - table = {props.error} ; - } else if (props.gotData && props.coaches.length === 0) { + if (props.gotData && props.coaches.length === 0) { table =
No coaches found
; } else { table = ( diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx index fe21934dc..e20a90737 100644 --- a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx @@ -5,13 +5,14 @@ import { Button, Modal, Spinner } from "react-bootstrap"; import { AddButtonDiv } from "../../../AdminsComponents/styles"; import Typeahead from "react-bootstrap-typeahead/types/core/Typeahead"; import UserMenuItem from "../../../Common/Users/MenuItem"; -import { Error, StyledMenuItem } from "../../../Common/Users/styles"; +import { StyledMenuItem } from "../../../Common/Users/styles"; import { EmailAndAuth } from "../../../Common/Users"; import { EmailDiv } from "../styles"; import CreateButton from "../../../Common/Buttons/CreateButton"; import { ModalContentConfirm } from "../../../Common/styles"; import { StyledInput } from "../../../Common/Forms/styles"; import { AsyncTypeahead, Menu } from "react-bootstrap-typeahead"; +import { toast } from "react-toastify"; /** * A button and popup to add a new coach to the given edition. @@ -23,7 +24,6 @@ export default function AddCoach(props: { edition: string; refreshCoaches: () => const [show, setShow] = useState(false); const [selected, setSelected] = useState(undefined); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); const [gettingData, setGettingData] = useState(false); // Waiting for data const [users, setUsers] = useState([]); // All users which are not a coach const [searchTerm, setSearchTerm] = useState(""); // The word set in filter @@ -45,20 +45,16 @@ export default function AddCoach(props: { edition: string; refreshCoaches: () => filter = searchTerm; } setGettingData(true); - setError(""); - try { - const response = await getUsersExcludeEdition(props.edition, filter, page); - if (page === 0) { - setUsers(response.users); - } else { - setUsers(users.concat(response.users)); - } - - setGettingData(false); - } catch (exception) { - setError("Oops, something went wrong..."); - setGettingData(false); + const response = await toast.promise(getUsersExcludeEdition(props.edition, filter, page), { + error: "Failed to retrieve users", + }); + if (page === 0) { + setUsers(response.users); + } else { + setUsers(users.concat(response.users)); } + + setGettingData(false); } function filterData(searchTerm: string) { @@ -69,7 +65,7 @@ export default function AddCoach(props: { edition: string; refreshCoaches: () => const handleClose = () => { setSelected(undefined); - setError(""); + props.refreshCoaches(); setShow(false); }; const handleShow = () => { @@ -78,24 +74,15 @@ export default function AddCoach(props: { edition: string; refreshCoaches: () => async function addCoach(user: User) { setLoading(true); - setError(""); - let success = false; - try { - success = await addCoachToEdition(user.userId, props.edition); - if (!success) { - setError("Something went wrong. Failed to add coach"); - } - } catch (error) { - setError("Something went wrong. Failed to add coach"); - } + await toast.promise(addCoachToEdition(user.userId, props.edition), { + error: "Failed to add coach", + pending: "Adding coach", + success: "Coach successfully added", + }); + setLoading(false); - if (success) { - props.refreshCoaches(); - setSearchTerm(""); - getData(0, ""); - setSelected(undefined); - setClearRef(true); - } + setSelected(undefined); + setClearRef(true); } let addButton; @@ -143,7 +130,6 @@ export default function AddCoach(props: { edition: string; refreshCoaches: () => placeholder={"user's name"} onChange={selected => { setSelected(selected[0] as User); - setError(""); }} renderInput={({ inputRef, referenceElementRef, ...inputProps }) => ( - {error} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx index 2cce1db9e..e306197fd 100644 --- a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx @@ -1,10 +1,11 @@ import { User } from "../../../../utils/api/users/users"; -import { CoachesTable, RemoveTh } from "../styles"; +import { CoachesTable } from "../styles"; import React from "react"; import InfiniteScroll from "react-infinite-scroller"; import { CoachListItem } from "./index"; import LoadSpinner from "../../../Common/LoadSpinner"; import { ListDiv } from "../../../Common/Users/styles"; +import { RemoveTh } from "../../../Common/Tables/styles"; /** * A list of [[CoachListItem]]s. diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx index c38535bc5..c3a7ccfdc 100644 --- a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx @@ -1,8 +1,8 @@ import { User } from "../../../../utils/api/users/users"; import React from "react"; import RemoveCoach from "./RemoveCoach"; -import { RemoveTd } from "../styles"; import { EmailAndAuth } from "../../../Common/Users"; +import { RemoveTd } from "../../../Common/Tables/styles"; /** * An item from [[CoachList]] which represents one coach. diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx index 5ddfc9c22..52adc63f6 100644 --- a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx @@ -14,7 +14,7 @@ import { } from "../styles"; import LoadSpinner from "../../../Common/LoadSpinner"; import DeleteButton from "../../../Common/Buttons/DeleteButton"; -import { Error } from "../../../Common/Users/styles"; +import { toast } from "react-toastify"; /** * A button (part of [[CoachListItem]]) and popup to remove a user as coach from the given edition or all editions. @@ -29,14 +29,12 @@ export default function RemoveCoach(props: { removeCoach: () => void; }) { const [show, setShow] = useState(false); - const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const handleClose = () => setShow(false); const handleShow = () => { setShow(true); - setError(""); }; /** @@ -46,24 +44,22 @@ export default function RemoveCoach(props: { */ async function removeCoach(userId: number, allEditions: boolean) { setLoading(true); - let removed = false; - try { - if (allEditions) { - removed = await removeCoachFromAllEditions(userId); - } else { - removed = await removeCoachFromEdition(userId, props.edition); - } - - if (removed) { - props.removeCoach(); - } else { - setError("Something went wrong. Failed to remove coach"); - setLoading(false); - } - } catch (error) { - setError("Something went wrong. Failed to remove coach"); - setLoading(false); + if (allEditions) { + await toast.promise(removeCoachFromAllEditions(userId), { + error: "Failed to remove coach", + pending: "Removing coach", + success: "Coach successfully removed", + }); + } else { + await toast.promise(removeCoachFromEdition(userId, props.edition), { + error: "Failed to remove coach", + pending: "Removing coach", + success: "Coach successfully removed", + }); } + + setLoading(false); + props.removeCoach(); } let buttons; @@ -101,7 +97,7 @@ export default function RemoveCoach(props: { return ( <> - + Remove @@ -116,10 +112,7 @@ export default function RemoveCoach(props: { {props.coach.auth.email} - - {buttons} - {error} - + {buttons} diff --git a/frontend/src/components/UsersComponents/Coaches/styles.ts b/frontend/src/components/UsersComponents/Coaches/styles.ts index b261088f4..844fc7e6b 100644 --- a/frontend/src/components/UsersComponents/Coaches/styles.ts +++ b/frontend/src/components/UsersComponents/Coaches/styles.ts @@ -26,16 +26,6 @@ export const ModalContent = styled.div` background-color: var(--osoc_blue); `; -export const RemoveTh = styled.th` - width: 200px; - text-align: center; -`; - -export const RemoveTd = styled.td` - text-align: center; - vertical-align: middle; -`; - export const DialogButtonDiv = styled.div` margin-right: 4px; margin-bottom: 4px; diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx b/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx index 6aa5024af..0ec1e6bd9 100644 --- a/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx @@ -1,8 +1,9 @@ import React, { useState } from "react"; import { getInviteLink } from "../../../utils/api/users/users"; -import { InviteContainer, Error, MessageDiv, InputContainer } from "./styles"; +import { InviteContainer, MessageDiv, InputContainer } from "./styles"; import { ButtonsDiv } from "./InviteUserComponents"; import { SearchBar } from "../../Common/Forms"; +import { toast } from "react-toastify"; /** * A component to invite a user as coach to a given edition. @@ -14,8 +15,6 @@ import { SearchBar } from "../../Common/Forms"; export default function InviteUser(props: { edition: string }) { const [email, setEmail] = useState(""); // The email address which is entered const [valid, setValid] = useState(true); // The given email address is valid (or still being typed) - const [errorMessage, setErrorMessage] = useState(""); // An error message - const [loading, setLoading] = useState(false); // The invite link is being created const [message, setMessage] = useState(""); // A message to confirm link created /** @@ -26,7 +25,6 @@ export default function InviteUser(props: { edition: string }) { const changeEmail = function (email: string) { setEmail(email); setValid(true); - setErrorMessage(""); setMessage(""); }; @@ -39,26 +37,24 @@ export default function InviteUser(props: { edition: string }) { */ const sendInvite = async (copyInvite: boolean) => { if (/[^@\s]+@[^@\s]+\.[^@\s]+/.test(email)) { - setLoading(true); - try { - const response = await getInviteLink(props.edition, email); - if (copyInvite) { - await navigator.clipboard.writeText(response.inviteLink); - setMessage("Copied invite link for " + email); - } else { - window.open(response.mailTo); - setMessage("Created email for " + email); - } - setLoading(false); - setEmail(""); - } catch (error) { - setLoading(false); - setErrorMessage("Something went wrong"); - setMessage(""); + const response = await toast.promise(getInviteLink(props.edition, email), { + error: "Failed to create invite", + pending: "Creating invite", + success: "Invite successfully created", + }); + if (copyInvite) { + await navigator.clipboard.writeText(response.inviteLink); + setMessage("Copied invite link for " + email); + } else { + window.open(response.mailTo); + setMessage("Created email for " + email); } + setEmail(""); } else { setValid(false); - setErrorMessage("Invalid email"); + toast.error("Invalid email address", { + toastId: "invalid_email", + }); setMessage(""); } }; @@ -74,12 +70,9 @@ export default function InviteUser(props: { edition: string }) { placeholder="Email address" /> - + - - {message} - {errorMessage} - + {message} ); } diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx index fe675b6b3..aa65b5404 100644 --- a/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx @@ -1,21 +1,14 @@ import { DropdownField, InviteButton } from "../styles"; import React from "react"; -import { ButtonGroup, Dropdown, Spinner } from "react-bootstrap"; +import { ButtonGroup, Dropdown } from "react-bootstrap"; import { CreateButton } from "../../../Common/Buttons"; import { DropdownToggle } from "../../../Common/Buttons/styles"; /** * A component to choice between sending an invite or copying it to clipboard. - * @param props.loading Invite is being created. Used to show a spinner. * @param props.sendInvite A function to send/copy the link. */ -export default function SendInviteButton(props: { - loading: boolean; - sendInvite: (copy: boolean) => void; -}) { - if (props.loading) { - return ; - } +export default function SendInviteButton(props: { sendInvite: (copy: boolean) => void }) { return ( diff --git a/frontend/src/components/UsersComponents/Requests/Requests.tsx b/frontend/src/components/UsersComponents/Requests/Requests.tsx index e3e9c8089..ab598de4d 100644 --- a/frontend/src/components/UsersComponents/Requests/Requests.tsx +++ b/frontend/src/components/UsersComponents/Requests/Requests.tsx @@ -1,10 +1,11 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Collapsible from "react-collapsible"; import { RequestsContainer, RequestListContainer } from "./styles"; import { getRequests, Request } from "../../../utils/api/users/requests"; import { RequestList, RequestsHeader } from "./RequestsComponents"; import SearchBar from "../../Common/Forms/SearchBar"; -import { Error, SearchFieldDiv } from "../../Common/Users/styles"; +import { SearchFieldDiv } from "../../Common/Users/styles"; +import { toast } from "react-toastify"; /** * A collapsible component which contains all coach requests for a given edition. @@ -19,11 +20,12 @@ export default function Requests(props: { edition: string; refreshCoaches: () => const [searchTerm, setSearchTerm] = useState(""); // The word set in the filter const [gotData, setGotData] = useState(false); // Received data const [open, setOpen] = useState(false); // Collapsible is open - const [error, setError] = useState(""); // Error message const [moreRequestsAvailable, setMoreRequestsAvailable] = useState(true); // Endpoint has more requests available const [allRequestsFetched, setAllRequestsFetched] = useState(false); const [page, setPage] = useState(0); // The next page which needs to be fetched + const [controller, setController] = useState(undefined); + /** * Remove a request from the list of requests (Request is accepter or rejected). * When the request was accepted, the refreshCoaches will be called. @@ -67,37 +69,65 @@ export default function Requests(props: { edition: string; refreshCoaches: () => } setLoading(true); - setError(""); - try { - const response = await getRequests(props.edition, searchTerm, page); + + if (controller !== undefined) { + controller.abort(); + } + const newController = new AbortController(); + setController(newController); + + const response = await toast.promise( + getRequests(props.edition, searchTerm, page, newController), + { + error: "Failed to retrieve requests", + } + ); + + if (response.requests.length === 0) { + setMoreRequestsAvailable(false); + } + if (page === 0) { + setRequests(response.requests); + } else { + setRequests(requests.concat(response.requests)); + } + + if (searchTerm === "") { if (response.requests.length === 0) { - setMoreRequestsAvailable(false); + setAllRequestsFetched(true); } if (page === 0) { - setRequests(response.requests); + setAllRequests(response.requests); } else { - setRequests(requests.concat(response.requests)); + setAllRequests(allRequests.concat(response.requests)); } + } - if (searchTerm === "") { - if (response.requests.length === 0) { - setAllRequestsFetched(true); - } - if (page === 0) { - setAllRequests(response.requests); - } else { - setAllRequests(allRequests.concat(response.requests)); - } - } + setPage(page + 1); - setPage(page + 1); - setGotData(true); - } catch (exception) { - setError("Oops, something went wrong..."); - } + setGotData(true); setLoading(false); } + /** + * update the requests when the edition changes + */ + useEffect(() => { + refreshRequests(); + }, [props.edition]); + + /** + * Reset the list of requests and get the first page. + * Used when the edition is changed. + */ + function refreshRequests() { + setRequests([]); + setPage(0); + setAllRequestsFetched(false); + setGotData(false); + setMoreRequestsAvailable(true); + } + function filter(searchTerm: string) { setPage(0); setGotData(false); @@ -107,9 +137,7 @@ export default function Requests(props: { edition: string; refreshCoaches: () => } let list; - if (error) { - list = {error}; - } else if (gotData && requests.length === 0) { + if (gotData && requests.length === 0) { list =
No requests found
; } else { list = ( diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx b/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx index 8bbd93101..9e7e095dc 100644 --- a/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx @@ -1,9 +1,9 @@ import { Request, acceptRequest, rejectRequest } from "../../../../utils/api/users/requests"; -import React, { useState } from "react"; -import LoadSpinner from "../../../Common/LoadSpinner"; +import React from "react"; import CreateButton from "../../../Common/Buttons/CreateButton"; import DeleteButton from "../../../Common/Buttons/DeleteButton"; import { Spacing } from "../styles"; +import { toast } from "react-toastify"; /** * Component consisting of two buttons to accept or reject a coach request. @@ -14,58 +14,31 @@ export default function AcceptReject(props: { request: Request; removeRequest: (coachAdded: boolean, request: Request) => void; }) { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - async function accept() { - setLoading(true); - let success = false; - try { - success = await acceptRequest(props.request.requestId); - if (!success) { - setError("Failed to accept"); - } - } catch (exception) { - setError("Failed to accept"); - } - setLoading(false); - if (success) { - props.removeRequest(true, props.request); - } - } + await toast.promise(acceptRequest(props.request.requestId), { + error: "Failed to accept request", + pending: "Accepting request", + }); - async function reject() { - setLoading(true); - let success = false; - try { - success = await rejectRequest(props.request.requestId); - if (!success) { - setError("Failed to reject"); - } - } catch (exception) { - setError("Failed to reject"); - } - setLoading(false); - if (success) { - props.removeRequest(false, props.request); - } + props.removeRequest(true, props.request); } - if (error) { - return
{error}
; - } + async function reject() { + await toast.promise(rejectRequest(props.request.requestId), { + error: "Failed to reject request", + pending: "Rejecting request", + }); - if (loading) { - return ; + props.removeRequest(false, props.request); } return (
- + Accept - + Reject
diff --git a/frontend/src/contexts/auth-context.tsx b/frontend/src/contexts/auth-context.tsx index bfd2a818d..5e7ccc46f 100644 --- a/frontend/src/contexts/auth-context.tsx +++ b/frontend/src/contexts/auth-context.tsx @@ -1,7 +1,7 @@ /** Context hook to maintain the authentication state of the user **/ import { Role } from "../data/enums"; import React, { useContext, ReactNode, useState } from "react"; -import { User } from "../data/interfaces"; +import { Edition, User } from "../data/interfaces"; import { setCurrentEdition } from "../utils/session-storage"; import { setAccessToken, setRefreshToken } from "../utils/local-storage"; @@ -15,8 +15,8 @@ export interface AuthContextState { setRole: (value: Role | null) => void; userId: number | null; setUserId: (value: number | null) => void; - editions: string[]; - setEditions: (value: string[]) => void; + editions: Edition[]; + setEditions: (value: Edition[]) => void; } /** @@ -33,7 +33,7 @@ function authDefaultState(): AuthContextState { userId: null, setUserId: (_: number | null) => {}, editions: [], - setEditions: (_: string[]) => {}, + setEditions: (_: Edition[]) => {}, }; } @@ -56,7 +56,7 @@ export function useAuth(): AuthContextState { export function AuthProvider({ children }: { children: ReactNode }) { const [isLoggedIn, setIsLoggedIn] = useState(null); const [role, setRole] = useState(null); - const [editions, setEditions] = useState([]); + const [editions, setEditions] = useState([]); const [userId, setUserId] = useState(null); // Create AuthContext value @@ -100,3 +100,18 @@ export function logOut(authContext: AuthContextState) { // Remove current edition from SessionStorage setCurrentEdition(null); } + +/** + * Update the state of an edition in the AuthContext + */ +export function updateEditionState(authContext: AuthContextState, edition: Edition) { + const index = authContext.editions.findIndex(e => e.name === edition.name); + if (index === -1) return; + + // Flip the state of the element + const copy = [...authContext.editions]; + copy[index].readonly = !copy[index].readonly; + + // Call the setter to update the state + authContext.setEditions(copy); +} diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts index 133c24ae4..f37fd4506 100644 --- a/frontend/src/contexts/index.ts +++ b/frontend/src/contexts/index.ts @@ -1,3 +1,3 @@ import type { AuthContextState } from "./auth-context"; export type { AuthContextState }; -export { AuthProvider, logIn, logOut, useAuth } from "./auth-context"; +export { AuthProvider, logIn, logOut, useAuth, updateEditionState } from "./auth-context"; diff --git a/frontend/src/data/enums/session-storage.ts b/frontend/src/data/enums/session-storage.ts index aeacb071f..592f3aab4 100644 --- a/frontend/src/data/enums/session-storage.ts +++ b/frontend/src/data/enums/session-storage.ts @@ -4,4 +4,13 @@ export const enum SessionStorageKey { CURRENT_EDITION = "currentEdition", REGISTER_STATE = "registerState", + /** + * Enums used for storing filters + */ + SUGGESTED_FILTER = "suggestedFilter", + ALUMNI_FILTER = "alumniFilter", + NAME_FILTER = "nameFilter", + STUDENT_COACH_VOLUNTEER_FILTER = "studentCoachVolunteerFilter", + ROLES_FILTER = "rolesFilter", + CONFIRM_FILTER = "confirmFilter", } diff --git a/frontend/src/data/interfaces/editions.ts b/frontend/src/data/interfaces/editions.ts index db1e88e65..6fbebd074 100644 --- a/frontend/src/data/interfaces/editions.ts +++ b/frontend/src/data/interfaces/editions.ts @@ -4,4 +4,5 @@ export interface Edition { name: string; year: number; + readonly: boolean; } diff --git a/frontend/src/data/interfaces/users.ts b/frontend/src/data/interfaces/users.ts index 1c653263d..7622c7f0e 100644 --- a/frontend/src/data/interfaces/users.ts +++ b/frontend/src/data/interfaces/users.ts @@ -1,3 +1,5 @@ +import { Edition } from "./editions"; + /** * Data about a user using the application. * Contains a list of edition names so that we can quickly check if @@ -7,5 +9,5 @@ export interface User { userId: number; name: string; admin: boolean; - editions: string[]; + editions: Edition[]; } diff --git a/frontend/src/utils/api/editions.ts b/frontend/src/utils/api/editions.ts index c4947052d..6b58d2527 100644 --- a/frontend/src/utils/api/editions.ts +++ b/frontend/src/utils/api/editions.ts @@ -6,11 +6,6 @@ interface EditionsResponse { editions: Edition[]; } -interface EditionFields { - name: string; - year: number; -} - /** * Get all editions the user can see. */ @@ -20,9 +15,9 @@ export async function getEditions(): Promise { } /** - * Get all edition names sorted the user can see + * Get all edition names sorted that the user can see */ -export async function getSortedEditions(): Promise { +export async function getSortedEditions(): Promise { const response = await axiosInstance.get("/users/current"); return response.data.editions; } @@ -39,7 +34,7 @@ export async function deleteEdition(name: string): Promise { * Create a new edition with the given name and year */ export async function createEdition(name: string, year: number): Promise { - const payload: EditionFields = { name: name, year: year }; + const payload = { name: name, year: year }; try { return await axiosInstance.post("/editions", payload); } catch (error) { @@ -50,3 +45,11 @@ export async function createEdition(name: string, year: number): Promise { + const payload = { readonly: readonly }; + return await axiosInstance.patch(`/editions/${name}`, payload); +} diff --git a/frontend/src/utils/api/mail_overview.ts b/frontend/src/utils/api/mail_overview.ts index 67cbc0f49..b4e7f3700 100644 --- a/frontend/src/utils/api/mail_overview.ts +++ b/frontend/src/utils/api/mail_overview.ts @@ -24,7 +24,8 @@ export async function getMailOverview( edition: string | undefined, page: number, name: string, - filters: EmailType[] + filters: EmailType[], + controller: AbortController ): Promise { const FormatFilters: string[] = filters.map(filter => { return `&email_status=${Object.values(EmailType).indexOf(filter)}`; @@ -32,7 +33,8 @@ export async function getMailOverview( const concatted: string = FormatFilters.join(""); const response = await axiosInstance.get( - `/editions/${edition}/students/emails?page=${page}&name=${name}${concatted}` + `/editions/${edition}/students/emails?page=${page}&name=${name}${concatted}`, + { signal: controller.signal } ); return response.data as StudentEmails; } diff --git a/frontend/src/utils/api/projects.ts b/frontend/src/utils/api/projects.ts index 80d7f3418..6cc4491db 100644 --- a/frontend/src/utils/api/projects.ts +++ b/frontend/src/utils/api/projects.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { Projects, Project, CreateProject } from "../../data/interfaces/projects"; +import { CreateProject, Project, Projects } from "../../data/interfaces/projects"; import { axiosInstance } from "./api"; /** @@ -8,34 +8,29 @@ import { axiosInstance } from "./api"; * @param name To filter on project name. * @param ownProjects To filter on your own projects. * @param page The requested page. + * @param controller An optional AbortController to cancel the request * @returns */ export async function getProjects( edition: string, name: string, ownProjects: boolean, - page: number -): Promise { - try { - const response = await axiosInstance.get( - "/editions/" + - edition + - "/projects?name=" + - name + - "&coach=" + - ownProjects.toString() + - "&page=" + - page.toString() - ); - const projects = response.data as Projects; - return projects; - } catch (error) { - if (axios.isAxiosError(error)) { - return null; - } else { - throw error; - } - } + page: number, + controller: AbortController +): Promise { + // eslint-disable-next-line promise/param-names + const response = await axiosInstance.get( + "/editions/" + + edition + + "/projects?name=" + + name + + "&coach=" + + ownProjects.toString() + + "&page=" + + page.toString(), + { signal: controller.signal } + ); + return response.data as Projects; } /** @@ -47,8 +42,7 @@ export async function getProjects( export async function getProject(edition: string, projectId: number): Promise { try { const response = await axiosInstance.get("/editions/" + edition + "/projects/" + projectId); - const project = response.data as Project; - return project; + return response.data as Project; } catch (error) { if (axios.isAxiosError(error)) { return null; @@ -81,10 +75,8 @@ export async function createProject( }; try { - const response = await axiosInstance.post("editions/" + edition + "/projects", payload); - const project = response.data as Project; - - return project; + const response = await axiosInstance.post("editions/" + edition + "/projects/", payload); + return response.data as Project; } catch (error) { if (axios.isAxiosError(error)) { return null; diff --git a/frontend/src/utils/api/students.ts b/frontend/src/utils/api/students.ts index 95f6bc142..c7daae59b 100644 --- a/frontend/src/utils/api/students.ts +++ b/frontend/src/utils/api/students.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { Student, Students } from "../../data/interfaces/students"; import { axiosInstance } from "./api"; +import { DropdownRole } from "../../components/StudentsComponents/StudentListFilters/RolesFilter/RolesFilter"; /** * API call to get students (and filter them). @@ -9,29 +10,59 @@ import { axiosInstance } from "./api"; * @param rolesFilter roles to filter on. * @param alumniFilter check to filter on. * @param studentCoachVolunteerFilter check to filter on. + * @param suggestedFilter check to filter on. + * @param confirmFilter confirmation state to filter on. * @param page The page to fetch. + * @param controller An optional AbortController to cancel the request */ export async function getStudents( edition: string, nameFilter: string, - rolesFilter: number[], + rolesFilter: DropdownRole[], alumniFilter: boolean, studentCoachVolunteerFilter: boolean, - page: number -): Promise { - const response = await axiosInstance.get( - "/editions/" + - edition + - "/students?name=" + - nameFilter + - "&alumni=" + - alumniFilter + - "&student_coach=" + - studentCoachVolunteerFilter + - "&page=" + - page - ); - return response.data as Students; + suggestedFilter: boolean, + confirmFilter: DropdownRole[], + page: number, + controller: AbortController +): Promise { + let rolesRequestField: string = ""; + + for (const role of rolesFilter) { + rolesRequestField += "skill_ids=" + role.value.toString() + "&"; + } + let confirmRequestField: string = ""; + for (const confirmField of confirmFilter) { + confirmRequestField += "decisions=" + confirmField.value.toString() + "&"; + } + try { + const response = await axiosInstance.get( + "/editions/" + + edition + + "/students?name=" + + nameFilter + + "&alumni=" + + alumniFilter + + "&own_suggestions=" + + suggestedFilter + + "&" + + rolesRequestField + + "&student_coach=" + + studentCoachVolunteerFilter + + "&" + + confirmRequestField + + "&page=" + + page, + { signal: controller.signal } + ); + return response.data as Students; + } catch (error) { + if (axios.isAxiosError(error) && error.code === "ERR_CANCELED") { + return null; + } else { + throw error; + } + } } /** @@ -77,19 +108,10 @@ export async function makeSuggestion( suggestionArg: number, argumentationArg: string ): Promise { - try { - const request = - "/editions/" + edition + "/students/" + studentId.toString() + "/suggestions"; - await axiosInstance.post(request, { - suggestion: suggestionArg, - argumentation: argumentationArg, - }); - return 201; - } catch (error) { - if (axios.isAxiosError(error)) { - return 422; - } else { - throw error; - } - } + const request = "/editions/" + edition + "/students/" + studentId.toString() + "/suggestions"; + await axiosInstance.post(request, { + suggestion: suggestionArg, + argumentation: argumentationArg, + }); + return 201; } diff --git a/frontend/src/utils/api/suggestions.ts b/frontend/src/utils/api/suggestions.ts index 272b6de04..837f5adb2 100644 --- a/frontend/src/utils/api/suggestions.ts +++ b/frontend/src/utils/api/suggestions.ts @@ -29,17 +29,9 @@ export async function getSuggestions(edition: string, studentId: number) { * @param confirmValue The decision to give this student. */ export async function confirmStudent(edition: string, studentId: string, confirmValue: number) { - try { - const response = await axiosInstance.put( - "/editions/" + edition + "/students/" + studentId.toString() + "/decision", - { decision: confirmValue } - ); - return response.status === 204; - } catch (error) { - if (axios.isAxiosError(error)) { - throw error; - } else { - throw error; - } - } + const response = await axiosInstance.put( + "/editions/" + edition + "/students/" + studentId.toString() + "/decision", + { decision: confirmValue } + ); + return response.status === 204; } diff --git a/frontend/src/utils/api/users/admins.ts b/frontend/src/utils/api/users/admins.ts index cc6b8bf12..5bf0dbdb6 100644 --- a/frontend/src/utils/api/users/admins.ts +++ b/frontend/src/utils/api/users/admins.ts @@ -19,9 +19,8 @@ export async function getAdmins(page: number, name: string): Promise * Make the given user admin. * @param {number} userId The id of the user. */ -export async function addAdmin(userId: number): Promise { - const response = await axiosInstance.patch(`/users/${userId}`, { admin: true }); - return response.status === 204; +export async function addAdmin(userId: number) { + await axiosInstance.patch(`/users/${userId}`, { admin: true }); } /** @@ -29,8 +28,7 @@ export async function addAdmin(userId: number): Promise { * @param {number} userId The id of the user. */ export async function removeAdmin(userId: number) { - const response = await axiosInstance.patch(`/users/${userId}`, { admin: false }); - return response.status === 204; + await axiosInstance.patch(`/users/${userId}`, { admin: false }); } /** @@ -38,7 +36,6 @@ export async function removeAdmin(userId: number) { * @param {number} userId The id of the user. */ export async function removeAdminAndCoach(userId: number) { - const response2 = await axiosInstance.delete(`/users/${userId}/editions`); - const response1 = await axiosInstance.patch(`/users/${userId}`, { admin: false }); - return response1.status === 204 && response2.status === 204; + await axiosInstance.delete(`/users/${userId}/editions`); + await axiosInstance.patch(`/users/${userId}`, { admin: false }); } diff --git a/frontend/src/utils/api/users/coaches.ts b/frontend/src/utils/api/users/coaches.ts index f35aefe2d..e9539e021 100644 --- a/frontend/src/utils/api/users/coaches.ts +++ b/frontend/src/utils/api/users/coaches.ts @@ -6,16 +6,26 @@ import { axiosInstance } from "../api"; * @param edition The edition name. * @param name The username to filter. * @param page The requested page. + * @param controller An optional AbortController to cancel the request */ -export async function getCoaches(edition: string, name: string, page: number): Promise { - if (name) { +export async function getCoaches( + edition: string, + name: string, + page: number, + controller: AbortController | null = null +): Promise { + if (controller === null) { const response = await axiosInstance.get( `/users?edition=${edition}&page=${page}&name=${name}` ); return response.data as UsersList; + } else { + const response = await axiosInstance.get( + `/users?edition=${edition}&page=${page}&name=${name}`, + { signal: controller.signal } + ); + return response.data as UsersList; } - const response = await axiosInstance.get(`/users?edition=${edition}&page=${page}`); - return response.data as UsersList; } /** @@ -23,18 +33,16 @@ export async function getCoaches(edition: string, name: string, page: number): P * @param {number} userId The user's id. * @param {string} edition The edition's name. */ -export async function removeCoachFromEdition(userId: number, edition: string): Promise { - const response = await axiosInstance.delete(`/users/${userId}/editions/${edition}`); - return response.status === 204; +export async function removeCoachFromEdition(userId: number, edition: string) { + await axiosInstance.delete(`/users/${userId}/editions/${edition}`); } /** * Remove a user as coach from all editions. * @param {number} userId The user's id. */ -export async function removeCoachFromAllEditions(userId: number): Promise { - const response = await axiosInstance.delete(`/users/${userId}/editions`); - return response.status === 204; +export async function removeCoachFromAllEditions(userId: number) { + await axiosInstance.delete(`/users/${userId}/editions`); } /** @@ -42,7 +50,6 @@ export async function removeCoachFromAllEditions(userId: number): Promise { - const response = await axiosInstance.post(`/users/${userId}/editions/${edition}`); - return response.status === 204; +export async function addCoachToEdition(userId: number, edition: string) { + await axiosInstance.post(`/users/${userId}/editions/${edition}`); } diff --git a/frontend/src/utils/api/users/requests.ts b/frontend/src/utils/api/users/requests.ts index b40888652..2741706ce 100644 --- a/frontend/src/utils/api/users/requests.ts +++ b/frontend/src/utils/api/users/requests.ts @@ -21,19 +21,18 @@ export interface GetRequestsResponse { * @param edition The edition's name. * @param name String which every request's user's name needs to contain * @param page The pagenumber to fetch. + * @param controller An optional AbortController to cancel the request */ export async function getRequests( edition: string, name: string, - page: number + page: number, + controller: AbortController ): Promise { - if (name) { - const response = await axiosInstance.get( - `/users/requests?edition=${edition}&page=${page}&user=${name}` - ); - return response.data as GetRequestsResponse; - } - const response = await axiosInstance.get(`/users/requests?edition=${edition}&page=${page}`); + const response = await axiosInstance.get( + `/users/requests?edition=${edition}&page=${page}&user=${name}`, + { signal: controller.signal } + ); return response.data as GetRequestsResponse; } @@ -41,16 +40,14 @@ export async function getRequests( * Accept a coach request. * @param {number} requestId The id of the request. */ -export async function acceptRequest(requestId: number): Promise { - const response = await axiosInstance.post(`/users/requests/${requestId}/accept`); - return response.status === 204; +export async function acceptRequest(requestId: number) { + await axiosInstance.post(`/users/requests/${requestId}/accept`); } /** * Reject a coach request. * @param {number} requestId The id of the request.s */ -export async function rejectRequest(requestId: number): Promise { - const response = await axiosInstance.post(`/users/requests/${requestId}/reject`); - return response.status === 204; +export async function rejectRequest(requestId: number) { + await axiosInstance.post(`/users/requests/${requestId}/reject`); } diff --git a/frontend/src/utils/logic/editions.ts b/frontend/src/utils/logic/editions.ts new file mode 100644 index 000000000..bc3c17c5f --- /dev/null +++ b/frontend/src/utils/logic/editions.ts @@ -0,0 +1,9 @@ +import { Edition } from "../../data/interfaces"; + +/** + * Check if an edition is read-only + */ +export function isReadonlyEdition(name: string | undefined, editions: Edition[]): boolean { + if (!name) return false; + return editions.find(e => e.name === name)?.readonly || false; +} diff --git a/frontend/src/utils/logic/index.ts b/frontend/src/utils/logic/index.ts index 4e45eae5c..1f8a18d35 100644 --- a/frontend/src/utils/logic/index.ts +++ b/frontend/src/utils/logic/index.ts @@ -1,2 +1,3 @@ +export { isReadonlyEdition } from "./editions"; export { createRedirectUri, decodeRegistrationLink } from "./registration"; export { getBestRedirect } from "./routes"; diff --git a/frontend/src/utils/logic/routes.test.ts b/frontend/src/utils/logic/routes.test.ts index 0f370b567..903a5210d 100644 --- a/frontend/src/utils/logic/routes.test.ts +++ b/frontend/src/utils/logic/routes.test.ts @@ -5,6 +5,15 @@ import { getBestRedirect } from "./routes"; * about the asterisk matching it */ +test("/students/states stays there", () => { + expect(getBestRedirect("/editions/old/students/states", "new")).toEqual( + "/editions/new/students/states" + ); + expect(getBestRedirect("/editions/old/students/states/", "new")).toEqual( + "/editions/new/students/states" + ); +}); + test("/students stays there", () => { expect(getBestRedirect("/editions/old/students", "new")).toEqual("/editions/new/students"); expect(getBestRedirect("/editions/old/students/", "new")).toEqual("/editions/new/students"); diff --git a/frontend/src/utils/logic/routes.ts b/frontend/src/utils/logic/routes.ts index 68260dc15..588d69553 100644 --- a/frontend/src/utils/logic/routes.ts +++ b/frontend/src/utils/logic/routes.ts @@ -5,7 +5,12 @@ import { matchPath } from "react-router-dom"; * Boils down to the most-specific route that can be used across editions */ export function getBestRedirect(location: string, editionName: string): string { - // All /student/X routes should go to /students + // Students/states should stay at /students/states + if (matchPath({ path: "/editions/:edition/students/states" }, location)) { + return `/editions/${editionName}/students/states`; + } + + // All remaining /student/X routes should go to /students if (matchPath({ path: "/editions/:edition/students/*" }, location)) { return `/editions/${editionName}/students`; } diff --git a/frontend/src/utils/session-storage/student-filters.ts b/frontend/src/utils/session-storage/student-filters.ts new file mode 100644 index 000000000..7d298c588 --- /dev/null +++ b/frontend/src/utils/session-storage/student-filters.ts @@ -0,0 +1,85 @@ +import { SessionStorageKey } from "../../data/enums"; +import { DropdownRole } from "../../components/StudentsComponents/StudentListFilters/RolesFilter/RolesFilter"; + +export function getNameFilter(): string { + const nameFilter = sessionStorage.getItem(SessionStorageKey.NAME_FILTER); + return nameFilter === null ? "" : nameFilter; +} + +export function getAlumniFilter(): boolean { + const alumniFilter = sessionStorage.getItem(SessionStorageKey.ALUMNI_FILTER); + return alumniFilter === null ? false : JSON.parse(alumniFilter); +} + +export function getStudentCoachVolunteerFilter(): boolean { + const studentCoachVolunteerFilter = sessionStorage.getItem( + SessionStorageKey.STUDENT_COACH_VOLUNTEER_FILTER + ); + return studentCoachVolunteerFilter === null ? false : JSON.parse(studentCoachVolunteerFilter); +} + +export function getSuggestedFilter(): boolean { + const suggestedFilter = sessionStorage.getItem(SessionStorageKey.SUGGESTED_FILTER); + return suggestedFilter === null ? false : JSON.parse(suggestedFilter); +} + +export function getRolesFilter(): DropdownRole[] { + const rolesFilter = sessionStorage.getItem(SessionStorageKey.ROLES_FILTER); + return rolesFilter === null ? [] : JSON.parse(rolesFilter); +} + +export function getConfirmFilter(): DropdownRole[] { + const confirmFilter = sessionStorage.getItem(SessionStorageKey.CONFIRM_FILTER); + return confirmFilter === null ? [] : JSON.parse(confirmFilter); +} + +export function setNameFilterStorage(nameFilter: string | null) { + if (nameFilter === null) { + sessionStorage.removeItem(SessionStorageKey.NAME_FILTER); + } else { + sessionStorage.setItem(SessionStorageKey.NAME_FILTER, nameFilter); + } +} + +export function setAlumniFilterStorage(alumniFilter: string | null) { + if (alumniFilter === null) { + sessionStorage.removeItem(SessionStorageKey.ALUMNI_FILTER); + } else { + sessionStorage.setItem(SessionStorageKey.ALUMNI_FILTER, alumniFilter); + } +} + +export function setStudentCoachVolunteerFilterStorage(studentCoachVolunteerFilter: string | null) { + if (studentCoachVolunteerFilter === null) { + sessionStorage.removeItem(SessionStorageKey.STUDENT_COACH_VOLUNTEER_FILTER); + } else { + sessionStorage.setItem( + SessionStorageKey.STUDENT_COACH_VOLUNTEER_FILTER, + studentCoachVolunteerFilter + ); + } +} + +export function setSuggestedFilterStorage(suggestedFilter: string | null) { + if (suggestedFilter === null) { + sessionStorage.removeItem(SessionStorageKey.SUGGESTED_FILTER); + } else { + sessionStorage.setItem(SessionStorageKey.SUGGESTED_FILTER, suggestedFilter); + } +} + +export function setRolesFilterStorage(rolesFilter: string | null) { + if (rolesFilter === null) { + sessionStorage.removeItem(SessionStorageKey.ROLES_FILTER); + } else { + sessionStorage.setItem(SessionStorageKey.ROLES_FILTER, rolesFilter); + } +} + +export function setConfirmFilterStorage(confirmFilter: string | null) { + if (confirmFilter === null) { + sessionStorage.removeItem(SessionStorageKey.CONFIRM_FILTER); + } else { + sessionStorage.setItem(SessionStorageKey.CONFIRM_FILTER, confirmFilter); + } +} diff --git a/frontend/src/views/AdminsPage/AdminsPage.tsx b/frontend/src/views/AdminsPage/AdminsPage.tsx index da9513584..ee151d34d 100644 --- a/frontend/src/views/AdminsPage/AdminsPage.tsx +++ b/frontend/src/views/AdminsPage/AdminsPage.tsx @@ -4,8 +4,9 @@ import { getAdmins } from "../../utils/api/users/admins"; import { AddAdmin, AdminList } from "../../components/AdminsComponents"; import { User } from "../../utils/api/users/users"; import { SearchBar } from "../../components/Common/Forms"; -import { Error, SearchFieldDiv, TableDiv } from "../../components/Common/Users/styles"; +import { SearchFieldDiv, TableDiv } from "../../components/Common/Users/styles"; import LoadSpinner from "../../components/Common/LoadSpinner"; +import { toast } from "react-toastify"; export default function AdminsPage() { const [allAdmins, setAllAdmins] = useState([]); @@ -13,39 +14,36 @@ export default function AdminsPage() { const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [gotData, setGotData] = useState(false); - const [error, setError] = useState(""); const getData = useCallback(async () => { - setError(""); - try { - let adminsAvailable = true; - let page = 0; - let newAdmins: User[] = []; - while (adminsAvailable) { - const response = await getAdmins(page, searchTerm); - if (page === 0) { - newAdmins = response.users; - } else { - newAdmins = newAdmins.concat(response.users); - } - adminsAvailable = response.users.length !== 0; - page += 1; + let adminsAvailable = true; + let page = 0; + let newAdmins: User[] = []; + while (adminsAvailable) { + const response = await toast.promise(getAdmins(page, searchTerm), { + error: "Failed to receive admins", + }); + if (page === 0) { + newAdmins = response.users; + } else { + newAdmins = newAdmins.concat(response.users); } - setGotData(true); - setAdmins(newAdmins); - setAllAdmins(newAdmins); - } catch (exception) { - setError("Oops, something went wrong..."); + adminsAvailable = response.users.length !== 0; + page += 1; } + setAdmins(newAdmins); + setAllAdmins(newAdmins); + + setGotData(true); setLoading(false); }, [searchTerm]); useEffect(() => { - if (!gotData && !loading && !error) { + if (!gotData && !loading) { setLoading(true); getData(); } - }, [gotData, loading, error, getData]); + }, [gotData, loading, getData]); function addAdmin(user: User) { setAllAdmins(allAdmins.concat([user])); @@ -74,10 +72,8 @@ export default function AdminsPage() { if (admins.length === 0) { if (loading) { list = ; - } else if (gotData) { - list =
No admins found
; } else { - list = {error}; + list =
No admins found
; } } else { list = ( diff --git a/frontend/src/views/MailOverviewPage/MailOverviewPage.css b/frontend/src/views/MailOverviewPage/MailOverviewPage.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/views/MailOverviewPage/MailOverviewPage.tsx b/frontend/src/views/MailOverviewPage/MailOverviewPage.tsx index 346bf4538..19488cdb2 100644 --- a/frontend/src/views/MailOverviewPage/MailOverviewPage.tsx +++ b/frontend/src/views/MailOverviewPage/MailOverviewPage.tsx @@ -1,27 +1,27 @@ import React, { useState } from "react"; import { getMailOverview, setStateRequest, StudentEmail } from "../../utils/api/mail_overview"; -import DropdownButton from "react-bootstrap/DropdownButton"; import Dropdown from "react-bootstrap/Dropdown"; -import InputGroup from "react-bootstrap/InputGroup"; -import FormControl from "react-bootstrap/FormControl"; import InfiniteScroll from "react-infinite-scroller"; -import { Multiselect } from "multiselect-react-dropdown"; import { Form } from "react-bootstrap"; import { TableDiv, DropDownButtonDiv, SearchDiv, FilterDiv, - SearchAndFilterDiv, - EmailsTable, CenterDiv, MessageDiv, + MailOverviewDiv, + SearchAndChangeDiv, } from "./styles"; import { EmailType } from "../../data/enums"; import { useParams } from "react-router-dom"; import { Student } from "../../data/interfaces"; import LoadSpinner from "../../components/Common/LoadSpinner"; -import { Error } from "../../components/Common/Users/styles"; +import { toast } from "react-toastify"; +import { StyledTable } from "../../components/Common/Tables/styles"; +import SearchBar from "../../components/Common/Forms/SearchBar"; +import { CommonMultiselect } from "../../components/Common/Forms"; +import { CommonDropdownButton } from "../../components/Common/Buttons/styles"; interface EmailRow { email: StudentEmail; @@ -36,10 +36,11 @@ export default function MailOverviewPage() { const [gotEmails, setGotEmails] = useState(false); const [loading, setLoading] = useState(false); const [moreEmailsAvailable, setMoreEmailsAvailable] = useState(true); // Endpoint has more emailRows available - const [error, setError] = useState(undefined); const [page, setPage] = useState(0); const [allSelected, setAllSelected] = useState(false); + const [controller, setController] = useState(undefined); + // Keep track of the set filters const [searchTerm, setSearchTerm] = useState(""); const [filters, setFilters] = useState([]); @@ -56,37 +57,43 @@ export default function MailOverviewPage() { setLoading(true); - try { - const response = await getMailOverview(editionId, page, searchTerm, filters); - if (response.studentEmails.length === 0) { - setMoreEmailsAvailable(false); - } - if (page === 0) { - setEmailRows( + if (controller !== undefined) { + controller.abort(); + } + const newController = new AbortController(); + setController(newController); + + const response = await toast.promise( + getMailOverview(editionId, page, searchTerm, filters, newController), + { error: "Failed to retrieve states" } + ); + if (response.studentEmails.length === 0) { + setMoreEmailsAvailable(false); + } + if (page === 0) { + setEmailRows( + response.studentEmails.map(email => { + return { + email: email, + checked: false, + }; + }) + ); + } else { + setEmailRows( + emailRows.concat( response.studentEmails.map(email => { return { email: email, checked: false, }; }) - ); - } else { - setEmailRows( - emailRows.concat( - response.studentEmails.map(email => { - return { - email: email, - checked: false, - }; - }) - ) - ); - } - setPage(page + 1); - setGotEmails(true); - } catch (exception) { - setError("Oops, something went wrong..."); + ) + ); } + setPage(page + 1); + + setGotEmails(true); setLoading(false); } @@ -138,30 +145,22 @@ export default function MailOverviewPage() { .filter(row => row.checked) .map(row => row.email.student.studentId); - try { - await setStateRequest(eventKey, editionId, selectedStudents); - setEmailRows( - emailRows.map(row => { - row.checked = false; - return row; - }) - ); - setAllSelected(false); - alert("Successful changed"); - refresh(); - } catch { - alert("Failed to change state"); - } + await toast.promise(setStateRequest(eventKey, editionId, selectedStudents), { + error: "Failed to change state", + pending: "Changing state", + }); + setEmailRows( + emailRows.map(row => { + row.checked = false; + return row; + }) + ); + setAllSelected(false); + refresh(); } let table; - if (error) { - table = ( - - {error} - - ); - } else if (gotEmails && emailRows.length === 0) { + if (gotEmails && emailRows.length === 0) { table = ( No students found. @@ -173,12 +172,12 @@ export default function MailOverviewPage() { } + loader={} initialLoad={true} useWindow={false} getScrollParent={() => document.getElementById("root")} > - + @@ -222,46 +221,28 @@ export default function MailOverviewPage() { ))} - + ); } return ( - <> - - - {Object.values(EmailType).map((type, index) => ( - changeState(index.toString())} - > - {type} - - ))} - - - - - - { - searchName(e.target.value); - }} - /> - - + + + { + searchName(e.target.value); + }} + value={searchTerm} + placeholder="Search a student" + /> + +
+ - -
+ + + {Object.values(EmailType).map((type, index) => ( + changeState(index.toString())} + > + {type} + + ))} + + + {table} - + ); } diff --git a/frontend/src/views/MailOverviewPage/styles.ts b/frontend/src/views/MailOverviewPage/styles.ts index 1857f0111..7fbf66782 100644 --- a/frontend/src/views/MailOverviewPage/styles.ts +++ b/frontend/src/views/MailOverviewPage/styles.ts @@ -1,4 +1,3 @@ -import { Table } from "react-bootstrap"; import styled from "styled-components"; export const TableDiv = styled.div` @@ -6,42 +5,37 @@ export const TableDiv = styled.div` margin-top: 5px; margin-bottom: 50px; width: fit-content; + min-width: 100%; `; export const DropDownButtonDiv = styled.div` - margin: auto; - margin-top: 50px; + float: right; width: fit-content; `; export const SearchDiv = styled.div` - margin: auto; - margin-top: 50px; - width: fit-content; + margin-top: 20px; + width: 14em; display: inline-block; `; export const FilterDiv = styled.div` - background-color: white; - color: black; - border-radius: 5px; - margin: auto; - margin-top: 50px; - margin-left: 5px; - width: fit-content; + width: 14em; max-width: 350px; display: inline-block; `; +export const SearchAndChangeDiv = styled.div` + float: left; + width: 100%; + margin-bottom: 5px; +`; + export const SearchAndFilterDiv = styled.div` margin: auto; width: fit-content; `; -export const EmailsTable = styled(Table)` - // TODO: Make all tables uniform -`; - export const CenterDiv = styled.div` width: 100%; margin: auto; @@ -51,3 +45,9 @@ export const MessageDiv = styled.div` width: fit-content; margin: auto; `; + +export const MailOverviewDiv = styled.div` + min-width: 34em; + width: fit-content; + margin: auto; +`; diff --git a/frontend/src/views/UsersPage/UsersPage.tsx b/frontend/src/views/UsersPage/UsersPage.tsx index afa74330c..51dda0130 100644 --- a/frontend/src/views/UsersPage/UsersPage.tsx +++ b/frontend/src/views/UsersPage/UsersPage.tsx @@ -1,10 +1,11 @@ -import React, { useState } from "react"; -import { useParams } from "react-router-dom"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { Coaches } from "../../components/UsersComponents/Coaches"; import { InviteUser } from "../../components/UsersComponents/InviteUser"; import { PendingRequests } from "../../components/UsersComponents/Requests"; import { User } from "../../utils/api/users/users"; import { getCoaches } from "../../utils/api/users/coaches"; +import { toast } from "react-toastify"; /** * Page for admins to manage coach and admin settings. @@ -15,13 +16,15 @@ function UsersPage() { const [coaches, setCoaches] = useState([]); // All coaches from the selected edition const [loading, setLoading] = useState(false); // Waiting for data (used for spinner) const [gotData, setGotData] = useState(false); // Received data - const [error, setError] = useState(""); // Error message const [moreCoachesAvailable, setMoreCoachesAvailable] = useState(true); // Endpoint has more coaches available const [allCoachesFetched, setAllCoachesFetched] = useState(false); const [searchTerm, setSearchTerm] = useState(""); // The word set in filter for coachlist const [page, setPage] = useState(0); // The next page to request + const [controller, setController] = useState(undefined); + const params = useParams(); + const navigate = useNavigate(); /** * Request the next page from the list of coaches. @@ -43,37 +46,50 @@ function UsersPage() { } setLoading(true); - setError(""); - try { - const response = await getCoaches(params.editionId as string, searchTerm, page); + + if (controller !== undefined) { + controller.abort(); + } + const newController = new AbortController(); + setController(newController); + + const response = await toast.promise( + getCoaches(params.editionId as string, searchTerm, page, newController), + { error: "Failed to retrieve coaches" } + ); + if (response.users.length === 0) { + setMoreCoachesAvailable(false); + } + if (page === 0) { + setCoaches(response.users); + } else { + setCoaches(coaches.concat(response.users)); + } + + if (searchTerm === "") { if (response.users.length === 0) { - setMoreCoachesAvailable(false); + setAllCoachesFetched(true); } if (page === 0) { - setCoaches(response.users); + setAllCoaches(response.users); } else { - setCoaches(coaches.concat(response.users)); + setAllCoaches(allCoaches.concat(response.users)); } + } - if (searchTerm === "") { - if (response.users.length === 0) { - setAllCoachesFetched(true); - } - if (page === 0) { - setAllCoaches(response.users); - } else { - setAllCoaches(allCoaches.concat(response.users)); - } - } + setPage(page + 1); - setPage(page + 1); - setGotData(true); - } catch (exception) { - setError("Oops, something went wrong..."); - } + setGotData(true); setLoading(false); } + /** + * update the coaches when the edition changes + */ + useEffect(() => { + refreshCoaches(); + }, [params.editionId]); + /** * Set the searchTerm and request the first page with this filter. * The current list of coaches will be resetted. @@ -89,7 +105,7 @@ function UsersPage() { /** * Reset the list of coaches and get the first page. - * Used when a new coach is added. + * Used when a new coach is added, or when the edition is changed. */ function refreshCoaches() { setCoaches([]); @@ -109,11 +125,16 @@ function UsersPage() { return object !== coach; }) ); + setAllCoaches( + allCoaches.filter(object => { + return object !== coach; + }) + ); } if (params.editionId === undefined) { - // If this happens, User should be redirected to error page - return
Error
; + navigate("/404-not-found"); + return null; } else { return (
@@ -123,7 +144,6 @@ function UsersPage() { edition={params.editionId} coaches={coaches} gotData={gotData} - error={error} getMoreCoaches={getCoachesData} searchCoaches={filterCoachesData} moreCoachesAvailable={moreCoachesAvailable} diff --git a/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx b/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx index d90de2bf5..ccab9b2dd 100644 --- a/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx +++ b/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; +import { LoadSpinner } from "../../components/Common"; import { validateBearerToken } from "../../utils/api/auth"; import { logIn, logOut, useAuth } from "../../contexts/auth-context"; import { getAccessToken, getRefreshToken } from "../../utils/local-storage"; @@ -39,7 +40,7 @@ export default function VerifyingTokenPage() { // This will be replaced later on return (
-

Loading...

+
); } diff --git a/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx index d644eda40..ebb80a8ec 100644 --- a/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { getProjects } from "../../../utils/api/projects"; import { CreateButton, SearchField, OwnProject } from "./styles"; import { Project } from "../../../data/interfaces"; @@ -8,6 +8,8 @@ import { useAuth } from "../../../contexts"; import { Role } from "../../../data/enums"; import ConflictsButton from "../../../components/ProjectsComponents/Conflicts/ConflictsButton"; +import { isReadonlyEdition } from "../../../utils/logic"; +import { toast } from "react-toastify"; /** * @returns The projects overview page where you can see all the projects. * You can filter on your own projects or filter on project name. @@ -19,7 +21,8 @@ export default function ProjectPage() { const [loading, setLoading] = useState(false); const [moreProjectsAvailable, setMoreProjectsAvailable] = useState(true); // Endpoint has more coaches available const [allProjectsFetched, setAllProjectsFetched] = useState(false); - const [error, setError] = useState(undefined); + + const [controller, setController] = useState(undefined); // Keep track of the set filters const [searchString, setSearchString] = useState(""); @@ -52,40 +55,50 @@ export default function ProjectPage() { } setLoading(true); - try { - const response = await getProjects(editionId, searchString, ownProjects, page); - if (response) { - if (response.projects.length === 0) { - setMoreProjectsAvailable(false); - } - if (page === 0) { - setProjects(response.projects); - } else { - setProjects(projects.concat(response.projects)); - } - - if (searchString === "") { - if (response.projects.length === 0) { - setAllProjectsFetched(true); - } - if (page === 0) { - setAllProjects(response.projects); - } else { - setAllProjects(allProjects.concat(response.projects)); - } - } - - setPage(page + 1); - setGotProjects(true); + + if (controller !== undefined) { + controller.abort(); + } + const newController = new AbortController(); + setController(newController); + + const response = await toast.promise( + getProjects(editionId, searchString, ownProjects, page, newController), + { error: "Failed to retrieve projects" } + ); + if (response.projects.length === 0) { + setMoreProjectsAvailable(false); + } + if (page === 0) { + setProjects(response.projects); + } else { + setProjects(projects.concat(response.projects)); + } + + if (searchString === "") { + if (response.projects.length === 0) { + setAllProjectsFetched(true); + } + if (page === 0) { + setAllProjects(response.projects); } else { - setError("Oops, something went wrong..."); + setAllProjects(allProjects.concat(response.projects)); } - } catch (exception) { - setError("Oops, something went wrong..."); } + + setPage(page + 1); + + setGotProjects(true); setLoading(false); } + /** + * update the projects when the edition changes + */ + useEffect(() => { + refreshProjects(); + }, [editionId]); + /** * Reset fetched projects */ @@ -132,7 +145,7 @@ export default function ProjectPage() { placeholder="project name" /> - {role === Role.ADMIN && editionId === editions[0] && ( + {role === Role.ADMIN && !isReadonlyEdition(editionId, editions) && ( navigate("/editions/" + editionId + "/projects/new")} > @@ -158,7 +171,6 @@ export default function ProjectPage() { getMoreProjects={loadProjects} moreProjectsAvailable={moreProjectsAvailable} removeProject={removeProject} - error={error} />
);