From 86755ba1fd8ffcf8af7a6d8fc72fee8992f95e06 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 25 Nov 2025 13:37:34 -0700 Subject: [PATCH 01/58] feat: Edge functions search and sorting (#40796) * added sorting and search for edge functions * updated logic * updated types * updated types * updated search bar * updated to remove unneeded map --- .../EdgeFunctionsSortDropdown.tsx | 88 ++++++++ .../pages/project/[ref]/functions/index.tsx | 201 ++++++++++++------ 2 files changed, 228 insertions(+), 61 deletions(-) create mode 100644 apps/studio/components/interfaces/EdgeFunctions/EdgeFunctionsSortDropdown.tsx diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctionsSortDropdown.tsx b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctionsSortDropdown.tsx new file mode 100644 index 00000000000..2c1368d4b7f --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctionsSortDropdown.tsx @@ -0,0 +1,88 @@ +import { ArrowDownNarrowWide, ArrowDownWideNarrow } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from 'ui' + +export const EDGE_FUNCTIONS_SORT_VALUES = [ + 'name:asc', + 'name:desc', + 'created_at:asc', + 'created_at:desc', + 'updated_at:asc', + 'updated_at:desc', +] as const + +export type EdgeFunctionsSort = (typeof EDGE_FUNCTIONS_SORT_VALUES)[number] +export type EdgeFunctionsSortColumn = EdgeFunctionsSort extends `${infer Column}:${string}` + ? Column + : unknown +export type EdgeFunctionsSortOrder = EdgeFunctionsSort extends `${string}:${infer Order}` + ? Order + : unknown + +interface EdgeFunctionsSortDropdownProps { + value: EdgeFunctionsSort + onChange: (value: EdgeFunctionsSort) => void +} + +function getSortLabel(value: EdgeFunctionsSort) { + const [sortCol] = value.split(':') + return sortCol.replace('_', ' ') +} + +export const EdgeFunctionsSortDropdown = ({ value, onChange }: EdgeFunctionsSortDropdownProps) => { + return ( + + + + + + onChange(val as EdgeFunctionsSort)} + > + + Sort by name + + Ascending + Descending + + + + Sort by created at + + Ascending + Descending + + + + Sort by updated at + + Ascending + Descending + + + + + + ) +} diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 90a558c8459..3601eb75a6e 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -1,8 +1,16 @@ -import { ExternalLink } from 'lucide-react' -import React from 'react' +import { ExternalLink, Search, X } from 'lucide-react' +import React, { useMemo, useState } from 'react' +import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import { useParams } from 'common' import { DeployEdgeFunctionButton } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionButton' +import { + EdgeFunctionsSort, + EdgeFunctionsSortColumn, + EdgeFunctionsSortDropdown, + EdgeFunctionsSortOrder, + EDGE_FUNCTIONS_SORT_VALUES, +} from 'components/interfaces/EdgeFunctions/EdgeFunctionsSortDropdown' import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem' import { FunctionsEmptyState, @@ -16,7 +24,17 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { DOCS_URL, IS_PLATFORM } from 'lib/constants' import type { NextPageWithLayout } from 'types' -import { Button, Card, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui' +import { + Button, + Card, + Input_Shadcn_, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -27,6 +45,7 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { Input } from 'ui-patterns/DataInputs/Input' const EdgeFunctionsPage: NextPageWithLayout = () => { const { ref } = useParams() @@ -38,6 +57,36 @@ const EdgeFunctionsPage: NextPageWithLayout = () => { isSuccess, } = useEdgeFunctionsQuery({ projectRef: ref }) + const [search, setSearch] = useQueryState('search', parseAsString.withDefault('')) + const [sort, setSortQueryParam] = useQueryState( + 'sort', + parseAsStringLiteral(EDGE_FUNCTIONS_SORT_VALUES).withDefault('name:asc') + ) + + const filteredFunctions = useMemo(() => { + const temp = (functions ?? []).filter((x) => + x.name.toLowerCase().includes(search.toLowerCase()) + ) + const [sortCol, sortOrder] = sort.split(':') as [ + EdgeFunctionsSortColumn, + EdgeFunctionsSortOrder, + ] + const orderMultiplier = sortOrder === 'asc' ? 1 : -1 + + return temp.sort((a, b) => { + if (sortCol === 'name') { + return a.name.localeCompare(b.name) * orderMultiplier + } + if (sortCol === 'created_at') { + return (a.created_at - b.created_at) * orderMultiplier + } + if (sortCol === 'updated_at') { + return (a.updated_at - b.updated_at) * orderMultiplier + } + return 0 + }) + }, [functions, search, sort]) + const hasFunctions = (functions ?? []).length > 0 return ( @@ -51,28 +100,68 @@ const EdgeFunctionsPage: NextPageWithLayout = () => { {isSuccess && ( <> {hasFunctions ? ( - - - - - Name - URL - Created - Last updated - Deployments - - +
+
+
+
+ } + size="tiny" + className="w-32 md:w-64 pl-8 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2" + value={search} + onChange={(event) => setSearch(event.target.value)} + actions={[ + search && ( +
+
+
+ +
+
+ +
+ + + Name + URL + Created + Last updated + Deployments + + - - <> - {functions.length > 0 && - functions.map((item) => ( - - ))} - - -
-
+ + <> + {filteredFunctions.length > 0 ? ( + filteredFunctions.map((item) => ( + + )) + ) : ( + + +

No results found

+

+ Your search for "{search}" did not return any results +

+
+
+ )} + +
+ + + ) : ( )} @@ -89,46 +178,36 @@ const EdgeFunctionsPage: NextPageWithLayout = () => { } EdgeFunctionsPage.getLayout = (page: React.ReactElement) => { - const EdgeFunctionsPageLayout = () => { - const secondaryActions = [ - , - , - ] - - return ( -
- - - - Edge Functions - - Deploy edge functions to handle complex business logic - - - - {secondaryActions.map((action) => action)} - {IS_PLATFORM && } - - - - - {page} -
- ) - } - return ( - +
+ + + + Edge Functions + + Deploy edge functions to handle complex business logic + + + + + + {IS_PLATFORM && } + + + + + {page} +
) From 8f8f57b8c68b98b903716f7ff9a788857188ebff Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:51:23 -0500 Subject: [PATCH 02/58] feat: show published studio tags in workflow summary (#40802) * feat: test workflow with GitHub step summary * fix: run test workflow on commit * feat: tweak summary * fix: format * feat: format as table * fix: wording * fix: format * feat: remote test workflow, add summary to `publish_image.yml` * fix: wording --- .github/workflows/publish_image.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/publish_image.yml b/.github/workflows/publish_image.yml index 40c142134ab..9865b284630 100644 --- a/.github/workflows/publish_image.yml +++ b/.github/workflows/publish_image.yml @@ -124,6 +124,12 @@ jobs: docker buildx imagetools create -t supabase/studio:latest \ supabase/studio@${{ needs.release_x86.outputs.image_digest }} \ supabase/studio@${{ needs.release_arm.outputs.image_digest }} + echo "Published Registry Images" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Image | Link |" >> $GITHUB_STEP_SUMMARY + echo "|-------|------|" >> $GITHUB_STEP_SUMMARY + echo "| \`supabase/studio:${{ needs.settings.outputs.image_version }}\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=${{ needs.settings.outputs.image_version }}) |" >> $GITHUB_STEP_SUMMARY + echo "| \`supabase/studio:latest\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=latest) |" >> $GITHUB_STEP_SUMMARY publish: needs: From 74fa49d4bdbe0c5f675f144f7702919a041bbe28 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 26 Nov 2025 10:48:58 +1100 Subject: [PATCH 03/58] fix(studio): keyboard focus on button (#40458) * add necessary prop to main button component * remove redundant props * docs * docs improvements * typo * tabbable sidebar items * aria-label and aria-hidden * update docs * clarification * fix link --------- Co-authored-by: Ali Waseem --- .../content/docs/accessibility.mdx | 75 +++++++++++++++---- .../content/docs/components/button.mdx | 10 +++ .../CreateAnalyticsBucketModal.tsx | 1 - .../interfaces/Storage/FilesBuckets/index.tsx | 2 +- .../CreateVectorBucketDialog.tsx | 1 - packages/ui/src/components/Button/Button.tsx | 9 ++- .../ui/src/components/shadcn/ui/sidebar.tsx | 8 ++ 7 files changed, 87 insertions(+), 19 deletions(-) diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx index 7d2fd7ad637..8677b350c52 100644 --- a/apps/design-system/content/docs/accessibility.mdx +++ b/apps/design-system/content/docs/accessibility.mdx @@ -21,18 +21,31 @@ About to push some code? At a minimum, check your work against this list: ## Focus management -All interactive page elements should be reachable by keyboard. They should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly. +All interactive page elements should be reachable by keyboard. Given the below inconsistency between devices and browsers, add `tabIndex={0}` to all buttons, links, and non-text inputs, ideally at the component level. Consider tying the state of `tabIndex` to the `disabled` state of a component, if applicable. -```jsx - -

{name}

- -
+Chromium-based browsers and Firefox handle this automatically via the Tab key. Safari, by default, requires the Option key to also be held down. Enabling _Keyboard navigation_ on macOS Settings [removes this requirement](https://mayank.co/blog/safari-focus/#keyboard-navigation) but makes links non-tabbable as a result. + +Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly. + +[Button](../components/button) has all of the above built-in. Bespoke interactive elements however, such as the below clickable [TableRow](../components/table), require these props to be added manually: + +```tsx showLineNumbers {3, 5-11} + handleBucketNavigation(name, event)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleBucketNavigation(name, event) + } + }} + tabIndex={0} +> + +

{name}

+
+
``` Consider also affordances like `ctrl` and `meta` key support for opening in a new tab. Anything that you can do with a mouse input should be replicable by keyboard. @@ -51,13 +64,45 @@ Some keyboard-navigable content may be contain hundreds or thousands of items. H - Pagination or virtualization - “Jump to” shortcuts to skip ahead -## Screen reader support +## Screen readers -Textual elements are supported out-of-the-box by screen readers. Imagery of course should be described by `alt` tags. +Textual elements are supported out-of-the-box by screen readers. -Less obvious however are scaffolding elements that only makes sense visually, when paired with other content. For example: a table column for actions may not have a visual _Actions_ label because its purpose is obvious to a sighted person. For everyone else’s sake, this column should be titled with `sr-only` text: +### Imagery -```jsx +Images should have their contents described with an `alt` attribute. Write an objective description of the content rather than its context. For example: + +```tsx showLineNumbers {2} +// Correct: painting a picture with words +A tricolor beagle galloping through a grassy field, ears in the air + +// Incorrect: Unhelpful context +Our logo +``` + +Icons and other visual elements that aren’t strictly images should use the `aria-label` attribute. For example: + +```tsx showLineNumbers {2} + + + +``` + +Visual elements that are _purely_ visual aids may be removed from the accessibility tree via the `aria-hidden` attribute. For example: + +```tsx showLineNumbers {2} + + + +``` + +Never use `aria-hidden={true}` on focusable elements, since these are critical pieces of functionality. + +### Scaffolding + +Some scaffolding elements only make sense visually, in the context of surrounding visual content. For example: a table column for actions may not have a visual _Actions_ label because its purpose is obvious (by nearby contents) to a sighted person. For everyone else’s sake, this column should be titled with `sr-only` text: + +```tsx showLineNumbers {2} Actions diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index 53c183989c5..2fe36168c07 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -122,3 +122,13 @@ Displaying only an Icon in a button. Supports slot behavior with `asChild` prop. + +## Accessibility + +[Keyboard focus](../accessibility#focus-management) is automatically handled: + +- Enabled buttons default to `tabIndex={0}` (keyboard accessible) +- Disabled buttons default to `tabIndex={-1}` (removed from tab order) +- You can still override with an explicit `tabIndex` prop when needed + +You therefore don't need to manually set `tabIndex`, as Button handles it automatically based on its `disabled` state. diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index c752555f9ec..3d7e2772b76 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -244,7 +244,6 @@ export const CreateAnalyticsBucketModal = ({ className={buttonClassName} icon={} disabled={isDisabled} - tabIndex={isDisabled ? -1 : 0} style={{ justifyContent: 'start' }} onClick={() => setVisible(true)} tooltip={{ diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx index 379015dd9c4..cc1690819aa 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx @@ -104,7 +104,7 @@ export const FilesBuckets = () => { /> - diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx index 8d2d2b60838..a0ec45ed29a 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -178,7 +178,6 @@ export const CreateVectorBucketDialog = () => { className="w-fit" icon={} disabled={!canCreateBuckets} - tabIndex={!canCreateBuckets ? -1 : 0} onClick={() => setVisible(true)} tooltip={{ content: { diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index 81c5c0366d6..12fa29c2d19 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -239,13 +239,19 @@ const Button = forwardRef( ref ) => { const Comp = asChild ? Slot : 'button' - const { className } = props + const { className, tabIndex } = props const showIcon = loading || icon // decrecating 'showIcon' for rightIcon const _iconLeft: React.ReactNode = icon ?? iconLeft // if loading, button is disabled const disabled = loading === true || props.disabled + // Set default tabIndex for proper Safari focus handling + // - Explicit tabIndex prop takes precedence + // - If disabled, default to -1 (unless explicitly set) + // - Otherwise, default to 0 for keyboard accessibility + const computedTabIndex = tabIndex !== undefined ? tabIndex : disabled ? -1 : 0 + return ( ( type={htmlType} {...props} disabled={disabled} + tabIndex={computedTabIndex} className={cn(buttonVariants({ type, size, disabled, block, rounded }), className)} onClick={(e) => { // [Joshen] Prevents redirecting if Button is used with a link-based child element diff --git a/packages/ui/src/components/shadcn/ui/sidebar.tsx b/packages/ui/src/components/shadcn/ui/sidebar.tsx index a731edcfec0..d322e70d370 100644 --- a/packages/ui/src/components/shadcn/ui/sidebar.tsx +++ b/packages/ui/src/components/shadcn/ui/sidebar.tsx @@ -553,6 +553,13 @@ const SidebarMenuButton = React.forwardRef< ) => { const Comp = asChild ? Slot : 'button' const { isMobile, state } = useSidebar() + const { disabled, tabIndex } = props + + // Set default tabIndex for proper Safari focus handling + // - Explicit tabIndex prop takes precedence + // - If disabled, default to -1 (unless explicitly set) + // - Otherwise, default to 0 for keyboard accessibility + const computedTabIndex = tabIndex !== undefined ? tabIndex : disabled ? -1 : 0 const button = ( From ff38efd38f51ca22c3df19c3b2edb144f2688bda Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 26 Nov 2025 13:27:55 +0800 Subject: [PATCH 04/58] Update service status checks text note when some services are unhealthy (#40767) * Update service status checks * Nit * Update apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young --- .../interfaces/Home/ServiceStatus.tsx | 31 +++++++++++----- .../interfaces/HomeNew/ServiceStatus.tsx | 37 +++++++++++++------ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ServiceStatus.tsx b/apps/studio/components/interfaces/Home/ServiceStatus.tsx index 95226b175ed..f977e8517e7 100644 --- a/apps/studio/components/interfaces/Home/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/Home/ServiceStatus.tsx @@ -3,8 +3,8 @@ import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react import Link from 'next/link' import { useEffect, useState } from 'react' -import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' import { useParams } from 'common' +import { InlineLink } from 'components/ui/InlineLink' import { useBranchesQuery } from 'data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from 'data/service-status/edge-functions-status-query' import { @@ -335,16 +335,27 @@ export const ServiceStatus = () => { ))} - {allServicesOperational ? null : ( - <> - -
-
- -
- Recently restored projects can take up to 5 minutes to become fully operational. + {!allServicesOperational && ( +
+
+
- +
+

+ {isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '} + {SERVICE_STATUS_THRESHOLD} minutes to become fully operational. +

+

+ If services stay unhealthy, refer to our{' '} + + docs + {' '} + for more information. +

+
+
)} diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx index 62278cfabeb..98b4ba12838 100644 --- a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx @@ -1,8 +1,9 @@ +import dayjs from 'dayjs' import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' import Link from 'next/link' -import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' import { useParams } from 'common' +import { InlineLink } from 'components/ui/InlineLink' import { SingleStat } from 'components/ui/SingleStat' import { useBranchesQuery } from 'data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from 'data/service-status/edge-functions-status-query' @@ -15,6 +16,8 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { InfoIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +const SERVICE_STATUS_THRESHOLD = 5 // minutes + /** * [Joshen] JFYI before we go live with this, we need to revisit the migrations section * as I don't think it should live in the ServiceStatus component since its not indicative @@ -248,6 +251,7 @@ export const ServiceStatus = () => { // Check if project or branch is in a startup state const isProjectNew = + dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < SERVICE_STATUS_THRESHOLD || project?.status === 'COMING_UP' || (isBranch && (currentBranch?.status === 'CREATING_PROJECT' || @@ -301,7 +305,7 @@ export const ServiceStatus = () => { value={{overallStatusLabel}} /> - + {services.map((service) => ( {
))} - {allServicesOperational ? null : ( - <> - -
-
- -
- Recently restored projects can take up to 5 minutes to become fully operational. + {!allServicesOperational && ( +
+
+
- +
+

+ {isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '} + {SERVICE_STATUS_THRESHOLD} minutes to become fully operational. +

+

+ If services stay unhealthy, refer to our{' '} + + docs + {' '} + for more information. +

+
+
)} From a054053f751aea288e35d478bcf4c377565edcca Mon Sep 17 00:00:00 2001 From: barcofourie Date: Wed, 26 Nov 2025 09:57:16 +0200 Subject: [PATCH 05/58] feat: adds success message to link support query (#40773) * feat: adds success message to link support query * style: fixes styling * Use existing support form components + fix styling issues + refactor and clean up * nit --------- Co-authored-by: Joshen Lim --- .../Support/CategoryAndSeverityInfo.tsx | 19 +- .../Support/LinkSupportTicketForm.schema.ts | 2 +- .../Support/LinkSupportTicketForm.tsx | 364 +++++------------- .../Support/LinkSupportTicketPage.tsx | 62 ++- .../interfaces/Support/ProjectAndPlanInfo.tsx | 17 +- apps/studio/hooks/misc/useHideSidebar.ts | 2 +- 6 files changed, 171 insertions(+), 295 deletions(-) diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx index d8bc51dd936..4ec3871f8b9 100644 --- a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -27,8 +27,10 @@ import { NO_PROJECT_MARKER } from './SupportForm.utils' interface CategoryAndSeverityInfoProps { form: UseFormReturn category: ExtendedSupportCategories - severity: string + severity?: string projectRef: string + showSeverity?: boolean + showIssueSuggestion?: boolean } export function CategoryAndSeverityInfo({ @@ -36,14 +38,19 @@ export function CategoryAndSeverityInfo({ category, severity, projectRef, + showSeverity = true, + showIssueSuggestion = true, }: CategoryAndSeverityInfoProps) { return ( -
+
- - - - + {showSeverity && } + {showIssueSuggestion && } {(severity === 'Urgent' || severity === 'High') && ( val !== NO_ORG_MARKER, { message: 'Please select an organization', }), - projectRef: z.string().optional(), + projectRef: z.string().min(1, 'Please select a project'), category: z.enum( CATEGORY_OPTIONS.map((opt) => opt.value) as [ ExtendedSupportCategories, diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx index 65a36291d81..aeb32893467 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx @@ -1,7 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { Check, ChevronsUpDown, ChevronRight } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' +import { Link2 } from 'lucide-react' +import { useEffect } from 'react' import type { SubmitHandler } from 'react-hook-form' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -9,44 +8,37 @@ import { toast } from 'sonner' import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { - Badge, Button, - Collapsible_Shadcn_, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - CommandGroup_Shadcn_, - CommandItem_Shadcn_, DialogSectionSeparator, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - Form_Shadcn_, Input_Shadcn_, - Select_Shadcn_, - SelectContent_Shadcn_, - SelectGroup_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - Switch, - cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants' +import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' import { LinkSupportTicketFormSchema, type LinkSupportTicketFormValues, } from './LinkSupportTicketForm.schema' -import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils' +import { OrganizationSelector } from './OrganizationSelector' +import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' +import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { getOrgSubscriptionPlan, NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils' interface LinkSupportTicketFormProps { conversationId: string + onSuccess: () => void } -export function LinkSupportTicketForm({ conversationId }: LinkSupportTicketFormProps) { - const router = useRouter() - const { data: organizations } = useOrganizationsQuery() +// [Joshen] Am reusing the SupportFormV2 components here but am not sure how to type things properly +// hence marking all the `form` as `any` when passing as props to the components for now + +export const LinkSupportTicketForm = ({ + conversationId, + onSuccess, +}: LinkSupportTicketFormProps) => { + const { data: organizations, isSuccess } = useOrganizationsQuery() const form = useForm({ resolver: zodResolver(LinkSupportTicketFormSchema), @@ -61,30 +53,32 @@ export function LinkSupportTicketForm({ conversationId }: LinkSupportTicketFormP reValidateMode: 'onBlur', }) - const { mutate: linkSupportTicket, isLoading } = useLinkSupportTicketMutation({ + const { category, organizationSlug, projectRef } = form.watch() + const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug + const selectedProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef + const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) + + const { mutate: linkSupportTicket, isPending } = useLinkSupportTicketMutation({ onSuccess: () => { toast.success('Support ticket linked successfully!') - router.push('/') + onSuccess() }, onError: (error) => { - toast.error(`Failed to link support ticket: ${error.message}`) + let errorMessage = error.message + try { + const parsed = JSON.parse(error.message) + errorMessage = parsed?._error?.message || parsed?.message || error.message + } catch {} + // If parsing fails, use the original message + toast.error(`Failed to link support ticket: ${errorMessage}`) }, }) - const organizationSlug = form.watch('organizationSlug') - const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug - const onSubmit: SubmitHandler = (values) => { - if (!organizations) { - toast.error('Organizations not loaded. Please try again.') - return - } + if (!organizations) return toast.error('Organizations not loaded. Please try again.') const selectedOrg = organizations.find((org) => org.slug === values.organizationSlug) - if (!selectedOrg) { - toast.error('Selected organization not found. Please try again.') - return - } + if (!selectedOrg) return toast.error('Selected organization not found. Please try again.') linkSupportTicket({ conversation_id: values.conversation_id, @@ -94,264 +88,88 @@ export function LinkSupportTicketForm({ conversationId }: LinkSupportTicketFormP ? values.projectRef : undefined, category: values.category, - allow_support_access: values.allowSupportAccess, + allow_support_access: SUPPORT_ACCESS_CATEGORIES.includes(values.category) + ? values.allowSupportAccess + : false, }) } + useEffect(() => { + if (!!organizations && organizations.length > 0) { + form.reset({ + conversation_id: conversationId, + organizationSlug: organizations[0].slug, + projectRef: NO_PROJECT_MARKER, + category: '' as any, + allowSupportAccess: true, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + return (