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 {
isUploading,
isBusy,
removingFileId,
uploadProgress,
failed,
errorMessage,
@@ -32,11 +34,16 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
accept={DOCUMENT_FILE_ACCEPT}
className="sr-only"
onChange={onSelectFiles}
disabled={isUploading}
disabled={isBusy}
/>
<label
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">
<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>
) : 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}
</div>
@@ -85,7 +99,8 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
key={fileId}
fileId={fileId}
onRemove={onRemove}
disabled={isUploading}
disabled={isBusy}
isRemoving={removingFileId === fileId}
/>
))}
</div>
@@ -7,14 +7,19 @@ interface IProps {
/** When provided, a remove button is shown and invoked with the file id. */
onRemove?: (fileId: string) => void;
disabled?: boolean;
isRemoving?: boolean;
}
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
const DocumentItem = ({ fileId, onRemove, disabled, isRemoving }: IProps) => {
const { info, isLoading, isDownloading, download } =
useDocumentItemPresenter(fileId);
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">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -28,7 +33,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<div className="min-w-0 flex-1">
<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>
{info?.size ? (
<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
type="button"
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"
>
{isDownloading ? "Downloading…" : "Download"}
@@ -48,10 +53,10 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<button
type="button"
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"
>
Remove
{isRemoving ? "Removing…" : "Remove"}
</button>
) : null}
</div>
@@ -30,10 +30,13 @@ const useCompanyDocumentsPresenter = ({
onChange,
}: IProps) => {
const [isUploading, setIsUploading] = useState(false);
const [removingFileId, setRemovingFileId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [failed, setFailed] = useState<FailedFile[]>([]);
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const isBusy = isUploading || removingFileId !== null;
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
companyId ?? "",
);
@@ -67,6 +70,8 @@ const useCompanyDocumentsPresenter = ({
};
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
if (isBusy) return;
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
// Reset so selecting the same file again re-triggers change.
@@ -95,21 +100,33 @@ const useCompanyDocumentsPresenter = ({
};
const onRemove = async (fileId: string) => {
onChange(value.filter((id) => id !== fileId));
// 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
// form's update (PUT replaces the full document list).
if (!companyId) {
if (isBusy) return;
setRemovingFileId(fileId);
setErrorMessage(undefined);
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);
} 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 {
isUploading,
isBusy,
removingFileId,
uploadProgress,
failed,
errorMessage,