fix(forms): add loading state for company document removal
continuous-integration/drone/push Build is passing
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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user