diff --git a/apps/docs/content/troubleshooting/transfer-edge-function-from-one-project-to-another.mdx b/apps/docs/content/troubleshooting/transfer-edge-function-from-one-project-to-another.mdx new file mode 100644 index 0000000000000..414a0d25de9d0 --- /dev/null +++ b/apps/docs/content/troubleshooting/transfer-edge-function-from-one-project-to-another.mdx @@ -0,0 +1,59 @@ +--- +title = "Transfer edge functions from one project to another" +topics = ["cli", "database", "functions"] +keywords = ["functions", "typescript", "deno"] +--- + +This guide shows how you can transfer your Edge Functions from one project to another using the Supabase CLI. + +## Pre-requisites + +To follow through this guide, you need the following: + +- You must have the right access privileges to both the source and target project +- You must have installed the [Supabase CLI tool](/docs/guides/local-development/cli/getting-started#installing-the-supabase-cli) +- Both source and target projects must be active + +## Steps: + +1. Login to your Supabase account (the account with the functions) using your terminal + +```bash +supabase login +``` + +This should open up a web page with an access code. Copy the code, paste it in your terminal, and hit enter. + +2. List all Edge Functions by running the following command + +```bash +supabase functions list --project-ref your_project_ref +``` + +3. Download the function + +```bash +supabase functions download function_name --project-ref your_project_ref +``` + +Repeat this step to download multiple functions. + +This downloads the function(s) into `supabase/functions`. You can view the downloaded function(s) by running + +```bash +ls supabase/functions +``` + +4. Link to the target project + +```bash +supabase link --project-ref your_target_project_ref +``` + +5. Deploy function(s) to target project + +```bash +supabase functions deploy --project-ref your_target_project_ref +``` + +This deploys all functions within the `supabase/functions` to the target project. You can confirm by checking your Edge Functions on [the project dashboard](/dashboard/project/_/functions) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index e160735aa300b..124f6066fd60b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { Clock, HistoryIcon } from 'lucide-react' import type { PropsWithChildren } from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Badge } from '@ui/components/shadcn/ui/badge' import { Label } from '@ui/components/shadcn/ui/label' @@ -13,6 +13,7 @@ import { Button, ButtonProps, Calendar, + Input_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, @@ -21,6 +22,85 @@ import { } from 'ui' import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants' import type { DatetimeHelper } from './Logs.types' +import type { PlanId } from 'data/subscriptions/types' + +type Unit = 'minute' | 'hour' | 'day' + +export type ParsedCustomInput = + | { type: 'number'; value: number } + | { type: 'unit'; value: number; unit: Unit } + | { type: 'invalid' } + +export const parseCustomInput = (input: string): ParsedCustomInput => { + const trimmed = input.trim().toLowerCase() + if (!trimmed) return { type: 'invalid' } + + // Try to match "number + optional space + unit prefix" + const match = trimmed.match(/^(\d+)\s*([a-z]*)$/) + if (!match) return { type: 'invalid' } + + const [, numStr, unitStr] = match + const value = parseInt(numStr, 10) + + if (isNaN(value) || value <= 0) return { type: 'invalid' } + + if (!unitStr) { + return { type: 'number', value } + } + + // Match if unitStr is a prefix of any unit name or its first letter + const units: Unit[] = ['minute', 'hour', 'day'] + const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr) + + if (!matchedUnit) return { type: 'invalid' } + + return { type: 'unit', value, unit: matchedUnit } +} + +export const getAvailableInForDays = (days: number): PlanId[] => { + if (days <= 1) return ['free', 'pro', 'team', 'enterprise', 'platform'] + if (days <= 7) return ['pro', 'team', 'enterprise', 'platform'] + return ['team', 'enterprise', 'platform'] +} + +export const convertToDays = (value: number, unit: Unit): number => { + switch (unit) { + case 'minute': + return value / (60 * 24) + case 'hour': + return value / 24 + case 'day': + return value + } +} + +export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => { + const days = convertToDays(value, unit) + return { + text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`, + calcFrom: () => dayjs().subtract(value, unit).toISOString(), + calcTo: () => dayjs().toISOString(), + availableIn: getAvailableInForDays(days), + } +} + +export const generateDynamicHelpers = (value: number): DatetimeHelper[] => { + const units: Unit[] = ['minute', 'hour', 'day'] + return units.map((unit) => generateDynamicHelper(value, unit)) +} + +export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => { + const parsed = parseCustomInput(input) + + switch (parsed.type) { + case 'number': + return generateDynamicHelpers(parsed.value) + case 'unit': + return [generateDynamicHelper(parsed.value, parsed.unit)] + case 'invalid': + return null + } +} export type DatePickerValue = { to: string @@ -49,10 +129,18 @@ export const LogsDatePicker = ({ align = 'end', }: PropsWithChildren) => { const [open, setOpen] = useState(false) + const [customValue, setCustomValue] = useState('') + + const displayedHelpers = useMemo(() => { + if (!customValue.trim()) return helpers + const generated = generateHelpersFromInput(customValue) + return generated ?? [] + }, [customValue, helpers]) // Reset the state when the popover closes useEffect(() => { if (!open) { + setCustomValue('') setStartDate(value.from ? new Date(value.from) : null) const defaultEndDate = value.to ? new Date(value.to) : new Date() setEndDate(defaultEndDate) @@ -81,7 +169,7 @@ export const LogsDatePicker = ({ }, [open, value]) const handleHelperChange = (newValue: string) => { - const selectedHelper = helpers.find((h) => h.text === newValue) + const selectedHelper = displayedHelpers.find((h) => h.text === newValue) if (onSubmit && selectedHelper) { onSubmit({ to: selectedHelper.calcTo(), @@ -266,33 +354,42 @@ export const LogsDatePicker = ({ portal={true} {...popoverContentProps} > - - {helpers.map((helper) => ( - - ))} - + className={cn( + '[&:has([data-state=checked])]:bg-background-overlay-hover [&:has([data-state=checked])]:text-foreground px-4 py-1.5 text-foreground-light flex items-center gap-2 hover:bg-background-overlay-hover hover:text-foreground transition-all rounded-sm text-xs w-full', + { + 'cursor-not-allowed pointer-events-none opacity-50': helper.disabled, + } + )} + > + + {helper.text} + {showHelperBadge(helper) ? {helper.availableIn?.[0] || ''} : null} + + ))} + +
diff --git a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx index a56abf9288327..087dffb59bd3c 100644 --- a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx @@ -1,17 +1,174 @@ import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers' +import { + LogsDatePicker, + parseCustomInput, + convertToDays, + getAvailableInForDays, + generateDynamicHelper, + generateDynamicHelpers, + generateHelpersFromInput, +} from 'components/interfaces/Settings/Logs/Logs.DatePickers' import { PREVIEWER_DATEPICKER_HELPERS } from 'components/interfaces/Settings/Logs/Logs.constants' import { DatetimeHelper } from 'components/interfaces/Settings/Logs/Logs.types' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { render } from '../../helpers' dayjs.extend(timezone) dayjs.extend(utc) +describe('parseCustomInput', () => { + test('returns invalid for empty input', () => { + expect(parseCustomInput('')).toEqual({ type: 'invalid' }) + expect(parseCustomInput(' ')).toEqual({ type: 'invalid' }) + }) + + test('parses number only input', () => { + expect(parseCustomInput('25')).toEqual({ type: 'number', value: 25 }) + expect(parseCustomInput(' 10 ')).toEqual({ type: 'number', value: 10 }) + }) + + test('parses number with unit letter', () => { + expect(parseCustomInput('2h')).toEqual({ type: 'unit', value: 2, unit: 'hour' }) + expect(parseCustomInput('30m')).toEqual({ type: 'unit', value: 30, unit: 'minute' }) + expect(parseCustomInput('7d')).toEqual({ type: 'unit', value: 7, unit: 'day' }) + }) + + test('parses number with space and unit letter', () => { + expect(parseCustomInput('2 h')).toEqual({ type: 'unit', value: 2, unit: 'hour' }) + expect(parseCustomInput('30 m')).toEqual({ type: 'unit', value: 30, unit: 'minute' }) + expect(parseCustomInput('7 d')).toEqual({ type: 'unit', value: 7, unit: 'day' }) + }) + + test('parses number with full unit name prefix', () => { + expect(parseCustomInput('2hour')).toEqual({ type: 'unit', value: 2, unit: 'hour' }) + expect(parseCustomInput('2hours')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('4day')).toEqual({ type: 'unit', value: 4, unit: 'day' }) + expect(parseCustomInput('4days')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('30min')).toEqual({ type: 'unit', value: 30, unit: 'minute' }) + expect(parseCustomInput('30minute')).toEqual({ type: 'unit', value: 30, unit: 'minute' }) + }) + + test('is case insensitive', () => { + expect(parseCustomInput('2H')).toEqual({ type: 'unit', value: 2, unit: 'hour' }) + expect(parseCustomInput('30M')).toEqual({ type: 'unit', value: 30, unit: 'minute' }) + expect(parseCustomInput('7D')).toEqual({ type: 'unit', value: 7, unit: 'day' }) + }) + + test('returns invalid for non-matching unit', () => { + expect(parseCustomInput('2x')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('2yoie')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('abc')).toEqual({ type: 'invalid' }) + }) + + test('returns invalid for zero or negative', () => { + expect(parseCustomInput('0')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('-5')).toEqual({ type: 'invalid' }) + }) +}) + +describe('convertToDays', () => { + test('converts minutes to days', () => { + expect(convertToDays(1440, 'minute')).toBe(1) + expect(convertToDays(720, 'minute')).toBe(0.5) + }) + + test('converts hours to days', () => { + expect(convertToDays(24, 'hour')).toBe(1) + expect(convertToDays(48, 'hour')).toBe(2) + }) + + test('days remain unchanged', () => { + expect(convertToDays(7, 'day')).toBe(7) + }) +}) + +describe('getAvailableInForDays', () => { + test('returns all plans for <= 1 day', () => { + expect(getAvailableInForDays(0.5)).toEqual(['free', 'pro', 'team', 'enterprise', 'platform']) + expect(getAvailableInForDays(1)).toEqual(['free', 'pro', 'team', 'enterprise', 'platform']) + }) + + test('returns pro+ for <= 7 days', () => { + expect(getAvailableInForDays(2)).toEqual(['pro', 'team', 'enterprise', 'platform']) + expect(getAvailableInForDays(7)).toEqual(['pro', 'team', 'enterprise', 'platform']) + }) + + test('returns team+ for > 7 days', () => { + expect(getAvailableInForDays(8)).toEqual(['team', 'enterprise', 'platform']) + expect(getAvailableInForDays(30)).toEqual(['team', 'enterprise', 'platform']) + }) +}) + +describe('generateDynamicHelper', () => { + test('generates helper with correct text', () => { + const helper = generateDynamicHelper(5, 'hour') + expect(helper.text).toBe('Last 5 hours') + }) + + test('uses singular form when value is 1', () => { + expect(generateDynamicHelper(1, 'minute').text).toBe('Last 1 minute') + expect(generateDynamicHelper(1, 'hour').text).toBe('Last 1 hour') + expect(generateDynamicHelper(1, 'day').text).toBe('Last 1 day') + }) + + test('uses plural form when value > 1', () => { + expect(generateDynamicHelper(2, 'minute').text).toBe('Last 2 minutes') + expect(generateDynamicHelper(2, 'hour').text).toBe('Last 2 hours') + expect(generateDynamicHelper(2, 'day').text).toBe('Last 2 days') + }) + + test('generates helper with correct availableIn based on time range', () => { + const minuteHelper = generateDynamicHelper(30, 'minute') + expect(minuteHelper.availableIn).toEqual(['free', 'pro', 'team', 'enterprise', 'platform']) + + const dayHelper = generateDynamicHelper(14, 'day') + expect(dayHelper.availableIn).toEqual(['team', 'enterprise', 'platform']) + }) + + test('calcFrom returns correct ISO string', () => { + const helper = generateDynamicHelper(1, 'hour') + const from = dayjs(helper.calcFrom()) + const expectedFrom = dayjs().subtract(1, 'hour') + expect(from.diff(expectedFrom, 'second')).toBeLessThan(2) + }) +}) + +describe('generateDynamicHelpers', () => { + test('generates 3 helpers for minutes, hours, days', () => { + const helpers = generateDynamicHelpers(5) + expect(helpers).toHaveLength(3) + expect(helpers[0].text).toBe('Last 5 minutes') + expect(helpers[1].text).toBe('Last 5 hours') + expect(helpers[2].text).toBe('Last 5 days') + }) +}) + +describe('generateHelpersFromInput', () => { + test('returns null for invalid input', () => { + expect(generateHelpersFromInput('')).toBeNull() + expect(generateHelpersFromInput('abc')).toBeNull() + expect(generateHelpersFromInput('2yoie')).toBeNull() + }) + + test('returns 3 helpers for number only input', () => { + const helpers = generateHelpersFromInput('25') + expect(helpers).toHaveLength(3) + expect(helpers![0].text).toBe('Last 25 minutes') + expect(helpers![1].text).toBe('Last 25 hours') + expect(helpers![2].text).toBe('Last 25 days') + }) + + test('returns single helper for unit input', () => { + const helpers = generateHelpersFromInput('2h') + expect(helpers).toHaveLength(1) + expect(helpers![0].text).toBe('Last 2 hours') + }) +}) + const mockFn = vi.fn() test('renders warning', async () => {