(frontend) show specific quota messages on rejected actions

Uploads, moves to the root and duplications can now be refused by the
backend quota gates. The API error code carries the can_upload reason,
so each surface (upload list, move toast, duplicate toast) maps it to
a dedicated translated message instead of a generic failure, and the
40x redirect is disabled on those calls so the user stays in place
and sees the toast. The move mutation handles its own error feedback
to avoid double toasting through the global handler.
This commit is contained in:
Nathan Vasse
2026-07-23 17:08:11 +02:00
parent 5ed2639e61
commit 027433253f
15 changed files with 366 additions and 61 deletions
@@ -13,6 +13,13 @@ export class APIError extends Error {
}
}
export const errorToCode = (error: unknown): string | undefined => {
if (error instanceof APIError) {
return error.data?.errors?.[0]?.code;
}
return undefined;
};
export const errorToString = (error: unknown): string => {
if (typeof error === "string") {
return error;
@@ -77,6 +77,9 @@ export type Entitlement<T extends EntitlementReason> = {
export enum EntitlementCanUploadReasons {
NO_ORGANIZATION = "no_organization",
NOT_ACTIVATED = "not_activated",
USER_QUOTA_EXCEEDED = "user_quota_excedeed",
USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_excedeed",
ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_excedeed",
}
type EntitlementOperator = {
@@ -163,10 +163,18 @@ export class StandardDriver extends Driver {
const payload = {
...(parentId ? { target_item_id: parentId } : {}),
};
await fetchAPI(`items/${id}/move/`, {
method: "POST",
body: JSON.stringify(payload),
});
await fetchAPI(
`items/${id}/move/`,
{
method: "POST",
body: JSON.stringify(payload),
},
{
// A move can be rejected (e.g. quota gate): let the caller toast the
// error instead of redirecting the whole app to the 403 page.
redirectOn40x: false,
},
);
}
async getItemAccesses(itemId: string): Promise<Access[]> {
@@ -421,10 +429,16 @@ export class StandardDriver extends Driver {
throw new DOMException("Upload cancelled", "AbortError");
}
await fetchAPI(`items/${item.id}/upload-ended/`, {
method: "POST",
signal: abortController.signal,
});
await fetchAPI(
`items/${item.id}/upload-ended/`,
{
method: "POST",
signal: abortController.signal,
},
{
redirectOn40x: false,
},
);
progressHandler?.(100);
@@ -462,9 +476,17 @@ export class StandardDriver extends Driver {
}
async duplicateItem(id: string): Promise<Item> {
const response = await fetchAPI(`items/${id}/duplicate/`, {
method: "POST",
});
const response = await fetchAPI(
`items/${id}/duplicate/`,
{
method: "POST",
},
{
// A duplication can be rejected (e.g. quota gate): let the global
// handler toast the error instead of redirecting to the 403 page.
redirectOn40x: false,
},
);
return jsonToItem(await response.json());
}
@@ -51,24 +51,12 @@ const Content = ({
entitlements: Entitlements;
}) => {
const { t } = useTranslation();
const reasonTitle = getCannotUploadReasonDescription(
entitlements.can_upload.reason,
);
return (
<div>
{entitlements.can_upload.reason ===
EntitlementCanUploadReasons.NOT_ACTIVATED && (
<p>
{t(
"entitlements.disclaimers.cannot_upload.not_activated.description",
)}
</p>
)}
{entitlements.can_upload.reason ===
EntitlementCanUploadReasons.NO_ORGANIZATION && (
<p>
{t(
"entitlements.disclaimers.cannot_upload.no_organization.description",
)}
</p>
)}
{reasonTitle && <p>{reasonTitle}</p>}
{config?.showPotentialOperators &&
(entitlements.context?.potentialOperators?.length ?? 0) > 0 && (
<div>
@@ -106,4 +94,15 @@ const Content = ({
);
};
export const getCannotUploadReasonDescription = (
reason?: EntitlementCanUploadReasons,
) => {
if (reason) {
return i18n.t(
`entitlements.disclaimers.cannot_upload.${reason}.description`,
);
}
return undefined;
};
export default CannotUploadDisclaimer;
@@ -12,7 +12,7 @@ export type EntitlementDisclaimer<
name: Name;
show: (entitlements: Entitlements) => boolean;
render: (
config: DisclaimersConfig[Name],
config: DisclaimersConfig[Name] | undefined,
entitlements: Entitlements,
) => {
title: string;
@@ -1,5 +1,11 @@
import { getDriver } from "@/features/config/Config";
import { getCanUploadErrorDescription } from "@/utils/entitlements";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
addToast,
ToasterItem,
} from "@/features/ui/components/toaster/Toaster";
import { useRemoveItemsFromPaginatedList } from "../hooks/useOptimisticPagination";
import {
getMyFilesQueryKey,
@@ -14,6 +20,7 @@ export const useMoveItems = () => {
oldParentId?: string;
};
const { t } = useTranslation();
const queryClient = useQueryClient();
const driver = getDriver();
@@ -51,6 +58,24 @@ export const useMoveItems = () => {
queryClient.invalidateQueries({
queryKey: ["items", variables.parentId, "children", "infinite"],
});
addToast(
<ToasterItem type="error">
<span className="material-icons">arrow_forward</span>
<span>
{getCanUploadErrorDescription(err, (key) =>
t(`explorer.modal.move.errors.${key}`),
) ??
t("explorer.actions.move.toast_error", {
count: variables.ids.length,
})}
</span>
</ToasterItem>,
);
},
meta: {
// The onError above already toasts a localized message.
noGlobalError: true,
},
});
};
@@ -116,20 +116,24 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => {
setOveredItemIds({});
const ids = currentSelected.map((item) => item.id);
await moveItems.mutateAsync(
{
ids: ids,
parentId: newParentId,
oldParentId: itemId,
},
{
onSuccess: () => {
addItemsMovedToast(ids.length);
// Reset the selected items after the move
setSelectedItems([]);
await moveItems
.mutateAsync(
{
ids: ids,
parentId: newParentId,
oldParentId: itemId,
},
},
);
{
onSuccess: () => {
addItemsMovedToast(ids.length);
// Reset the selected items after the move
setSelectedItems([]);
},
},
)
.catch(() => {
// The error feedback is already handled by the mutation's onError.
});
};
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
@@ -131,7 +131,9 @@ export const ExplorerMoveFolder = ({
newParentId: string | undefined,
oldParentId: string,
) => {
moveItems.mutateAsync(
// mutate (not mutateAsync): a rejection is already handled by the
// mutation's onError, an unused rejected promise would be unhandled.
moveItems.mutate(
{
ids: ids,
parentId: newParentId,
@@ -34,6 +34,7 @@ import {
useMutationDuplicateItem,
} from "./useMutations";
import { DefaultRoute } from "@/utils/defaultRoutes";
import { getCanUploadErrorDescription } from "@/utils/entitlements";
import {
addToast,
ToasterItem,
@@ -179,11 +180,14 @@ export const useItemActionMenuItems = ({
callback: async () => {
try {
await duplicateItem(effectiveItemId);
} catch {
} catch (err) {
addToast(
<ToasterItem type="error">
<span className="material-icons">content_copy</span>
<span>{t("explorer.item.actions.duplicate_error")}</span>
<span>
{getCanUploadErrorDescription(err) ??
t("explorer.item.actions.duplicate_error")}
</span>
</ToasterItem>,
);
}
@@ -231,6 +231,10 @@ export const useMutationDuplicateItem = () => {
const parentId = item?.originalId ?? item?.id;
refresh(parentId);
},
meta: {
// The caller already toasts a localized message on failure.
noGlobalError: true,
},
});
};
@@ -16,7 +16,7 @@ import { useCanCreateChildren } from "@/features/items/utils";
import { getMyFilesQueryKey } from "@/utils/defaultRoutes";
import { useConfig } from "@/features/config/ConfigProvider";
import { getDriver } from "@/features/config/Config";
import { APIError } from "@/features/api/APIError";
import { errorToCode } from "@/features/api/APIError";
import { useRefreshQueryCacheAfterMutation } from "./useRefreshItems";
import { isIdInItemTree } from "../utils/utils";
@@ -33,6 +33,7 @@ import {
customGetFilesFromEvent,
isEmptyFolderMarker,
} from "@/features/explorer/utils/dropTraversal";
import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer";
type FileUpload = FileWithPath & {
parentId?: string;
@@ -421,10 +422,12 @@ export const useUploadZone = ({ item }: { item: Item }) => {
...prev,
step: UploadingStep.NONE,
}));
const description = getCannotUploadReasonDescription(entitlements.can_upload.reason);
addToast(
<ToasterItem type="error">
<span>
{entitlements.can_upload.message ||
description ||
t("entitlements.can_upload.cannot_upload")}
</span>
</ToasterItem>,
@@ -618,10 +621,7 @@ export const useUploadZone = ({ item }: { item: Item }) => {
// Already handled by onCancelFile/onCancelAll
continue;
}
let errorCode = "unknown";
if (err instanceof APIError && err.data?.errors?.[0]?.code) {
errorCode = err.data.errors[0].code;
}
const errorCode = errorToCode(err) ?? "unknown";
// Keep file in state with error status
setUploadingState((prev) => ({
@@ -705,9 +705,7 @@ export const useUploadZone = ({ item }: { item: Item }) => {
// Collect matches first: onCancelFile mutates the map we're iterating.
const toCancel: string[] = [];
for (const [filePath, upload] of activeUploadsRef.current) {
if (
deletedIds.some((id) => isIdInItemTree(upload.parentPath, id))
) {
if (deletedIds.some((id) => isIdInItemTree(upload.parentPath, id))) {
toCancel.push(filePath);
}
}
@@ -9,6 +9,15 @@
"close": "OK",
"cannot_upload": {
"title": "Uploads are unavailable",
"user_quota_excedeed": {
"description": "You can no longer add documents, your personal quota has been reached. Contact your administrator."
},
"user_override_quota_excedeed": {
"description": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator."
},
"organization_quota_excedeed": {
"description": "You can no longer add documents, your organization quota has been reached. Contact your administrator."
},
"no_organization": {
"description": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access."
},
@@ -157,7 +166,14 @@
"move_to_root": "Move to root",
"aria_label": "Move folder modal",
"description_one_item": "Choose the new location for <strong>{{name}}</strong>",
"description_multiple_items": "Choose the new location for the <strong>{{count}}</strong> selected elements"
"description_multiple_items": "Choose the new location for the <strong>{{count}}</strong> selected elements",
"errors": {
"user_quota_excedeed": "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.",
"user_override_quota_excedeed": "You can no longer move documents to the root, your personal storage limit has been reached. Contact your administrator.",
"organization_quota_excedeed": "You can no longer move documents to the root, your organization quota has been reached. Contact your administrator.",
"no_organization": "Your account isn't linked to an organization, so you can't move documents to the root yet. Contact your administrator to get access.",
"not_activated": "File upload isn't activated for your organization yet, so you can't move documents to the root. Contact your administrator to enable it."
}
}
},
"drag_overlay": {
@@ -484,10 +500,20 @@
"file_type_not_allowed": "This file type is not allowed.",
"item_create_file_extension_not_allowed": "This file extension is not allowed.",
"file_too_large": "The file exceeds the maximum allowed size.",
"user_quota_excedeed": "You can no longer add documents, your personal quota has been reached. Contact your administrator.",
"user_override_quota_excedeed": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator.",
"organization_quota_excedeed": "You can no longer add documents, your organization quota has been reached. Contact your administrator.",
"no_organization": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access.",
"not_activated": "File upload isn't activated for your organization yet. Contact your administrator to enable it.",
"unknown": "An unexpected error occurred."
},
"error_short": {
"file_too_large": "File too large"
"file_too_large": "File too large",
"user_quota_excedeed": "Quota exceeded",
"user_override_quota_excedeed": "Quota exceeded",
"organization_quota_excedeed": "Quota exceeded",
"no_organization": "Upload not allowed",
"not_activated": "Upload not allowed"
}
},
"cancel_modal": {
@@ -510,7 +536,9 @@
},
"move": {
"toast_one": "{{count}} item moved",
"toast_other": "{{count}} items moved"
"toast_other": "{{count}} items moved",
"toast_error_one": "An error occurred while moving the item.",
"toast_error_other": "An error occurred while moving the items."
},
"share": {
"modal": {
@@ -656,6 +684,15 @@
"close": "OK",
"cannot_upload": {
"title": "Les téléversements ne sont pas disponibles",
"user_quota_excedeed": {
"description": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur."
},
"user_override_quota_excedeed": {
"description": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur."
},
"organization_quota_excedeed": {
"description": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur."
},
"no_organization": {
"description": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès."
},
@@ -804,7 +841,14 @@
"move_to_root": "Déplacer vers la racine",
"aria_label": "Modal de déplacement de dossier",
"description_one_item": "Choisissez le nouvel emplacement pour <strong>{{name}}</strong>",
"description_multiple_items": "Choisissez le nouvel emplacement pour les <strong>{{count}}</strong> éléments sélectionnés"
"description_multiple_items": "Choisissez le nouvel emplacement pour les <strong>{{count}}</strong> éléments sélectionnés",
"errors": {
"user_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota personnel est atteint. Contactez votre administrateur.",
"user_override_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.",
"organization_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota d'organisation est atteint. Contactez votre administrateur.",
"no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore déplacer de documents à la racine. Contactez votre administrateur pour obtenir un accès.",
"not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Vous ne pouvez donc pas déplacer de documents à la racine. Contactez votre administrateur pour l'activer."
}
}
},
"drag_overlay": {
@@ -1134,10 +1178,20 @@
"file_type_not_allowed": "Ce type de fichier n'est pas autorisé.",
"item_create_file_extension_not_allowed": "Cette extension de fichier n'est pas autorisée.",
"file_too_large": "Le fichier dépasse la taille maximale autorisée.",
"user_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur.",
"user_override_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.",
"organization_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur.",
"no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès.",
"not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Contactez votre administrateur pour l'activer.",
"unknown": "Une erreur inattendue est survenue."
},
"error_short": {
"file_too_large": "Fichier trop volumineux"
"file_too_large": "Fichier trop volumineux",
"user_quota_excedeed": "Quota atteint",
"user_override_quota_excedeed": "Quota atteint",
"organization_quota_excedeed": "Quota atteint",
"no_organization": "Téléversement non autorisé",
"not_activated": "Téléversement non autorisé"
}
},
"cancel_modal": {
@@ -1164,7 +1218,9 @@
},
"move": {
"toast_one": "{{count}} élément déplacé",
"toast_other": "{{count}} éléments déplacés"
"toast_other": "{{count}} éléments déplacés",
"toast_error_one": "Une erreur est survenue lors du déplacement de l'élément.",
"toast_error_other": "Une erreur est survenue lors du déplacement des éléments."
},
"share": {
"modal": {
@@ -1311,6 +1367,15 @@
"close": "OK",
"cannot_upload": {
"title": "Uploaden is niet beschikbaar",
"user_quota_excedeed": {
"description": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder."
},
"user_override_quota_excedeed": {
"description": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder."
},
"organization_quota_excedeed": {
"description": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder."
},
"no_organization": {
"description": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang."
},
@@ -1459,7 +1524,14 @@
"move_to_root": "Verplaats naar root",
"aria_label": "Map verplaatsen modal",
"description_one_item": "Kies de nieuwe locatie voor <strong>{{name}}</strong>",
"description_multiple_items": "Kies de nieuwe locatie voor de <strong>{{count}}</strong> geselecteerde elementen"
"description_multiple_items": "Kies de nieuwe locatie voor de <strong>{{count}}</strong> geselecteerde elementen",
"errors": {
"user_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.",
"user_override_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.",
"organization_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.",
"no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen documenten naar de root verplaatsen. Neem contact op met uw beheerder voor toegang.",
"not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie, dus u kunt geen documenten naar de root verplaatsen. Neem contact op met uw beheerder om dit in te schakelen."
}
}
},
"drag_overlay": {
@@ -1744,10 +1816,20 @@
"file_type_not_allowed": "Dit bestandstype is niet toegestaan.",
"item_create_file_extension_not_allowed": "Deze bestandsextensie is niet toegestaan.",
"file_too_large": "Het bestand overschrijdt de maximale toegestane grootte.",
"user_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.",
"user_override_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.",
"organization_quota_excedeed": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.",
"no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang.",
"not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie. Neem contact op met uw beheerder om dit in te schakelen.",
"unknown": "Er is een onverwachte fout opgetreden."
},
"error_short": {
"file_too_large": "Bestand te groot"
"file_too_large": "Bestand te groot",
"user_quota_excedeed": "Quotum bereikt",
"user_override_quota_excedeed": "Quotum bereikt",
"organization_quota_excedeed": "Quotum bereikt",
"no_organization": "Uploaden niet toegestaan",
"not_activated": "Uploaden niet toegestaan"
}
},
"cancel_modal": {
@@ -1770,7 +1852,9 @@
},
"move": {
"toast_one": "{{count}} item verplaatst",
"toast_other": "{{count}} items verplaatst"
"toast_other": "{{count}} items verplaatst",
"toast_error_one": "Er is een fout opgetreden bij het verplaatsen van het item.",
"toast_error_other": "Er is een fout opgetreden bij het verplaatsen van de items."
},
"share": {
"modal": {
@@ -1,6 +1,34 @@
import { getDriver } from "@/features/config/Config";
import { errorToCode } from "@/features/api/APIError";
import { EntitlementCanUploadReasons } from "@/features/drivers/Driver";
import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer";
export const getEntitlements = async () => {
const driver = getDriver();
return driver.getEntitlements();
};
/**
* The quota gates (upload, move, duplicate) expose the can_upload reason as the
* API error code: map it back to its localized description when known.
*/
export const getCanUploadErrorDescription = (
error: unknown,
translate?: (key: string) => string,
): string | undefined => {
const code = errorToCode(error);
if (
code &&
Object.values(EntitlementCanUploadReasons).includes(
code as EntitlementCanUploadReasons,
)
) {
if (translate) {
return translate(code);
}
return getCannotUploadReasonDescription(
code as EntitlementCanUploadReasons,
);
}
return undefined;
};
@@ -121,6 +121,41 @@ test.describe("Duplicate item", () => {
).not.toBeVisible();
});
test("Shows the specific quota message when the duplication is rejected", async ({
page,
}) => {
await createFileFromTemplate(page, "TestDoc");
// Reject the duplication with the quota gate's reason code.
await page.route("**/api/v1.0/items/*/duplicate/", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
type: "client_error",
errors: [
{
code: "user_quota_excedeed",
detail: "You do not have permission to upload files.",
attr: null,
},
],
}),
}),
);
await triggerDuplicate(page, "TestDoc");
await expect(
page.getByText(
"You can no longer add documents, your personal quota has been reached. Contact your administrator.",
),
).toBeVisible();
await expect(
page.getByText("An error occurred while duplicating the item."),
).not.toBeVisible();
});
test("Duplicate option is available for files but not folders", async ({
page,
}) => {
@@ -28,6 +28,96 @@ test("Move an item to a new folder", async ({ page }) => {
await expect(JohnRow).not.toBeVisible();
});
test("Show an error toast when the server rejects the move", async ({
page,
}) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await createFolderInCurrentFolder(page, "John");
await createFolderInCurrentFolder(page, "Doe");
// Reject the move server-side (e.g. quota gate on move-to-root).
await page.route("**/api/v1.0/items/*/move/", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
type: "client_error",
errors: [
{
code: "permission_denied",
detail: "You cannot take ownership of more storage.",
attr: null,
},
],
}),
}),
);
const JohnRow = await getRowItem(page, "John");
await clickOnRowItemActions(page, "John", "Move");
const moveFolderModal = await getMoveFolderModal(page);
const DoeRow = await getRowItem(moveFolderModal, "Doe");
await DoeRow.click();
await acceptMoveItem(page);
await expect(
page.getByText("An error occurred while moving the item."),
).toBeVisible();
// The modal stays open on failure; close it and check the item stayed put.
await moveFolderModal.getByRole("button", { name: "Cancel" }).click();
await expect(JohnRow).toBeVisible();
});
test("Show the specific quota message when the move is rejected", async ({
page,
}) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await createFolderInCurrentFolder(page, "John");
await createFolderInCurrentFolder(page, "Doe");
// Reject the move with the quota gate's reason code.
await page.route("**/api/v1.0/items/*/move/", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
type: "client_error",
errors: [
{
code: "user_quota_excedeed",
detail: "You cannot take ownership of more storage.",
attr: null,
},
],
}),
}),
);
const JohnRow = await getRowItem(page, "John");
await clickOnRowItemActions(page, "John", "Move");
const moveFolderModal = await getMoveFolderModal(page);
const DoeRow = await getRowItem(moveFolderModal, "Doe");
await DoeRow.click();
await acceptMoveItem(page);
await expect(
page.getByText(
"You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.",
),
).toBeVisible();
// The modal stays open on failure; close it and check the item stayed put.
await moveFolderModal.getByRole("button", { name: "Cancel" }).click();
await expect(JohnRow).toBeVisible();
});
test("Search and select to move an item", async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");