mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## What kind of change does this PR introduce? Bug fix / design-system alignment for the legacy `Button` from `ui`. ## What is the current behavior? Omitting `variant` on the legacy `Button` falls back to brand-green `primary`. That makes accidental greens easy, and it is hard to spot the real main action on busy pages. ## What is the new behavior? - Legacy `Button` now defaults to neutral `default` - Intentional primary CTAs (create, save, submit, marketing CTAs, and matching `ButtonTooltip` usages) now set `variant="primary"` so their appearance is unchanged - Neutral actions that previously relied on the old fallback (cancel, close, back, dashboard nav, and similar) become grey/white - Design-system docs updated; regression tests cover the new default `Button_Shadcn_` is unchanged. It already uses its own CVA default. This is PR 1 of 2 in a stack. PR 2 drops now-redundant `variant="default"` props. ## To test Studio (http://localhost:8082): - `/sign-in`: Sign in stays green - Open a project → Database → Tables: New table stays green - Auth → Users → Invite: Invite user stays green; Cancel / dismiss controls stay neutral - Project Settings → General: edit a field so Cancel and Save appear. Cancel is neutral, Save is green Design system (http://localhost:3003): - Components → Button: default demo is neutral; primary demo is green; featured preview is the default variant Marketing (optional): - www header: Start your project stays green; logged-in Dashboard is neutral <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Style** - Buttons now default to a neutral style, while primary actions across Studio, documentation, marketing pages, forms, dialogs, and error states use prominent primary styling. - Updated button examples and previews clarify the distinction between default and primary variants. - Event registration now includes a directional arrow icon. - **Tests** - Added coverage confirming default button styling and explicit primary styling behave as expected. - Updated related test fixtures to use primary styling where appropriate. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { submitSimilarThreadFeedback, updateSimilarThreadFeedback } from '~/app/contribute/actions'
|
||
import type {
|
||
SimilarSolvedThread,
|
||
SimilarThreadFeedbackReaction,
|
||
ThreadSource,
|
||
} from '~/types/contribute'
|
||
import { ChevronDown, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||
import { useRef, useState } from 'react'
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
CardFooter,
|
||
CardHeader,
|
||
cn,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogSection,
|
||
DialogSectionSeparator,
|
||
DialogTitle,
|
||
Label,
|
||
TextArea,
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from 'ui'
|
||
|
||
import { ChannelIcon } from './Icons'
|
||
|
||
function getChannelFromUrl(url: string): ThreadSource {
|
||
const u = url.toLowerCase()
|
||
if (u.includes('discord')) return 'discord'
|
||
if (u.includes('reddit')) return 'reddit'
|
||
if (u.includes('github')) return 'github'
|
||
// TODO: handle unknown URL patterns more explicitly, don’t just default to GitHub
|
||
return 'github'
|
||
}
|
||
|
||
interface SimilarSolvedThreadsProps {
|
||
threads: SimilarSolvedThread[]
|
||
parentThreadId: string
|
||
}
|
||
|
||
const SimilarThreadCard = ({
|
||
thread,
|
||
className,
|
||
}: {
|
||
thread: SimilarSolvedThread
|
||
className?: string
|
||
}) => {
|
||
const channel = getChannelFromUrl(thread.external_activity_url || '')
|
||
const filteredStack = thread.stack?.filter((s) => s !== 'Other') ?? []
|
||
const hasStack = filteredStack.length > 0
|
||
|
||
const url = thread.external_activity_url || null
|
||
const linkClassName = cn(
|
||
'border-b border-border px-6 py-4 flex items-center gap-3 overflow-hidden hover:bg-surface-200 transition-colors',
|
||
className
|
||
)
|
||
const content = (
|
||
<>
|
||
<div className="flex items-center justify-center bg-surface-200 dark:bg-surface-300 h-10 w-10 rounded-md shrink-0">
|
||
<ChannelIcon channel={channel} />
|
||
</div>
|
||
<div className="min-w-0 flex-1 flex flex-col">
|
||
<h4 className="text-base text-foreground truncate block">{thread.subject}</h4>
|
||
{thread.problem_description ? (
|
||
<p className="text-sm text-foreground-lighter leading-relaxed line-clamp-2">
|
||
{thread.problem_description}
|
||
</p>
|
||
) : null}
|
||
{hasStack ? (
|
||
<div className="flex flex-wrap gap-x-1.5 gap-y-1 overflow-hidden pt-1">
|
||
{filteredStack.map((tech) => (
|
||
<Badge key={tech} variant="default">
|
||
{tech}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
)
|
||
|
||
if (url) {
|
||
return (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={linkClassName}
|
||
aria-label={`View thread: ${thread.subject}`}
|
||
>
|
||
{content}
|
||
</a>
|
||
)
|
||
}
|
||
|
||
return <div className={linkClassName}>{content}</div>
|
||
}
|
||
|
||
export const SimilarSolvedThreads = ({ threads, parentThreadId }: SimilarSolvedThreadsProps) => {
|
||
const [isExpanded, setIsExpanded] = useState(true)
|
||
const [submittedReaction, setSubmittedReaction] = useState<SimilarThreadFeedbackReaction | null>(
|
||
null
|
||
)
|
||
const [feedbackId, setFeedbackId] = useState<string | null>(null)
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [dialogReaction, setDialogReaction] = useState<SimilarThreadFeedbackReaction>('positive')
|
||
const [dialogFeedback, setDialogFeedback] = useState('')
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const isClosingProgrammatically = useRef(false)
|
||
|
||
const handleThumbClick = async (reaction: SimilarThreadFeedbackReaction) => {
|
||
if (submittedReaction) return
|
||
setIsSubmitting(true)
|
||
const result = await submitSimilarThreadFeedback({
|
||
parentThreadId,
|
||
reaction,
|
||
similarThreadKey: null,
|
||
})
|
||
setIsSubmitting(false)
|
||
if (result.success) {
|
||
setFeedbackId(result.id ?? null)
|
||
setDialogReaction(reaction)
|
||
setDialogFeedback('')
|
||
setDialogOpen(true)
|
||
}
|
||
}
|
||
|
||
const persistAndCloseDialog = async () => {
|
||
if (!feedbackId) return
|
||
setIsSubmitting(true)
|
||
const result = await updateSimilarThreadFeedback(
|
||
feedbackId,
|
||
dialogReaction,
|
||
dialogFeedback.trim() || null
|
||
)
|
||
setIsSubmitting(false)
|
||
if (result.success) {
|
||
setSubmittedReaction(dialogReaction)
|
||
isClosingProgrammatically.current = true
|
||
setDialogOpen(false)
|
||
queueMicrotask(() => {
|
||
isClosingProgrammatically.current = false
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleDialogOpenChange = (open: boolean) => {
|
||
setDialogOpen(open)
|
||
if (!open && feedbackId && !isClosingProgrammatically.current) {
|
||
persistAndCloseDialog()
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card className={cn('relative')}>
|
||
<CardHeader className={cn('p-0', !isExpanded && 'border-b-0')}>
|
||
<button
|
||
tabIndex={0}
|
||
type="button"
|
||
onClick={() => setIsExpanded((prev) => !prev)}
|
||
className="flex w-full items-center gap-1.5 px-(--card-padding-x) py-4 text-left text-xs font-mono uppercase text-card-foreground"
|
||
>
|
||
Related threads
|
||
<span className="text-foreground-muted tabular-nums font-normal">({threads.length})</span>
|
||
<ChevronDown
|
||
className={`h-3.5 w-3.5 text-foreground-lighter transition-transform duration-200 ${
|
||
isExpanded ? 'rotate-0' : '-rotate-90'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</CardHeader>
|
||
{isExpanded && (
|
||
<>
|
||
<CardContent className="p-0">
|
||
{threads.map((thread, idx) => (
|
||
<SimilarThreadCard
|
||
key={thread.thread_key || idx}
|
||
thread={thread}
|
||
className={idx === threads.length - 1 ? 'border-b-0' : undefined}
|
||
/>
|
||
))}
|
||
</CardContent>
|
||
<CardFooter className="flex items-center justify-between min-h-[58px]">
|
||
{submittedReaction ? (
|
||
<span className="text-sm text-foreground-muted">
|
||
Thanks for helping improve related threads
|
||
</span>
|
||
) : (
|
||
<TooltipProvider>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
tabIndex={0}
|
||
type="button"
|
||
onClick={() => handleThumbClick('positive')}
|
||
disabled={isSubmitting}
|
||
className="p-1 rounded-sm hover:bg-surface-200 transition-colors disabled:opacity-50"
|
||
aria-label="Relevant"
|
||
>
|
||
<ThumbsUp className="h-4 w-4 text-foreground-muted" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Relevant</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
tabIndex={0}
|
||
type="button"
|
||
onClick={() => handleThumbClick('negative')}
|
||
disabled={isSubmitting}
|
||
className="p-1 rounded-sm hover:bg-surface-200 transition-colors disabled:opacity-50"
|
||
aria-label="Irrelevant"
|
||
>
|
||
<ThumbsDown className="h-4 w-4 text-foreground-muted" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Irrelevant</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TooltipProvider>
|
||
)}
|
||
</CardFooter>
|
||
</>
|
||
)}
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Help improve related threads</DialogTitle>
|
||
<DialogDescription>How relevant were these threads to your issue?</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogSectionSeparator />
|
||
<DialogSection className="space-y-4">
|
||
<fieldset className="space-y-1">
|
||
<legend className="text-sm text-foreground">Relevance</legend>
|
||
<div className="flex items-center gap-1">
|
||
<label
|
||
className={cn(
|
||
'p-2 rounded-sm cursor-pointer transition-colors',
|
||
dialogReaction === 'positive'
|
||
? 'bg-surface-300 text-foreground'
|
||
: 'hover:bg-surface-200 text-foreground-lighter'
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="relevance"
|
||
value="positive"
|
||
checked={dialogReaction === 'positive'}
|
||
onChange={() => setDialogReaction('positive')}
|
||
className="sr-only"
|
||
aria-label="Relevant"
|
||
/>
|
||
<ThumbsUp className="h-4 w-4" aria-hidden />
|
||
</label>
|
||
<label
|
||
className={cn(
|
||
'p-2 rounded-sm cursor-pointer transition-colors',
|
||
dialogReaction === 'negative'
|
||
? 'bg-surface-300 text-foreground'
|
||
: 'hover:bg-surface-200 text-foreground-lighter'
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="relevance"
|
||
value="negative"
|
||
checked={dialogReaction === 'negative'}
|
||
onChange={() => setDialogReaction('negative')}
|
||
className="sr-only"
|
||
aria-label="Irrelevant or unhelpful"
|
||
/>
|
||
<ThumbsDown className="h-4 w-4" aria-hidden />
|
||
</label>
|
||
</div>
|
||
</fieldset>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="feedback">
|
||
Additional feedback <span className="text-foreground-muted">(optional)</span>
|
||
</Label>
|
||
<TextArea
|
||
id="feedback"
|
||
placeholder="What was helpful or missing?"
|
||
rows={4}
|
||
value={dialogFeedback}
|
||
onChange={(e) => setDialogFeedback(e.target.value)}
|
||
className="text-sm resize-none"
|
||
/>
|
||
</div>
|
||
</DialogSection>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="primary"
|
||
onClick={persistAndCloseDialog}
|
||
disabled={isSubmitting}
|
||
loading={isSubmitting}
|
||
>
|
||
{isSubmitting ? 'Submitting...' : 'Submit feedback'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
)
|
||
}
|