fix(forms): add loading state for company document removal
continuous-integration/drone/push Build is passing

Block uploads and other document actions while a file is being removed, and show per-item and global loading feedback until deletion completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
AmirReza Jamali
2026-06-27 10:31:24 +03:30
parent a63ba453d7
commit 561931876f
3 changed files with 54 additions and 17 deletions
@@ -13,6 +13,8 @@ interface IProps {
const CompanyDocuments = ({ companyId, value, onChange }: IProps) => { const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
const { const {
isUploading, isUploading,
isBusy,
removingFileId,
uploadProgress, uploadProgress,
failed, failed,
errorMessage, errorMessage,
@@ -32,11 +34,16 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
accept={DOCUMENT_FILE_ACCEPT} accept={DOCUMENT_FILE_ACCEPT}
className="sr-only" className="sr-only"
onChange={onSelectFiles} onChange={onSelectFiles}
disabled={isUploading} disabled={isBusy}
/> />
<label <label
htmlFor={inputId} htmlFor={inputId}
className="group flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:border-dark-3 dark:bg-dark/20 dark:hover:bg-primary/[0.08]" aria-disabled={isBusy}
className={`group flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 dark:border-dark-3 dark:bg-dark/20 ${
isBusy
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:hover:bg-primary/[0.08]"
}`}
> >
<div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105"> <div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -71,6 +78,13 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
/> />
</div> </div>
</div> </div>
) : removingFileId ? (
<div className="mt-3">
<p className="text-xs font-medium text-primary">Removing document</p>
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
<div className="h-full w-full animate-pulse rounded-full bg-gradient-to-r from-primary to-purple-500" />
</div>
</div>
) : null} ) : null}
</div> </div>
@@ -85,7 +99,8 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
key={fileId} key={fileId}
fileId={fileId} fileId={fileId}
onRemove={onRemove} onRemove={onRemove}
disabled={isUploading} disabled={isBusy}
isRemoving={removingFileId === fileId}
/> />
))} ))}
</div> </div>
@@ -7,14 +7,19 @@ interface IProps {
/** When provided, a remove button is shown and invoked with the file id. */ /** When provided, a remove button is shown and invoked with the file id. */
onRemove?: (fileId: string) => void; onRemove?: (fileId: string) => void;
disabled?: boolean; disabled?: boolean;
isRemoving?: boolean;
} }
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => { const DocumentItem = ({ fileId, onRemove, disabled, isRemoving }: IProps) => {
const { info, isLoading, isDownloading, download } = const { info, isLoading, isDownloading, download } =
useDocumentItemPresenter(fileId); useDocumentItemPresenter(fileId);
return ( return (
<div className="flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30"> <div
className={`flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30 ${
isRemoving ? "opacity-70" : ""
}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -28,7 +33,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-dark dark:text-white"> <p className="truncate text-sm font-semibold text-dark dark:text-white">
{isLoading ? "Loading…" : info?.filename || "Document"} {isRemoving ? "Removing…" : isLoading ? "Loading…" : info?.filename || "Document"}
</p> </p>
{info?.size ? ( {info?.size ? (
<p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p> <p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p>
@@ -39,7 +44,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<button <button
type="button" type="button"
onClick={download} onClick={download}
disabled={isDownloading} disabled={disabled || isRemoving || isDownloading}
className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white" className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white"
> >
{isDownloading ? "Downloading…" : "Download"} {isDownloading ? "Downloading…" : "Download"}
@@ -48,10 +53,10 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<button <button
type="button" type="button"
onClick={() => onRemove(fileId)} onClick={() => onRemove(fileId)}
disabled={disabled} disabled={disabled || isRemoving}
className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60"
> >
Remove {isRemoving ? "Removing…" : "Remove"}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -30,10 +30,13 @@ const useCompanyDocumentsPresenter = ({
onChange, onChange,
}: IProps) => { }: IProps) => {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [removingFileId, setRemovingFileId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [failed, setFailed] = useState<FailedFile[]>([]); const [failed, setFailed] = useState<FailedFile[]>([]);
const [errorMessage, setErrorMessage] = useState<string | undefined>(); const [errorMessage, setErrorMessage] = useState<string | undefined>();
const isBusy = isUploading || removingFileId !== null;
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments( const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
companyId ?? "", companyId ?? "",
); );
@@ -67,6 +70,8 @@ const useCompanyDocumentsPresenter = ({
}; };
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => { const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
if (isBusy) return;
const input = event.target; const input = event.target;
const files = input.files ? Array.from(input.files) : []; const files = input.files ? Array.from(input.files) : [];
// Reset so selecting the same file again re-triggers change. // Reset so selecting the same file again re-triggers change.
@@ -95,21 +100,33 @@ const useCompanyDocumentsPresenter = ({
}; };
const onRemove = async (fileId: string) => { const onRemove = async (fileId: string) => {
onChange(value.filter((id) => id !== fileId)); if (isBusy) return;
// In create mode the file is an orphan in storage (not linked to any
// company), so clean it up. In edit mode the removal is persisted by the setRemovingFileId(fileId);
// form's update (PUT replaces the full document list). setErrorMessage(undefined);
if (!companyId) {
try { try {
// In create mode the file is an orphan in storage (not linked to any
// company), so clean it up before dropping it from the form. In edit mode
// the removal is persisted by the form's update (PUT replaces the full
// document list).
if (!companyId) {
await fileService.deleteFile(fileId); await fileService.deleteFile(fileId);
} catch (error) {
console.error("ERROR caught while deleting orphan document:", error);
} }
onChange(value.filter((id) => id !== fileId));
} catch (error) {
console.error("ERROR caught while deleting company document:", error);
setErrorMessage("Failed to remove document. Please try again.");
} finally {
setRemovingFileId(null);
} }
}; };
return { return {
isUploading, isUploading,
isBusy,
removingFileId,
uploadProgress, uploadProgress,
failed, failed,
errorMessage, errorMessage,