Merge pull request #13284 from supabase/feat/show-view-definition

Feat/show view definition
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-03-30 13:15:47 +08:00
commit ff2e9a5f3d
12 files changed
+420 -166

No files matched your search

+27 -10
View File
@@ -58,7 +58,15 @@ export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>((prop
})
const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
const { editable, storageRef, gridProps, headerActions } = props
const {
editable,
storageRef,
gridProps,
headerActions,
showCustomChildren,
customHeader,
children,
} = props
const dispatch = useDispatch()
const state = useTrackedState()
@@ -194,19 +202,28 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
table={table}
sorts={sorts}
filters={filters}
isRefetching={isRefetching}
onAddRow={editable ? props.onAddRow : undefined}
onAddColumn={editable ? props.onAddColumn : undefined}
headerActions={headerActions}
customHeader={customHeader}
/>
<Grid
ref={gridRef}
{...gridProps}
rows={data?.rows ?? []}
updateRow={props.updateTableRow}
onEditForeignKeyColumnValue={props.onEditForeignKeyColumnValue}
/>
<Footer isLoading={isLoading || isRefetching} />
<Shortcuts gridRef={gridRef} />
{showCustomChildren && children !== undefined ? (
<>{children}</>
) : (
<>
<Grid
ref={gridRef}
{...gridProps}
rows={data?.rows ?? []}
updateRow={props.updateTableRow}
onEditForeignKeyColumnValue={props.onEditForeignKeyColumnValue}
/>
<Footer isLoading={isLoading || isRefetching} />
<Shortcuts gridRef={gridRef} />
</>
)}
{mounted &&
createPortal(<RowContextMenu table={table} rows={data?.rows ?? []} />, document.body)}
</div>
@@ -28,22 +28,44 @@ export type HeaderProps = {
table: SupaTable
sorts: Sort[]
filters: Filter[]
isRefetching: boolean
onAddColumn?: () => void
onAddRow?: () => void
headerActions?: ReactNode
customHeader: ReactNode
}
const Header = ({ table, sorts, filters, onAddColumn, onAddRow, headerActions }: HeaderProps) => {
const Header = ({
table,
sorts,
filters,
onAddColumn,
onAddRow,
headerActions,
customHeader,
isRefetching,
}: HeaderProps) => {
const state = useTrackedState()
const { selectedRows } = state
return (
<div>
<div className="flex h-10 items-center justify-between bg-scale-100 px-5 py-1.5 dark:bg-scale-300">
{selectedRows.size > 0 ? (
<RowHeader table={table} sorts={sorts} filters={filters} />
{customHeader ? (
<>{customHeader}</>
) : (
<DefaultHeader table={table} onAddColumn={onAddColumn} onAddRow={onAddRow} />
<>
{selectedRows.size > 0 ? (
<RowHeader table={table} sorts={sorts} filters={filters} />
) : (
<DefaultHeader
table={table}
isRefetching={isRefetching}
onAddColumn={onAddColumn}
onAddRow={onAddRow}
/>
)}
</>
)}
<div className="sb-grid-header__inner">{headerActions}</div>
</div>
@@ -56,10 +78,11 @@ export default Header
type DefaultHeaderProps = {
table: SupaTable
isRefetching: boolean
onAddColumn?: () => void
onAddRow?: () => void
}
const DefaultHeader = ({ table, onAddColumn, onAddRow }: DefaultHeaderProps) => {
const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHeaderProps) => {
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
// [Joshen] Using this logic to block both column and row creation/update/delete
@@ -72,7 +95,7 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow }: DefaultHeaderProps) =>
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<RefreshButton table={table} />
<RefreshButton table={table} isRefetching={isRefetching} />
<FilterDropdown table={table} filters={filters as string[]} setParams={setParams} />
<SortPopover table={table} sorts={sorts as string[]} setParams={setParams} />
</div>
@@ -1,4 +1,4 @@
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { SupaTable } from 'components/grid/types'
import { sqlKeys } from 'data/sql/keys'
import { useParams } from 'hooks'
@@ -8,16 +8,14 @@ import { SupabaseGridQueue } from '../../constants'
export type RefreshButtonProps = {
table: SupaTable
isRefetching: boolean
}
const RefreshButton = ({ table }: RefreshButtonProps) => {
const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => {
const { ref } = useParams()
const queryClient = useQueryClient()
const queryKey = sqlKeys.query(ref, [table.schema, table.name])
const loading = useIsFetching({ queryKey }) > 0
const [status, setStatus] = useState<string>()
useEffect(() => {
@@ -61,7 +59,7 @@ const RefreshButton = ({ table }: RefreshButtonProps) => {
onClick={() => {
if (!status) onClick()
}}
loading={loading || status === 'saving'}
loading={isRefetching || status === 'saving'}
>
{!status ? 'Refresh' : status === 'saved' ? 'Changes saved' : 'Saving changes'}
</Button>
+16 -1
View File
@@ -1,5 +1,5 @@
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
import React from 'react'
import React, { ReactNode } from 'react'
import { Dictionary } from './base'
import { SupaRow, SupaTable } from './table'
@@ -81,6 +81,21 @@ export interface SupabaseGridProps {
row: any
column: any
}) => void
/**
* Show custom component passed as children instead of the grid editor
*/
showCustomChildren?: boolean
/**
* Custom header left most actions component
*/
customHeader?: ReactNode
/**
* Custom component passed as children
*/
children?: ReactNode
}
export interface SupabaseGridRef {
@@ -163,7 +163,7 @@ const ProviderForm: FC<Props> = ({ provider }) => {
<Button
htmlType="submit"
loading={isSubmitting}
disabled={!canUpdateConfig || noChanges}
disabled={isSubmitting || !canUpdateConfig || noChanges}
>
Save
</Button>
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { SidePanel, IconBookOpen } from 'ui'
import { useParams, useStore } from 'hooks'
import { GeneralContent, ResourceContent } from '../Docs'
import LangSelector from '../Docs/LangSelector'
import GeneratingTypes from '../Docs/GeneratingTypes'
import ActionBar from './SidePanelEditor/ActionBar'
import { useProjectApiQuery } from 'data/config/project-api-query'
import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query'
import { snakeToCamel } from 'lib/helpers'
interface APIDocumentationPanelProps {
visible: boolean
onClose: () => void
}
const APIDocumentationPanel = ({ visible, onClose }: APIDocumentationPanelProps) => {
const { meta } = useStore()
const { ref, page, id } = useParams()
const {
data: jsonSchema,
error: jsonSchemaError,
refetch,
} = useProjectJsonSchemaQuery({ projectRef: ref })
const { data: settings } = useProjectApiQuery({ projectRef: ref })
const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' }
const [selectedLang, setSelectedLang] = useState<any>('js')
const [showApiKey, setShowApiKey] = useState<any>(DEFAULT_KEY)
const tables = meta.tables.list()
const autoApiService = {
...settings?.autoApiService,
endpoint: `${settings?.autoApiService.protocol ?? 'https'}://${
settings?.autoApiService.endpoint ?? '-'
}`,
}
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
: undefined
const resources = getResourcesFromJsonSchema(jsonSchema)
function getResourcesFromJsonSchema(value: any) {
const { paths } = value || {}
const functionPath = 'rpc/'
let resources: any = {}
Object.entries(paths || []).forEach(([name, val]) => {
let trimmed = name.slice(1)
let id = trimmed.replace(functionPath, '')
let displayName = id.replace(/_/g, ' ')
let camelCase = snakeToCamel(id)
let enriched = { id, displayName, camelCase }
if (!trimmed.length) return
else resources[id] = enriched
})
return resources
}
return (
<SidePanel
key="WrapperTableEditor"
size="xxlarge"
visible={visible}
onCancel={() => onClose()}
header={
<span className="flex items-center gap-2">
<IconBookOpen size="tiny" />
API
</span>
}
customFooter={
<ActionBar
backButtonLabel="Close"
hideApply={true}
formId="wrapper-table-editor-form"
closePanel={() => onClose()}
/>
}
>
<div className="Docs Docs--table-editor">
<SidePanel.Content>
{jsonSchemaError ? (
<div className="p-6 mx-auto text-center sm:w-full md:w-3/4">
<div className="text-scale-1000">
<p>Error connecting to API</p>
<p>{`${jsonSchemaError}`}</p>
</div>
</div>
) : (
<>
{jsonSchema ? (
<>
<div className="sticky top-0 z-10 bg-scale-100 dark:bg-scale-300">
<LangSelector
selectedLang={selectedLang}
setSelectedLang={setSelectedLang}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
apiKey={anonKey}
autoApiService={autoApiService}
/>
</div>
<GeneralContent
autoApiService={autoApiService}
selectedLang={selectedLang}
showApiKey={true}
page={page}
/>
<GeneratingTypes selectedLang={selectedLang} />
{jsonSchema?.definitions && (
<ResourceContent
autoApiService={autoApiService}
selectedLang={selectedLang}
resourceId={tables.find((table) => table.id === Number(id))?.name}
resources={resources}
definitions={jsonSchema.definitions}
paths={jsonSchema.paths}
showApiKey={showApiKey.key}
refreshDocs={async () => await refetch()}
/>
)}
</>
) : (
<div className="p-6 mx-auto text-center sm:w-full md:w-3/4">
<h3 className="text-lg">Building docs ...</h3>
</div>
)}
</>
)}
</SidePanel.Content>
</div>
</SidePanel>
)
}
export default APIDocumentationPanel
@@ -127,7 +127,7 @@ const ForeignRowSelector = ({
<div className="h-full flex flex-col">
<div className="flex items-center justify-between my-2 mx-3">
<div className="flex items-center">
<RefreshButton table={supaTable} />
<RefreshButton table={supaTable} isRefetching={isRefetching} />
<FilterPopover
table={supaTable}
filters={params.filter ?? []}
@@ -18,17 +18,10 @@ import {
SupabaseGridRef,
SupaTable,
} from 'components/grid'
import { IconBookOpen, SidePanel } from 'ui'
import ActionBar from './SidePanelEditor/ActionBar'
import { GeneralContent, ResourceContent } from '../Docs'
import { sqlKeys } from 'data/sql/keys'
import { useProjectApiQuery } from 'data/config/project-api-query'
import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import { snakeToCamel } from 'lib/helpers'
import { JsonEditValue } from './SidePanelEditor/RowEditor/RowEditor.types'
import LangSelector from '../Docs/LangSelector'
import GeneratingTypes from '../Docs/GeneratingTypes'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import {
ForeignKeyConstraint,
@@ -36,6 +29,9 @@ import {
} from 'data/database/foreign-key-constraints-query'
import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants'
import { ForeignRowSelectorProps } from './SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
import ViewDefinition from './ViewDefinition'
import APIDocumentationPanel from './APIDocumentationPanel'
export interface TableGridEditorProps {
/** Theme for the editor */
@@ -95,30 +91,18 @@ const TableGridEditor = ({
onEditForeignKeyColumnValue = noop,
onClosePanel = noop,
}: TableGridEditorProps) => {
const { project } = useProjectContext()
const { meta, ui, vault } = useStore()
const router = useRouter()
const { ref: projectRef, page, id } = useParams()
const { ref: projectRef, id } = useParams()
const gridRef = useRef<SupabaseGridRef>(null)
const tables = meta.tables.list()
const { data: settings } = useProjectApiQuery({ projectRef: projectRef })
const autoApiService = {
...settings?.autoApiService,
endpoint: `${settings?.autoApiService.protocol ?? 'https'}://${
settings?.autoApiService.endpoint ?? '-'
}`,
}
const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' }
const { project } = useProjectContext()
const isVaultEnabled = useFlag('vaultExtension')
const [encryptedColumns, setEncryptedColumns] = useState([])
const [apiPreviewPanelOpen, setApiPreviewPanelOpen] = useState(false)
const [selectedLang, setSelectedLang] = useState<any>('js')
const [showApiKey, setShowApiKey] = useState<any>(DEFAULT_KEY)
// When rendering a view in the grid
const [selectedView, setSelectedView] = useState<'data' | 'definition'>('data')
const isReadOnly =
!checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') &&
@@ -188,37 +172,7 @@ const TableGridEditor = ({
},
})
function getResourcesFromJsonSchema(value: any) {
const { paths } = value || {}
const functionPath = 'rpc/'
let resources: any = {}
Object.entries(paths || []).forEach(([name, val]) => {
let trimmed = name.slice(1)
let id = trimmed.replace(functionPath, '')
let displayName = id.replace(/_/g, ' ')
let camelCase = snakeToCamel(id)
let enriched = { id, displayName, camelCase }
if (!trimmed.length) return
else resources[id] = enriched
})
return resources
}
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
: undefined
const {
data: jsonSchema,
error: jsonSchemaError,
refetch,
} = useProjectJsonSchemaQuery({ projectRef })
if (jsonSchemaError) console.error('jsonSchemaError', jsonSchemaError)
const resources = getResourcesFromJsonSchema(jsonSchema)
const { refetch } = useProjectJsonSchemaQuery({ projectRef })
const refreshDocs = async () => await refetch()
const { data } = useForeignKeyConstraintsQuery({
@@ -364,6 +318,11 @@ const TableGridEditor = ({
})
}
/** [Joshen] We're going to need to refactor SupabaseGrid eventually to make the code here more readable
* For context we previously built the SupabaseGrid as a reusable npm component, but eventually decided
* to just integrate it directly into the dashboard. The header, and body (+footer) should be decoupled.
*/
return (
<>
<SupabaseGrid
@@ -377,14 +336,24 @@ const TableGridEditor = ({
table={gridTable}
refreshDocs={refreshDocs}
headerActions={
canEditViaTableEditor && (
isViewSelected ? (
<div>
<TwoOptionToggle
width={75}
options={['definition', 'data']}
activeOption={selectedView}
borderOverride="border-gray-500"
onClickOption={setSelectedView}
/>
</div>
) : canEditViaTableEditor ? (
<GridHeaderActions
table={selectedTable as PostgresTable}
apiPreviewPanelOpen={apiPreviewPanelOpen}
setApiPreviewPanelOpen={setApiPreviewPanelOpen}
refreshDocs={refreshDocs}
/>
)
) : null
}
onAddColumn={onAddColumn}
onEditColumn={onSelectEditColumn}
@@ -396,7 +365,21 @@ const TableGridEditor = ({
onSqlQuery={onSqlQuery}
onExpandJSONEditor={onExpandJSONEditor}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
/>
showCustomChildren={isViewSelected && selectedView === 'definition'}
customHeader={
isViewSelected && selectedView === 'definition' ? (
<div className="flex items-center space-x-2">
<p>
SQL Definition of <code className="text-sm">{selectedTable.name}</code>{' '}
</p>
<p className="text-scale-1000 text-sm">(Read only)</p>
</div>
) : null
}
>
{isViewSelected && <ViewDefinition name={selectedTable.name} />}
</SupabaseGrid>
{!isUndefined(selectedSchema) && (
<SidePanelEditor
selectedSchema={selectedSchema}
@@ -416,81 +399,10 @@ const TableGridEditor = ({
/>
)}
<SidePanel
key="WrapperTableEditor"
size="xxlarge"
<APIDocumentationPanel
visible={apiPreviewPanelOpen}
onCancel={() => setApiPreviewPanelOpen(false)}
header={
<span className="flex items-center gap-2">
<IconBookOpen size="tiny" />
API
</span>
}
customFooter={
<ActionBar
backButtonLabel="Close"
hideApply={true}
formId="wrapper-table-editor-form"
closePanel={() => setApiPreviewPanelOpen(false)}
/>
}
>
<div className="Docs Docs--table-editor">
<SidePanel.Content>
{jsonSchemaError ? (
<div className="p-6 mx-auto text-center sm:w-full md:w-3/4">
<div className="text-scale-1000">
<p>Error connecting to API</p>
<p>{`${jsonSchemaError}`}</p>
</div>
</div>
) : (
<>
{jsonSchema ? (
<>
<div className="sticky top-0 z-10 bg-scale-100 dark:bg-scale-300">
<LangSelector
selectedLang={selectedLang}
setSelectedLang={setSelectedLang}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
apiKey={anonKey}
autoApiService={autoApiService}
/>
</div>
<GeneralContent
autoApiService={autoApiService}
selectedLang={selectedLang}
showApiKey={true}
page={page}
/>
<GeneratingTypes selectedLang={selectedLang} />
{jsonSchema?.definitions && (
<ResourceContent
autoApiService={autoApiService}
selectedLang={selectedLang}
resourceId={tables.find((table) => table.id === Number(id))?.name}
resources={resources}
definitions={jsonSchema.definitions}
paths={jsonSchema.paths}
showApiKey={showApiKey.key}
refreshDocs={refreshDocs}
/>
)}
</>
) : (
<div className="p-6 mx-auto text-center sm:w-full md:w-3/4">
<h3 className="text-lg">Building docs ...</h3>
</div>
)}
</>
)}
</SidePanel.Content>
</div>
</SidePanel>
onClose={() => setApiPreviewPanelOpen(false)}
/>
</>
)
}
@@ -0,0 +1,78 @@
import { useRef } from 'react'
import Editor from '@monaco-editor/react'
import { timeout } from 'lib/helpers'
import { useStore } from 'hooks'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useViewDefinitionQuery } from 'data/database/view-definition-query'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
export interface ViewDefinitionProps {
name: string
}
const ViewDefinition = ({ name }: ViewDefinitionProps) => {
const editorRef = useRef(null)
const monacoRef = useRef(null)
const { ui } = useStore()
const { isDarkTheme } = ui
const { project } = useProjectContext()
const { data: definition, isLoading } = useViewDefinitionQuery({
name,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const handleEditorOnMount = async (editor: any, monaco: any) => {
editorRef.current = editor
monacoRef.current = monaco
// add margin above first line
editor.changeViewZones((accessor: any) => {
accessor.addZone({
afterLineNumber: 0,
heightInPx: 4,
domNode: document.createElement('div'),
})
})
// when editor did mount, it will need a delay before focus() works properly
await timeout(500)
editor?.focus()
}
if (isLoading) {
return (
<div className="py-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)
}
return (
<div className="flex-grow overflow-y-auto border-t border-scale-400">
<Editor
className="monaco-editor"
theme={isDarkTheme ? 'vs-dark' : 'vs'}
onMount={handleEditorOnMount}
defaultLanguage="pgsql"
defaultValue={definition}
path={''}
options={{
domReadOnly: true,
readOnly: true,
tabSize: 2,
fontSize: 13,
minimap: { enabled: false },
wordWrap: 'on',
fixedOverflowWidgets: true,
}}
/>
</div>
)
}
export default ViewDefinition
@@ -28,15 +28,13 @@ const FolderContextMenu: FC<Props> = ({ id = '' }) => {
<IconDownload size="tiny" />
<span className="ml-2 text-xs">Download</span>
</Item>
{canUpdateFiles && (
<>
<Separator />
<Item onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
<IconTrash2 size="tiny" />
<span className="ml-2 text-xs">Delete</span>
</Item>
</>
)}
{canUpdateFiles && [
<Separator />,
<Item onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
<IconTrash2 size="tiny" />
<span className="ml-2 text-xs">Delete</span>
</Item>,
]}
</Menu>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ const TwoOptionToggle: FC<Props> = ({
return (
<div
className={`relative border ${borderOverride} rounded-md h-8`}
className={`relative border ${borderOverride} rounded-md h-7`}
style={{ padding: 1, width: (width + 1) * 2 }}
>
<span
@@ -0,0 +1,71 @@
import { UseQueryOptions } from '@tanstack/react-query'
import { useCallback } from 'react'
import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query'
type getViewDefinition = {
name?: string
}
type ViewDefinition = {
id: number
constraint_name: string
deletion_action: string
source_id: string
source_schema: string
source_table: string
source_columns: string
target_id: string
target_schema: string
target_table: string
target_columns: string
}
export const getViewDefinitionQuery = ({ name }: getViewDefinition) => {
const sql = /* SQL */ `
select pg_get_viewdef('${name}', true) as definition
`.trim()
return sql
}
export type ViewDefinitionVariables = getViewDefinition & {
projectRef?: string
connectionString?: string
}
export type ViewDefinitionData = any
export type ViewDefinitionError = unknown
export const useViewDefinitionQuery = <TData extends ViewDefinitionData = ViewDefinitionData>(
{ projectRef, connectionString, name }: ViewDefinitionVariables,
options: UseQueryOptions<ExecuteSqlData, ViewDefinitionError, TData> = {}
) => {
return useExecuteSqlQuery(
{
projectRef,
connectionString,
sql: getViewDefinitionQuery({ name }),
queryKey: ['view-definition', name],
},
{
select(data) {
return data.result[0].definition.trim()
},
...options,
}
)
}
export const useViewDefinitionQueryPrefetch = () => {
const prefetch = useExecuteSqlPrefetch()
return useCallback(
({ projectRef, connectionString, name }: ViewDefinitionVariables) =>
prefetch({
projectRef,
connectionString,
sql: getViewDefinitionQuery({ name }),
queryKey: ['view-definition', name],
}),
[prefetch]
)
}