diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 53963fb288310..16e9cd7d1f7f5 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -67,6 +67,7 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ icon: 'getting-started', href: '/guides/getting-started', level: 'gettingstarted', + enabled: frameworkQuickstartsEnabled, }, ], [ diff --git a/apps/docs/content/guides/auth/sessions.mdx b/apps/docs/content/guides/auth/sessions.mdx index ebaeec2937cc2..1b79d9f8d6644 100644 --- a/apps/docs/content/guides/auth/sessions.mdx +++ b/apps/docs/content/guides/auth/sessions.mdx @@ -69,7 +69,7 @@ Otherwise sessions are progressively deleted from the database 24 hours after th ### What are recommended values for access token (JWT) expiration? -Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/auth/sessions) in the Advanced Settings section. +Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/settings/jwt) in the Advanced Settings section. Setting a value over 1 hour is generally discouraged for security reasons, but it may make sense in certain situations. diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index efd334c142906..8226944743a8d 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -60,6 +60,7 @@ Greg Kress Greg P Greg Richardson Guilherme Souza +Hannah Bowers Hardik Maheshwari Haydn Maley Hieu Pham @@ -102,6 +103,7 @@ Matt Johnston Matt Rossman Monica Khoury Mykhailo Mischa Lieibenson +Nick Littman Nyannyacha Oli R Pamela Chia @@ -129,6 +131,7 @@ Sreyas Udayavarman Stanislav M Stephen Morgan Steve Chavez +Steven Eubank Stojan Dimitrovski Sugu Sougoumarane Supun Sudaraka Kalidasa diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index ff6477548e808..a3fd138b5eb2f 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -1094,6 +1094,67 @@ functions: let session = try await supabase.auth.refreshSession(refreshToken: "custom-refresh-token") ``` + - id: get-claims + title: 'getClaims()' + notes: | + - Verifies a JWT and extracts its claims. + - For symmetric JWTs (HS256), verification is performed server-side via the `getUser()` API. + - For asymmetric JWTs (RS256), verification is performed client-side using Apple Security framework. + - Uses a global JWKS cache shared across all clients with the same storage key for optimal performance. + - Automatically handles key rotation by falling back to server-side verification when a JWK is not found. + - The JWKS cache has a 10-minute TTL (time-to-live). + overwriteParams: + - name: jwt + isOptional: true + type: String + description: > + The JWT to verify. If not provided, uses the access token from the current session. + - name: options + isOptional: true + type: GetClaimsOptions + description: > + Options for JWT verification. Can specify `allowExpired` to skip expiration check and `jwks` to provide custom JSON Web Key Set. + examples: + - id: get-claims-current-session + name: Verify and get claims from current session + isSpotlight: true + code: | + ```swift + let response = try await supabase.auth.getClaims() + print("User ID: \(response.claims.sub ?? "N/A")") + print("Email: \(response.claims.email ?? "N/A")") + print("Role: \(response.claims.role ?? "N/A")") + ``` + - id: get-claims-custom-jwt + name: Verify and get claims from a specific JWT + isSpotlight: false + code: | + ```swift + let customToken = "eyJhbGci..." + let response = try await supabase.auth.getClaims(jwt: customToken) + ``` + - id: get-claims-allow-expired + name: Get claims from an expired JWT + description: Useful for testing or extracting information from expired tokens. + isSpotlight: false + code: | + ```swift + let response = try await supabase.auth.getClaims( + options: GetClaimsOptions(allowExpired: true) + ) + ``` + - id: get-claims-custom-jwks + name: Verify JWT with custom JWKS + description: Provide a custom JSON Web Key Set for verification. + isSpotlight: false + code: | + ```swift + let customJWKS = JWKS(keys: [...]) + let response = try await supabase.auth.getClaims( + options: GetClaimsOptions(jwks: customJWKS) + ) + ``` + - id: start-auto-refresh title: 'startAutoRefresh()' description: | @@ -1395,6 +1456,140 @@ functions: ) ``` + - id: admin-oauth-list-clients + title: 'admin.oauth.listClients()' + description: | + List all OAuth clients with optional pagination. + notes: | + - Requires `service_role` key. + - This method is part of the OAuth 2.1 server administration API. + - Only works when the OAuth 2.1 server is enabled in your Supabase Auth configuration. + overwriteParams: + - name: params + isOptional: true + type: PageParams + description: > + Pagination parameters with `page` and `perPage` options. + examples: + - id: list-oauth-clients + name: List all OAuth clients + isSpotlight: true + code: | + ```swift + let response = try await supabase.auth.admin.oauth.listClients() + ``` + - id: list-oauth-clients-paginated + name: List OAuth clients with pagination + isSpotlight: false + code: | + ```swift + let response = try await supabase.auth.admin.oauth.listClients( + params: PageParams(page: 1, perPage: 10) + ) + ``` + + - id: admin-oauth-create-client + title: 'admin.oauth.createClient()' + description: | + Create a new OAuth client. + notes: | + - Requires `service_role` key. + - This method is part of the OAuth 2.1 server administration API. + - Only works when the OAuth 2.1 server is enabled in your Supabase Auth configuration. + overwriteParams: + - name: params + type: CreateOAuthClientParams + description: > + Parameters for creating the OAuth client including name, redirect URIs, and client type. + examples: + - id: create-oauth-client + name: Create a new OAuth client + isSpotlight: true + code: | + ```swift + let client = try await supabase.auth.admin.oauth.createClient( + params: CreateOAuthClientParams( + name: "My OAuth App", + redirectUris: ["https://example.com/callback"], + clientType: .confidential + ) + ) + ``` + + - id: admin-oauth-get-client + title: 'admin.oauth.getClient()' + description: | + Get details of a specific OAuth client. + notes: | + - Requires `service_role` key. + - This method is part of the OAuth 2.1 server administration API. + overwriteParams: + - name: clientId + type: String + description: > + The UUID of the OAuth client to retrieve. + examples: + - id: get-oauth-client + name: Get OAuth client by ID + isSpotlight: true + code: | + ```swift + let client = try await supabase.auth.admin.oauth.getClient( + clientId: "12345678-1234-1234-1234-123456789012" + ) + ``` + + - id: admin-oauth-delete-client + title: 'admin.oauth.deleteClient()' + description: | + Delete an OAuth client. + notes: | + - Requires `service_role` key. + - This method is part of the OAuth 2.1 server administration API. + - This action cannot be undone. + overwriteParams: + - name: clientId + type: String + description: > + The UUID of the OAuth client to delete. + examples: + - id: delete-oauth-client + name: Delete an OAuth client + isSpotlight: true + code: | + ```swift + try await supabase.auth.admin.oauth.deleteClient( + clientId: "12345678-1234-1234-1234-123456789012" + ) + ``` + + - id: admin-oauth-regenerate-client-secret + title: 'admin.oauth.regenerateClientSecret()' + description: | + Regenerate the secret for an OAuth client. + notes: | + - Requires `service_role` key. + - This method is part of the OAuth 2.1 server administration API. + - The old secret will be immediately invalidated. + - Make sure to update your application with the new secret. + overwriteParams: + - name: clientId + type: String + description: > + The UUID of the OAuth client whose secret should be regenerated. + examples: + - id: regenerate-oauth-client-secret + name: Regenerate OAuth client secret + isSpotlight: true + code: | + ```swift + let client = try await supabase.auth.admin.oauth.regenerateClientSecret( + clientId: "12345678-1234-1234-1234-123456789012" + ) + // The response contains the new secret + print("New secret: \(client.secret ?? "")") + ``` + - id: select title: 'Fetch data: select()' notes: | @@ -4012,6 +4207,7 @@ functions: notes: | - Requires an Authorization header. - When you pass in a body to your function, we automatically attach the Content-Type header for `String`, and `Data`. If it doesn't match any of these types we assume the payload is `json`, serialize it and attach the `Content-Type` header as `application/json`. You can override this behaviour by passing in a `Content-Type` header of your own. + - When a region is specified, both the `x-region` header and `forceFunctionRegion` query parameter are set to ensure proper function routing. examples: - id: invocation-with-decodable name: Invocation with `Decodable` response @@ -4246,6 +4442,37 @@ functions: subscription.cancel() ``` + - id: broadcast-with-replay + name: Configure broadcast with replay + description: | + Replay allows you to receive messages that were broadcast since a specific timestamp. The `meta` field in the message payload will indicate if a message is being replayed. + isSpotlight: false + code: | + ```swift + let config = RealtimeJoinConfig( + broadcast: BroadcastJoinConfig( + acknowledgeBroadcasts: true, + receiveOwnBroadcasts: true, + replay: ReplayOption( + since: 1234567890, + limit: 100 + ) + ) + ) + + let channel = supabase.channel("my-channel", config: config) + + channel.onBroadcast { message in + if let meta = message.payload["meta"] as? [String: Any], + let replayed = meta["replayed"] as? Bool, + replayed { + print("Replayed message: \(meta["id"] ?? "")") + } + } + + await channel.subscribe() + ``` + - id: listen-to-presence-updates name: Listen to presence updates code: | diff --git a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx index 518dbd536ed6a..a0244db8bb126 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { IS_PLATFORM } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsQuery } from 'data/projects/projects-query' +import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' import { PageType, useRegisterCommands, useRegisterPage, useSetPage } from 'ui-patterns/CommandMenu' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' @@ -13,11 +13,11 @@ const ORGANIZATION_SWITCHER_PAGE_NAME = 'Configure organization' export function useProjectSwitchCommand() { const setPage = useSetPage() - const { data } = useProjectsQuery({ enabled: IS_PLATFORM }) - const projects = useMemo( - () => (data?.projects ?? []).map(({ name, ref }) => ({ name, ref })), - [data] - ) + // [Joshen] Using paginated data here which means we won't be showing all projects + // Ideally we somehow support searching with Cmd K if we want to make this ideal + // e.g Cmd K input to support async searching while in "switch project" state + const { data } = useProjectsInfiniteQuery({}, { enabled: IS_PLATFORM }) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] useRegisterPage( PROJECT_SWITCHER_PAGE_NAME, diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewLearnMore.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewLearnMore.tsx index 09053d42ce704..14274aeb8e8c6 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewLearnMore.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewLearnMore.tsx @@ -15,7 +15,7 @@ export const OverviewLearnMore = () => { { label: 'Docs', title: 'Authentication docs', - description: 'Read more on authentication and benefits of using Supabase policies.', + description: 'Read more on authentication and the benefits of using Supabase policies.', image: `${BASE_PATH}/img/auth-overview/auth-overview-docs.jpg`, actions: [ { @@ -68,8 +68,7 @@ export const OverviewLearnMore = () => { { label: 'Logs', title: 'Dive into the logs', - description: - 'Our authentication logs provide a deeper view into your auth requests and errors.', + description: 'Authentication logs provide a deeper view into your auth requests.', image: `${BASE_PATH}/img/auth-overview/auth-overview-logs.jpg`, actions: [ { @@ -84,7 +83,7 @@ export const OverviewLearnMore = () => { return ( Learn more -
+
{LearnMoreCards.map((card) => ( @@ -100,12 +99,12 @@ export const OverviewLearnMore = () => { className="object-fit" />
-
-
+
+

{card.title}

{card.description}

-
+
{card.actions.map((action) => { if ('href' in action) { return ( diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts new file mode 100644 index 0000000000000..caad4e200302b --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts @@ -0,0 +1,105 @@ +import dayjs from 'dayjs' +import { fetchLogs } from 'data/reports/report.utils' + +// Date range helpers +export const getDateRanges = () => { + const endDate = dayjs().toISOString() + const startDate = dayjs().subtract(24, 'hour').toISOString() + const previousEndDate = dayjs().subtract(24, 'hour').toISOString() + const previousStartDate = dayjs().subtract(48, 'hour').toISOString() + + return { + current: { startDate, endDate }, + previous: { startDate: previousStartDate, endDate: previousEndDate }, + } +} + +export const AUTH_COMBINED_QUERY = () => ` + with base as ( + select + json_value(event_message, "$.auth_event.action") as action, + json_value(event_message, "$.auth_event.actor_id") as actor_id, + cast(json_value(event_message, "$.duration") as int64) as duration_ns + from auth_logs + ) + + select + 'activeUsers' as metric, + cast(count(distinct case + when action in ( + 'login','user_signedup','token_refreshed','user_modified', + 'user_recovery_requested','user_reauthenticate_requested' + ) then actor_id + else null + end) as float64) as value + from base + + union all + select 'passwordResetRequests' as metric, + cast(count(case when action = 'user_recovery_requested' then 1 else null end) as float64) + from base + + union all + select 'signUpCount' as metric, + cast(count(case when action = 'user_signedup' then 1 else null end) as float64) + from base + + union all + select 'signInLatency' as metric, + coalesce(round(avg(case when action = 'login' then duration_ns else null end) / 1000000, 2), 0) + from base + + union all + select 'signUpLatency' as metric, + coalesce(round(avg(case when action = 'user_signedup' then duration_ns else null end) / 1000000, 2), 0) + from base +` + +export const fetchAllAuthMetrics = async (projectRef: string, period: 'current' | 'previous') => { + const sql = AUTH_COMBINED_QUERY() + const { current, previous } = getDateRanges() + const dateRange = period === 'current' ? current : previous + + return await fetchLogs(projectRef, sql, dateRange.startDate, dateRange.endDate) +} + +export const processAllAuthMetrics = (currentData: any[], previousData: any[]) => { + const processData = (data: any[]) => { + if (!data || !Array.isArray(data)) { + return { activeUsers: 0, passwordResets: 0, signInLatency: 0, signUpLatency: 0 } + } + + const result = data.reduce( + (acc, row) => { + const { metric, value } = row + if (metric === 'activeUsers') acc.activeUsers = value || 0 + if (metric === 'passwordResetRequests') acc.passwordResets = value || 0 + if (metric === 'signInLatency') acc.signInLatency = value || 0 + if (metric === 'signUpLatency') acc.signUpLatency = value || 0 + return acc + }, + { activeUsers: 0, passwordResets: 0, signInLatency: 0, signUpLatency: 0 } + ) + + return result + } + + return { + current: processData(currentData), + previous: processData(previousData), + } +} + +// Utility functions +export const calculatePercentageChange = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0 + return ((current - previous) / previous) * 100 +} + +export const getChangeColor = (percentageChange: number): string => { + return percentageChange >= 0 ? 'text-brand' : 'text-destructive' +} + +export const getChangeSign = (percentageChange: number): string => { + return percentageChange >= 0 ? '+' : '' +} diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx index f0223bd5091a5..dac8b7b29aba9 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx @@ -1,14 +1,204 @@ -import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' -import { Card } from 'ui' +import { + ScaffoldSection, + ScaffoldSectionTitle, + ScaffoldSectionContent, +} from 'components/layouts/Scaffold' +import { Card, CardContent, cn } from 'ui' +import Link from 'next/link' +import { useParams } from 'common' +import { ChevronRight, Loader2 } from 'lucide-react' +import { Reports } from 'icons' +import { + getChangeSign, + getChangeColor, + fetchAllAuthMetrics, + processAllAuthMetrics, + calculatePercentageChange, +} from './OverviewUsage.constants' +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2' +import { createAuthReportConfig } from 'data/reports/v2/auth.config' +import dayjs from 'dayjs' + +const StatCard = ({ + title, + current, + previous, + loading, + suffix = '', +}: { + title: string + current: number + previous: number + loading: boolean + suffix?: string +}) => { + const changeColor = getChangeColor(previous) + const changeSign = getChangeSign(previous) + const formattedCurrent = suffix === 'ms' ? current.toFixed(2) : current + + return ( + + + {loading ? ( + + ) : ( + <> +

{title}

+

{`${formattedCurrent}${suffix}`}

+

+ {`${changeSign}${previous.toFixed(1)}%`} +

+ + )} +
+
+ ) +} export const OverviewUsage = () => { + const { ref } = useParams() + + const { data: currentData, isLoading: currentLoading } = useQuery({ + queryKey: ['auth-metrics', ref, 'current'], + queryFn: () => fetchAllAuthMetrics(ref as string, 'current'), + enabled: !!ref, + }) + + const { data: previousData, isLoading: previousLoading } = useQuery({ + queryKey: ['auth-metrics', ref, 'previous'], + queryFn: () => fetchAllAuthMetrics(ref as string, 'previous'), + enabled: !!ref, + }) + + const metrics = processAllAuthMetrics(currentData?.result || [], previousData?.result || []) + const isLoading = currentLoading || previousLoading + + const activeUsersChange = calculatePercentageChange( + metrics.current.activeUsers, + metrics.previous.activeUsers + ) + const passwordResetChange = calculatePercentageChange( + metrics.current.passwordResets, + metrics.previous.passwordResets + ) + const signInLatencyChange = calculatePercentageChange( + metrics.current.signInLatency, + metrics.previous.signInLatency + ) + const signUpLatencyChange = calculatePercentageChange( + metrics.current.signUpLatency, + metrics.previous.signUpLatency + ) + + const endDate = dayjs().toISOString() + const startDate = dayjs().subtract(24, 'hour').toISOString() + + const signUpChartConfig = useMemo(() => { + const config = createAuthReportConfig({ + projectRef: ref as string, + startDate, + endDate, + interval: '1h', + filters: { status_code: null }, + }) + const chart = config.find((c) => c.id === 'signups') + if (chart) { + return { ...chart, defaultChartStyle: 'bar' } + } + return chart + }, [ref, startDate, endDate]) + + const signInChartConfig = useMemo(() => { + const config = createAuthReportConfig({ + projectRef: ref as string, + startDate, + endDate, + interval: '1h', + filters: { status_code: null }, + }) + const chart = config.find((c) => c.id === 'sign-in-attempts') + if (chart) { + return { ...chart, defaultChartStyle: 'bar' } + } + return chart + }, [ref, startDate, endDate]) + + const updateDateRange = (from: string, to: string) => { + console.log('Date range update:', from, to) + } + return ( - Usage -
- - +
+ Usage + + + View all reports + +
+ +
+ + + + +
+
+ {signUpChartConfig && ( + + )} + {signInChartConfig && ( + + )} +
+
) } diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 6570110caf0da..fd4f05d91284c 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -503,7 +503,9 @@ export const UsersV2 = () => {
- {isNewAPIDocsEnabled && } + {isNewAPIDocsEnabled && ( + + )} @@ -285,7 +285,9 @@ export const RestoreToNewProject = () => { being created. You'll be able to restore again once the project is ready.

diff --git a/apps/studio/components/interfaces/Docs/CodeSnippet.tsx b/apps/studio/components/interfaces/Docs/CodeSnippet.tsx index 813a346d4dfbc..fe2db105f64c4 100644 --- a/apps/studio/components/interfaces/Docs/CodeSnippet.tsx +++ b/apps/studio/components/interfaces/Docs/CodeSnippet.tsx @@ -1,3 +1,6 @@ +import { useParams } from 'common' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { SimpleCodeBlock } from 'ui' interface CodeSnippetProps { @@ -10,11 +13,29 @@ interface CodeSnippetProps { } const CodeSnippet = ({ selectedLang, snippet }: CodeSnippetProps) => { + const { ref: projectRef } = useParams() + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + + const handleCopy = () => { + sendEvent({ + action: 'api_docs_code_copy_button_clicked', + properties: { + title: snippet.title, + selectedLanguage: selectedLang, + }, + groups: { + project: projectRef ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + if (!snippet[selectedLang]) return null return (

{snippet.title}

- + {snippet[selectedLang]?.code}
diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index c580103e1039a..e8c94cdb15d60 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -19,7 +19,7 @@ import Panel from 'components/ui/Panel' import { useOrganizationCreateMutation } from 'data/organizations/organization-create-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' -import { useProjectsQuery } from 'data/projects/projects-query' +import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -94,13 +94,15 @@ type FormState = z.infer const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) -const newMandatoryAddressInput = true - /** * No org selected yet, create a new one * [Joshen] Need to refactor to use Form_Shadcn here */ -const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOrgFormProps) => { +export const NewOrgForm = ({ + onPaymentMethodReset, + setupIntent, + onPlanSelected, +}: NewOrgFormProps) => { const router = useRouter() const user = useProfile() const { resolvedTheme } = useTheme() @@ -108,7 +110,8 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr const isBillingEnabled = useIsFeatureEnabled('billing:all') const { data: organizations, isSuccess } = useOrganizationsQuery() - const { data } = useProjectsQuery() + const { data } = useProjectsInfiniteQuery({}) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]) const [lastVisitedOrganization] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, @@ -117,9 +120,13 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr const freeOrgs = (organizations || []).filter((it) => it.plan.id === 'free') + // [Joshen] JFYI because we're now using a paginated endpoint, there's a chance that not all projects will be + // factored in here (page limit is 100 results). This data is mainly used for the `hasFreeOrgWithProjects` check + // in onSubmit below, which isn't a critical functionality imo so am okay for now. But ideally perhaps this data can + // be computed on the API and returned in /profile or something (since this data is on the account level) const projectsByOrg = useMemo(() => { - return _.groupBy(data?.projects ?? [], 'organization_slug') - }, [data]) + return _.groupBy(projects, 'organization_slug') + }, [projects]) const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] = useState(false) @@ -657,5 +664,3 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr ) } - -export default NewOrgForm diff --git a/apps/studio/components/interfaces/Organization/index.ts b/apps/studio/components/interfaces/Organization/index.ts index 6d2f34a57dae5..ad48fc6448301 100644 --- a/apps/studio/components/interfaces/Organization/index.ts +++ b/apps/studio/components/interfaces/Organization/index.ts @@ -1,4 +1,3 @@ export { default as Documents } from './Documents/Documents' export { default as IntegrationSettings } from './IntegrationSettings/IntegrationSettings' export { default as InvoicesSettings } from './InvoicesSettings/InvoicesSettings' -export { default as NewOrgForm } from './NewOrg/NewOrgForm' diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index e8d906fe5da51..3852809125466 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -3,6 +3,8 @@ import { Button, Input, copyToClipboard } from 'ui' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Copy } from 'lucide-react' import { useEffect, useState } from 'react' import ContentSnippet from '../ContentSnippet' @@ -13,6 +15,8 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => const { ref } = useParams() const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref }) const { data } = useProjectSettingsV2Query({ projectRef: ref }) + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() const [copied, setCopied] = useState<'anon' | 'service'>() @@ -54,6 +58,17 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => onClick={() => { setCopied('anon') copyToClipboard(anonApiKey ?? 'SUPABASE_CLIENT_ANON_KEY') + sendEvent({ + action: 'api_docs_code_copy_button_clicked', + properties: { + title: 'Client API key', + selectedLanguage: language, + }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) }} > {copied === 'anon' ? 'Copied' : 'Copy'} @@ -87,6 +102,17 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => onClick={() => { setCopied('service') copyToClipboard(serviceApiKey) + sendEvent({ + action: 'api_docs_code_copy_button_clicked', + properties: { + title: 'Service key', + selectedLanguage: language, + }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) }} > {copied === 'service' ? 'Copied' : 'Copy'} diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ContentSnippet.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ContentSnippet.tsx index 6a8a8cd60a5bf..7c9cc1a9f51c6 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ContentSnippet.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ContentSnippet.tsx @@ -3,6 +3,8 @@ import { useParams } from 'common' import { SimpleCodeBlock } from 'ui' import { Markdown } from '../Markdown' import { PropsWithChildren } from 'react' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' interface ContentSnippetProps { apikey?: string @@ -26,11 +28,28 @@ const ContentSnippet = ({ children, }: PropsWithChildren) => { const { ref: projectRef } = useParams() + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + const codeSnippet = snippet[selectedLanguage]?.(apikey, endpoint).replaceAll( '[ref]', projectRef ?? '' ) + const handleCopy = () => { + sendEvent({ + action: 'api_docs_code_copy_button_clicked', + properties: { + title: snippet.title, + selectedLanguage, + }, + groups: { + project: projectRef ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + return (
@@ -50,7 +69,9 @@ const ContentSnippet = ({ {codeSnippet !== undefined && (
- {codeSnippet} + + {codeSnippet} +
)} diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ResourceContent.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ResourceContent.tsx index de50d604b47ac..35f375305d15f 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ResourceContent.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ResourceContent.tsx @@ -2,6 +2,8 @@ import { SimpleCodeBlock } from 'ui' import { useParams } from 'common' import { DocsButton } from 'components/ui/DocsButton' import { Markdown } from '../Markdown' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' interface ResourceContentProps { selectedLanguage: 'js' | 'bash' @@ -16,6 +18,22 @@ interface ResourceContentProps { const ResourceContent = ({ selectedLanguage, snippet, codeSnippets }: ResourceContentProps) => { const { ref: projectRef } = useParams() + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + + const handleCopy = (title: string) => { + sendEvent({ + action: 'api_docs_code_copy_button_clicked', + properties: { + title, + selectedLanguage, + }, + groups: { + project: projectRef ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } return (
@@ -40,7 +58,10 @@ const ResourceContent = ({ selectedLanguage, snippet, codeSnippets }: ResourceCo

{codeSnippet.title}

- + handleCopy(codeSnippet.title)} + > {codeSnippet[selectedLanguage]}
diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 72eea15c4c951..9b9700a3ee282 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -13,6 +13,7 @@ import { generateToolRoutes, } from 'components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils' import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useHideSidebar } from 'hooks/misc/useHideSidebar' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLints } from 'hooks/misc/useLints' @@ -223,9 +224,11 @@ const ProjectLinks = () => { const router = useRouter() const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { data: org } = useSelectedOrganizationQuery() const snap = useAppStateSnapshot() const { securityLints, errorLints } = useLints() const showReports = useIsFeatureEnabled('reports:all') + const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const isStorageV2 = useIsNewStorageUIEnabled() @@ -297,18 +300,37 @@ const ProjectLinks = () => { {otherRoutes.map((route, i) => { - if (route.key === 'api' && isNewAPIDocsEnabled) { + if (route.key === 'api') { + const handleApiClick = () => { + if (isNewAPIDocsEnabled) { + snap.setShowProjectApiDocs(true) + } + sendEvent({ + action: 'api_docs_opened', + properties: { + source: 'sidebar', + }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + return ( { - snap.setShowProjectApiDocs(true) - }} + route={ + isNewAPIDocsEnabled + ? { + label: route.label, + icon: route.icon, + key: route.key, + } + : route + } + active={activeRoute === route.key} + onClick={handleApiClick} /> ) } else if (route.key === 'advisors') { diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx index 7025cf213cdbf..c42bde0e89c84 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -517,7 +517,7 @@ export const FileExplorerHeader = ({ <>
- +
)} diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index a2a99dfcd3e22..179e3f46ee259 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -527,7 +527,9 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp - {doesHaveAutoGeneratedAPIDocs && } + {doesHaveAutoGeneratedAPIDocs && ( + + )}
diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 679f916b09f61..33b83ecf4faa9 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -4,6 +4,7 @@ import { PropsWithChildren, useEffect } from 'react' import { LOCAL_STORAGE_KEYS } from 'common' import { useCustomContent } from 'hooks/custom-content/useCustomContent' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' import { IS_PLATFORM } from 'lib/constants' @@ -19,6 +20,8 @@ const AccountLayout = ({ children, title }: PropsWithChildren { export const ProjectDropdown = () => { const router = useRouter() const { ref } = useParams() - const { data: project } = useSelectedProjectQuery() - const { data, isLoading: isLoadingProjects } = useProjectsQuery() + const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const projectCreationEnabled = useIsFeatureEnabled('projects:create') - const isBranch = project?.parentRef !== project?.ref + const { data: parentProject, isLoading: isLoadingParentProject } = useProjectDetailQuery( + { ref: project?.parent_project_ref }, + { enabled: isBranch } + ) + const selectedProject = parentProject ?? project - const projects = (data?.projects ?? []) - .filter((x) => x.organization_id === selectedOrganization?.id) - .sort((a, b) => a.name.localeCompare(b.name)) - const selectedProject = isBranch - ? projects?.find((p) => p.ref === project?.parentRef) - : projects?.find((p) => p.ref === ref) + const projectCreationEnabled = useIsFeatureEnabled('projects:create') const [open, setOpen] = useState(false) - if (isLoadingProjects || !selectedProject) { + if (isLoadingProject || (isBranch && isLoadingParentProject) || !selectedProject) { return } diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index f37b37a7a447d..998ab9bf59db8 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -168,6 +168,7 @@ const EdgeFunctionDetailsLayout = ({ section={ functionSlug !== undefined ? ['edge-functions', functionSlug] : ['edge-functions'] } + source="edge-functions" /> )} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index fe558c231face..1b8f39e782cd4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -20,9 +20,9 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useHotKey } from 'hooks/ui/useHotKey' import { IS_PLATFORM } from 'lib/constants' +import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' import { Badge, cn } from 'ui' -import { useRouter } from 'next/router' import { BreadcrumbsView } from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown' import { HelpPopover } from './HelpPopover' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx index 0dff796b0b720..a1779c5aa5cdb 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx @@ -9,13 +9,12 @@ import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { ItemRenderer } from 'components/ui/InfiniteList' import { Notification, NotificationData } from 'data/notifications/notifications-v2-query' -import { ProjectInfo } from 'data/projects/projects-query' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' import type { Organization } from 'types' import { CriticalIcon, WarningIcon } from 'ui' interface NotificationRowProps { setRowHeight: (idx: number, height: number) => void - getProject: (ref: string) => ProjectInfo getOrganizationById: (id: number) => Organization getOrganizationBySlug: (slug: string) => Organization onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void @@ -27,7 +26,6 @@ const NotificationRow: ItemRenderer = ({ listRef, item: notification, setRowHeight, - getProject, getOrganizationById, getOrganizationBySlug, onUpdateNotificationStatus, @@ -35,10 +33,12 @@ const NotificationRow: ItemRenderer = ({ }) => { const ref = useRef(null) const { ref: viewRef, inView } = useInView() - const { status, priority } = notification + const { status, priority } = notification const data = notification.data as NotificationData - const project = data.project_ref !== undefined ? getProject(data.project_ref) : undefined + + const { data: project } = useProjectDetailQuery({ ref: data.project_ref }) + const organization = data.org_slug !== undefined ? getOrganizationBySlug(data.org_slug) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx index ba8ee16dc2678..ffd7e5ba2b360 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx @@ -1,8 +1,9 @@ -import { RotateCcw, Settings2Icon } from 'lucide-react' -import { useState } from 'react' +import { RotateCcw, Settings2Icon, X } from 'lucide-react' +import { useMemo, useState } from 'react' import { Button, Checkbox_Shadcn_, + CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, @@ -19,18 +20,31 @@ import { } from 'ui' import { CommandGroup } from '@ui/components/shadcn/ui/command' +import { useDebounce } from '@uidotdev/usehooks' import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsQuery } from 'data/projects/projects-query' +import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' import { useNotificationsStateSnapshot } from 'state/notifications' import { CriticalIcon, WarningIcon } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +// [Joshen] Opting to not use infinite loading for projects in this UI specifically +// since the UX feels quite awkward having infinite loading for just a specific section in this Popover export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => { const [open, setOpen] = useState(false) const snap = useNotificationsStateSnapshot() + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + const { data: organizations } = useOrganizationsQuery() - const { data } = useProjectsQuery() - const projects = data?.projects ?? [] + const { data } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { keepPreviousData: true } + ) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] + const projectCount = data?.pages[0].pagination.count ?? 0 + const pageLimit = data?.pages[0].pagination.limit ?? 0 return ( @@ -47,6 +61,9 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi + + No filters found that match your search + @@ -67,12 +84,14 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi + /> Unread + + Priority + /> Warning @@ -112,13 +131,15 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi + /> Critical + + Organizations {(organizations ?? []).map((org) => ( @@ -140,21 +161,45 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi + /> {org.name} ))} + + Projects + {/* + [Joshen] Adding a separate search input field here for projects as the + top level CommandInput doesn't work well with a mix of sync and async data + */} +
+ setSearch(e.target.value)} + actions={ + search.length > 0 ? ( + setSearch('')} + /> + ) : null + } + /> +
{(projects ?? []).map((project) => ( { + onSelect={() => { snap.setFilters(project.ref, 'projects') }} > @@ -168,21 +213,27 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi + /> {project.name} ))} + {projectCount > pageLimit && ( +

+ Not all projects are shown here. Try searching to find a specific project. +

+ )}
+ + snap.resetFilters()} className="flex gap-x-2 items-center" > - + Reset filters diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index 1e30b9525a155..bf3b1dd16a3e3 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -11,7 +11,6 @@ import { useNotificationsV2Query } from 'data/notifications/notifications-v2-que import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query' import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsQuery } from 'data/projects/projects-query' import { useNotificationsStateSnapshot } from 'state/notifications' import { Button, @@ -40,9 +39,6 @@ export const NotificationsPopoverV2 = () => { // so opting to simplify and implement it here for now const rowHeights = useRef<{ [key: number]: number }>({}) - const { data: projectsData } = useProjectsQuery({ enabled: open }) - const projects = projectsData?.projects ?? [] - const { data: organizations } = useOrganizationsQuery({ enabled: open }) const { data, @@ -210,7 +206,6 @@ export const NotificationsPopoverV2 = () => { rowHeights.current = { ...rowHeights.current, [idx]: height } } }, - getProject: (ref: string) => projects?.find((project) => project.ref === ref)!, getOrganizationById: (id: number) => organizations?.find((org) => org.id === id)!, getOrganizationBySlug: (slug: string) => diff --git a/apps/studio/components/layouts/ProjectLayout/LoadingState.tsx b/apps/studio/components/layouts/ProjectLayout/LoadingState.tsx index 36da56931d606..cfd897d7a8ee1 100644 --- a/apps/studio/components/layouts/ProjectLayout/LoadingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LoadingState.tsx @@ -1,16 +1,12 @@ import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useProjectsQuery } from 'data/projects/projects-query' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' export const LoadingState = () => { const { ref } = useParams() - const { data, isLoading } = useProjectsQuery() - const allProjects = data?.projects ?? [] + const { data: project, isLoading } = useProjectDetailQuery({ ref }) - const projectName = - ref !== 'default' - ? allProjects?.find((project) => project.ref === ref)?.name - : 'Welcome to your project' + const projectName = ref !== 'default' ? project?.name : 'Welcome to your project' return (
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx index e7cc40af96095..9bcdaf7e5ab35 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -1,6 +1,5 @@ import { motion } from 'framer-motion' import { BarChart, FileText, Shield } from 'lucide-react' -// End of third-party imports import { useParams } from 'common' import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' @@ -59,7 +58,7 @@ export const AIOnboarding = ({

Suggestions

{prompts.map((item, index) => ( {Array.from({ length: 6 }).map((_, index) => ( - + ))}
) : ( @@ -99,6 +98,7 @@ export const AIOnboarding = ({ {performanceErrorLints.map((lint, index) => { return (