diff --git a/components/buckets/info.tsx b/components/buckets/info.tsx index c6f86918..6e58c7b2 100644 --- a/components/buckets/info.tsx +++ b/components/buckets/info.tsx @@ -110,7 +110,9 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { const [loading, setLoading] = React.useState(true) const [loadedBucket, setLoadedBucket] = React.useState(null) const [readErrors, setReadErrors] = React.useState>>({}) - const [versionLoading, setVersionLoading] = React.useState(false) + const [pendingVersioning, setPendingVersioning] = React.useState(null) + const [versioningError, setVersioningError] = React.useState("") + const versioningSwitchRef = React.useRef(null) const [mutation, setMutation] = React.useState(null) const requestVersionRef = React.useRef(0) const mutationRef = React.useRef(null) @@ -300,6 +302,8 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { setShowQuotaModal(false) setShowRetentionModal(false) setDeleteTagIndex(null) + setPendingVersioning(null) + setVersioningError("") mutationRef.current = null setMutation(null) fetchData() @@ -572,21 +576,36 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { } } - const handleVersionToggle = async (enabled: boolean) => { - if (!canEditVersioning || readErrors.versioning || readErrors.objectLock || loading) return + const versioningDisabled = + !canEditVersioning || + Boolean(readErrors.versioning || readErrors.objectLock) || + Boolean(objectLock) || + loading || + mutation !== null + + const handleVersionToggle = (enabled: boolean) => { + if (versioningDisabled || enabled === (versioning === "Enabled")) return + setVersioningError("") + setPendingVersioning(enabled) + } + + const confirmVersioning = async () => { + if (pendingVersioning === null || versioningDisabled) return if (!beginMutation("versioning")) return - setVersionLoading(true) - const prev = versioning + const requestVersion = requestVersionRef.current + const next = pendingVersioning ? "Enabled" : "Suspended" + setVersioningError("") try { - await bucketApi.putBucketVersioning(bucketName, enabled ? "Enabled" : "Suspended") - setVersioning(enabled ? "Enabled" : "Suspended") + await bucketApi.putBucketVersioning(bucketName, next) + if (requestVersion !== requestVersionRef.current) return + setVersioning(next) + setPendingVersioning(null) message.success(t("Edit Success")) } catch (err) { - setVersioning(prev) - message.error(`${t("Edit Failed")}: ${(err as Error).message}`) + if (requestVersion !== requestVersionRef.current) return + setVersioningError(`${t("Edit Failed")}: ${(err as Error).message}`) } finally { - setVersionLoading(false) - finishMutation() + if (requestVersion === requestVersionRef.current) finishMutation() } } @@ -841,26 +860,29 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { ? t("Versioning stays enabled while Object Lock is active.") : t("Keep previous object versions and recover from overwrites or deletes.") } - status={readErrors.versioning ? t("Unavailable") : versioning === "Enabled" ? t("Enabled") : t("Disabled")} + status={ + readErrors.versioning + ? t("Unavailable") + : versioning === "Enabled" + ? t("Enabled") + : versioning === "Suspended" + ? t("Suspended") + : t("Not Configured") + } statusVariant={readErrors.versioning ? "destructive" : versioning === "Enabled" ? "secondary" : "outline"} action={ canEditVersioning ? (
- {versionLoading ? ( + {mutation === "versioning" ? ( ) : null}
@@ -1571,6 +1593,53 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { + { + if (!open && !mutationRef.current) setPendingVersioning(null) + }} + > + + + {pendingVersioning ? t("Enable Versioning") : t("Suspend Versioning")} + }> +

+ {pendingVersioning + ? t("Enable versioning for bucket {bucket}?", { bucket: bucketName }) + : t("Suspend versioning for bucket {bucket}?", { bucket: bucketName })} +

+

+ {pendingVersioning + ? t( + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.", + ) + : t( + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.", + )} +

+
+
+ {versioningError ? ( +

+ {versioningError} +

+ ) : null} + + + {t("Cancel")} + + + {mutation === "versioning" ? : null} + {pendingVersioning ? t("Enable Versioning") : t("Suspend Versioning")} + + +
+
+ {/* Delete Tag Confirm */} { setVersion(checked) @@ -189,11 +190,17 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) { /> + + {t( + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.", + )} + {t("Object Lock")} { setObjectLock(checked) @@ -203,6 +210,9 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) { /> + + {t("Object Lock cannot be disabled after bucket creation and automatically enables versioning.")} + {objectLock && (
{t("Object Lock")} diff --git a/docs/ui-review/register.md b/docs/ui-review/register.md index 181b0b04..8a5e5c8d 100644 --- a/docs/ui-review/register.md +++ b/docs/ui-review/register.md @@ -92,6 +92,7 @@ The 2026-07-11 follow-up reviewed 44 candidate files containing cards, dialogs, | UI-053 | Upload selection | File pickers were detached from the drop target, fixed height left excessive empty space, and instructions competed with the destination. | The next action and upload scope were harder to scan. | Center file selection in the empty drop target, size the queue to its contents, retain one destination field and footer, and preserve picker focus across selection changes. | `upload-redesign-53` | Resolved | | UI-054 | Object refresh | Refresh was hidden in a header menu even when it was the only item. | Reloading the object list required an unnecessary extra click. | Show Refresh beside Upload; keep pagination controls with the list. | `object-refresh-54` | Resolved | | UI-055 | Bucket distribution | Similar blue segments had no matching color legend or percentages, and the remainder was unlabeled. | Bucket shares were unclear, especially when one bucket held almost all data. | Use distinct chart colors with matching names, sizes and percentages; label other buckets and preserve tiny nonzero shares in text. | `bucket-distribution-55` | Resolved | +| UI-056 | Bucket versioning | Toggling versioning submitted immediately and labeled suspended buckets as disabled. | An accidental click could permanently configure versioning without explaining later overwrite risks. | Confirm the bucket and consequences before enabling or suspending; preserve state on cancel and failure, distinguish all three states, and explain irreversible creation settings. | `bucket-versioning-confirmation-56` | Resolved | ## Deferred design debt diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 72696aae..5ea15860 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "تنظيف الكائنات لهذه الطبقة معلق. يستمر الاسترداد التلقائي في الخلفية.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "لا يزال هناك نقل معلق لهذه الطبقة. انتظر الاسترداد ثم أعد التحقق.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "لم يتمكن الخادم من إكمال فحص أمان الحذف. أعد التحقق قبل إعادة محاولة الحذف.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "أبلغ الخادم عن عائق غير معروف. لا يزال خطأ الحذف الأصلي سارياً." + "The server reported an unrecognized blocker. The original deletion error still applies.": "أبلغ الخادم عن عائق غير معروف. لا يزال خطأ الحذف الأصلي سارياً.", + "Enable Versioning": "تفعيل التحكم في الإصدارات", + "Suspend Versioning": "تعليق التحكم في الإصدارات", + "Suspended": "معلّق", + "Enable versioning for bucket {bucket}?": "هل تريد تفعيل التحكم في الإصدارات للحاوية {bucket}؟", + "Suspend versioning for bucket {bucket}?": "هل تريد تعليق التحكم في الإصدارات للحاوية {bucket}؟", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "بعد تفعيل التحكم في الإصدارات، لا يمكن تعطيله أو إعادته إلى حالة غير مهيأة. يمكن تعليقه فقط. يتم الاحتفاظ بالإصدارات الموجودة.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "يؤدي تعليق التحكم في الإصدارات إلى إيقاف إنشاء إصدارات جديدة لعمليات الكتابة المستقبلية. يتم الاحتفاظ بمعرّفات الإصدارات الموجودة، لكن عمليات الكتابة المستقبلية قد تستبدل الكائنات التي يكون معرّف إصدارها null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "لا يمكن تعطيل قفل الكائن بعد إنشاء الحاوية، وهو يفعّل التحكم في الإصدارات تلقائياً." } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index a653368e..98d7c5aa 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Für dieses Tier steht eine Objektbereinigung aus. Die Hintergrundwiederherstellung läuft automatisch weiter.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Für dieses Tier steht noch eine Auslagerung aus. Wiederherstellung abwarten und erneut prüfen.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Der Server konnte die Sicherheitsprüfung zur Löschung nicht abschließen. Vor dem erneuten Löschen erneut prüfen.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Der Server hat ein unbekanntes Hindernis gemeldet. Der ursprüngliche Löschfehler gilt weiterhin." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Der Server hat ein unbekanntes Hindernis gemeldet. Der ursprüngliche Löschfehler gilt weiterhin.", + "Enable Versioning": "Versionierung aktivieren", + "Suspend Versioning": "Versionierung aussetzen", + "Suspended": "Ausgesetzt", + "Enable versioning for bucket {bucket}?": "Versionierung für Bucket {bucket} aktivieren?", + "Suspend versioning for bucket {bucket}?": "Versionierung für Bucket {bucket} aussetzen?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Nach der Aktivierung kann die Versionierung weder deaktiviert noch auf den nicht konfigurierten Zustand zurückgesetzt werden. Sie kann nur ausgesetzt werden. Vorhandene Versionen bleiben erhalten.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Wenn die Versionierung ausgesetzt wird, werden bei künftigen Schreibvorgängen keine neuen Versionen erstellt. Vorhandene Versions-IDs bleiben erhalten, aber künftige Schreibvorgänge können Objekte mit der Versions-ID null überschreiben.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Die Objektsperre kann nach dem Erstellen des Buckets nicht deaktiviert werden und aktiviert automatisch die Versionierung." } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 79f9eea6..0d27b827 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Object cleanup is pending for this tier. Background recovery continues automatically.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "A transition is still pending for this tier. Wait for recovery, then recheck.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "The server could not complete the deletion safety check. Recheck before retrying deletion.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "The server reported an unrecognized blocker. The original deletion error still applies." + "The server reported an unrecognized blocker. The original deletion error still applies.": "The server reported an unrecognized blocker. The original deletion error still applies.", + "Enable Versioning": "Enable Versioning", + "Suspend Versioning": "Suspend Versioning", + "Suspended": "Suspended", + "Enable versioning for bucket {bucket}?": "Enable versioning for bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "Suspend versioning for bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Object Lock cannot be disabled after bucket creation and automatically enables versioning." } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 3fc25920..ef312542 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Hay limpieza de objetos pendiente para este nivel. La recuperación automática en segundo plano continúa.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Hay una transferencia pendiente para este nivel. Espere a la recuperación y vuelva a comprobarlo.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "El servidor no pudo completar la comprobación de seguridad. Vuelva a comprobarlo antes de reintentar la eliminación.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "El servidor notificó un bloqueo desconocido. El error de eliminación original sigue siendo válido." + "The server reported an unrecognized blocker. The original deletion error still applies.": "El servidor notificó un bloqueo desconocido. El error de eliminación original sigue siendo válido.", + "Enable Versioning": "Activar el control de versiones", + "Suspend Versioning": "Suspender el control de versiones", + "Suspended": "Suspendido", + "Enable versioning for bucket {bucket}?": "¿Activar el control de versiones para el bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "¿Suspender el control de versiones para el bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Una vez activado, el control de versiones no se puede desactivar ni restablecer al estado sin configurar. Solo se puede suspender. Se conservan las versiones existentes.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Al suspender el control de versiones, las futuras escrituras dejarán de crear nuevas versiones. Se conservan los ID de versión existentes, pero las futuras escrituras pueden sobrescribir objetos con un ID de versión null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "El bloqueo de objetos no se puede desactivar después de crear el bucket y activa automáticamente el control de versiones." } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 4caff00b..47e0239b 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Le nettoyage d’objets de ce niveau est en attente. La récupération automatique en arrière-plan continue.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Un transfert est encore en attente pour ce niveau. Attendez la récupération, puis vérifiez à nouveau.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Le serveur n’a pas pu terminer la vérification de sécurité. Vérifiez à nouveau avant de réessayer la suppression.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Le serveur a signalé un blocage inconnu. L’erreur de suppression initiale reste valable." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Le serveur a signalé un blocage inconnu. L’erreur de suppression initiale reste valable.", + "Enable Versioning": "Activer la gestion des versions", + "Suspend Versioning": "Suspendre la gestion des versions", + "Suspended": "Suspendue", + "Enable versioning for bucket {bucket}?": "Activer la gestion des versions pour le bucket {bucket} ?", + "Suspend versioning for bucket {bucket}?": "Suspendre la gestion des versions pour le bucket {bucket} ?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Une fois activée, la gestion des versions ne peut pas être désactivée ni réinitialisée à un état non configuré. Elle peut uniquement être suspendue. Les versions existantes sont conservées.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "La suspension de la gestion des versions empêche la création de nouvelles versions lors des écritures futures. Les identifiants de version existants sont conservés, mais les écritures futures peuvent écraser les objets dont l’identifiant de version est null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Le verrouillage des objets ne peut pas être désactivé après la création du bucket et active automatiquement la gestion des versions." } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index a09f8c0a..b35a68d5 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Pembersihan objek untuk tier ini masih tertunda. Pemulihan otomatis di latar belakang tetap berjalan.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Transisi untuk tier ini masih tertunda. Tunggu pemulihan, lalu periksa kembali.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Server tidak dapat menyelesaikan pemeriksaan keamanan penghapusan. Periksa kembali sebelum mencoba menghapus lagi.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Server melaporkan penghalang yang tidak dikenal. Kesalahan penghapusan awal tetap berlaku." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Server melaporkan penghalang yang tidak dikenal. Kesalahan penghapusan awal tetap berlaku.", + "Enable Versioning": "Aktifkan Pembuatan Versi", + "Suspend Versioning": "Tangguhkan Pembuatan Versi", + "Suspended": "Ditangguhkan", + "Enable versioning for bucket {bucket}?": "Aktifkan pembuatan versi untuk bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "Tangguhkan pembuatan versi untuk bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Setelah diaktifkan, pembuatan versi tidak dapat dinonaktifkan atau dikembalikan ke status belum dikonfigurasi. Fitur ini hanya dapat ditangguhkan. Versi yang sudah ada tetap disimpan.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Menangguhkan pembuatan versi menghentikan pembuatan versi baru untuk penulisan berikutnya. ID versi yang sudah ada tetap disimpan, tetapi penulisan berikutnya dapat menimpa objek dengan ID versi null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Object Lock tidak dapat dinonaktifkan setelah bucket dibuat dan secara otomatis mengaktifkan pembuatan versi." } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 0f2e5c4f..ef8b6d95 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "La pulizia degli oggetti di questo livello è in sospeso. Il recupero automatico in background continua.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Un trasferimento per questo livello è ancora in sospeso. Attendere il recupero e verificare di nuovo.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Il server non ha completato la verifica di sicurezza. Verificare di nuovo prima di riprovare l’eliminazione.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Il server ha segnalato un impedimento sconosciuto. L’errore di eliminazione originale resta valido." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Il server ha segnalato un impedimento sconosciuto. L’errore di eliminazione originale resta valido.", + "Enable Versioning": "Abilita il controllo delle versioni", + "Suspend Versioning": "Sospendi il controllo delle versioni", + "Suspended": "Sospeso", + "Enable versioning for bucket {bucket}?": "Abilitare il controllo delle versioni per il bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "Sospendere il controllo delle versioni per il bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Una volta abilitato, il controllo delle versioni non può essere disabilitato né ripristinato allo stato non configurato. Può solo essere sospeso. Le versioni esistenti vengono conservate.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "La sospensione del controllo delle versioni interrompe la creazione di nuove versioni per le scritture future. Gli ID delle versioni esistenti vengono conservati, ma le scritture future possono sovrascrivere gli oggetti con ID versione null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Il blocco degli oggetti non può essere disabilitato dopo la creazione del bucket e abilita automaticamente il controllo delle versioni." } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index f8ee7b02..dfacbb74 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "この階層のオブジェクトクリーンアップが保留中です。バックグラウンドの自動復旧は継続します。", "A transition is still pending for this tier. Wait for recovery, then recheck.": "この階層の移行処理が保留中です。復旧を待って再確認してください。", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "サーバーが削除の安全性確認を完了できませんでした。削除の再試行前に再確認してください。", - "The server reported an unrecognized blocker. The original deletion error still applies.": "サーバーが認識できない阻害要因を報告しました。元の削除エラーは引き続き有効です。" + "The server reported an unrecognized blocker. The original deletion error still applies.": "サーバーが認識できない阻害要因を報告しました。元の削除エラーは引き続き有効です。", + "Enable Versioning": "バージョニングを有効化", + "Suspend Versioning": "バージョニングを一時停止", + "Suspended": "一時停止中", + "Enable versioning for bucket {bucket}?": "バケット {bucket} のバージョニングを有効にしますか?", + "Suspend versioning for bucket {bucket}?": "バケット {bucket} のバージョニングを一時停止しますか?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "バージョニングは一度有効にすると、無効にしたり未設定の状態に戻したりすることはできません。一時停止のみ可能です。既存のバージョンは保持されます。", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "バージョニングを一時停止すると、以降の書き込みで新しいバージョンが作成されなくなります。既存のバージョン ID は保持されますが、以降の書き込みでバージョン ID が null のオブジェクトが上書きされる可能性があります。", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "オブジェクトロックはバケット作成後に無効にできず、バージョニングが自動的に有効になります。" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 2760cfcf..fd5f6cf9 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "이 계층의 개체 정리가 대기 중입니다. 자동 백그라운드 복구는 계속됩니다.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "이 계층의 전환이 아직 대기 중입니다. 복구를 기다린 후 다시 확인하세요.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "서버가 삭제 안전성 확인을 완료하지 못했습니다. 삭제 재시도 전에 다시 확인하세요.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "서버가 알 수 없는 차단 요인을 보고했습니다. 원래 삭제 오류가 계속 적용됩니다." + "The server reported an unrecognized blocker. The original deletion error still applies.": "서버가 알 수 없는 차단 요인을 보고했습니다. 원래 삭제 오류가 계속 적용됩니다.", + "Enable Versioning": "버전 관리 활성화", + "Suspend Versioning": "버전 관리 일시 중지", + "Suspended": "일시 중지됨", + "Enable versioning for bucket {bucket}?": "버킷 {bucket}의 버전 관리를 활성화하시겠습니까?", + "Suspend versioning for bucket {bucket}?": "버킷 {bucket}의 버전 관리를 일시 중지하시겠습니까?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "버전 관리는 한 번 활성화하면 비활성화하거나 미설정 상태로 되돌릴 수 없습니다. 일시 중지만 가능합니다. 기존 버전은 유지됩니다.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "버전 관리를 일시 중지하면 이후 쓰기 작업에서 새 버전이 생성되지 않습니다. 기존 버전 ID는 유지되지만 이후 쓰기 작업에서 버전 ID가 null인 객체를 덮어쓸 수 있습니다.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "객체 잠금은 버킷 생성 후 비활성화할 수 없으며 버전 관리를 자동으로 활성화합니다." } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index c49c08fd..c6fbca29 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Há limpeza de objetos pendente nesta camada. A recuperação automática em segundo plano continua.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Há uma transferência pendente nesta camada. Aguarde a recuperação e verifique novamente.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "O servidor não concluiu a verificação de segurança. Verifique novamente antes de tentar excluir.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "O servidor informou um impedimento desconhecido. O erro de exclusão original continua válido." + "The server reported an unrecognized blocker. The original deletion error still applies.": "O servidor informou um impedimento desconhecido. O erro de exclusão original continua válido.", + "Enable Versioning": "Ativar controle de versões", + "Suspend Versioning": "Suspender controle de versões", + "Suspended": "Suspenso", + "Enable versioning for bucket {bucket}?": "Ativar o controle de versões para o bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "Suspender o controle de versões para o bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Após ser ativado, o controle de versões não pode ser desativado nem redefinido para um estado não configurado. Ele só pode ser suspenso. As versões existentes são preservadas.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Suspender o controle de versões interrompe a criação de novas versões em gravações futuras. Os IDs de versão existentes são preservados, mas as gravações futuras podem sobrescrever objetos com ID de versão null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "O bloqueio de objetos não pode ser desativado após a criação do bucket e ativa o controle de versões automaticamente." } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index f35fe3a0..b4cc2901 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Для этого уровня ожидается очистка объектов. Автоматическое фоновое восстановление продолжается.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Для этого уровня ещё ожидается перемещение. Дождитесь восстановления и повторите проверку.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Сервер не завершил проверку безопасности удаления. Повторите проверку перед удалением.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Сервер сообщил о неизвестном препятствии. Исходная ошибка удаления остаётся в силе." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Сервер сообщил о неизвестном препятствии. Исходная ошибка удаления остаётся в силе.", + "Enable Versioning": "Включить управление версиями", + "Suspend Versioning": "Приостановить управление версиями", + "Suspended": "Приостановлено", + "Enable versioning for bucket {bucket}?": "Включить управление версиями для бакета {bucket}?", + "Suspend versioning for bucket {bucket}?": "Приостановить управление версиями для бакета {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "После включения управление версиями нельзя отключить или вернуть в ненастроенное состояние. Его можно только приостановить. Существующие версии сохраняются.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Приостановка управления версиями прекращает создание новых версий при последующих операциях записи. Существующие идентификаторы версий сохраняются, но последующие операции записи могут перезаписать объекты с идентификатором версии null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Блокировку объектов нельзя отключить после создания бакета. Она автоматически включает управление версиями." } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index ef6fded4..785632d7 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Bu katmanda nesne temizliği bekliyor. Otomatik arka plan kurtarması devam ediyor.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Bu katmanda bir aktarım hâlâ bekliyor. Kurtarmayı bekleyip yeniden denetleyin.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Sunucu silme güvenliği denetimini tamamlayamadı. Silmeyi tekrar denemeden önce yeniden denetleyin.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Sunucu tanınmayan bir engel bildirdi. İlk silme hatası hâlâ geçerli." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Sunucu tanınmayan bir engel bildirdi. İlk silme hatası hâlâ geçerli.", + "Enable Versioning": "Sürüm Oluşturmayı Etkinleştir", + "Suspend Versioning": "Sürüm Oluşturmayı Askıya Al", + "Suspended": "Askıya Alındı", + "Enable versioning for bucket {bucket}?": "{bucket} kovası için sürüm oluşturma etkinleştirilsin mi?", + "Suspend versioning for bucket {bucket}?": "{bucket} kovası için sürüm oluşturma askıya alınsın mı?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Sürüm oluşturma etkinleştirildikten sonra devre dışı bırakılamaz veya yapılandırılmamış duruma döndürülemez. Yalnızca askıya alınabilir. Mevcut sürümler korunur.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Sürüm oluşturmanın askıya alınması, sonraki yazma işlemlerinde yeni sürüm oluşturulmasını durdurur. Mevcut sürüm kimlikleri korunur, ancak sonraki yazma işlemleri sürüm kimliği null olan nesnelerin üzerine yazabilir.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Nesne Kilidi kova oluşturulduktan sonra devre dışı bırakılamaz ve sürüm oluşturmayı otomatik olarak etkinleştirir." } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index a10eefd5..eeac6f7d 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "Việc dọn dẹp đối tượng của tầng này đang chờ. Khôi phục tự động trong nền vẫn tiếp tục.", "A transition is still pending for this tier. Wait for recovery, then recheck.": "Một quá trình chuyển của tầng này vẫn đang chờ. Hãy đợi khôi phục rồi kiểm tra lại.", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "Máy chủ không hoàn tất kiểm tra an toàn xóa. Hãy kiểm tra lại trước khi thử xóa lại.", - "The server reported an unrecognized blocker. The original deletion error still applies.": "Máy chủ báo cáo trở ngại không xác định. Lỗi xóa ban đầu vẫn có hiệu lực." + "The server reported an unrecognized blocker. The original deletion error still applies.": "Máy chủ báo cáo trở ngại không xác định. Lỗi xóa ban đầu vẫn có hiệu lực.", + "Enable Versioning": "Bật quản lý phiên bản", + "Suspend Versioning": "Tạm dừng quản lý phiên bản", + "Suspended": "Đã tạm dừng", + "Enable versioning for bucket {bucket}?": "Bật quản lý phiên bản cho bucket {bucket}?", + "Suspend versioning for bucket {bucket}?": "Tạm dừng quản lý phiên bản cho bucket {bucket}?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "Sau khi bật, không thể tắt quản lý phiên bản hoặc đưa về trạng thái chưa cấu hình. Chỉ có thể tạm dừng. Các phiên bản hiện có được giữ lại.", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "Tạm dừng quản lý phiên bản sẽ ngừng tạo phiên bản mới cho các thao tác ghi sau này. Các ID phiên bản hiện có được giữ lại, nhưng các thao tác ghi sau này có thể ghi đè đối tượng có ID phiên bản là null.", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "Không thể tắt Khóa đối tượng sau khi tạo bucket và tính năng này tự động bật quản lý phiên bản." } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index de4c14f9..802b3b0e 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "此存储层仍有对象等待清理。后台恢复会自动继续。", "A transition is still pending for this tier. Wait for recovery, then recheck.": "此存储层仍有迁移尚未完成。请等待恢复后重新检查。", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "服务器未能完成删除安全检查。请重新检查后再重试删除。", - "The server reported an unrecognized blocker. The original deletion error still applies.": "服务器报告了无法识别的阻塞项。原始删除错误仍然适用。" + "The server reported an unrecognized blocker. The original deletion error still applies.": "服务器报告了无法识别的阻塞项。原始删除错误仍然适用。", + "Enable Versioning": "启用版本控制", + "Suspend Versioning": "暂停版本控制", + "Suspended": "已暂停", + "Enable versioning for bucket {bucket}?": "确认对存储桶 {bucket} 启用版本控制?", + "Suspend versioning for bucket {bucket}?": "确认暂停存储桶 {bucket} 的版本控制?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "版本控制一旦启用,就无法禁用或恢复为未配置状态,只能暂停。现有版本会予以保留。", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "暂停版本控制后,后续写入将不再创建新版本。现有版本 ID 会予以保留,但后续写入可能覆盖版本 ID 为 null 的对象。", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "对象锁在存储桶创建后无法禁用,并会自动启用版本控制。" } diff --git a/i18n/locales/zh-TW.json b/i18n/locales/zh-TW.json index de33d295..1429e165 100644 --- a/i18n/locales/zh-TW.json +++ b/i18n/locales/zh-TW.json @@ -2313,5 +2313,13 @@ "Object cleanup is pending for this tier. Background recovery continues automatically.": "此儲存層仍有物件等待清理。背景復原會自動繼續。", "A transition is still pending for this tier. Wait for recovery, then recheck.": "此儲存層仍有轉換尚未完成。請等待復原後重新檢查。", "The server could not complete the deletion safety check. Recheck before retrying deletion.": "伺服器未能完成刪除安全檢查。請重新檢查後再重試刪除。", - "The server reported an unrecognized blocker. The original deletion error still applies.": "伺服器回報了無法識別的阻擋項目。原始刪除錯誤仍然適用。" + "The server reported an unrecognized blocker. The original deletion error still applies.": "伺服器回報了無法識別的阻擋項目。原始刪除錯誤仍然適用。", + "Enable Versioning": "啟用版本控制", + "Suspend Versioning": "暫停版本控制", + "Suspended": "已暫停", + "Enable versioning for bucket {bucket}?": "確認對儲存桶 {bucket} 啟用版本控制?", + "Suspend versioning for bucket {bucket}?": "確認暫停儲存桶 {bucket} 的版本控制?", + "Once enabled, versioning cannot be disabled or reset to an unconfigured state. It can only be suspended. Existing versions are preserved.": "版本控制一旦啟用,就無法停用或還原為未設定狀態,只能暫停。現有版本會予以保留。", + "Suspending versioning stops creating new versions for future writes. Existing version IDs are preserved, but future writes can overwrite objects with a null version ID.": "暫停版本控制後,後續寫入將不再建立新版本。現有版本 ID 會予以保留,但後續寫入可能覆寫版本 ID 為 null 的物件。", + "Object Lock cannot be disabled after bucket creation and automatically enables versioning.": "物件鎖定在儲存桶建立後無法停用,並會自動啟用版本控制。" } diff --git a/tests/lib/bucket-versioning-confirmation.test.ts b/tests/lib/bucket-versioning-confirmation.test.ts new file mode 100644 index 00000000..f8e6a675 --- /dev/null +++ b/tests/lib/bucket-versioning-confirmation.test.ts @@ -0,0 +1,373 @@ +import test, { type TestContext } from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { act, createContext, createElement, useContext, type ReactNode } from "react" +import { createRoot } from "react-dom/client" +import { Window } from "happy-dom" +import { createInstance } from "i18next" +import ts from "typescript" + +const mocksKey = Symbol.for("bucket-versioning-confirmation") +const mockExports: Record = { + "@/components/section-navigation": ["SectionNavigation"], + "react-i18next": ["useTranslation"], + "@remixicon/react": ["RiAddLine", "RiArrowRightSLine", "RiCloseLine", "RiEdit2Line"], + "@/hooks/use-bucket": ["useBucket"], + "@/hooks/use-permissions": ["usePermissions"], + "@/hooks/use-sse": ["useSSE"], + "@/components/buckets/settings-layout": ["BucketSettingRow", "BucketSettingsSection"], + "@/components/on-demand-migration/settings-row": ["OnDemandMigrationSettingsRow"], + "@/components/ui/button": ["Button"], + "@/components/ui/alert": ["Alert", "AlertDescription", "AlertTitle"], + "@/components/ui/switch": ["Switch"], + "@/components/ui/spinner": ["Spinner"], + "@/components/ui/input": ["Input"], + "@/components/ui/textarea": ["Textarea"], + "@/components/ui/field": ["Field", "FieldContent", "FieldDescription", "FieldError", "FieldLabel"], + "@/components/ui/radio-group": ["RadioGroup", "RadioGroupItem"], + "@/components/ui/select": ["Select", "SelectContent", "SelectItem", "SelectTrigger", "SelectValue"], + "@/components/dialog": [ + "Dialog", + "DialogBody", + "DialogContent", + "DialogFooter", + "DialogHeader", + "DialogTitle", + "AlertDialog", + "AlertDialogAction", + "AlertDialogCancel", + "AlertDialogContent", + "AlertDialogDescription", + "AlertDialogFooter", + "AlertDialogHeader", + "AlertDialogTitle", + ], + "@/lib/feedback/message": ["useMessage"], +} + +const source = ts.transpileModule(readFileSync(new URL("../../components/buckets/info.tsx", import.meta.url), "utf8"), { + compilerOptions: { module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.ReactJSX }, +}).outputText +const rewritten = source.replace(/from "([^"]+)"/g, (_, specifier: string) => { + let url: string + if (mockExports[specifier]) { + const stub = mockExports[specifier] + .map( + (name) => + `export function ${name}(...args) { return globalThis[Symbol.for("bucket-versioning-confirmation")].${name}(...args) }`, + ) + .join("\n") + url = `data:text/javascript,${encodeURIComponent(stub)}` + } else if (specifier === "next/link") { + url = `data:text/javascript,${encodeURIComponent("export default function Link() { return null }")}` + } else if (specifier.startsWith("@/")) { + url = new URL(`../../${specifier.slice(2)}.ts`, import.meta.url).href + } else { + url = import.meta.resolve(specifier) + } + return `from "${url}"` +}) +const { BucketInfo } = (await import( + `data:text/javascript,${encodeURIComponent(rewritten)}` +)) as typeof import("../../components/buckets/info") + +type ButtonProps = { + children?: ReactNode + onClick?: (event: React.MouseEvent) => void + disabled?: boolean + "aria-label"?: string +} +type DialogProps = { + children?: ReactNode + open: boolean + onOpenChange: (open: boolean) => void +} +const DialogContext = createContext<(open: boolean) => void>(() => {}) +const i18n = createInstance() +await i18n.init({ + lng: "en-US", + fallbackLng: "en-US", + resources: { + "en-US": { + translation: JSON.parse(readFileSync(new URL("../../i18n/locales/en-US.json", import.meta.url), "utf8")), + }, + }, + interpolation: { escapeValue: false, prefix: "{", suffix: "}" }, + returnNull: false, +}) +const translate = i18n.t.bind(i18n) + +async function mountBucket( + t: TestContext, + options: { + status?: "Enabled" | "Suspended" + objectLock?: boolean + canEdit?: boolean + readError?: "versioning" | "objectLock" + pendingRead?: Promise + } = {}, +) { + const browser = new Window() + const globals = { window: browser, document: browser.document, IS_REACT_ACT_ENVIRONMENT: true } + const previousDescriptors = Object.keys(globals).map( + (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const, + ) + for (const [key, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }) + } + + let canEdit = options.canEdit ?? true + let bucketName = "test-bucket" + const writes: { bucket: string; status: string; result: ReturnType> }[] = [] + const api = { + getBucketPolicy: async () => null, + getBucketEncryption: async () => null, + getBucketCors: async () => null, + getBucketTagging: async () => ({ TagSet: [] }), + getObjectLockConfiguration: async () => { + if (options.readError === "objectLock") throw new Error("AccessDenied") + return { ObjectLockConfiguration: { ObjectLockEnabled: options.objectLock ? "Enabled" : undefined } } + }, + getBucketVersioning: async () => { + if (options.readError === "versioning") throw new Error("AccessDenied") + if (options.pendingRead) await options.pendingRead + return { Status: options.status } + }, + getBucketQuota: async () => null, + putBucketVersioning: (bucket: string, status: string) => { + const result = Promise.withResolvers() + writes.push({ bucket, status, result }) + return result.promise + }, + } + const message = { success: t.mock.fn(), error: t.mock.fn() } + const getKeyList = async () => [] + const container = browser.document.createElement("div") + browser.document.body.appendChild(container) + const root = createRoot(container as unknown as Element) + const Wrapper = ({ children }: { children?: ReactNode }) => createElement("div", null, children) + const Button = ({ children, onClick, disabled, "aria-label": label }: ButtonProps) => + createElement("button", { onClick, disabled, "aria-label": label }, children) + const Dialog = ({ children, open, onOpenChange }: DialogProps) => + open ? createElement(DialogContext.Provider, { value: onOpenChange }, children) : null + + Reflect.set(globalThis, mocksKey, { + ...Object.fromEntries( + Object.values(mockExports) + .flat() + .map((name) => [name, Wrapper]), + ), + useTranslation: () => ({ t: translate }), + useMessage: () => message, + useBucket: () => api, + useSSE: () => ({ getKeyList }), + usePermissions: () => ({ + canCapability: (capability: string) => capability === "bucket.versioning.edit" && canEdit, + }), + SectionNavigation: () => null, + OnDemandMigrationSettingsRow: () => null, + Button, + Switch: ({ + checked, + onCheckedChange, + disabled, + "aria-label": label, + }: ButtonProps & { + checked: boolean + onCheckedChange: (checked: boolean) => void + }) => + createElement("button", { + role: "switch", + "aria-label": label, + "aria-checked": checked, + disabled, + onClick: () => onCheckedChange(!checked), + }), + BucketSettingRow: ({ + title, + status, + action, + children, + error, + }: { + title: string + status: string + action: ReactNode + children?: ReactNode + error?: ReactNode + }) => createElement("section", { "data-setting": title }, title, status, action, children, error), + Dialog, + AlertDialog: Dialog, + AlertDialogContent: ({ children }: { children?: ReactNode }) => + createElement("div", { role: "alertdialog" }, children), + AlertDialogTitle: ({ children }: { children?: ReactNode }) => createElement("h2", null, children), + AlertDialogAction: Button, + AlertDialogCancel: function Cancel(props: ButtonProps) { + const onOpenChange = useContext(DialogContext) + return createElement(Button, { ...props, onClick: () => onOpenChange(false) }) + }, + }) + + let unmounted = false + const render = async (changes: { canEdit?: boolean; bucketName?: string } = {}) => { + canEdit = changes.canEdit ?? canEdit + bucketName = changes.bucketName ?? bucketName + await act(async () => root.render(createElement(BucketInfo, { bucketName, key: bucketName }))) + } + const unmount = async () => { + await act(async () => root.unmount()) + unmounted = true + } + t.after(async () => { + if (!unmounted) await unmount() + await browser.happyDOM.close() + Reflect.deleteProperty(globalThis, mocksKey) + for (const [key, descriptor] of previousDescriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + }) + await render() + + const dialog = () => container.querySelector('[role="alertdialog"]') + const versionSwitch = () => + container.querySelector('[role="switch"][aria-label="Versioning"]') as unknown as HTMLButtonElement | null + const button = (label: string) => { + const match = [...container.querySelectorAll("button")].find((item) => item.textContent === label) + assert.ok(match, `missing button: ${label}`) + return match + } + const click = async (element: { click: () => void } | null) => { + assert.ok(element, "missing click target") + await act(async () => element.click()) + } + return { container, dialog, versionSwitch, button, click, render, unmount, writes, message } +} + +test("versioning only opens confirmation, and cancelling leaves an unconfigured bucket untouched", async (t) => { + const view = await mountBucket(t) + await view.click(view.versionSwitch()) + + assert.equal(view.writes.length, 0) + assert.equal(view.versionSwitch()?.getAttribute("aria-checked"), "false") + assert.match(view.dialog()?.textContent ?? "", /Enable versioning for bucket test-bucket\?/) + assert.doesNotMatch(view.dialog()?.textContent ?? "", /\{+bucket\}+/) + assert.match(view.dialog()?.textContent ?? "", /cannot.*(?:unconfigured|not configured|original)/i) + + await view.click(view.button("Cancel")) + assert.equal(view.dialog(), null) + assert.equal(view.writes.length, 0) +}) + +test("confirmation sends one enable request and blocks duplicate submits and cancellation while pending", async (t) => { + const view = await mountBucket(t) + await view.click(view.versionSwitch()) + const confirm = view.button("Enable Versioning") + await act(async () => { + confirm.click() + confirm.click() + }) + + assert.equal(view.writes.length, 1) + assert.equal(view.writes[0].bucket, "test-bucket") + assert.equal(view.writes[0].status, "Enabled") + assert.equal(view.versionSwitch()?.getAttribute("aria-checked"), "false") + assert.equal(view.button("Cancel").disabled, true) + assert.equal(confirm.disabled, true) + await view.click(view.button("Cancel")) + assert.ok(view.dialog()) + + await act(async () => view.writes[0].result.resolve()) + assert.equal(view.dialog(), null) + assert.equal(view.versionSwitch()?.getAttribute("aria-checked"), "true") +}) + +test("failed versioning writes keep the prior state and confirmation available for retry", async (t) => { + const view = await mountBucket(t) + await view.click(view.versionSwitch()) + await view.click(view.button("Enable Versioning")) + await act(async () => view.writes[0].result.reject(new Error("AccessDenied"))) + + assert.ok(view.dialog()) + assert.equal(view.versionSwitch()?.getAttribute("aria-checked"), "false") + assert.match(view.dialog()?.querySelector('[role="alert"]')?.textContent ?? "", /AccessDenied/) + assert.equal(view.message.success.mock.callCount(), 0) + + await view.click(view.button("Enable Versioning")) + assert.equal(view.writes.length, 2) + await act(async () => view.writes[1].result.resolve()) + assert.equal(view.dialog(), null) + assert.equal(view.versionSwitch()?.getAttribute("aria-checked"), "true") +}) + +test("suspending versioning requires confirmation and retains a distinct Suspended status", async (t) => { + const view = await mountBucket(t, { status: "Enabled" }) + await view.click(view.versionSwitch()) + assert.equal(view.writes.length, 0) + assert.match(view.dialog()?.textContent ?? "", /Suspend versioning for bucket test-bucket\?/) + assert.doesNotMatch(view.dialog()?.textContent ?? "", /\{+bucket\}+/) + assert.match(view.dialog()?.textContent ?? "", /existing version/i) + await view.click(view.button("Suspend Versioning")) + assert.equal(view.writes[0].status, "Suspended") + await act(async () => view.writes[0].result.resolve()) + assert.match(view.container.querySelector('[data-setting="Versioning"]')?.textContent ?? "", /Suspended/) +}) + +test("a suspended bucket can only resume versioning after confirmation", async (t) => { + const view = await mountBucket(t, { status: "Suspended" }) + assert.match(view.container.querySelector('[data-setting="Versioning"]')?.textContent ?? "", /Suspended/) + await view.click(view.versionSwitch()) + assert.equal(view.writes.length, 0) + await view.click(view.button("Enable Versioning")) + assert.equal(view.writes[0].status, "Enabled") + await act(async () => view.writes[0].result.resolve()) +}) + +test("revoking versioning permission while confirmation is open prevents a write", async (t) => { + const view = await mountBucket(t) + await view.click(view.versionSwitch()) + await view.render({ canEdit: false }) + await view.click(view.button("Enable Versioning")) + assert.equal(view.writes.length, 0) +}) + +test("Object Lock and failed versioning or lock reads block the versioning control", async (t) => { + for (const options of [ + { status: "Enabled" as const, objectLock: true }, + { readError: "versioning" as const }, + { readError: "objectLock" as const }, + { canEdit: false }, + ]) { + await t.test(JSON.stringify(options), async (scenario) => { + const view = await mountBucket(scenario, options) + const control = view.versionSwitch() + assert.ok(!control || control.disabled) + if (control) await view.click(control) + assert.equal(view.dialog(), null) + assert.equal(view.writes.length, 0) + }) + } +}) + +test("loading does not offer versioning changes before a trusted response arrives", async (t) => { + const response = Promise.withResolvers() + const view = await mountBucket(t, { pendingRead: response.promise }) + assert.equal(view.versionSwitch(), null) + assert.equal(view.writes.length, 0) + await act(async () => response.resolve()) + assert.ok(view.versionSwitch()) +}) + +test("changing bucket or unmounting discards the previous confirmation without a write", async (t) => { + const view = await mountBucket(t) + await view.click(view.versionSwitch()) + assert.ok(view.dialog()) + await view.render({ bucketName: "other-bucket" }) + assert.equal(view.dialog(), null) + await view.click(view.versionSwitch()) + assert.match(view.dialog()?.textContent ?? "", /Enable versioning for bucket other-bucket\?/) + assert.doesNotMatch(view.dialog()?.textContent ?? "", /\{+bucket\}+/) + await view.unmount() + assert.equal(view.dialog(), null) + assert.equal(view.writes.length, 0) +})