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
1 change: 1 addition & 0 deletions apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Kang Ming Tay
Karan S
Karlo Ison
Katerina Skroumpelou
Kemal Y
Kevin Brolly
Kevin Grüneberg
Lakshan Perera
Expand Down
129 changes: 129 additions & 0 deletions apps/studio/components/grid/components/header/ExportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useParams } from 'common'
import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSettings.utils'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { pluckObjectFields } from 'lib/helpers'
import { useState } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
cn,
CodeBlock,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'

interface ExportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}

export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
const { ref: projectRef } = useParams()
const snap = useTableEditorTableStateSnapshot()
const [selectedTab, setSelectedTab] = useState<string>('csv')

const { data: databases } = useReadReplicasQuery({ projectRef })
const primaryDatabase = (databases ?? []).find((db) => db.identifier === projectRef)
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }

const connectionInfo = pluckObjectFields(primaryDatabase || emptyState, DB_FIELDS)
const { db_host, db_port, db_user, db_name } = connectionInfo

const connectionStrings = getConnectionStrings({
connectionInfo,
metadata: { projectRef },
// [Joshen] We don't need any pooler details for this context, we only want direct
poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' },
})

const outputName = `${snap.table.name}_rows`

const csvExportCommand = `
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
`.trim()

const sqlExportCommand = `
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql
`.trim()

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export table data via CLI</DialogTitle>
</DialogHeader>

<DialogSectionSeparator />

<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
We highly recommend using <code>{selectedTab === 'csv' ? 'psql' : 'pg_dump'}</code> to
export your table data, in particular if your table is relatively large. This can be
done via the following command that you can run in your terminal:
</p>

<Tabs_Shadcn_ value={selectedTab} onValueChange={setSelectedTab}>
<TabsList_Shadcn_ className="gap-x-3">
<TabsTrigger_Shadcn_ value="csv">As CSV</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="sql">As SQL</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="csv">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3')}
language="bash"
value={csvExportCommand}
className="[&_code]:text-[12px] [&_code]:text-foreground"
/>
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="sql">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3')}
language="bash"
value={sqlExportCommand}
className="[&_code]:text-[12px] [&_code]:text-foreground"
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>

<p className="text-sm">
You will be prompted for your database password, and the output file{' '}
<code>
{outputName}.{selectedTab}
</code>{' '}
will be saved in the current directory that your terminal is in.
</p>

{selectedTab === 'sql' && (
<Admonition
type="note"
title="The pg_dump version needs to match your Postgres version"
>
<p className="!leading-normal">
If you run into a server version mismatch error, you will need to update{' '}
<code>pg_dump</code> before running the command.
</p>
</Admonition>
)}
</DialogSection>
<DialogFooter>
<Button type="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
120 changes: 67 additions & 53 deletions apps/studio/components/grid/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
Separator,
SonnerProgress,
} from 'ui'
import { ExportDialog } from './ExportDialog'
import { FilterPopover } from './filter/FilterPopover'
import { SortPopover } from './sort/SortPopover'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
Expand Down Expand Up @@ -230,6 +231,7 @@ const RowHeader = () => {
const { sorts } = useTableSort()

const [isExporting, setIsExporting] = useState(false)
const [showExportModal, setShowExportModal] = useState(false)

const { data } = useTableRowsQuery({
projectRef: project?.ref,
Expand Down Expand Up @@ -441,61 +443,73 @@ const RowHeader = () => {
})

return (
<div className="flex items-center gap-x-2">
{snap.editable && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Trash />}
onClick={onRowsDelete}
disabled={snap.allRowsSelected && isImpersonatingRole}
tooltip={{
content: {
side: 'bottom',
text:
snap.allRowsSelected && isImpersonatingRole
? 'Table truncation is not supported when impersonating a role'
: undefined,
},
}}
>
{snap.allRowsSelected
? `Delete all rows in table`
: snap.selectedRows.size > 1
? `Delete ${snap.selectedRows.size} rows`
: `Delete ${snap.selectedRows.size} row`}
</ButtonTooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<>
<div className="flex items-center gap-x-2">
{snap.editable && (
<ButtonTooltip
type="default"
size="tiny"
iconRight={<ChevronDown />}
loading={isExporting}
disabled={isExporting}
icon={<Trash />}
onClick={onRowsDelete}
disabled={snap.allRowsSelected && isImpersonatingRole}
tooltip={{
content: {
side: 'bottom',
text:
snap.allRowsSelected && isImpersonatingRole
? 'Table truncation is not supported when impersonating a role'
: undefined,
},
}}
>
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuItem onClick={onRowsExportCSV}>
<span className="text-foreground-light">Export to CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onRowsExportSQL}>Export to SQL</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

{!snap.allRowsSelected && totalRows > allRows.length && (
<>
<div className="h-6 ml-0.5">
<Separator orientation="vertical" />
</div>
<Button type="text" onClick={() => onSelectAllRows()}>
Select all rows in table
</Button>
</>
)}
</div>
{snap.allRowsSelected
? `Delete all rows in table`
: snap.selectedRows.size > 1
? `Delete ${snap.selectedRows.size} rows`
: `Delete ${snap.selectedRows.size} row`}
</ButtonTooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
size="tiny"
iconRight={<ChevronDown />}
loading={isExporting}
disabled={isExporting}
>
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={snap.allRowsSelected ? 'w-52' : 'w-40'}>
<DropdownMenuItem onClick={onRowsExportCSV}>Export as CSV</DropdownMenuItem>
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
{/* [Joshen] Should make this available for all cases, but that'll involve updating
the Dialog's SQL output to be dynamic based on any filters applied */}
{snap.allRowsSelected && (
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
<div>
<p className="group-hover:text-foreground">Export via CLI</p>
<p className="text-foreground-lighter">Recommended for large tables</p>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

{!snap.allRowsSelected && totalRows > allRows.length && (
<>
<div className="h-6 ml-0.5">
<Separator orientation="vertical" />
</div>
<Button type="text" onClick={() => onSelectAllRows()}>
Select all rows in table
</Button>
</>
)}
</div>

<ExportDialog open={showExportModal} onOpenChange={() => setShowExportModal(false)} />
</>
)
}
52 changes: 42 additions & 10 deletions apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Key } from 'lucide-react'
import { useMemo } from 'react'

import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, copyToClipboard } from 'ui'
import type { ICommand } from 'ui-patterns/CommandMenu'
import {
Expand All @@ -21,14 +21,11 @@ export function useApiKeysCommands() {
const setIsOpen = useSetCommandMenuOpen()
const setPage = useSetPage()

const project = useSelectedProject()
const { data: project } = useSelectedProjectQuery()
const ref = project?.ref || '_'

const { data: settings } = useProjectSettingsV2Query(
{ projectRef: project?.ref },
{ enabled: !!project }
)
const { anonKey, serviceKey } = getAPIKeys(settings)
const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true })
const { anonKey, serviceKey, publishableKey, allSecretKeys } = getKeys(apiKeys)

const commands = useMemo(
() =>
Expand All @@ -42,9 +39,10 @@ export function useApiKeysCommands() {
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-2">
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge>Public</Badge>
<Badge className="capitalize">{anonKey.type}</Badge>
</span>
),
icon: () => <Key />,
Expand All @@ -58,13 +56,47 @@ export function useApiKeysCommands() {
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-2">
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge variant="destructive">Secret</Badge>
<Badge className="capitalize">{serviceKey.type}</Badge>
</span>
),
icon: () => <Key />,
},
project &&
publishableKey && {
id: 'publishable-key',
name: `Copy publishable key`,
action: () => {
copyToClipboard(publishableKey.api_key ?? '')
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{publishableKey.type}</Badge>
</span>
),
icon: () => <Key />,
},
...(project && allSecretKeys
? allSecretKeys.map((key) => ({
id: key.id,
name: `Copy secret key (${key.name})`,
action: () => {
copyToClipboard(key.api_key ?? '')
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{key.type}</Badge>
</span>
),
icon: () => <Key />,
}))
: []),
!(anonKey || serviceKey) && {
id: 'api-keys-project-settings',
name: 'See API keys in Project Settings',
Expand Down
Loading
Loading