diff --git a/backend/siarnaq/api/teams/views.py b/backend/siarnaq/api/teams/views.py index 97fd22642..bab9527a6 100644 --- a/backend/siarnaq/api/teams/views.py +++ b/backend/siarnaq/api/teams/views.py @@ -159,7 +159,17 @@ def join(self, request, pk=None, *, episode_id): logger.debug("team_join", message="User has joined team.", team=team.pk) return Response(None, status=status.HTTP_204_NO_CONTENT) - @extend_schema(responses={status.HTTP_204_NO_CONTENT: None}) + @extend_schema( + responses={status.HTTP_204_NO_CONTENT: None}, + request={ + "multipart/form-data": { + "type": "object", + "properties": { + "avatar": {"type": "string", "format": "binary"}, + }, + } + }, + ) @action( detail=False, methods=["post"], diff --git a/frontend2/schema.yml b/frontend2/schema.yml index 5f96700f9..50120da02 100644 --- a/frontend2/schema.yml +++ b/frontend2/schema.yml @@ -158,7 +158,7 @@ paths: type: array items: type: integer - description: A list of teams to filter for. Defaults to just your own team. + description: A list of teams to filter for. Defaults to your own team. tags: - compete security: @@ -1290,16 +1290,13 @@ paths: - team requestBody: content: - application/json: - schema: - $ref: '#/components/schemas/TeamAvatarRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TeamAvatarRequest' multipart/form-data: schema: - $ref: '#/components/schemas/TeamAvatarRequest' - required: true + type: object + properties: + avatar: + type: string + format: binary security: - jwtAuth: [] responses: @@ -2749,15 +2746,6 @@ components: type: boolean required: - invocation - TeamAvatarRequest: - type: object - properties: - avatar: - type: string - format: binary - writeOnly: true - required: - - avatar TeamCreate: type: object properties: diff --git a/frontend2/src/api/_autogen/.openapi-generator/FILES b/frontend2/src/api/_autogen/.openapi-generator/FILES index 2d1757da7..49e39f640 100644 --- a/frontend2/src/api/_autogen/.openapi-generator/FILES +++ b/frontend2/src/api/_autogen/.openapi-generator/FILES @@ -48,7 +48,6 @@ models/StyleEnum.ts models/Submission.ts models/SubmissionDownload.ts models/SubmissionReportRequest.ts -models/TeamAvatarRequest.ts models/TeamCreate.ts models/TeamCreateRequest.ts models/TeamJoinRequest.ts diff --git a/frontend2/src/api/_autogen/apis/TeamApi.ts b/frontend2/src/api/_autogen/apis/TeamApi.ts index 960c2aa8b..96fb55e49 100644 --- a/frontend2/src/api/_autogen/apis/TeamApi.ts +++ b/frontend2/src/api/_autogen/apis/TeamApi.ts @@ -19,7 +19,6 @@ import type { PaginatedClassRequirementList, PaginatedTeamPublicList, PatchedTeamPrivateRequest, - TeamAvatarRequest, TeamCreate, TeamCreateRequest, TeamJoinRequest, @@ -38,8 +37,6 @@ import { PaginatedTeamPublicListToJSON, PatchedTeamPrivateRequestFromJSON, PatchedTeamPrivateRequestToJSON, - TeamAvatarRequestFromJSON, - TeamAvatarRequestToJSON, TeamCreateFromJSON, TeamCreateToJSON, TeamCreateRequestFromJSON, @@ -89,7 +86,7 @@ export interface TeamRequirementRetrieveRequest { export interface TeamTAvatarCreateRequest { episodeId: string; - teamAvatarRequest: TeamAvatarRequest; + avatar?: Blob; } export interface TeamTCreateRequest { @@ -394,16 +391,10 @@ export class TeamApi extends runtime.BaseAPI { throw new runtime.RequiredError('episodeId','Required parameter requestParameters.episodeId was null or undefined when calling teamTAvatarCreate.'); } - if (requestParameters.teamAvatarRequest === null || requestParameters.teamAvatarRequest === undefined) { - throw new runtime.RequiredError('teamAvatarRequest','Required parameter requestParameters.teamAvatarRequest was null or undefined when calling teamTAvatarCreate.'); - } - const queryParameters: any = {}; const headerParameters: runtime.HTTPHeaders = {}; - headerParameters['Content-Type'] = 'application/json'; - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("jwtAuth", []); @@ -412,12 +403,32 @@ export class TeamApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + const consumes: runtime.Consume[] = [ + { contentType: 'multipart/form-data' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters.avatar !== undefined) { + formParams.append('avatar', requestParameters.avatar as any); + } + const response = await this.request({ path: `/api/team/{episode_id}/t/avatar/`.replace(`{${"episode_id"}}`, encodeURIComponent(String(requestParameters.episodeId))), method: 'POST', headers: headerParameters, query: queryParameters, - body: TeamAvatarRequestToJSON(requestParameters.teamAvatarRequest), + body: formParams, }, initOverrides); return new runtime.VoidApiResponse(response); diff --git a/frontend2/src/api/_autogen/models/TeamAvatarRequest.ts b/frontend2/src/api/_autogen/models/TeamAvatarRequest.ts deleted file mode 100644 index 34b25ad73..000000000 --- a/frontend2/src/api/_autogen/models/TeamAvatarRequest.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.0.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from '../runtime'; -/** - * - * @export - * @interface TeamAvatarRequest - */ -export interface TeamAvatarRequest { - /** - * - * @type {Blob} - * @memberof TeamAvatarRequest - */ - avatar: Blob; -} - -/** - * Check if a given object implements the TeamAvatarRequest interface. - */ -export function instanceOfTeamAvatarRequest(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "avatar" in value; - - return isInstance; -} - -export function TeamAvatarRequestFromJSON(json: any): TeamAvatarRequest { - return TeamAvatarRequestFromJSONTyped(json, false); -} - -export function TeamAvatarRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): TeamAvatarRequest { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'avatar': json['avatar'], - }; -} - -export function TeamAvatarRequestToJSON(value?: TeamAvatarRequest | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - - 'avatar': value.avatar, - }; -} - diff --git a/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts b/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts index d04544dfa..3cffe1073 100644 --- a/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts +++ b/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts @@ -90,7 +90,7 @@ export function UserProfilePrivateRequestFromJSONTyped(json: any, ignoreDiscrimi return json; } return { - + 'gender': GenderEnumFromJSON(json['gender']), 'gender_details': !exists(json, 'gender_details') ? undefined : json['gender_details'], 'school': !exists(json, 'school') ? undefined : json['school'], @@ -108,7 +108,7 @@ export function UserProfilePrivateRequestToJSON(value?: UserProfilePrivateReques return null; } return { - + 'gender': GenderEnumToJSON(value.gender), 'gender_details': value.gender_details, 'school': value.school, diff --git a/frontend2/src/api/_autogen/models/index.ts b/frontend2/src/api/_autogen/models/index.ts index 8d8127cd6..7d5ab4f73 100644 --- a/frontend2/src/api/_autogen/models/index.ts +++ b/frontend2/src/api/_autogen/models/index.ts @@ -41,7 +41,6 @@ export * from './StyleEnum'; export * from './Submission'; export * from './SubmissionDownload'; export * from './SubmissionReportRequest'; -export * from './TeamAvatarRequest'; export * from './TeamCreate'; export * from './TeamCreateRequest'; export * from './TeamJoinRequest'; diff --git a/frontend2/src/api/compete/competeKeys.ts b/frontend2/src/api/compete/competeKeys.ts index a51124c9c..30e4f7557 100644 --- a/frontend2/src/api/compete/competeKeys.ts +++ b/frontend2/src/api/compete/competeKeys.ts @@ -147,6 +147,9 @@ export const competeMutationKeys = { uploadSub: ({ episodeId }: { episodeId: string }) => ["compete", episodeId, "submit"] as const, + downloadSub: ({ episodeId }: { episodeId: string }) => + ["compete", episodeId, "submit", "download"] as const, + // --- SCRIMMAGES --- // requestScrim: ({ episodeId }: { episodeId: string }) => ["compete", episodeId, "scrimmage", "request"] as const, diff --git a/frontend2/src/api/compete/useCompete.ts b/frontend2/src/api/compete/useCompete.ts index 1ae1d3b81..2cb68702a 100644 --- a/frontend2/src/api/compete/useCompete.ts +++ b/frontend2/src/api/compete/useCompete.ts @@ -539,11 +539,18 @@ export const useCancelScrimmage = ( /** * For downloading a submission. */ -export const useDownloadSubmission = ( - { episodeId }: { episodeId: string }, -): UseMutationResult => +export const useDownloadSubmission = ({ + episodeId, +}: { + episodeId: string; +}): UseMutationResult< + void, + Error, + CompeteSubmissionDownloadRetrieveRequest, + unknown +> => useMutation({ - mutationKey: competeMutationKeys.acceptScrim({ episodeId }), + mutationKey: competeMutationKeys.downloadSub({ episodeId }), mutationFn: async ({ episodeId, id, diff --git a/frontend2/src/api/team/teamApi.ts b/frontend2/src/api/team/teamApi.ts index 962b764ae..3f53d3a36 100644 --- a/frontend2/src/api/team/teamApi.ts +++ b/frontend2/src/api/team/teamApi.ts @@ -151,13 +151,10 @@ export const searchTeams = async ({ /** * Upload a new avatar for the currently logged in user's team. * @param episodeId The current episode's ID. - * @param avatar The avatar file. + * @param teamAvatarRequest The avatar file. */ -export const teamAvatarUpload = async ({ - episodeId, - teamAvatarRequest, -}: TeamTAvatarCreateRequest): Promise => { - await API.teamTAvatarCreate({ episodeId, teamAvatarRequest }); +export const teamAvatarUpload = async (teamAvatarRequest: TeamTAvatarCreateRequest): Promise => { + await API.teamTAvatarCreate(teamAvatarRequest); }; /** diff --git a/frontend2/src/api/team/teamKeys.ts b/frontend2/src/api/team/teamKeys.ts index 9b4685bee..b9cc0b549 100644 --- a/frontend2/src/api/team/teamKeys.ts +++ b/frontend2/src/api/team/teamKeys.ts @@ -59,7 +59,7 @@ export const teamMutationKeys = { update: ({ episodeId }: { episodeId: string }) => ["team", "update", episodeId] as const, - avatar: ({ episodeId }: { episodeId: string }) => + avatarUpload: ({ episodeId }: { episodeId: string }) => ["team", "avatar", episodeId] as const, report: ({ episodeId }: { episodeId: string }) => diff --git a/frontend2/src/api/team/useTeam.ts b/frontend2/src/api/team/useTeam.ts index 9159b2de6..c9bb610b0 100644 --- a/frontend2/src/api/team/useTeam.ts +++ b/frontend2/src/api/team/useTeam.ts @@ -8,7 +8,7 @@ import { import type { PaginatedTeamPublicList, PatchedTeamPrivateRequest, - TeamAvatarRequest, + TeamTAvatarCreateRequest, TeamCreate, TeamJoinRequest, TeamPrivate, @@ -196,11 +196,12 @@ export const useUpdateTeamAvatar = ( episodeId: string; }, queryClient: QueryClient, -): UseMutationResult => +): UseMutationResult => useMutation({ - mutationKey: teamMutationKeys.avatar({ episodeId }), - mutationFn: async (teamAvatarRequest: TeamAvatarRequest) => { - await toast.promise(teamAvatarUpload({ episodeId, teamAvatarRequest }), { + mutationKey: teamMutationKeys.avatarUpload({ episodeId }), + // We pass in a Blob because we already have the episodeId + mutationFn: async (avatar: Blob) => { + await toast.promise(teamAvatarUpload({ episodeId, avatar }), { loading: "Uploading team avatar...", success: "Uploaded team avatar!", error: "Error uploading team avatar.", diff --git a/frontend2/src/api/user/useUser.ts b/frontend2/src/api/user/useUser.ts index d848ac3c9..c91ad8270 100644 --- a/frontend2/src/api/user/useUser.ts +++ b/frontend2/src/api/user/useUser.ts @@ -19,12 +19,12 @@ import type { } from "../_autogen"; import { userMutationKeys, userQueryKeys } from "./userKeys"; import { - avatarUpload, createUser, doResetPassword, resumeUpload, updateCurrentUser, - downloadResume + downloadResume, + userAvatarUpload, } from "./userApi"; import { toast } from "react-hot-toast"; import { login } from "../auth/authApi"; @@ -185,14 +185,14 @@ export const useResetPassword = ({ /** * For uploading a new avatar for the currently logged in user. */ -export const useAvatarUpload = ( +export const useUpdateUserAvatar = ( { episodeId }: { episodeId: string }, queryClient: QueryClient, ): UseMutationResult => useMutation({ mutationKey: userMutationKeys.avatarUpload({ episodeId }), mutationFn: async (userAvatarRequest: UserUAvatarCreateRequest) => { - await toast.promise(avatarUpload(userAvatarRequest), { + await toast.promise(userAvatarUpload(userAvatarRequest), { loading: "Uploading new avatar...", success: "Uploaded new avatar!", error: "Error uploading new avatar.", @@ -233,9 +233,11 @@ export const useResumeUpload = ( /** * For downloading the resume of the currently logged in user. */ -export const useDownloadResume = ( - { episodeId }: { episodeId: string }, -): UseMutationResult => +export const useDownloadResume = ({ + episodeId, +}: { + episodeId: string; +}): UseMutationResult => useMutation({ mutationKey: userMutationKeys.resumeDownload({ episodeId }), mutationFn: async () => { diff --git a/frontend2/src/api/user/userApi.ts b/frontend2/src/api/user/userApi.ts index 4fe34f145..f5eb1ce09 100644 --- a/frontend2/src/api/user/userApi.ts +++ b/frontend2/src/api/user/userApi.ts @@ -87,7 +87,7 @@ export const updateCurrentUser = async ({ * Upload a new avatar for the currently logged in user. * @param userAvatarRequest The avatar file. */ -export const avatarUpload = async ( +export const userAvatarUpload = async ( userAvatarRequest: UserUAvatarCreateRequest, ): Promise => { await API.userUAvatarCreate(userAvatarRequest); diff --git a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx index 83d7c2728..4c209bba0 100644 --- a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx @@ -122,7 +122,7 @@ const TeamsTable: React.FC = ({ header: "Team", key: "team", value: (team) => ( - + {trimString(team.name, 13)} ), diff --git a/frontend2/src/components/tables/submissions/SubHistoryTable.tsx b/frontend2/src/components/tables/submissions/SubHistoryTable.tsx index 5879a484a..f63e45764 100644 --- a/frontend2/src/components/tables/submissions/SubHistoryTable.tsx +++ b/frontend2/src/components/tables/submissions/SubHistoryTable.tsx @@ -4,9 +4,7 @@ import { StatusBccEnum, } from "../../../api/_autogen"; import { useEpisodeId } from "contexts/EpisodeContext"; -import { - useDownloadSubmission -} from "../../../api/compete/useCompete"; +import { useDownloadSubmission } from "../../../api/compete/useCompete"; import type { Maybe } from "../../../utils/utilTypes"; import { NavLink } from "react-router-dom"; import { dateTime } from "../../../utils/dateTime"; @@ -30,15 +28,11 @@ interface SubHistoryTableProps { handlePage: (page: number) => void; } - - -// TODO: should i pass episodeId down as a prop? const SubHistoryTable: React.FC = ({ data, loading, page, handlePage, - // downloadSubmission, }) => { const { episodeId } = useEpisodeId(); const downloadSubmission = useDownloadSubmission({ episodeId }); @@ -111,10 +105,17 @@ const SubHistoryTable: React.FC = ({ { header: "", key: "download", - value: (sub) => , + value: (sub) => ( + + ), }, ]} /> diff --git a/frontend2/src/views/Account.tsx b/frontend2/src/views/Account.tsx index da251d91f..7c6cae111 100644 --- a/frontend2/src/views/Account.tsx +++ b/frontend2/src/views/Account.tsx @@ -20,7 +20,7 @@ import FormLabel from "../components/elements/FormLabel"; import { useDownloadResume, useUpdateCurrentUserInfo, - useAvatarUpload, + useUpdateUserAvatar, useResumeUpload, } from "../api/user/useUser"; import { useEpisodeId } from "../contexts/EpisodeContext"; @@ -34,12 +34,11 @@ interface FileInput { const Account: React.FC = () => { const { episodeId } = useEpisodeId(); const queryClient = useQueryClient(); - const uploadAvatar = useAvatarUpload({ episodeId }, queryClient); + const uploadAvatar = useUpdateUserAvatar({ episodeId }, queryClient); const uploadResume = useResumeUpload({ episodeId }, queryClient); const downloadResume = useDownloadResume({ episodeId }); const { user } = useCurrentUser(); - const { register: avatarRegister, handleSubmit: handleAvatarSubmit } = useForm(); @@ -67,7 +66,7 @@ const Account: React.FC = () => { )} -
+
{ loading={uploadResume.isPending} disabled={uploadResume.isPending} /> - {user?.profile?.has_resume ?? false - - ? (

- Resume uploaded! -

) - :

No resume uploaded.

- } + {user?.profile?.has_resume ?? false ? ( +

+ Resume uploaded!{" "} + +

+ ) : ( +

No resume uploaded.

+ )}
-
- + + ); }; diff --git a/frontend2/src/views/MyTeam.tsx b/frontend2/src/views/MyTeam.tsx index 148248308..7e2950b93 100644 --- a/frontend2/src/views/MyTeam.tsx +++ b/frontend2/src/views/MyTeam.tsx @@ -9,26 +9,37 @@ import Modal from "components/Modal"; import EligibilitySettings from "components/team/EligibilitySettings"; import ScrimmageSettings from "components/team/ScrimmageSettings"; import { useEpisodeId } from "contexts/EpisodeContext"; -import { useLeaveTeam, useUpdateTeam, useUserTeam } from "api/team/useTeam"; +import { + useLeaveTeam, + useUpdateTeam, + useUserTeam, + useUpdateTeamAvatar, +} from "api/team/useTeam"; import { useQueryClient } from "@tanstack/react-query"; import JoinTeam from "./JoinTeam"; import Loading from "components/Loading"; import { type SubmitHandler, useForm } from "react-hook-form"; +import { FIELD_REQUIRED_ERROR_MSG } from "utils/constants"; +import FormLabel from "components/elements/FormLabel"; interface InfoFormInput { quote: string; biography: string; } +interface AvatarInput { + file: FileList; +} + const MyTeam: React.FC = () => { const { episodeId } = useEpisodeId(); const queryClient = useQueryClient(); const { - register, - handleSubmit, - formState: { isDirty }, - reset, + register: registerInfo, + handleSubmit: handleInfoSubmit, + formState: { isDirty: isInfoDirty }, + reset: resetInfo, } = useForm(); const teamData = useUserTeam({ episodeId }); @@ -47,26 +58,7 @@ const MyTeam: React.FC = () => { const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); - const membersList = useMemo(() => { - return ( -
- {!teamData.isSuccess ? ( - - ) : ( - - )} -
- ); - }, [teamData]); - - const onSubmit: SubmitHandler = async (data) => { + const onInfoSubmit: SubmitHandler = async (data) => { if (updateTeam.isPending) return; await updateTeam.mutateAsync({ profile: { @@ -74,7 +66,7 @@ const MyTeam: React.FC = () => { biography: data.biography, }, }); - reset(); + resetInfo(); }; // eslint-disable-next-line @typescript-eslint/no-misused-promises @@ -87,6 +79,25 @@ const MyTeam: React.FC = () => { setIsLeaveModalOpen(false); }; + const membersList = useMemo(() => { + return ( +
+ {!teamData.isSuccess ? ( + + ) : ( + + )} +
+ ); + }, [teamData]); + if (teamData.isLoading) { return ; } else if (!teamData.isSuccess) { @@ -101,14 +112,13 @@ const MyTeam: React.FC = () => {
{teamData.data.name} @@ -122,17 +132,17 @@ const MyTeam: React.FC = () => { />