DashboardPreferences.tsx206 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 3 | import { HelpCircle } from 'lucide-react' |
| 4 | import { useForm } from 'react-hook-form' |
| 5 | import { toast } from 'sonner' |
| 6 | import { |
| 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' |
| 24 | import { Admonition } from 'ui-patterns' |
| 25 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 26 | import { |
| 27 | PageSection, |
| 28 | PageSectionContent, |
| 29 | PageSectionMeta, |
| 30 | PageSectionSummary, |
| 31 | PageSectionTitle, |
| 32 | } from 'ui-patterns/PageSection' |
| 33 | import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 34 | import * as z from 'zod' |
| 35 | |
| 36 | import { DatabaseSelector } from '@/components/ui/DatabaseSelector' |
| 37 | import { InlineLink } from '@/components/ui/InlineLink' |
| 38 | import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' |
| 39 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 40 | |
| 41 | const formSchema = z.object({ |
| 42 | defaultDatabase: z.string().optional(), |
| 43 | }) |
| 44 | |
| 45 | export type DashboardPreference = z.infer<typeof formSchema> |
| 46 | |
| 47 | const 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 | |
| 61 | export 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 | |
| 171 | const 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 | } |