From 6ef729cb5c2b4ac5daa37c41c8eb70d66bbadac5 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 7 Oct 2026 16:38:35 +0200 Subject: [PATCH] feat(storage): versioning bucket modals (FE-4161) (#49205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | # | Branch | Base | | - | ------ | ---- | | 1 | `feat/storage-versioning-private-alpha` — merged | `master` | | 2 | `feat/storage-versioning/002-bucket-form-fields` | `master` | | 3 | `feat/storage-versioning/003-bucket-modals` ◀ | 2 | | 4 | `feat/storage-versioning/004-object-versions-data` | 3 | | 5 | `feat/storage-versioning/005-file-preview-versions` | 4 | | 6 | `feat/storage-versioning/006-billing-storage-retention` | 5 | | 7 | `feat/storage-versioning/007-archived-objects-data` | 6 | | 8 | `feat/storage-versioning/008-archived-rows` | 7 | | 9 | `feat/storage-versioning/009-archived-preview-pane` | 8 | | 10 | `feat/storage-versioning/010-replace-file` | 9 | ## [3/10] Storage object versioning: wire into the bucket modals **Base:** `feat/storage-versioning/002-bucket-form-fields` (PR 2) ### This PR Mounts the object-versioning form section in the create and edit bucket modals behind the feature preview, and saves it. - create and edit bucket modals spread `bucketVersioningFormFields` into their existing form schema - lifecycle defaults to 30 days / 10 versions - edit adds a confirmation before suspending an actively versioned bucket ## Enabling object versioning on a new bucket and setting lifecycle policies https://github.com/user-attachments/assets/194f8319-4929-432e-8a50-206f180a77a8 ## Edit and suspend object-versioning https://github.com/user-attachments/assets/f31e1d34-9840-4f5a-a269-6a911214742d ## To reproduce 1. Make sure storage versioning is enabled under feature previews > Storage Versioning 2. Open Storage Bucket File explorer 3. create new bucket and enable Object Versioning 4. set lifecycle policy - Noncurrent version expiration: can be either empty or >1 - Retained noncurrent versions: can be either empty or between 1 and 100 and can't exist without "Noncurrent version expiration" 5. Open new bucket with object versioning and test changing lifecycle policies 6. Disabling object-versioning shows proper warning and updates `versioning_status` to SUSPENDED (it can never go back to DISABLED once it has been enabled on a bucket) --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Ivan Vasilov --- .../BucketVersioningFields.lifecycle.test.ts | 241 +++++++ .../BucketVersioningFields.lifecycle.ts | 133 ++++ .../BucketVersioningFields.schema.ts | 65 -- .../BucketVersioningFields.test.tsx | 23 +- .../BucketVersioningFields.tsx | 20 +- .../LifecyclePolicySection.tsx | 17 +- .../CreateBucketModal.test.tsx | 43 +- .../FilesBuckets/CreateBucketModal.tsx | 73 ++- .../DeleteBucketModal.test.tsx | 2 +- .../FilesBuckets/EditBucketModal.test.tsx | 257 ++++++++ .../Storage/FilesBuckets/EditBucketModal.tsx | 594 +++++++++++------- .../EditBucketModal.utils.test.ts | 147 +++++ .../FilesBuckets/EditBucketModal.utils.ts | 65 ++ .../EmptyBucketModal.test.tsx | 2 +- ...ema.test.ts => FilesBucket.schema.test.ts} | 2 +- .../FilesBuckets/FilesBucket.schema.ts | 68 ++ .../__tests__/EditBucketModal.test.tsx | 118 ---- .../data/storage/bucket-create-mutation.ts | 2 + .../data/storage/bucket-delete-mutation.ts | 10 +- .../data/storage/bucket-lifecycle-query.ts | 47 ++ .../bucket-lifecycle-update-mutation.ts | 77 +++ .../data/storage/bucket-update-mutation.ts | 10 +- apps/studio/data/storage/keys.ts | 2 + packages/common/telemetry-constants.ts | 19 + 24 files changed, 1587 insertions(+), 450 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.test.ts create mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.ts delete mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.ts rename apps/studio/components/interfaces/Storage/{__tests__ => FilesBuckets}/CreateBucketModal.test.tsx (63%) rename apps/studio/components/interfaces/Storage/{__tests__ => FilesBuckets}/DeleteBucketModal.test.tsx (98%) create mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.test.ts create mode 100644 apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.ts rename apps/studio/components/interfaces/Storage/{__tests__ => FilesBuckets}/EmptyBucketModal.test.tsx (97%) rename apps/studio/components/interfaces/Storage/FilesBuckets/{BucketVersioningFields/BucketVersioningFields.schema.test.ts => FilesBucket.schema.test.ts} (98%) delete mode 100644 apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx create mode 100644 apps/studio/data/storage/bucket-lifecycle-query.ts create mode 100644 apps/studio/data/storage/bucket-lifecycle-update-mutation.ts diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.test.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.test.ts new file mode 100644 index 00000000000..ce7bf588ac5 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' + +import { + AGE_RULE_ID, + COUNT_RULE_ID, + fromLifecycleRules, + hasLifecyclePolicyChanged, + hasUnsupportedLifecycleRules, + toLifecycleRules, +} from './BucketVersioningFields.lifecycle' + +const form = { + enable_versioning: true as const, + version_expiry_days: 30 as '' | number, + max_noncurrent_versions: 10 as '' | number, + expiration_mode: 'and' as const, +} + +describe('toLifecycleRules', () => { + it('sends no rules when neither condition is set', () => { + expect( + toLifecycleRules({ ...form, version_expiry_days: '', max_noncurrent_versions: '' }) + ).toEqual([]) + }) + + it('sends an age-only rule when no cap is set', () => { + expect(toLifecycleRules({ ...form, max_noncurrent_versions: '' })).toEqual([ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30 }, + }, + ]) + }) + + it('combines both conditions into one rule for "and"', () => { + expect(toLifecycleRules(form)).toEqual([ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30, newer_noncurrent_versions: 10 }, + }, + ]) + }) + + it('splits "or" into an age rule and a capped rule at the minimum age', () => { + const rules = toLifecycleRules({ ...form, expiration_mode: 'or' }) + + expect(rules).toHaveLength(2) + expect(rules[0]).toEqual({ + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30 }, + }) + expect(rules[1]).toEqual({ + id: COUNT_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 1, newer_noncurrent_versions: 10 }, + }) + }) + + it('drops a cap with no age, which the API cannot express', () => { + expect(toLifecycleRules({ ...form, version_expiry_days: '' })).toEqual([]) + }) +}) + +describe('fromLifecycleRules', () => { + it('reports no policy when nothing is stored', () => { + expect(fromLifecycleRules(null)).toEqual({ + versionExpiryDays: null, + maxNoncurrentVersions: null, + expirationMode: 'and', + }) + }) + + it('reads a combined rule back as "and"', () => { + expect( + fromLifecycleRules({ + rules: [ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30, newer_noncurrent_versions: 10 }, + }, + ], + }) + ).toEqual({ versionExpiryDays: 30, maxNoncurrentVersions: 10, expirationMode: 'and' }) + }) + + it('reads the two-rule form back as "or"', () => { + expect( + fromLifecycleRules({ + rules: [ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30 }, + }, + { + id: COUNT_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 1, newer_noncurrent_versions: 10 }, + }, + ], + }) + ).toEqual({ versionExpiryDays: 30, maxNoncurrentVersions: 10, expirationMode: 'or' }) + }) + + it('ignores disabled rules', () => { + expect( + fromLifecycleRules({ + rules: [ + { + id: AGE_RULE_ID, + status: 'Disabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30 }, + }, + ], + }) + ).toEqual({ versionExpiryDays: null, maxNoncurrentVersions: null, expirationMode: 'and' }) + }) + + it('ignores rules with an action it cannot model', () => { + expect(fromLifecycleRules({ rules: [{ id: 'other', status: 'Enabled', filter: {} }] })).toEqual( + { + versionExpiryDays: null, + maxNoncurrentVersions: null, + expirationMode: 'and', + } + ) + }) + + it('round-trips the "and" form', () => { + expect(fromLifecycleRules({ rules: toLifecycleRules(form) })).toEqual({ + versionExpiryDays: 30, + maxNoncurrentVersions: 10, + expirationMode: 'and', + }) + }) + + it('round-trips the "or" form', () => { + const rules = toLifecycleRules({ ...form, expiration_mode: 'or' }) + + expect(fromLifecycleRules({ rules })).toEqual({ + versionExpiryDays: 30, + maxNoncurrentVersions: 10, + expirationMode: 'or', + }) + }) +}) + +describe('hasLifecyclePolicyChanged', () => { + const stored = { + versionExpiryDays: 30, + maxNoncurrentVersions: 10, + expirationMode: 'and' as const, + } + + it('is false when nothing moved', () => { + expect(hasLifecyclePolicyChanged(stored, form)).toBe(false) + }) + + it('is true when the age changed', () => { + expect(hasLifecyclePolicyChanged(stored, { ...form, version_expiry_days: 60 })).toBe(true) + }) + + it('is true when the cap was cleared', () => { + expect(hasLifecyclePolicyChanged(stored, { ...form, max_noncurrent_versions: '' })).toBe(true) + }) + + it('is true when the mode changed and both conditions are set', () => { + expect(hasLifecyclePolicyChanged(stored, { ...form, expiration_mode: 'or' })).toBe(true) + }) + + it('ignores the mode when only one condition is set', () => { + const ageOnly = { + versionExpiryDays: 30, + maxNoncurrentVersions: null, + expirationMode: 'and' as const, + } + + expect( + hasLifecyclePolicyChanged(ageOnly, { + ...form, + max_noncurrent_versions: '', + expiration_mode: 'or', + }) + ).toBe(false) + }) +}) + +describe('hasUnsupportedLifecycleRules', () => { + const ageRule = { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 30 }, + } + const cappedRule = { + id: COUNT_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: 1, newer_noncurrent_versions: 10 }, + } + + it('is false when there is no policy at all', () => { + expect(hasUnsupportedLifecycleRules(null)).toBe(false) + expect(hasUnsupportedLifecycleRules({ rules: [] })).toBe(false) + }) + + it('is false for either shape the form writes', () => { + expect(hasUnsupportedLifecycleRules({ rules: toLifecycleRules(form) })).toBe(false) + expect( + hasUnsupportedLifecycleRules({ rules: toLifecycleRules({ ...form, expiration_mode: 'or' }) }) + ).toBe(false) + expect(hasUnsupportedLifecycleRules({ rules: [cappedRule] })).toBe(false) + }) + + it('is true for a rule the form would drop on a save', () => { + expect( + hasUnsupportedLifecycleRules({ rules: [{ id: 'other', status: 'Enabled', filter: {} }] }) + ).toBe(true) + expect( + hasUnsupportedLifecycleRules({ rules: [ageRule, { ...cappedRule, status: 'Disabled' }] }) + ).toBe(true) + }) + + it('is true for more rules than the two the form can hold', () => { + expect(hasUnsupportedLifecycleRules({ rules: [ageRule, cappedRule, cappedRule] })).toBe(true) + expect(hasUnsupportedLifecycleRules({ rules: [ageRule, ageRule] })).toBe(true) + expect(hasUnsupportedLifecycleRules({ rules: [cappedRule, cappedRule] })).toBe(true) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.ts new file mode 100644 index 00000000000..81707395739 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.lifecycle.ts @@ -0,0 +1,133 @@ +import type { BucketVersioningFormValues } from '../FilesBucket.schema' +import type { ExpirationMode } from '@/components/interfaces/Storage/StorageVersioning.constants' +import type { BucketLifecycle } from '@/data/storage/bucket-lifecycle-query' +import type { BucketLifecycleRuleInput } from '@/data/storage/bucket-lifecycle-update-mutation' + +/** Lets a round trip recognize the rules the dashboard wrote, rather than guess from shape. */ +export const AGE_RULE_ID = 'supabase-noncurrent-age' +export const COUNT_RULE_ID = 'supabase-noncurrent-count' + +/** + * S3 honors `newer_noncurrent_versions` only alongside `noncurrent_days`, so "either + * condition" becomes two rules, the second pinned at the shortest age the API accepts. + */ +const MIN_NONCURRENT_DAYS = 1 + +export interface LifecycleFormPolicy { + versionExpiryDays: number | null + maxNoncurrentVersions: number | null + expirationMode: ExpirationMode +} + +export const toLifecycleRules = ( + values: Pick< + BucketVersioningFormValues, + 'enable_versioning' | 'version_expiry_days' | 'max_noncurrent_versions' | 'expiration_mode' + > +): BucketLifecycleRuleInput[] => { + const days = values.version_expiry_days === '' ? null : values.version_expiry_days + const versions = values.max_noncurrent_versions === '' ? null : values.max_noncurrent_versions + + if (days === null && versions === null) return [] + + if (days !== null && versions !== null && values.expiration_mode === 'or') { + return [ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days: days }, + }, + { + id: COUNT_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { + noncurrent_days: MIN_NONCURRENT_DAYS, + newer_noncurrent_versions: versions, + }, + }, + ] + } + + // The form's validation already rejects a count with no age. + if (days === null) return [] + + return [ + { + id: AGE_RULE_ID, + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { + noncurrent_days: days, + ...(versions === null ? {} : { newer_noncurrent_versions: versions }), + }, + }, + ] +} + +type LifecycleRule = BucketLifecycle['rules'][number] + +const isModeledRule = (rule: LifecycleRule) => + rule.status === 'Enabled' && rule.noncurrent_version_expiration !== undefined + +const isCappedRule = (rule: LifecycleRule) => + rule.noncurrent_version_expiration?.newer_noncurrent_versions !== undefined + +/** Keeps a save that changed nothing from rewriting the policy. */ +export const hasLifecyclePolicyChanged = ( + stored: LifecycleFormPolicy, + values: Pick< + BucketVersioningFormValues, + 'version_expiry_days' | 'max_noncurrent_versions' | 'expiration_mode' + > +) => { + const days = values.version_expiry_days === '' ? null : values.version_expiry_days + const versions = values.max_noncurrent_versions === '' ? null : values.max_noncurrent_versions + + if (stored.versionExpiryDays !== days) return true + if (stored.maxNoncurrentVersions !== versions) return true + // The mode only means anything when both conditions are set. + return days !== null && versions !== null && stored.expirationMode !== values.expiration_mode +} + +/** + * The update endpoint replaces the whole policy, so anything `fromLifecycleRules` had to + * skip would be dropped by a save. Splitting an age rule from a capped one is the only + * multi-rule shape the form writes; a policy set elsewhere can hold more. + */ +export const hasUnsupportedLifecycleRules = (lifecycle?: BucketLifecycle | null) => { + const rules = lifecycle?.rules ?? [] + + if (!rules.every(isModeledRule)) return true + if (rules.length <= 1) return false + return rules.length > 2 || rules.filter(isCappedRule).length !== 1 +} + +/** Rules this can't model — extra, disabled, or non-expiration — are ignored, not guessed at. */ +export const fromLifecycleRules = (lifecycle?: BucketLifecycle | null): LifecycleFormPolicy => { + const enabledRules = (lifecycle?.rules ?? []).filter(isModeledRule) + + if (enabledRules.length === 0) { + return { versionExpiryDays: null, maxNoncurrentVersions: null, expirationMode: 'and' } + } + + const ageOnlyRule = enabledRules.find((rule) => !isCappedRule(rule)) + const cappedRule = enabledRules.find(isCappedRule) + + if (ageOnlyRule !== undefined && cappedRule !== undefined) { + return { + versionExpiryDays: ageOnlyRule.noncurrent_version_expiration?.noncurrent_days ?? null, + maxNoncurrentVersions: + cappedRule.noncurrent_version_expiration?.newer_noncurrent_versions ?? null, + expirationMode: 'or', + } + } + + const rule = cappedRule ?? ageOnlyRule + return { + versionExpiryDays: rule?.noncurrent_version_expiration?.noncurrent_days ?? null, + maxNoncurrentVersions: rule?.noncurrent_version_expiration?.newer_noncurrent_versions ?? null, + expirationMode: 'and', + } +} diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.ts deleted file mode 100644 index 3d90f1dff63..00000000000 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod' - -import type { ExpirationMode } from '@/components/interfaces/Storage/StorageVersioning.constants' - -/** Empty drops the condition from the policy, which is distinct from zero. */ -const versioningNumberField = z.union([z.literal(''), z.coerce.number().int()]) - -export const S3_MAX_NONCURRENT_VERSIONS = 100 - -export const bucketVersioningFormFields = { - enable_versioning: z.boolean().default(false), - version_expiry_days: versioningNumberField.default(''), - max_noncurrent_versions: versioningNumberField.default(''), - expiration_mode: z.enum(['and', 'or']).default('and'), -} - -export interface BucketVersioningFormValues { - enable_versioning: boolean - version_expiry_days: '' | number - max_noncurrent_versions: '' | number - expiration_mode: ExpirationMode -} - -export const superRefineBucketVersioning = ( - data: BucketVersioningFormValues, - ctx: z.RefinementCtx -) => { - if (!data.enable_versioning) return - - const { version_expiry_days: days, max_noncurrent_versions: versions } = data - - if (days !== '' && days < 1) { - ctx.addIssue({ - path: ['version_expiry_days'], - code: z.ZodIssueCode.custom, - message: 'Must be at least 1 day', - }) - } - - if (versions === '') return - - // S3 only accepts a noncurrent-count condition alongside a noncurrent-days one - if (days === '') { - ctx.addIssue({ - path: ['max_noncurrent_versions'], - code: z.ZodIssueCode.custom, - message: 'Requires an expiration age to be set', - }) - return - } - - if (versions < 1) { - ctx.addIssue({ - path: ['max_noncurrent_versions'], - code: z.ZodIssueCode.custom, - message: 'Must be at least 1 version or empty to disable', - }) - } else if (versions > S3_MAX_NONCURRENT_VERSIONS) { - ctx.addIssue({ - path: ['max_noncurrent_versions'], - code: z.ZodIssueCode.custom, - message: `Cannot exceed ${S3_MAX_NONCURRENT_VERSIONS} versions`, - }) - } -} diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.test.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.test.tsx index d424531f1c3..b8a49c2679d 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.test.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.test.tsx @@ -4,21 +4,18 @@ import userEvent from '@testing-library/user-event' import { useForm } from 'react-hook-form' import { Form } from 'ui' import { describe, expect, test } from 'vitest' -import { z } from 'zod' +import { BucketFormSchema, type BucketFormValues } from '../FilesBucket.schema' import { BucketVersioningFields } from './BucketVersioningFields' -import { - bucketVersioningFormFields, - superRefineBucketVersioning, - type BucketVersioningFormValues, -} from './BucketVersioningFields.schema' import type { BucketVersioningState } from '@/components/interfaces/Storage/StorageVersioning.constants' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' -const FormSchema = z.object(bucketVersioningFormFields).superRefine(superRefineBucketVersioning) - -const DEFAULT_VALUES: BucketVersioningFormValues = { +const DEFAULT_VALUES: BucketFormValues = { + name: 'bucket', + public: false, + has_file_size_limit: false, + allowed_mime_types: '', enable_versioning: false, version_expiry_days: 30, max_noncurrent_versions: 10, @@ -29,7 +26,7 @@ const Harness = ({ defaultValues, ...props }: { - defaultValues?: Partial + defaultValues?: Partial initialVersioningState?: BucketVersioningState initialRetentionDays?: number | null initialMaxVersions?: number | null @@ -38,8 +35,8 @@ const Harness = ({ isUnsupportedPolicy?: boolean policyError?: { message: string } | null }) => { - const form = useForm({ - resolver: zodResolver(FormSchema), + const form = useForm({ + resolver: zodResolver(BucketFormSchema), defaultValues: { ...DEFAULT_VALUES, ...defaultValues }, mode: 'onChange', }) @@ -47,7 +44,7 @@ const Harness = ({ return (
- + ) diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.tsx index 751544e0bc5..93dd4cd1456 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.tsx @@ -1,23 +1,21 @@ import { AnimatePresence } from 'framer-motion' -import { useFormContext, useWatch } from 'react-hook-form' +import { useWatch, type UseFormReturn } from 'react-hook-form' import { Badge, DialogSection, DialogSectionSeparator, FormControl, FormField, Switch } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { type BucketVersioningFormValues } from './BucketVersioningFields.schema' +import { type BucketFormValues } from '../FilesBucket.schema' import { getRetentionTightening, RETENTION_TIGHTENING_DESCRIPTION, toNullableNumber, } from './BucketVersioningFields.utils' import { LifecyclePolicySection } from './LifecyclePolicySection' -import type { - BucketVersioningState, - ExpirationMode, -} from '@/components/interfaces/Storage/StorageVersioning.constants' +import type { BucketVersioningState } from '@/components/interfaces/Storage/StorageVersioning.constants' import { FormSectionCollapse } from '@/components/ui/FormSectionCollapse' interface BucketVersioningFieldsProps { + form: UseFormReturn initialVersioningState?: BucketVersioningState initialRetentionDays?: number | null initialMaxVersions?: number | null @@ -31,6 +29,7 @@ interface BucketVersioningFieldsProps { } export const BucketVersioningFields = ({ + form, initialVersioningState = 'disabled', initialRetentionDays, initialMaxVersions, @@ -39,7 +38,7 @@ export const BucketVersioningFields = ({ isUnsupportedPolicy = false, policyError, }: BucketVersioningFieldsProps) => { - const { control, setValue } = useFormContext() + const { control } = form const isVersioningEnabled = useWatch({ control, name: 'enable_versioning' }) const retentionDays = useWatch({ control, name: 'version_expiry_days' }) @@ -64,10 +63,6 @@ export const BucketVersioningFields = ({ const hasDays = typeof retentionDays === 'number' && retentionDays > 0 const hasVersions = typeof maxVersions === 'number' && maxVersions > 0 - const handleModeChange = (mode: ExpirationMode) => { - setValue('expiration_mode', mode, { shouldDirty: true }) - } - return ( <> @@ -147,11 +142,10 @@ export const BucketVersioningFields = ({ {isVersioningEnabled && ( ( ) interface LifecyclePolicySectionProps { - control: Control + form: UseFormReturn hasDays: boolean hasVersions: boolean mode: ExpirationMode - onModeChange: (mode: ExpirationMode) => void /** The stored policy is still in flight, so there is nothing truthful to show yet. */ isLoading?: boolean /** The stored policy holds rules these fields cannot represent, let alone round-trip. */ @@ -47,17 +46,16 @@ interface LifecyclePolicySectionProps { } export const LifecyclePolicySection = ({ - control, + form, hasDays, hasVersions, mode, - onModeChange, isLoading = false, isUnsupported = false, error, }: LifecyclePolicySectionProps) => { const { ref } = useParams() - const { setValue } = useFormContext() + const { control, setValue } = form const hasNoPolicy = !hasDays && !hasVersions const hasBothConditions = hasDays && hasVersions @@ -171,7 +169,10 @@ export const LifecyclePolicySection = ({ {hasBothConditions && ( - + setValue('expiration_mode', value, { shouldDirty: true })} + /> )} diff --git a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.test.tsx similarity index 63% rename from apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx rename to apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.test.tsx index 2905e779fd5..c0130dffed9 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/dom' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CreateBucketModal } from '../FilesBuckets/CreateBucketModal' +import { CreateBucketModal } from './CreateBucketModal' import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' @@ -13,8 +13,19 @@ vi.mock(`hooks/misc/useCheckPermissions`, () => ({ useAsyncCheckPermissions: vi.fn().mockImplementation(() => ({ can: true })), })) +const { versioningEnabled } = vi.hoisted(() => ({ versioningEnabled: { current: false } })) + +vi.mock( + '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', + async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, useIsStorageVersioningEnabled: () => versioningEnabled.current } + } +) + describe(`CreateBucketModal`, () => { beforeEach(() => { + versioningEnabled.current = false // useParams routerMock.setCurrentUrl(`/project/default/storage/buckets`) // useSelectedProject -> Project @@ -95,4 +106,34 @@ describe(`CreateBucketModal`, () => { fireEvent.click(submitButton) }) + + it('keeps the bucket it created when the retention policy is rejected', async () => { + versioningEnabled.current = true + const postBucket = vi.fn(() => Response.json({ name: 'versioned' })) + addAPIMock({ method: 'post', path: '/platform/storage/:ref/buckets', response: postBucket }) + addAPIMock({ + method: 'put', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: () => Response.json({ message: 'Lifecycle is not available' }, { status: 500 }), + }) + const onOpenChange = vi.fn() + + customRender( + + + + ) + + await userEvent.type(await screen.findByLabelText('Bucket name'), 'versioned') + await userEvent.click(screen.getByRole('switch', { name: 'Object versioning' })) + + // Submitted off the form itself: jsdom doesn't associate a button with a form by + // attribute, so clicking the footer's Create submits nothing. + fireEvent.submit(document.getElementById('create-storage-bucket-form')!) + + // The bucket exists, so the modal closes rather than inviting a retry that would + // only collide on the name. + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(postBucket).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.tsx index e4bdb7cf974..e6be8b4db9f 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/CreateBucketModal.tsx @@ -27,20 +27,37 @@ import { import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { BucketVersioningFields } from './BucketVersioningFields/BucketVersioningFields' +import { toLifecycleRules } from './BucketVersioningFields/BucketVersioningFields.lifecycle' import { BucketFormSchema, type BucketFormValues } from './FilesBucket.schema' +import { useIsStorageVersioningEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils' +import { PROJECT_VERSIONING_DEFAULTS } from '@/components/interfaces/Storage/StorageVersioning.constants' import { InlineLink } from '@/components/ui/InlineLink' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation' +import { useBucketLifecycleUpdateMutation } from '@/data/storage/bucket-lifecycle-update-mutation' import { IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' const formId = 'create-storage-bucket-form' +const DEFAULT_VALUES: BucketFormValues = { + name: '', + public: false, + has_file_size_limit: false, + formatted_size_limit: undefined, + allowed_mime_types: '', + enable_versioning: false, + version_expiry_days: PROJECT_VERSIONING_DEFAULTS.versionExpiryDays, + max_noncurrent_versions: PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions, + expiration_mode: 'and', +} + interface CreateBucketModalProps { open: boolean onOpenChange: (value: boolean) => void @@ -55,21 +72,22 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0) const formattedGlobalUploadLimit = `${value} ${unit}` + const isStorageVersioningEnabled = useIsStorageVersioningEnabled() + const track = useTrack() const { mutateAsync: createBucket, isPending: isCreatingBucket } = useBucketCreateMutation({ // [Joshen] Silencing the error here as it's being handled in onSubmit onError: () => {}, }) + const { mutateAsync: updateLifecycle, isPending: isUpdatingLifecycle } = + useBucketLifecycleUpdateMutation({ onError: () => {} }) + const isSaving = isCreatingBucket || isUpdatingLifecycle const form = useForm({ resolver: zodResolver(BucketFormSchema), - defaultValues: { - name: '', - public: false, - has_file_size_limit: false, - formatted_size_limit: undefined, - allowed_mime_types: '', - }, + defaultValues: DEFAULT_VALUES, + // Show numeric versioning bounds as the user types, not only on submit + mode: 'onChange', }) const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors const isPublicBucket = useWatch({ control: form.control, name: 'public' }) @@ -97,6 +115,8 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps }) } + const isVersioningEnabled = isStorageVersioningEnabled && values.enable_versioning + await createBucket({ projectRef: ref, id: values.name, @@ -104,10 +124,35 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps isPublic: values.public, file_size_limit: fileSizeLimit, allowed_mime_types: allowedMimeTypes, + // A bucket is only ever created DISABLED or ENABLED; suspending comes later. + versioning_status: isVersioningEnabled ? 'ENABLED' : 'DISABLED', }) - track('storage_bucket_created', { bucketType: 'STANDARD' }) - toast.success(`Successfully created bucket ${values.name}`) + // A second call, and the bucket exists whether or not it lands, so its failure is + // reported against the policy rather than the creation the user would only retry. + const lifecycleRules = isVersioningEnabled ? toLifecycleRules(values) : [] + let lifecycleError: string | undefined + if (lifecycleRules.length > 0) { + try { + await updateLifecycle({ projectRef: ref, bucketId: values.name, rules: lifecycleRules }) + } catch (error) { + lifecycleError = error instanceof Error ? error.message : 'Unknown error' + } + } + + track('storage_bucket_created', { + bucketType: 'STANDARD', + hasVersioningEnabled: isVersioningEnabled, + }) + + if (lifecycleError === undefined) { + toast.success(`Successfully created bucket ${values.name}`) + } else { + toast.error( + `Created bucket ${values.name}, but its retention policy was not saved: ${lifecycleError}` + ) + } + form.reset() setSelectedUnit(StorageSizeUnits.MB) onOpenChange(false) @@ -332,19 +377,23 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps /> )} + + {isStorageVersioningEnabled && ( + + )} - diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/DeleteBucketModal.test.tsx similarity index 98% rename from apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx rename to apps/studio/components/interfaces/Storage/FilesBuckets/DeleteBucketModal.test.tsx index c6c30b3c03b..36bbe0d6632 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/DeleteBucketModal.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DeleteBucketModal } from '../FilesBuckets/DeleteBucketModal' +import { DeleteBucketModal } from './DeleteBucketModal' import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext' import { Bucket } from '@/data/storage/buckets-query' import { customRender } from '@/tests/lib/custom-render' diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.test.tsx new file mode 100644 index 00000000000..1172c2f4ecb --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.test.tsx @@ -0,0 +1,257 @@ +import { faker } from '@faker-js/faker' +import { fireEvent, screen, waitFor } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { EditBucketModal } from './EditBucketModal' +import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext' +import { Bucket } from '@/data/storage/buckets-query' +import { render } from '@/tests/helpers' +import { addAPIMock } from '@/tests/lib/msw' + +const { versioningEnabled } = vi.hoisted(() => ({ versioningEnabled: { current: false } })) + +vi.mock( + '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', + async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, useIsStorageVersioningEnabled: () => versioningEnabled.current } + } +) + +const bucket: Bucket = { + id: faker.string.uuid(), + name: `test`, + owner: faker.string.uuid(), + public: false, + allowed_mime_types: [], + file_size_limit: undefined, + type: 'STANDARD', + created_at: faker.date.recent().toISOString(), + updated_at: faker.date.recent().toISOString(), +} + +const versionedBucket: Bucket = { + id: 'my-bucket', + name: 'my-bucket', + owner: 'owner', + public: false, + allowed_mime_types: [], + file_size_limit: undefined, + type: 'STANDARD', + versioning_status: 'ENABLED', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +} + +const Page = ({ onClose }: { onClose: () => void }) => { + const [open, setOpen] = useState(false) + return ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +describe(`EditBucketModal`, () => { + beforeEach(() => { + versioningEnabled.current = false + // useSelectedProject -> Project + addAPIMock({ + method: `get`, + path: `/platform/projects/:ref`, + // @ts-expect-error + response: { + cloud_provider: 'localhost', + id: 1, + inserted_at: '2021-08-02T06:40:40.646Z', + name: 'Default Project', + organization_id: 1, + ref: 'default', + region: 'local', + status: 'ACTIVE_HEALTHY', + }, + }) + // useBucketUpdateMutation + addAPIMock({ + method: `patch`, + path: `/platform/storage/:ref/buckets/:id`, + }) + }) + + it(`renders a dialog with a form`, async () => { + const onClose = vi.fn() + render() + + const openButton = screen.getByRole(`button`, { name: `Open` }) + await userEvent.click(openButton) + await screen.findByRole(`dialog`) + + const nameInput = screen.getByLabelText(`Bucket name`) + expect(nameInput).toHaveValue(`test`) + expect(nameInput).toBeDisabled() + + const publicToggle = screen.getByLabelText(`Public bucket`) + expect(publicToggle).not.toBeChecked() + await userEvent.click(publicToggle) + expect(publicToggle).toBeChecked() + + const sizeLimitToggle = screen.getByLabelText(`Restrict file size`) + expect(sizeLimitToggle).not.toBeChecked() + await userEvent.click(sizeLimitToggle) + expect(sizeLimitToggle).toBeChecked() + + const sizeLimitInput = screen.getByLabelText(`File size limit`) + expect(sizeLimitInput).toHaveValue(null) + await userEvent.type(sizeLimitInput, `25`) + + const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`) + expect(sizeLimitUnitSelect).toHaveTextContent(`MB`) + await userEvent.click(sizeLimitUnitSelect) + const mbOption = screen.getByRole(`option`, { name: `GB` }) + await userEvent.click(mbOption) + expect(sizeLimitUnitSelect).toHaveTextContent(`GB`) + + const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`) + expect(mimeTypeToggle).not.toBeChecked() + await userEvent.click(mimeTypeToggle) + expect(mimeTypeToggle).toBeChecked() + + const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`) + expect(mimeTypeInput).toHaveValue(``) + await userEvent.type(mimeTypeInput, `image/jpeg, image/png`) + + const confirmButton = screen.getByRole(`button`, { name: `Save` }) + + fireEvent.click(confirmButton) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + describe('versioning', () => { + beforeEach(() => { + versioningEnabled.current = true + }) + + it('shows the policy as loading rather than absent, and holds the save', async () => { + // Never resolves, so the modal stays in the window the user sees on open. + addAPIMock({ + method: 'get', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: () => new Promise(() => {}) as unknown as Response, + }) + + render( + + + + ) + + expect(await screen.findByLabelText('Loading lifecycle policy')).toBeInTheDocument() + expect(screen.queryByText('No lifecycle policy')).not.toBeInTheDocument() + // Submitting now would persist the empty policy the form is still seeded with. + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('reports no policy once the bucket is known to have none', async () => { + addAPIMock({ + method: 'get', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: () => Response.json({ rules: [] }), + }) + + render( + + + + ) + + expect(await screen.findByText('No lifecycle policy')).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled()) + }) + + it('surfaces a failed policy fetch and holds the save', async () => { + addAPIMock({ + method: 'get', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: () => + Response.json({ message: 'Bucket lifecycle is unavailable' }, { status: 500 }), + }) + + render( + + + + ) + + expect(await screen.findByText('Failed to retrieve the lifecycle policy')).toBeInTheDocument() + // A 404 is the "no policy" answer; anything else leaves the real policy unknown. + expect(screen.queryByText('No lifecycle policy')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('leaves a policy it cannot model alone when versioning is switched on', async () => { + // Two age rules: the form reads the first and has nowhere to put the second, and the + // update endpoint replaces the whole policy, so writing the form's defaults loses it. + addAPIMock({ + method: 'get', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: () => + Response.json({ + rules: [30, 60].map((noncurrent_days) => ({ + status: 'Enabled', + filter: {}, + noncurrent_version_expiration: { noncurrent_days }, + })), + }), + }) + const putLifecycle = vi.fn(() => Response.json({ rules: [] })) + addAPIMock({ + method: 'put', + path: '/platform/storage/:ref/buckets/:id/lifecycle', + response: putLifecycle, + }) + const patchBucket = vi.fn(() => Response.json({ message: 'Successfully updated bucket' })) + addAPIMock({ + method: 'patch', + path: '/platform/storage/:ref/buckets/:id', + response: patchBucket, + }) + + render( + + + + ) + + await userEvent.click(await screen.findByRole('switch', { name: 'Object versioning' })) + + expect( + await screen.findByText('Lifecycle policy set outside the dashboard') + ).toBeInTheDocument() + + // Saved off the form itself: jsdom doesn't associate a button with a form by attribute, + // so clicking the footer's Save submits nothing. + fireEvent.submit(document.getElementById('edit-storage-bucket-form')!) + + await waitFor(() => expect(patchBucket).toHaveBeenCalled()) + expect(putLifecycle).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx index c9c3f817158..43aec484f04 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' import { useEffect, useRef, useState } from 'react' import { useForm, useWatch, type SubmitHandler } from 'react-hook-form' @@ -25,19 +26,40 @@ import { Switch, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { BucketVersioningFields } from './BucketVersioningFields/BucketVersioningFields' +import { + fromLifecycleRules, + hasLifecyclePolicyChanged, + hasUnsupportedLifecycleRules, + toLifecycleRules, +} from './BucketVersioningFields/BucketVersioningFields.lifecycle' +import { + getNextVersioningState, + getVersioningFormDefaults, + isEnablingVersioning, + isSuspendingVersioning, + toVersioningStatusUpdate, + type BucketVersioningSettings, +} from './EditBucketModal.utils' import { EditBucketFormSchema, type BucketFormValues } from './FilesBucket.schema' +import { useIsStorageVersioningEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils' +import { getBucketVersioningState } from '@/components/interfaces/Storage/StorageVersioning.constants' import { InlineLink } from '@/components/ui/InlineLink' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' +import { bucketLifecycleQueryOptions } from '@/data/storage/bucket-lifecycle-query' +import { useBucketLifecycleUpdateMutation } from '@/data/storage/bucket-lifecycle-update-mutation' import { useBucketUpdateMutation } from '@/data/storage/bucket-update-mutation' import { Bucket } from '@/data/storage/buckets-query' import { DOCS_URL, IS_PLATFORM } from '@/lib/constants' +import { useTrack } from '@/lib/telemetry/track' export interface EditBucketModalProps { visible: boolean @@ -58,12 +80,53 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0) - const { mutate: updateBucket, isPending: isUpdating } = useBucketUpdateMutation({ + const isStorageVersioningEnabled = useIsStorageVersioningEnabled() + + const { + data: lifecycle, + isLoading: isLoadingLifecycle, + error: lifecycleError, + } = useQuery({ + ...bucketLifecycleQueryOptions({ projectRef: ref, bucketId: bucket?.id }), + enabled: isStorageVersioningEnabled && visible && !!ref && !!bucket?.id, + }) + const storedPolicy = fromLifecycleRules(lifecycle) + const isUnsupportedPolicy = hasUnsupportedLifecycleRules(lifecycle) + // Still loading, or the fetch failed: either way the form is seeded from a policy + // nobody has read, and the update endpoint would replace the real one with it. + const hasUnreadPolicy = isLoadingLifecycle || !!lifecycleError + + const versioningSettings: BucketVersioningSettings = { + versioning: getBucketVersioningState(bucket), + versionExpiryDays: storedPolicy.versionExpiryDays, + maxNoncurrentVersions: storedPolicy.maxNoncurrentVersions, + expirationMode: storedPolicy.expirationMode, + } + + // Held while the suspend confirmation is open, so confirming completes the save. + const [pendingSuspendValues, setPendingSuspendValues] = useState(null) + + const track = useTrack() + // Stashed at submit time and only reported once the save actually succeeds. + const enabledVersioningRef = useRef<{ hasLifecyclePolicy: boolean } | null>(null) + + const { mutateAsync: updateLifecycle, isPending: isUpdatingLifecycle } = + useBucketLifecycleUpdateMutation() + + const { mutate: updateBucket, isPending: isUpdatingBucket } = useBucketUpdateMutation({ onSuccess: () => { + setPendingSuspendValues(null) + if (enabledVersioningRef.current !== null) { + track('storage_bucket_versioning_enabled', enabledVersioningRef.current) + enabledVersioningRef.current = null + } toast.success(`Successfully updated bucket "${bucket?.name}"`) onClose() }, onError: (error) => { + // Release the suspend dialog too, so a failed save doesn't trap the user in it. + setPendingSuspendValues(null) + // Handle specific error cases for inline display const errorMessage = error.message?.toLowerCase() || '' @@ -94,19 +157,23 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro }, }) + const isUpdating = isUpdatingBucket || isUpdatingLifecycle + const defaultValues = { name: bucket?.name ?? '', public: bucket?.public, has_file_size_limit: Boolean(bucket?.file_size_limit), formatted_size_limit: bucket?.file_size_limit ? (fileSizeLimit ?? 0) : undefined, allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '), + ...getVersioningFormDefaults(versioningSettings), } const form = useForm({ resolver: zodResolver(EditBucketFormSchema), defaultValues, values: defaultValues, - mode: 'onSubmit', + // Show numeric versioning bounds as the user types, not only on submit. + mode: 'onChange', }) const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors @@ -125,6 +192,65 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro onClose() } + const persistChanges = async (values: BucketFormValues) => { + if (bucket === undefined) return console.error('Bucket is required') + if (ref === undefined) return console.error('Project ref is required') + + enabledVersioningRef.current = + isStorageVersioningEnabled && + isEnablingVersioning(versioningSettings.versioning, values.enable_versioning) + ? { + hasLifecyclePolicy: + values.version_expiry_days !== '' || values.max_noncurrent_versions !== '', + } + : null + + const nextVersioningState = getNextVersioningState( + versioningSettings.versioning, + values.enable_versioning + ) + const versioningStatus = isStorageVersioningEnabled + ? toVersioningStatusUpdate(nextVersioningState) + : undefined + + // Saved first, so a rejected policy leaves the bucket untouched. Skipped for a policy + // the form never fully read, since the update endpoint replaces the whole of it. + if ( + isStorageVersioningEnabled && + nextVersioningState !== 'disabled' && + !isUnsupportedPolicy && + !hasUnreadPolicy && + hasLifecyclePolicyChanged(storedPolicy, values) + ) { + try { + await updateLifecycle({ + projectRef: ref, + bucketId: bucket.id, + rules: toLifecycleRules(values), + }) + } catch { + // The mutation raises its own toast; leave the modal open to retry. + return + } + } + + updateBucket({ + projectRef: ref, + id: bucket.id, + isPublic: values.public, + versioning_status: versioningStatus, + file_size_limit: + values.has_file_size_limit && values.formatted_size_limit + ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits) + : null, + allowed_mime_types: hasAllowedMimeTypes + ? values.allowed_mime_types.length > 0 + ? values.allowed_mime_types.split(',').map((x: string) => x.trim()) + : null + : null, + }) + } + const onSubmit: SubmitHandler = async (values) => { if (bucket === undefined) return console.error('Bucket is required') if (ref === undefined) return console.error('Project ref is required') @@ -149,20 +275,15 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro } } - updateBucket({ - projectRef: ref, - id: bucket.id, - isPublic: values.public, - file_size_limit: - values.has_file_size_limit && values.formatted_size_limit - ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits) - : null, - allowed_mime_types: hasAllowedMimeTypes - ? values.allowed_mime_types.length > 0 - ? values.allowed_mime_types.split(',').map((x: string) => x.trim()) - : null - : null, - }) + // Future uploads stop producing recoverable versions, so confirm first. + if ( + isStorageVersioningEnabled && + isSuspendingVersioning(versioningSettings.versioning, values.enable_versioning) + ) { + return setPendingSuspendValues(values) + } + + await persistChanges(values) } useEffect(() => { @@ -178,49 +299,109 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro }, [visible, bucket, form]) return ( - { - if (!open) closeModal() - }} - > - - - {`Edit bucket “${bucket?.name}”`} - + <> + { + if (!open) closeModal() + }} + > + + + {`Edit bucket “${bucket?.name}”`} + - + -
- - - ( - - - - - - )} - /> - -
+ + + ( + + + + + )} + /> + +
+ ( + + + + + + )} + /> + + {isChangingBucketVisibility && ( + + {isMakingBucketPublic && ( +

This will make all objects in your bucket publicly accessible.

+ )} + + {isMakingBucketPrivate && ( + <> +

+ All objects in your bucket will only accessible via signed URLs, or + downloaded with the right authorization headers. +

+

+ Assets cached in the CDN may still be publicly accessible. You can + consider{' '} + + purging the cache + {' '} + or moving your assets to a new bucket. +

+ + )} + + } + /> + )} +
+
+ + + + + ( + @@ -233,180 +414,167 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro )} /> + {hasFileSizeLimit && ( +
+ ( + +
+
+ + + +
+
+ +
+
+
+ )} + /> + {formattedSizeLimitError?.message === 'exceed_global_limit' && ( + + Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '} + + Storage Settings + {' '} + first. + + )} - {isChangingBucketVisibility && ( - - {isMakingBucketPublic && ( -

This will make all objects in your bucket publicly accessible.

- )} - - {isMakingBucketPrivate && ( - <> -

- All objects in your bucket will only accessible via signed URLs, or - downloaded with the right authorization headers. -

-

- Assets cached in the CDN may still be publicly accessible. You can - consider{' '} - - purging the cache - {' '} - or moving your assets to a new bucket. -

- - )} - - } - /> + {IS_PLATFORM && ( +

+ This project has a{' '} + + global file size limit + {' '} + of {formattedGlobalUploadLimit}. +

+ )} +
)} -
-
+ - + - - ( - - - - - - )} - /> - {hasFileSizeLimit && ( -
+ + + + + + + {hasAllowedMimeTypes && ( ( - -
-
- - - -
-
- -
-
+ + + + )} /> - {formattedSizeLimitError?.message === 'exceed_global_limit' && ( - - Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '} - - Storage Settings - {' '} - first. - - )} + )} +
- {IS_PLATFORM && ( -

- This project has a{' '} - - global file size limit - {' '} - of {formattedGlobalUploadLimit}. -

- )} -
- )} -
- - - - - - - - - - {hasAllowedMimeTypes && ( - ( - - - - - - )} + {isStorageVersioningEnabled && ( + )} - - - + + - - - - -
-
+ + + + +
+
+ + setPendingSuspendValues(null)} + // Cleared by the mutation, so the dialog keeps its loading state while the save runs. + onConfirm={() => { + if (pendingSuspendValues !== null) persistChanges(pendingSuspendValues) + }} + > +

+ Overwriting or deleting an object in{' '} + {bucket?.name} will no longer keep a recoverable + copy. Every version already retained stays until you delete it or a lifecycle policy + expires it, and you can re-enable versioning at any time. +

+
+ ) } diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.test.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.test.ts new file mode 100644 index 00000000000..c62e856af89 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' + +import { + getNextVersioningState, + getVersioningFormDefaults, + isEnablingVersioning, + isSuspendingVersioning, + toVersioningStatusUpdate, + type BucketVersioningSettings, +} from './EditBucketModal.utils' +import { PROJECT_VERSIONING_DEFAULTS } from '@/components/interfaces/Storage/StorageVersioning.constants' + +const settings = (overrides: Partial = {}): BucketVersioningSettings => ({ + versioning: 'disabled', + versionExpiryDays: null, + maxNoncurrentVersions: null, + expirationMode: 'and', + ...overrides, +}) + +describe('getVersioningFormDefaults', () => { + it('prefills the project defaults for a bucket that has never been versioned', () => { + expect(getVersioningFormDefaults(settings())).toEqual({ + enable_versioning: false, + version_expiry_days: PROJECT_VERSIONING_DEFAULTS.versionExpiryDays, + max_noncurrent_versions: PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions, + expiration_mode: 'and', + }) + }) + + it('ignores any stray stored policy on a never-versioned bucket', () => { + const defaults = getVersioningFormDefaults( + settings({ versionExpiryDays: 1, maxNoncurrentVersions: 1 }) + ) + expect(defaults.version_expiry_days).toBe(PROJECT_VERSIONING_DEFAULTS.versionExpiryDays) + expect(defaults.max_noncurrent_versions).toBe(PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions) + }) + + it('shows the stored policy for an actively versioned bucket', () => { + expect( + getVersioningFormDefaults( + settings({ + versioning: 'enabled', + versionExpiryDays: 60, + maxNoncurrentVersions: 5, + expirationMode: 'or', + }) + ) + ).toEqual({ + enable_versioning: true, + version_expiry_days: 60, + max_noncurrent_versions: 5, + expiration_mode: 'or', + }) + }) + + it('leaves an unset condition unset rather than filling in a default', () => { + // Prefilling here would silently add a bound the user never chose. + const defaults = getVersioningFormDefaults( + settings({ versioning: 'enabled', versionExpiryDays: 60, maxNoncurrentVersions: null }) + ) + expect(defaults.version_expiry_days).toBe(60) + expect(defaults.max_noncurrent_versions).toBe('') + }) + + it('shows a suspended bucket with the switch off but its policy intact', () => { + const defaults = getVersioningFormDefaults( + settings({ versioning: 'suspended', versionExpiryDays: 14, maxNoncurrentVersions: 3 }) + ) + expect(defaults.enable_versioning).toBe(false) + expect(defaults.version_expiry_days).toBe(14) + expect(defaults.max_noncurrent_versions).toBe(3) + }) +}) + +describe('getNextVersioningState', () => { + it('enables versioning from any starting state', () => { + expect(getNextVersioningState('disabled', true)).toBe('enabled') + expect(getNextVersioningState('suspended', true)).toBe('enabled') + expect(getNextVersioningState('enabled', true)).toBe('enabled') + }) + + it('suspends rather than disables a bucket that has been versioned', () => { + expect(getNextVersioningState('enabled', false)).toBe('suspended') + }) + + it('keeps an already-suspended bucket suspended', () => { + expect(getNextVersioningState('suspended', false)).toBe('suspended') + }) + + it('leaves a never-versioned bucket disabled', () => { + expect(getNextVersioningState('disabled', false)).toBe('disabled') + }) +}) + +describe('isSuspendingVersioning', () => { + it('is true only when an actively versioning bucket is being turned off', () => { + expect(isSuspendingVersioning('enabled', false)).toBe(true) + }) + + it('is false when versioning stays on', () => { + expect(isSuspendingVersioning('enabled', true)).toBe(false) + }) + + it('is false for an already-suspended bucket, which has nothing left to stop', () => { + expect(isSuspendingVersioning('suspended', false)).toBe(false) + }) + + it('is false for a bucket that was never versioned', () => { + expect(isSuspendingVersioning('disabled', false)).toBe(false) + }) +}) + +describe('isEnablingVersioning', () => { + it('is true only when a never-versioned bucket is being turned on', () => { + expect(isEnablingVersioning('disabled', true)).toBe(true) + }) + + it('is false when re-enabling a suspended bucket', () => { + // The user already opted in once, so this is not a first-time enablement. + expect(isEnablingVersioning('suspended', true)).toBe(false) + }) + + it('is false when versioning is already on', () => { + expect(isEnablingVersioning('enabled', true)).toBe(false) + }) + + it('is false whenever the switch is off', () => { + for (const state of ['disabled', 'suspended', 'enabled'] as const) { + expect(isEnablingVersioning(state, false), state).toBe(false) + } + }) +}) + +describe('toVersioningStatusUpdate', () => { + it('sends ENABLED when versioning is on', () => { + expect(toVersioningStatusUpdate('enabled')).toBe('ENABLED') + }) + + it('sends SUSPENDED when versioning is being turned off', () => { + expect(toVersioningStatusUpdate('suspended')).toBe('SUSPENDED') + }) + + it('sends nothing for a bucket that was never versioned', () => { + expect(toVersioningStatusUpdate('disabled')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.ts new file mode 100644 index 00000000000..a8bbec92b63 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/EditBucketModal.utils.ts @@ -0,0 +1,65 @@ +import type { BucketVersioningFormValues } from './FilesBucket.schema' +import { + PROJECT_VERSIONING_DEFAULTS, + type BucketVersioningState, + type ExpirationMode, +} from '@/components/interfaces/Storage/StorageVersioning.constants' + +export interface BucketVersioningSettings { + versioning: BucketVersioningState + versionExpiryDays: number | null + maxNoncurrentVersions: number | null + expirationMode: ExpirationMode +} + +/** A never-versioned bucket prefills the project defaults; an unset condition stays unset. */ +export const getVersioningFormDefaults = ( + settings: BucketVersioningSettings +): BucketVersioningFormValues => { + const hasEverBeenVersioned = settings.versioning !== 'disabled' + + if (!hasEverBeenVersioned) { + return { + enable_versioning: false, + version_expiry_days: PROJECT_VERSIONING_DEFAULTS.versionExpiryDays, + max_noncurrent_versions: PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions, + expiration_mode: 'and', + } + } + + return { + enable_versioning: settings.versioning === 'enabled', + version_expiry_days: settings.versionExpiryDays ?? '', + max_noncurrent_versions: settings.maxNoncurrentVersions ?? '', + expiration_mode: settings.expirationMode, + } +} + +/** Turning versioning off suspends rather than disables. */ +export const getNextVersioningState = ( + currentState: BucketVersioningState, + isVersioningEnabled: boolean +): BucketVersioningState => { + if (isVersioningEnabled) return 'enabled' + return currentState === 'disabled' ? 'disabled' : 'suspended' +} + +/** `DISABLED` is not an accepted update, so both cases send no versioning field at all. */ +export const toVersioningStatusUpdate = ( + nextState: BucketVersioningState +): 'ENABLED' | 'SUSPENDED' | undefined => { + if (nextState === 'enabled') return 'ENABLED' + if (nextState === 'suspended') return 'SUSPENDED' + return undefined +} + +export const isSuspendingVersioning = ( + currentState: BucketVersioningState, + isVersioningEnabled: boolean +) => currentState === 'enabled' && !isVersioningEnabled + +/** Re-enabling a suspended bucket doesn't count — the user already opted in once. */ +export const isEnablingVersioning = ( + currentState: BucketVersioningState, + isVersioningEnabled: boolean +) => currentState === 'disabled' && isVersioningEnabled diff --git a/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/EmptyBucketModal.test.tsx similarity index 97% rename from apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx rename to apps/studio/components/interfaces/Storage/FilesBuckets/EmptyBucketModal.test.tsx index d62fda2859b..3571265c6bc 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/EmptyBucketModal.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { EmptyBucketModal } from '../FilesBuckets/EmptyBucketModal' +import { EmptyBucketModal } from './EmptyBucketModal' import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext' import { Bucket } from '@/data/storage/buckets-query' import { render } from '@/tests/helpers' diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.test.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.test.ts similarity index 98% rename from apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.test.ts rename to apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.test.ts index 36532b40582..f82ad324c7f 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketVersioningFields/BucketVersioningFields.schema.test.ts +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.test.ts @@ -6,7 +6,7 @@ import { S3_MAX_NONCURRENT_VERSIONS, superRefineBucketVersioning, type BucketVersioningFormValues, -} from './BucketVersioningFields.schema' +} from './FilesBucket.schema' const values = ( overrides: Partial = {} diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.ts b/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.ts index 1126efb4e0b..1a23ab97e40 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.ts +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/FilesBucket.schema.ts @@ -4,6 +4,69 @@ import { inverseValidBucketNameRegex, validBucketNameRegex, } from '@/components/interfaces/Storage/Storage.utils' +import type { ExpirationMode } from '@/components/interfaces/Storage/StorageVersioning.constants' + +/** Empty drops the condition from the policy, which is distinct from zero. */ +const versioningNumberField = z.union([z.literal(''), z.coerce.number().int()]) + +export const S3_MAX_NONCURRENT_VERSIONS = 100 + +export const bucketVersioningFormFields = { + enable_versioning: z.boolean().default(false), + version_expiry_days: versioningNumberField.default(''), + max_noncurrent_versions: versioningNumberField.default(''), + expiration_mode: z.enum(['and', 'or']).default('and'), +} + +export interface BucketVersioningFormValues { + enable_versioning: boolean + version_expiry_days: '' | number + max_noncurrent_versions: '' | number + expiration_mode: ExpirationMode +} + +export const superRefineBucketVersioning = ( + data: BucketVersioningFormValues, + ctx: z.RefinementCtx +) => { + if (!data.enable_versioning) return + + const { version_expiry_days: days, max_noncurrent_versions: versions } = data + + if (days !== '' && days < 1) { + ctx.addIssue({ + path: ['version_expiry_days'], + code: z.ZodIssueCode.custom, + message: 'Must be at least 1 day', + }) + } + + if (versions === '') return + + // S3 only accepts a noncurrent-count condition alongside a noncurrent-days one + if (days === '') { + ctx.addIssue({ + path: ['max_noncurrent_versions'], + code: z.ZodIssueCode.custom, + message: 'Requires an expiration age to be set', + }) + return + } + + if (versions < 1) { + ctx.addIssue({ + path: ['max_noncurrent_versions'], + code: z.ZodIssueCode.custom, + message: 'Must be at least 1 version or empty to disable', + }) + } else if (versions > S3_MAX_NONCURRENT_VERSIONS) { + ctx.addIssue({ + path: ['max_noncurrent_versions'], + code: z.ZodIssueCode.custom, + message: `Cannot exceed ${S3_MAX_NONCURRENT_VERSIONS} versions`, + }) + } +} const BucketFormObjectSchema = z.object({ name: z @@ -23,10 +86,13 @@ const BucketFormObjectSchema = z.object({ .min(0, 'File size upload limit has to be at least 0') .optional(), allowed_mime_types: z.string().trim().default(''), + ...bucketVersioningFormFields, }) export const EditBucketFormSchema = BucketFormObjectSchema.extend({ name: z.string(), +}).superRefine((data, ctx) => { + superRefineBucketVersioning(data, ctx) }) export const BucketFormSchema = BucketFormObjectSchema.superRefine((data, ctx) => { @@ -40,6 +106,8 @@ export const BucketFormSchema = BucketFormObjectSchema.superRefine((data, ctx) = : 'Bucket name contains an invalid special character', }) } + + superRefineBucketVersioning(data, ctx) }) export type BucketFormValues = z.infer diff --git a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx deleted file mode 100644 index afdee45b9d1..00000000000 --- a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { faker } from '@faker-js/faker' -import { fireEvent, screen, waitFor } from '@testing-library/dom' -import userEvent from '@testing-library/user-event' -import { useState } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { EditBucketModal } from '../FilesBuckets/EditBucketModal' -import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext' -import { Bucket } from '@/data/storage/buckets-query' -import { render } from '@/tests/helpers' -import { addAPIMock } from '@/tests/lib/msw' - -const bucket: Bucket = { - id: faker.string.uuid(), - name: `test`, - owner: faker.string.uuid(), - public: false, - allowed_mime_types: [], - file_size_limit: undefined, - type: 'STANDARD', - created_at: faker.date.recent().toISOString(), - updated_at: faker.date.recent().toISOString(), -} - -const Page = ({ onClose }: { onClose: () => void }) => { - const [open, setOpen] = useState(false) - return ( - - - - { - setOpen(false) - onClose() - }} - /> - - ) -} - -describe(`EditBucketModal`, () => { - beforeEach(() => { - // useSelectedProject -> Project - addAPIMock({ - method: `get`, - path: `/platform/projects/:ref`, - // @ts-expect-error - response: { - cloud_provider: 'localhost', - id: 1, - inserted_at: '2021-08-02T06:40:40.646Z', - name: 'Default Project', - organization_id: 1, - ref: 'default', - region: 'local', - status: 'ACTIVE_HEALTHY', - }, - }) - // useBucketUpdateMutation - addAPIMock({ - method: `patch`, - path: `/platform/storage/:ref/buckets/:id`, - }) - }) - - it(`renders a dialog with a form`, async () => { - const onClose = vi.fn() - render() - - const openButton = screen.getByRole(`button`, { name: `Open` }) - await userEvent.click(openButton) - await screen.findByRole(`dialog`) - - const nameInput = screen.getByLabelText(`Bucket name`) - expect(nameInput).toHaveValue(`test`) - expect(nameInput).toBeDisabled() - - const publicToggle = screen.getByLabelText(`Public bucket`) - expect(publicToggle).not.toBeChecked() - await userEvent.click(publicToggle) - expect(publicToggle).toBeChecked() - - const sizeLimitToggle = screen.getByLabelText(`Restrict file size`) - expect(sizeLimitToggle).not.toBeChecked() - await userEvent.click(sizeLimitToggle) - expect(sizeLimitToggle).toBeChecked() - - const sizeLimitInput = screen.getByLabelText(`File size limit`) - expect(sizeLimitInput).toHaveValue(null) - await userEvent.type(sizeLimitInput, `25`) - - const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`) - expect(sizeLimitUnitSelect).toHaveTextContent(`MB`) - await userEvent.click(sizeLimitUnitSelect) - const mbOption = screen.getByRole(`option`, { name: `GB` }) - await userEvent.click(mbOption) - expect(sizeLimitUnitSelect).toHaveTextContent(`GB`) - - const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`) - expect(mimeTypeToggle).not.toBeChecked() - await userEvent.click(mimeTypeToggle) - expect(mimeTypeToggle).toBeChecked() - - const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`) - expect(mimeTypeInput).toHaveValue(``) - await userEvent.type(mimeTypeInput, `image/jpeg, image/png`) - - const confirmButton = screen.getByRole(`button`, { name: `Save` }) - - fireEvent.click(confirmButton) - - await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) - }) -}) diff --git a/apps/studio/data/storage/bucket-create-mutation.ts b/apps/studio/data/storage/bucket-create-mutation.ts index 9628a6af997..35bf1670dbb 100644 --- a/apps/studio/data/storage/bucket-create-mutation.ts +++ b/apps/studio/data/storage/bucket-create-mutation.ts @@ -20,6 +20,7 @@ async function createBucket({ isPublic, file_size_limit, allowed_mime_types, + versioning_status, }: BucketCreateVariables) { if (!projectRef) throw new Error('projectRef is required') if (!id) throw new Error('Bucket name is required') @@ -28,6 +29,7 @@ async function createBucket({ if (type === 'STANDARD') { if (file_size_limit) payload.file_size_limit = file_size_limit if (allowed_mime_types) payload.allowed_mime_types = allowed_mime_types + if (versioning_status) payload.versioning_status = versioning_status } const { data, error } = await post('/platform/storage/{ref}/buckets', { diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts index 67870781f21..780b6d55c68 100644 --- a/apps/studio/data/storage/bucket-delete-mutation.ts +++ b/apps/studio/data/storage/bucket-delete-mutation.ts @@ -53,9 +53,13 @@ export const useBucketDeleteMutation = ({ await onSuccess?.(data, variables, context) - // Fire-and-forget: only the bucket list needs refreshing, and it shouldn't block - // onSuccess (modal close/navigation) above. - void queryClient.invalidateQueries({ queryKey: storageKeys.bucketsList(projectRef) }) + // Keyed on `buckets`, not `bucketsList`: the latter spells out every param as + // `undefined`, which partial matching never matches against a registered list key. + // `refetchType: 'all'` because the list query is inactive on the bucket page. + void queryClient.invalidateQueries({ + queryKey: storageKeys.buckets(projectRef), + refetchType: 'all', + }) }, async onError(data, variables, context) { if (onError === undefined) { diff --git a/apps/studio/data/storage/bucket-lifecycle-query.ts b/apps/studio/data/storage/bucket-lifecycle-query.ts new file mode 100644 index 00000000000..4029546e2c8 --- /dev/null +++ b/apps/studio/data/storage/bucket-lifecycle-query.ts @@ -0,0 +1,47 @@ +import { queryOptions } from '@tanstack/react-query' +import { components } from 'api-types' + +import { storageKeys } from './keys' +import { get, handleError } from '@/data/fetchers' +import { IS_PLATFORM } from '@/lib/constants' +import type { ResponseError } from '@/types' + +export type BucketLifecycle = components['schemas']['BucketLifecycleResponse_Output'] +export type BucketLifecycleRule = BucketLifecycle['rules'][number] + +export type BucketLifecycleVariables = { + projectRef?: string + bucketId?: string +} + +export type BucketLifecycleError = ResponseError + +/** No stored configuration answers 404, which is "no policy" rather than a query error. */ +async function getBucketLifecycle( + { projectRef, bucketId }: BucketLifecycleVariables, + signal?: AbortSignal +): Promise { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + + const { data, error } = await get('/platform/storage/{ref}/buckets/{id}/lifecycle', { + params: { path: { ref: projectRef, id: bucketId } }, + signal, + }) + + if (error) { + if ((error as { code?: number }).code === 404) return null + handleError(error) + } + + return data ?? null +} + +export type BucketLifecycleData = Awaited> + +export const bucketLifecycleQueryOptions = ({ projectRef, bucketId }: BucketLifecycleVariables) => + queryOptions({ + queryKey: storageKeys.bucketLifecycle(projectRef, bucketId), + queryFn: ({ signal }) => getBucketLifecycle({ projectRef, bucketId }, signal), + enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined', + }) diff --git a/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts b/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts new file mode 100644 index 00000000000..d3ccf18f381 --- /dev/null +++ b/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts @@ -0,0 +1,77 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { components } from 'api-types' +import { toast } from 'sonner' + +import { storageKeys } from './keys' +import { del, handleError, put } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type UpdateBucketLifecycleBody = components['schemas']['UpdateBucketLifecycleBody'] +export type BucketLifecycleRuleInput = UpdateBucketLifecycleBody['rules'][number] + +export type BucketLifecycleUpdateVariables = { + projectRef: string + bucketId: string + /** An empty list removes the configuration — the API has no "zero rules" body. */ + rules: BucketLifecycleRuleInput[] +} + +async function updateBucketLifecycle({ + projectRef, + bucketId, + rules, +}: BucketLifecycleUpdateVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + + if (rules.length === 0) { + const { error } = await del('/platform/storage/{ref}/buckets/{id}/lifecycle', { + params: { path: { ref: projectRef, id: bucketId } }, + }) + if (error) handleError(error) + return null + } + + const { data, error } = await put('/platform/storage/{ref}/buckets/{id}/lifecycle', { + params: { path: { ref: projectRef, id: bucketId } }, + body: { rules }, + }) + + if (error) handleError(error) + return data +} + +export type BucketLifecycleUpdateData = Awaited> + +export const useBucketLifecycleUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + BucketLifecycleUpdateData, + ResponseError, + BucketLifecycleUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: updateBucketLifecycle, + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.bucketLifecycle(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update the retention policy: ${error.message}`) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/bucket-update-mutation.ts b/apps/studio/data/storage/bucket-update-mutation.ts index 057a6659797..3b56057fbb4 100644 --- a/apps/studio/data/storage/bucket-update-mutation.ts +++ b/apps/studio/data/storage/bucket-update-mutation.ts @@ -12,6 +12,8 @@ type BucketUpdateVariables = { isPublic: boolean file_size_limit: number | null allowed_mime_types: string[] | null + /** Omitted when versioning isn't changing. There is no going back to DISABLED. */ + versioning_status?: components['schemas']['UpdateStorageBucketBody']['versioning_status'] } // [Alaister]: API accept null values for allowed_mime_types and file_size_limit to reset @@ -29,6 +31,7 @@ async function updateBucket({ isPublic, file_size_limit, allowed_mime_types, + versioning_status, }: BucketUpdateVariables): Promise { if (!projectRef) throw new Error('projectRef is required') if (!id) throw new Error('Bucket name is required') @@ -36,6 +39,7 @@ async function updateBucket({ const payload: Partial = { public: isPublic } if (file_size_limit !== undefined) payload.file_size_limit = file_size_limit if (allowed_mime_types !== undefined) payload.allowed_mime_types = allowed_mime_types + if (versioning_status !== undefined) payload.versioning_status = versioning_status const { data, error } = await patch('/platform/storage/{ref}/buckets/{id}', { params: { path: { id, ref: projectRef } }, @@ -73,7 +77,11 @@ export const useBucketUpdateMutation = ({ }, async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries({ queryKey: storageKeys.buckets(projectRef) }) + // `refetchType: 'all'` because the list query is inactive on the bucket page. + await queryClient.invalidateQueries({ + queryKey: storageKeys.buckets(projectRef), + refetchType: 'all', + }) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index f6614f28357..4da7604208c 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -56,6 +56,8 @@ export const storageKeys = { ] as const, folders: (projectRef: string | undefined, bucketId: string | undefined) => ['projects', projectRef, 'buckets', bucketId, 'folders'] as const, + bucketLifecycle: (projectRef: string | undefined, bucketId: string | undefined) => + ['projects', projectRef, 'buckets', bucketId, 'lifecycle'] as const, icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) => [projectRef, 'warehouse', warehouse, 'namespaces'] as const, icebergNamespace: ({ diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 31e2cc4e116..b94df9b7024 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2149,6 +2149,24 @@ export interface StorageBucketCreatedEvent { * The type of the bucket created. E.g. standard or analytics iceberg. */ bucketType?: string + /** Whether object versioning was turned on at creation time. */ + hasVersioningEnabled?: boolean + } + groups: TelemetryGroups +} + +/** + * Triggered when object versioning is turned on for a bucket that has never had it. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/storage/files/buckets/{bucketId} + */ +export interface StorageBucketVersioningEnabledEvent { + action: 'storage_bucket_versioning_enabled' + properties: { + /** Whether a lifecycle policy was configured at the same time. */ + hasLifecyclePolicy?: boolean } groups: TelemetryGroups } @@ -4268,6 +4286,7 @@ export type TelemetryEvent = | OrganizationMfaEnforcementUpdatedEvent | ForeignDataWrapperCreatedEvent | StorageBucketCreatedEvent + | StorageBucketVersioningEnabledEvent | BranchCreateButtonClickedEvent | BranchDeleteButtonClickedEvent | BranchCreateMergeRequestButtonClickedEvent