feat(storage): versioning bucket modals (FE-4161) (#49205)

| # | 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 <noreply@anthropic.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-07 16:38:35 +02:00
1 parent 075c611463
commit 6ef729cb5c
24 files changed
+1587 -450

No files matched your search

@@ -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)
})
})
@@ -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',
}
}
@@ -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`,
})
}
}
@@ -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<BucketVersioningFormValues>
defaultValues?: Partial<BucketFormValues>
initialVersioningState?: BucketVersioningState
initialRetentionDays?: number | null
initialMaxVersions?: number | null
@@ -38,8 +35,8 @@ const Harness = ({
isUnsupportedPolicy?: boolean
policyError?: { message: string } | null
}) => {
const form = useForm<BucketVersioningFormValues>({
resolver: zodResolver(FormSchema),
const form = useForm<BucketFormValues>({
resolver: zodResolver(BucketFormSchema),
defaultValues: { ...DEFAULT_VALUES, ...defaultValues },
mode: 'onChange',
})
@@ -47,7 +44,7 @@ const Harness = ({
return (
<Form {...form}>
<form>
<BucketVersioningFields {...props} />
<BucketVersioningFields form={form} {...props} />
</form>
</Form>
)
@@ -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<BucketFormValues>
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<BucketVersioningFormValues>()
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 (
<>
<DialogSectionSeparator />
@@ -147,11 +142,10 @@ export const BucketVersioningFields = ({
{isVersioningEnabled && (
<FormSectionCollapse key="lifecycle-policy">
<LifecyclePolicySection
control={control}
form={form}
hasDays={hasDays}
hasVersions={hasVersions}
mode={expirationMode}
onModeChange={handleModeChange}
isLoading={isLoadingPolicy}
isUnsupported={isUnsupportedPolicy}
error={policyError}
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { AnimatePresence } from 'framer-motion'
import { useEffect, useRef } from 'react'
import { useFormContext, type Control } from 'react-hook-form'
import type { UseFormReturn } from 'react-hook-form'
import {
FormControl,
FormField,
@@ -14,7 +14,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import type { BucketVersioningFormValues } from './BucketVersioningFields.schema'
import type { BucketFormValues } from '../FilesBucket.schema'
import { ExpirationModeToggle } from './ExpirationModeToggle'
import type { ExpirationMode } from '@/components/interfaces/Storage/StorageVersioning.constants'
import { AlertError } from '@/components/ui/AlertError'
@@ -33,11 +33,10 @@ const SectionHeading = () => (
)
interface LifecyclePolicySectionProps {
control: Control<BucketVersioningFormValues>
form: UseFormReturn<BucketFormValues>
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<BucketVersioningFormValues>()
const { control, setValue } = form
const hasNoPolicy = !hasDays && !hasVersions
const hasBothConditions = hasDays && hasVersions
@@ -171,7 +169,10 @@ export const LifecyclePolicySection = ({
<AnimatePresence initial={false}>
{hasBothConditions && (
<FormSectionCollapse key="mode">
<ExpirationModeToggle mode={mode} onModeChange={onModeChange} />
<ExpirationModeToggle
mode={mode}
onModeChange={(value) => setValue('expiration_mode', value, { shouldDirty: true })}
/>
</FormSectionCollapse>
)}
@@ -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<Record<string, unknown>>()
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(
<ProjectContextProvider projectRef="default">
<CreateBucketModal open onOpenChange={onOpenChange} />
</ProjectContextProvider>
)
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)
})
})
@@ -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<BucketFormValues>({
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
/>
)}
</DialogSection>
{isStorageVersioningEnabled && (
<BucketVersioningFields form={form} isPublicBucket={isPublicBucket} />
)}
</form>
</Form>
<DialogFooter>
<Button disabled={isCreatingBucket} onClick={() => onOpenChange(false)}>
<Button disabled={isSaving} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
form={formId}
type="submit"
loading={isCreatingBucket}
disabled={isCreatingBucket}
loading={isSaving}
disabled={isSaving}
>
Create
</Button>
@@ -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'
@@ -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<Record<string, unknown>>()
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 (
<ProjectContextProvider projectRef="default">
<button tabIndex={0} onClick={() => setOpen(true)}>
Open
</button>
<EditBucketModal
visible={open}
bucket={bucket}
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ProjectContextProvider>
)
}
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(<Page onClose={onClose} />)
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(
<ProjectContextProvider projectRef="default">
<EditBucketModal visible bucket={versionedBucket} onClose={vi.fn()} />
</ProjectContextProvider>
)
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(
<ProjectContextProvider projectRef="default">
<EditBucketModal visible bucket={versionedBucket} onClose={vi.fn()} />
</ProjectContextProvider>
)
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(
<ProjectContextProvider projectRef="default">
<EditBucketModal visible bucket={versionedBucket} onClose={vi.fn()} />
</ProjectContextProvider>
)
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(
<ProjectContextProvider projectRef="default">
<EditBucketModal
visible
bucket={{ ...versionedBucket, versioning_status: 'DISABLED' }}
onClose={vi.fn()}
/>
</ProjectContextProvider>
)
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()
})
})
})
@@ -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<string>(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<BucketFormValues | null>(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<BucketFormValues>({
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<BucketFormValues> = 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 (
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) closeModal()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Edit bucket “${bucket?.name}”`}</DialogTitle>
</DialogHeader>
<>
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) closeModal()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Edit bucket “${bucket?.name}”`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSectionSeparator />
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="space-y-6">
<FormField
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
label="Bucket name"
labelOptional="Cannot be changed after creation"
>
<FormControl>
<Input {...field} disabled />
</FormControl>
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-3">
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="space-y-6">
<FormField
key="public"
name="public"
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
label="Public bucket"
description="Allow anyone to read objects without authorization"
label="Bucket name"
labelOptional="Cannot be changed after creation"
>
<FormControl>
<Input {...field} disabled />
</FormControl>
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-3">
<FormField
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
label="Public bucket"
description="Allow anyone to read objects without authorization"
layout="flex"
>
<FormControl>
<Switch
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
/>
{isChangingBucketVisibility && (
<Admonition
type="warning"
title={`Warning: Making bucket ${isMakingBucketPublic ? 'public' : 'private'}`}
description={
<>
{isMakingBucketPublic && (
<p>This will make all objects in your bucket publicly accessible.</p>
)}
{isMakingBucketPrivate && (
<>
<p className="mb-2 leading-normal!">
All objects in your bucket will only accessible via signed URLs, or
downloaded with the right authorization headers.
</p>
<p className="leading-normal!">
Assets cached in the CDN may still be publicly accessible. You can
consider{' '}
<InlineLink
href={`${DOCS_URL}/guides/storage/cdn/smart-cdn#cache-eviction`}
>
purging the cache
</InlineLink>{' '}
or moving your assets to a new bucket.
</p>
</>
)}
</>
}
/>
)}
</div>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormField
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl>
@@ -233,180 +414,167 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div>
<FormField
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage label="File size limit">
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl>
<Input
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl>
</div>
<div className="col-span-4">
<Select value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger aria-label="File size limit unit" size="small">
<SelectValue>{selectedUnit}</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem key={unit} value={unit} className="text-xs">
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</FormItemLayout>
)}
/>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage>
)}
{isChangingBucketVisibility && (
<Admonition
type="warning"
title={`Warning: Making bucket ${isMakingBucketPublic ? 'public' : 'private'}`}
description={
<>
{isMakingBucketPublic && (
<p>This will make all objects in your bucket publicly accessible.</p>
)}
{isMakingBucketPrivate && (
<>
<p className="mb-2 leading-normal!">
All objects in your bucket will only accessible via signed URLs, or
downloaded with the right authorization headers.
</p>
<p className="leading-normal!">
Assets cached in the CDN may still be publicly accessible. You can
consider{' '}
<InlineLink
href={`${DOCS_URL}/guides/storage/cdn/smart-cdn#cache-eviction`}
>
purging the cache
</InlineLink>{' '}
or moving your assets to a new bucket.
</p>
</>
)}
</>
}
/>
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</div>
</DialogSection>
</DialogSection>
<DialogSectionSeparator />
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormField
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl>
<Switch size="large" checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div>
<DialogSection className="space-y-2">
<FormItemLayout
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
layout="flex"
>
<FormControl>
<Switch
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
/>
</FormControl>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField
key="formatted_size_limit"
name="formatted_size_limit"
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage label="File size limit">
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl>
<Input
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl>
</div>
<div className="col-span-4">
<Select value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger aria-label="File size limit unit" size="small">
<SelectValue>{selectedUnit}</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem key={unit} value={unit} className="text-xs">
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<FormItemLayout
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl>
<Input
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl>
</FormItemLayout>
)}
/>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage>
)}
)}
</DialogSection>
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormItemLayout
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
layout="flex"
>
<FormControl>
<Switch
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
/>
</FormControl>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl>
<Input
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl>
</FormItemLayout>
)}
{isStorageVersioningEnabled && (
<BucketVersioningFields
form={form}
initialVersioningState={versioningSettings.versioning}
initialRetentionDays={versioningSettings.versionExpiryDays}
initialMaxVersions={versioningSettings.maxNoncurrentVersions}
isPublicBucket={isPublicBucket}
isLoadingPolicy={isLoadingLifecycle}
isUnsupportedPolicy={isUnsupportedPolicy}
policyError={lifecycleError}
/>
)}
</DialogSection>
</form>
</Form>
</form>
</Form>
<DialogFooter>
<Button disabled={isUpdating} onClick={closeModal}>
Cancel
</Button>
<Button variant="primary" form={formId} type="submit" loading={isUpdating}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button disabled={isUpdating} onClick={closeModal}>
Cancel
</Button>
<Button
variant="primary"
form={formId}
type="submit"
loading={isUpdating}
// Saving now would write the empty policy the form is still seeded with.
disabled={hasUnreadPolicy}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmationModal
variant="warning"
visible={pendingSuspendValues !== null}
title="Suspend object versioning?"
confirmLabel="Suspend versioning"
confirmLabelLoading="Suspending versioning..."
loading={isUpdating}
onCancel={() => setPendingSuspendValues(null)}
// Cleared by the mutation, so the dialog keeps its loading state while the save runs.
onConfirm={() => {
if (pendingSuspendValues !== null) persistChanges(pendingSuspendValues)
}}
>
<p className="text-sm text-foreground-light">
Overwriting or deleting an object in{' '}
<span className="text-foreground">{bucket?.name}</span> 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.
</p>
</ConfirmationModal>
</>
)
}
@@ -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> = {}): 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()
})
})
@@ -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
@@ -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'
@@ -6,7 +6,7 @@ import {
S3_MAX_NONCURRENT_VERSIONS,
superRefineBucketVersioning,
type BucketVersioningFormValues,
} from './BucketVersioningFields.schema'
} from './FilesBucket.schema'
const values = (
overrides: Partial<BucketVersioningFormValues> = {}
@@ -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<typeof BucketFormSchema>
@@ -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 (
<ProjectContextProvider projectRef="default">
<button tabIndex={0} onClick={() => setOpen(true)}>
Open
</button>
<EditBucketModal
visible={open}
bucket={bucket}
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ProjectContextProvider>
)
}
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(<Page onClose={onClose} />)
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())
})
})
@@ -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', {
@@ -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) {
@@ -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<BucketLifecycle | null> {
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<ReturnType<typeof getBucketLifecycle>>
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',
})
@@ -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<ReturnType<typeof updateBucketLifecycle>>
export const useBucketLifecycleUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
BucketLifecycleUpdateData,
ResponseError,
BucketLifecycleUpdateVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BucketLifecycleUpdateData, ResponseError, BucketLifecycleUpdateVariables>({
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,
})
}
@@ -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<BucketUpdateResult> {
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<UpdateStorageBucketBody> = { 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) {
+2
View File
@@ -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: ({
+19
View File
@@ -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