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 {
|
||||
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) {
|
||||
try {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user