mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 21:59:16 +03:00
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:
24 files changed
+1587
-450
No files matched your search
+241
@@ -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)
|
||||
})
|
||||
})
|
||||
+133
@@ -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',
|
||||
}
|
||||
}
|
||||
-65
@@ -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`,
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -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>
|
||||
)
|
||||
|
||||
+7
-13
@@ -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}
|
||||
|
||||
+9
-8
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+42
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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'
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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: ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user