diff --git a/src/api/missionsUpload.ts b/src/api/missionsUpload.ts new file mode 100644 index 0000000..7ac13ed --- /dev/null +++ b/src/api/missionsUpload.ts @@ -0,0 +1,39 @@ +export interface UploadMissionRequest { + missionFile: File; + difficulty: number; + tags?: string[] | null; +} + +export const buildUploadMissionFormData = ( + request: UploadMissionRequest, +): FormData => { + const formData = new FormData(); + + formData.append('missionFile', request.missionFile); + formData.append('difficulty', request.difficulty.toString()); + + // tags: + // - undefined => fromArchive (do not include tags key at all) + // - [] => empty list (not reliably representable via multipart without backend support) + // - [..] => custom tags, repeated keys + if (Array.isArray(request.tags)) { + request.tags.forEach((tag) => formData.append('tags', tag)); + } + + return formData; +}; + +export const getProblemXmlMissingNameMessage = (responseData: unknown) => { + const asText = + typeof responseData === 'string' + ? responseData + : responseData == null + ? '' + : JSON.stringify(responseData); + + if (/problem\.xml/i.test(asText) || /Mission name was not found/i.test(asText)) { + return 'В архиве отсутствует имя в problem.xml'; + } + + return null; +}; diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index d314f7b..7155581 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -1,6 +1,11 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; import { toastError } from '../../lib/toastNotification'; +import { + buildUploadMissionFormData, + getProblemXmlMissingNameMessage, + UploadMissionRequest, +} from '../../api/missionsUpload'; // ─── Типы ──────────────────────────────────────────── @@ -148,27 +153,45 @@ export const fetchMyMissions = createAsyncThunk( export const uploadMission = createAsyncThunk( 'missions/uploadMission', async ( - { - file, - name, - difficulty, - tags, - }: { file: File; name: string; difficulty: number; tags: string[] }, + request: UploadMissionRequest, { rejectWithValue }, ) => { try { - const formData = new FormData(); - formData.append('MissionFile', file); - formData.append('Name', name); - formData.append('Difficulty', difficulty.toString()); - tags.forEach((tag) => formData.append('Tags', tag)); + const formData = buildUploadMissionFormData(request); const response = await axios.post('/missions/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; // Mission } catch (err: any) { - return rejectWithValue(err.response?.data); + const status = err?.response?.status; + const responseData = err?.response?.data; + + if (status === 400) { + const msg = getProblemXmlMissingNameMessage(responseData); + if (msg) { + return rejectWithValue({ + errors: { + missionFile: [msg], + }, + }); + } + } + + if (responseData?.errors) { + return rejectWithValue(responseData); + } + + const fallback = + typeof responseData === 'string' + ? responseData + : 'Не удалось загрузить миссию'; + + return rejectWithValue({ + errors: { + general: [fallback], + }, + }); } }, ); @@ -351,17 +374,19 @@ const missionsSlice = createSlice({ (state, action: PayloadAction) => { state.statuses.upload = 'failed'; - const errors = action.payload.errors as Record< - string, - string[] - >; - Object.values(errors).forEach((messages) => { - messages.forEach((msg) => { - toastError(msg); + const errors = action.payload?.errors as + | Record + | undefined; + if (errors) { + Object.values(errors).forEach((messages) => { + messages.forEach((msg) => { + toastError(msg); + }); }); - }); - - state.create.errors = errors; + state.create.errors = errors; + } else { + toastError('Не удалось загрузить миссию'); + } }, ); diff --git a/src/views/home/missions/ModalCreate.tsx b/src/views/home/missions/ModalCreate.tsx index da35a87..3a34a07 100644 --- a/src/views/home/missions/ModalCreate.tsx +++ b/src/views/home/missions/ModalCreate.tsx @@ -12,6 +12,7 @@ import { toastSuccess } from '../../../lib/toastNotification'; import { cn } from '../../../lib/cn'; import { Link } from 'react-router-dom'; import { NumberInput } from '../../../components/input/NumberInput'; +import { DropDownList, DropDownListItem } from '../../../components/input/DropDownList'; interface ModalCreateProps { active: boolean; @@ -19,23 +20,37 @@ interface ModalCreateProps { } const ModalCreate: FC = ({ active, setActive }) => { - const [name, setName] = useState(''); + type TagsMode = 'fromArchive' | 'none' | 'custom'; const [difficulty, setDifficulty] = useState(1); const [file, setFile] = useState(null); const [tagInput, setTagInput] = useState(''); const [tags, setTags] = useState([]); + const [tagsMode, setTagsMode] = useState('fromArchive'); const status = useAppSelector((state) => state.missions.statuses.upload); const dispatch = useAppDispatch(); const [clickSubmit, setClickSubmit] = useState(false); - const addTag = () => { - const newTag = tagInput.trim(); - if (newTag && !tags.includes(newTag)) { - setTags([...tags, newTag]); - setTagInput(''); + const normalizeTags = (raw: string[]): string[] => { + const result: string[] = []; + const seen = new Set(); + for (const t of raw) { + const trimmed = t.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); } + return result; + }; + + const addTag = () => { + const parts = tagInput.split(',').map((v) => v.trim()); + const next = normalizeTags([...tags, ...parts]); + setTags(next); + setTagInput(''); }; const removeTag = (tagToRemove: string) => { @@ -51,16 +66,27 @@ const ModalCreate: FC = ({ active, setActive }) => { const handleSubmit = async () => { setClickSubmit(true); if (!file) return; - dispatch(uploadMission({ file, name, difficulty, tags })); + + const payloadTags = + tagsMode === 'custom' ? normalizeTags(tags) : undefined; + + dispatch( + uploadMission({ + missionFile: file, + difficulty, + tags: payloadTags, + }), + ); }; useEffect(() => { if (status === 'successful') { toastSuccess('Миссия создана!'); - setName(''); setDifficulty(1); setTags([]); setFile(null); + setTagInput(''); + setTagsMode('fromArchive'); dispatch(setMissionsStatus({ key: 'upload', status: 'idle' })); setActive(false); } @@ -73,12 +99,32 @@ const ModalCreate: FC = ({ active, setActive }) => { dispatch(setMissionsStatus({ key: 'upload', status: 'idle' })); }, [active]); - const getNameErrorMessage = (): string => { + const getDifficultyErrorMessage = (): string => { if (!clickSubmit) return ''; - if (name == '') return 'Поле не может быть пустым'; + if (!Number.isFinite(difficulty) || difficulty < 1) + return 'Укажите сложность (минимум 1)'; return ''; }; + const tagsModeItems: DropDownListItem[] = [ + { text: 'Теги: из архива', value: 'fromArchive' }, + { text: 'Теги: без тегов', value: 'none' }, + { text: 'Теги: свои', value: 'custom' }, + ]; + + const isTagsMode = (v: string): v is TagsMode => + v === 'fromArchive' || v === 'none' || v === 'custom'; + + const currentTagsModeItem = + tagsModeItems.find((i) => i.value === tagsMode) ?? tagsModeItems[0]; + + useEffect(() => { + if (tagsMode === 'none') { + setTags([]); + setTagInput(''); + } + }, [tagsMode]); + return ( = ({ active, setActive }) => {
Добавить задачу
- - = ({ active, setActive }) => { maxValue={3500} onChange={(v) => setDifficulty(v)} placeholder="1" + error={getDifficultyErrorMessage()} />
@@ -137,42 +172,66 @@ const ModalCreate: FC = ({ active, setActive }) => { {/* Теги */}
-
- setTagInput(v)} - defaultState={tagInput} - placeholder="arrays" - onKeyDown={(e) => { - if (e.key === 'Enter') addTag(); +
+
+ Режим тегов +
+ { + if (isTagsMode(v)) setTagsMode(v); }} + weight="w-full" /> - -
-
- {tags.map((tag) => ( -
- {tag} - + {tagsMode === 'none' && ( +
+ Пустой список тегов сейчас не поддерживается в multipart. + Будет использован режим “из архива”.
- ))} + )}
+ + {tagsMode === 'custom' && ( + <> +
+ setTagInput(v)} + defaultState={tagInput} + placeholder="arrays, dp" + onKeyDown={(e) => { + if (e.key === 'Enter') addTag(); + }} + /> + +
+
+ {tags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ + )}