Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export const FormStructure = ({ sections, currentStep, onSectionClick }: FormStr
</div>
<div className={styles.workflow}>
{sections.map((section, index) => (
<button key={section.id} type="button" className={`${styles.workflowButton} ${index === currentStep ? styles.active : ""}`} onClick={() => onSectionClick(index)}>
<button
key={section.id}
type="button"
className={[styles.workflowButton, index < currentStep ? styles.completed : "", index === currentStep ? styles.active : ""].join(" ")}
onClick={() => onSectionClick(index)}
>
{section.title}
</button>
))}
Expand Down
18 changes: 17 additions & 1 deletion src/features/form/components/FormFilloutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { FormHeader } from "./FormDetail/components/FormHeader/FormHeader";
import { FormPreviewSection } from "./FormDetail/components/FormPreviewSection/FormPreviewSection";
import { FormStructure } from "./FormDetail/components/FormStructure/FormStructure";
import styles from "./FormFilloutPage.module.css";
import { FormQuestionRenderer } from "./FormQuestionRenderer";
import { FormQuestionRenderer, type ServerFileInfo } from "./FormQuestionRenderer";

type Section = FormsSection;

Expand Down Expand Up @@ -43,6 +43,7 @@ export const FormFilloutPage = () => {
const [isSubmitted, setIsSubmitted] = useState(false);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [otherTexts, setOtherTexts] = useState<Record<string, string>>({});
const [fileMetadata, setFileMetadata] = useState<Record<string, ServerFileInfo[]>>({});

// Ref
const answersInitialized = useRef(false);
Expand Down Expand Up @@ -151,12 +152,24 @@ export const FormFilloutPage = () => {
if (answersInitialized.current) return;
const data = responseQuery.data as unknown as FormResponseData | undefined;
if (!data?.sections) return;
const loadedFileMetadata: Record<string, ServerFileInfo[]> = {};
const loaded: Record<string, string> = {};
const loadedOtherTexts: Record<string, string> = {};
data.sections.forEach(section => {
section.answerDetails?.forEach(detail => {
if (!detail.question.id) return;
const answerPayload = detail.payload?.answer;
if (detail.question.type === "UPLOAD_FILE") {
const fileValue = answerPayload as { value?: { originalFilename?: string; fileId?: string; contentType?: string }[] } | undefined;
const fileInfos: ServerFileInfo[] = (fileValue?.value ?? [])
.filter(f => f.fileId && f.originalFilename)
.map(f => ({ fileId: f.fileId!, originalFilename: f.originalFilename!, contentType: f.contentType ?? "application/octet-stream" }));
loadedFileMetadata[detail.question.id] = fileInfos;
if (fileInfos.length > 0) {
loaded[detail.question.id] = fileInfos.map(f => f.originalFilename).join(",");
}
return;
}
if (answerPayload && Array.isArray((answerPayload as ResponsesStringArrayAnswer).value)) {
const arrayAnswer = answerPayload as ResponsesStringArrayAnswer;
loaded[detail.question.id] = arrayAnswer.value.join(",");
Expand All @@ -172,6 +185,7 @@ export const FormFilloutPage = () => {
});
setAnswers(loaded);
setOtherTexts(loadedOtherTexts);
setFileMetadata(loadedFileMetadata);
answersInitialized.current = true;
}, [responseQuery.data]);

Expand Down Expand Up @@ -413,6 +427,8 @@ export const FormFilloutPage = () => {
sourceQuestion={question.sourceId ? questionsById.get(question.sourceId) : undefined}
sourceAnswerValue={question.sourceId ? answers[question.sourceId] || "" : ""}
responseId={urlResponseId}
initialFiles={fileMetadata[question.id]}
onFileMetadataChange={files => setFileMetadata(prev => ({ ...prev, [question.id]: files }))}
onAnswerChange={updateAnswer}
onOtherTextChange={updateOtherText}
/>
Expand Down
154 changes: 132 additions & 22 deletions src/features/form/components/FormQuestionRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,44 +16,114 @@

interface FileItem {
id: string;
file: File;
status: "uploading" | "success" | "error";
file?: File;
filename: string;
serverInfo?: ServerFileInfo;
status: "loading" | "uploading" | "success" | "error";
error?: string;
}

export interface ServerFileInfo {
fileId: string;
originalFilename: string;
contentType: string;
}

interface FileUploadQuestionProps {
responseId?: string;
questionId: string;
maxFileAmount: number;
allowedFileTypes: string;
onFilesChange: (fileNames: string) => void;
onFileMetadataChange?: (files: ServerFileInfo[]) => void;
disabled?: boolean;
initialFiles?: ServerFileInfo[];
}

const FileUploadQuestion = ({ responseId, questionId, maxFileAmount, allowedFileTypes, onFilesChange, disabled = false }: FileUploadQuestionProps) => {
const FileUploadQuestion = ({ responseId, questionId, maxFileAmount, allowedFileTypes, onFilesChange, onFileMetadataChange, disabled = false, initialFiles }: FileUploadQuestionProps) => {
const [items, setItems] = useState<FileItem[]>([]);
const itemsRef = useRef<FileItem[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const initialized = useRef(false);

const doUpload = async (item: FileItem) => {
if (!responseId) return;
setItems(prev => prev.map(i => (i.id === item.id ? { ...i, status: "uploading" as const, error: undefined } : i)));
useEffect(() => {
itemsRef.current = items;
}, [items]);

useEffect(() => {
if (initialized.current) return;
if (initialFiles === undefined) return; // data not yet loaded, wait
initialized.current = true;
if (initialFiles.length === 0) return;

const placeholders: FileItem[] = initialFiles.map(f => ({
id: crypto.randomUUID(),
filename: f.originalFilename,
serverInfo: f,
status: "loading" as const
}));
setItems(placeholders);

placeholders.forEach(async (placeholder, index) => {
const info = initialFiles[index];
try {
const res = await fetch(`/api/files/${info.fileId}`, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], info.originalFilename, { type: info.contentType || blob.type });
setItems(prev => {
const next = prev.map(i => (i.id === placeholder.id ? { ...i, file, status: "success" as const } : i));
onFilesChange(
next
.filter(i => i.status === "success")
.map(i => i.filename)
.join(",")
);
return next;
});
} catch {
setItems(prev => prev.map(i => (i.id === placeholder.id ? { ...i, status: "error" as const, error: "下載失敗,請重新上傳" } : i)));
}
});
}, [initialFiles]);

Check warning on line 88 in src/features/form/components/FormQuestionRenderer.tsx

View workflow job for this annotation

GitHub Actions / Lint with ESLint

React Hook useEffect has a missing dependency: 'onFilesChange'. Either include it or remove the dependency array. If 'onFilesChange' changes too often, find the parent component that defines it and wrap that definition in useCallback

const doUploadBatch = async (batch: FileItem[]) => {
if (!responseId || batch.length === 0) return;
const batchIds = new Set(batch.map(i => i.id));
const existingFiles = itemsRef.current.filter(i => !batchIds.has(i.id) && i.file).map(i => i.file as File);
const newFiles = batch.map(i => i.file).filter((f): f is File => f !== undefined);
const allFiles = [...existingFiles, ...newFiles];
if (allFiles.length === 0) return;

setItems(prev => prev.map(i => (batchIds.has(i.id) ? { ...i, status: "uploading" as const, error: undefined } : i)));
try {
await formApi.uploadQuestionFiles(responseId, questionId, [item.file]);
const uploadResult = await formApi.uploadQuestionFiles(responseId, questionId, allFiles);
const serverInfoMap = new Map(uploadResult.files.map(f => [f.originalFilename, { fileId: f.fileId, originalFilename: f.originalFilename, contentType: f.contentType } as ServerFileInfo]));
setItems(prev => {
const next = prev.map(i => (i.id === item.id ? { ...i, status: "success" as const } : i));
const next = prev.map(i => ({
...i,
...(batchIds.has(i.id) ? { status: "success" as const } : {}),
serverInfo: serverInfoMap.get(i.filename) ?? i.serverInfo
}));
onFilesChange(
next
.filter(i => i.status === "success")
.map(i => i.file.name)
.map(i => i.filename)
.join(",")
);
const updatedServerInfos = next.filter(i => i.status === "success" && i.serverInfo).map(i => i.serverInfo as ServerFileInfo);
onFileMetadataChange?.(updatedServerInfos);
return next;
});
} catch (err) {
setItems(prev => prev.map(i => (i.id === item.id ? { ...i, status: "error" as const, error: (err as Error).message } : i)));
setItems(prev => prev.map(i => (batchIds.has(i.id) ? { ...i, status: "error" as const, error: (err as Error).message } : i)));
}
};

const doUpload = async (item: FileItem) => {
await doUploadBatch([item]);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!responseId || disabled) return;
const newFiles = Array.from(e.target.files || []);
Expand All @@ -62,34 +132,68 @@
const toAdd: FileItem[] = newFiles.slice(0, canAdd).map(file => ({
id: crypto.randomUUID(),
file,
filename: file.name,
status: "uploading" as const
}));
if (inputRef.current) inputRef.current.value = "";
setItems(prev => [...prev, ...toAdd]);
toAdd.forEach(doUpload);
doUploadBatch(toAdd);
};

const removeItem = (id: string) => {
setItems(prev => {
const next = prev.filter(i => i.id !== id);
const removeItem = async (id: string) => {
const originalItems = itemsRef.current;
const itemToRemove = originalItems.find(i => i.id === id);
const remaining = originalItems.filter(i => i.id !== id);
setItems(remaining);
onFilesChange(
remaining
.filter(i => i.status === "success")
.map(i => i.filename)
.join(",")
);
const remainingServerInfos = remaining.filter(i => i.status === "success" && i.serverInfo).map(i => i.serverInfo as ServerFileInfo);
onFileMetadataChange?.(remainingServerInfos);

if (!responseId || itemToRemove?.status !== "success") return;

try {
const remainingFiles = remaining.filter(i => i.status === "success" && i.file).map(i => i.file as File);
if (remainingFiles.length > 0) {
const uploadResult = await formApi.uploadQuestionFiles(responseId, questionId, remainingFiles);
const serverInfoMap = new Map(uploadResult.files.map(f => [f.originalFilename, { fileId: f.fileId, originalFilename: f.originalFilename, contentType: f.contentType } as ServerFileInfo]));
setItems(prev => {
const next = prev.map(i => ({ ...i, serverInfo: serverInfoMap.get(i.filename) ?? i.serverInfo }));
onFileMetadataChange?.(next.filter(i => i.status === "success" && i.serverInfo).map(i => i.serverInfo as ServerFileInfo));
return next;
});
} else {
await formApi.clearQuestionFiles(responseId, questionId);
}
} catch {
setItems(originalItems);
onFilesChange(
next
originalItems
.filter(i => i.status === "success")
.map(i => i.file.name)
.map(i => i.filename)
.join(",")
);
return next;
});
onFileMetadataChange?.(originalItems.filter(i => i.status === "success" && i.serverInfo).map(i => i.serverInfo as ServerFileInfo));
}
};

const activeCount = items.filter(i => i.status !== "error").length;
const canAddMore = !disabled && Boolean(responseId) && activeCount < maxFileAmount;
const isLoadingFromServer = items.some(i => i.status === "loading");
const canAddMore = !disabled && Boolean(responseId) && activeCount < maxFileAmount && !isLoadingFromServer;

return (
<div className={styles.fileUploadList}>
{items.map(item => (
<div key={item.id} className={`${styles.fileItem} ${item.status === "error" ? styles.fileItemError : item.status === "uploading" ? styles.fileItemUploading : ""}`}>
<span className={styles.fileItemName}>{item.file.name}</span>
<div
key={item.id}
className={`${styles.fileItem} ${item.status === "error" ? styles.fileItemError : item.status === "uploading" || item.status === "loading" ? styles.fileItemUploading : ""}`}
>
<span className={styles.fileItemName}>{item.filename}</span>
{item.status === "loading" && <span className={styles.fileItemHint}>載入中…</span>}
{item.status === "uploading" && <span className={styles.fileItemHint}>上傳中…</span>}
{item.status === "error" && <span className={styles.fileItemHint}>{item.error || "上傳失敗"}</span>}
<div className={styles.fileItemActions}>
Expand All @@ -99,7 +203,7 @@
重新上傳
</button>
)}
{item.status !== "uploading" && (
{item.status !== "uploading" && item.status !== "loading" && (
<button type="button" className={styles.fileRemoveBtn} onClick={() => removeItem(item.id)} aria-label="移除" disabled={disabled}>
<X size={14} />
</button>
Expand All @@ -126,6 +230,8 @@
sourceAnswerValue?: string;
responseId?: string;
disableFileUpload?: boolean;
initialFiles?: ServerFileInfo[];
onFileMetadataChange?: (files: ServerFileInfo[]) => void;
onAnswerChange: (questionId: string, value: string) => void;
onOtherTextChange: (questionId: string, value: string) => void;
};
Expand All @@ -140,6 +246,8 @@
sourceAnswerValue = "",
responseId,
disableFileUpload = false,
initialFiles,
onFileMetadataChange,
onAnswerChange,
onOtherTextChange
}: FormQuestionRendererProps) => {
Expand Down Expand Up @@ -366,7 +474,9 @@
maxFileAmount={question.uploadFile?.maxFileAmount || 1}
allowedFileTypes={question.uploadFile?.allowedFileTypes?.join(",") || "*"}
onFilesChange={fileNames => onAnswerChange(question.id, fileNames)}
onFileMetadataChange={onFileMetadataChange}
disabled={disableFileUpload}
initialFiles={initialFiles}
/>
<p className={styles.uploadHint}>
最多 {question.uploadFile?.maxFileAmount || 1} 個檔案,每個檔案最大 {((question.uploadFile?.maxFileSizeLimit || 10485760) / 1024 / 1024).toFixed(0)} MB
Expand Down
18 changes: 15 additions & 3 deletions src/features/form/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
FormsQuestionResponse,
FormsSection,
FormsSectionRequest,
ResponsesAnswersRequest,
ResponsesAnswersRequestUpdate,
ResponsesCreateResponse,
ResponsesGetFormResponse,
ResponsesGetQuestionResponse,
Expand Down Expand Up @@ -218,20 +220,30 @@ export const deleteFormResponse = async (formId: string, responseId: string): Pr
assertOk(res.status, "Failed to delete response", res.data);
};

export const updateFormResponse = async (responseId: string, answers: import("@nycu-sdc/core-system-sdk").ResponsesAnswersRequestUpdate): Promise<void> => {
export const updateFormResponse = async (responseId: string, answers: ResponsesAnswersRequestUpdate): Promise<void> => {
const res = await responsesUpdateFormResponse(responseId, answers, defaultRequestOptions);
assertOk(res.status, "Failed to save answers", res.data);
};

export const submitFormResponse = async (responseId: string, answers: import("@nycu-sdc/core-system-sdk").ResponsesAnswersRequest): Promise<void> => {
export const submitFormResponse = async (responseId: string, answers: ResponsesAnswersRequest): Promise<void> => {
const res = await responsesSubmitFormResponse(responseId, answers, defaultRequestOptions);
assertOk(res.status, "Failed to submit form", res.data);
};

export const clearQuestionFiles = async (responseId: string, questionId: string): Promise<void> => {
const response = await fetch(`/api/responses/${responseId}/questions/${questionId}/files`, {
...defaultRequestOptions,
method: "DELETE"
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to clear files: HTTP ${response.status}`);
}
};

export const uploadQuestionFiles = async (responseId: string, questionId: string, files: File[]): Promise<ResponsesQuestionFilesUploadResponse> => {
const res = await (async () => {
const formData = new FormData();
files.forEach(file => formData.append("file", file));
files.forEach(file => formData.append("files", file));
const response = await fetch(`/api/responses/${responseId}/questions/${questionId}/files`, {
...defaultRequestOptions,
method: "POST",
Expand Down
4 changes: 2 additions & 2 deletions src/shared/components/FileUpload/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ export const FileUpload = ({ label, onChange, error, accept = "image/*", ...prop
</button>
</div>
) : fileName ? (
<>
<div className={styles.preview}>
<div className={styles.icon}>
<File size={48} />
</div>
<span className={styles.text}>{fileName}</span>
<button type="button" className={styles.removeBtn} onClick={handleRemove} aria-label="移除檔案">
<X size={16} />
</button>
</>
</div>
) : (
<>
<div className={styles.icon}>
Expand Down
Loading