diff --git a/apps/docs/components/Clippy/ClippyModal.tsx b/apps/docs/components/Clippy/ClippyModal.tsx index cb38781fda1..cc782b7591a 100644 --- a/apps/docs/components/Clippy/ClippyModal.tsx +++ b/apps/docs/components/Clippy/ClippyModal.tsx @@ -123,7 +123,7 @@ const ClippyModal: FC = ({ onClose }) => { return (
e.stopPropagation()} >
diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx index 4588fc5864e..94e24890e15 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx @@ -11,12 +11,14 @@ import clippyImage from '../../../public/img/clippy.png' import { getPageType } from '~/lib/helpers' import { useClippy } from '~/components/Clippy/ClippyProvider' +import { useFlag } from '~/hooks/useFlag' const TopNavBarRef: FC = () => { const { isDarkMode, toggleTheme } = useTheme() const [mounted, setMounted] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const { open: openClippy } = useClippy() + const enableClippy = useFlag('enableClippy') const { asPath, push } = useRouter() const pathSegments = asPath.split('/') @@ -89,15 +91,17 @@ const TopNavBarRef: FC = () => {
-
- Clippy -
+ {enableClippy && ( +
+ Clippy +
+ )}
+ + 語言 + 客戶端 + 精選客戶端(包含在 Supabase 客戶端) + + + + Supabase + PostgREST + GoTrue + Realtime + Storage + Functions + + + + ⚡️ 官方 ⚡️ + + JavaScript (TypeScript) + supabase-js + postgrest-js + gotrue-js + realtime-js + storage-js + functions-js + + + Flutter + supabase-flutter + postgrest-dart + gotrue-dart + realtime-dart + storage-dart + functions-dart + + 💚 社群 💚 + + C# + supabase-csharp + postgrest-csharp + gotrue-csharp + realtime-csharp + storage-csharp + functions-csharp + + + Go + - + postgrest-go + gotrue-go + - + storage-go + functions-go + + + Java + - + - + gotrue-java + - + storage-java + - + + + Kotlin + supabase-kt + postgrest-kt + gotrue-kt + realtime-kt + storage-kt + functions-kt + + + Python + supabase-py + postgrest-py + gotrue-py + realtime-py + storage-py + functions-py + + + Ruby + supabase-rb + postgrest-rb + - + - + - + - + + + Rust + - + postgrest-rs + - + - + - + - + + + Swift + supabase-swift + postgrest-swift + gotrue-swift + realtime-swift + storage-swift + functions-swift + + + Godot Engine (GDScript) + supabase-gdscript + postgrest-gdscript + gotrue-gdscript + realtime-gdscript + storage-gdscript + functions-gdscript + + diff --git a/package-lock.json b/package-lock.json index 5bb23e0dd7c..78869d57003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38976,7 +38976,6 @@ "semver": "^6.3.0", "shared-data": "*", "sqlstring": "^2.3.2", - "swr": "^1.2.1", "ui": "*", "uniforms-bootstrap4": "^3.6.2", "uniforms-bridge-json-schema": "^3.6.2", @@ -39318,14 +39317,6 @@ "react-dom": "^16.0.0 || ^17.0.0" } }, - "studio/node_modules/swr": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz", - "integrity": "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==", - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" - } - }, "studio/node_modules/typescript": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", @@ -65856,7 +65847,6 @@ "shared-data": "*", "sqlstring": "^2.3.2", "storybook-dark-mode": "^1.0.4", - "swr": "^1.2.1", "tailwindcss": "^3.0.22", "ts-jest": "^27.0.7", "typescript": "4.8.4", @@ -66083,12 +66073,6 @@ "reduce-css-calc": "^2.1.8" } }, - "swr": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz", - "integrity": "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==", - "requires": {} - }, "typescript": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", diff --git a/packages/ui/src/components/Dropdown/Dropdown.tsx b/packages/ui/src/components/Dropdown/Dropdown.tsx index a957a36399c..50d8861002a 100644 --- a/packages/ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/ui/src/components/Dropdown/Dropdown.tsx @@ -80,6 +80,7 @@ export function RightSlot({ children }: any) { } interface ItemProps { + className?: string children: React.ReactNode icon?: React.ReactNode disabled?: boolean @@ -87,11 +88,15 @@ interface ItemProps { rightSlot?: React.ReactNode } -export function Item({ children, icon, disabled, onClick, rightSlot }: ItemProps) { +export function Item({ className, children, icon, disabled, onClick, rightSlot }: ItemProps) { let __styles = styleHandler('dropdown') return ( - + {icon && icon} {children} diff --git a/packages/ui/src/components/Toast/Toast.tsx b/packages/ui/src/components/Toast/Toast.tsx index 9607f7372ca..6841327e2a7 100644 --- a/packages/ui/src/components/Toast/Toast.tsx +++ b/packages/ui/src/components/Toast/Toast.tsx @@ -1,13 +1,6 @@ import React, { ComponentProps } from 'react' -import { - Toaster as HotToaster, - toast as hotToast, - resolveValue, -} from 'react-hot-toast' -import { - Toast as HotToastProps, - ToastType, -} from 'react-hot-toast/dist/core/types' +import { Toaster as HotToaster, toast as hotToast, resolveValue } from 'react-hot-toast' +import { Toast as HotToastProps, ToastType } from 'react-hot-toast/dist/core/types' import * as Portal from '@radix-ui/react-portal' import { IconCheck } from '../Icon/icons/IconCheck' import { IconLoader } from '../Icon/icons/IconLoader' @@ -30,10 +23,7 @@ export interface ToastProps extends Partial { width?: 'xs' | 'sm' | 'md' } -function Message({ - children, - ...props -}: ComponentProps) { +function Message({ children, ...props }: ComponentProps) { return ( {children} @@ -41,15 +31,9 @@ function Message({ ) } -function Description({ - children, - ...props -}: ComponentProps) { +function Description({ children, ...props }: ComponentProps) { return ( - + {children} ) @@ -100,18 +84,10 @@ function Toast({ } const _message = - typeof message === 'string' ? ( - {message} - ) : ( - resolveValue(message, rest) - ) + typeof message === 'string' ? {message} : resolveValue(message, rest) return ( -
+
{type === 'loading' ? ( @@ -129,28 +105,21 @@ function Toast({ {_message} {description && {description}}
- {actions && ( -
- {actions} -
- )} + {actions &&
{actions}
}
{closable && (
)} @@ -200,8 +169,7 @@ type ToastOptions = Partial< > export function toast(message: string, opts?: ToastOptions) { - const { description, closable, actions, actionsPosition, type, ...rest } = - opts || {} + const { description, closable, actions, actionsPosition, type, ...rest } = opts || {} return hotToast( ({ message: _m, type: _t, ...t }) => ( @@ -219,16 +187,13 @@ export function toast(message: string, opts?: ToastOptions) { ) } -const createToastType = (type: ToastType) => ( - message: string, - opts?: Omit -) => toast(message, { ...opts, type }) +const createToastType = (type: ToastType) => (message: string, opts?: Omit) => + toast(message, { ...opts, type }) toast.success = createToastType('success') toast.error = createToastType('error') toast.loading = createToastType('loading') -toast.promise = (...args: Parameters) => - hotToast.promise(...args) +toast.promise = (...args: Parameters) => hotToast.promise(...args) Toast.Toaster = Toaster Toast.toast = toast diff --git a/spec/supabase_js_v2.yml b/spec/supabase_js_v2.yml index 2d9f55b79ac..f2e625f0edc 100644 --- a/spec/supabase_js_v2.yml +++ b/spec/supabase_js_v2.yml @@ -4440,7 +4440,8 @@ functions: .from('avatars') .update('public/avatar1.png', avatarFile, { cacheControl: '3600', - upsert: false + // Overwrite file if it exists + upsert: true }) ``` - id: update-file-using-arraybuffer-from-base64-file-data diff --git a/studio/__mocks__/data/subscriptions/project-subscription-query.js b/studio/__mocks__/data/subscriptions/project-subscription-query.js new file mode 100644 index 00000000000..8f95fdbac4e --- /dev/null +++ b/studio/__mocks__/data/subscriptions/project-subscription-query.js @@ -0,0 +1,7 @@ +export const useProjectSubscriptionQuery = jest.fn().mockReturnValue({ + data: { + tier: { + supabase_prod_id: 'tier_free', + }, + }, +}) diff --git a/studio/__mocks__/hooks.js b/studio/__mocks__/hooks.js index e6d89720b13..77e269ed6c9 100644 --- a/studio/__mocks__/hooks.js +++ b/studio/__mocks__/hooks.js @@ -1,20 +1,15 @@ export const useFlag = jest.fn().mockReturnValue(true) export const checkPermissions = jest.fn().mockReturnValue(true) -export const useProjectSubscription = jest.fn().mockReturnValue({ - subscription: { - tier: { - supabase_prod_id: 'tier_free', - }, - }, -}) const { useStore, StoreProvider } = jest.requireActual('hooks') export const useStore = useStore +export const useParams = jest.fn().mockImplementation(() => ({ ref: '123' })) + // mocks browser event listener adding/removing window.matchMedia = jest.fn().mockReturnValue({ removeEventListener: jest.fn(), }) export const StoreProvider = StoreProvider -export const withAuth = jest.fn() \ No newline at end of file +export const withAuth = jest.fn() diff --git a/studio/components/grid/SupabaseGrid.tsx b/studio/components/grid/SupabaseGrid.tsx index 75b3abf65b9..4e3e2d5ab0b 100644 --- a/studio/components/grid/SupabaseGrid.tsx +++ b/studio/components/grid/SupabaseGrid.tsx @@ -184,6 +184,7 @@ const SupabaseGridLayout = forwardRef((props return (
{}, }) dispatch({ diff --git a/studio/components/grid/components/common/BlockKeys.tsx b/studio/components/grid/components/common/BlockKeys.tsx index c64ef804e2d..388fb92703c 100644 --- a/studio/components/grid/components/common/BlockKeys.tsx +++ b/studio/components/grid/components/common/BlockKeys.tsx @@ -1,4 +1,5 @@ -import { FC, useCallback, KeyboardEvent, ReactNode } from 'react' +import { useClickedOutside } from 'hooks' +import { FC, useRef, useEffect, useCallback, KeyboardEvent, ReactNode } from 'react' interface BlockKeysProps { value: string | null @@ -13,6 +14,9 @@ interface BlockKeysProps { * Example: press enter to add newline on textEditor */ export const BlockKeys: FC = ({ value, children, onEscape, onEnter }) => { + const ref = useRef(null) + const isClickedOutside = useClickedOutside(ref) + const handleKeyDown = useCallback( (ev: KeyboardEvent) => { switch (ev.key) { @@ -32,12 +36,12 @@ export const BlockKeys: FC = ({ value, children, onEscape, onEnt [value] ) - function onBlur() { - if (onEnter) onEnter(value) - } + useEffect(() => { + if (isClickedOutside && onEnter !== undefined) onEnter(value) + }, [isClickedOutside]) return ( -
+
{children}
) diff --git a/studio/components/grid/components/editor/JsonEditor.tsx b/studio/components/grid/components/editor/JsonEditor.tsx index 7d2670beba4..d068097412a 100644 --- a/studio/components/grid/components/editor/JsonEditor.tsx +++ b/studio/components/grid/components/editor/JsonEditor.tsx @@ -1,15 +1,22 @@ -import { Popover } from 'ui' +import { IconMaximize, Popover } from 'ui' import { useState, useCallback } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' import { EditorProps } from '@supabase/react-data-grid' import { useTrackedState } from 'components/grid/store' import { BlockKeys, MonacoEditor, NullValue } from 'components/grid/components/common' +import { tryParseJson } from 'lib/helpers' + +interface JsonEditorProps extends EditorProps { + onExpandEditor: (column: string, row: TRow) => void +} export function JsonEditor({ row, column, onRowChange, -}: EditorProps) { + onExpandEditor, +}: JsonEditorProps) { const state = useTrackedState() const gridColumn = state.gridColumns.find((x) => x.name == column.key) @@ -28,12 +35,20 @@ export function JsonEditor({ commitChange(newValue) }, []) - function onChange(_value: string | undefined) { + const onChange = (_value: string | undefined) => { if (!_value || _value == '') setValue(null) else setValue(_value) } - function commitChange(newValue: string | null) { + const onSelectExpand = () => { + cancelChanges() + onExpandEditor(column.key, { + ...row, + [column.key]: tryParseJson(value) || (row as any)[column.key], + }) + } + + const commitChange = (newValue: string | null) => { if (!newValue) { onRowChange({ ...row, [column.key]: null }, true) setIsPopoverOpen(false) @@ -62,9 +77,45 @@ export function JsonEditor({ language="json" onChange={onChange} /> -
-

Save changes

- ⏎ +
+
+
+
+ ⏎ +
+

Save changes

+
+
+
+ Esc +
+

Cancel changes

+
+
+ + +
onSelectExpand()} + > + +
+
+ + +
+ Expand editor +
+
+
} diff --git a/studio/components/grid/components/editor/TextEditor.tsx b/studio/components/grid/components/editor/TextEditor.tsx index ac375f9178c..2b0fa077dc3 100644 --- a/studio/components/grid/components/editor/TextEditor.tsx +++ b/studio/components/grid/components/editor/TextEditor.tsx @@ -44,9 +44,21 @@ export function TextEditor({ value={value ?? ''} onChange={onChange} /> -
-

Save changes

- ⏎ +
+
+
+
+ ⏎ +
+

Save changes

+
+
+
+ Esc +
+

Cancel changes

+
+
} diff --git a/studio/components/grid/components/header/Header.tsx b/studio/components/grid/components/header/Header.tsx index 4ad75e4eaef..d839ced0cfd 100644 --- a/studio/components/grid/components/header/Header.tsx +++ b/studio/components/grid/components/header/Header.tsx @@ -1,5 +1,14 @@ import { FC, useState, ReactNode } from 'react' -import { Button, IconDownload, IconPlus, IconX, IconTrash } from 'ui' +import { + Button, + IconDownload, + IconPlus, + IconX, + IconTrash, + Dropdown, + IconColumns, + IconChevronDown, +} from 'ui' import { saveAs } from 'file-saver' import { useStore } from 'hooks' @@ -7,7 +16,7 @@ import FilterDropdown from './filter' import SortPopover from './sort' import RefreshButton from './RefreshButton' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' -import { Sort, Filter } from 'components/grid/types' +import { Sort, Filter, SupaTable } from 'components/grid/types' import { exportRowsToCsv } from 'components/grid/utils' import { useDispatch, useTrackedState } from 'components/grid/store' @@ -17,6 +26,7 @@ import { useDispatch, useTrackedState } from 'components/grid/store' const MAX_EXPORT_ROW_COUNT = 500000 interface HeaderProps { + table: Partial sorts: Sort[] filters: Filter[] onAddColumn?: () => void @@ -24,7 +34,14 @@ interface HeaderProps { headerActions?: ReactNode } -const Header: FC = ({ sorts, filters, onAddColumn, onAddRow, headerActions }) => { +const Header: FC = ({ + table, + sorts, + filters, + onAddColumn, + onAddRow, + headerActions, +}) => { const state = useTrackedState() const { selectedRows } = state @@ -34,6 +51,7 @@ const Header: FC = ({ sorts, filters, onAddColumn, onAddRow, header ) : ( = ({ sorts, filters, onAddColumn, onAddRow, header export default Header interface DefaultHeaderProps { + table: Partial sorts: Sort[] filters: Filter[] onAddColumn?: () => void onAddRow?: () => void } -const DefaultHeader: FC = ({ sorts, filters, onAddColumn, onAddRow }) => { - const renderNewColumn = (onAddColumn?: () => void) => { - if (!onAddColumn) return null - return ( - - ) - } - - const renderAddRow = (onAddRow?: () => void) => { - if (!onAddRow) return null - return ( - - ) - } +const DefaultHeader: FC = ({ + table, + sorts, + filters, + onAddColumn, + onAddRow, +}) => { + const canAddNew = onAddRow !== undefined || onAddColumn !== undefined return (
@@ -78,11 +87,78 @@ const DefaultHeader: FC = ({ sorts, filters, onAddColumn, on
-
-
- {renderNewColumn(onAddColumn)} - {renderAddRow(onAddRow)} -
+ {canAddNew && ( + <> +
+
+ +
+
+
+
+ } + > +
+

Insert row

+

Insert a new row into {table.name}

+
+ , + ] + : []), + ...(onAddColumn !== undefined + ? [ + +
+
+
+
+ } + > +
+

Insert column

+

Insert a new column into {table.name}

+
+ , + ] + : []), + ]} + > + + +
+ + )}
) } diff --git a/studio/components/grid/components/menu/RowContextMenu.tsx b/studio/components/grid/components/menu/RowContextMenu.tsx index 28f2b6430a2..e0dd6c1da4b 100644 --- a/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/studio/components/grid/components/menu/RowContextMenu.tsx @@ -70,18 +70,22 @@ const RowContextMenu: React.FC = ({}) => { return ( <> - + - Copy cell content + Copy cell content diff --git a/studio/components/grid/types/grid.ts b/studio/components/grid/types/grid.ts index e05352cfdfc..a036249a97d 100644 --- a/studio/components/grid/types/grid.ts +++ b/studio/components/grid/types/grid.ts @@ -17,6 +17,7 @@ export interface SupabaseGridProps { */ table: SupaTable | string /** + * * run sql query */ onSqlQuery: (query: string) => Promise<{ data?: any; error?: any }> @@ -25,9 +26,6 @@ export interface SupabaseGridProps { * Optional react node to display in grid header */ headerActions?: React.ReactNode - /** - * enable table editor - */ editable?: boolean /** * props to config grid view @@ -65,10 +63,8 @@ export interface SupabaseGridProps { * show edit row button if available */ onEditRow?: (row: SupaRow) => void - /** - * error handler - */ onError?: (error: any) => void + onExpandJSONEditor: (column: string, row: SupaRow) => void } export interface SupabaseGridRef { diff --git a/studio/components/grid/utils/gridColumns.tsx b/studio/components/grid/utils/gridColumns.tsx index ea5c1aae385..87021fc6ce1 100644 --- a/studio/components/grid/utils/gridColumns.tsx +++ b/studio/components/grid/utils/gridColumns.tsx @@ -42,6 +42,7 @@ export function getGridColumns( editable?: boolean defaultWidth?: string | number onAddColumn?: () => void + onExpandJSONEditor: (column: string, row: SupaRow) => void } ): any[] { const columns = table.columns.map((x, idx) => { @@ -76,7 +77,9 @@ export function getGridColumns( format={x.format} /> ), - editor: options?.editable ? getColumnEditor(x, columnType) : undefined, + editor: options?.editable + ? getColumnEditor(x, columnType, options.onExpandJSONEditor) + : undefined, formatter: getColumnFormatter(x, columnType), } @@ -91,7 +94,11 @@ export function getGridColumns( return gridColumns } -function getColumnEditor(columnDefinition: SupaColumn, columnType: ColumnType) { +function getColumnEditor( + columnDefinition: SupaColumn, + columnType: ColumnType, + onExpandJSONEditor: (column: string, row: any) => void +) { if (columnDefinition.isPrimaryKey || !columnDefinition.isUpdatable) { return } @@ -117,7 +124,7 @@ function getColumnEditor(columnDefinition: SupaColumn, columnType: ColumnType) { } case 'array': case 'json': { - return JsonEditor + return (p: any) => } case 'number': { return NumberEditor diff --git a/studio/components/interfaces/Account/AccessTokenList.tsx b/studio/components/interfaces/Account/AccessTokenList.tsx index 0e3aba63e4b..8abe747f6e2 100644 --- a/studio/components/interfaces/Account/AccessTokenList.tsx +++ b/studio/components/interfaces/Account/AccessTokenList.tsx @@ -1,9 +1,8 @@ import { useState } from 'react' import { Button, Modal, IconTrash } from 'ui' import { useStore } from 'hooks' -import { delete_ } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { AccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens' +import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' +import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation' import { observer } from 'mobx-react-lite' import Table from 'components/to-be-cleaned/Table' @@ -11,21 +10,21 @@ import ConfirmationModal from 'components/ui/ConfirmationModal' const AccessTokenList = observer(() => { const { ui } = useStore() - const { mutateDeleteToken } = useAccessTokens() - const { tokens, isLoading } = useAccessTokens() + const { data: tokens, isLoading } = useAccessTokensQuery() + const { mutateAsync: deleteToken } = useAccessTokenDeleteMutation() + const [isOpen, setIsOpen] = useState(false) const [token, setToken] = useState(undefined) async function onDeleteToken(tokenId: number) { - const response = await delete_(`${API_URL}/profile/access-tokens/${tokenId}`) - if (response.error) { + try { + await deleteToken({ id: tokenId }) + setIsOpen(false) + } catch (error: any) { ui.setNotification({ category: 'error', - message: `Failed to delete token: ${response.error.message}`, + message: `Failed to delete token: ${error.message}`, }) - } else { - mutateDeleteToken(tokenId) - setIsOpen(false) } } @@ -50,7 +49,7 @@ const AccessTokenList = observer(() => { ) : ( <> - {tokens?.map((x: AccessToken) => { + {tokens?.map((x) => { return ( diff --git a/studio/components/interfaces/Account/NewAccessTokenButton.tsx b/studio/components/interfaces/Account/NewAccessTokenButton.tsx index edf5e24de1d..4bdf9e36fa6 100644 --- a/studio/components/interfaces/Account/NewAccessTokenButton.tsx +++ b/studio/components/interfaces/Account/NewAccessTokenButton.tsx @@ -1,14 +1,14 @@ import { FC, useState } from 'react' import { Input, Button, Modal, Form, Alert } from 'ui' import { useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { NewAccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens' +import { + NewAccessToken, + useAccessTokenCreateMutation, +} from 'data/access-tokens/access-tokens-create-mutation' import { observer } from 'mobx-react-lite' const NewAccessTokenButton = observer(() => { const { ui } = useStore() - const { mutateNewToken } = useAccessTokens() const [isOpen, setIsOpen] = useState(false) const [newToken, setNewToken] = useState(undefined) @@ -20,21 +20,24 @@ const NewAccessTokenButton = observer(() => { return errors } + const { mutateAsync: createAccessToken } = useAccessTokenCreateMutation() + async function onFormSubmit(values: any, { setSubmitting }: any) { setSubmitting(true) - const response = await post(`${API_URL}/profile/access-tokens`, { name: values.tokenName }) - if (response.error) { - ui.setNotification({ - category: 'error', - message: `Failed to create token: ${response.error.message}`, - }) - setSubmitting(false) - } else { - mutateNewToken(response) + + try { + const response = await createAccessToken({ name: values.tokenName }) setNewToken(response) setSubmitting(false) setIsOpen(false) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to create token: ${error.message}`, + }) + + setSubmitting(false) } } @@ -68,7 +71,7 @@ const NewAccessTokenButton = observer(() => { validate={validate} > {({ isSubmitting }: { isSubmitting: boolean }) => ( -
+
= observer(({ data }) => { copy readOnly size="small" - className="input-mono max-w-xl" + className="max-w-xl input-mono" value={data.token} onChange={() => {}} /> diff --git a/studio/components/interfaces/Billing/Subscription/Subscription.tsx b/studio/components/interfaces/Billing/Subscription/Subscription.tsx index f055eaa1bbc..27e92bba8aa 100644 --- a/studio/components/interfaces/Billing/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Billing/Subscription/Subscription.tsx @@ -5,7 +5,7 @@ import { Button, Loading } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore, useFlag, useProjectUsage } from 'hooks' +import { checkPermissions, useFlag, useParams } from 'hooks' import { STRIPE_PRODUCT_IDS } from 'lib/constants' import { formatBytes } from 'lib/helpers' @@ -14,6 +14,7 @@ import CostBreakdownRow from './CostBreakdownRow' import { StripeSubscription } from './Subscription.types' import NoPermission from 'components/ui/NoPermission' import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' interface Props { project: any @@ -32,10 +33,9 @@ const Subscription: FC = ({ currentPeriodStart, currentPeriodEnd, }) => { - const { ui } = useStore() const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const canReadSubscription = checkPermissions(PermissionAction.READ, 'subscriptions') @@ -44,7 +44,7 @@ const Subscription: FC = ({ 'stripe.subscriptions' ) - const { usage, isLoading: loadingUsage } = useProjectUsage(ref as string) + const { data: usage, isLoading: loadingUsage } = useProjectUsageQuery({ projectRef }) const isPayg = subscription?.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG const isEnterprise = subscription.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE @@ -62,7 +62,7 @@ const Subscription: FC = ({ ? 0 : Object.keys(usage) .map((productKey) => { - return usage[productKey].cost ?? 0 + return usage[productKey as keyof ProjectUsageResponse].cost ?? 0 }) .reduce((prev, current) => prev + current, 0) @@ -71,7 +71,7 @@ const Subscription: FC = ({ return ( -
+
@@ -134,7 +134,7 @@ const Subscription: FC = ({

)} -
+

See our{' '} @@ -151,7 +151,7 @@ const Subscription: FC = ({ ) : !loading && subscription ? ( // Cost breakdown <> -

+

Item

@@ -183,9 +183,11 @@ const Subscription: FC = ({ {isPayg && USAGE_BASED_PRODUCTS.map((product) => { return product.features.map((feature) => { - const amount = usage?.[feature.key]?.usage ?? 0 - const limit = usage?.[feature.key]?.limit ?? 0 - const cost = (usage?.[feature.key]?.cost ?? 0).toFixed(2) + const amount = usage?.[feature.key as keyof ProjectUsageResponse]?.usage ?? 0 + const limit = usage?.[feature.key as keyof ProjectUsageResponse]?.limit ?? 0 + const cost = ( + usage?.[feature.key as keyof ProjectUsageResponse]?.cost ?? 0 + ).toFixed(2) return ( { - const { ref } = useParams() - const { subscription } = useProjectSubscription(ref) + const { ref: projectRef } = useParams() + const { data: subscription } = useProjectSubscriptionQuery({ projectRef }) const retentionPeriod = getPITRRetentionDuration(subscription?.addons ?? []) return ( @@ -14,10 +15,10 @@ const PITRNotice = ({}) => { disabled={true} footer={
} > -
-
+
+
diff --git a/studio/components/interfaces/Functions/TerminalInstructions.tsx b/studio/components/interfaces/Functions/TerminalInstructions.tsx index dbb81db4061..f8f30b523ab 100644 --- a/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -1,12 +1,12 @@ -import { useRouter } from 'next/router' -import { Button, IconTerminal, IconMaximize2, IconMinimize2, IconBookOpen, IconCode } from 'ui' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { useAccessTokens } from 'hooks/queries/useAccessTokens' -import { Commands } from './Functions.types' import CommandRender from 'components/interfaces/Functions/CommandRender' -import { FC, useState } from 'react' -import { useStore } from 'hooks' +import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useParams } from 'hooks' import Link from 'next/link' +import { useRouter } from 'next/router' +import { FC, useState } from 'react' +import { Button, IconBookOpen, IconCode, IconMaximize2, IconMinimize2, IconTerminal } from 'ui' +import { Commands } from './Functions.types' interface Props { closable?: boolean @@ -14,21 +14,21 @@ interface Props { const TerminalInstructions: FC = ({ closable = false }) => { const router = useRouter() - const { ref } = router.query - const { ui } = useStore() - - const { tokens } = useAccessTokens() - const { data: settings } = useProjectSettingsQuery({ projectRef: ref as string }) + const { ref: projectRef } = useParams() const [showInstructions, setShowInstructions] = useState(!closable) - // Get the API service + const { data: tokens } = useAccessTokensQuery() + + const { data: settings } = useProjectApiQuery({ + projectRef, + }) const apiService = settings?.autoApiService - const anonKey = apiService?.service_api_keys.find((x: any) => x.name === 'anon key') + const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') ? apiService.defaultApiKey : undefined + const endpoint = settings?.autoApiService.app_config.endpoint ?? '' - const endpoint = apiService?.endpoint ?? '' const endpointSections = endpoint.split('.') const functionsEndpoint = [ ...endpointSections.slice(0, 1), @@ -37,8 +37,8 @@ const TerminalInstructions: FC = ({ closable = false }) => { ].join('.') // get the .co or .net TLD from the restUrl - const restUrl = ui.selectedProject?.restUrl - const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() + const restUrl = settings?.autoApiService.restUrl + const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : '' const commands: Commands[] = [ { @@ -54,20 +54,20 @@ const TerminalInstructions: FC = ({ closable = false }) => { comment: 'Create a function', }, { - command: `supabase functions deploy hello-world --project-ref ${ref}`, + command: `supabase functions deploy hello-world --project-ref ${projectRef}`, description: 'Deploys function at ./functions/hello-world/index.ts', jsx: () => { return ( <> supabase functions deploy hello-world - --project-ref {ref} + --project-ref {projectRef} ) }, comment: 'Deploy your function', }, { - command: `curl -L -X POST 'https://${ref}.functions.supabase.${restUrlTld}/hello-world' -H 'Authorization: Bearer ${ + command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/hello-world' -H 'Authorization: Bearer ${ anonKey ?? '[YOUR ANON KEY]' }' --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', @@ -86,13 +86,13 @@ const TerminalInstructions: FC = ({ closable = false }) => { return (
-
+
-
+

Terminal instructions

@@ -112,7 +112,7 @@ const TerminalInstructions: FC = ({ closable = false }) => {
{tokens && tokens.length === 0 ? ( -
+

You may need to create an access token

@@ -124,7 +124,7 @@ const TerminalInstructions: FC = ({ closable = false }) => {

) : ( -
+

Need help?

diff --git a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 20b89d4cce5..a2fc1baf0dd 100644 --- a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -1,19 +1,18 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import Link from 'next/link' import { useState } from 'react' -import { useRouter } from 'next/router' -import { Input, IconLoader, IconAlertCircle } from 'ui' -import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { IconAlertCircle, IconLoader, Input } from 'ui' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { checkPermissions, useJwtSecretUpdateStatus } from 'hooks' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { checkPermissions, useParams } from 'hooks' import Snippets from 'components/to-be-cleaned/Docs/Snippets' import Panel from 'components/ui/Panel' import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' const APIKeys = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const availableLanguages = [ { name: 'Javascript', key: 'js' }, @@ -25,13 +24,16 @@ const APIKeys = () => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettingsQuery({ projectRef: ref as string }) + } = useProjectApiQuery({ + projectRef, + }) const { - jwtSecretUpdateStatus, + data, isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, - }: any = useJwtSecretUpdateStatus(ref) + } = useJwtSecretUpdatingStatusQuery({ projectRef }) + const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') @@ -45,7 +47,7 @@ const APIKeys = () => { jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` - const anonKey = apiKeys.find((key: any) => key.tags === 'anon') + const anonKey = apiKeys.find((key) => key.tags === 'anon') const clientInitSnippet: any = Snippets.init(apiUrl) const selectedLanguageSnippet = @@ -65,14 +67,14 @@ const APIKeys = () => { } > {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( -

+

{isProjectSettingsError ? 'Failed to retrieve API keys' : 'Failed to update JWT secret'}

) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( -
+

{isProjectSettingsLoading || isApiKeysEmpty @@ -135,8 +137,8 @@ const APIKeys = () => { This key is safe to use in a browser if you have enabled Row Level Security (RLS) for your tables and configured policies. You may also use the service key which can be found{' '} - - here + + here {' '} to bypass RLS.

diff --git a/studio/components/interfaces/Home/ProjectUsage.tsx b/studio/components/interfaces/Home/ProjectUsage.tsx index 3bdd1ed3a8a..d113c2bd5c9 100644 --- a/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/studio/components/interfaces/Home/ProjectUsage.tsx @@ -1,4 +1,3 @@ -import useSWR from 'swr' import dayjs from 'dayjs' import Link from 'next/link' import { FC, useState } from 'react' @@ -6,9 +5,10 @@ import { useRouter } from 'next/router' import { Button, Dropdown, IconArchive, IconChevronDown, IconDatabase, IconKey, IconZap } from 'ui' import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' import Panel from 'components/ui/Panel' -import { get } from 'lib/common/fetch' -import { API_URL, DATE_FORMAT, METRICS } from 'lib/constants' +import { DATE_FORMAT, METRICS } from 'lib/constants' import { ChartIntervals } from 'types' +import { useParams } from 'hooks' +import { useProjectLogStatsQuery } from 'data/logs/project-log-stats-query' const CHART_INTERVALS: ChartIntervals[] = [ { @@ -25,14 +25,11 @@ interface Props {} const ProjectUsage: FC = ({}) => { const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const [interval, setInterval] = useState('hourly') - const { data, error }: any = useSWR( - `${API_URL}/projects/${ref}/log-stats?interval=${interval}`, - get - ) + const { data, error } = useProjectLogStatsQuery({ projectRef, interval }) const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] const startDate = dayjs() @@ -51,9 +48,9 @@ const ProjectUsage: FC = ({}) => { if (timestampDigits < 16) { // pad unix timestamp with additional 0 and then forward const paddedTimestamp = String(timestamp) + '0'.repeat(16 - timestampDigits) - router.push(`/project/${ref}/logs/edge-logs?te=${paddedTimestamp}`) + router.push(`/project/${projectRef}/logs/edge-logs?te=${paddedTimestamp}`) } else { - router.push(`/project/${ref}/logs/edge-logs?te=${timestamp}`) + router.push(`/project/${projectRef}/logs/edge-logs?te=${timestamp}`) } } @@ -94,7 +91,7 @@ const ProjectUsage: FC = ({}) => {
} title="Database" - href={`/project/${ref}/editor`} + href={`/project/${projectRef}/editor`} /> = ({}) => {
} title="Auth" - href={`/project/${ref}/auth/users`} + href={`/project/${projectRef}/auth/users`} /> = ({}) => {
} title="Storage" - href={`/project/${ref}/storage/buckets`} + href={`/project/${projectRef}/storage/buckets`} /> { - const router = useRouter() - const { ref } = router.query - const { usage, error: usageError, isLoading } = useProjectUsage(ref as string) + const { ref: projectRef } = useParams() + const { data: usage, error: usageError, isLoading } = useProjectUsageQuery({ projectRef }) if (usageError) { return ( @@ -25,8 +24,8 @@ const ProjectUsageSection: FC = observer(({}) => { const hasProjectData = usage ? Object.keys(usage) - .map((key) => usage[key].usage) - .some((usage) => usage > 0) + .map((key) => usage[key as keyof ProjectUsageResponse].usage) + .some((usage) => (usage ?? 0) > 0) : false return ( diff --git a/studio/components/interfaces/Organization/BillingSettings/ProjectsSummary.tsx b/studio/components/interfaces/Organization/BillingSettings/ProjectsSummary.tsx index 0b2216bcb55..6590417c026 100644 --- a/studio/components/interfaces/Organization/BillingSettings/ProjectsSummary.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/ProjectsSummary.tsx @@ -3,7 +3,8 @@ import dayjs from 'dayjs' import { FC, useEffect } from 'react' import { IconChevronRight, IconLoader } from 'ui' -import { useProjectSubscription, useStore } from 'hooks' +import { useStore } from 'hooks' +import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query' import Panel from 'components/ui/Panel' interface ProjectSummaryProps { @@ -12,7 +13,11 @@ interface ProjectSummaryProps { const ProjectSummary: FC = ({ project }) => { const { ui } = useStore() - const { subscription, isLoading: loading, error } = useProjectSubscription(project.ref) + const { + data: subscription, + isLoading: loading, + error, + } = useProjectSubscriptionQuery({ projectRef: project.ref }) const currentPeriodStart = subscription?.billing?.current_period_start ?? 0 const currentPeriodEnd = subscription?.billing?.current_period_end ?? 0 @@ -21,13 +26,13 @@ const ProjectSummary: FC = ({ project }) => { if (error) { ui.setNotification({ category: 'error', - message: `Failed to get project subscription: ${error?.message ?? 'unknown'}`, + message: `Failed to get project subscription: ${(error as any)?.message ?? 'unknown'}`, }) } }, [error]) return ( -
+

{project.name}

@@ -47,8 +52,8 @@ const ProjectSummary: FC = ({ project }) => {
- -

View details

+
+

View details

@@ -69,7 +74,7 @@ const ProjectsSummary: FC = ({ projects }) => {

Projects at a glance

+

Name

diff --git a/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx b/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx index f7893223a7e..99ab20e79f7 100644 --- a/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx +++ b/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx @@ -1,7 +1,7 @@ import { FC } from 'react' import { IconAlertCircle } from 'ui' import InformationBox from 'components/ui/InformationBox' -import { MemberWithFreeProjectLimit } from 'hooks' +import { MemberWithFreeProjectLimit } from 'data/organizations/free-project-limit-check-query' interface Props { membersExceededLimit: MemberWithFreeProjectLimit[] @@ -21,7 +21,7 @@ const FreeProjectLimitWarning: FC = ({ membersExceededLimit }) => { The following members have reached their maximum limits for the number of active free tier projects within organizations where they are an administrator or owner:

-
    +
      {membersExceededLimit.map((member, idx: number) => (
    • {member.username || member.primary_email} (Limit: {member.free_project_limit} free diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index f18b914c361..3f73035e896 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,15 +1,15 @@ import { isNil } from 'lodash' -import { useRouter } from 'next/router' import { FC, useEffect, useState } from 'react' import { object, string } from 'yup' import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Form, IconMail, Input, Modal, Select } from 'ui' import { Member, User, Role } from 'types' -import { checkPermissions, useOrganizationDetail, useStore } from 'hooks' +import { checkPermissions, useParams, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' interface Props { user: User @@ -20,11 +20,9 @@ interface Props { const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAddable = [] }) => { const { ui } = useStore() - const router = useRouter() - const { slug } = router.query + const { slug } = useParams() const [isOpen, setIsOpen] = useState(false) - const { mutateOrgMembers } = useOrganizationDetail((slug as string) || '') const canInviteMembers = roles.some(({ id: role_id }) => checkPermissions(PermissionAction.CREATE, 'user_invites', { resource: { role_id } }) @@ -37,7 +35,13 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd role: string().required('Role is required'), }) + const { mutateAsync } = useOrganizationMemberInviteCreateMutation() + const onInviteMember = async (values: any, { setSubmitting, resetForm }: any) => { + if (!slug) { + throw new Error('slug is required') + } + const existingMember = members.find( (member) => member.primary_email === values.email.toLowerCase() ) @@ -59,33 +63,27 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd setSubmitting(true) - const response = await post(`${API_URL}/organizations/${slug}/members/invite`, { - invited_email: values.email.toLowerCase(), - owner_id: user.id, - role_id: roleId, - }) + try { + const response = await mutateAsync({ + slug, + invitedEmail: values.email.toLowerCase(), + ownerId: user.id, + roleId, + }) - if (response.error) { + if (isNil(response)) { + ui.setNotification({ category: 'error', message: 'Failed to add member' }) + } else { + ui.setNotification({ category: 'success', message: 'Successfully added new member.' }) + + setIsOpen(!isOpen) + resetForm({ initialValues: { ...initialValues, role: roleId } }) + } + } catch (error: any) { ui.setNotification({ category: 'error', - message: `Failed to add member: ${response.error.message}`, + message: `Failed to add member: ${error.message}`, }) - } else if (isNil(response)) { - ui.setNotification({ category: 'error', message: 'Failed to add member' }) - } else { - const newMember: Member = { - id: 0, - invited_id: response.invited_id, - invited_at: response.invited_at, - primary_email: response.invited_email, - username: response.invited_email[0], - role_ids: [response.role_id], - } - mutateOrgMembers([...members, newMember]) - ui.setNotification({ category: 'success', message: 'Successfully added new member.' }) - - setIsOpen(!isOpen) - resetForm({ initialValues: { ...initialValues, role: roleId } }) } setSubmitting(false) diff --git a/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 9a01bc28c8f..a920d38809e 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -1,15 +1,16 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' -import { FC, useState } from 'react' +import { FC } from 'react' import { Button, Dropdown, IconMoreHorizontal, IconTrash } from 'ui' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' -import { checkPermissions, useOrganizationDetail, useParams, useStore } from 'hooks' -import { delete_, post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { checkPermissions, useParams, useStore } from 'hooks' import { Member, Role } from 'types' +import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation' +import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' +import { useOrganizationMemberInviteDeleteMutation } from 'data/organizations/organization-member-invite-delete-mutation' import { isInviteExpired } from '../Organization.utils' import { getRolesManagementPermissions } from './TeamSettings.utils' @@ -23,9 +24,6 @@ const MemberActions: FC = ({ members, member, roles }) => { const { ui } = useStore() const { slug } = useParams() const { rolesRemovable } = getRolesManagementPermissions(roles) - const { mutateOrgMembers } = useOrganizationDetail(slug || '') - - const [loading, setLoading] = useState(false) const isExpired = isInviteExpired(member?.invited_at ?? '') const isPendingInviteAcceptance = member.invited_id @@ -39,81 +37,94 @@ const MemberActions: FC = ({ members, member, roles }) => { resource: { role_id: roleId }, }) + const { + mutateAsync: deleteOrganizationMemberAsync, + isLoading: isOrganizationMemberDeleteLoading, + } = useOrganizationMemberDeleteMutation({ + onSuccess() { + ui.setNotification({ + category: 'success', + message: `Successfully removed ${member.primary_email}`, + }) + }, + onError(error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to delete user: ${error.message}`, + }) + }, + }) + const handleMemberDelete = async () => { confirmAlert({ title: 'Confirm to remove', message: `This is permanent! Are you sure you want to remove ${member.primary_email}`, onAsyncConfirm: async () => { - setLoading(true) - - const response = await delete_( - `${API_URL}/organizations/${slug}/members/${member.gotrue_id}` - ) - - if (response.error) { - ui.setNotification({ - category: 'error', - message: `Failed to delete user: ${response.error.message}`, - }) - setLoading(false) - } else { - const updatedMembers = members.filter((m) => m.gotrue_id !== member.gotrue_id) - - mutateOrgMembers(updatedMembers) - ui.setNotification({ - category: 'success', - message: `Successfully removed ${member.primary_email}`, - }) + if (!slug) { + throw new Error('slug is required') } + if (!member.gotrue_id) { + throw new Error('gotrue_id is required') + } + + await deleteOrganizationMemberAsync({ + slug, + gotrueId: member.gotrue_id, + }) }, }) } + const { + mutate: createOrganizationMemberInvite, + isLoading: isOrganizationMemberInviteCreateLoading, + } = useOrganizationMemberInviteCreateMutation({ + onSuccess() { + ui.setNotification({ category: 'success', message: 'Resent the invitation.' }) + }, + onError(error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to resend invitation: ${error.message}`, + }) + }, + }) + async function handleResendInvite(member: Member) { - setLoading(true) + if (!member.invited_id || !slug) return const roleId = (member?.role_ids ?? [])[0] - const response = await post(`${API_URL}/organizations/${slug}/members/invite`, { - invited_email: member.primary_email, - owner_id: member.invited_id, - role_id: roleId, + createOrganizationMemberInvite({ + slug, + invitedEmail: member.primary_email, + ownerId: member.invited_id, + roleId: roleId, }) - - if (response.error) { - ui.setNotification({ - category: 'error', - message: `Failed to resend invitation: ${response.error.message}`, - }) - } else { - const updatedMembers = [...members] - mutateOrgMembers(updatedMembers) - ui.setNotification({ category: 'success', message: 'Resent the invitation.' }) - } - setLoading(false) } - async function handleRevokeInvitation(member: Member) { - setLoading(true) - - const invitedId = member.invited_id - if (!invitedId) return - - const response = await delete_( - `${API_URL}/organizations/${slug}/members/invite?invited_id=${invitedId}`, - {} - ) - - if (response.error) { + const { + mutate: deleteOrganizationMemberInvite, + isLoading: isOrganizationMemberInviteDeleteLoading, + } = useOrganizationMemberInviteDeleteMutation({ + onSuccess() { + ui.setNotification({ category: 'success', message: 'Successfully revoked the invitation.' }) + }, + onError(error: any) { ui.setNotification({ category: 'error', - message: `Failed to revoke invitation: ${response.error.message}`, + message: `Failed to revoke invitation: ${error.message}`, }) - } else { - const updatedMembers = [...members] - mutateOrgMembers(updatedMembers) - ui.setNotification({ category: 'success', message: 'Successfully revoked the invitation.' }) - } - setLoading(false) + }, + }) + + function handleRevokeInvitation(member: Member) { + const invitedId = member.invited_id + if (!invitedId || !slug) return + + deleteOrganizationMemberInvite({ + slug, + invitedId, + }) } if (!canRemoveMember || (isPendingInviteAcceptance && !canResendInvite && !canRevokeInvite)) { @@ -141,6 +152,11 @@ const MemberActions: FC = ({ members, member, roles }) => { ) } + const isLoading = + isOrganizationMemberDeleteLoading || + isOrganizationMemberInviteDeleteLoading || + isOrganizationMemberInviteCreateLoading + return (
      = ({ members, member, roles }) => { -
      +
      diff --git a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx index bd1a306c5f9..ede0524c0a3 100644 --- a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx +++ b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx @@ -1,13 +1,14 @@ import { FC, useEffect } from 'react' import { Badge, Button, IconAlertCircle, IconInfo, Loading } from 'ui' -import { useStore, useProjectUsage } from 'hooks' +import { useStore } from 'hooks' import { formatBytes } from 'lib/helpers' import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants' import SparkBar from 'components/ui/SparkBar' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import InformationBox from 'components/ui/InformationBox' import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' import { useRouter } from 'next/router' import * as Tooltip from '@radix-ui/react-tooltip' @@ -17,7 +18,7 @@ interface Props { const ProjectUsage: FC = ({ projectRef }) => { const { ui } = useStore() - const { usage, error, isLoading } = useProjectUsage(projectRef) + const { data: usage, error, isLoading } = useProjectUsageQuery({ projectRef }) const router = useRouter() const subscriptionTier = ui.selectedProject?.subscription_tier @@ -43,7 +44,7 @@ const ProjectUsage: FC = ({ projectRef }) => { if (error) { ui.setNotification({ category: 'error', - message: `Failed to get project's usage data: ${error?.message ?? 'unknown'}`, + message: `Failed to get project's usage data: ${(error as any)?.message ?? 'unknown'}`, }) } }, [error]) @@ -68,8 +69,8 @@ const ProjectUsage: FC = ({ projectRef }) => { showUsageExceedMessage && product.features .map((feature) => { - const featureUsage = usage[feature.key] - return featureUsage.usage / featureUsage.limit > 1 + const featureUsage = usage[feature.key as keyof ProjectUsageResponse] + return (featureUsage.usage ?? 0) / featureUsage.limit > 1 }) .some((x) => x === true) @@ -99,23 +100,23 @@ const ProjectUsage: FC = ({ projectRef }) => {
      {/* Plan Limits */} - + {isExceededUsage && Exceeded usage} {/* Usage */} - + {/* Line items */} {usage === undefined ? ( -
      +
      ) : ( {product.features.map((feature) => { - const featureUsage = usage[feature.key] + const featureUsage = usage[feature.key as keyof ProjectUsageResponse] const usageValue = featureUsage.usage || 0 const usageRatio = usageValue / featureUsage.limit const isApproaching = usageRatio >= USAGE_APPROACHING_THRESHOLD @@ -125,7 +126,7 @@ const ProjectUsage: FC = ({ projectRef }) => { let usageElement if (!isAvailableInPlan) { usageElement = ( -
      +
      Not included in {planName} tier @@ -47,8 +53,8 @@ const ActionBar: FC = ({ diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 37d15fae541..61a169e9555 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -249,6 +249,7 @@ const ColumnEditor: FC = ({ > void } @@ -44,6 +45,7 @@ const ColumnType: FC = ({ disabled = false, showLabel = true, description, + showRecommendation = false, onOptionSelect = () => {}, }) => { // @ts-ignore @@ -162,7 +164,7 @@ const ColumnType: FC = ({ ))} - {recommendation !== undefined && ( + {showRecommendation && recommendation !== undefined && ( void onSaveJSON: (value: string | number) => void } -const JsonEdit: FC = ({ column, jsonString, visible, closePanel, onSaveJSON }) => { +const JsonEdit: FC = ({ + column, + jsonString, + visible, + backButtonLabel, + applyButtonLabel, + closePanel, + onSaveJSON, +}) => { const { ui } = useStore() const [view, setView] = useState<'edit' | 'view'>('edit') const [jsonStr, setJsonStr] = useState('') useEffect(() => { - const temp = prettifyJSON(jsonString) - setJsonStr(temp) - }, [jsonString]) + if (visible) { + const temp = prettifyJSON(jsonString) + setJsonStr(temp) + } + }, [visible]) - function validateJSON(resolve: () => void) { + const validateJSON = async (resolve: () => void) => { try { const minifiedJSON = minifyJSON(jsonStr) if (onSaveJSON) onSaveJSON(minifiedJSON) @@ -72,7 +84,14 @@ const JsonEdit: FC = ({ column, jsonString, visible, closePanel, } visible={visible} onCancel={closePanel} - customFooter={} + customFooter={ + + } >
      diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts index 6af2e718b44..fe7eade0c1f 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts @@ -1,6 +1,7 @@ import type { PostgresRelationship } from '@supabase/postgres-meta' export interface JsonEditValue { + row?: any column: string jsonString: string } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 7d8c40bd8d0..25626fd10ee 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -9,6 +9,8 @@ import { RowEditor, ColumnEditor, TableEditor } from '.' import { ImportContent } from './TableEditor/TableEditor.types' import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' import ConfirmationModal from 'components/ui/ConfirmationModal' +import JsonEdit from './RowEditor/JsonEditor/JsonEditor' +import { JsonEditValue } from './RowEditor/RowEditor.types' interface Props { selectedSchema: string @@ -16,7 +18,8 @@ interface Props { selectedRowToEdit?: Dictionary selectedColumnToEdit?: PostgresColumn selectedTableToEdit?: PostgresTable - sidePanelKey?: 'row' | 'column' | 'table' + selectedValueForJsonEdit?: JsonEditValue + sidePanelKey?: 'row' | 'column' | 'table' | 'json' isDuplicating?: boolean closePanel: () => void onRowCreated?: (row: Dictionary) => void @@ -34,6 +37,7 @@ const SidePanelEditor: FC = ({ selectedRowToEdit, selectedColumnToEdit, selectedTableToEdit, + selectedValueForJsonEdit, sidePanelKey, isDuplicating = false, closePanel, @@ -113,6 +117,22 @@ const SidePanelEditor: FC = ({ } } + const onSaveJSON = async (value: string | number) => { + if (selectedTable === undefined || selectedValueForJsonEdit === undefined) return + + try { + const { row, column } = selectedValueForJsonEdit + const payload = { [column]: JSON.parse(value as any) } + const identifiers = {} as Dictionary + selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) + + const isNewRecord = false + const configuration = { identifiers, rowIdx: row.idx } + + saveRow(payload, isNewRecord, configuration, () => {}) + } catch (error: any) {} + } + const saveColumn = async ( payload: CreateColumnPayload | UpdateColumnPayload, foreignKey: Partial | undefined, @@ -287,6 +307,15 @@ const SidePanelEditor: FC = ({ saveChanges={saveTable} updateEditorDirty={() => setIsEdited(true)} /> + selectedColumnToEdit?: PostgresColumn selectedTableToEdit?: PostgresTable + selectedValueForJsonEdit?: JsonEditValue onAddRow: () => void onEditRow: (row: Dictionary) => void onAddColumn: () => void onEditColumn: (column: PostgresColumn) => void onDeleteColumn: (column: PostgresColumn) => void + onExpandJSONEditor: (column: string, row: any) => void onClosePanel: () => void } @@ -46,12 +49,14 @@ const TableGridEditor: FC = ({ selectedRowToEdit, selectedColumnToEdit, selectedTableToEdit, + selectedValueForJsonEdit, onAddRow = () => {}, onEditRow = () => {}, onAddColumn = () => {}, onEditColumn = () => {}, onDeleteColumn = () => {}, + onExpandJSONEditor = () => {}, onClosePanel = () => {}, }) => { const { meta, ui, vault } = useStore() @@ -59,8 +64,8 @@ const TableGridEditor: FC = ({ const gridRef = useRef(null) const projectRef = ui.selectedProject?.ref - const [encryptedColumns, setEncryptedColumns] = useState([]) const isVaultEnabled = useFlag('vaultExtension') + const [encryptedColumns, setEncryptedColumns] = useState([]) const getEncryptedColumns = async (table: any) => { const columns = await vault.listEncryptedColumns(table.schema, table.name) @@ -178,6 +183,7 @@ const TableGridEditor: FC = ({ onEditRow={onEditRow} onError={onError} onSqlQuery={onSqlQuery} + onExpandJSONEditor={onExpandJSONEditor} /> {!isUndefined(selectedSchema) && ( = ({ selectedRowToEdit={selectedRowToEdit} selectedColumnToEdit={selectedColumnToEdit} selectedTableToEdit={selectedTableToEdit} + selectedValueForJsonEdit={selectedValueForJsonEdit} sidePanelKey={sidePanelKey} onRowCreated={onRowCreated} onRowUpdated={onRowUpdated} diff --git a/studio/components/layouts/AccountLayout/AccountLayout.tsx b/studio/components/layouts/AccountLayout/AccountLayout.tsx index 10ddf248e05..3baa26f9994 100644 --- a/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -5,9 +5,10 @@ import { observer } from 'mobx-react-lite' import { auth, STORAGE_KEY } from 'lib/gotrue' import { useStore, withAuth, useFlag } from 'hooks' -import { API_URL, IS_PLATFORM } from 'lib/constants' +import { IS_PLATFORM } from 'lib/constants' import WithSidebar from './WithSidebar' import { SidebarSection } from './AccountLayout.types' +import { useQueryClient } from '@tanstack/react-query' interface Props { title: string @@ -21,6 +22,7 @@ interface Props { const AccountLayout: FC = ({ children, title, breadcrumbs }) => { const router = useRouter() const { app, ui } = useStore() + const queryClient = useQueryClient() const ongoingIncident = useFlag('ongoingIncident') const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' @@ -28,7 +30,8 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { const onClickLogout = async () => { await auth.signOut() localStorage.removeItem(STORAGE_KEY) - window.location.href = '/sign-in' + await router.push('/sign-in') + await queryClient.resetQueries() } const organizationsLinks = app.organizations diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 7a747d206d3..cf8789a80e2 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -1,9 +1,8 @@ import Link from 'next/link' import { observer } from 'mobx-react-lite' -import { useRouter } from 'next/router' import { IS_PLATFORM, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { useStore, useProjectUsage } from 'hooks' +import { useParams, useStore } from 'hooks' import BreadcrumbsView from './BreadcrumbsView' import OrgDropdown from './OrgDropdown' import ProjectDropdown from './ProjectDropdown' @@ -11,15 +10,15 @@ import FeedbackDropdown from './FeedbackDropdown' import HelpPopover from './HelpPopover' import NotificationsPopover from './NotificationsPopover' import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils' +import { useProjectUsageQuery } from 'data/usage/project-usage-query' const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => { const { ui } = useStore() const { selectedOrganization, selectedProject } = ui - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() - const { usage } = useProjectUsage(ref as string) + const { data: usage } = useProjectUsageQuery({ projectRef }) const resourcesExceededLimits = getResourcesExceededLimits(usage) const projectHasNoLimits = ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG || @@ -67,7 +66,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = {/* [Joshen TODO] Temporarily hidden until usage endpoint is sorted out */} {/* {showOverUsageBadge && (
      - + Project has exceeded usage limits diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx index 897df2353c1..450722f5a8c 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx @@ -1,10 +1,11 @@ import dayjs from 'dayjs' -import useSWR from 'swr' import { FC, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' import * as Tooltip from '@radix-ui/react-tooltip' import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications' -import { useStore, useNotifications } from 'hooks' +import { useStore } from 'hooks' +import { notificationKeys } from 'data/notifications/keys' import { Project } from 'types' import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils' import NotificationActions from './NotificationActions' @@ -28,7 +29,7 @@ const NotificationRow: FC = ({ onSelectFinalizeMigration, }) => { const { app, ui } = useStore() - const { refresh } = useNotifications() + const queryClient = useQueryClient() const [dismissing, setDismissing] = useState(false) const [project] = app.projects.list((project: Project) => project.id === notification.project_id) @@ -36,14 +37,15 @@ const NotificationRow: FC = ({ const changelogLink = (notification.data as any).changelog_link const availableActions = notification.meta?.actions_available ?? [] - // [Joshen TODO] This should be removed after 5th November when the migration notifications + // [Joshen TODO] This should be removed after the env of Feb when the migration notifications // have been removed, double check with Qiao before removing. // Relevant PR: https://github.com/supabase/supabase/pull/9229 - const { data: ownerReassignStatus } = useSWR( - (notification.data as any).upgrade_type === 'schema-migration' - ? `${API_URL}/database/${project.ref}/owner-reassign` - : null, - get + const { data: ownerReassignStatus } = useQuery( + ['projects', project.ref, 'owner-reassign'], + ({ signal }) => get(`${API_URL}/database/${project.ref}/owner-reassign`, { signal }), + { + enabled: (notification.data as any).upgrade_type === 'schema-migration', + } ) const dismissNotification = async (notificationId: string) => { @@ -58,7 +60,7 @@ const NotificationRow: FC = ({ duration: 4000, }) } else { - refresh() + await queryClient.invalidateQueries(notificationKeys.list()) } setDismissing(false) } diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx index 00b33c7de2e..0aa4afd87a2 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx @@ -9,18 +9,19 @@ import { } from '@supabase/shared-types/out/notifications' import { Project } from 'types' -import { useNotifications, useStore } from 'hooks' +import { useStore } from 'hooks' import { delete_, patch, post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import NotificationRow from './NotificationRow' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { useNotificationsQuery } from 'data/notifications/notifications-query' interface Props {} const NotificationsPopover: FC = () => { const router = useRouter() const { app, meta, ui } = useStore() - const { notifications, refresh } = useNotifications() + const { data: notifications, refetch } = useNotificationsQuery() const [projectToRestart, setProjectToRestart] = useState() const [projectToApplyMigration, setProjectToApplyMigration] = useState() @@ -36,6 +37,7 @@ const NotificationsPopover: FC = () => { ) const onOpenChange = async (open: boolean) => { + // TODO(alaister): move this to a mutation if (!open) { // Mark notifications as seen const notificationIds = notifications @@ -44,7 +46,7 @@ const NotificationsPopover: FC = () => { if (notificationIds.length > 0) { const { error } = await patch(`${API_URL}/notifications`, { ids: notificationIds }) if (error) console.error('Failed to update notifications', error) - refresh() + refetch() } } } diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index bc86ead7c13..3def6eaba71 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -70,7 +70,7 @@ const ProjectLayout = ({ export const ProjectLayoutWithAuth = withAuth(observer(ProjectLayout)) -export default ProjectLayout +export default observer(ProjectLayout) interface MenuBarWrapperProps { isLoading: boolean diff --git a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx index 1392bbd62e9..8181045826d 100644 --- a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx @@ -4,11 +4,12 @@ import { Modal, Button, IconPauseCircle } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Project } from 'types' -import { checkPermissions, useStore, useFlag, useFreeProjectLimitCheck } from 'hooks' +import { checkPermissions, useStore, useFlag } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' import { DeleteProjectButton } from 'components/interfaces/Settings/General' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' interface Props { project: Project @@ -19,7 +20,7 @@ const ProjectPausedState: FC = ({ project }) => { const orgSlug = ui.selectedOrganization?.slug const kpsEnabled = useFlag('initWithKps') - const { membersExceededLimit } = useFreeProjectLimitCheck(orgSlug) + const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug: orgSlug }) const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const [showConfirmRestore, setShowConfirmRestore] = useState(false) @@ -49,7 +50,7 @@ const ProjectPausedState: FC = ({ project }) => { return ( <>
      -
      +
      @@ -112,14 +113,14 @@ const ProjectPausedState: FC = ({ project }) => { header="Your organization has members who have exceeded their free project limits" onCancel={() => setShowFreeProjectLimitWarning(false)} > -
      +

      The following members have reached their maximum limits for the number of active free tier projects within organizations where they are an administrator or owner:

      -
        +
          {(membersExceededLimit || []).map((member, idx: number) => (
        • {member.username || member.primary_email} (Limit: {member.free_project_limit}{' '} diff --git a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx index 6baddb56a35..59facd25d12 100644 --- a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx +++ b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { useStore, withAuth } from 'hooks' -import BaseLayout from '..' +import ProjectLayout from '../ProjectLayout/ProjectLayout' import ProductMenu from 'components/ui/ProductMenu' import { generateReportsMenu } from './ReportsMenu.utils' @@ -18,7 +18,7 @@ const ReportsLayout = ({ title, children }: PropsWithChildren} @@ -26,7 +26,7 @@ const ReportsLayout = ({ title, children }: PropsWithChildren {children} - + ) } diff --git a/studio/components/layouts/SignInLayout/SignInLayout.tsx b/studio/components/layouts/SignInLayout/SignInLayout.tsx index 3bc77388456..a9a88f48348 100644 --- a/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query' import { useStore } from 'hooks' import { usePushNext } from 'hooks/misc/useAutoAuthRedirect' import { IS_PLATFORM } from 'lib/constants' @@ -8,7 +9,6 @@ import Image from 'next/image' import Link from 'next/link' import { PropsWithChildren, useEffect, useState } from 'react' import { tweets } from 'shared-data' -import { useSWRConfig } from 'swr' import { Button, IconFileText } from 'ui' type SignInLayoutProps = { @@ -27,7 +27,7 @@ const SignInLayout = ({ }: PropsWithChildren) => { const pushNext = usePushNext() const { ui } = useStore() - const { cache } = useSWRConfig() + const queryClient = useQueryClient() const { theme } = ui useEffect(() => { @@ -54,9 +54,7 @@ const SignInLayout = ({ } = await auth.getSession() if (session) { - // .clear() does actually exist on the cache object, but it's not in the types 🤦🏻 - // @ts-ignore - cache.clear() + await queryClient.resetQueries() await pushNext() } diff --git a/studio/components/layouts/StorageLayout/StorageLayout.tsx b/studio/components/layouts/StorageLayout/StorageLayout.tsx index e6b7c0ad180..93b1d1346ea 100644 --- a/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -3,8 +3,8 @@ import { useRouter } from 'next/router' import { find, filter, get as _get } from 'lodash' import { observer } from 'mobx-react-lite' -import { AutoApiService, useProjectSettingsQuery } from 'data/config/project-settings-query' -import { useStore, withAuth } from 'hooks' +import { useParams, useStore, withAuth } from 'hooks' +import { AutoApiService, useProjectApiQuery } from 'data/config/project-api-query' import BaseLayout from 'components/layouts' import ProjectLayout from '../ProjectLayout/ProjectLayout' import StorageMenu from './StorageMenu' @@ -23,7 +23,7 @@ interface Props { const StorageLayout: FC = ({ title, children }) => { const { ui, meta } = useStore() const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const storageExplorerStore = useStorageStore() const { @@ -40,7 +40,7 @@ const StorageLayout: FC = ({ title, children }) => { buckets, } = storageExplorerStore || {} - const { data: settings, isLoading } = useProjectSettingsQuery({ projectRef: ref as string }) + const { data: settings, isLoading } = useProjectApiQuery({ projectRef }) const apiService = settings?.autoApiService const serviceKey = find(apiService?.service_api_keys ?? [], (key) => key.tags === 'service_role') const canAccessStorage = !isLoading && apiService && serviceKey @@ -53,7 +53,7 @@ const StorageLayout: FC = ({ title, children }) => { if (apiService.endpoint) { if (serviceKey) { storageExplorerStore.initStore( - ref, + projectRef, apiService.endpoint, apiService.serviceApiKey, apiService.protocol @@ -72,7 +72,7 @@ const StorageLayout: FC = ({ title, children }) => { const onSelectCreateBucket = async (bucketName: string, isPublic: boolean) => { const bucket = await createBucket(bucketName, isPublic) - if (bucket !== undefined) router.push(`/project/${ref}/storage/buckets/${bucket.name}`) + if (bucket !== undefined) router.push(`/project/${projectRef}/storage/buckets/${bucket.name}`) } const onSelectDeleteBucket = async (bucket: any) => { diff --git a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js index c377465d834..d4cde836715 100644 --- a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js +++ b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js @@ -234,14 +234,20 @@ const ResultsDropdown = observer(() => { function onDownloadCSV() { csvRef.current?.link.click() - Telemetry.sendEvent('sql_editor', 'sql_download_csv', '') + Telemetry.sendEvent( + { category: 'sql_editor', action: 'sql_download_csv', label: '' }, + ui.googleAnalyticsProps + ) } function onCopyAsMarkdown() { if (navigator) { copyToClipboard(sqlEditorStore.activeTab.markdownData, () => { ui.setNotification({ category: 'success', message: 'Copied results to clipboard' }) - Telemetry.sendEvent('sql_editor', 'sql_copy_as_markdown', '') + Telemetry.sendEvent( + { category: 'sql_editor', action: 'sql_copy_as_markdown', label: '' }, + ui.googleAnalyticsProps + ) }) } } diff --git a/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js b/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js index b488d7bd9c5..9faa88f801b 100644 --- a/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js +++ b/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js @@ -37,7 +37,14 @@ const TabWelcome = observer(() => { sql={x.sql} onClick={(sql, title) => { handleNewQuery({ sql, name: title }) - Telemetry.sendEvent('scripts', 'script_clicked', x.title) + Telemetry.sendEvent( + { + category: 'scripts', + action: 'script_clicked', + label: x.title, + }, + ui.googleAnalyticsProps + ) }} /> ))} @@ -64,7 +71,14 @@ const TabWelcome = observer(() => { sql={x.sql} onClick={(sql, title) => { handleNewQuery({ sql, name: title }) - Telemetry.sendEvent('quickstart', 'quickstart_clicked', x.title) + Telemetry.sendEvent( + { + category: 'quickstart', + action: 'quickstart_clicked', + label: x.title, + }, + ui.googleAnalyticsProps + ) }} /> ))} diff --git a/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx b/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx index be80781933a..4e05953129a 100644 --- a/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx @@ -1,20 +1,23 @@ -import useSWR from 'swr' -import { FC, useState } from 'react' +import { useState } from 'react' import { Button, Form, IconClock, Input, Listbox } from 'ui' import { useStore } from 'hooks' -import { patch, get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' +import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import UpgradeToPro from 'components/ui/UpgradeToPro' import { convertFromBytes, convertToBytes } from './StorageSettings.utils' import { StorageSizeUnits, STORAGE_FILE_SIZE_LIMIT_MAX_BYTES } from './StorageSettings.constants' -const StorageSettings: FC = ({ projectRef }) => { - const { data, error } = useSWR(`${API_URL}/projects/${projectRef}/config/storage`, get) +export type StorageSettingsProps = { + projectRef: string | undefined +} + +const StorageSettings = ({ projectRef }: StorageSettingsProps) => { + const { data, error } = useProjectStorageConfigQuery({ projectRef }) if (error || data?.error) { return ( -
          +

          Error loading storage settings

          ) @@ -22,7 +25,7 @@ const StorageSettings: FC = ({ projectRef }) => { if (!data) { return ( -
          +

          Loading...

          ) @@ -58,6 +61,8 @@ const StorageConfig = ({ config, projectRef }: any) => { return errors } + const { mutateAsync: updateStorageConfig } = useProjectStorageConfigUpdateUpdateMutation() + const onSubmit = async (values: any) => { const errors = onValidate(values) @@ -67,20 +72,23 @@ const StorageConfig = ({ config, projectRef }: any) => { message: `Upload file size limit must be up to 5GB (${formattedMaxSizeBytes})`, }) } else { - const payload = { fileSizeLimit: convertToBytes(values.fileSizeLimit, selectedUnit) } - const res = await patch(`${API_URL}/projects/${projectRef}/config/storage`, payload) - if (res?.error) { - ui.setNotification({ - category: 'error', - message: `Failed to update storage settings: ${res.error.message}`, + try { + const res = await updateStorageConfig({ + projectRef, + fileSizeLimit: convertToBytes(values.fileSizeLimit, selectedUnit), }) - } else { + const updatedValue = convertFromBytes(res.fileSizeLimit) initialValues = { fileSizeLimit: updatedValue.value, unformattedFileSizeLimit: res.fileSizeLimit, } ui.setNotification({ category: 'success', message: 'Successfully updated settings' }) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update storage settings: ${error?.message}`, + }) } } } @@ -119,12 +127,12 @@ const StorageConfig = ({ config, projectRef }: any) => { ].join(' ')} >
          -
          -
          +
          +

          Upload file size limit

          -
          -
          +
          +
          {
          )}
          -
          -
          +
          +
          diff --git a/studio/components/ui/PageTelemetry.tsx b/studio/components/ui/PageTelemetry.tsx index b00f61f9be5..62f31a0a031 100644 --- a/studio/components/ui/PageTelemetry.tsx +++ b/studio/components/ui/PageTelemetry.tsx @@ -4,35 +4,34 @@ import { API_URL, IS_PLATFORM } from 'lib/constants' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { FC, useEffect } from 'react' -import { User } from 'types' const PageTelemetry: FC = ({ children }) => { const router = useRouter() const { ui } = useStore() - const { profile } = ui useEffect(() => { - function handleRouteChange() { - handlePageTelemetry(profile) + function handleRouteChange(url: string) { + handlePageTelemetry(url) } + // Listen for page changes after a navigation or when the query changes router.events.on('routeChangeComplete', handleRouteChange) return () => { router.events.off('routeChangeComplete', handleRouteChange) } - }, [router.events, profile]) + }, [router.events]) useEffect(() => { /** * Send page telemetry on first page load - * if there asPath is defined, then this isn't needed + * if the route is not ready. Don't need to send it will be picked up by router.event above */ - if (router.route === '/' && router.asPath === '/') { - handlePageTelemetry(profile) + if (router.isReady) { + handlePageTelemetry(router.route) } }, []) - const handlePageTelemetry = (profile?: User) => { + const handlePageTelemetry = async (route?: string) => { if (IS_PLATFORM) { /** * Get referrer from browser @@ -48,6 +47,11 @@ const PageTelemetry: FC = ({ children }) => { post(`${API_URL}/telemetry/page`, { referrer: referrer, title: document.title, + route, + ga: { + screen_resolution: ui.googleAnalyticsProps?.screenResolution, + language: ui.googleAnalyticsProps?.language, + }, }) } } diff --git a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 62066551307..587e742a41a 100644 --- a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,33 +1,36 @@ -import { useRouter } from 'next/router' -import { IconAlertCircle, IconLoader, Input } from 'ui' -import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { IconAlertCircle, IconLoader, Input } from 'ui' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import { checkPermissions, useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' import Panel from 'components/ui/Panel' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { checkPermissions, useParams } from 'hooks' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' const DisplayApiSettings = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const { - services, + data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettings(ref as string | undefined) + } = useProjectSettingsQuery({ projectRef }) const { - jwtSecretUpdateStatus, + data, isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, - }: any = useJwtSecretUpdateStatus(ref) + } = useJwtSecretUpdatingStatusQuery({ projectRef }) + const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated // Get the API service - const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiService = (settings?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) const apiKeys = apiService?.service_api_keys ?? [] // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 @@ -46,14 +49,14 @@ const DisplayApiSettings = () => { } > {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( -
          +

          {isProjectSettingsError ? 'Failed to retrieve API keys' : 'Failed to update JWT secret'}

          ) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( -
          +

          {isProjectSettingsLoading || isApiKeysEmpty diff --git a/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx index 320a0a8b82e..70f1c5eba57 100644 --- a/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx +++ b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -1,38 +1,43 @@ -import { FC } from 'react' -import { useRouter } from 'next/router' -import { Input, IconAlertCircle, IconLoader } from 'ui' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import Panel from 'components/ui/Panel' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useParams } from 'hooks' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' +import { FC } from 'react' +import { IconAlertCircle, IconLoader, Input } from 'ui' const DisplayConfigSettings = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const { - project, - services, + data: settings, isLoading: isProjectSettingsLoading, isError: isProjectSettingsError, - } = useProjectSettings(ref as string | undefined) + } = useProjectSettingsQuery({ + projectRef, + }) + const { + data, isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, - jwtSecretUpdateStatus, - }: any = useJwtSecretUpdateStatus(ref) + } = useJwtSecretUpdatingStatusQuery({ projectRef }) + const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated // Get the API service - const jwtSecret = project?.jwt_secret ?? '' - const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) - const apiConfig = apiService?.app_config ?? {} - const apiUrl = `${apiConfig.protocol ?? 'https'}://${apiConfig.endpoint ?? '-'}` + const jwtSecret = settings?.project?.jwt_secret ?? '' + const apiService = (settings?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) + const apiConfig = apiService?.app_config + const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}` return ( {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( -

          +

          {isProjectSettingsError @@ -41,7 +46,7 @@ const DisplayConfigSettings = () => {

          ) : isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( -
          +

          {isProjectSettingsLoading ? 'Retrieving API keys' : 'JWT secret is being updated'} diff --git a/studio/components/ui/ShimmeringLoader.tsx b/studio/components/ui/ShimmeringLoader.tsx index 5a97e10827e..23f6a002108 100644 --- a/studio/components/ui/ShimmeringLoader.tsx +++ b/studio/components/ui/ShimmeringLoader.tsx @@ -1,5 +1,15 @@ -const ShimmeringLoader = ({ className = '' }) => { - return

          +const ANIMATION_DELAY = 150 + +const ShimmeringLoader = ({ className = '', delayIndex = 0, animationDelay = 150 }) => { + return ( +
          + ) } export default ShimmeringLoader diff --git a/studio/data/__templates/resource-query.ts b/studio/data/__templates/resource-query.ts index 4c91e063b3a..c348077547f 100644 --- a/studio/data/__templates/resource-query.ts +++ b/studio/data/__templates/resource-query.ts @@ -56,5 +56,5 @@ export const useResourcePrefetch = ({ projectRef, id }: ResourceVariables) => { getResource({ projectRef, id }, signal) ) } - }, [projectRef]) + }, [projectRef, id]) } diff --git a/studio/data/access-tokens/access-tokens-create-mutation.ts b/studio/data/access-tokens/access-tokens-create-mutation.ts new file mode 100644 index 00000000000..8e13d2fe1fe --- /dev/null +++ b/studio/data/access-tokens/access-tokens-create-mutation.ts @@ -0,0 +1,44 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { AccessToken } from './access-tokens-query' +import { accessTokenKeys } from './keys' + +export type AccessTokenCreateVariables = { + name: string +} + +export type NewAccessToken = AccessToken & { token: string } + +export async function createAccessToken({ name }: AccessTokenCreateVariables) { + const response = await post(`${API_URL}/profile/access-tokens`, { name }) + if (response.error) { + throw response.error + } + + return response as NewAccessToken +} + +type AccessTokenCreateData = Awaited> + +export const useAccessTokenCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createAccessToken(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(accessTokenKeys.list()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/access-tokens/access-tokens-delete-mutation.ts b/studio/data/access-tokens/access-tokens-delete-mutation.ts new file mode 100644 index 00000000000..0f9bc82f575 --- /dev/null +++ b/studio/data/access-tokens/access-tokens-delete-mutation.ts @@ -0,0 +1,41 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { accessTokenKeys } from './keys' + +export type AccessTokenDeleteVariables = { + id: number +} + +export async function deleteAccessToken({ id }: AccessTokenDeleteVariables) { + const response = await delete_(`${API_URL}/profile/access-tokens/${id}`) + if (response.error) { + throw response.error + } + + return response +} + +type AccessTokenDeleteData = Awaited> + +export const useAccessTokenDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteAccessToken(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(accessTokenKeys.list()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/access-tokens/access-tokens-query.ts b/studio/data/access-tokens/access-tokens-query.ts new file mode 100644 index 00000000000..6cfb187510e --- /dev/null +++ b/studio/data/access-tokens/access-tokens-query.ts @@ -0,0 +1,46 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { accessTokenKeys } from './keys' + +export type AccessToken = { + id: number + token_alias: string + name: string + created_at: number +} + +export type AccessTokensResponse = AccessToken[] + +export async function getAccessTokens(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile/access-tokens`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as AccessTokensResponse +} + +export type AccessTokensData = Awaited> +export type AccessTokensError = unknown + +export const useAccessTokensQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + accessTokenKeys.list(), + ({ signal }) => getAccessTokens(signal), + options + ) + +export const useAccessTokensPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(accessTokenKeys.list(), ({ signal }) => getAccessTokens(signal)) + }, []) +} diff --git a/studio/data/access-tokens/keys.ts b/studio/data/access-tokens/keys.ts new file mode 100644 index 00000000000..8d5e3a8f3da --- /dev/null +++ b/studio/data/access-tokens/keys.ts @@ -0,0 +1,3 @@ +export const accessTokenKeys = { + list: () => ['access-tokens'] as const, +} diff --git a/studio/data/analytics/functions-inv-stats-query.ts b/studio/data/analytics/functions-inv-stats-query.ts new file mode 100644 index 00000000000..8bf0b5ad76e --- /dev/null +++ b/studio/data/analytics/functions-inv-stats-query.ts @@ -0,0 +1,80 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { analyticsKeys } from './keys' + +export type FunctionsInvStatsVariables = { + projectRef?: string + functionId?: string + interval?: string +} + +export type FunctionsInvStatsResponse = any + +export async function getFunctionsInvStats( + { projectRef, functionId, interval }: FunctionsInvStatsVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + if (!functionId) { + throw new Error('functionId is required') + } + if (!interval) { + throw new Error('interval is required') + } + + const response = await get( + `${API_URL}/projects/${projectRef}/analytics/endpoints/functions.inv-stats?interval=${interval}&function_id=${functionId}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as FunctionsInvStatsResponse +} + +export type FunctionsInvStatsData = Awaited> +export type FunctionsInvStatsError = unknown + +export const useFunctionsInvStatsQuery = ( + { projectRef, functionId, interval }: FunctionsInvStatsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + analyticsKeys.functionsInvStats(projectRef, { functionId, interval }), + ({ signal }) => getFunctionsInvStats({ projectRef, functionId, interval }, signal), + { + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof functionId !== 'undefined' && + typeof interval !== 'undefined', + ...options, + } + ) + +export const useFunctionsInvStatsPrefetch = ({ + projectRef, + functionId, + interval, +}: FunctionsInvStatsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef && functionId && interval) { + client.prefetchQuery( + analyticsKeys.functionsInvStats(projectRef, { functionId, interval }), + ({ signal }) => getFunctionsInvStats({ projectRef, functionId, interval }, signal) + ) + } + }, [projectRef, functionId, interval]) +} diff --git a/studio/data/analytics/keys.ts b/studio/data/analytics/keys.ts new file mode 100644 index 00000000000..b360a8f7132 --- /dev/null +++ b/studio/data/analytics/keys.ts @@ -0,0 +1,8 @@ +import { LogsEndpointParams } from 'components/interfaces/Settings/Logs' + +export const analyticsKeys = { + functionsInvStats: ( + projectRef: string | undefined, + { interval, functionId }: { functionId: string | undefined; interval: string | undefined } + ) => ['projects', projectRef, 'functions-inv-stats', { interval, functionId }] as const, +} diff --git a/studio/data/config/jwt-secret-update-mutation.ts b/studio/data/config/jwt-secret-update-mutation.ts new file mode 100644 index 00000000000..60706721f01 --- /dev/null +++ b/studio/data/config/jwt-secret-update-mutation.ts @@ -0,0 +1,52 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { configKeys } from './keys' + +export type JwtSecretUpdateVariables = { + projectRef: string + jwtSecret: string + changeTrackingId: string +} + +export async function updateJwtSecret({ + projectRef, + jwtSecret, + changeTrackingId, +}: JwtSecretUpdateVariables) { + const response = await patch(`${API_URL}/projects/${projectRef}/config/secrets`, { + jwt_secret: jwtSecret, + change_tracking_id: changeTrackingId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type JwtSecretUpdateData = Awaited> + +export const useJwtSecretUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateJwtSecret(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(configKeys.jwtSecretUpdatingStatus(projectRef)) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/config/jwt-secret-updating-status-query.ts b/studio/data/config/jwt-secret-updating-status-query.ts new file mode 100644 index 00000000000..80f7024f0cc --- /dev/null +++ b/studio/data/config/jwt-secret-updating-status-query.ts @@ -0,0 +1,92 @@ +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { configKeys } from './keys' + +export type JwtSecretUpdatingStatusVariables = { + projectRef?: string +} + +export type JwtSecretUpdatingStatusResponse = { + changeTrackingId: string | undefined + jwtSecretUpdateError: number | null | undefined + jwtSecretUpdateProgress: number | null | undefined + jwtSecretUpdateStatus: JwtSecretUpdateStatus | undefined +} + +export async function getJwtSecretUpdatingStatus( + { projectRef }: JwtSecretUpdatingStatusVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/props/project/${projectRef}/jwt-secret-update-status`, { + signal, + }) + if (response.error) { + throw response.error + } + + const meta = response?.jwtSecretUpdateStatus?.meta + + return { + changeTrackingId: meta?.change_tracking_id, + jwtSecretUpdateError: meta?.error, + jwtSecretUpdateProgress: meta?.progress, + jwtSecretUpdateStatus: meta?.status, + } as JwtSecretUpdatingStatusResponse +} + +export type JwtSecretUpdatingStatusData = Awaited> +export type JwtSecretUpdatingStatusError = unknown + +export const useJwtSecretUpdatingStatusQuery = ( + { projectRef }: JwtSecretUpdatingStatusVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const client = useQueryClient() + + return useQuery( + configKeys.jwtSecretUpdatingStatus(projectRef), + ({ signal }) => getJwtSecretUpdatingStatus({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data) { + if (!data) { + return false + } + + const { jwtSecretUpdateStatus } = data as unknown as JwtSecretUpdatingStatusResponse + + const interval = jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating ? 1000 : false + + return interval + }, + onSuccess() { + client.invalidateQueries(configKeys.postgrest(projectRef)) + }, + ...options, + } + ) +} + +export const useJwtSecretUpdatingStatusPrefetch = ({ + projectRef, +}: JwtSecretUpdatingStatusVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(configKeys.jwtSecretUpdatingStatus(projectRef), ({ signal }) => + getJwtSecretUpdatingStatus({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/config/keys.ts b/studio/data/config/keys.ts index 875848a4247..1f0b8701751 100644 --- a/studio/data/config/keys.ts +++ b/studio/data/config/keys.ts @@ -1,3 +1,8 @@ export const configKeys = { settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const, + api: (projectRef: string | undefined) => ['projects', projectRef, 'settings', 'api'] as const, + postgrest: (projectRef: string | undefined) => ['projects', projectRef, 'postgrest'] as const, + jwtSecretUpdatingStatus: (projectRef: string | undefined) => + ['projects', projectRef, 'jwt-secret-updating-status'] as const, + storage: (projectRef: string | undefined) => ['projects', projectRef, 'storage'] as const, } diff --git a/studio/data/config/project-api-query.ts b/studio/data/config/project-api-query.ts new file mode 100644 index 00000000000..5fc7d414364 --- /dev/null +++ b/studio/data/config/project-api-query.ts @@ -0,0 +1,117 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { configKeys } from './keys' + +export type ProjectApiVariables = { + projectRef?: string +} + +export type Project = { + id: number + name: string + ref: string + status: string + db_host: string + db_name: string + db_user: string + db_schema: string + db_port: number + db_ssl: boolean + services: Service[] +} + +export type App = { + id: number + name: string +} + +export type AppConfig = { + endpoint: string + db_schema: string + realtime_multitenant_enabled: boolean +} + +export type Service = { + id: number + name: string + app_config: AppConfig + app: App + service_api_keys: ServiceApiKey[] +} + +export type ServiceApiKey = { + api_key_encrypted: string + tags: string + name: string +} + +export type AutoApiService = Service & { + protocol: 'https' | 'http' + endpoint: string + restUrl: string + project: Project + defaultApiKey: string + serviceApiKey: string +} + +export type ProjectApiResponse = { + project: Project + autoApiService: AutoApiService +} + +export async function getProjectApi({ projectRef }: ProjectApiVariables, signal?: AbortSignal) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/props/project/${projectRef}/api`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectApiResponse +} + +export type ProjectApiData = Awaited> +export type ProjectApiError = unknown + +export const useProjectApiQuery = ( + { projectRef }: ProjectApiVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + configKeys.api(projectRef), + ({ signal }) => getProjectApi({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data, query) { + if (!data) { + return false + } + + const { autoApiService } = data as unknown as ProjectApiData + + const apiKeys = autoApiService?.service_api_keys ?? [] + const interval = apiKeys.length === 0 ? 2000 : 0 + + return interval + }, + ...options, + } + ) + +export const useProjectApiPrefetch = ({ projectRef }: ProjectApiVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(configKeys.api(projectRef), ({ signal }) => + getProjectApi({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/config/project-postgrest-config-query.ts b/studio/data/config/project-postgrest-config-query.ts new file mode 100644 index 00000000000..4a8abe2ee13 --- /dev/null +++ b/studio/data/config/project-postgrest-config-query.ts @@ -0,0 +1,69 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { configKeys } from './keys' + +export type ProjectPostgrestConfigVariables = { + projectRef?: string +} + +export type ProjectPostgrestConfigResponse = { + max_rows: number + role_claim_key: string + db_schema: string + db_anon_role: string + db_extra_search_path: string + jwt_secret: string +} + +export async function getProjectPostgrestConfig( + { projectRef }: ProjectPostgrestConfigVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/config/postgrest`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectPostgrestConfigResponse +} + +export type ProjectPostgrestConfigData = Awaited> +export type ProjectPostgrestConfigError = unknown + +export const useProjectPostgrestConfigQuery = ( + { projectRef }: ProjectPostgrestConfigVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + configKeys.postgrest(projectRef), + ({ signal }) => getProjectPostgrestConfig({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) + +export const useProjectPostgrestConfigPrefetch = ({ + projectRef, +}: ProjectPostgrestConfigVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(configKeys.postgrest(projectRef), ({ signal }) => + getProjectPostgrestConfig({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/config/project-postgrest-config-update-mutation.ts b/studio/data/config/project-postgrest-config-update-mutation.ts new file mode 100644 index 00000000000..ede88881f11 --- /dev/null +++ b/studio/data/config/project-postgrest-config-update-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { configKeys } from './keys' + +export type ProjectPostgrestConfigUpdateVariables = { + projectRef: string + dbSchema: string + maxRows: string + dbExtraSearchPath: string +} + +export async function updateProjectPostgrestConfig({ + projectRef, + dbSchema, + maxRows, + dbExtraSearchPath, +}: ProjectPostgrestConfigUpdateVariables) { + const response = await patch(`${API_URL}/projects/${projectRef}/config/postgrest`, { + db_schema: dbSchema, + max_rows: maxRows, + db_extra_search_path: dbExtraSearchPath, + }) + if (response.error) { + throw response.error + } + + return response +} + +type ProjectPostgrestConfigUpdateData = Awaited> + +export const useProjectPostgrestConfigUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + ProjectPostgrestConfigUpdateData, + unknown, + ProjectPostgrestConfigUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + ProjectPostgrestConfigUpdateData, + unknown, + ProjectPostgrestConfigUpdateVariables + >((vars) => updateProjectPostgrestConfig(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + queryClient.invalidateQueries(configKeys.postgrest(projectRef)) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/config/project-settings-query.ts b/studio/data/config/project-settings-query.ts index 2c61a9909a8..cbed1b251a5 100644 --- a/studio/data/config/project-settings-query.ts +++ b/studio/data/config/project-settings-query.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import { useCallback } from 'react' import { configKeys } from './keys' @@ -13,24 +13,17 @@ export type Project = { name: string ref: string status: string - db_host: string - db_name: string - db_user: string - db_schema: string + inserted_at: string + db_dns_name: string db_port: number + db_name: string db_ssl: boolean + db_host: string + db_user: string + cloud_provider: string + region: string services: Service[] -} - -export type App = { - id: number - name: string -} - -export type AppConfig = { - endpoint: string - db_schema: string - realtime_multitenant_enabled: boolean + jwt_secret: string } export type Service = { @@ -41,24 +34,28 @@ export type Service = { service_api_keys: ServiceApiKey[] } +export type AppConfig = { + protocol: 'https' | 'http' + endpoint: string + db_schema: string + realtime_multitenant_enabled: boolean +} + +export type App = { + id: number + name: string +} + export type ServiceApiKey = { api_key_encrypted: string tags: string name: string -} - -export type AutoApiService = Service & { - protocol: 'https' | 'http' - endpoint: string - restUrl: string - project: Project - defaultApiKey: string - serviceApiKey: string + api_key: string } export type ProjectSettingsResponse = { project: Project - autoApiService: AutoApiService + services: Service[] } export async function getProjectSettings( @@ -69,7 +66,7 @@ export async function getProjectSettings( throw new Error('projectRef is required') } - const response = await get(`${API_URL}/props/project/${projectRef}/api`, { + const response = await get(`${API_URL}/props/project/${projectRef}/settings`, { signal, }) if (response.error) { @@ -95,13 +92,10 @@ export const useProjectSettingsQuery = ( { enabled: enabled && typeof projectRef !== 'undefined', refetchInterval(data, query) { - if (!data) { - return false - } - - const { autoApiService } = data as unknown as ProjectSettingsData - - const apiKeys = autoApiService?.service_api_keys ?? [] + const apiService = ( + (data as unknown as ProjectSettingsData | undefined)?.services ?? [] + ).find((x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiKeys = apiService?.service_api_keys ?? [] const interval = apiKeys.length === 0 ? 2000 : 0 return interval diff --git a/studio/data/config/project-storage-config-query.ts b/studio/data/config/project-storage-config-query.ts new file mode 100644 index 00000000000..fb6d717880e --- /dev/null +++ b/studio/data/config/project-storage-config-query.ts @@ -0,0 +1,58 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { configKeys } from './keys' + +export type ProjectStorageConfigVariables = { + projectRef?: string +} + +// TODO: Add proper type +export type ProjectStorageConfigResponse = any + +export async function getProjectStorageConfig( + { projectRef }: ProjectStorageConfigVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/config/storage`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectStorageConfigResponse +} + +export type ProjectStorageConfigData = Awaited> +export type ProjectStorageConfigError = unknown + +export const useProjectStorageConfigQuery = ( + { projectRef }: ProjectStorageConfigVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + configKeys.storage(projectRef), + ({ signal }) => getProjectStorageConfig({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) + +export const useProjectStorageConfigPrefetch = ({ projectRef }: ProjectStorageConfigVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(configKeys.storage(projectRef), ({ signal }) => + getProjectStorageConfig({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/config/project-storage-config-update-mutation.ts b/studio/data/config/project-storage-config-update-mutation.ts new file mode 100644 index 00000000000..8a9bf5e83b0 --- /dev/null +++ b/studio/data/config/project-storage-config-update-mutation.ts @@ -0,0 +1,56 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { configKeys } from './keys' + +export type ProjectStorageConfigUpdateUpdateVariables = { + projectRef: string + fileSizeLimit: number +} + +export async function updateProjectStorageConfigUpdate({ + projectRef, + fileSizeLimit, +}: ProjectStorageConfigUpdateUpdateVariables) { + const response = await patch(`${API_URL}/projects/${projectRef}/config/storage`, { + fileSizeLimit, + }) + if (response.error) { + throw response.error + } + + return response +} + +type ProjectStorageConfigUpdateUpdateData = Awaited< + ReturnType +> + +export const useProjectStorageConfigUpdateUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + ProjectStorageConfigUpdateUpdateData, + unknown, + ProjectStorageConfigUpdateUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + ProjectStorageConfigUpdateUpdateData, + unknown, + ProjectStorageConfigUpdateUpdateVariables + >((vars) => updateProjectStorageConfigUpdate(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(configKeys.storage(projectRef)) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/docs/keys.ts b/studio/data/docs/keys.ts new file mode 100644 index 00000000000..b0f66362945 --- /dev/null +++ b/studio/data/docs/keys.ts @@ -0,0 +1,7 @@ +export const docsKeys = { + jsonSchema: ( + projectRef: string | undefined, + swaggerUrl: string | undefined, + apiKey: string | undefined + ) => ['projects', projectRef, 'docs', { swaggerUrl, apiKey }] as const, +} diff --git a/studio/data/docs/project-json-schema-query.ts b/studio/data/docs/project-json-schema-query.ts new file mode 100644 index 00000000000..f37f6dd55cb --- /dev/null +++ b/studio/data/docs/project-json-schema-query.ts @@ -0,0 +1,75 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { useCallback } from 'react' +import { docsKeys } from './keys' + +export type ProjectJsonSchemaVariables = { + projectRef?: string + swaggerUrl?: string + apiKey?: string +} + +export type ProjectJsonSchemaResponse = any + +export async function getProjectJsonSchema( + { projectRef, swaggerUrl, apiKey }: ProjectJsonSchemaVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + if (!swaggerUrl) { + throw new Error('id is required') + } + + let headers: { + [key: string]: string | undefined + } = { apikey: apiKey } + if ((apiKey?.length ?? 0) > 40) headers['Authorization'] = `Bearer ${apiKey}` + + const response = await get(swaggerUrl, { + signal, + headers, + credentials: 'omit', + }) + if (response.error) { + throw response.error + } + + return response as ProjectJsonSchemaResponse +} + +export type ProjectJsonSchemaData = Awaited> +export type ProjectJsonSchemaError = unknown + +export const useProjectJsonSchemaQuery = ( + { projectRef, swaggerUrl, apiKey }: ProjectJsonSchemaVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + docsKeys.jsonSchema(projectRef, swaggerUrl, apiKey), + ({ signal }) => getProjectJsonSchema({ projectRef, swaggerUrl, apiKey }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof swaggerUrl !== 'undefined', + ...options, + } + ) + +export const useProjectJsonSchemaPrefetch = ({ + projectRef, + swaggerUrl, + apiKey, +}: ProjectJsonSchemaVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef && swaggerUrl) { + client.prefetchQuery(docsKeys.jsonSchema(projectRef, swaggerUrl, apiKey), ({ signal }) => + getProjectJsonSchema({ projectRef, swaggerUrl, apiKey }, signal) + ) + } + }, [projectRef, swaggerUrl]) +} diff --git a/studio/data/logs/keys.ts b/studio/data/logs/keys.ts new file mode 100644 index 00000000000..c9ba47f22aa --- /dev/null +++ b/studio/data/logs/keys.ts @@ -0,0 +1,4 @@ +export const logKeys = { + logStats: (projectRef: string | undefined, interval: string | undefined) => + ['projects', projectRef, 'log-stats', interval] as const, +} diff --git a/studio/data/logs/project-log-stats-query.ts b/studio/data/logs/project-log-stats-query.ts new file mode 100644 index 00000000000..b14039751e9 --- /dev/null +++ b/studio/data/logs/project-log-stats-query.ts @@ -0,0 +1,64 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { logKeys } from './keys' + +export type ProjectLogStatsVariables = { + projectRef?: string + interval?: string +} + +export type ProjectLogStatsResponse = any + +export async function getProjectLogStats( + { projectRef, interval }: ProjectLogStatsVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + if (!interval) { + throw new Error('interval is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/log-stats?interval=${interval}`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectLogStatsResponse +} + +export type ProjectLogStatsData = Awaited> +export type ProjectLogStatsError = unknown + +export const useProjectLogStatsQuery = ( + { projectRef, interval }: ProjectLogStatsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logKeys.logStats(projectRef, interval), + ({ signal }) => getProjectLogStats({ projectRef, interval }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof interval !== 'undefined', + ...options, + } + ) + +export const useProjectLogStatsPrefetch = ({ projectRef, interval }: ProjectLogStatsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef && interval) { + client.prefetchQuery(logKeys.logStats(projectRef, interval), ({ signal }) => + getProjectLogStats({ projectRef, interval }, signal) + ) + } + }, [projectRef, interval]) +} diff --git a/studio/data/notifications/keys.ts b/studio/data/notifications/keys.ts new file mode 100644 index 00000000000..9142775070a --- /dev/null +++ b/studio/data/notifications/keys.ts @@ -0,0 +1,3 @@ +export const notificationKeys = { + list: () => ['notifications'] as const, +} diff --git a/studio/data/notifications/notifications-query.ts b/studio/data/notifications/notifications-query.ts new file mode 100644 index 00000000000..7cbc6fb0c98 --- /dev/null +++ b/studio/data/notifications/notifications-query.ts @@ -0,0 +1,39 @@ +import { Notification } from '@supabase/shared-types/out/notifications' +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { notificationKeys } from './keys' + +export type NotificationsResponse = Notification[] + +export async function getNotifications(signal?: AbortSignal) { + const response = await get(`${API_URL}/notifications`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as NotificationsResponse +} + +export type NotificationsData = Awaited> +export type NotificationsError = unknown + +export const useNotificationsQuery = ( + options: UseQueryOptions = {} +) => + useQuery( + notificationKeys.list(), + ({ signal }) => getNotifications(signal), + options + ) + +export const useNotificationsPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(notificationKeys.list(), ({ signal }) => getNotifications(signal)) + }, []) +} diff --git a/studio/data/organizations/free-project-limit-check-query.ts b/studio/data/organizations/free-project-limit-check-query.ts new file mode 100644 index 00000000000..182e38ab065 --- /dev/null +++ b/studio/data/organizations/free-project-limit-check-query.ts @@ -0,0 +1,69 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' + +export type MemberWithFreeProjectLimit = { + free_project_limit: number + primary_email: string + username: string +} + +export type FreeProjectLimitCheckVariables = { + slug?: string +} + +export type FreeProjectLimitCheckResponse = MemberWithFreeProjectLimit[] + +export async function getFreeProjectLimitCheck( + { slug }: FreeProjectLimitCheckVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + const response = await get( + `${API_URL}/organizations/${slug}/members/reached-free-project-limit`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as FreeProjectLimitCheckResponse +} + +export type FreeProjectLimitCheckData = Awaited> +export type FreeProjectLimitCheckError = unknown + +export const useFreeProjectLimitCheckQuery = ( + { slug }: FreeProjectLimitCheckVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.freeProjectLimitCheck(slug), + ({ signal }) => getFreeProjectLimitCheck({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useFreeProjectLimitCheckPrefetch = ({ slug }: FreeProjectLimitCheckVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.freeProjectLimitCheck(slug), ({ signal }) => + getFreeProjectLimitCheck({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts new file mode 100644 index 00000000000..5b7250c028d --- /dev/null +++ b/studio/data/organizations/keys.ts @@ -0,0 +1,6 @@ +export const organizationKeys = { + detail: (slug: string | undefined) => ['organizations', slug, 'detail'] as const, + roles: (slug: string | undefined) => ['organizations', slug, 'roles'] as const, + freeProjectLimitCheck: (slug: string | undefined) => + ['organizations', slug, 'free-project-limit-check'] as const, +} diff --git a/studio/data/organizations/organization-detail-query.ts b/studio/data/organizations/organization-detail-query.ts new file mode 100644 index 00000000000..34c46d9e306 --- /dev/null +++ b/studio/data/organizations/organization-detail-query.ts @@ -0,0 +1,87 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { Member } from 'types' +import { organizationKeys } from './keys' + +export type OrganizationDetailVariables = { + slug?: string +} + +export type OrganizationDetailResponse = { + members: Member[] +} + +export async function getOrganizationDetail( + { slug }: OrganizationDetailVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + let [data, inviteData] = await Promise.all([ + get(`${API_URL}/organizations/${slug}/members?member_roles`, { + signal, + }), + get(`${API_URL}/organizations/${slug}/members/invite`, { + signal, + }), + ]) + if (data.error) { + throw data.error + } + if (inviteData.error) { + throw inviteData.error + } + + if (data && inviteData && inviteData.length > 0) { + // Remap invite data to look like existing members data + const invitedMembers = inviteData.map((x: any) => { + const member = { + is_owner: false, + invited_at: x.invited_at, + invited_id: x.invited_id, + username: x.invited_email.slice(0, 1), + primary_email: x.invited_email, + } + return { ...member, role_ids: [x.role_id] } + }) + + data = [...data, ...invitedMembers] + } + + return { members: data } as OrganizationDetailResponse +} + +export type OrganizationDetailData = Awaited> +export type OrganizationDetailError = unknown + +export const useOrganizationDetailQuery = ( + { slug }: OrganizationDetailVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.detail(slug), + ({ signal }) => getOrganizationDetail({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationDetailPrefetch = ({ slug }: OrganizationDetailVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.detail(slug), ({ signal }) => + getOrganizationDetail({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/organization-member-delete-mutation.ts b/studio/data/organizations/organization-member-delete-mutation.ts new file mode 100644 index 00000000000..73a8e43f98c --- /dev/null +++ b/studio/data/organizations/organization-member-delete-mutation.ts @@ -0,0 +1,50 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberDeleteVariables = { + slug: string + gotrueId: string +} + +export async function deleteOrganizationMember({ + slug, + gotrueId, +}: OrganizationMemberDeleteVariables) { + const response = await delete_(`${API_URL}/organizations/${slug}/members/${gotrueId}`) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberDeleteData = Awaited> + +export const useOrganizationMemberDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteOrganizationMember(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/organizations/organization-member-invite-create-mutation.ts b/studio/data/organizations/organization-member-invite-create-mutation.ts new file mode 100644 index 00000000000..b07df4f6756 --- /dev/null +++ b/studio/data/organizations/organization-member-invite-create-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberInviteCreateVariables = { + slug: string + invitedEmail: string + ownerId: number + roleId: number +} + +export async function createOrganizationMemberInvite({ + slug, + invitedEmail, + ownerId, + roleId, +}: OrganizationMemberInviteCreateVariables) { + const response = await post(`${API_URL}/organizations/${slug}/members/invite`, { + invited_email: invitedEmail, + owner_id: ownerId, + role_id: roleId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberInviteCreateData = Awaited> + +export const useOrganizationMemberInviteCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationMemberInviteCreateData, + unknown, + OrganizationMemberInviteCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationMemberInviteCreateData, + unknown, + OrganizationMemberInviteCreateVariables + >((vars) => createOrganizationMemberInvite(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-member-invite-delete-mutation.ts b/studio/data/organizations/organization-member-invite-delete-mutation.ts new file mode 100644 index 00000000000..a801f9795f0 --- /dev/null +++ b/studio/data/organizations/organization-member-invite-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberInviteDeleteVariables = { + slug: string + invitedId: number +} + +export async function deleteOrganizationMemberInvite({ + slug, + invitedId, +}: OrganizationMemberInviteDeleteVariables) { + const response = await delete_( + `${API_URL}/organizations/${slug}/members/invite?invited_id=${invitedId}`, + {} + ) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberInviteDeleteData = Awaited> + +export const useOrganizationMemberInviteDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationMemberInviteDeleteData, + unknown, + OrganizationMemberInviteDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationMemberInviteDeleteData, + unknown, + OrganizationMemberInviteDeleteVariables + >((vars) => deleteOrganizationMemberInvite(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-member-update-mutation.ts b/studio/data/organizations/organization-member-update-mutation.ts new file mode 100644 index 00000000000..bdfb211ebc7 --- /dev/null +++ b/studio/data/organizations/organization-member-update-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberUpdateVariables = { + slug: string + gotrueId: string + roleId: number +} + +export async function updateOrganizationMember({ + slug, + gotrueId, + roleId, +}: OrganizationMemberUpdateVariables) { + const response = await patch(`${API_URL}/organizations/${slug}/members/${gotrueId}`, { + role_id: roleId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberUpdateData = Awaited> + +export const useOrganizationMemberUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrganizationMember(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/organizations/organization-roles-query.ts b/studio/data/organizations/organization-roles-query.ts new file mode 100644 index 00000000000..67618853e85 --- /dev/null +++ b/studio/data/organizations/organization-roles-query.ts @@ -0,0 +1,63 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { Role } from 'types' +import { organizationKeys } from './keys' + +export type OrganizationRolesVariables = { + slug?: string +} + +export type OrganizationRolesResponse = { + roles: Role[] +} + +export async function getOrganizationRoles( + { slug }: OrganizationRolesVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + const data = await get(`${API_URL}/organizations/${slug}/roles`, { + signal, + }) + if (data.error) { + throw data.error + } + + return { roles: data } as OrganizationRolesResponse +} + +export type OrganizationRolesData = Awaited> +export type OrganizationRolesError = unknown + +export const useOrganizationRolesQuery = ( + { slug }: OrganizationRolesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.roles(slug), + ({ signal }) => getOrganizationRoles({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationRolesPrefetch = ({ slug }: OrganizationRolesVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.roles(slug), ({ signal }) => + getOrganizationRoles({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/permissions/keys.ts b/studio/data/permissions/keys.ts new file mode 100644 index 00000000000..f90354c2b84 --- /dev/null +++ b/studio/data/permissions/keys.ts @@ -0,0 +1,3 @@ +export const permissionKeys = { + list: () => ['permissions'] as const, +} diff --git a/studio/data/permissions/permissions-query.ts b/studio/data/permissions/permissions-query.ts new file mode 100644 index 00000000000..45d73e0d0a5 --- /dev/null +++ b/studio/data/permissions/permissions-query.ts @@ -0,0 +1,40 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { Permission } from 'types' +import { permissionKeys } from './keys' + +export type PermissionsResponse = Permission[] + +export async function getPermissions(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile/permissions`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as PermissionsResponse +} + +export type PermissionsData = Awaited> +export type PermissionsError = unknown + +export const usePermissionsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + permissionKeys.list(), + ({ signal }) => getPermissions(signal), + options + ) + +export const usePermissionsPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(permissionKeys.list(), ({ signal }) => getPermissions(signal)) + }, []) +} diff --git a/studio/data/platform/keys.ts b/studio/data/platform/keys.ts new file mode 100644 index 00000000000..5b17e948137 --- /dev/null +++ b/studio/data/platform/keys.ts @@ -0,0 +1,3 @@ +export const platformKeys = { + status: () => ['platform', 'status'] as const, +} diff --git a/studio/data/platform/platform-status-query.ts b/studio/data/platform/platform-status-query.ts new file mode 100644 index 00000000000..0cff8bfd4f5 --- /dev/null +++ b/studio/data/platform/platform-status-query.ts @@ -0,0 +1,40 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { platformKeys } from './keys' + +export type PlatformStatusResponse = { + isHealthy: boolean +} + +export async function getPlatformStatus(signal?: AbortSignal) { + const response = await get(`${API_URL}/status`, { + signal, + }) + if (response.error) { + throw response.error + } + + return { isHealthy: response.is_healthy } as PlatformStatusResponse +} + +export type PlatformStatusData = Awaited> +export type PlatformStatusError = unknown + +export const usePlatformStatusQuery = ( + options: UseQueryOptions = {} +) => + useQuery( + platformKeys.status(), + ({ signal }) => getPlatformStatus(signal), + options + ) + +export const usePlatformStatusPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(platformKeys.status(), ({ signal }) => getPlatformStatus(signal)) + }, []) +} diff --git a/studio/data/profile/keys.ts b/studio/data/profile/keys.ts new file mode 100644 index 00000000000..49ba29ad7b7 --- /dev/null +++ b/studio/data/profile/keys.ts @@ -0,0 +1,3 @@ +export const profileKeys = { + profile: () => ['profile'] as const, +} diff --git a/studio/data/profile/profile-query.ts b/studio/data/profile/profile-query.ts new file mode 100644 index 00000000000..d48256ef0d3 --- /dev/null +++ b/studio/data/profile/profile-query.ts @@ -0,0 +1,52 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { profileKeys } from './keys' + +export type Profile = { + id: number + auth0_id: string + primary_email: string + username: string + first_name: string + last_name: string + mobile: string | null + is_alpha_user: boolean + gotrue_id: string + free_project_limit: number +} + +export type ProfileResponse = Profile + +export async function getProfile(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProfileResponse +} + +export type ProfileData = Awaited> +export type ProfileError = unknown + +export const useProfileQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + profileKeys.profile(), + ({ signal }) => getProfile(signal), + options + ) + +export const useProfilePrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(profileKeys.profile(), ({ signal }) => getProfile(signal)) + }, []) +} diff --git a/studio/data/profile/profile-update-mutation.ts b/studio/data/profile/profile-update-mutation.ts new file mode 100644 index 00000000000..b1b9ba67252 --- /dev/null +++ b/studio/data/profile/profile-update-mutation.ts @@ -0,0 +1,45 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { profileKeys } from './keys' + +export type ProfileUpdateVariables = { + firstName: string + lastName: string +} + +export async function updateProfile({ firstName, lastName }: ProfileUpdateVariables) { + const response = await post(`${API_URL}/profile/update`, { + first_name: firstName, + last_name: lastName, + }) + if (response.error) { + throw response.error + } + + return response +} + +type ProfileUpdateData = Awaited> + +export const useProfileUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateProfile(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(profileKeys.profile()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/subscriptions/keys.ts b/studio/data/subscriptions/keys.ts new file mode 100644 index 00000000000..2b695295cc7 --- /dev/null +++ b/studio/data/subscriptions/keys.ts @@ -0,0 +1,4 @@ +export const subscriptionKeys = { + subscription: (projectRef: string | undefined) => + ['projects', projectRef, 'subscription'] as const, +} diff --git a/studio/data/subscriptions/project-subscription-query.ts b/studio/data/subscriptions/project-subscription-query.ts new file mode 100644 index 00000000000..8a3365dc9f9 --- /dev/null +++ b/studio/data/subscriptions/project-subscription-query.ts @@ -0,0 +1,61 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { StripeSubscription } from 'components/interfaces/Billing' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { subscriptionKeys } from './keys' + +export type ProjectSubscriptionVariables = { + projectRef?: string +} + +export type ProjectSubscriptionResponse = StripeSubscription + +export async function getProjectSubscription( + { projectRef }: ProjectSubscriptionVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/subscription`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectSubscriptionResponse +} + +export type ProjectSubscriptionData = Awaited> +export type ProjectSubscriptionError = unknown + +export const useProjectSubscriptionQuery = ( + { projectRef }: ProjectSubscriptionVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + subscriptionKeys.subscription(projectRef), + ({ signal }) => getProjectSubscription({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) + +export const useProjectSubscriptionPrefetch = ({ projectRef }: ProjectSubscriptionVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(subscriptionKeys.subscription(projectRef), ({ signal }) => + getProjectSubscription({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/usage/keys.ts b/studio/data/usage/keys.ts new file mode 100644 index 00000000000..b37e6e2454f --- /dev/null +++ b/studio/data/usage/keys.ts @@ -0,0 +1,3 @@ +export const usageKeys = { + usage: (projectRef: string | undefined) => ['projects', projectRef, 'usage'] as const, +} diff --git a/studio/data/usage/project-usage-query.ts b/studio/data/usage/project-usage-query.ts new file mode 100644 index 00000000000..577fd0bfd69 --- /dev/null +++ b/studio/data/usage/project-usage-query.ts @@ -0,0 +1,97 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { usageKeys } from './keys' + +export type ProjectUsageVariables = { + projectRef?: string +} + +export interface DbSize { + usage: number | null + limit: number + cost: number + current: number + available_in_plan: boolean +} + +export interface DbEgress { + usage: number + limit: number + cost: number + available_in_plan: boolean +} + +export interface StorageSize { + usage: number | null + limit: number + cost: number + current: number + available_in_plan: boolean +} + +export interface StorageEgress { + usage: number + limit: number + cost: number + available_in_plan: boolean +} + +export interface MonthlyActiveUsers { + usage: number + limit: number + cost: number + available_in_plan: boolean +} + +export type ProjectUsageResponse = { + db_size: DbSize + db_egress: DbEgress + storage_size: StorageSize + storage_egress: StorageEgress + monthly_active_users: MonthlyActiveUsers +} + +export async function getProjectUsage({ projectRef }: ProjectUsageVariables, signal?: AbortSignal) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/usage`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectUsageResponse +} + +export type ProjectUsageData = Awaited> +export type ProjectUsageError = unknown + +export const useProjectUsageQuery = ( + { projectRef }: ProjectUsageVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + usageKeys.usage(projectRef), + ({ signal }) => getProjectUsage({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) + +export const useProjectUsagePrefetch = ({ projectRef }: ProjectUsageVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(usageKeys.usage(projectRef), ({ signal }) => + getProjectUsage({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/hooks/analytics/useDbQuery.tsx b/studio/hooks/analytics/useDbQuery.tsx index 9c229fb7f83..0130ca85ed9 100644 --- a/studio/hooks/analytics/useDbQuery.tsx +++ b/studio/hooks/analytics/useDbQuery.tsx @@ -1,3 +1,4 @@ +import { useQuery } from '@tanstack/react-query' import { DEFAULT_QUERY_PARAMS } from 'components/interfaces/Reports/Reports.constants' import { BaseReportParams, @@ -6,28 +7,45 @@ import { MetaQueryResponse, DbQuery, } from 'components/interfaces/Reports/Reports.types' -import { useStore } from 'hooks' -import useSWR from 'swr' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { executeSql } from 'data/sql/execute-sql-query' type UseDbQuery = (sql: DbQuery['sql'], params?: BaseReportParams) => [DbQueryData, DbQueryHandler] const useDbQuery: UseDbQuery = (sql, params = DEFAULT_QUERY_PARAMS) => { - const { meta } = useStore() + const { project } = useProjectContext() const resolvedSql = typeof sql === 'function' ? sql(params) : sql + const { data, - error: swrError, - isValidating: isLoading, - mutate, - } = useSWR(resolvedSql, async () => await meta.query(resolvedSql), { - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnReconnect: false, - dedupingInterval: 2000, - }) + error: rqError, + isLoading, + isRefetching, + refetch, + } = useQuery( + ['projects', project?.ref, 'db', { ...params, sql: resolvedSql }], + ({ signal }) => { + return executeSql( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + sql: resolvedSql, + }, + signal + ).then((res) => res.result) as Promise + }, + { + enabled: Boolean(resolvedSql), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + } + ) - const error = swrError || (typeof data === 'object' ? data?.error : '') - return [{ error, data, isLoading, params }, { runQuery: () => mutate() }] + const error = rqError || (typeof data === 'object' ? data?.error : '') + return [ + { error, data, isLoading: isLoading || isRefetching, params }, + { runQuery: () => refetch() }, + ] } export default useDbQuery diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index fc65f7288de..15e5be9a7ab 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -1,3 +1,4 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { Count, EventChart, @@ -15,8 +16,6 @@ import { PREVIEWER_DATEPICKER_HELPERS, } from 'components/interfaces/Settings/Logs' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' -import useSWR from 'swr' -import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite' import { API_URL } from 'lib/constants' import { get } from 'lib/common/fetch' import dayjs from 'dayjs' @@ -26,7 +25,7 @@ interface Data { error: string | Object | null newCount: number isLoading: boolean - pageSize: number + isLoadingOlder: boolean filters: Filters params: LogsEndpointParams oldestTimestamp?: string @@ -64,51 +63,44 @@ function useLogsPreview( refresh() }, [JSON.stringify(filters)]) - // handle url generation for log pagination - const getKeyLogs: SWRInfiniteKeyLoader = (_pageIndex: number, prevPageData: Logs) => { - let queryParams - - // cancel request if no sql provided - if (!params.sql) { - // return null to restrict unnecessary requests to api - // https://swr.vercel.app/docs/conditional-fetching#conditional - return null - } - - // if prev page data is 100 items, could possibly have more records that are not yet fetched within this interval - if (prevPageData === null) { - // reduce interval window limit by using the timestamp of the last log - queryParams = genQueryParams(params as any) - } else if ((prevPageData.result ?? []).length === 0) { - // no rows returned, indicates that no more data to retrieve and append. - return null - } else { - const len = prevPageData.result.length - const { timestamp: tsLimit }: LogData = prevPageData.result[len - 1] - const isoTsLimit = dayjs.utc(Number(tsLimit / 1000)).toISOString() - // create new key from params - queryParams = genQueryParams({ ...params, iso_timestamp_end: isoTsLimit } as any) - } - return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` - } + const queryParamsKey = genQueryParams(params as any) const { - data = [], - error: swrError, - isValidating, - size, - setSize, - } = useSWRInfinite(getKeyLogs, get, { revalidateOnFocus: false, dedupingInterval: 3000 }) + data, + isLoading, + isRefetching, + error: rqError, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery( + ['projects', projectRef, 'logs', queryParamsKey], + ({ signal, pageParam }) => { + return get( + `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ + ...params, + iso_timestamp_end: pageParam, + } as any)}`, + { signal } + ) + }, + { + refetchOnWindowFocus: false, + getNextPageParam(lastPage) { + if ((lastPage?.result?.length ?? 0) === 0) { + return undefined + } + const len = lastPage.result.length + const { timestamp: tsLimit }: LogData = lastPage.result[len - 1] + const isoTsLimit = dayjs.utc(Number(tsLimit / 1000)).toISOString() + return isoTsLimit + }, + } + ) + let logData: LogData[] = [] const countUrl = () => { - // cancel request if no sql provided - if (!params.sql) { - // return null to restrict unnecessary requests to api - // https://swr.vercel.app/docs/conditional-fetching#conditional - return null - } - return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ ...params, sql: genCountQuery(table, filters), @@ -116,24 +108,26 @@ function useLogsPreview( } as any)}` } - const { data: countData } = useSWR(countUrl, get, { - revalidateOnFocus: false, - dedupingInterval: 5000, - refreshInterval: 5000, - }) + const { data: countData } = useQuery( + [ + 'projects', + projectRef, + 'logs-count', + { ...params, sql: genCountQuery(table, filters), iso_timestamp_start: latestRefresh }, + ], + ({ signal }) => get(countUrl(), { signal }), + { + refetchOnWindowFocus: false, + refetchInterval: 5000, + } + ) + const newCount = countData?.result?.[0]?.count ?? 0 // chart data const chartQuery = genChartQuery(table, params, filters) const chartUrl = () => { - // cancel request if no sql provided - if (!params.sql) { - // return null to restrict unnecessary requests to api - // https://swr.vercel.app/docs/conditional-fetching#conditional - return null - } - return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ iso_timestamp_end: params.iso_timestamp_end, project: params.project, @@ -141,26 +135,27 @@ function useLogsPreview( } as any)}` } - const { data: eventChartResponse, mutate: refreshEventChart } = useSWR( - chartUrl, - get, - { - revalidateOnFocus: false, - dedupingInterval: 10000, - refreshInterval: 0, - } + const { data: eventChartResponse, refetch: refreshEventChart } = useQuery( + [ + 'projects', + projectRef, + 'logs-chart', + { iso_timestamp_end: params.iso_timestamp_end, project: params.project, sql: chartQuery }, + ], + ({ signal }) => get(chartUrl(), { signal }), + { refetchOnWindowFocus: false } ) const refresh = async () => { const generatedSql = genDefaultQuery(table, filters) setParams((prev) => ({ ...prev, sql: generatedSql })) setLatestRefresh(new Date().toISOString()) - setSize(1) refreshEventChart() + refetch() } - let error: null | string | object = swrError ? swrError.message : null - data.forEach((response) => { + let error: null | string | object = rqError ? (rqError as any).message : null + data?.pages.forEach((response) => { if (!error && response?.result) { logData = [...logData, ...response.result] } @@ -185,8 +180,8 @@ function useLogsPreview( { newCount, logData, - isLoading: isValidating, - pageSize: size, + isLoading: isLoading || isRefetching, + isLoadingOlder: isFetchingNextPage, error, filters, params, @@ -196,7 +191,7 @@ function useLogsPreview( { setFilters: handleSetFilters, refresh, - loadOlder: () => setSize((prev) => prev + 1), + loadOlder: () => fetchNextPage(), setParams, }, ] diff --git a/studio/hooks/analytics/useLogsQuery.tsx b/studio/hooks/analytics/useLogsQuery.tsx index be58c480c58..99fc2c41713 100644 --- a/studio/hooks/analytics/useLogsQuery.tsx +++ b/studio/hooks/analytics/useLogsQuery.tsx @@ -1,3 +1,4 @@ +import { useQuery } from '@tanstack/react-query' import { EXPLORER_DATEPICKER_HELPERS, genQueryParams, @@ -6,7 +7,6 @@ import { import { Dispatch, SetStateAction, useState } from 'react' import { LogsEndpointParams, Logs, LogData } from 'components/interfaces/Settings/Logs/Logs.types' import { API_URL } from 'lib/constants' -import useSWR from 'swr' import { get } from 'lib/common/fetch' export interface LogsQueryData { params: LogsEndpointParams @@ -37,20 +37,28 @@ const useLogsQuery = ( : defaultHelper.calcTo(), }) + const enabled = typeof projectRef !== 'undefined' && Boolean(params.sql) + const queryParams = genQueryParams(params as any) const { data, - error: swrError, - isValidating: isLoading, - mutate, - } = useSWR( - params.sql - ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` - : null, - get, - { revalidateOnFocus: false } + error: rqError, + isLoading, + isRefetching, + refetch, + } = useQuery( + ['projects', projectRef, 'logs', queryParams], + ({ signal }) => + get(`${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}`, { + signal, + }), + { + enabled, + refetchOnWindowFocus: false, + } ) - let error: null | string | object = swrError ? swrError.message : null + + let error: null | string | object = rqError ? (rqError as any).message : null if (!error && data?.error) { error = data?.error @@ -60,8 +68,13 @@ const useLogsQuery = ( } return [ - { params, isLoading, logData: data?.result ? data?.result : [], error }, - { changeQuery, runQuery: () => mutate(), setParams }, + { + params, + isLoading: (enabled && isLoading) || isRefetching, + logData: data?.result ? data?.result : [], + error, + }, + { changeQuery, runQuery: () => refetch(), setParams }, ] } export default useLogsQuery diff --git a/studio/hooks/analytics/useSingleLog.tsx b/studio/hooks/analytics/useSingleLog.tsx index 1d468d18c6c..690c5df5eb3 100644 --- a/studio/hooks/analytics/useSingleLog.tsx +++ b/studio/hooks/analytics/useSingleLog.tsx @@ -7,9 +7,9 @@ import { LOGS_TABLES, QueryType, } from 'components/interfaces/Settings/Logs' -import useSWR from 'swr' import { API_URL } from 'lib/constants' import { get } from 'lib/common/fetch' +import { useQuery } from '@tanstack/react-query' interface Data { logData: LogData | undefined @@ -20,38 +20,46 @@ interface Handlers { refresh: () => void } function useSingleLog( - project: string, + projectRef: string, queryType?: QueryType, paramsToMerge?: Partial, id?: string | null ): [Data, Handlers] { const table = queryType ? LOGS_TABLES[queryType] : undefined const sql = id && table ? genSingleLogQuery(table, id) : '' - const params: LogsEndpointParams = { ...paramsToMerge, project, sql } - const endpointUrl = `${API_URL}/projects/${project}/analytics/endpoints/logs.all?${genQueryParams( + const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql } + const endpointUrl = `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams( params as any )}` + + const enabled = Boolean(id && table) + const { data, - error: swrError, - isValidating, - mutate, - } = useSWR(id && table ? endpointUrl : null, get, { - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnReconnect: false, - dedupingInterval: 5000, - }) + error: rcError, + isLoading, + isRefetching, + refetch, + } = useQuery( + ['projects', projectRef, 'log', id], + ({ signal }) => get(endpointUrl, { signal }) as Promise, + { + enabled, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ) - let error: null | string | object = swrError ? swrError.message : null + let error: null | string | object = rcError ? (rcError as any).message : null return [ { logData: data?.result ? data.result[0] : undefined, - isLoading: isValidating, + isLoading: (enabled && isLoading) || isRefetching, error, }, { - refresh: () => mutate(), + refresh: () => refetch(), }, ] } diff --git a/studio/hooks/index.ts b/studio/hooks/index.ts index 587af606a57..5995d22e059 100644 --- a/studio/hooks/index.ts +++ b/studio/hooks/index.ts @@ -2,9 +2,7 @@ export * from './misc/useStore' export * from './misc/withAuth' export * from './misc/useOptimisticSqlSnippetCreate' export * from './misc/useImmutableValue' -export * from './misc/useNotifications' -export * from './misc/usePlatformStatus' export * from './misc/useParams' +export * from './misc/useCheckPermissions' export * from './deprecated' -export * from './queries' export * from './ui' diff --git a/studio/hooks/misc/useAutoAuthRedirect.tsx b/studio/hooks/misc/useAutoAuthRedirect.tsx index eed587eaca7..3a63cae6976 100644 --- a/studio/hooks/misc/useAutoAuthRedirect.tsx +++ b/studio/hooks/misc/useAutoAuthRedirect.tsx @@ -1,7 +1,7 @@ +import { QueryClient } from '@tanstack/react-query' import { auth, getReturnToPath } from 'lib/gotrue' import { useRouter } from 'next/router' import { useCallback, useEffect } from 'react' -import { useSWRConfig } from 'swr' export function usePushNext() { const router = useRouter() @@ -47,9 +47,8 @@ export function usePushNext() { ) } -function useAutoAuthRedirect() { +function useAutoAuthRedirect(queryClient: QueryClient) { const pushNext = usePushNext() - const { cache } = useSWRConfig() useEffect(() => { const searchParams = new URLSearchParams(location.search) @@ -75,9 +74,7 @@ function useAutoAuthRedirect() { } = await auth.getSession() if (session) { - // .clear() does actually exist on the cache object, but it's not in the types 🤦🏻 - // @ts-ignore - cache.clear() + await queryClient.resetQueries() await pushNext() } diff --git a/studio/hooks/queries/usePermissions.ts b/studio/hooks/misc/useCheckPermissions.ts similarity index 51% rename from studio/hooks/queries/usePermissions.ts rename to studio/hooks/misc/useCheckPermissions.ts index 537535324bd..9f9aa4f5883 100644 --- a/studio/hooks/queries/usePermissions.ts +++ b/studio/hooks/misc/useCheckPermissions.ts @@ -1,33 +1,6 @@ +import { useFlag, useStore } from 'hooks' import jsonLogic from 'json-logic-js' -import { useStore } from 'hooks' -import { get } from 'lib/common/fetch' -import { API_URL, IS_PLATFORM } from 'lib/constants' -import useSWR from 'swr' - -export function usePermissions(profile?: any, returning?: 'minimal') { - let url = `${API_URL}/profile/permissions` - - if (returning) { - const query = new URLSearchParams({ returning }).toString() - url = `${url}?${query}` - } - - const { - data: data, - error, - mutate, - } = useSWR(profile !== undefined && IS_PLATFORM ? url : null, get, { - loadingTimeout: 10000, - }) - const anyError = data?.error || error - - return { - permissions: IS_PLATFORM ? (anyError ? undefined : data) : [], - isLoading: !anyError && !data, - isError: !!anyError, - mutate, - } -} +import { IS_PLATFORM } from 'lib/constants' const toRegexpString = (actionOrResource: string) => `^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$` @@ -41,7 +14,7 @@ export function checkPermissions( if (!IS_PLATFORM) return true const { ui } = useStore() - const orgid = organizationId ?? ui?.selectedOrganization?.id + const orgId = organizationId ?? ui?.selectedOrganization?.id return (ui?.permissions ?? []) .filter( @@ -53,7 +26,7 @@ export function checkPermissions( }) => permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) && permission.resources.some((res) => resource.match(toRegexpString(res))) && - permission.organization_id === orgid + permission.organization_id === orgId ) .some( ({ condition }: { condition: jsonLogic.RulesLogic }) => diff --git a/studio/hooks/misc/useNotifications.tsx b/studio/hooks/misc/useNotifications.tsx deleted file mode 100644 index 23b8f35d3dd..00000000000 --- a/studio/hooks/misc/useNotifications.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import useSWR from 'swr' -import { Notification } from '@supabase/shared-types/out/notifications' - -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export const useNotifications = () => { - const { data, error, mutate } = useSWR(`${API_URL}/notifications`, get) - - const anyError = (data as any)?.error || error !== undefined - const refresh = () => mutate() - - return { - notifications: anyError ? undefined : data, - isLoading: !anyError && !data, - isError: !!anyError, - refresh, - } -} diff --git a/studio/hooks/misc/usePlatformStatus.tsx b/studio/hooks/misc/usePlatformStatus.tsx deleted file mode 100644 index 1fac085769d..00000000000 --- a/studio/hooks/misc/usePlatformStatus.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export const usePlatformStatus = () => { - const { data, error, mutate } = useSWR<{ is_healthy: boolean }>(`${API_URL}/status`, get) - - const anyError = (data as any)?.error || error !== undefined - const refresh = () => mutate() - - return { - isHealthy: anyError ? undefined : data?.is_healthy, - isLoading: !anyError && !data, - isError: !!anyError, - refresh, - } -} diff --git a/studio/hooks/misc/useProjectSubscription.tsx b/studio/hooks/misc/useProjectSubscription.tsx deleted file mode 100644 index beb32f9ab71..00000000000 --- a/studio/hooks/misc/useProjectSubscription.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import useSWR from 'swr' - -export interface Tier { - name: string - prod_id: string - key: 'FREE' | 'PRO' | 'PAYG' - unit_amount: number -} -interface Data { - tier: Tier - addons: any[] - billing: { - billing_cycle_anchor: number - current_period_end: number - current_period_start: number - } -} - -interface Handlers { - refresh: () => void -} - -const useProjectSubscription = (projectRef: string): [Data | null, Handlers] => { - const { data, error, mutate } = useSWR( - projectRef ? `${API_URL}/projects/${projectRef}/subscription` : null, - get - ) - const refresh = () => mutate() - if (!data) return [null, { refresh }] - if (error) return [null, { refresh }] - return [data, { refresh }] -} - -export default useProjectSubscription diff --git a/studio/hooks/misc/useStore.tsx b/studio/hooks/misc/useStore.tsx index ad858c288b4..a998f78d3d1 100644 --- a/studio/hooks/misc/useStore.tsx +++ b/studio/hooks/misc/useStore.tsx @@ -83,6 +83,24 @@ export const StoreProvider: FC = ({ children, rootStore }) => { } } }) + + /** + * get Ga4 client_id when it's available + * */ + // @ts-ignore + if (!!window?.gtag) { + // @ts-ignore + window?.gtag( + 'get', + `${process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID}`, + 'client_id', + // @ts-ignore + (client_id) => { + ui.setGaClientId(client_id) + } + ) + } + return () => window .matchMedia('(prefers-color-scheme: dark)') diff --git a/studio/hooks/misc/useUpgradePrompt.tsx b/studio/hooks/misc/useUpgradePrompt.tsx index 71aa956ca23..f19f090c302 100644 --- a/studio/hooks/misc/useUpgradePrompt.tsx +++ b/studio/hooks/misc/useUpgradePrompt.tsx @@ -1,13 +1,11 @@ import { useEffect, useState } from 'react' -import { useProjectSubscription } from 'hooks' -import { useRouter } from 'next/router' +import { useParams } from 'hooks' import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs' -import { StripeProduct } from 'components/interfaces/Billing' +import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query' export const useUpgradePrompt = (from: string) => { - const router = useRouter() - const { ref } = router.query - const { subscription } = useProjectSubscription(ref as string) + const { ref: projectRef } = useParams() + const { data: subscription } = useProjectSubscriptionQuery({ projectRef }) const tier = subscription?.tier const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index d701d513282..b41734cb1c8 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -1,12 +1,14 @@ -import { ComponentType, useEffect } from 'react' import Head from 'next/head' import { NextRouter, useRouter } from 'next/router' +import { ComponentType, useEffect } from 'react' -import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useProfileQuery } from 'data/profile/profile-query' +import { useStore } from 'hooks' import { IS_PLATFORM } from 'lib/constants' -import { useProfile, useStore, usePermissions } from 'hooks' -import Error500 from '../../pages/500' +import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' import { NextPageWithLayout } from 'types' +import Error500 from '../../pages/500' const PLATFORM_ONLY_PAGES = [ 'reports', @@ -34,14 +36,24 @@ export function withAuth( const redirectTo = options?.redirectTo ?? defaultRedirectTo(ref) const redirectIfFound = options?.redirectIfFound - const returning = - app.projects.isInitialized && app.organizations.isInitialized ? 'minimal' : undefined - const { profile, isLoading, error } = useProfile(returning) const { - permissions, - isLoading: isPermissionLoading, - mutate: mutatePermissions, - } = usePermissions(profile, returning) + data: profile, + isLoading, + error, + } = useProfileQuery({ + onSuccess(profile) { + ui.setProfile(profile) + + if (!app.organizations.isInitialized) app.organizations.load() + if (!app.projects.isInitialized) app.projects.load() + }, + }) + + usePermissionsQuery({ + onSuccess(permissions) { + ui.setPermissions(permissions) + }, + }) const isAccessingBlockedPage = !IS_PLATFORM && @@ -51,31 +63,14 @@ export function withAuth( checkRedirectTo(isLoading, router, profile, error, redirectTo, redirectIfFound) useEffect(() => { - // This should run before redirecting - if (!isLoading) { - if (!profile) { - ui.setProfile(undefined) - } else if (returning !== 'minimal') { - ui.setProfile(profile) - - if (!app.organizations.isInitialized) app.organizations.load() - if (!app.projects.isInitialized) app.projects.load() - mutatePermissions() - } - } - - if (!isPermissionLoading) { - ui.setPermissions(permissions) - } - // This should run after setting store data if (isRedirecting) { router.push(redirectTo) } - }, [isLoading, isPermissionLoading, isRedirecting, profile, permissions]) + }, [isRedirecting, redirectTo]) useEffect(() => { - if (!isLoading && router.isReady) { + if (router.isReady) { if (ref) { rootStore.setProjectRef(Array.isArray(ref) ? ref[0] : ref) } diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts deleted file mode 100644 index 4582c19b2fb..00000000000 --- a/studio/hooks/queries/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './useJwtSecretUpdateStatus' -export * from './useOrganizationDetail' -export * from './useOrganizationRoles' -export * from './useFreeProjectLimitCheck' -export * from './usePermissions' -export * from './useProfile' -export * from './useProjectAuthConfig' -export * from './useProjectSettings' -export * from './useProjectSubscription' -export * from './useProjectUsage' -export * from './useProjectPostgrestConfig' diff --git a/studio/hooks/queries/useAccessTokens.ts b/studio/hooks/queries/useAccessTokens.ts deleted file mode 100644 index 918c827fbad..00000000000 --- a/studio/hooks/queries/useAccessTokens.ts +++ /dev/null @@ -1,40 +0,0 @@ -import useSWR, { mutate } from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export interface AccessToken { - id: number - token_alias: string - name: string - created_at: number -} - -export interface NewAccessToken extends AccessToken { - token: string -} - -export function useAccessTokens() { - const url = `${API_URL}/profile/access-tokens` - let { data, error } = useSWR(url, get) - const anyError = data?.error || error - - function mutateNewToken(newToken: AccessToken, revalidate?: boolean) { - mutate(url, (data: AccessToken[]) => [...(data || []), newToken], revalidate ?? true) - } - - function mutateDeleteToken(tokenId: number, revalidate?: boolean) { - mutate( - url, - (data: AccessToken[]) => data.filter((x: AccessToken) => x.id === tokenId), - revalidate ?? true - ) - } - - return { - tokens: anyError ? undefined : (data as AccessToken[]), - isLoading: !anyError && !data, - isError: !!anyError, - mutateNewToken, - mutateDeleteToken, - } -} diff --git a/studio/hooks/queries/useFreeProjectLimitCheck.ts b/studio/hooks/queries/useFreeProjectLimitCheck.ts deleted file mode 100644 index 3884388ebdd..00000000000 --- a/studio/hooks/queries/useFreeProjectLimitCheck.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export interface MemberWithFreeProjectLimit { - free_project_limit: number - primary_email: string - username: string -} - -export function useFreeProjectLimitCheck(slug?: string) { - const url = `${API_URL}/organizations/${slug}/members/reached-free-project-limit` - const { data, error } = useSWR(slug ? url : null, get) - const anyError = data?.error || error - - return { - membersExceededLimit: anyError ? undefined : (data as MemberWithFreeProjectLimit[]), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/hooks/queries/useJwtSecretUpdateStatus.ts b/studio/hooks/queries/useJwtSecretUpdateStatus.ts deleted file mode 100644 index 11cb7dc31ca..00000000000 --- a/studio/hooks/queries/useJwtSecretUpdateStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { API_URL } from 'lib/constants' - -export function useJwtSecretUpdateStatus(projectRef: string | string[] | undefined) { - const url = `${API_URL}/props/project/${projectRef}/jwt-secret-update-status` - const { data, error, mutate } = useSWR(url, get, { - refreshInterval: (data) => - data?.jwtSecretUpdateStatus?.meta?.status === JwtSecretUpdateStatus.Updating ? 1_000 : 0, - }) - const anyError = data?.error || error - const meta = data?.jwtSecretUpdateStatus?.meta - return { - changeTrackingId: meta?.change_tracking_id, - error, - isError: !!anyError, - isLoading: !anyError && !data, - jwtSecretUpdateError: meta?.error, - jwtSecretUpdateProgress: meta?.progress, - jwtSecretUpdateStatus: meta?.status, - mutateJwtSecretUpdateStatus: mutate, - } -} diff --git a/studio/hooks/queries/useOrganizationDetail.ts b/studio/hooks/queries/useOrganizationDetail.ts deleted file mode 100644 index e2f4253fef6..00000000000 --- a/studio/hooks/queries/useOrganizationDetail.ts +++ /dev/null @@ -1,47 +0,0 @@ -import useSWR, { mutate } from 'swr' - -import { Member } from 'types' -import { API_URL } from 'lib/constants' -import { get } from 'lib/common/fetch' - -export function useOrganizationDetail(slug: string) { - // Get org members - const url = `${API_URL}/organizations/${slug}/members?member_roles` - - const { data, error } = useSWR(slug ? url : null, get) - let members = data - - // Get pending invite users - const pendingInviteUrl = `${API_URL}/organizations/${slug}/members/invite` - const { data: inviteData, error: inviteError } = useSWR(slug ? pendingInviteUrl : null, get) - - if (data && inviteData && inviteData.length > 0) { - // Remap invite data to look like existing members data - const invitedMembers = inviteData.map((x: any) => { - const member = { - is_owner: false, - invited_at: x.invited_at, - invited_id: x.invited_id, - username: x.invited_email.slice(0, 1), - primary_email: x.invited_email, - } - return { ...member, role_ids: [x.role_id] } - }) - - members = [...members, ...invitedMembers] - } - - const anyError = data?.error || error || inviteError - - function mutateOrgMembers(updatedMembers: Member[], revalidate?: boolean) { - mutate(url, [...updatedMembers], revalidate ?? true) - mutate(pendingInviteUrl, {}, revalidate ?? true) - } - - return { - members: members as Member[], - isLoading: !anyError && !data, - isError: !!anyError, - mutateOrgMembers, - } -} diff --git a/studio/hooks/queries/useOrganizationRoles.ts b/studio/hooks/queries/useOrganizationRoles.ts deleted file mode 100644 index 8267ec0ad9f..00000000000 --- a/studio/hooks/queries/useOrganizationRoles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { Role } from 'types' - -// [Joshen] Putting roles in a hook for now for simplicity -// Unless we plan to support CRUD on roles i think it can stay as a hook -// otherwise, best to shift it to a store. If we do shift it, have it in the -// App store to prevent confusion with PG roles - -export function useOrganizationRoles(slug?: string) { - const url = `${API_URL}/organizations/${slug}/roles` - const { data, error } = useSWR(slug ? url : null, get) - const anyError = data?.error || error - - return { - roles: anyError ? [] : (data as Role[]), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/hooks/queries/useProfile.ts b/studio/hooks/queries/useProfile.ts deleted file mode 100644 index b4f8f78d79f..00000000000 --- a/studio/hooks/queries/useProfile.ts +++ /dev/null @@ -1,35 +0,0 @@ -import useSWR, { mutate } from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { User } from 'types' - -export function useProfile(returning?: 'minimal') { - let url = `${API_URL}/profile` - - if (returning) { - const query = new URLSearchParams({ returning }).toString() - url = `${url}?${query}` - } - - const { data, error } = useSWR(url, get, { loadingTimeout: 10000 }) - const anyError = data?.error || error - - function mutateProfile(updatedUser: User, revalidate?: boolean) { - mutate( - url, - { - first_name: updatedUser.first_name, - last_name: updatedUser.last_name, - }, - revalidate ?? true - ) - } - - return { - profile: anyError ? undefined : data, - isLoading: !anyError && !data, - isError: !!anyError, - error: anyError, - mutateProfile, - } -} diff --git a/studio/hooks/queries/useProjectAuthConfig.ts b/studio/hooks/queries/useProjectAuthConfig.ts deleted file mode 100644 index e658dc14543..00000000000 --- a/studio/hooks/queries/useProjectAuthConfig.ts +++ /dev/null @@ -1,21 +0,0 @@ -import useSWR, { mutate } from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export function useProjectAuthConfig(ref: string) { - const url = `${API_URL}/auth/${ref}/config` - const { data, error } = useSWR(url, get) - const anyError = data?.error || error - - function mutateAuthConfig(updatedConfig: any, revalidate?: boolean) { - mutate(url, { ...updatedConfig }, revalidate ?? true) - } - - return { - config: anyError ? undefined : data, - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - mutateAuthConfig, - } -} diff --git a/studio/hooks/queries/useProjectPostgrestConfig.ts b/studio/hooks/queries/useProjectPostgrestConfig.ts deleted file mode 100644 index a18768bbb75..00000000000 --- a/studio/hooks/queries/useProjectPostgrestConfig.ts +++ /dev/null @@ -1,17 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export function useProjectPostgrestConfig(ref?: string) { - const url = `${API_URL}/projects/${ref}/config/postgrest` - const { data, error, mutate } = useSWR(ref ? url : null, get) - const anyError = data?.error || error - - return { - config: anyError ? undefined : data, - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - mutateConfig: mutate, - } -} diff --git a/studio/hooks/queries/useProjectSettings.ts b/studio/hooks/queries/useProjectSettings.ts deleted file mode 100644 index cccebec4bb1..00000000000 --- a/studio/hooks/queries/useProjectSettings.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { get } from 'lib/common/fetch' -import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import useSWR from 'swr' - -export function useProjectSettings(ref?: string) { - const url = `${API_URL}/props/project/${ref}/settings` - const { data, error, mutate } = useSWR(ref ? url : null, get, { - /** - * on project creation, the service_api_keys will be populated with a delay - * check for data.services.service_api_keys, return a valid refresh interval if it's empty - */ - refreshInterval: function (data: any) { - const apiService = (data?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 - return interval - }, - }) - const anyError = data?.error || error - - return { - mutateSettings: mutate, - project: anyError ? undefined : data?.project, - services: anyError ? undefined : data?.services, - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/hooks/queries/useProjectSubscription.ts b/studio/hooks/queries/useProjectSubscription.ts deleted file mode 100644 index 48fcb2368a4..00000000000 --- a/studio/hooks/queries/useProjectSubscription.ts +++ /dev/null @@ -1,25 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { StripeSubscription } from 'components/interfaces/Billing' - -/** - * Get project payg statistics - * - * todo: this looks like a duplicate - * of /studio/hooks/misc/useProjectSubscription.tsx - * - * @param ref project reference - */ -export function useProjectSubscription(ref: string | undefined) { - const url = `${API_URL}/projects/${ref}/subscription` - const { data, error } = useSWR(url, get) - const anyError = data?.error || error - - return { - subscription: anyError ? null : (data as StripeSubscription), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/hooks/queries/useProjectUsage.ts b/studio/hooks/queries/useProjectUsage.ts deleted file mode 100644 index aa1eb266c55..00000000000 --- a/studio/hooks/queries/useProjectUsage.ts +++ /dev/null @@ -1,17 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { UsageStats } from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.types' - -export function useProjectUsage(ref?: string) { - const url = `${API_URL}/projects/${ref}/usage` - const { data, error } = useSWR(ref ? url : null, get) - const anyError = data?.error || error - - return { - usage: anyError ? undefined : (data as UsageStats), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/hooks/ui/index.ts b/studio/hooks/ui/index.ts index a62409a29de..42c5f109a3c 100644 --- a/studio/hooks/ui/index.ts +++ b/studio/hooks/ui/index.ts @@ -1,3 +1,4 @@ export * from './useFlag' export * from './useIsActive' export * from './useUrlState' +export * from './useClickedOutside' diff --git a/studio/hooks/ui/useClickedOutside.ts b/studio/hooks/ui/useClickedOutside.ts new file mode 100644 index 00000000000..8e4086f83cc --- /dev/null +++ b/studio/hooks/ui/useClickedOutside.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' + +/** + * Hook that alerts clicks outside of the passed ref + */ +export const useClickedOutside = (ref: any) => { + const [active, setActive] = useState(false) + + useEffect(() => { + const handleClickOutside = (event: any) => { + if (ref.current && !ref.current.contains(event.target)) { + setActive(true) + } else { + setActive(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [ref]) + + return active +} diff --git a/studio/lib/common/fetch/base.ts b/studio/lib/common/fetch/base.ts index bc6b87c49f4..45597dc3588 100644 --- a/studio/lib/common/fetch/base.ts +++ b/studio/lib/common/fetch/base.ts @@ -108,5 +108,10 @@ export async function constructHeaders(requestId: string, optionHeaders?: { [pro if (accessToken) headers.Authorization = `Bearer ${accessToken}` } + if (typeof window !== 'undefined') { + const gaClientId = window?.localStorage?.getItem('ga_client_id') + if (gaClientId && gaClientId !== 'undefined') headers['X-GA-Client-Id'] = gaClientId + } + return headers } diff --git a/studio/lib/telemetry.ts b/studio/lib/telemetry.ts index 488c9a6d998..ea5dcb38bcb 100644 --- a/studio/lib/telemetry.ts +++ b/studio/lib/telemetry.ts @@ -2,21 +2,50 @@ import { post } from 'lib/common/fetch' import { API_URL, IS_PLATFORM } from 'lib/constants' import { User } from 'types' -const sendEvent = (category: string, action: string, label: string, value?: string) => { +export interface GoogleAnalyticsProps { + screenResolution?: string + language: string +} + +const sendEvent = ( + event: { + category: string + action: string + label: string + value?: string + }, + gaProps?: GoogleAnalyticsProps +) => { if (!IS_PLATFORM) return + const { category, action, label, value } = event + return post(`${API_URL}/telemetry/event`, { action: action, category: category, label: label, value: value, + ga: { + screen_resolution: gaProps?.screenResolution, + language: gaProps?.language, + }, }) } -const sendIdentify = (user: User) => { +/** + * TODO: GA4 doesn't have identify method. + * We may or may not need gaClientId here. Confirm later + */ +const sendIdentify = (user: User, gaProps?: GoogleAnalyticsProps) => { if (!IS_PLATFORM) return - return post(`${API_URL}/telemetry/identify`, { user }) + return post(`${API_URL}/telemetry/identify`, { + user, + ga: { + screen_resolution: gaProps?.screenResolution, + language: gaProps?.language, + }, + }) } export default { diff --git a/studio/localStores/sqlEditor/QueryTab.js b/studio/localStores/sqlEditor/QueryTab.js index 343302bd27b..978683669a4 100644 --- a/studio/localStores/sqlEditor/QueryTab.js +++ b/studio/localStores/sqlEditor/QueryTab.js @@ -103,6 +103,7 @@ class QueryTab extends Tab { this.finishExecuting(undefined, response.error) } else { this.finishExecuting(response) + // TODO: missing ga props post(`${API_URL}/telemetry/event`, { category: 'sql_editor', action: 'sql_query_run' }) } } diff --git a/studio/package.json b/studio/package.json index 1e7e8d7ea37..e6eea6a9c4b 100644 --- a/studio/package.json +++ b/studio/package.json @@ -84,7 +84,6 @@ "semver": "^6.3.0", "shared-data": "*", "sqlstring": "^2.3.2", - "swr": "^1.2.1", "ui": "*", "uniforms-bootstrap4": "^3.6.2", "uniforms-bridge-json-schema": "^3.6.2", diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index e77baa71827..d97675d69ef 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -21,6 +21,8 @@ import utc from 'dayjs/plugin/utc' import Prism from 'prism-react-renderer/prism' import Head from 'next/head' +import Script from 'next/script' + import { AppPropsWithLayout } from 'types' import { useEffect, useState } from 'react' @@ -96,7 +98,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useAutoAuthRedirect() + useAutoAuthRedirect(queryClient) const getLayout = Component.getLayout ?? ((page) => page) @@ -110,6 +112,20 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { + + {getLayout()} diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index 9b3cbad9551..e5cdeeeb8bd 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -1,17 +1,16 @@ import { observer } from 'mobx-react-lite' import { useEffect, useState } from 'react' -import { Button, IconArrowRight, IconMoon, IconSun, Input, Listbox } from 'ui' +import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' import { Session } from '@supabase/supabase-js' import { AccountLayout } from 'components/layouts' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' import Panel from 'components/ui/Panel' -import { useProfile, useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' +import { useStore } from 'hooks' import { auth } from 'lib/gotrue' -import { NextPageWithLayout } from 'types' import Link from 'next/link' +import { NextPageWithLayout } from 'types' const User: NextPageWithLayout = () => { return ( @@ -39,14 +38,16 @@ export default User const ProfileCard = observer(() => { const { ui } = useStore() - const { mutateProfile } = useProfile() + const { mutate } = useProfileUpdateMutation() const user = ui.profile const updateUser = async (model: any) => { try { - const updatedUser = await post(`${API_URL}/profile/update`, model) - mutateProfile(updatedUser, false) - ui.setProfile(updatedUser) + mutate({ + firstName: model.first_name, + lastName: model.last_name, + }) + ui.setNotification({ category: 'success', message: 'Successfully saved profile' }) } catch (error) { ui.setNotification({ diff --git a/studio/pages/join/index.tsx b/studio/pages/join/index.tsx index be1c151a700..120db3cb51b 100644 --- a/studio/pages/join/index.tsx +++ b/studio/pages/join/index.tsx @@ -3,10 +3,11 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/router' import { Button, IconCheckSquare, Loading } from 'ui' -import { useProfile, useStore } from 'hooks' +import { useStore } from 'hooks' import { auth } from 'lib/gotrue' import { API_URL } from 'lib/constants' import { get, post, delete_ } from 'lib/common/fetch' +import { useProfileQuery } from 'data/profile/profile-query' interface ITokenInfo { organization_name?: string | undefined @@ -23,7 +24,7 @@ const JoinOrganizationPage = () => { const router = useRouter() const { slug, token, name } = router.query const { ui, app } = useStore() - const { profile } = useProfile() + const { data: profile } = useProfileQuery() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(false) diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index fb677f75a92..9702dca4aac 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -21,13 +21,8 @@ import { PRICING_TIER_FREE_KEY, PRICING_TIER_PRODUCT_IDS, } from 'lib/constants' -import { - useStore, - useFlag, - withAuth, - checkPermissions, - useFreeProjectLimitCheck, -} from 'hooks' +import { useStore, useFlag, withAuth, checkPermissions, useParams } from 'hooks' +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { WizardLayoutWithoutAuth } from 'components/layouts' import Panel from 'components/ui/Panel' @@ -41,13 +36,13 @@ import { const Wizard: NextPageWithLayout = () => { const router = useRouter() - const { slug } = router.query + const { slug } = useParams() const { app, ui } = useStore() const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const kpsEnabled = useFlag('initWithKps') - const { membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = - useFreeProjectLimitCheck(slug as string) + const { data: membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = + useFreeProjectLimitCheckQuery({ slug }) const [projectName, setProjectName] = useState('') const [dbPass, setDbPass] = useState('') @@ -197,17 +192,14 @@ const Wizard: NextPageWithLayout = () => { return (

          Create a new project

          } footer={ -
          +
          @@ -236,7 +228,7 @@ const Wizard: NextPageWithLayout = () => {

          {projectCreationDisabled ? ( - + ) : ( @@ -290,7 +282,7 @@ const Wizard: NextPageWithLayout = () => { /> - + 0} @@ -312,7 +304,7 @@ const Wizard: NextPageWithLayout = () => { /> - + = observer(({}) => { const PageState: any = useContext(PageContext) - const router = useRouter() + const { ref: projectRef, page, resource, rpc } = useParams() const [selectedLang, setSelectedLang] = useState('js') const [showApiKey, setShowApiKey] = useState(DEFAULT_KEY) - const { data, error } = useProjectSettingsQuery({ projectRef: PageState.projectRef as string }) - const API_KEY = data?.autoApiService.service_api_keys.find((key: any) => key.tags === 'anon') - ? data.autoApiService.defaultApiKey + const { data, error } = useProjectApiQuery({ + projectRef, + }) + + const apiService = data?.autoApiService + const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') + ? apiService.defaultApiKey : undefined - const swaggerUrl = data?.autoApiService.restUrl - const headers: any = { apikey: API_KEY } + const swaggerUrl = data?.autoApiService?.restUrl const canReadServiceKey = checkPermissions( PermissionAction.READ, 'service_api_keys.service_role_key' ) - if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}` - - const { data: jsonSchema, error: jsonSchemaError } = useSWR( - () => swaggerUrl, - (url: string) => get(url, { headers, credentials: 'omit' }).then((res) => res) - ) + const { + data: jsonSchema, + error: jsonSchemaError, + refetch, + } = useProjectJsonSchemaQuery({ + projectRef, + swaggerUrl, + apiKey: anonKey, + }) useEffect(() => { PageState.setJsonSchema(jsonSchema) }, [jsonSchema]) const refreshDocs = async () => { - // A bit hacky calling coding this up twice - at some point we should move this function - // and the SWR into the store. - mutate(swaggerUrl) + await refetch() } if (error || jsonSchemaError) @@ -121,8 +124,6 @@ const DocView: FC = observer(({}) => { endpoint: `${data.autoApiService.protocol ?? 'https'}://${data.autoApiService.endpoint ?? '-'}`, } - const { query } = router - const { page, resource, rpc } = query const { paths, definitions } = PageState.jsonSchema const PAGE_KEY: any = resource || rpc || page || 'index' @@ -174,7 +175,7 @@ const DocView: FC = observer(({}) => { key="anon" onClick={() => setShowApiKey({ - key: API_KEY, + key: anonKey, name: 'anon (public)', }) } diff --git a/studio/pages/project/[ref]/database/hooks/[id]/logs.tsx b/studio/pages/project/[ref]/database/hooks/[id]/logs.tsx deleted file mode 100644 index 11965f726f1..00000000000 --- a/studio/pages/project/[ref]/database/hooks/[id]/logs.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import useSWR from 'swr' -import dayjs from 'dayjs' -import { useRouter } from 'next/router' -import { uniqBy, map as lodashMap } from 'lodash' -import { FC, useState, useEffect } from 'react' -import { Button } from 'ui' -import { observer } from 'mobx-react-lite' - -import { useStore } from 'hooks' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { DatabaseLayout } from 'components/layouts' -import { NextPageWithLayout } from 'types' - -const FunctionsPage: NextPageWithLayout = () => { - return ( -
          -
          - -
          -
          - ) -} - -FunctionsPage.getLayout = (page) => {page} - -export default observer(FunctionsPage) - -const HookLogs: FC = observer(() => { - const router = useRouter() - const { id: hookId } = router.query - - const { meta } = useStore() - - const [hooksData, setHooksData] = useState([]) - const schemas = lodashMap(uniqBy(hooksData, 'schema'), 'schema') - - useEffect(() => { - const fetchHooks = async () => { - await meta.hooks.load() - const hooksData = meta.hooks.list() - setHooksData(hooksData) - } - fetchHooks() - }, []) - - const logsUrl = `${API_URL}/database/${router.query.ref}/hook-logs?id=${hookId}` - const { data: logsData, error: logsError }: any = useSWR(logsUrl, get) - - if (meta.hooks.hasError) { - return ( -

          -

          Error connecting to API

          -

          {`${meta.hooks.error}`}

          -

          - ) - } - - if (logsError) { - return ( -

          -

          Error connecting to API

          -

          {`${logsError.error}`}

          -

          - ) - } - - if (meta.hooks.isLoading || !logsData) { - return

          Loading hook logs...

          - } - - return ( -
          - {/* */} -
          -

          {hooksData.name} logs

          -
          - - -
          -
          - -
          -
          - {logsData?.map((log: any, i: number) => { - return ( -
          -
          -
          -

          [{log.method}]

          -

          {log.url}

          -
          -

          - {dayjs(log.created_at).format('DDMMYYY')} -

          -
          -
          - ) - })} -
          -
          -
          - ) -}) diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index cd55132af0e..c0f9c541c85 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -13,6 +13,8 @@ import { TableEditorLayout } from 'components/layouts' import { TableGridEditor } from 'components/interfaces' import ConfirmationModal from 'components/ui/ConfirmationModal' import { SchemaView } from 'types' +import { JsonEditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' +import { tryParseJson } from 'lib/helpers' const TableEditorPage: NextPage = () => { const router = useRouter() @@ -29,10 +31,11 @@ const TableEditorPage: NextPage = () => { const [selectedColumnToDelete, setSelectedColumnToDelete] = useState() const [selectedTableToDelete, setSelectedTableToDelete] = useState() - const [sidePanelKey, setSidePanelKey] = useState<'row' | 'column' | 'table'>() + const [sidePanelKey, setSidePanelKey] = useState<'row' | 'column' | 'table' | 'json'>() const [selectedRowToEdit, setSelectedRowToEdit] = useState>() const [selectedColumnToEdit, setSelectedColumnToEdit] = useState() const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState() const projectRef = ui.selectedProject?.ref const tables: PostgresTable[] = meta.tables.list() @@ -105,6 +108,11 @@ const TableEditorPage: NextPage = () => { setSelectedTableToEdit(table) } + const onExpandJSONEditor = (column: string, row: any) => { + setSidePanelKey('json') + setSelectedValueForJsonEdit({ column, row, jsonString: JSON.stringify(row[column]) || '' }) + } + const onClosePanel = () => { setSidePanelKey(undefined) } @@ -204,11 +212,13 @@ const TableEditorPage: NextPage = () => { selectedRowToEdit={selectedRowToEdit} selectedColumnToEdit={selectedColumnToEdit} selectedTableToEdit={selectedTableToEdit} + selectedValueForJsonEdit={selectedValueForJsonEdit} onAddRow={onAddRow} onEditRow={onEditRow} onAddColumn={onAddColumn} onEditColumn={onEditColumn} onDeleteColumn={onDeleteColumn} + onExpandJSONEditor={onExpandJSONEditor} onClosePanel={onClosePanel} theme={ui.themeOption == 'dark' ? 'dark' : 'light'} /> diff --git a/studio/pages/project/[ref]/functions/[id]/details.tsx b/studio/pages/project/[ref]/functions/[id]/details.tsx index 248434f9d84..613833e4f31 100644 --- a/studio/pages/project/[ref]/functions/[id]/details.tsx +++ b/studio/pages/project/[ref]/functions/[id]/details.tsx @@ -1,20 +1,18 @@ import dayjs from 'dayjs' import { useEffect, useState } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { IconGlobe, IconTerminal } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { checkPermissions, useStore } from 'hooks' +import { checkPermissions, useParams, useStore } from 'hooks' +import { useProjectApiQuery } from 'data/config/project-api-query' import FunctionsLayout from 'components/layouts/FunctionsLayout' import CommandRender from 'components/interfaces/Functions/CommandRender' import NoPermission from 'components/ui/NoPermission' const PageLayout: NextPageWithLayout = () => { - const router = useRouter() - const { ref, id } = router.query + const { ref: projectRef, id } = useParams() const { functions, ui } = useStore() @@ -24,6 +22,10 @@ const PageLayout: NextPageWithLayout = () => { setSelectedFunction(functions.byId(id)) }, [functions.isLoaded, ui.selectedProject]) + const { data: settings } = useProjectApiQuery({ + projectRef, + }) + // get the .co or .net TLD from the restUrl const restUrl = ui.selectedProject?.restUrl const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() @@ -97,13 +99,12 @@ const PageLayout: NextPageWithLayout = () => { ] // Get the API service - const { data: settings } = useProjectSettingsQuery({ projectRef: ref as string }) const apiService = settings?.autoApiService - const anonKey = apiService?.service_api_keys.find((x: any) => x.name === 'anon key') + const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') ? apiService.defaultApiKey : undefined - const endpoint = apiService?.endpoint ?? '' + const endpoint = apiService?.app_config.endpoint ?? '' const endpointSections = endpoint.split('.') const functionsEndpoint = [ ...endpointSections.slice(0, 1), @@ -113,7 +114,7 @@ const PageLayout: NextPageWithLayout = () => { const invokeCommands: any = [ { - command: `curl -L -X POST 'https://${ref}.functions.supabase.${restUrlTld}/${ + command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/${ selectedFunction?.slug }' -H 'Authorization: Bearer ${anonKey ?? '[YOUR ANON KEY]'}' --data '{"name":"Functions"}'`, description: 'Invokes the hello function', @@ -176,7 +177,7 @@ const PageLayout: NextPageWithLayout = () => {
          Endpoint URL
          - {`https://${ref}.functions.supabase.co/${selectedFunction?.slug}`} + {`https://${projectRef}.functions.supabase.co/${selectedFunction?.slug}`}
          diff --git a/studio/pages/project/[ref]/functions/[id]/index.tsx b/studio/pages/project/[ref]/functions/[id]/index.tsx index ad4faaa7b92..ed3b891d2a2 100644 --- a/studio/pages/project/[ref]/functions/[id]/index.tsx +++ b/studio/pages/project/[ref]/functions/[id]/index.tsx @@ -1,4 +1,3 @@ -import useSWR from 'swr' import dayjs from 'dayjs' import { useState } from 'react' import { useRouter } from 'next/router' @@ -6,10 +5,10 @@ import { Button } from 'ui' import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { checkPermissions, useParams } from 'hooks' +import { useFunctionsInvStatsQuery } from 'data/analytics/functions-inv-stats-query' import { ChartIntervals, NextPageWithLayout } from 'types' -import { get } from 'lib/common/fetch' -import { API_URL, DATE_FORMAT } from 'lib/constants' +import { DATE_FORMAT } from 'lib/constants' import Panel from 'components/ui/Panel' import NoPermission from 'components/ui/NoPermission' import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' @@ -50,17 +49,17 @@ function calculateHighlightedValue(array: any, attribute: string, options?: { su const PageLayout: NextPageWithLayout = () => { const router = useRouter() - const { ref, id } = router.query + const { ref: projectRef, id } = useParams() const [interval, setInterval] = useState('15min') - const url = `${API_URL}/projects/${ref}/analytics/endpoints/functions.inv-stats` const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] - const { data, error }: any = useSWR( - `${url}?interval=${selectedInterval.key}&function_id=${id}`, - get - ) + const { data, error } = useFunctionsInvStatsQuery({ + projectRef, + functionId: id, + interval: selectedInterval.key, + }) const startDate = dayjs() .subtract(selectedInterval.startValue, selectedInterval.startUnit) @@ -81,9 +80,9 @@ const PageLayout: NextPageWithLayout = () => { if (timestampDigits < 16) { // pad unix timestamp with additional 0 and then forward const paddedTimestamp = String(timestamp) + '0'.repeat(16 - timestampDigits) - router.push(`/project/${ref}/functions/${id}/logs?te=${paddedTimestamp}`) + router.push(`/project/${projectRef}/functions/${id}/logs?te=${paddedTimestamp}`) } else { - router.push(`/project/${ref}/functions/${id}/logs?te=${timestamp}`) + router.push(`/project/${projectRef}/functions/${id}/logs?te=${timestamp}`) } } diff --git a/studio/pages/project/[ref]/logs/explorer/index.tsx b/studio/pages/project/[ref]/logs/explorer/index.tsx index 52d7906b166..2a93f527629 100644 --- a/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -4,8 +4,9 @@ import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { Input, Modal, Form, Button } from 'ui' -import { useProjectSubscription, useStore } from 'hooks' +import { useParams, useStore } from 'hooks' import useLogsQuery from 'hooks/analytics/useLogsQuery' +import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query' import { NextPageWithLayout, UserContent } from 'types' import { uuidv4 } from 'lib/helpers' import { LogsLayout } from 'components/layouts' @@ -28,20 +29,18 @@ import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' export const LogsExplorerPage: NextPageWithLayout = () => { + const { ui, content } = useStore() const router = useRouter() - const { ui } = useStore() - - const { ref, q, ite, its } = router.query + const { ref: projectRef, q, ite, its } = useParams() const [editorId, setEditorId] = useState(uuidv4()) const [editorValue, setEditorValue] = useState('') const [saveModalOpen, setSaveModalOpen] = useState(false) const [warnings, setWarnings] = useState([]) - const { content } = useStore() - const { subscription } = useProjectSubscription(ref as string) + const { data: subscription } = useProjectSubscriptionQuery({ projectRef }) const tier = subscription?.tier const [{ params, logData, error, isLoading }, { changeQuery, runQuery, setParams }] = - useLogsQuery(ref as string, { + useLogsQuery(projectRef as string, { iso_timestamp_start: its ? (its as string) : undefined, iso_timestamp_end: ite ? (ite as string) : undefined, }) @@ -148,11 +147,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } return ( -
          +
          -
          -
          +
          +
          { />
          -
          +
          -
          - +
          +
          -
          +
          @@ -256,7 +260,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
          -
          +
          -