From b0227cf9dcfa6abfa53c56a8bae86d1cb2d5e581 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:43:34 +0100 Subject: [PATCH 1/6] fix: custom report gets stuck block after deleting sql snippet (#43210) - Deleted SQL Snippets leave a hanging block that loads forever in custom reports, and its not possible to delete them. - Now you can delete blocks if they get stuck loading - Also shows correct error state when a block couldn't load because the sql snippet was removed ## before - stuck forever CleanShot 2026-02-26 at 13 23 25@2x ## after - show error state - allow user to delete snippet CleanShot 2026-02-26 at 13 23 45@2x --------- Co-authored-by: Joshen Lim --- .../Reports/ReportBlock/ReportBlock.tsx | 22 ++-- .../components/ui/QueryBlock/QueryBlock.tsx | 101 +++++++++--------- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx index d6fe1a27ceef1..34f540c384218 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx @@ -63,8 +63,8 @@ export const ReportBlock = ({ refetchOnWindowFocus: false, refetchOnMount: false, refetchIntervalInBackground: false, - retry: (failureCount: number) => { - if (failureCount >= 2) return false + retry: (failureCount: number, error) => { + if (error.code === 404 || failureCount >= 2) return false return true }, } @@ -151,18 +151,16 @@ export const ReportBlock = ({ ? String(executeSqlError) : undefined } - isExecuting={executeSqlLoading} + isExecuting={!contentError && executeSqlLoading} isWriteQuery={isWriteQuery} actions={ - !isLoadingContent && ( - } - className="w-7 h-7" - onClick={() => onRemoveChart({ metric: { key: item.attribute } })} - tooltip={{ content: { side: 'bottom', text: 'Remove chart' } }} - /> - ) + } + className="w-7 h-7" + onClick={() => onRemoveChart({ metric: { key: item.attribute } })} + tooltip={{ content: { side: 'bottom', text: 'Remove chart' } }} + /> } onExecute={(queryType) => { refetch() diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index c8a4bf9a38c0a..d6d530ebbe37d 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -146,59 +146,62 @@ export const QueryBlock = ({ label={label} badge={isWriteQuery && Write} actions={ - disabled ? null : ( - <> - } - onClick={() => setShowSql(!showSql)} - tooltip={{ - content: { side: 'bottom', text: showSql ? 'Hide query' : 'Show query' }, - }} - /> - {hasResults && ( - { - if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { view: nextView } }) - setChartSettings({ ...chartSettings, view: nextView }) - }} - updateChartConfig={(config) => { - if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: config }) - setChartSettings(config) + <> + {!disabled && ( + <> + } + onClick={() => setShowSql(!showSql)} + tooltip={{ + content: { side: 'bottom', text: showSql ? 'Hide query' : 'Show query' }, }} /> - )} + {hasResults && ( + { + if (onUpdateChartConfig) + onUpdateChartConfig({ chartConfig: { view: nextView } }) + setChartSettings({ ...chartSettings, view: nextView }) + }} + updateChartConfig={(config) => { + if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: config }) + setChartSettings(config) + }} + /> + )} - - } - loading={isExecuting} - disabled={isExecuting || disabled || !sql} - onClick={runSelect} - tooltip={{ - content: { - side: 'bottom', - className: 'max-w-56 text-center', - text: isExecuting - ? 'Query is running. Check the SQL Editor to manage running queries.' - : 'Run query', - }, - }} - /> + + } + loading={isExecuting} + disabled={isExecuting || disabled || !sql} + onClick={runSelect} + tooltip={{ + content: { + side: 'bottom', + className: 'max-w-56 text-center', + text: isExecuting + ? 'Query is running. Check the SQL Editor to manage running queries.' + : 'Run query', + }, + }} + /> + + )} - {actions} - - ) + {actions} + } > {!!showWarning && !blockWriteQueries && ( From 3ab3e999d34674f83859784c22d7e625850447e6 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 27 Feb 2026 11:55:39 +0100 Subject: [PATCH 2/6] feat: require current password on reset password (#43085) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? feature ## What is the current behavior? Change password only requires the new password ## What is the new behavior? The current password is required and must be valid before the password can be changed. Also hides a revealed password if the user clicks out of the input field. ## Additional context Needs the backend flag to be changed, but is safe to merge _before_ backend change is made. Is unsafe to merge _after_ backend flag is changed (there will be a gap where users can't reset password through the dashboard). before: Screenshot 2026-02-23 at 09 58 04 after: Screenshot 2026-02-23 at 09 58 56 before: Screenshot 2026-02-23 at 09 59 53 after: Screenshot 2026-02-23 at 10 00 10 --------- Co-authored-by: Joshen Lim --- .../Account/Preferences/AccountIdentities.tsx | 2 +- .../SignIn/ForgotPasswordWizard.tsx | 9 +- .../interfaces/SignIn/ResetPasswordForm.tsx | 125 ++++++++++++------ .../interfaces/SignIn/SignInMfaForm.tsx | 16 +-- apps/studio/pages/reset-password.tsx | 2 +- 5 files changed, 95 insertions(+), 59 deletions(-) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 588636ebcbe6c..eb40c22ec1787 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -140,7 +140,7 @@ export const AccountIdentities = () => {
{provider === 'email' && ( )} { + const hasUppercase = /[A-Z]/.test(password) + const hasLowercase = /[a-z]/.test(password) + const hasNumber = /[0-9]/.test(password) + const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password) + const isLongEnough = password.length >= 8 + + return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough + }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character') + const passwordSchema = z.object({ - password: z - .string() - .min(1, 'Password is required') - .max(72, 'Password cannot exceed 72 characters') - .refine((password) => { - // Basic password validation - you can enhance this based on your requirements - const hasUppercase = /[A-Z]/.test(password) - const hasLowercase = /[a-z]/.test(password) - const hasNumber = /[0-9]/.test(password) - const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password) - const isLongEnough = password.length >= 8 - - return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough - }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character'), + currentPassword: z.string().min(1, 'Current password is required'), + password: passwordValidation, +}) + +const recoveryPasswordSchema = z.object({ + currentPassword: z.string().optional(), + password: passwordValidation, }) type FormData = z.infer -const ResetPasswordForm = () => { +export const ResetPasswordForm = () => { const router = useRouter() + const { type } = useParams() + const requireCurrentPassword = type === 'change' + const [showConditions, setShowConditions] = useState(false) const [passwordHidden, setPasswordHidden] = useState(true) + const [currentPasswordHidden, setCurrentPasswordHidden] = useState(true) const form = useForm({ - resolver: zodResolver(passwordSchema), - defaultValues: { - password: '', - }, + resolver: zodResolver(requireCurrentPassword ? passwordSchema : recoveryPasswordSchema), + defaultValues: { password: '', currentPassword: '' }, mode: 'onChange', }) const onResetPassword = async (data: FormData) => { const toastId = toast.loading('Saving password...') - const { error } = await auth.updateUser({ password: data.password }) + const { error } = await auth.updateUser({ + password: data.password, + ...(requireCurrentPassword ? { current_password: data.currentPassword } : {}), + }) if (!error) { toast.success('Password saved successfully!', { id: toastId }) @@ -72,16 +77,46 @@ const ResetPasswordForm = () => { return (
+ {requireCurrentPassword && ( + ( + + + : } + type="default" + className="w-7" + onClick={() => setCurrentPasswordHidden((prev) => !prev)} + /> + } + {...field} + onBlur={() => { + field.onBlur() + setCurrentPasswordHidden(true) + }} + /> + + + )} + /> + )} ( - + setShowConditions(true)} @@ -90,27 +125,31 @@ const ResetPasswordForm = () => { - -
- - - - - ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx new file mode 100644 index 0000000000000..c986e14a34c83 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx @@ -0,0 +1,46 @@ +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { HelpCircle } from 'lucide-react' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { cn } from 'ui' + +export const HelpButton = () => { + const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() + const { data: project } = useSelectedProjectQuery() + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + + const isOpen = activeSidebar?.id === SIDEBAR_KEYS.HELP_PANEL + + return ( + { + toggleSidebar(SIDEBAR_KEYS.HELP_PANEL) + // Don't send telemetry event if dropdown is already open + if (!isOpen) { + sendEvent({ + action: 'help_button_clicked', + groups: { project: project?.ref, organization: org?.slug }, + }) + } + }} + tooltip={{ content: { side: 'bottom', text: 'Help' } }} + > + + + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx similarity index 97% rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx index 33171c30f4444..278f35584623b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/router' import SVG from 'react-inlinesvg' import { AiIconAnimation, ButtonGroup, ButtonGroupItem } from 'ui' -import type { HelpOptionId } from './HelpDropdown.constants' -import { HELP_OPTION_IDS } from './HelpDropdown.constants' +import type { HelpOptionId } from './HelpPanel.constants' +import { HELP_OPTION_IDS } from './HelpPanel.constants' const DISCORD_URL = 'https://discord.supabase.com' const STATUS_URL = 'https://status.supabase.com' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.constants.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.constants.ts similarity index 100% rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.constants.ts rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.constants.ts diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx new file mode 100644 index 0000000000000..264450d190d89 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx @@ -0,0 +1,97 @@ +import { IS_PLATFORM } from 'common' +import type { SupportFormUrlKeys } from 'components/interfaces/Support/SupportForm.utils' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { X } from 'lucide-react' +import Image from 'next/image' +import { useRouter } from 'next/router' +import SVG from 'react-inlinesvg' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { Button, cn, Separator } from 'ui' +import styleHandler from 'ui/src/lib/theme/styleHandler' + +import { ASSISTANT_SUGGESTIONS } from './HelpPanel.constants' +import { HelpSection } from './HelpSection' + +export const HelpPanel = ({ + onClose, + projectRef, + supportLinkQueryParams, +}: { + onClose: () => void + projectRef: string | undefined + supportLinkQueryParams: Partial | undefined +}) => { + const snap = useAiAssistantStateSnapshot() + const { openSidebar, closeSidebar } = useSidebarManagerSnapshot() + const router = useRouter() + + const __styles = styleHandler('popover') + + return ( +
+
+ Help & Support + closeSidebar(SIDEBAR_KEYS.HELP_PANEL)} + icon={} + tooltip={{ content: { side: 'bottom', text: 'Close' } }} + /> +
+ { + onClose() + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + snap.newChat(ASSISTANT_SUGGESTIONS) + }} + onSupportClick={onClose} + /> + +
+
+
Community support
+

+ Our Discord community can help with code-related issues. Many questions are answered in + minutes. +

+
+ +
+
+ ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts new file mode 100644 index 0000000000000..95e640f0e3846 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { getSupportLinkQueryParams } from './HelpPanel.utils' + +describe('getSupportLinkQueryParams', () => { + it('returns { projectRef } when project has parent_project_ref', () => { + expect( + getSupportLinkQueryParams( + { parent_project_ref: 'main-project' }, + { slug: 'my-org' }, + 'router-ref' + ) + ).toEqual({ projectRef: 'main-project' }) + }) + + it('returns { projectRef } from routerRef when project has no parent_project_ref', () => { + expect(getSupportLinkQueryParams({}, { slug: 'my-org' }, 'router-ref')).toEqual({ + projectRef: 'router-ref', + }) + }) + + it('returns { projectRef } from routerRef when project is undefined', () => { + expect(getSupportLinkQueryParams(undefined, { slug: 'my-org' }, 'router-ref')).toEqual({ + projectRef: 'router-ref', + }) + }) + + it('returns { orgSlug } when no projectRef (no project, no routerRef)', () => { + expect(getSupportLinkQueryParams(undefined, { slug: 'my-org' }, undefined)).toEqual({ + orgSlug: 'my-org', + }) + }) + + it('returns { orgSlug } when project and routerRef are undefined but org has slug', () => { + expect(getSupportLinkQueryParams(undefined, { slug: 'acme' }, undefined)).toEqual({ + orgSlug: 'acme', + }) + }) + + it('returns undefined when project, org and routerRef give no ref', () => { + expect(getSupportLinkQueryParams(undefined, undefined, undefined)).toBeUndefined() + }) + + it('returns undefined when org has no slug and no projectRef', () => { + expect(getSupportLinkQueryParams(undefined, {}, undefined)).toBeUndefined() + }) + + it('returns undefined when org is undefined and no projectRef', () => { + expect(getSupportLinkQueryParams(undefined, undefined, undefined)).toBeUndefined() + }) + + it('prefers parent_project_ref over routerRef when both are present', () => { + expect( + getSupportLinkQueryParams({ parent_project_ref: 'parent-ref' }, { slug: 'org' }, 'router-ref') + ).toEqual({ projectRef: 'parent-ref' }) + }) +}) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.ts similarity index 100% rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.utils.ts rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.ts diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx similarity index 95% rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx index bc6a246880002..7aea2451dc4f3 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx @@ -1,8 +1,8 @@ import type { SupportFormUrlKeys } from 'components/interfaces/Support/SupportForm.utils' import { cn } from 'ui' -import type { HelpOptionId } from './HelpDropdown.constants' import { HelpOptionsList } from './HelpOptionsList' +import type { HelpOptionId } from './HelpPanel.constants' type HelpSectionProps = { excludeIds?: HelpOptionId[] diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 08f9f317bf2d3..ce5d2e78aebc4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -26,7 +26,7 @@ import { CommandMenuTriggerInput } from 'ui-patterns' import { BreadcrumbsView } from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown' -import { HelpDropdown } from './HelpDropdown/HelpDropdown' +import { HelpButton } from './HelpPanel/HelpButton' import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import { MergeRequestButton } from './MergeRequestButton' @@ -231,7 +231,7 @@ export const LayoutHeader = ({ '[&_.command-shortcut>div]:text-foreground-lighter' )} /> - + {!!projectRef && ( @@ -257,7 +257,7 @@ export const LayoutHeader = ({ [&_.command-shortcut>div]:text-foreground-lighter " /> - + {!!projectRef && ( diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index f653c316d499d..74aa05af224b7 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -1,22 +1,37 @@ -import { useRouter } from 'next/router' -import { parseAsString, useQueryState } from 'nuqs' -import { PropsWithChildren, useEffect } from 'react' - import { LOCAL_STORAGE_KEYS } from 'common' -import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' -import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' -import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import useLatest from 'hooks/misc/useLatest' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect, type PropsWithChildren } from 'react' import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { getSupportLinkQueryParams } from '../LayoutHeader/HelpPanel/HelpPanel.utils' + +const AdvisorPanel = dynamic(() => + import('components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel) +) +const AIAssistant = dynamic(() => + import('components/ui/AIAssistantPanel/AIAssistant').then((m) => m.AIAssistant) +) +const EditorPanel = dynamic(() => + import('components/ui/EditorPanel/EditorPanel').then((m) => m.EditorPanel) +) +const HelpPanel = dynamic(() => + import('components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel').then( + (m) => m.HelpPanel + ) +) + export const SIDEBAR_KEYS = { AI_ASSISTANT: 'ai-assistant', EDITOR_PANEL: 'editor-panel', ADVISOR_PANEL: 'advisor-panel', + HELP_PANEL: 'help-panel', } as const export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { @@ -24,7 +39,7 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() - const { openSidebar, activeSidebar } = useSidebarManagerSnapshot() + const { openSidebar, closeSidebar, activeSidebar } = useSidebarManagerSnapshot() const [sidebarURLParam, setSidebarUrlParam] = useQueryState('sidebar', parseAsString) const [sidebarLocalStorage, setSidebarLocalStorage, { isSuccess: isLoadedLocalStorage }] = @@ -36,6 +51,23 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) + useRegisterSidebar( + SIDEBAR_KEYS.HELP_PANEL, + () => ( + closeSidebar(SIDEBAR_KEYS.HELP_PANEL)} + projectRef={project?.ref} + supportLinkQueryParams={getSupportLinkQueryParams( + project, + org, + router.query.ref as string | undefined + )} + /> + ), + {}, + undefined, + true + ) useEffect(() => { if (!!project) { diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index 889e07bc12443..8872548186be2 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -1,10 +1,11 @@ import { act, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { sidebarManagerState } from 'state/sidebar-manager-state' import { render } from 'tests/helpers' import { routerMock } from 'tests/lib/route-mock' import { ResizablePanel, ResizablePanelGroup } from 'ui' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { MobileSheetProvider } from '../NavigationBar/MobileSheetContext' import { LayoutSidebar } from './index' import { LayoutSidebarProvider, SIDEBAR_KEYS } from './LayoutSidebarProvider' @@ -109,7 +110,9 @@ describe('LayoutSidebar', () => {
- + + + ) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx index 3e3161f0ce447..b8e61d94b516d 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx @@ -1,5 +1,10 @@ +import { useBreakpoint } from 'common' +import { useEffect } from 'react' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { ResizableHandle, ResizablePanel, cn } from 'ui' +import { cn, ResizableHandle, ResizablePanel } from 'ui' +import { MobileSheetNav } from 'ui-patterns' + +import { useMobileSheet } from '../NavigationBar/MobileSheetContext' // Having these params as props as otherwise it's quite hard to visually check the sizes in DefaultLayout // as react resizeable panels requires all these values to be valid to render correctly @@ -14,10 +19,36 @@ export const LayoutSidebar = ({ maxSize = '50', defaultSize = '30', }: LayoutSidebarProps) => { - const { activeSidebar } = useSidebarManagerSnapshot() + const { activeSidebar, closeActive } = useSidebarManagerSnapshot() + const isMobile = useBreakpoint('md') + const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet() + + // On mobile the sidebar content is rendered in MobileSheetNav + useEffect(() => { + if (isMobile && activeSidebar?.component) { + setMobileSheetContent(activeSidebar.id) + } else { + setMobileSheetContent(null) + } + }, [isMobile, activeSidebar, setMobileSheetContent]) if (!activeSidebar?.component) return null + if (isMobile) + return ( + { + if (!open) { + setMobileSheetContent(null) + closeActive() + } + }} + > + {activeSidebar?.component?.()} + + ) + return ( <> diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx index 04b87035fd372..15873138927b0 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx @@ -1,14 +1,12 @@ +import { useParams } from 'common' +import { SidebarContent } from 'components/interfaces/Sidebar' +import { IS_PLATFORM } from 'lib/constants' import { Menu, Search } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' - -import { useParams } from 'common' -import { SidebarContent } from 'components/interfaces/Sidebar' -import { IS_PLATFORM } from 'lib/constants' import { Button, cn } from 'ui' -import { CommandMenuTrigger } from 'ui-patterns' -import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' +import { CommandMenuTrigger, MobileSheetNav } from 'ui-patterns' export const ICON_SIZE = 20 export const ICON_STROKE_WIDTH = 1.5 diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx new file mode 100644 index 0000000000000..72a6f76ff5059 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx @@ -0,0 +1,33 @@ +import type { PropsWithChildren } from 'react' +import { createContext, useCallback, useContext, useState } from 'react' + +export type MobileSheetContentType = null | string + +type MobileSheetContextValue = { + content: MobileSheetContentType + setContent: (content: MobileSheetContentType) => void +} + +const MobileSheetContext = createContext(null) + +export function MobileSheetProvider({ children }: PropsWithChildren) { + const [content, setContentState] = useState(null) + + const setContent = useCallback((next: MobileSheetContentType) => { + setContentState(next) + }, []) + + return ( + + {children} + + ) +} + +export function useMobileSheet(): MobileSheetContextValue { + const ctx = useContext(MobileSheetContext) + if (!ctx) { + throw new Error('useMobileSheet must be used within MobileSheetProvider') + } + return ctx +} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx new file mode 100644 index 0000000000000..423a81f812a2b --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx @@ -0,0 +1,23 @@ +import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { MobileSheetNav } from 'ui-patterns' + +import { useMobileSheet } from './MobileSheetContext' + +export function MobileSheetNavLayout() { + const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet() + const { activeSidebar } = useSidebarManagerSnapshot() + + return ( + { + if (!open) { + setMobileSheetContent(null) + sidebarManagerState.closeActive() + } + }} + > + {activeSidebar?.component?.() ?? null} + + ) +} diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 7286edec5fd23..4eb26581df46f 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2320,7 +2320,7 @@ export interface SidebarOpenedEvent { /** * The sidebar panel that was opened, e.g. ai-assistant, editor-panel, advisor-panel */ - sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel' + sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel' | 'help-panel' } groups: TelemetryGroups } diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx index 1be06287121d5..f7ffdde0b60fd 100644 --- a/packages/ui-patterns/index.tsx +++ b/packages/ui-patterns/index.tsx @@ -16,6 +16,7 @@ export * from './src/FilterBar' export * from './src/GlassPanel' export * from './src/InnerSideMenu' export * from './src/McpUrlBuilder' +export * from './src/MobileSheetNav' export * from './src/PageContainer' export * from './src/PageHeader' export * from './src/PageSection' diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx index 6e06fc0621dd1..7749316d350dd 100644 --- a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx +++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx @@ -1,9 +1,6 @@ 'use client' -import { useRouter } from 'next/router' -import { useEffect } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { useWindowSize } from 'react-use' import { CommandEmpty_Shadcn_, Sheet, SheetContent } from 'ui' import { cn } from 'ui/src/lib/utils' @@ -11,19 +8,8 @@ const MobileSheetNav: React.FC<{ children: React.ReactNode open?: boolean onOpenChange(open: boolean): void -}> = ({ children, open = false, onOpenChange }) => { - const router = useRouter() - const { width } = useWindowSize() - - const pathWithoutQuery = router?.asPath?.split('?')?.[0] - useEffect(() => { - onOpenChange(false) - }, [pathWithoutQuery]) - - useEffect(() => { - onOpenChange(false) - }, [width]) - + className?: string +}> = ({ children, open = false, onOpenChange, className }) => { return ( }>{children} @@ -39,4 +28,5 @@ const MobileSheetNav: React.FC<{ ) } +export { MobileSheetNav } export default MobileSheetNav diff --git a/packages/ui-patterns/src/MobileSheetNav/index.ts b/packages/ui-patterns/src/MobileSheetNav/index.ts index fd58868d48141..06b0a8256915b 100644 --- a/packages/ui-patterns/src/MobileSheetNav/index.ts +++ b/packages/ui-patterns/src/MobileSheetNav/index.ts @@ -1 +1 @@ -export * from './MobileSheetNav' +export { default as MobileSheetNav } from './MobileSheetNav' \ No newline at end of file From affdf865e0d95f92a73bee16eb9a8edd8f981343 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 27 Feb 2026 16:14:57 +0100 Subject: [PATCH 5/6] fix: mobile sheet nav close (#43239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile sheet nav doesn't close on route change and viewport resizing after #43184. This PR fixes that and also makes it optional to close on nav and resizing, because the sidepanels need them _not_ to resize, while mobile navigation menu does. Also added some tests. 🪄 --- .../ProjectLayout/LayoutSidebar/index.tsx | 2 + .../NavigationBar/MobileSheetNavLayout.tsx | 23 -- .../MobileSheetNav/MobileSheetNav.test.tsx | 199 ++++++++++++++++++ .../src/MobileSheetNav/MobileSheetNav.tsx | 30 ++- 4 files changed, 230 insertions(+), 24 deletions(-) delete mode 100644 apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx create mode 100644 packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx index b8e61d94b516d..ac76bf481647d 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx @@ -37,6 +37,8 @@ export const LayoutSidebar = ({ if (isMobile) return ( { if (!open) { diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx deleted file mode 100644 index 423a81f812a2b..0000000000000 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { MobileSheetNav } from 'ui-patterns' - -import { useMobileSheet } from './MobileSheetContext' - -export function MobileSheetNavLayout() { - const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet() - const { activeSidebar } = useSidebarManagerSnapshot() - - return ( - { - if (!open) { - setMobileSheetContent(null) - sidebarManagerState.closeActive() - } - }} - > - {activeSidebar?.component?.() ?? null} - - ) -} diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx new file mode 100644 index 0000000000000..65137313efda0 --- /dev/null +++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx @@ -0,0 +1,199 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import { useEffect, useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { MobileSheetNav } from './MobileSheetNav' + +const mockRouter = vi.hoisted(() => ({ asPath: '/initial' })) +const mockWindowSize = vi.hoisted(() => ({ width: 400 })) + +vi.mock('next/router', () => ({ + useRouter: () => mockRouter, +})) + +vi.mock('react-use', () => ({ + useWindowSize: () => mockWindowSize, +})) + +function MobileSheetNavWithState({ + shouldCloseOnRouteChange = true, + shouldCloseOnViewportResize = true, +}: { + shouldCloseOnRouteChange?: boolean + shouldCloseOnViewportResize?: boolean +}) { + const [open, setOpen] = useState(false) + useEffect(() => { + setOpen(true) + }, []) + return ( + <> + {String(open)} + +
Nav content
+
+ + ) +} + +describe('MobileSheetNav', () => { + const defaultProps = { + onOpenChange: vi.fn(), + children:
Nav content
, + } + + beforeEach(() => { + vi.clearAllMocks() + mockRouter.asPath = '/initial' + mockWindowSize.width = 400 + }) + + describe('shouldCloseOnRouteChange', () => { + it('calls onOpenChange(false) when route changes and shouldCloseOnRouteChange is true (default)', () => { + const onOpenChange = vi.fn() + const { rerender } = render() + + onOpenChange.mockClear() + mockRouter.asPath = '/other-page' + act(() => { + rerender() + }) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('effectively closes the sheet on route change when shouldCloseOnRouteChange is true', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + mockRouter.asPath = '/other-page' + act(() => { + rerender() + }) + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('false') + }) + }) + + it('effectively does NOT close the sheet on route change when shouldCloseOnRouteChange is false', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + mockRouter.asPath = '/other-page' + act(() => { + rerender() + }) + + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + it('does not call onOpenChange when route changes if shouldCloseOnRouteChange is false', () => { + const onOpenChange = vi.fn() + const { rerender } = render( + + ) + + onOpenChange.mockClear() + mockRouter.asPath = '/other-page' + act(() => { + rerender( + + ) + }) + + expect(onOpenChange).not.toHaveBeenCalled() + }) + }) + + describe('shouldCloseOnViewportResize', () => { + it('calls onOpenChange(false) when width changes and shouldCloseOnViewportResize is true (default)', () => { + const onOpenChange = vi.fn() + const { rerender } = render() + + onOpenChange.mockClear() + mockWindowSize.width = 800 + act(() => { + rerender() + }) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('effectively closes the sheet on viewport resize when shouldCloseOnViewportResize is true', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + mockWindowSize.width = 800 + act(() => { + rerender() + }) + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('false') + }) + }) + + it('effectively does NOT close the sheet on viewport resize when shouldCloseOnViewportResize is false', async () => { + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + mockWindowSize.width = 800 + act(() => { + rerender() + }) + + expect(screen.getByTestId('sheet-open')).toHaveTextContent('true') + }) + + it('does not call onOpenChange when width changes if shouldCloseOnViewportResize is false', () => { + const onOpenChange = vi.fn() + const { rerender } = render( + + ) + + onOpenChange.mockClear() + mockWindowSize.width = 800 + act(() => { + rerender( + + ) + }) + + expect(onOpenChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx index 7749316d350dd..064035ac60739 100644 --- a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx +++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx @@ -1,6 +1,9 @@ 'use client' +import { useRouter } from 'next/router' +import { useEffect } from 'react' import { ErrorBoundary } from 'react-error-boundary' +import { useWindowSize } from 'react-use' import { CommandEmpty_Shadcn_, Sheet, SheetContent } from 'ui' import { cn } from 'ui/src/lib/utils' @@ -9,7 +12,32 @@ const MobileSheetNav: React.FC<{ open?: boolean onOpenChange(open: boolean): void className?: string -}> = ({ children, open = false, onOpenChange, className }) => { + shouldCloseOnRouteChange?: boolean + shouldCloseOnViewportResize?: boolean +}> = ({ + children, + open = false, + onOpenChange, + className, + shouldCloseOnRouteChange = true, + shouldCloseOnViewportResize = true, +}) => { + const router = useRouter() + const { width } = useWindowSize() + + const pathWithoutQuery = router?.asPath?.split('?')?.[0] + useEffect(() => { + if (shouldCloseOnRouteChange) { + onOpenChange(false) + } + }, [pathWithoutQuery]) + + useEffect(() => { + if (shouldCloseOnViewportResize) { + onOpenChange(false) + } + }, [width]) + return ( Date: Fri, 27 Feb 2026 10:42:07 -0500 Subject: [PATCH 6/6] refactor: move storage utils out of valtio + write tests (#43225) Refactor ## What is the current behavior? Many utility functions are in the Valtio object which is overly large and complex. ## What is the new behavior? Some utility functions are moved out and tested. No behaviour has been changed, they've just been moved with any necessary changes to arguments. --- .../StorageExplorer.utils.test.ts | 277 ++++++++++++++++++ .../StorageExplorer/StorageExplorer.utils.tsx | 101 +++++++ .../Storage/StorageExplorer/useCopyUrl.tsx | 8 +- .../StorageExplorer/useFetchFileUrlQuery.tsx | 5 +- apps/studio/state/storage-explorer.tsx | 101 +------ 5 files changed, 397 insertions(+), 95 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts new file mode 100644 index 0000000000000..478f04475e47b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts @@ -0,0 +1,277 @@ +import { toast } from 'sonner' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + STORAGE_ROW_STATUS, + STORAGE_ROW_TYPES, +} from '@/components/interfaces/Storage/Storage.constants' +import type { StorageItem } from '@/components/interfaces/Storage/Storage.types' +import { + getPathAlongFoldersToIndex, + getPathAlongOpenedFolders, + sanitizeNameForDuplicateInColumn, + validateFolderName, +} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils' + +function makeBucket(name: string) { + return { + id: name, + name, + owner: 'owner', + public: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + } +} + +function makeFolder(name: string): StorageItem { + return { + id: null, + name, + type: STORAGE_ROW_TYPES.FOLDER, + status: STORAGE_ROW_STATUS.READY, + metadata: null, + isCorrupted: false, + created_at: null, + updated_at: null, + last_accessed_at: null, + } +} + +describe('validateFolderName', () => { + describe('valid names', () => { + it('accepts plain alphanumeric names', () => { + expect(validateFolderName('myfolder')).toBeNull() + expect(validateFolderName('MyFolder123')).toBeNull() + }) + + it('accepts names with underscores and hyphens', () => { + expect(validateFolderName('my_folder')).toBeNull() + expect(validateFolderName('my-folder')).toBeNull() + }) + + it('accepts names with dots', () => { + expect(validateFolderName('my.folder')).toBeNull() + }) + + it('accepts names with spaces', () => { + expect(validateFolderName('my folder')).toBeNull() + }) + + it('accepts names with allowed special characters', () => { + expect(validateFolderName('folder!')).toBeNull() + expect(validateFolderName("folder'")).toBeNull() + expect(validateFolderName('folder(1)')).toBeNull() + expect(validateFolderName('folder*')).toBeNull() + expect(validateFolderName('folder&name')).toBeNull() + expect(validateFolderName('folder$name')).toBeNull() + expect(validateFolderName('folder@name')).toBeNull() + expect(validateFolderName('folder=name')).toBeNull() + expect(validateFolderName('folder;name')).toBeNull() + expect(validateFolderName('folder:name')).toBeNull() + expect(validateFolderName('folder+name')).toBeNull() + expect(validateFolderName('folder,name')).toBeNull() + expect(validateFolderName('folder?name')).toBeNull() + }) + + it('accepts names with forward slashes', () => { + expect(validateFolderName('parent/child')).toBeNull() + }) + + it('accepts an empty string', () => { + expect(validateFolderName('')).toBeNull() + }) + }) + + describe('invalid names', () => { + it('rejects a name containing #', () => { + const result = validateFolderName('my#folder') + expect(result).toBe('Folder name cannot contain the "#" character') + }) + + it('rejects a name containing %', () => { + const result = validateFolderName('my%folder') + expect(result).toBe('Folder name cannot contain the "%" character') + }) + + it('rejects a name containing ^', () => { + const result = validateFolderName('my^folder') + expect(result).toBe('Folder name cannot contain the "^" character') + }) + + it('rejects a name containing [', () => { + const result = validateFolderName('my[folder') + expect(result).toBe('Folder name cannot contain the "[" character') + }) + }) +}) + +describe('getPathAlongOpenedFolders', () => { + const selectedBucket = makeBucket('my-bucket') + + it('returns only the bucket name when there are no opened folders and includeBucket=true', () => { + expect(getPathAlongOpenedFolders({ openedFolders: [], selectedBucket })).toBe('my-bucket') + }) + + it('returns bucket/folder when one folder is open and includeBucket=true', () => { + expect( + getPathAlongOpenedFolders({ openedFolders: [makeFolder('images')], selectedBucket }) + ).toBe('my-bucket/images') + }) + + it('returns the full path when multiple folders are open and includeBucket=true', () => { + const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')] + expect(getPathAlongOpenedFolders({ openedFolders, selectedBucket })).toBe( + 'my-bucket/images/2024/january' + ) + }) + + it('returns an empty string when there are no opened folders and includeBucket=false', () => { + expect(getPathAlongOpenedFolders({ openedFolders: [], selectedBucket }, false)).toBe('') + }) + + it('returns the folder path without the bucket when includeBucket=false', () => { + const openedFolders = [makeFolder('images'), makeFolder('2024')] + expect(getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)).toBe('images/2024') + }) +}) + +describe('getPathAlongFoldersToIndex', () => { + const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')] + + it('returns an empty string for index 0', () => { + expect(getPathAlongFoldersToIndex({ openedFolders }, 0)).toBe('') + }) + + it('returns the first folder name for index 1', () => { + expect(getPathAlongFoldersToIndex({ openedFolders }, 1)).toBe('images') + }) + + it('returns folders joined up to (not including) the given index', () => { + expect(getPathAlongFoldersToIndex({ openedFolders }, 2)).toBe('images/2024') + expect(getPathAlongFoldersToIndex({ openedFolders }, 3)).toBe('images/2024/january') + }) + + it('returns an empty string for an empty openedFolders array', () => { + expect(getPathAlongFoldersToIndex({ openedFolders: [] }, 5)).toBe('') + }) +}) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +describe('sanitizeNameForDuplicateInColumn', () => { + // Reset mock call counts between tests + beforeEach(() => vi.mocked(toast.error).mockClear()) + + // Build a state with one column per array of item overrides. + // e.g. makeState([['a.txt'], ['b.txt', 'c.txt']]) → two columns + function makeState(columns: Array>>) { + return { + columns: columns.map((columnItems, i) => ({ + id: null, + name: `col-${i}`, + status: STORAGE_ROW_STATUS.READY, + items: columnItems.map((overrides) => ({ + id: 'file-id', + name: 'file.txt', + type: STORAGE_ROW_TYPES.FILE, + status: STORAGE_ROW_STATUS.READY, + metadata: null, + isCorrupted: false, + created_at: null, + updated_at: null, + last_accessed_at: null, + ...overrides, + })), + })), + } + } + + it('returns the original name when there is no conflict', () => { + const state = makeState([[{ name: 'other.txt' }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt') + }) + + it('is case-insensitive when detecting duplicates', () => { + const state = makeState([[{ name: 'FILE.TXT' }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBeNull() + expect(toast.error).toHaveBeenCalled() + }) + + it('skips items that are currently being edited', () => { + const state = makeState([[{ name: 'file.txt', status: STORAGE_ROW_STATUS.EDITING }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt') + }) + + describe('columnIndex', () => { + // Two-column state: col 0 has 'file.txt', col 1 has 'other.txt' + const state = makeState([[{ name: 'file.txt' }], [{ name: 'other.txt' }]]) + + it('defaults to the last column when columnIndex is omitted', () => { + // col 1 has 'other.txt', not 'file.txt' → no conflict + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt') + }) + + it('uses the explicitly provided columnIndex', () => { + // col 0 has 'file.txt' → conflict + expect( + sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 0 }) + ).toBeNull() + expect(toast.error).toHaveBeenCalled() + }) + + it('only checks the specified column, ignoring conflicts in other columns', () => { + // col 1 has 'other.txt' but not 'file.txt' → no conflict at columnIndex 1 + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 1 })).toBe( + 'file.txt' + ) + }) + + it('detects a conflict in the first column when columnIndex is 0', () => { + // col 0 has 'file.txt' → conflict + expect(sanitizeNameForDuplicateInColumn(state, { name: 'other.txt', columnIndex: 0 })).toBe( + 'other.txt' + ) // 'other.txt' is not in col 0 + expect( + sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 0 }) + ).toBeNull() + }) + }) + + describe('autofix: false (default)', () => { + it('shows an error toast and returns null on conflict', () => { + const state = makeState([[{ name: 'file.txt' }]]) + const result = sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: false }) + expect(result).toBeNull() + expect(toast.error).toHaveBeenCalledWith( + 'The name file.txt already exists in the current directory. Please use a different name.' + ) + }) + }) + + describe('autofix: true', () => { + it('appends (1) to a file name with no prior duplicates', () => { + const state = makeState([[{ name: 'file.txt' }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: true })).toBe( + 'file (1).txt' + ) + }) + + it('appends (2) when one auto-named duplicate already exists', () => { + const state = makeState([[{ name: 'file.txt' }, { name: 'file (1).txt' }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: true })).toBe( + 'file (2).txt' + ) + }) + + it('treats the whole name as the extension when there is no dot (existing behaviour)', () => { + // NOTE: the function splits on '.' and always treats the last segment as the + // extension, so a dotless name produces " (1).myfile" rather than "myfile (1)". + // This is a known quirk of the implementation — not a regression. + const state = makeState([[{ name: 'myfile' }]]) + expect(sanitizeNameForDuplicateInColumn(state, { name: 'myfile', autofix: true })).toBe( + ' (1).myfile' + ) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx index bd1af77e8f1ae..bc8dbe73db08f 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -2,8 +2,10 @@ import { toast } from 'sonner' import { StorageObject } from 'data/storage/bucket-objects-list-mutation' import { copyToClipboard } from 'ui' +import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucketModal.utils' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' import { StorageItem, StorageItemMetadata } from '../Storage.types' +import type { StorageExplorerState } from '@/state/storage-explorer' type UploadProgress = { percentage: number @@ -16,6 +18,105 @@ type UploadProgress = { const CORRUPTED_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes export const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = '.emptyFolderPlaceholder' +/** + * Returns the path to the current folder, optionally prefixed with the bucket name. + */ +export function getPathAlongOpenedFolders( + state: Pick, + includeBucket = true +): string { + if (includeBucket) { + return state.openedFolders.length > 0 + ? `${state.selectedBucket.name}/${state.openedFolders.map((folder) => folder.name).join('/')}` + : state.selectedBucket.name + } + return state.openedFolders.map((folder) => folder.name).join('/') +} + +/** + * Returns the path to the folder at the given index in the openedFolders array, + * joining all folders from the root up to (but not including) the given index. + */ +export function getPathAlongFoldersToIndex( + state: Pick, + index: number +): string { + return state.openedFolders + .slice(0, index) + .map((folder) => folder.name) + .join('/') +} + +/** + * Returns an error message string if the folder name contains invalid characters, + * or null if the name is valid. + */ +export function validateFolderName(name: string): string | null { + if (!validObjectKeyRegex.test(name)) { + const [match] = name.match(inverseValidObjectKeyRegex) ?? [] + return !!match + ? `Folder name cannot contain the "${match}" character` + : 'Folder name contains an invalid special character' + } + return null +} + +/** + * Checks whether `name` already exists in the column (case-insensitive). + * - When `autofix` is false and a duplicate is found, shows an error toast and returns null. + * - When `autofix` is true and a duplicate is found, appends a numeric suffix and returns the new name. + * - Returns the original name when there is no conflict. + * + * When `columnIndex` is omitted it defaults to the last column. + */ +export function sanitizeNameForDuplicateInColumn( + state: Pick, + { + name, + columnIndex, + autofix = false, + }: { + name: string + columnIndex?: number + autofix?: boolean + } +): string | null { + const columnIndex_ = columnIndex !== undefined ? columnIndex : state.columns.length - 1 + const currentColumn = state.columns[columnIndex_] + const currentColumnItems = currentColumn.items.filter( + (item) => item.status !== STORAGE_ROW_STATUS.EDITING + ) + // [Joshen] JFYI storage does support folders of the same name with different casing + // but its an issue with the List V1 endpoint that's causing an issue with fetching contents + // for folders of the same name with different casing + // We should remove this check once all projects are on the List V2 endpoint + const hasSameNameInColumn = + currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length > 0 + + if (hasSameNameInColumn) { + if (autofix) { + const fileNameSegments = name.split('.') + const fileName = fileNameSegments.slice(0, fileNameSegments.length - 1).join('.') + const fileExt = fileNameSegments[fileNameSegments.length - 1] + + const dupeNameRegex = new RegExp(`${fileName} \\([-0-9]+\\)${fileExt ? '.' + fileExt : ''}$`) + const itemsWithSameNameInColumn = currentColumnItems.filter((item) => + item.name.match(dupeNameRegex) + ) + + const updatedFileName = fileName + ` (${itemsWithSameNameInColumn.length + 1})` + return fileExt ? `${updatedFileName}.${fileExt}` : updatedFileName + } else { + toast.error( + `The name ${name} already exists in the current directory. Please use a different name.` + ) + return null + } + } + + return name +} + export const copyPathToFolder = ( openedFolders: StorageItem[], item: StorageItem & { columnIndex: number } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx index 44a9f3b812531..ed94cfbddcc7d 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx @@ -6,11 +6,11 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { copyToClipboard } from 'ui' import { URL_EXPIRY_DURATION } from '../Storage.constants' +import { getPathAlongOpenedFolders } from './StorageExplorer.utils' import { fetchFileUrl } from './useFetchFileUrlQuery' export const useCopyUrl = () => { - const { projectRef, selectedBucket, getPathAlongOpenedFolders } = - useStorageExplorerStateSnapshot() + const { projectRef, selectedBucket, openedFolders } = useStorageExplorerStateSnapshot() const { data: customDomainData } = useCustomDomainsQuery({ projectRef: projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef: projectRef }) @@ -20,7 +20,7 @@ export const useCopyUrl = () => { const getFileUrl = useCallback( (fileName: string, expiresIn?: URL_EXPIRY_DURATION) => { - const pathToFile = getPathAlongOpenedFolders(false) + const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false) const formattedPathToFile = [pathToFile, fileName].join('/') return fetchFileUrl( @@ -31,7 +31,7 @@ export const useCopyUrl = () => { expiresIn ) }, - [projectRef, selectedBucket.id, selectedBucket.public, getPathAlongOpenedFolders] + [projectRef, selectedBucket, openedFolders] ) const onCopyUrl = useCallback( diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx index 74579a90e6280..289303c4b9593 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx @@ -5,6 +5,7 @@ import { Bucket } from 'data/storage/buckets-query' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import type { ResponseError, UseCustomQueryOptions } from 'types' import { StorageItem } from '../Storage.types' +import { getPathAlongOpenedFolders } from './StorageExplorer.utils' const DEFAULT_EXPIRY = 7 * 24 * 60 * 60 // in seconds, default to 1 week @@ -43,8 +44,8 @@ export const useFetchFileUrlQuery = ( { file, projectRef, bucket }: UseFileUrlQueryVariables, { ...options }: UseCustomQueryOptions = {} ) => { - const { getPathAlongOpenedFolders } = useStorageExplorerStateSnapshot() - const pathToFile = getPathAlongOpenedFolders(false) + const { openedFolders, selectedBucket } = useStorageExplorerStateSnapshot() + const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false) const formattedPathToFile = [pathToFile, file?.name].join('/') return useQuery({ diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index fa1a9590e69e0..e4fcede7007a1 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -8,10 +8,6 @@ import * as tus from 'tus-js-client' import { Button, SONNER_DEFAULT_DURATION, SonnerProgress } from 'ui' import { proxy, useSnapshot } from 'valtio' -import { - inverseValidObjectKeyRegex, - validObjectKeyRegex, -} from '@/components/interfaces/Storage/CreateBucketModal.utils' import { STORAGE_BUCKET_SORT, STORAGE_ROW_STATUS, @@ -32,6 +28,10 @@ import { formatFolderItems, formatTime, getFilesDataTransferItems, + getPathAlongFoldersToIndex, + getPathAlongOpenedFolders, + sanitizeNameForDuplicateInColumn, + validateFolderName, } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils' import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { InlineLink } from '@/components/ui/InlineLink' @@ -73,7 +73,7 @@ const DEFAULT_PREFERENCES = { } const STORAGE_PROGRESS_INFO_TEXT = "Do not close the browser until it's completed" -let abortController: any +let abortController: AbortController if (typeof window !== 'undefined') { abortController = new AbortController() } @@ -100,7 +100,6 @@ function createStorageExplorerState({ resumableUploadUrl, uploadProgresses: [] as UploadProgress[], - // abortController, abortApiCalls: () => { if (abortController) { abortController.abort() @@ -133,7 +132,6 @@ function createStorageExplorerState({ popOpenedFolders: () => { state.openedFolders = state.openedFolders.slice(0, state.openedFolders.length - 1) }, - popOpenedFoldersAtIndex: (index: number) => { state.openedFolders = state.openedFolders.slice(0, index + 1) }, @@ -241,33 +239,6 @@ function createStorageExplorerState({ // ======== Folders CRUD ======== - getPathAlongOpenedFolders: (includeBucket = true) => { - if (includeBucket) { - return state.openedFolders.length > 0 - ? `${state.selectedBucket.name}/${state.openedFolders.map((folder) => folder.name).join('/')}` - : state.selectedBucket.name - } - return state.openedFolders.map((folder) => folder.name).join('/') - }, - - getPathAlongFoldersToIndex: (index: number) => { - return state.openedFolders - .slice(0, index) - .map((folder) => folder.name) - .join('/') - }, - - validateFolderName: (name: string) => { - if (!validObjectKeyRegex.test(name)) { - const [match] = name.match(inverseValidObjectKeyRegex) ?? [] - return !!match - ? `Folder name cannot contain the "${match}" character` - : 'Folder name contains an invalid special character' - } - - return null - }, - addNewFolderPlaceholder: (columnIndex: number) => { const isPrepend = true const folderName = 'Untitled folder' @@ -293,7 +264,7 @@ function createStorageExplorerState({ onError?: () => void }) => { const autofix = false - const formattedName = state.sanitizeNameForDuplicateInColumn({ + const formattedName = sanitizeNameForDuplicateInColumn(state, { name: folderName, autofix, columnIndex, @@ -308,7 +279,7 @@ function createStorageExplorerState({ return state.removeTempRows(columnIndex) } - const folderNameError = state.validateFolderName(formattedName) + const folderNameError = validateFolderName(formattedName) if (folderNameError) { onError?.() return toast.error(folderNameError) @@ -640,7 +611,7 @@ function createStorageExplorerState({ }) } - const folderNameError = state.validateFolderName(newName) + const folderNameError = validateFolderName(newName) if (folderNameError) { onError?.() return toast.error(folderNameError) @@ -1103,7 +1074,7 @@ function createStorageExplorerState({ const path = file.path.split('/') const topLevelFolder = path.length > 1 ? path[0] : null if (topLevelFolders.includes(topLevelFolder as string)) { - const newTopLevelFolder = state.sanitizeNameForDuplicateInColumn({ + const newTopLevelFolder = sanitizeNameForDuplicateInColumn(state, { name: topLevelFolder as string, autofix, columnIndex, @@ -1144,7 +1115,7 @@ function createStorageExplorerState({ const isWithinFolder = (file?.path ?? '').split('/').length > 1 const fileName = !isWithinFolder - ? state.sanitizeNameForDuplicateInColumn({ name: file.name, autofix }) + ? sanitizeNameForDuplicateInColumn(state, { name: file.name, autofix }) : file.name const unsanitizedFormattedFileName = has(file, ['path']) && isWithinFolder ? file.path : fileName @@ -1707,7 +1678,7 @@ function createStorageExplorerState({ columnIndex, updatedName: newName, }) - const pathToFile = state.getPathAlongFoldersToIndex(columnIndex) + const pathToFile = getPathAlongFoldersToIndex(state, columnIndex) const fromPath = pathToFile.length > 0 ? `${pathToFile}/${originalName}` : originalName const toPath = pathToFile.length > 0 ? `${pathToFile}/${newName}` : newName @@ -1777,54 +1748,6 @@ function createStorageExplorerState({ } }, - sanitizeNameForDuplicateInColumn: ({ - name, - columnIndex, - autofix = false, - }: { - name: string - columnIndex?: number - autofix?: boolean - }) => { - const columnIndex_ = columnIndex !== undefined ? columnIndex : state.getLatestColumnIndex() - const currentColumn = state.columns[columnIndex_] - const currentColumnItems = currentColumn.items.filter( - (item) => item.status !== STORAGE_ROW_STATUS.EDITING - ) - // [Joshen] JFYI storage does support folders of the same name with different casing - // but its an issue with the List V1 endpoint that's causing an issue with fetching contents - // for folders of the same name with different casing - // We should remove this check once all projects are on the List V2 endpoint - const hasSameNameInColumn = - currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length > - 0 - - if (hasSameNameInColumn) { - if (autofix) { - const fileNameSegments = name.split('.') - const fileName = fileNameSegments.slice(0, fileNameSegments.length - 1).join('.') - const fileExt = fileNameSegments[fileNameSegments.length - 1] - - const dupeNameRegex = new RegExp( - `${fileName} \\([-0-9]+\\)${fileExt ? '.' + fileExt : ''}$` - ) - const itemsWithSameNameInColumn = currentColumnItems.filter((item) => - item.name.match(dupeNameRegex) - ) - - const updatedFileName = fileName + ` (${itemsWithSameNameInColumn.length + 1})` - return fileExt ? `${updatedFileName}.${fileExt}` : updatedFileName - } else { - toast.error( - `The name ${name} already exists in the current directory. Please use a different name.` - ) - return null - } - } - - return name - }, - addTempRow: ({ type, name, @@ -1927,7 +1850,7 @@ function createStorageExplorerState({ return state } -type StorageExplorerState = ReturnType +export type StorageExplorerState = ReturnType const DEFAULT_STATE_CONFIG = { projectRef: '',