From b50c127a35fa4bb33de61cb9050f5b7eb7a079c6 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 18 Jun 2025 10:36:05 -0700 Subject: [PATCH 01/20] chore: initial form refactor --- .../components/ApiKeys/CreateApiKeyForm.tsx | 286 ++++++++++-------- packages/clerk-js/src/ui/elements/Select.tsx | 20 +- packages/localizations/src/en-US.ts | 4 +- packages/types/src/clerk.ts | 5 + packages/types/src/elementIds.ts | 2 +- 5 files changed, 184 insertions(+), 133 deletions(-) diff --git a/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx b/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx index a5c89ca3790..60645cac548 100644 --- a/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx +++ b/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx @@ -1,12 +1,13 @@ -import React, { useState } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; -import { Box, Button, Col, descriptors, Flex, FormLabel, localizationKeys, Text } from '@/ui/customizables'; +import { useApiKeysContext } from '@/ui/contexts'; +import { Col, descriptors, Flex, FormLabel, localizationKeys, Text } from '@/ui/customizables'; import { useActionContext } from '@/ui/elements/Action/ActionRoot'; import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; -import { SegmentedControl } from '@/ui/elements/SegmentedControl'; -import { mqu } from '@/ui/styledSystem'; +import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; +import { ChevronUpDown } from '@/ui/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; export type OnCreateParams = { name: string; description?: string; expiration: number | undefined }; @@ -16,25 +17,37 @@ interface CreateApiKeyFormProps { isSubmitting: boolean; } -export type Expiration = 'never' | '30d' | '90d' | 'custom'; +export type Expiration = null | '1d' | '7d' | '30d' | '60d' | '90d' | '180d' | '1y'; -const getTimeLeftInSeconds = (expirationOption: Expiration, customDate?: string) => { - if (expirationOption === 'never') { +const getTimeLeftInSeconds = (expirationOption: Expiration) => { + if (expirationOption === null) { return; } const now = new Date(); - let future = new Date(now); + const future = new Date(now); switch (expirationOption) { + case '1d': + future.setDate(future.getDate() + 1); + break; + case '7d': + future.setDate(future.getDate() + 7); + break; case '30d': future.setDate(future.getDate() + 30); break; case '90d': future.setDate(future.getDate() + 90); break; - case 'custom': - future = new Date(customDate as string); + case '60d': + future.setDate(future.getDate() + 60); + break; + case '180d': + future.setDate(future.getDate() + 180); + break; + case '1y': + future.setFullYear(future.getFullYear() + 1); break; default: throw new Error('Invalid expiration option'); @@ -45,18 +58,90 @@ const getTimeLeftInSeconds = (expirationOption: Expiration, customDate?: string) return diffInSecs; }; -const getMinDate = () => { - const min = new Date(); - min.setDate(min.getDate() + 1); - return min.toISOString().split('T')[0]; +const expirationOptions: { value: Expiration; label: string }[] = [ + { value: null, label: 'Select date' }, + { value: '1d', label: '1 Day' }, + { value: '7d', label: '7 Days' }, + { value: '30d', label: '30 Days' }, + { value: '60d', label: '60 Days' }, + { value: '90d', label: '90 Days' }, + { value: '180d', label: '180 Days' }, + { value: '1y', label: '1 Year' }, +]; + +const ExpirationSelector = ({ + selectedExpiration, + setSelectedExpiration, +}: { + selectedExpiration: { value: Expiration; label: string }; + setSelectedExpiration: (value: { value: Expiration; label: string }) => void; +}) => { + const buttonRef = useRef(null); + const [buttonWidth, setButtonWidth] = useState(); + + useLayoutEffect(() => { + if (buttonRef.current) { + setButtonWidth(buttonRef.current.offsetWidth); + } + }, []); + + return ( + + ); }; +function getExpirationCaption(expirationSeconds?: number): string { + if (!expirationSeconds) { + return 'This key will never expire'; + } + + const expirationDate = new Date(Date.now() + expirationSeconds * 1000); + // Example: "Expiring June 28, 2025 - 12:45:24 PM PDT" + return ( + 'Expiring ' + + expirationDate.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', + }) + ); +} + export const CreateApiKeyForm = ({ onCreate, isSubmitting }: CreateApiKeyFormProps) => { - const [showAdvanced, setShowAdvanced] = useState(false); - const [expiration, setExpiration] = useState('never'); - const createApiKeyFormId = React.useId(); - const segmentedControlId = `${createApiKeyFormId}-segmented-control`; + const [selectedExpiration, setSelectedExpiration] = useState<{ value: Expiration; label: string }>({ + value: null, + label: 'Select date', + }); const { close: closeCardFn } = useActionContext(); + const { showDescription } = useApiKeysContext(); const nameField = useFormControl('name', '', { type: 'text', @@ -72,22 +157,17 @@ export const CreateApiKeyForm = ({ onCreate, isSubmitting }: CreateApiKeyFormPro isRequired: false, }); - const expirationDateField = useFormControl('apiKeyExpirationDate', '', { - type: 'date', - label: localizationKeys('formFieldLabel__apiKeyExpirationDate'), - placeholder: localizationKeys('formFieldInputPlaceholder__apiKeyExpirationDate'), - isRequired: false, - }); - const canSubmit = nameField.value.length > 2; + const expirationCaption = getExpirationCaption(getTimeLeftInSeconds(selectedExpiration.value)); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onCreate( { name: nameField.value, description: descriptionField.value || undefined, - expiration: getTimeLeftInSeconds(expiration, expirationDateField.value), + expiration: getTimeLeftInSeconds(selectedExpiration.value), }, closeCardFn, ); @@ -100,107 +180,65 @@ export const CreateApiKeyForm = ({ onCreate, isSubmitting }: CreateApiKeyFormPro elementDescriptor={descriptors.apiKeysCreateForm} > - - - - {showAdvanced && ( - <> - - - - + + + + + - + - - - - setExpiration(value as Expiration)} - fullWidth - sx={t => ({ height: t.sizes.$8 })} - > - - - - - - - {expiration === 'custom' ? ( - - - - ) : ( - - )} - - - )} - - - + Optional + + + + {/* */} + + {expirationCaption} + + + {showDescription && ( + + + + )} + ); diff --git a/packages/clerk-js/src/ui/elements/Select.tsx b/packages/clerk-js/src/ui/elements/Select.tsx index 0d7502f5f26..63dcdd8929b 100644 --- a/packages/clerk-js/src/ui/elements/Select.tsx +++ b/packages/clerk-js/src/ui/elements/Select.tsx @@ -376,12 +376,13 @@ export const SelectOptionList = (props: SelectOptionListProps) => { ); }; -export const SelectButton = ( - props: PropsOfComponent & { +export const SelectButton = React.forwardRef< + HTMLButtonElement, + PropsOfComponent & { icon?: React.ReactElement | React.ComponentType; iconSx?: ThemableCssProp; - }, -) => { + } +>((props, ref) => { const { sx, children, icon, iconSx, ...rest } = props; const { popoverCtx, onTriggerClick, buttonRenderOption, selectedOption, placeholder, elementId } = useSelectState(); const { reference } = popoverCtx; @@ -404,7 +405,14 @@ export const SelectButton = ( ); -}; +}); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 979d3d07b74..8b8b93ab527 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -150,7 +150,7 @@ export const enUS: LocalizationResource = { formFieldHintText__optional: 'Optional', formFieldHintText__slug: 'A slug is a human-readable ID that must be unique. It’s often used in URLs.', formFieldInputPlaceholder__apiKeyDescription: 'Enter your secret key description', - formFieldInputPlaceholder__apiKeyExpirationDate: 'Enter expiration date', + formFieldInputPlaceholder__apiKeyExpirationDate: 'Select date', formFieldInputPlaceholder__apiKeyName: 'Enter your secret key name', formFieldInputPlaceholder__backupCode: 'Enter backup code', formFieldInputPlaceholder__confirmDeletionUserAccount: 'Delete account', @@ -169,7 +169,7 @@ export const enUS: LocalizationResource = { formFieldLabel__apiKeyDescription: 'Description', formFieldLabel__apiKeyExpiration: 'Expiration', formFieldLabel__apiKeyExpirationDate: 'Select date', - formFieldLabel__apiKeyName: 'Name', + formFieldLabel__apiKeyName: 'Secret key name', formFieldLabel__automaticInvitations: 'Enable automatic invitations for this domain', formFieldLabel__backupCode: 'Backup code', formFieldLabel__confirmDeletion: 'Confirmation', diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e25115150a9..8a05a9be03f 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1683,6 +1683,11 @@ export type APIKeysProps = { * prop of ClerkProvider (if one is provided) */ appearance?: APIKeysTheme; + /** + * Whether to show the description field in the API key creation form. + * @default false + */ + showDescription?: boolean; }; export type GetAPIKeysParams = { diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts index aa42c78eb5d..22284ef91de 100644 --- a/packages/types/src/elementIds.ts +++ b/packages/types/src/elementIds.ts @@ -56,4 +56,4 @@ export type OrganizationPreviewId = export type CardActionId = 'havingTrouble' | 'alternativeMethods' | 'signUp' | 'signIn' | 'usePasskey' | 'waitlist'; export type MenuId = 'invitation' | 'member' | ProfileSectionId; -export type SelectId = 'countryCode' | 'role' | 'paymentSource'; +export type SelectId = 'countryCode' | 'role' | 'paymentSource' | 'apiKeyExpiration'; From 69f1a9fed5ce3357c4d296aeca075cb2418320e5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 18 Jun 2025 10:43:04 -0700 Subject: [PATCH 02/20] chore: use merged ref util --- packages/clerk-js/src/ui/elements/Select.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/Select.tsx b/packages/clerk-js/src/ui/elements/Select.tsx index 63dcdd8929b..aa4f465b432 100644 --- a/packages/clerk-js/src/ui/elements/Select.tsx +++ b/packages/clerk-js/src/ui/elements/Select.tsx @@ -1,5 +1,6 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { SelectId } from '@clerk/types'; +import { useMergeRefs } from '@floating-ui/react'; import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { useState } from 'react'; @@ -385,7 +386,7 @@ export const SelectButton = React.forwardRef< >((props, ref) => { const { sx, children, icon, iconSx, ...rest } = props; const { popoverCtx, onTriggerClick, buttonRenderOption, selectedOption, placeholder, elementId } = useSelectState(); - const { reference } = popoverCtx; + const mergedRef = useMergeRefs([popoverCtx.reference, ref]); let show: React.ReactNode = children; if (!children) { @@ -405,14 +406,7 @@ export const SelectButton = React.forwardRef<