Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isbot } from 'isbot'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'

import { clientSdkIds } from '~/content/navigation.references'
import { BASE_PATH } from '~/lib/constants'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const FormSchema = z.object({
const defaultValues = {
secrets: [{ name: '', value: '' }],
}

const removeWrappingQuotes = (str: string): string => {
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
return str.slice(1, -1)
}
return str
}

const AddNewSecretForm = () => {
const { ref: projectRef } = useParams()
const [showSecretValue, setShowSecretValue] = useState(false)
Expand Down Expand Up @@ -102,9 +110,10 @@ const AddNewSecretForm = () => {
lines.forEach((line) => {
const [key, ...valueParts] = line.split('=')
if (key && valueParts.length) {
const valueStr = valueParts.join('=').trim()
pairs.push({
name: key.trim(),
value: valueParts.join('=').trim(),
value: removeWrappingQuotes(valueStr),
})
}
})
Expand Down
23 changes: 13 additions & 10 deletions apps/studio/components/interfaces/Support/AIAssistantOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { AnimatePresence, motion } from 'framer-motion'
import { MessageSquare } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
// End of third-party imports

import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { Button } from 'ui'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'

interface AIAssistantOptionProps {
projectRef: string
organizationSlug: string
projectRef?: string | null
organizationSlug?: string | null
isCondensed?: boolean
}

Expand All @@ -18,7 +20,7 @@ export const AIAssistantOption = ({
isCondensed = false,
}: AIAssistantOptionProps) => {
const { mutate: sendEvent } = useSendEventMutation()
const [isVisible, setIsVisible] = useState(isCondensed ? true : false)
const [isVisible, setIsVisible] = useState(isCondensed)

useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 800)
Expand All @@ -29,18 +31,19 @@ export const AIAssistantOption = ({
sendEvent({
action: 'ai_assistant_in_support_form_clicked',
groups: {
project: projectRef === 'no-project' ? undefined : projectRef,
organization: organizationSlug,
project: projectRef === null || projectRef === NO_PROJECT_MARKER ? undefined : projectRef,
organization:
organizationSlug === null || organizationSlug === NO_ORG_MARKER
? undefined
: organizationSlug,
},
})
}, [projectRef, organizationSlug, sendEvent])

if (!organizationSlug || organizationSlug === 'no-org') {
return null
}

// If no specific project selected, use the wildcard route
const aiLink = `/project/${projectRef !== 'no-project' ? projectRef : '_'}?aiAssistantPanelOpen=true&slug=${organizationSlug}`
const aiLink = `/project/${projectRef !== NO_PROJECT_MARKER ? projectRef : '_'}?aiAssistantPanelOpen=true&slug=${organizationSlug}`

if (!organizationSlug || organizationSlug === NO_ORG_MARKER) return null

return (
<AnimatePresence initial={false}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports

import { SupportCategories } from '@supabase/shared-types/out/constants'
import { FormControl_Shadcn_, FormField_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { type ExtendedSupportCategories, SERVICE_OPTIONS } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'

interface AffectedServicesSelectorProps {
form: UseFormReturn<SupportFormValues>
category: ExtendedSupportCategories
}

export const CATEGORIES_WITHOUT_AFFECTED_SERVICES: ExtendedSupportCategories[] = [
SupportCategories.LOGIN_ISSUES,
'Plan_upgrade',
]

export function AffectedServicesSelector({ form, category }: AffectedServicesSelectorProps) {
if (CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(category)) return null

return (
<FormField_Shadcn_
name="affectedServices"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Which services are affected?">
<FormControl_Shadcn_>
<MultiSelectV2
options={SERVICE_OPTIONS}
value={field.value.length === 0 ? [] : field.value?.split(', ')}
placeholder="No particular service"
searchPlaceholder="Search for a service"
onChange={(services) => form.setValue('affectedServices', services.join(', '))}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
187 changes: 187 additions & 0 deletions apps/studio/components/interfaces/Support/AttachmentUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { compact } from 'lodash'
import { Plus, X } from 'lucide-react'
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
// End of third-party imports

import { uuidv4 } from 'lib/helpers'
import { cn } from 'ui'
import { createSupportStorageClient } from './support-storage-client'

const MAX_ATTACHMENTS = 5

const uploadAttachments = async (ref: string, files: File[]) => {
const supportSupabaseClient = createSupportStorageClient()

const filesToUpload = Array.from(files)
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const suffix = file.type.split('/')[1]
const prefix = `${ref}/${uuidv4()}.${suffix}`
const options = { cacheControl: '3600' }

const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.upload(prefix, file, options)

if (error) console.error('Failed to upload:', file.name, error)
return data
})
)
const keys = compact(uploadedFiles).map((file) => file.path)

if (keys.length === 0) return []

const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.createSignedUrls(keys, 10 * 365 * 24 * 60 * 60)
if (error) {
console.error('Failed to retrieve URLs for attachments', error)
}
return data ? data.map((file) => file.signedUrl) : []
}

export function useAttachmentUpload() {
const uploadButtonRef = useRef<HTMLInputElement>(null)

const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])

const isFull = uploadedFiles.length >= MAX_ATTACHMENTS

const addFile = useCallback(() => {
uploadButtonRef.current?.click()
}, [])

const handleFileUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
event.persist()
const items = event.target.files || (event as any).dataTransfer.items
const itemsCopied = Array.prototype.map.call(items, (item: any) => item) as File[]
const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length)

setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded))
if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) {
toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`)
}
event.target.value = ''
},
[uploadedFiles]
)

const removeFileUpload = useCallback(
(idx: number) => {
const updatedFiles = uploadedFiles.slice()
updatedFiles.splice(idx, 1)
setUploadedFiles(updatedFiles)

const updatedDataUrls = uploadedDataUrls.slice()
uploadedDataUrls.splice(idx, 1)
setUploadedDataUrls(updatedDataUrls)
},
[uploadedFiles, uploadedDataUrls]
)

useEffect(() => {
if (!uploadedFiles) return
const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file))
setUploadedDataUrls(objectUrls)

return () => {
objectUrls.forEach((url: any) => void URL.revokeObjectURL(url))
}
}, [uploadedFiles])

const createAttachments = useCallback(
async (projectRef: string) => {
const attachments =
uploadedFiles.length > 0 ? await uploadAttachments(projectRef, uploadedFiles) : []
return attachments
},
[uploadedFiles]
)

return useMemo(
() => ({
uploadButtonRef,
isFull,
addFile,
handleFileUpload,
removeFileUpload,
createAttachments,
uploadedDataUrls,
}),
[isFull, addFile, handleFileUpload, removeFileUpload, createAttachments, uploadedDataUrls]
)
}

interface AttachmentUploadDisplayProps {
uploadButtonRef: React.RefObject<HTMLInputElement>
isFull: boolean
addFile: () => void
handleFileUpload: (event: ChangeEvent<HTMLInputElement>) => Promise<void>
removeFileUpload: (idx: number) => void
uploadedDataUrls: Array<string>
}

export function AttachmentUploadDisplay({
uploadButtonRef,
isFull,
addFile,
handleFileUpload,
removeFileUpload,
uploadedDataUrls,
}: AttachmentUploadDisplayProps) {
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground-light">Attachments</p>
<p className="text-sm text-foreground-lighter">
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're
facing
</p>
</div>
<input
multiple
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={handleFileUpload}
/>
<div className="flex items-center gap-x-2">
{uploadedDataUrls.map((url, idx) => (
<div
key={url}
style={{ backgroundImage: `url("${url}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<button
type="button"
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={12} strokeWidth={2} />
</button>
</div>
))}
{!isFull && (
<button
type="button"
className={cn(
'border border-stronger opacity-50 transition hover:opacity-100',
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded'
)}
onClick={addFile}
>
<Plus strokeWidth={2} size={20} />
</button>
)}
</div>
</div>
)
}
Loading
Loading