DashboardPreferences.tsx206 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { LOCAL_STORAGE_KEYS, useParams } from 'common'
3import { HelpCircle } from 'lucide-react'
4import { useForm } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 Card,
9 CardContent,
10 CardFooter,
11 Dialog,
12 DialogClose,
13 DialogContent,
14 DialogFooter,
15 DialogHeader,
16 DialogSection,
17 DialogSectionSeparator,
18 DialogTitle,
19 DialogTrigger,
20 Form,
21 FormControl,
22 FormField,
23} from 'ui'
24import { Admonition } from 'ui-patterns'
25import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
26import {
27 PageSection,
28 PageSectionContent,
29 PageSectionMeta,
30 PageSectionSummary,
31 PageSectionTitle,
32} from 'ui-patterns/PageSection'
33import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
34import * as z from 'zod'
35
36import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
37import { InlineLink } from '@/components/ui/InlineLink'
38import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
39import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
40
41const formSchema = z.object({
42 defaultDatabase: z.string().optional(),
43})
44
45export type DashboardPreference = z.infer<typeof formSchema>
46
47const DEFAULT_PREFERENCE: DashboardPreference = {
48 defaultDatabase: undefined,
49}
50
51/**
52 * [Joshen] JFYI am not convinced about the UX of this, will iterate over time
53 * and only release to public when we're satisfied with how it behaves
54 * - Where should "Dashboard preferences" live
55 * - Preferences currently only apply to the user via local storage until we have middleware support that will persist the setting on the project for all users
56 * - Should selecting which database to run queries for dashboard be an option for users to configure (or for us to just default to)
57 * - I'd love for this to work seamlessly, but main concern atm is latency which is region dependent
58 * - Also, current database logic only applies to Table Editor atm, will need to extend it further to other pages
59 */
60
61export const DashboardPreferences = () => {
62 const { ref: projectRef } = useParams()
63
64 const [dashboardPreferences, setDashboardPreferences, { isLoading }] =
65 useLocalStorageQuery<DashboardPreference>(
66 LOCAL_STORAGE_KEYS.DASHBOARD_PREFERENCES(projectRef ?? '_'),
67 DEFAULT_PREFERENCE
68 )
69
70 const { isPending } = useReadReplicasQuery({ projectRef })
71
72 const form = useForm<z.infer<typeof formSchema>>({
73 resolver: zodResolver(formSchema as any),
74 defaultValues: dashboardPreferences,
75 values: dashboardPreferences,
76 mode: 'onSubmit',
77 reValidateMode: 'onBlur',
78 })
79
80 const onSubmit = async (values: DashboardPreference) => {
81 if (!projectRef) return console.error('Ref is required')
82 setDashboardPreferences(values)
83 form.reset(values)
84 toast.success('Successfully saved dashboard preferences!')
85 }
86
87 return (
88 <PageSection>
89 <PageSectionMeta>
90 <PageSectionSummary>
91 <PageSectionTitle id="queries">Queries</PageSectionTitle>
92 </PageSectionSummary>
93 </PageSectionMeta>
94
95 <PageSectionContent className="flex flex-col gap-y-4">
96 {/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */}
97 <Admonition
98 type="note"
99 description="These preferences control only your experience in the dashboard. Other members of this project will not be affected."
100 />
101
102 {isLoading ? (
103 <Card>
104 <CardContent>
105 <GenericSkeletonLoader />
106 </CardContent>
107 </Card>
108 ) : (
109 <Form {...form}>
110 <form onSubmit={form.handleSubmit(onSubmit)}>
111 <Card>
112 <CardContent>
113 <FormField
114 control={form.control}
115 name="defaultDatabase"
116 render={({ field }) => (
117 <FormItemLayout
118 layout="flex-row-reverse"
119 label="Preferred database for dashboard queries"
120 description={
121 <p>
122 All read queries from the dashboard will run against the selected
123 database by default
124 <DashboardQueriesDialog />
125 </p>
126 }
127 className="[&>div]:md:w-1/2"
128 >
129 {isPending ? (
130 <ShimmeringLoader />
131 ) : (
132 <FormControl>
133 {/* [Joshen] Need to disable unhealthy replicas */}
134 <DatabaseSelector
135 isForm
136 buttonProps={{ size: 'small', className: 'w-full' }}
137 selectedDatabaseId={field.value ?? projectRef}
138 onSelectId={(id) =>
139 field.onChange(id === projectRef ? undefined : id)
140 }
141 />
142 </FormControl>
143 )}
144 </FormItemLayout>
145 )}
146 />
147 </CardContent>
148 <CardFooter className="justify-end space-x-2">
149 {form.formState.isDirty && (
150 <Button
151 type="default"
152 htmlType="button"
153 onClick={() => form.reset(dashboardPreferences)}
154 >
155 Cancel
156 </Button>
157 )}
158 <Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
159 Save changes
160 </Button>
161 </CardFooter>
162 </Card>
163 </form>
164 </Form>
165 )}
166 </PageSectionContent>
167 </PageSection>
168 )
169}
170
171const DashboardQueriesDialog = () => {
172 const { ref } = useParams()
173 return (
174 <Dialog>
175 <DialogTrigger className="ml-1 translate-y-0.5">
176 <HelpCircle size={14} className="hover:text-foreground transition" />
177 </DialogTrigger>
178 <DialogContent aria-describedby={undefined}>
179 <DialogHeader>
180 <DialogTitle>How does the dashboard interact with your project's database?</DialogTitle>
181 </DialogHeader>
182
183 <DialogSectionSeparator />
184
185 <DialogSection className="flex flex-col gap-y-2">
186 <p className="text-sm">
187 The dashboard queries your project's database to display data across various interfaces,
188 such as the <InlineLink href={`/project/${ref}/editor`}>Table Editor</InlineLink>, the{' '}
189 <InlineLink href={`/project/${ref}/auth/users`}>Auth Users</InlineLink> page, and more.
190 </p>
191
192 <p className="text-sm">
193 You can route these queries to a read replica instead, which will help reduce load on
194 your primary database.
195 </p>
196 </DialogSection>
197
198 <DialogFooter>
199 <DialogClose asChild className="opacity-100">
200 <Button type="default">Understood</Button>
201 </DialogClose>
202 </DialogFooter>
203 </DialogContent>
204 </Dialog>
205 )
206}