This file is a merged representation of the entire codebase, combining all repository files into a single document. Generated by Repomix on: 2024-11-24T04:50:30.890Z ================================================================ File Summary ================================================================ Purpose: -------- This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. File Format: ------------ The content is organized as follows: 1. This summary section 2. Repository information 3. Repository structure 4. Multiple file entries, each consisting of: a. A separator line (================) b. The file path (File: path/to/file) c. Another separator line d. The full contents of the file e. A blank line Usage Guidelines: ----------------- - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. Notes: ------ - Some files may have been excluded based on .gitignore rules and Repomix's configuration. - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files. Additional Info: ---------------- For more information about Repomix, visit: https://github.com/yamadashy/repomix ================================================================ Repository Structure ================================================================ src/ components/ AccountSettings/ EnrollMFAForm.tsx index.tsx ActionSettings/ ArchiveDialog.tsx General.tsx Notifications.tsx Permissions.tsx Schedule.tsx ActionsList/ ActionsListGroup.tsx ActionsListItem.tsx index.tsx APIKeyButton/ index.tsx ChoiceButtons/ index.tsx CommandBar/ index.tsx MultilineKBarSearch.tsx Console/ index.tsx ConsoleOnboarding/ index.tsx DashboardLayout/ index.tsx DashboardNav/ ControlPanel.tsx index.tsx MobileNav.tsx ModeSwitch.tsx OrgSwitcher.tsx DotsSpinner/ index.tsx DropdownMenu/ index.tsx EmptyState/ index.tsx ErrorMessage/ index.tsx examples/ FormValidation.stories.tsx FileUploadButton/ FileUploadButton.stories.tsx index.tsx HelpText/ HelpText.stories.tsx index.tsx HighlightedCodeBlock/ index.tsx HostStatusIndicator/ index.tsx io-methods/ Confirm/ Confirm.stories.tsx index.tsx display-chart/ display-chart.stories.tsx index.tsx DisplayCode/ DisplayCode.stories.tsx index.tsx DisplayGrid/ index.tsx DisplayHeading/ DisplayHeading.stories.tsx index.tsx DisplayHTML/ DisplayHTML.stories.tsx index.tsx lazy.tsx stub.tsx DisplayImage/ DisplayImage.stories.tsx index.tsx DisplayLink/ DisplayLink.stories.tsx index.tsx DisplayMarkdown/ DisplayMarkdown.stories.tsx index.tsx lazy.tsx stub.tsx DisplayMetadata/ DisplayMetadata.stories.tsx index.tsx DisplayObject/ DisplayObject.stories.tsx index.tsx DisplayProgressIndeterminate/ DisplayProgressIndeterminate.stories.tsx index.tsx DisplayProgressSteps/ DisplayProgressSteps.stories.tsx index.tsx DisplayTable/ DisplayTable.stories.tsx index.tsx DisplayVideo/ DisplayVideo.stories.tsx index.tsx InputBoolean/ index.tsx InputBoolean.stories.tsx InputDate/ index.tsx InputDateTime/ index.tsx InputEmail/ index.tsx InputEmail.stories.tsx InputNumber/ index.tsx InputNumber.stories.tsx InputRichText/ index.tsx InputRichText.stories.tsx InputSlider/ index.tsx InputSlider.stories.tsx InputText/ index.tsx InputText.stories.tsx InputTime/ index.tsx InputURL/ index.tsx ListProgress/ index.tsx ListProgress.stories.tsx Search/ index.tsx Search.stories.tsx SelectMultiple/ index.tsx SelectMultiple.stories.tsx SelectSingle/ index.tsx SelectSingle.stories.tsx SelectTable/ index.tsx SelectTable.stories.tsx UploadFile/ index.tsx UploadFile.stories.tsx IVAlert/ index.tsx IVAPIError/ index.tsx IVAvatar/ index.tsx IVAvatar.stories.tsx IVButton/ index.tsx IVButton.stories.tsx IVCheckbox/ index.tsx IVCheckbox.stories.tsx IVConstraintsIndicator/ index.tsx IVDatePicker/ index.tsx IVDatePicker.stories.tsx IVDateTimePicker/ CalendarPopover.tsx DateInput.tsx index.tsx IVDateTimePicker.stories.tsx TimeInput.tsx IVDialog/ index.tsx IVDialog.stories.tsx IVInputField/ index.tsx IVMediaGrid/ index.tsx MediaGridItem.tsx IVRadio/ index.tsx IVRadio.stories.tsx IVRichTextEditor/ index.tsx IVRichTextEditor.stories.tsx lazy.tsx IVSelect/ async.tsx index.tsx IVSelect.stories.tsx IVSpinner/ IVSpinner/ index.tsx IVSpinner.stories.tsx index.tsx IVTable/ DesktopTable.tsx index.tsx IVTable.stories.tsx TableDownloader.tsx TableRowMenu.tsx VerticalTable.tsx IVTextArea/ index.tsx IVTextArea.stories.tsx IVTextInput/ index.tsx IVTextInput.stories.tsx IVTimePicker/ index.tsx IVTimePicker.stories.tsx IVToggle/ index.tsx IVTooltip/ index.tsx IVTooltip.stories.tsx KeysList/ index.tsx KeyValueTable/ index.tsx KeyValueTable.stories.tsx MetadataCardList/ index.tsx MetadataValue.tsx NavTabs/ index.tsx ObjectViewer/ index.tsx PageHeading/ index.tsx PageLayout/ index.tsx PageUI/ Layout/ Basic/ index.tsx index.tsx MobileSubnav.tsx Subnav.tsx index.tsx PermissionSelector/ index.tsx QueuedActionsList/ index.tsx QueuedActionsList.stories.tsx RenderHTML/ index.tsx RenderIOCall/ ComponentError.tsx ComponentRenderer.tsx index.tsx RenderMarkdown/ index.tsx RenderValue/ index.tsx SectionHeading/ index.tsx Sidebar/ OrgSwitcher.tsx simple-layout/ index.tsx SimpleTable/ index.tsx TeamsSelector/ index.tsx TransactionHistory/ index.tsx TransactionsList/ index.tsx TransactionUI/ _presentation/ CompletionState/ CompletionState.stories.tsx index.tsx ComponentError/ index.tsx ControlPanel/ index.tsx ErrorState/ index.tsx InlineAction/ index.tsx LoadingState/ Indeterminate.stories.tsx Indeterminate.tsx index.tsx InlineLoading.stories.tsx InlineLoading.tsx Progress.stories.tsx Progress.tsx Logs/ index.tsx Logs.stories.tsx Notifications/ index.tsx Result/ index.tsx TransactionHeader/ index.tsx TransactionLayout/ index.tsx ConfirmIdentity/ index.tsx InputSpreadsheet/ Importer/ index.tsx index.tsx InputSpreadsheet.stories.tsx lazy.tsx SpreadsheetEditor.tsx index.tsx useWebSocketClient.tsx ui/ button.tsx card.tsx chart.tsx dropdown-menu.tsx input.tsx UsersList/ index.tsx ActionBreadcrumbs.tsx AuthLoadingState.tsx AuthPageHeader.tsx ChronologicalScrollableFeed.tsx dark-mode-toggle.tsx DashboardContext.tsx EnvironmentColor.tsx IVStatusPill.tsx IVUnstyledButtonOrLink.tsx ListViewToggle.tsx LoginRedirect.tsx Logo.tsx MeContext.tsx MFAInput.tsx NotFound.tsx NotificationCenter.tsx RenderContext.tsx theme-provider.tsx Transition.tsx Truncate.tsx WrapOnUnderscores.tsx icons/ compiled/ Actions.tsx AddRow.tsx AddRowAbove.tsx BulletedList.tsx Calendar.tsx Cancel.tsx CaretDown.tsx Check.tsx CheckCircle.tsx CheckCircleOutline.tsx ChevronDown.tsx ChevronLeft.tsx ChevronRight.tsx ChevronUp.tsx CircledPlay.tsx ClearFormatting.tsx Clipboard.tsx Close.tsx Code.tsx Copy.tsx Delete.tsx DeleteRow.tsx DownloadsFolder.tsx ErrorCircle.tsx ExclamationWarn.tsx ExternalLink.tsx Eye.tsx Folder.tsx FrownFace.tsx Github.tsx Google.tsx IconSortUp.tsx Image.tsx Info.tsx Invisible.tsx Link.tsx Lock.tsx Menu.tsx MinusMini.tsx More.tsx NumberedList.tsx Offline.tsx Ok.tsx Online.tsx Play.tsx PlusMini.tsx Puzzled.tsx QuoteLeft.tsx Redirect.tsx Redo.tsx Refresh.tsx RightArrow.tsx Rocket.tsx Schedule.tsx Search.tsx Settings.tsx Slack.tsx SortDown.tsx SortUp.tsx Sparkling.tsx Subtract.tsx TailwindChevronDown.tsx ThumbsDown.tsx ThumbsUp.tsx TwitterCircled.tsx Undo.tsx UploadsFolder.tsx User.tsx XCircle.tsx pages/ confirm-signup/ [orgSlug].tsx dashboard/ [orgSlug]/ actions/ [...actionSlug].tsx configure/ [...actionSlug].tsx details/ [...actionSlug].tsx develop/ actions/ [...actionSlug].tsx console/ index.tsx keys/ index.tsx serverless-endpoints/ index.tsx index.tsx history/ index.tsx organization/ teams/ [groupId].tsx index.tsx actions.tsx environments.tsx index.tsx settings.tsx users.tsx transactions/ [transactionId].tsx index.tsx [...fallback].tsx account.tsx index.tsx new-organization.tsx index.tsx develop/ [orgSlug]/ actions/ [...actionSlug].tsx index.tsx GhostModeConsoleLayout.tsx accept-invitation.tsx authentication-confirmed.tsx authentication-not-confirmed.tsx component-preview.tsx confirm-email.tsx enroll-mfa.tsx forgot-password.tsx index.tsx login.tsx not-found.tsx reset-password.tsx signup.tsx verify-mfa.tsx App.tsx main.tsx ================================================================ Repository Files ================================================================ ================ File: src/components/AccountSettings/EnrollMFAForm.tsx ================ import { useEffect } from 'react' import { Form, Formik } from 'formik' import { trpc } from '~/utils/trpc' import IVButton from '~/components/IVButton' import IVSpinner from '~/components/IVSpinner' import MFAInput from '../MFAInput' export default function EnrollMFAForm({ onSubmit }: { onSubmit: () => void }) { const start = trpc.useMutation(['auth.mfa.enroll.start']) const complete = trpc.useMutation(['auth.mfa.enroll.complete'], { onSuccess() { onSubmit() }, }) const { mutate: startEnrollment } = start useEffect(() => { startEnrollment() }, [startEnrollment]) return ( key={start.data?.challengeId} initialValues={{ code: '', }} onSubmit={({ code }, { setFieldValue }) => { if (!start.data?.challengeId) return complete.mutate( { code, challengeId: start.data.challengeId, }, { onError: () => { setFieldValue('code', '') }, } ) }} >

Scan this QR code or use the secret below with your authenticator app of choice. Enter the verification code it gives you to complete enrollment.

{start.data ? ( <> QR code to enroll in MFA, alternatively use secret below

{start.data.secret}

) : ( )}
{complete.isError && (
Sorry, that code is invalid. Please try again.
)}
) } ================ File: src/components/AccountSettings/index.tsx ================ import { useState, useEffect } from 'react' import { Field, Form, Formik } from 'formik' import { DialogStateReturn } from 'reakit/ts' import { trpc, inferMutationInput } from '~/utils/trpc' import IVInputField from '~/components/IVInputField' import IVButton from '~/components/IVButton' import { notify } from '~/components/NotificationCenter' import useDashboard, { useHasPermission } from '~/components/DashboardContext' import { Link } from 'react-router-dom' import IVSelect from '~/components/IVSelect' import { EXPOSED_ROLES } from '~/utils/permissions' import { userAccessPermissionToString } from '~/utils/text' import IVAPIError from '~/components/IVAPIError' import IVRadio from '~/components/IVRadio' import { TIMEZONE_OPTIONS } from '~/utils/timezones' import { useOrgParams } from '~/utils/organization' import IVDialog, { useDialogState } from '~/components/IVDialog' import EnrollMFAForm from './EnrollMFAForm' import IVTooltip from '~/components/IVTooltip' import MFAInput from '../MFAInput' function UpdateAccountForm() { const mutation = trpc.useMutation('user.edit') const ctx = trpc.useContext() const { me, organization } = useDashboard() const canManageOrganization = useHasPermission('WRITE_ORG_SETTINGS') const [hasPendingEmailConf, setHasPendingEmailConf] = useState(false) return ( ['data']> initialValues={{ firstName: me.firstName ?? '', lastName: me.lastName ?? '', email: me.email, defaultNotificationMethod: me.defaultNotificationMethod ?? 'EMAIL', timeZoneName: me.timeZoneName, }} onSubmit={async data => { if (mutation.isLoading) return mutation.mutate( { id: me.id, data }, { onSuccess(res) { setHasPendingEmailConf(res.requiresEmailConfirmation) notify.success('Your changes were saved.') ctx.refetchQueries(['user.me']) }, } ) }} > {({ values }) => (
{hasPendingEmailConf && (
We sent you an email to confirm your new email address. Please click the link in the email to confirm this change.
)}

Configure where you would like to receive notifications.

Enable by connecting to Slack in your{' '} organization settings. ) : ( <> Contact your organization administrator about connecting your organization to Slack. )) } />
{mutation.isError && (
Sorry, there was a problem editing your account.
)}
)} ) } function UpdatePasswordForm() { const mutation = trpc.useMutation('auth.password.edit') return ( ['data']> initialValues={{ newPassword: '', newPasswordConfirm: '', }} validate={values => { if (values.newPassword !== values.newPasswordConfirm) { return { newPasswordConfirm: 'Passwords do not match', } } if (values.newPassword.length && values.newPassword.length < 6) { return { newPasswordConfirm: 'Password must be at least 6 characters', } } return {} }} onSubmit={async (data, { resetForm }) => { if (mutation.isLoading) return mutation.mutate( { data }, { onSuccess() { notify('Your password was updated.') resetForm() }, } ) }} > {({ errors, touched }) => (

Update password

{mutation.isError && (
Sorry, there was an error while updating your password.
)}
)} ) } function UpdateRoleForm() { const mutation = trpc.useMutation('user.edit-role') const { orgSlug } = useOrgParams() const { me } = useDashboard() if (!import.meta.env.DEV) return null return ( ['data']> initialValues={{ orgSlug, permission: me.userOrganizationAccess.find( access => access.organization.slug === orgSlug )?.permissions[0] || 'ACTION_RUNNER', }} onSubmit={async data => { if (mutation.isLoading) return mutation.mutate( { id: me.id, data }, { onSuccess() { notify.success('Your role was updated. Reloading...') setTimeout(() => window.location.reload(), 1000) }, } ) }} >

Change role

({ label: userAccessPermissionToString(role), value: role, }))} />
) } function AddMFADialog({ dialog, onSubmit, }: { dialog: DialogStateReturn onSubmit: () => void }) { return ( {(dialog.visible || dialog.animating) && ( )} ) } function RemoveMFADialog({ dialog, onSubmit, }: { dialog: DialogStateReturn onSubmit: () => void }) { const challenge = trpc.useMutation(['auth.mfa.challenge']) const removeMfa = trpc.useMutation(['auth.mfa.delete']) const { mutate: startChallenge } = challenge const { visible } = dialog useEffect(() => { if (visible) { startChallenge() } }, [visible, startChallenge]) return (

Enter a code with your current MFA enrollment to disable it.

initialValues={{ code: '', }} initialErrors={{ code: 'Please enter a code', }} onSubmit={async ({ code }) => { if (!challenge.data) return removeMfa.mutate( { code, challengeId: challenge.data, }, { onSuccess() { onSubmit() }, } ) }} validate={({ code }) => { if (!code) { return { code: 'Please enter a code.', } } }} > {({ isValid }) => (
{removeMfa.isError && (
Sorry, that code is invalid. Please try again.
)}
{ dialog.hide() }} />
)}
) } function MFASection() { const { organization } = useDashboard() const hasMfa = trpc.useQuery(['auth.mfa.has']) const addMfaDialog = useDialogState() const removeMfaDialog = useDialogState() const { invalidateQueries } = trpc.useContext() return (

Multi-factor authentication

{hasMfa.data ? ( <>

Multi-factor authentication is currently enabled.

{ const confirmed = window.confirm( 'Are you sure you want to re-enroll in multi-factor authentication? This will remove and invalidate your previous MFA enrollment.' ) if (confirmed) { addMfaDialog.show() } }} /> {organization.requireMfa ? ( ) : ( { removeMfaDialog.show() }} /> )}
) : (

Multi-factor authentication {' '} adds an additional layer of security to your account. We strongly recommending enabling MFA.

{ addMfaDialog.show() }} />
)} { addMfaDialog.hide() hasMfa.refetch() invalidateQueries('auth.session.session') notify.success('Multi-factor authentication is enabled.') }} /> { removeMfaDialog.hide() hasMfa.refetch() invalidateQueries('auth.session.session') notify.success('Multi-factor authentication is disabled.') }} />
) } export default function AccountSettings() { const { integrations } = useDashboard() const hasPassword = trpc.useQuery(['auth.password.has']) return (
{/* These should remain at the bottom of the page to prevent weird flickering */} {integrations?.workos && } {hasPassword.data && }
) } ================ File: src/components/ActionSettings/ArchiveDialog.tsx ================ import Dialog, { useDialogState } from '~/components/IVDialog' import { trpc } from '~/utils/trpc' import IVButton from '~/components/IVButton' import { notify } from '../NotificationCenter' function ArchiveForm({ actionId, onSubmit, onClose, }: { actionId: string onSubmit: () => void onClose: () => void }) { const archive = trpc.useMutation('action.archive') return (

Are you sure you want to archive this action?

{ archive.mutate({ actionId }, { onSuccess: onSubmit }) }} />
) } function UnarchiveForm({ actionId, onSubmit, onClose, }: { actionId: string onSubmit: () => void onClose: () => void }) { const unarchive = trpc.useMutation('action.unarchive') return (

Unarchive this action to add it to the actions list and run it.

{ unarchive.mutate({ actionId }, { onSuccess: onSubmit }) }} />
) } export default function ArchiveDialog({ actionId, dialog, onSuccess, mode, }: { actionId: string mode: 'archive' | 'unarchive' onSuccess: () => void dialog: ReturnType }) { return ( {mode === 'archive' && ( { dialog.hide() notify.success(() => (

Action archived.

Please remove it from your deployment.

)) onSuccess() }} onClose={dialog.hide} /> )} {mode === 'unarchive' && ( { dialog.hide() notify.success('Action unarchived.') onSuccess() }} onClose={dialog.hide} /> )}
) } ================ File: src/components/ActionSettings/General.tsx ================ import { useCallback } from 'react' import { Field, Form, Formik } from 'formik' import { inferQueryOutput, inferMutationInput, trpc } from '~/utils/trpc' import IVInputField from '~/components/IVInputField' import IVTextInput from '~/components/IVTextInput' import IVTextArea from '~/components/IVTextArea' import { slugToName } from '~/utils/text' import IVButton from '~/components/IVButton' import IconInfo from '~/icons/compiled/Info' import IVCheckbox from '~/components/IVCheckbox' import { useDialogState } from '~/components/IVDialog' import ArchiveDialog from '~/components/ActionSettings/ArchiveDialog' import IVAlert from '~/components/IVAlert' import { useIsFeatureEnabled } from '~/utils/useIsFeatureEnabled' function OverrideMetaTextField({ id, label, helpText, errorMessage, metaValue, actionValue, defaultValue = '', placeholder, disabled, onChange, multiline = false, }: { id: string label: string errorMessage?: React.ReactNode helpText?: string metaValue: string | null | undefined actionValue: string | null | undefined defaultValue?: string placeholder?: string disabled?: boolean onChange: (value: string | null) => void multiline?: boolean }) { const isOverriding = metaValue != null const InputComponent = multiline ? IVTextArea : IVTextInput return ( <> ) => { if (disabled) return onChange(e.target.value) }} readOnly={!!actionValue && !isOverriding} disabled={disabled || (!!actionValue && !isOverriding)} placeholder={placeholder} /> {!disabled && actionValue != null && ( { onChange(isOverriding ? null : actionValue ?? defaultValue) }} /> )} ) } function OverrideMetaToggleField({ id, label, helpText, metaValue, actionValue, defaultValue, disabled, onChange, }: { id: string label: string helpText?: string metaValue: boolean | null | undefined actionValue: boolean | null | undefined defaultValue: false disabled?: boolean onChange: (value: boolean | null) => void }) { const isOverriding = metaValue != null return (
{ if (disabled) return onChange(e.target.checked) }} disabled={disabled || !isOverriding} /> {!disabled && actionValue != null && ( { onChange(isOverriding ? null : actionValue ?? defaultValue) }} /> )}
) } function OverrideNotice({ label, buttonLabel, onClick }) { return (

{label}

) } export default function ActionGeneralSettings({ action, onSuccess, onError, refetch, }: { action: inferQueryOutput<'action.one'> onSuccess: () => void onError: () => void refetch: () => void }) { const archiveDialog = useDialogState() const updateMeta = trpc.useMutation('action.general.update') const generalConfigEnabled = useIsFeatureEnabled( 'ACTION_METADATA_GENERAL_CONFIG' ) const validateName = useCallback( (name: string | undefined | null) => { name = name?.trim() if (action.name && !name) return if (!name) { return 'Please enter a name.' } }, [action] ) const isArchived = action.metadata?.archivedAt return (
{isArchived && (

This action has been archived.{` `} {' '} to restore it to your Actions list.

)} {!generalConfigEnabled && (

Actions are configured in code. See{' '} defining actions documentation {' '} for more information.

)} ['data']> initialTouched={{ name: true, }} initialValues={{ name: action.metadata?.name ?? (action.name ? undefined : slugToName(action.slug)), backgroundable: action.metadata?.backgroundable, description: action.metadata?.description, }} validateOnBlur={false} validateOnChange={false} validate={({ name }) => { const nameValidation = validateName(name) if (nameValidation) { return { name: nameValidation, } } }} onSubmit={async data => { if (updateMeta.isLoading || !generalConfigEnabled) return if ( action.schedules.length && (data.backgroundable === false || (!action.backgroundable && !data.backgroundable)) ) { if ( !window.confirm( "Turning off 'Allow running in background' will remove this action's schedule. Are you sure you want to turn off this setting?" ) ) { return } } updateMeta.mutate( { actionId: action.id, data }, { onSuccess, onError } ) }} > {({ errors, touched, isValid, values, setFieldValue }) => (
{ setFieldValue('name', val) }} /> { setFieldValue('description', val) }} multiline />
{ setFieldValue('backgroundable', val) }} />
{generalConfigEnabled ? ( ) : ( /* Just to make the flex still work */
)} {!isArchived && (
)}
{updateMeta.isError && (
Sorry, there was a problem updating the action.
)} )}
) } ================ File: src/components/ActionSettings/Notifications.tsx ================ import { Formik, Form, Field } from 'formik' import { NotificationMethod } from '@prisma/client' import { inferQueryOutput, trpc } from '~/utils/trpc' import { useOrganization } from '~/components/DashboardContext' import IVInputField from '~/components/IVInputField' import IVSelect from '~/components/IVSelect' import { Link } from 'react-router-dom' import IVButton from '~/components/IVButton' type NotificationMethodOption = 'EMAIL' | 'SLACK' | 'ACTION_RUNNER' function validateSlackChannel(value: string | undefined) { if (!value || value === '') { return 'Please select a Slack channel.' } } function validateNotificationEmail(value: string | undefined) { if (!value || value === '') { return 'Please enter an email address.' } } export default function ActionNotificationsSettings({ action, onSuccess, onError, }: { action: inferQueryOutput<'action.one'> onSuccess: () => void onError: () => void refetch: () => void }) { const slackChannels = trpc.useQuery(['organization.slack-channels']) const updateMeta = trpc.useMutation('action.notifications.update') const organization = useOrganization() let defaultNotificationMethod: NotificationMethodOption = 'ACTION_RUNNER' let notificationEmail = '' let slackChannel = '' if ( action.metadata?.defaultNotificationDelivery && typeof action.metadata?.defaultNotificationDelivery === 'string' ) { let delivery = JSON.parse(action.metadata.defaultNotificationDelivery) if (Array.isArray(delivery)) { delivery = delivery[0] if (delivery['method']) { defaultNotificationMethod = delivery['method'] } if (delivery['method'] === 'EMAIL') { notificationEmail = delivery['to'] } if (delivery['method'] === 'SLACK') { slackChannel = delivery['to'] } } } return ( initialValues={{ defaultNotificationMethod, notificationEmail, slackChannel, }} validate={values => { const errors: { [key: string]: string } = {} if (values.defaultNotificationMethod === 'EMAIL') { const emailError = validateNotificationEmail(values.notificationEmail) if (emailError) errors.notificationEmail = emailError } if (values.defaultNotificationMethod === 'SLACK') { if (!organization.connectedToSlack) { errors.defaultNotificationMethod = 'Please connect to Slack first.' } const slackError = validateSlackChannel(values.slackChannel) if (slackError) errors.slackChannel = slackError } return errors }} validateOnBlur={false} validateOnChange={false} onSubmit={async data => { if (updateMeta.isLoading) return let defaultNotificationDelivery: | { method: NotificationMethod; to: string }[] | null = null if (data.defaultNotificationMethod === 'ACTION_RUNNER') { defaultNotificationDelivery = null } else if (data.defaultNotificationMethod === 'EMAIL') { defaultNotificationDelivery = [ { method: 'EMAIL', to: data.notificationEmail, }, ] } else if (data.defaultNotificationMethod === 'SLACK') { defaultNotificationDelivery = [ { method: 'SLACK', to: data.slackChannel, }, ] } updateMeta.mutate( { actionId: action.id, data: { defaultNotificationDelivery, }, }, { onSuccess, onError, } ) }} > {({ values, errors, setFieldValue, setErrors, isValid }) => (
{/* not a "real" setting, per se */}
Notify the action runner
{ setFieldValue('defaultNotificationMethod', e.target.value) setErrors({}) }} />
{values.defaultNotificationMethod === 'EMAIL' && ( { setFieldValue('notificationEmail', e.target.value, true) }} /> )} {values.defaultNotificationMethod === 'SLACK' && ( <> {organization.connectedToSlack ? (
({ label: `#${channel}`, value: `#${channel}`, })) ?? [] } defaultLabel="Select a channel" onChange={e => { setFieldValue( 'slackChannel', e.target.value, true ) }} />
) : (

Please{' '} connect to Slack {' '} first to enable this setting.

)} )}
)} ) } ================ File: src/components/ActionSettings/Permissions.tsx ================ import { useMemo, useEffect } from 'react' import { Formik, Form, Field, useFormikContext } from 'formik' import { ActionAccessLevel } from '@prisma/client' import { actionAccessLevelToString, pluralizeWithCount } from '~/utils/text' import { inferQueryOutput, inferMutationInput, trpc } from '~/utils/trpc' import IVButton from '~/components/IVButton' import IVInputField from '~/components/IVInputField' import IVRadio from '~/components/IVRadio' import IVSelect from '~/components/IVSelect' import { useOrgParams } from '~/utils/organization' import IVConstraintsIndicator from '~/components/IVConstraintsIndicator' import { useIsFeatureEnabled } from '~/utils/useIsFeatureEnabled' import useDashboard from '../DashboardContext' import { Link } from 'react-router-dom' import { SDK_PERMISSIONS_MIN_VERSION } from '~/utils/permissions' import IVAlert from '../IVAlert' import IconInfo from '~/icons/compiled/Info' function GroupAccessForm({ groups, groupPermissions, }: { groups: inferQueryOutput<'group.list'> groupPermissions: { groupId: string; level: ActionAccessLevel }[] }) { const { orgEnvSlug } = useOrgParams() const { setFieldValue, values } = useFormikContext['data']>() useEffect(() => { setFieldValue('groupPermissions', groupPermissions) }, [setFieldValue, groupPermissions]) if (!groups) return null return (
{groups.length > 0 ? (
    {groups.map(group => { const formValue = values.groupPermissions?.find( v => v.groupId === group.id ) return (
  • {group.name} {pluralizeWithCount(group._count.memberships, 'member')}
    ({ value: level, label: actionAccessLevelToString(level), }) )} onChange={event => { const { groupPermissions } = values if (!groupPermissions) return const idx = groupPermissions.findIndex( v => v.groupId === group.id ) if (idx < 0) return const level = event.target.value as ActionAccessLevel groupPermissions[idx].level = level || 'NONE' setFieldValue('groupPermissions', groupPermissions) }} />
    {formValue?.level === 'RUNNER' && ( To run the action, users on this team must also have{' '} a role with sufficient access . } id="admin-explanation" placement="right" /> )} {formValue?.level === 'ADMINISTRATOR' && (

    The Administrator permission enables running the action and configuring settings.

    To run the action, users on this team must also have{' '} a role with sufficient access .

    To configure action settings, users on this team must also have the{' '} Admin role .

    } id="admin-explanation" placement="right" />
    )}
  • ) })}
  • Everyone else
    No access
) : (

There aren't any teams yet. Please create one to configure team access.

)}
) } export default function ActionPermissionsSettings({ action, onSuccess, onError, isUsingCodeBasedPermissions, }: { action: inferQueryOutput<'action.one'> onSuccess: () => void onError: () => void refetch: () => void isUsingCodeBasedPermissions?: boolean }) { const { organization } = useDashboard() const groups = trpc.useQuery(['group.list', { actionId: action.id }], { refetchOnWindowFocus: false, }) const updateMeta = trpc.useMutation('action.permissions.update') const groupPermissions = useMemo( () => groups.data?.map(group => ({ groupId: group.id, level: group.actionAccesses[0]?.level ?? ('NONE' as ActionAccessLevel), })), [groups.data] ) if (!groups.data || !groupPermissions) { return null } if (isUsingCodeBasedPermissions) { return (

Starting with version {SDK_PERMISSIONS_MIN_VERSION} of the SDK, permissions must be configured via code.{' '} View documentation ›

) } return ( ['data']> initialValues={{ groupPermissions, availability: action.metadata?.availability ?? 'ORGANIZATION', }} validateOnBlur={false} validateOnChange={false} onSubmit={async data => { if (updateMeta.isLoading) return updateMeta.mutate( { actionId: action.id, data: { ...data, // before code-based permissions, this was the default behavior - organization = undefined. // this UI is not available for organizations using code-based permissions, so we'll // keep the previous behavior here. availability: data.availability === 'ORGANIZATION' ? null : data.availability, }, }, { onSuccess() { groups.refetch() onSuccess() }, onError, } ) }} > {({ values, isValid }) => (
{values.availability === 'GROUPS' && (
)}
)} ) } ================ File: src/components/ActionSettings/Schedule.tsx ================ import { Formik, Form } from 'formik' import { useEffect, useMemo, useCallback } from 'react' import classNames from 'classnames' import { useFormikContext } from 'formik' import { Link } from 'react-router-dom' import { DateTime } from 'luxon' import IconClose from '~/icons/compiled/Close' import IVButton from '~/components/IVButton' import IVInputField from '~/components/IVInputField' import IVSelect from '~/components/IVSelect' import { trpc, client, inferMutationInput, inferQueryOutput, } from '~/utils/trpc' import AsyncIVSelect from '~/components/IVSelect/async' import { SchedulePeriod, toScheduleInput } from '~/utils/actionSchedule' import relativeTime, { DAY_NAMES, DAYS_OF_MONTH, displayStringToTime, timeToDisplayString, numberWithOrdinal, } from '~/utils/date' import { TIMEZONE_OPTIONS } from '~/utils/timezones' import IVAlert from '~/components/IVAlert' import IVCheckbox from '~/components/IVCheckbox' import IconInfo from '~/icons/compiled/Info' import { displayName } from '~/utils/user' import { isBackgroundable } from '~/utils/actions' import { useOrgParams } from '~/utils/organization' const times = [ '12:00 AM', '12:30 AM', '1:00 AM', '1:30 AM', '2:00 AM', '2:30 AM', '3:00 AM', '3:30 AM', '4:00 AM', '4:30 AM', '5:00 AM', '5:30 AM', '6:00 AM', '6:30 AM', '7:00 AM', '7:30 AM', '8:00 AM', '8:30 AM', '9:00 AM', '9:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM', '12:00 PM', '12:30 PM', '1:00 PM', '1:30 PM', '2:00 PM', '2:30 PM', '3:00 PM', '3:30 PM', '4:00 PM', '4:30 PM', '5:00 PM', '5:30 PM', '6:00 PM', '6:30 PM', '7:00 PM', '7:30 PM', '8:00 PM', '8:30 PM', '9:00 PM', '9:30 PM', '10:00 PM', '10:30 PM', '11:00 PM', '11:30 PM', ] function usersToSelect(users: inferQueryOutput<'organization.users'>) { return ( users?.map(({ user }) => { const name = [user.firstName, user.lastName].join(' ') return { value: user.id, label: name || user.email, } }) ?? [] ) } function ActionSchedule({ action, }: { action: inferQueryOutput<'action.one'> }) { const { orgEnvSlug } = useOrgParams() const { values, setFieldValue } = useFormikContext['data']>() const actionScheduleInput = values.actionScheduleInputs?.[0] const actionScheduleTransactions = useMemo( () => action.transactions .filter(t => t.actionScheduleId === actionScheduleInput?.id) .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf()), [actionScheduleInput?.id, action.transactions] ) const time = useMemo(() => { const { hours, minutes } = actionScheduleInput ?? {} if (hours === undefined) { return '8:00 AM' } return timeToDisplayString(hours, minutes) }, [actionScheduleInput]) const input = useMemo(() => { return { schedulePeriod: actionScheduleInput?.schedulePeriod ?? 'hour', timeZoneName: actionScheduleInput?.timeZoneName ?? DateTime.now().zoneName, dayOfWeek: actionScheduleInput?.dayOfWeek ?? 0, dayOfMonth: actionScheduleInput?.dayOfMonth ?? 1, hours: actionScheduleInput?.hours ?? 8, minutes: actionScheduleInput?.minutes ?? 0, runnerId: actionScheduleInput?.runnerId ?? undefined, runnerName: actionScheduleInput?.['runnerName'] ?? undefined, notifyOnSuccess: actionScheduleInput?.notifyOnSuccess ?? false, } }, [actionScheduleInput]) const updateInput = useCallback( (updates: Partial = {}) => { setFieldValue('actionScheduleInputs', [{ ...input, ...updates }]) }, [input, setFieldValue] ) // Update form values on refetch useEffect(() => { setFieldValue( 'actionScheduleInputs', action.schedules.map(s => ({ id: s.id, runnerName: s.runner ? displayName(s.runner) : null, ...toScheduleInput(s), })) ) }, [setFieldValue, action.schedules]) const handleUserSearch = useCallback(async (searchQuery: string) => { const accesses = await client.query('organization.users', { searchQuery, limit: 50, }) return usersToSelect(accesses) }, []) return (
{actionScheduleInput ? (
updateInput({ schedulePeriod: e.target.value as SchedulePeriod, }) } value={input.schedulePeriod} options={[ { label: 'Every hour', value: 'hour', }, { label: 'Every day', value: 'day', }, { label: 'Every week', value: 'week', }, { label: 'Every month', value: 'month', }, ]} /> {input.schedulePeriod === 'week' && ( <>
on
({ label: day, value: i.toString(), }))} value={input.dayOfWeek} onChange={event => { updateInput({ dayOfWeek: Number(event.target.value) }) }} /> )} {input.schedulePeriod === 'month' && ( <>
on the
({ label: numberWithOrdinal(day), value: String(day), }))} value={input.dayOfMonth} onChange={event => { updateInput({ dayOfMonth: Number(event.target.value) }) }} /> )} {input.schedulePeriod !== 'hour' && ( <>
at
({ label: time, value: time }))} value={time} onChange={event => { const { hours, minutes } = displayStringToTime( event.target.value ) updateInput({ hours, minutes }) }} /> { updateInput({ timeZoneName: event.target.value }) }} /> )}
{ updateInput({ runnerId }) }} isClearable />

If enabled, will notify the action runner when the action completes successfully.

The runner will always be notified when the action is unsuccessful or if it requires input.

} checked={input.notifyOnSuccess} onChange={e => { updateInput({ notifyOnSuccess: e.target.checked }) }} />
{actionScheduleTransactions.length > 0 && (
Last run: {relativeTime(actionScheduleTransactions[0].createdAt, { fullDateThresholdInHours: 24, })} View history ›
)}
) : (

Add a schedule to run this action automatically.

{ updateInput() }} />
)}
) } export default function ActionScheduleSettings({ action, onSuccess, onError, }: { action: inferQueryOutput<'action.one'> onSuccess: () => void onError: () => void refetch: () => void }) { const updateMeta = trpc.useMutation('action.schedule.update') const actionScheduleInputs = useMemo( () => action.schedules.map(s => ({ id: s.id, runnerName: s.runner ? displayName(s.runner) : undefined, ...toScheduleInput(s), })), [action.schedules] ) if (!isBackgroundable(action)) { return (

Please enable Allow running in background in General settings before adding a schedule.

) } return ( ['data']> initialValues={{ actionScheduleInputs, }} validateOnBlur={false} validateOnChange={false} onSubmit={async data => { if (updateMeta.isLoading) return updateMeta.mutate( { actionId: action.id, data, }, { onSuccess, onError } ) }} > {({ isValid }) => (
)} ) } ================ File: src/components/ActionsList/ActionsListGroup.tsx ================ import { useMemo } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' import { ActionsListProps } from '.' import { ListViewToggleProps } from '../ListViewToggle' import ActionsListItem from './ActionsListItem' import { getActionUrl, getStatus } from '~/utils/actions' import IconFolder from '~/icons/compiled/Folder' import { useOrgParams } from '~/utils/organization' import WrapOnUnderscores from '../WrapOnUnderscores' import IVTooltip from '~/components/IVTooltip' interface ActionsListGroupProps extends Omit { title: string | null className?: string groupSlug?: string viewMode: ListViewToggleProps['value'] } export default function ActionsListGroup(props: ActionsListGroupProps) { const { orgEnvSlug } = useOrgParams() const { groupSlug, mode } = props const href = useMemo(() => { if (!groupSlug) return return getActionUrl({ base: window.location.origin, orgEnvSlug, mode, slug: groupSlug, }) }, [groupSlug, orgEnvSlug, mode]) return (
{props.title && (

{href ? {props.title} : props.title}

)}
    {props.groups?.map(group => { const url = getActionUrl({ base: window.location.origin, orgEnvSlug, mode, slug: group.slug, }) const hostStatus = getStatus(group) ?? 'OFFLINE' return ( {(group.unlisted || hostStatus !== 'ONLINE') && (
    {group.unlisted &&

    Unlisted

    } {hostStatus === 'OFFLINE' && (

    Offline

    )} {hostStatus === 'UNREACHABLE' && (

    Unreachable

    )}
    )}
    {group.description && (
    {group.description}
    )} ) })} {props.actions.map(action => ( ))}
) } ================ File: src/components/ActionsList/ActionsListItem.tsx ================ import { useMemo } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' import IconSchedule from '~/icons/compiled/Schedule' import IconSettings from '~/icons/compiled/Settings' import { getActionUrl, getDescription, getName, getStatus, } from '~/utils/actions' import { actionScheduleToDescriptiveString } from '~/utils/actionSchedule' import IVTooltip from '~/components/IVTooltip' import { ListViewToggleProps } from '../ListViewToggle' import { ActionsListProps } from '.' import WrapOnUnderscores from '../WrapOnUnderscores' import { useOrgParams } from '~/utils/organization' interface ActionsListItemProps { action: ActionsListProps['actions'][0] canRun?: boolean canConfigure?: boolean showFullSlug?: boolean viewMode: ListViewToggleProps['value'] actionMode: ActionsListProps['mode'] slugPrefix?: string } export default function ActionsListItem({ canRun, action, actionMode, canConfigure, showFullSlug, viewMode, slugPrefix, }: ActionsListItemProps) { const { orgEnvSlug } = useOrgParams() const hostStatus = getStatus(action) ?? 'OFFLINE' const actionOffline = hostStatus === 'OFFLINE' const canRunAction = canRun && action.canRun const name = useMemo(() => { let name = getName(action) if ( slugPrefix && name === action.slug && name.startsWith(`${slugPrefix}/`) ) { name = name.substring(slugPrefix.length + 1) } return name }, [action, slugPrefix]) const description = getDescription(action) const href = useMemo( () => getActionUrl({ base: window.location.origin, orgEnvSlug, mode: actionMode, slug: action.slug, }), [orgEnvSlug, action.slug, actionMode] ) return (
{canConfigure && (
) } ================ File: src/components/ActionsList/index.tsx ================ import classNames from 'classnames' import { useState } from 'react' import { Link } from 'react-router-dom' import { useRecoilState } from 'recoil' import IconCaretDown from '~/icons/compiled/CaretDown' import { useOrgParams } from '~/utils/organization' import { pluralize, pluralizeWithCount } from '~/utils/text' import { ActionMode, ActionLookupResult, ActionGroupLookupResult, } from '~/utils/types' import ListViewToggle, { actionsListViewMode } from '../ListViewToggle' import ActionsListGroup from './ActionsListGroup' import ActionsListItem from './ActionsListItem' export interface ActionsListProps { mode: ActionMode | 'anon-console' canRun?: boolean canConfigure?: boolean actions: ActionLookupResult[] archivedActions: ActionLookupResult[] groups?: ActionGroupLookupResult[] slugPrefix?: string showFullSlug?: boolean } export default function ActionsList(props: ActionsListProps) { const { orgEnvSlug } = useOrgParams() const [viewMode] = useRecoilState(actionsListViewMode) const [showArchived, setShowArchived] = useState(false) if (!props.actions.length && !props.groups?.length) return null return (
{(!!props.groups?.length || !!props.actions.length) && ( )}
{props.archivedActions.length > 0 && !props.slugPrefix && ( <>
{showArchived && (
{props.archivedActions.map((action, idx) => ( ))}
{props.mode === 'live' ? (

{pluralize( props.archivedActions.length, 'This action has been archived but is still registered in code.', 'These actions have been archived but are still registered in code.' )}
Remove archived actions from your code and redeploy to remove them from the dashboard, or unarchive the actions to restore them to the list above.

) : (

{pluralize( props.archivedActions.length, 'This action has been archived in live mode.', 'These actions have been archived in live mode.' )}
Remove archived actions from your code to remove them from the dashboard, or unarchive actions in{' '} live mode {' '} to restore them to the list above.

)}
)} )}
) } ================ File: src/components/APIKeyButton/index.tsx ================ import { trpc } from '~/utils/trpc' import useCopyToClipboard from '~/utils/useCopyToClipboard' import IVButton from '~/components/IVButton' import { notify } from '~/components/NotificationCenter' import { useEffect } from 'react' import IconClipboard from '~/icons/compiled/Clipboard' export default function ApiKeyButton() { const key = trpc.useQuery(['key.dev']) const { isCopied, onCopyClick } = useCopyToClipboard() useEffect(() => { if (isCopied) { notify.success('Copied token to clipboard') } }, [isCopied]) return ( onCopyClick(key?.data?.key || '')} label={ {key?.data?.key} } /> ) } ================ File: src/components/ChoiceButtons/index.tsx ================ import IVButton from '~/components/IVButton' import ErrorMessage from '~/components/ErrorMessage' import { ButtonConfig, ChoiceButtonConfig } from '@forgeapp/sdk/dist/types' type SubmittedChoice = { value: string label: string index: number } type ChoiceButtonsProps = { disabled: boolean isSubmitting: boolean submittedChoice?: SubmittedChoice | null onSubmit: ( props: SubmittedChoice ) => (e: React.MouseEvent) => void validationErrorMessage?: string | undefined | null choices?: (Omit & { theme?: ButtonConfig['theme'] | 'default' })[] continueButton?: Omit & { theme?: ButtonConfig['theme'] | 'default' } extraLoadingMessage?: string } export default function ChoiceButtons({ disabled, isSubmitting, submittedChoice, onSubmit, validationErrorMessage, choices, extraLoadingMessage, }: ChoiceButtonsProps) { if (!choices || choices.length < 1) { choices = [{ label: 'Continue', theme: 'primary', value: 'Continue' }] } return (
{validationErrorMessage && (
)}
{choices.map((choice, index) => { const defaultTheme = index === 0 ? 'primary' : 'secondary' return ( onSubmit({ ...choice, index, })(e) } label={choice.label} theme={ choice.theme === 'default' ? defaultTheme : choice.theme ?? defaultTheme } disabled={disabled || isSubmitting} loading={isSubmitting && submittedChoice?.index === index} data-pw="continue-button" /> ) })} {isSubmitting && extraLoadingMessage && (

{extraLoadingMessage}...

)}
) } ================ File: src/components/CommandBar/index.tsx ================ import React from 'react' import { KBarPortal, KBarPositioner, KBarAnimator, KBarResults, KBarSearch, useMatches, useKBar, ActionImpl, ActionId, } from 'kbar' import classNames from 'classnames' import MultilineKBarSearch from './MultilineKBarSearch' import useCommandBarActions, { COMMAND_BAR_INPUT_ID, VIEW_ONLY_MESSAGE, } from './useCommandBarActions' import IconExternalLink from '~/icons/compiled/ExternalLink' export function DynamicCommandBarActions() { useCommandBarActions() return null } function usePlatformControlIcon() { const isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 return isMac ? '⌘' : 'Ctrl' } function Shortcut({ children, withCmdCtrl, }: { children: React.ReactNode withCmdCtrl?: boolean }) { const prefix = usePlatformControlIcon() return ( {withCmdCtrl && prefix} {children} ) } export default function CommandBar() { const { currentRootActionId } = useKBar(state => ({ currentRootActionId: state.currentRootActionId, })) return (
{currentRootActionId === null && (
K
)} {currentRootActionId === 'search-actions' && (
/
)}
) } function RenderResults() { const { results, rootActionId } = useMatches() if (!results.length) { return (

No results

) } return ( // kbar gives the fixed container a static height that's 2px too short, unsure why
typeof item === 'string' ? (
{item}
) : ( ) } />
) } const ResultItem = React.forwardRef( ( { action, active, currentRootActionId, }: { action: ActionImpl active: boolean currentRootActionId: ActionId }, ref: React.Ref ) => { const ancestors = React.useMemo(() => { if (!currentRootActionId) return action.ancestors const index = action.ancestors.findIndex( ancestor => ancestor.id === currentRootActionId ) // +1 removes the currentRootAction; e.g. // if we are on the "Set theme" parent action, // the UI should not display "Set theme… > Dark" // but rather just "Dark" return action.ancestors.slice(index + 1) }, [action.ancestors, currentRootActionId]) return (
{action.icon && action.icon}
{ancestors.length > 0 && ancestors.map(ancestor => ( {ancestor.name} ))} {action.name} {action.id === 'docs' && ( )}
{action.subtitle && ( {action.subtitle} )}
{action.shortcut?.length ? (
{action.shortcut.map(sc => ( {sc} ))}
) : null}
) } ) ResultItem.displayName = 'ResultItem' ================ File: src/components/CommandBar/MultilineKBarSearch.tsx ================ import * as React from 'react' import { useKBar, VisualState } from 'kbar' import IVTextArea from '~/components/IVTextArea' export const KBAR_LISTBOX = 'kbar-listbox' export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}` export default function MultilineKBarSearch( props: React.InputHTMLAttributes & { defaultPlaceholder?: string } ) { const { query, search, actions, currentRootActionId, activeIndex, showing, options, } = useKBar(state => ({ search: state.searchQuery, currentRootActionId: state.currentRootActionId, actions: state.actions, activeIndex: state.activeIndex, showing: state.visualState === VisualState.showing, })) const ownRef = React.useRef(null) const { defaultPlaceholder, ...rest } = props React.useEffect(() => { query.setSearch('') if (ownRef.current) { ownRef.current.focus() } return () => query.setSearch('') }, [currentRootActionId, query]) const placeholder = React.useMemo((): string => { const defaultText = defaultPlaceholder ?? 'Type a command or search…' return currentRootActionId && actions[currentRootActionId] ? actions[currentRootActionId].name : defaultText }, [actions, currentRootActionId, defaultPlaceholder]) return ( { props.onChange?.(event) query.setSearch(event.target.value) options?.callbacks?.onQueryChange?.(event.target.value) }} onKeyDown={event => { // shift + enter to create a new line if (event.shiftKey && event.key === 'Enter') { event.stopPropagation() return } // prevent submit if empty if (event.key === 'Enter' && !search) { event.preventDefault() event.stopPropagation() return } props.onKeyDown?.(event) if (currentRootActionId && !search && event.key === 'Backspace') { const parent = actions[currentRootActionId].parent query.setCurrentRootAction(parent) } }} /> ) } ================ File: src/components/Console/index.tsx ================ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { atom, useRecoilState } from 'recoil' import { inferQueryOutput, trpc, useQueryClient } from '~/utils/trpc' import IVSpinner from '~/components/IVSpinner' import ActionsList from '~/components/ActionsList' import ConsoleOnboarding from '~/components/ConsoleOnboarding' import { PendingConnectionIndicator } from '~/components/TransactionUI/_presentation/TransactionLayout' import { UI_STATE } from '~/components/TransactionUI/useTransaction' import { hasRecentlyConnectedToConsole } from '~/pages/dashboard/[orgSlug]/develop' import EmptyState from '~/components/EmptyState' import IconCode from '~/icons/compiled/Code' import { useHasPermission } from '~/components/DashboardContext' import { useOrgParams } from '~/utils/organization' import IVAlert from '~/components/IVAlert' import IconInfo from '~/icons/compiled/Info' import { ActionGroup } from '@prisma/client' import PageHeading from '../PageHeading' import ApiKeyButton from '../APIKeyButton' import PageUI from '../PageUI' import QueuedActionsList from '../QueuedActionsList' function hasVisibleActions(data?: inferQueryOutput<'action.console.index'>) { return !!data?.actions.length || !!data?.groups.length || !!data?.currentPage } export const consoleUIState = atom({ key: 'consoleUIState', default: 'IDLE', }) function ConsoleExplanation() { const { orgSlug } = useOrgParams() return ( Actions you've defined using{' '} your personal development key {' '} will appear here in the Console when your SDK is connected. ) } export default function ConsoleIndex({ mode, slugPrefix, pageTitle, breadcrumbs, canRunActions, }: { canRunActions: boolean mode: 'console' | 'anon-console' slugPrefix?: string pageTitle?: string breadcrumbs?: ActionGroup[] }) { const queryClient = useQueryClient() const [hasRecentlyConnected, setHasRecentlyConnected] = useRecoilState( hasRecentlyConnectedToConsole ) // the cached page is shown while the host is reconnecting const [cachedCurrentPage, setCurrentPage] = useState(null) const [hostState, setHostState] = useRecoilState(consoleUIState) useHasPermission('RUN_DEV_ACTIONS', { redirectToDashboardHome: true }) const [ forceShowInstallationInstructions, setForceShowInstallationInstructions, ] = useState(false) useEffect(() => { if (hostState === 'IN_PROGRESS') { setForceShowInstallationInstructions(false) } }, [hostState]) const actions = trpc.useQuery( [ 'action.console.index', { slugPrefix, }, ], { refetchInterval: data => { // refetch every 1s if you don't have any online actions if (!hasVisibleActions(data)) return 1000 // refetch every 3 seconds otherwise return 3000 }, refetchIntervalInBackground: false, // forces all page components to unmount when switching between pages, // also prevents some cached data from displaying before remote data is received. cacheTime: 0, } ) const ctx = trpc.useContext() const { refetchQueries } = ctx const hasCreatedFirstAction = actions.data?.hasAnyActions ?? false // consider "recently connected" to be true if actions have // appeared on screen during this session. // this is also set to true when a user runs an action. useEffect(() => { if (hasVisibleActions(actions.data)) { setHasRecentlyConnected(true) setCurrentPage(actions.data?.currentPage ?? null) } }, [actions.data, setHasRecentlyConnected]) // refetch navigation when actions begins refetching, e.g. when connecting to a restarting host useEffect(() => { if (actions.isFetching) { refetchQueries(['dashboard.structure', { mode: 'console' }], { exact: true, }) } }, [actions.isFetching, refetchQueries]) // "fake" host state based on presence of online or offline actions. // host is considered "reconnecting" if we've connected during this session but no actions are visible. useEffect(() => { setHostState(prev => { if (hasVisibleActions(actions.data)) { return 'IN_PROGRESS' } if (actions.isLoading && prev === 'IN_PROGRESS') { return 'IN_PROGRESS' } if (hasRecentlyConnected) { return 'HOST_DROPPED' } return 'IDLE' }) }, [actions.data, setHostState, hasRecentlyConnected, actions.isLoading]) const onRefresh = () => { actions.refetch() } if (actions.isLoading) { return (
) } const fallback = hasVisibleActions(actions.data) ? (
{ queryClient.setQueryData(['action.console.index', { slugPrefix }], { ...actions.data, queued: actions.data?.queued.filter(q => q.id !== queuedAction.id) ?? [], }) }} />
) : hasRecentlyConnected ? ( ) : hasCreatedFirstAction && !forceShowInstallationInstructions ? ( setForceShowInstallationInstructions(true), theme: 'plain', className: 'text-primary-500 hover:opacity-70 font-medium', }, { label: 'Retry connection', onClick: onRefresh, theme: 'plain', className: 'text-primary-500 hover:opacity-70 font-medium', disabled: actions.isRefetching, }, ]} >

Actions will appear here when your development server comes online.

Use your personal development key to connect:

) : (
) const currentPage = actions.data?.currentPage ?? cachedCurrentPage if (currentPage?.hasHandler) { return ( ) } return fallback } ================ File: src/components/ConsoleOnboarding/index.tsx ================ import dedent from 'ts-dedent' import HighlightedCodeBlock, { HighlightedCodeBlockProps, } from '~/components/HighlightedCodeBlock' import { trpc } from '~/utils/trpc' import { useParams, useLocation, useNavigate } from 'react-router-dom' import Transition from '../Transition' import { useState } from 'react' import classNames from 'classnames' import { useMe } from '~/components/MeContext' import IVInputField from '~/components/IVInputField' import IVSelect from '~/components/IVSelect' import { examples } from '~/utils/examples' import IconCode from '~/icons/compiled/Code' function InstructionsContainer(props: { show?: boolean children: React.ReactNode }) { return (
{props.children}
) } function CompletionMessage() { return (

That's it! Once you start your app, this message will disappear and you'll be able to run the actions you're developing. Installation instructions and code samples will remain available{' '} in the docs .

) } function CodeSnippet(props: { children: React.ReactNode }) { return
{props.children}
} const codeBlockProps: Partial = { theme: 'light', canDownload: false, } function InstallToExisting({ language }: { language: string }) { const key = trpc.useQuery(['key.dev']) // const navigate = useNavigate() let mainCode = '' let mainFileName: string | undefined = undefined let actionCode: string | undefined = undefined let actionFileName: string | undefined = undefined // generate endpoint URL const hostname = window.location.hostname const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' const endpoint = `"ws${isLocal ? '' : 's'}://${hostname}${ isLocal ? `:${window.location.port}` : '' }/websocket"` switch (language) { case 'typescript': mainFileName = 'src/forge.ts' actionFileName = 'src/routes/hello_world.ts' mainCode = dedent(` import path from "path"; import { Forge } from "@forgeapp/sdk"; const forge = new Forge({ endpoint: ${endpoint}, apiKey: "${key?.data?.key || ''}", routesDirectory: path.resolve(__dirname, "routes"), }); forge.listen(); `) actionCode = dedent(` import { Action, io } from "@forgeapp/sdk"; export default new Action(async () => { const name = await io.input.text("Your name"); return \`Hello, $\{name}\`; }); `) break case 'javascript': mainFileName = 'src/forge.js' actionFileName = 'src/routes/hello_world.js' mainCode = dedent(` const path = require("path"); const { Forge } = require("@forgeapp/sdk"); const forge = new Forge({ endpoint: ${endpoint}, apiKey: "${key?.data?.key || ''}", routesDirectory: path.resolve(__dirname, "routes"), }); forge.listen(); `) actionCode = dedent(` const { Action, io } = require("@forgeapp/sdk"); module.exports = new Action(async () => { const name = await io.input.text("Your name"); return \`Hello, $\{name}\`; }); `) break case 'python': mainCode = dedent(` import os from forgeapp_sdk import Forge, IO forge = Forge( endpoint=${endpoint}, api_key="${key?.data?.key || ''}", ) @forge.action async def hello_world(io: IO): name = await io.input.text("Your name") return f"Hello {name}" forge.listen() `) break } return (

2. Create your first action

Actions are created from within your codebase and will appear here when your SDK listener comes online. To create your first action, copy the following example code into your project:

{actionCode && ( )}
) } function LanguageTab({ label, isActive, onClick }) { return ( ) } function InstallToNew({ onboardingExampleSlug, language, }: { language: 'typescript' | 'javascript' | 'python' onboardingExampleSlug: string | null }) { const navigate = useNavigate() const key = trpc.useQuery(['key.dev']) const [selectedTemplate, setSelectedTemplate] = useState( onboardingExampleSlug || 'basic' ) const keyArg = key?.data ? `--personal_development_key=${key.data.key}` : '' const languageArg = `--language=${language}` const templateArg = `--template=${selectedTemplate}` const command = ['npx create-forge-app', templateArg, languageArg, keyArg] .join(' ') .replace(/\s+/g, ' ') const templateOptions = [ { label: 'Start from scratch', value: 'basic' }, ...examples.map(e => ({ label: e.label, value: e.id })), ] return (

1. Create a new Forge project

Generate the scaffolding for a blank slate Forge app, or select a template to start with{' '} one of our pre-built example tools .

{language === 'python' ? (

This command uses JavaScript command line tools to create the project from a template. If you don't have Node installed, you can{' '} navigate(location.pathname, { state: 'installExisting' }) } className="text-primary-500 font-medium hover:opacity-60 cursor-pointer" > add Forge to an existing codebase {' '} or{' '} clone the templates directly .

) : (

You can also{' '} navigate(location.pathname, { state: 'installExisting' }) } className="text-primary-500 font-medium hover:opacity-60 cursor-pointer" > add Forge to an existing codebase instead .

)} { setSelectedTemplate(e.target.value) }} options={templateOptions} defaultValue={selectedTemplate} />

2. Start your app

) } const onboardingScreens = ['installNew', 'installExisting'] export default function ConsoleOnboarding() { const location = useLocation() const { orgSlug } = useParams() const { me } = useMe() const onboardingExampleSlug = me?.userOrganizationAccess.find( uoa => uoa.organization.slug === orgSlug )?.onboardingExampleSlug let currentScreen = location.state ? location.state : 'installNew' if (!onboardingScreens.includes(String(currentScreen))) { currentScreen = 'installNew' } const [language, setLanguage] = useState< 'javascript' | 'typescript' | 'python' >('typescript') return (

Tools in Forge are called{' '} actions {' '} and created using the Forge SDK. Follow the steps below to install the SDK in your existing project and start building actions.

setLanguage('typescript')} /> setLanguage('javascript')} /> setLanguage('python')} />
{/* {currentScreen === 'installNew' && ( */} {/* */} {/* )} */} {(currentScreen === 'installExisting' || currentScreen === 'installNew') && ( )}
) } ================ File: src/components/DashboardLayout/index.tsx ================ import { Suspense } from 'react' import { Outlet } from 'react-router-dom' import { MeProvider } from '~/components/MeContext' import { DashboardProvider } from '../DashboardContext' import IVSpinner from '~/components/IVSpinner' import { WebSocketClientProvider } from '../TransactionUI/useWebSocketClient' // This contains a base Suspense wrapper as a fallback, but should // likely add one closer to any lazy components if used. export default function DashboardLayout() { return ( }> ) } ================ File: src/components/DashboardNav/ControlPanel.tsx ================ import classNames from 'classnames' import { Link } from 'react-router-dom' import { MenuButton, Menu, useMenuState, MenuItem as ReakitMenuItem, } from 'reakit' import SettingsIcon from '~/icons/compiled/Settings' import useDashboard from '../DashboardContext' import Transition from '../Transition' import { dropdownMenuClassNames } from '~/components/DropdownMenu' import { logout } from '~/utils/auth' import useControlPanelNav, { ControlPanelMenuItem } from './useControlPanelNav' import IVAvatar from '../IVAvatar' import { logger } from '~/utils/logger' function MenuItem({ menu, isCurrentPage, path, name, onClick, spaceAbove, }: { menu: ReturnType path?: string name: string isCurrentPage?: boolean spaceAbove?: boolean onClick?: () => void }) { const baseClassName = classNames( 'group flex items-center w-full py-1 text-gray-600 dark:text-white hover:text-opacity-60 focus:text-opacity-60 text-left focus:outline-none', { 'mt-4': spaceAbove, } ) if (path?.endsWith('/logout')) { return ( { logout() .then(() => window.location.assign('/')) .catch(error => { logger.error('Error logging user out', { error }) }) }} > {name} ) } if (onClick) { return ( {name} ) } return ( menu.hide()} > {name} ) } function MenuGroup({ title, items, menu, }: { title: string items: ControlPanelMenuItem[] menu: ReturnType }) { return (

{title}

{items.map(item => ( ))}
) } function Footer() { return ( <> Documentation
Visit the Forge docs ›
) } export default function ControlPanel() { const { me } = useDashboard() const menu = useMenuState({ animated: 250, modal: true, gutter: 4, placement: 'bottom-end', }) const { orgNav, actionsNav, userNav } = useControlPanelNav() return (
e.stopPropagation()} className="bg-background shadow-dropdown rounded-lg border border-[#D9DEEF] focus:outline-none text-[13px] overflow-hidden" >
{me?.firstName} {me?.lastName}
{me?.email}
{me.isEmailConfirmationRequired && (
Confirm your email {' '} to create and deploy live actions.
)} {orgNav.length > 0 ? ( <>
 
) : ( <>
)}
) } ================ File: src/components/DashboardNav/index.tsx ================ import classNames from 'classnames' import { NavLink, useLocation } from 'react-router-dom' import { useOrgParams } from '~/utils/organization' import ControlPanel from './ControlPanel' import ModeSwitch from './ModeSwitch' import { useTopNavState } from '~/utils/useDashboardStructure' import { ActionGroupWithPossibleMetadata } from '~/utils/types' import { useEffect, useRef, useState } from 'react' import IconCaretDown from '~/icons/compiled/CaretDown' import { Menu, MenuButton, useMenuState } from 'ariakit' import Transition from '../Transition' import { dropdownMenuClassNames } from '~/components/DropdownMenu' import { useDebouncedCallback } from 'use-debounce' import MobileNav from './MobileNav' import IconMenu from '~/icons/compiled/Menu' import useControlPanelNav from './useControlPanelNav' import OrgSwitcher from './OrgSwitcher' import useDashboard from '../DashboardContext' import { ENV_COLOR_OPTIONS } from '~/utils/color' import { VisualState, useKBar } from 'kbar' import SearchIcon from '~/icons/compiled/Search' import { DarkModeToggle } from '../dark-mode-toggle' export const NAV_ITEM_HEIGHT = 54 export const NAVBAR_HEIGHT = NAV_ITEM_HEIGHT + 1 // 1px border const MORE_ITEM_WIDTH = 84 function getFittingNavItems( container: HTMLElement, topLevelGroups: ActionGroupWithPossibleMetadata[] ): ActionGroupWithPossibleMetadata[] { const spaceTarget = container.offsetWidth const items = Array.from(container.children) const sizes = items.map(li => li.getBoundingClientRect()) let spaceUsed = 0 const canFitAllItems = sizes.reduce((acc, size) => acc + size.width, 0) < spaceTarget if (canFitAllItems) return topLevelGroups return sizes.reduce((acc, size, index) => { if (spaceUsed + size.width + MORE_ITEM_WIDTH < spaceTarget) { spaceUsed += size.width // don't push the first item, which is 'Dashboard' and is not in topLevelGroups if (index > 0) { acc.push(topLevelGroups[index - 1]) } } return acc }, [] as ActionGroupWithPossibleMetadata[]) } export function getTopLevelGroups(data?: ActionGroupWithPossibleMetadata[]) { if (!data) return [] const topLevelGroups = data.filter(g => !g.slug.includes('/') && !g.unlisted) return topLevelGroups } export function useIsSettingsPage() { const { pathname } = useLocation() const { orgNav, userNav, actionsNav } = useControlPanelNav() const paths = [ ...orgNav.map(g => g.path), ...userNav.map(g => g.path), ...actionsNav.map(g => g.path), ] return paths.some(p => pathname.startsWith(p)) } function Navbar({ groups, isSettingsPage, }: { groups?: ActionGroupWithPossibleMetadata[] isSettingsPage: boolean }) { const { basePath } = useOrgParams() // To avoid getting stuck in a display-adjust-display loop, we use two elements, a "ghost nav" // and a visible nav. The ghost nav calculates what we can fit on screen, and the visible nav renders it. const ghostNavRef = useRef(null) const [visibleItems, setVisibleItems] = useState< ActionGroupWithPossibleMetadata[] | undefined >(undefined) const menu = useMenuState({ animated: 250, gutter: -6 }) const topLevelGroups = getTopLevelGroups(groups) const navLinkClassName = ({ isActive, className, }: { isActive: boolean className?: string }) => { return classNames( 'text-sm cursor-pointer flex items-center focus:outline-none font-medium px-4 rounded-md', className, { 'bg-opacity-50 text-primary-400 border-none': isActive, 'text-gray-900 dark:text-white hover:text-opacity-60 border-none': !isActive, } ) } const calculate = useDebouncedCallback(() => { if (!ghostNavRef.current) return if (!topLevelGroups.length) { setVisibleItems([]) } else { setVisibleItems(getFittingNavItems(ghostNavRef.current, topLevelGroups)) } }, 100) useEffect(() => { calculate() window.addEventListener('resize', calculate) return () => window.removeEventListener('resize', calculate) }, [calculate, groups]) return ( ) } function CommandBarToggle() { const { query, visible } = useKBar(state => ({ visible: state.visualState !== VisualState.hidden, })) return ( ) } export default function DashboardNav() { const { isDevMode } = useOrgParams() const { organizationEnvironment } = useDashboard() const [isSidebarOpen, setIsSidebarOpen] = useState(false) const groups = useTopNavState({ mode: isDevMode ? 'console' : 'live', }) const isSettingsPage = useIsSettingsPage() const hasEnvColor = !!organizationEnvironment?.color return ( <> setIsSidebarOpen(false)} groups={groups} mode={isDevMode ? 'console' : 'live'} />
{organizationEnvironment?.color && !isSettingsPage && (
{organizationEnvironment?.name}
)}
) } ================ File: src/components/DashboardNav/MobileNav.tsx ================ import classNames from 'classnames' import { Link, NavLink } from 'react-router-dom' import { logout } from '~/utils/auth' import Transition from '../Transition' import IVAvatar from '../IVAvatar' import useDashboard from '../DashboardContext' import IconCancel from '~/icons/compiled/Cancel' import IconSettings from '~/icons/compiled/Settings' import { useOrgParams } from '~/utils/organization' import { useState } from 'react' import { getNavHref } from '~/utils/navigation' import { ActionGroupWithPossibleMetadata, ActionMode } from '~/utils/types' import { getTopLevelGroups } from '.' import MobileEnvSwitcher from '../Sidebar/OrgSwitcher' interface MobileMenuProps { isOpen: boolean onClose: () => void groups?: ActionGroupWithPossibleMetadata[] mode: ActionMode } function Backdrop(props: MobileMenuProps) { return (
) } function CloseButton(props: MobileMenuProps) { return (
) } export default function MobileNav(props: MobileMenuProps) { const { isOpen, onClose } = props const { orgEnvSlug, basePath } = useOrgParams() const [isSwitcherVisible, setIsSwitcherVisible] = useState(false) const { me } = useDashboard() const topLevelGroups = getTopLevelGroups(props.groups) const navLinkClassName = ({ isActive, className, }: { isActive: boolean className?: string }) => { return classNames( 'text-base cursor-pointer flex items-center focus:outline-none font-medium px-4 py-2.5 rounded-md', className, { 'bg-opacity-50 text-primary-400 border-none': isActive, 'text-gray-900 dark:text-white hover:text-opacity-60 border-none': !isActive, } ) } return (
{/* TODO: the "in" transition doesn't work here, can't figure out why. */}
{me?.firstName} {me?.lastName}
{me?.email}
{/* Force sidebar to shrink to fit close icon */}
) } ================ File: src/components/DashboardNav/ModeSwitch.tsx ================ import React, { useEffect, useMemo, useRef, useCallback } from 'react' import { Link } from 'react-router-dom' import { Menu, MenuButton, MenuItem, useMenuState } from 'reakit' import classNames from 'classnames' import { useOrgParams } from '~/utils/organization' import { useRecoilState } from 'recoil' import { consoleUIState } from '../Console' import useDashboard, { useHasPermission } from '../DashboardContext' import Transition from '../Transition' import { dropdownMenuClassNames } from '~/components/DropdownMenu' import useCopyToClipboard from '~/utils/useCopyToClipboard' import { trpc } from '~/utils/trpc' import IconClipboard from '~/icons/compiled/Clipboard' import { notify } from '~/components/NotificationCenter' import { UI_STATE } from '~/components/TransactionUI/useTransaction' import { DEVELOPMENT_ORG_ENV_NAME, DEVELOPMENT_ORG_ENV_SLUG, } from '~/utils/environments' import IconChevronDown from '~/icons/compiled/ChevronDown' import SettingsIcon from '~/icons/compiled/Settings' import useEnvSwitcher from '~/utils/useOrgEnvSwitcher' import EnvironmentColor from '~/components/EnvironmentColor' import { useDebounce } from 'use-debounce' import IVSpinner from '~/components/IVSpinner' function ApiKeyButton({ apiKey }: { apiKey: string }) { const { isCopied, onCopyClick } = useCopyToClipboard() useEffect(() => { if (isCopied) { notify.success('Copied token to clipboard') } }, [isCopied]) return ( ) } function ConnectionIndicator({ state }: { state: UI_STATE | null }) { return ( ) } function ConnectionStatus({ state }: { state: UI_STATE }) { if (state === 'CONNECTING' || state === 'HOST_DROPPED') { return } if ( state === 'COMPLETED' || state === 'USURPED' || // usurped is still online state === 'IN_PROGRESS' ) { return Online } if ( state === 'HOST_NOT_FOUND' || state === 'SERVER_DROPPED' || state === 'IDLE' ) { return Offline } return <> } export default function ModeSwitch() { const { orgEnvSlug, envSlug, isDevMode, orgSlug } = useOrgParams<{ '*': string }>() const { organization } = useDashboard() const [hostState] = useRecoilState(consoleUIState) const canAccessEnvironments = useHasPermission('ACCESS_ORG_ENVIRONMENTS') const canUpdateEnvironments = useHasPermission('WRITE_ORG_SETTINGS') const { currentEnvName, envOptions, switchToEnvironment, isNonEnvPage } = useEnvSwitcher({ organization, orgEnvSlug, envSlug, }) // we use this data when the user is in live mode. // when in dev mode, we use the websocket connection's status from consoleUIState. const status = trpc.useQuery(['dashboard.dev-host-status']) const derivedStatus = useMemo(() => { if (isDevMode) { if ( hostState === 'COMPLETED' || hostState === 'USURPED' || hostState === 'IN_PROGRESS' ) { return 'IN_PROGRESS' } if (hostState === 'HOST_NOT_FOUND' || hostState === 'SERVER_DROPPED') { return 'SERVER_DROPPED' } if (hostState === 'HOST_DROPPED') { return 'HOST_DROPPED' } return 'IDLE' } if (status?.data?.hasOnlineDevHost) { return 'IN_PROGRESS' } return 'IDLE' }, [isDevMode, hostState, status.data]) const menu = useMenuState({ animated: 250, gutter: 4, placement: 'bottom-end', }) const currentItemRef = useRef(null) useEffect(() => { if (menu.visible) { currentItemRef.current?.focus() } }, [menu.visible]) const onMenuButtonClick = useCallback( (event: React.MouseEvent) => { // hold down meta key to automatically toggle between prod & dev if (event.metaKey || event.ctrlKey) { event.preventDefault() switchToEnvironment( currentEnvName === DEVELOPMENT_ORG_ENV_NAME ? null : DEVELOPMENT_ORG_ENV_SLUG ) } }, [currentEnvName, switchToEnvironment] ) // wait until menu is hidden before showing dev host status + key const [debounced_isDevMode] = useDebounce(isDevMode, 250) if (envOptions.length <= 1) return null if (!canAccessEnvironments) return null if (isNonEnvPage) return null return (
{isDevMode && } {currentEnvName}
e.stopPropagation()} className="bg-background shadow-dropdown rounded-lg border border-[#D9DEEF] focus:outline-none text-[13px] overflow-hidden w-[270px] pb-1" >

Environments

{canUpdateEnvironments && ( )}
{envOptions.map(env => ( { e.preventDefault() menu.hide() switchToEnvironment(env.path) }} ref={env.isCurrent ? currentItemRef : undefined} className={classNames( 'px-2.5 py-1.5 text-sm w-full flex items-center justify-between focus:outline-none rounded-md', { 'bg-primary-50 bg-opacity-50 dark:bg-white dark:bg-opacity-100 text-primary-500 font-medium': env.isCurrent, 'text-gray-700 dark:text-white focus:bg-gray-50 hover:bg-gray-50 dark:focus:bg-muted/50 dark:hover:bg-muted/50': !env.isCurrent, } )} > {env.name} {env.name === DEVELOPMENT_ORG_ENV_NAME && ( )} ))}
{debounced_isDevMode && (

Personal development key:

The Development environment is unique to your user account.{' '} Learn more ›

)}
) } ================ File: src/components/DashboardNav/OrgSwitcher.tsx ================ import { Link } from 'react-router-dom' import { Menu, useMenuState, MenuItem, MenuButton, MenuStateReturn, } from 'reakit' import classNames from 'classnames' import IconCheck from '~/icons/compiled/Check' import { useOrgSwitcher } from '~/utils/useOrgSwitcher' import Transition from '../Transition' import { dropdownMenuClassNames } from '~/components/DropdownMenu' import { NAV_ITEM_HEIGHT } from '.' import IconCaretDown from '~/icons/compiled/CaretDown' import React from 'react' function MenuHeader({ label }) { return (

{label}

) } function MenuListItem({ onClick, label, isActive, ...menu }: MenuStateReturn & { onClick?: (event: React.MouseEvent) => void label: string isActive?: boolean }) { return ( {isActive && ( )} {label} ) } export default function OrgSwitcher() { const switcherState = useOrgSwitcher() const { me, organization: currentOrg, setOrg } = switcherState const menu = useMenuState({ animated: 250, gutter: -4 }) const otherOrgs = me?.userOrganizationAccess.filter( access => access.organizationId !== currentOrg.id ) ?? [] return (
{currentOrg.name}
{otherOrgs.length > 0 && (
{otherOrgs.map(({ organization }) => (
{ if (e.metaKey || e.ctrlKey) { window.open( `/dashboard/${organization.slug}/actions` ) menu.hide() } else { setOrg(organization) } }} label={organization.name} isActive={currentOrg.id === organization.id} />
))}
)} Create organization...
) } ================ File: src/components/DotsSpinner/index.tsx ================ import React, { useState, useEffect } from 'react' export default function DotsSpinner() { const [value, setValue] = useState<[number, string]>([0, '⠋']) useEffect(() => { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const t = setInterval(function () { setValue(([frame]) => { frame = frame + 1 === frames.length ? 0 : frame + 1 return [frame, frames[frame]] }) }, 80) return () => clearInterval(t) }, []) return <>{value[1]} } ================ File: src/components/DropdownMenu/index.tsx ================ import { useIsomorphicLink } from '~/utils/useIsomorphicLocation' import { getCurrentPath } from '~/utils/url' import classNames from 'classnames' import { Menu, MenuButton, MenuInitialState, MenuItem, MenuItemProps, MenuStateReturn, useMenuState, } from 'reakit' export const dropdownMenuClassNames = { enter: 'transition-all ease-in-menu duration-200', enterFrom: 'scale-95 opacity-0', enterTo: 'scale-100 opacity-100', leave: 'transition-all ease-in duration-200', leaveFrom: 'scale-100 opacity-100', leaveTo: 'scale-95 opacity-0', } export type DropdownMenuItemProps = { label: string | React.ReactElement disabled?: boolean theme?: 'default' | 'danger' onClick?: () => void url?: string newTab?: boolean path?: string } type DropdownMenuProps = { children: React.ReactElement options: DropdownMenuItemProps[] disabled?: boolean buttonClassName?: string menuClassName?: string title: string } & Pick export function DropdownMenuItem( props: DropdownMenuItemProps & { menu: MenuStateReturn } ) { const { theme = 'default' } = props const Link = useIsomorphicLink() const shared: MenuItemProps = { ...props.menu, disabled: props.disabled, className: classNames( 'text-sm block w-full text-left px-3 py-1.5 hover:bg-blue-50 focus:bg-blue-50 dark:hover:bg-white dark:focus:bg-white focus:outline-none', { 'text-gray-600 dark:text-white hover:text-gray-800 focus:text-gray-800 dark:hover:text-black dark:focus:text-black': theme === 'default', 'text-red-600': theme === 'danger' && !props.disabled, 'text-gray-600 text-opacity-60': props.disabled, } ), } const path = props.path if (path) { // prevents a loading state flash on ActionsPage while we check if the action exists const title = props.path?.split('?')[0] ?? props.label return ( // @ts-ignore: This is correct according to their docs but their types aren't right // For some reason using `as` doesn't work with the state prop {innerProps => ( {props.label} )} ) } if (props.onClick) { return ( { if (props.onClick) props.onClick() props.menu.hide() }} children={props.label} /> ) } if (props.url) { return ( ) } return } export default function DropdownMenu(props: DropdownMenuProps) { const menu = useMenuState({ placement: props.placement ?? 'auto-end', modal: props.modal, gutter: 2, }) return ( <> { // prevents triggering click event on parent element(s), e.g. table rows e.stopPropagation() }} > Open options {props.children}
{ e.stopPropagation() }} className="bg-background py-1 shadow-dropdown rounded-lg border border-[#D9DEEF] focus:outline-none" > {props.options.map((opt, idx) => ( ))}
) } ================ File: src/components/EmptyState/index.tsx ================ import IVButton, { IVButtonProps } from '~/components/IVButton' export default function EmptyState({ title, children, actions = [], Icon, }: { title: string children?: React.ReactNode actions?: IVButtonProps[] Icon?: React.ComponentType> }) { return (
{Icon && }

{title}

{children}
{actions.map((button, key) => ( ))}
) } ================ File: src/components/ErrorMessage/index.tsx ================ export default function ErrorMessage({ id, message, }: { id?: string message: React.ReactNode }) { return (

{message}

) } ================ File: src/components/examples/FormValidation.stories.tsx ================ import { StoryFn } from '@storybook/react' import IVCheckbox from '~/components/IVCheckbox' import IVInputField from '~/components/IVInputField' import IVSelect from '~/components/IVSelect' import IVTextInput from '~/components/IVTextInput' function FormValidationExamples() { return (
) } export default { title: 'Examples/FormValidation', component: FormValidationExamples, } const Template: StoryFn = () => ( ) export const Default = Template.bind({}) ================ File: src/components/FileUploadButton/FileUploadButton.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import FileUploadButton from '.' export default { title: 'Components/FileUploadButton', component: FileUploadButton, } as Meta const Template: StoryFn = args => ( ) export const Default = Template.bind({}) Default.args = { id: 'default', currentStep: 'default', onChange: () => { /* */ }, } export const Uploading = Template.bind({}) Uploading.args = { id: 'uploading', currentStep: 'uploading', onChange: () => { /* */ }, } export const Success = Template.bind({}) Success.args = { id: 'success', currentStep: 'success', value: [new File([], '62d7e4dc-d58e-4f15-a8e4-29fc15c26281.png')], onChange: () => { /* */ }, onReset: () => { /* */ }, } export const Error = Template.bind({}) Error.args = { id: 'error', currentStep: 'error', onChange: () => { /* */ }, } ================ File: src/components/FileUploadButton/index.tsx ================ import classNames from 'classnames' import IVSpinner from '~/components/IVSpinner' import { useState } from 'react' import CheckIcon from '~/icons/compiled/Check' import UploadsFolderIcon from '~/icons/compiled/UploadsFolder' import { preventDefaultInputEnterKey } from '~/utils/preventDefaultInputEnter' export type UploadStep = 'default' | 'uploading' | 'success' | 'error' interface FileUploadButtonProps { id: string dropZoneClassName?: string onChange: (event: React.ChangeEvent) => void currentStep: UploadStep showUploadStatus: boolean accept?: string value?: File[] disabled?: boolean clickToSelectLabel?: React.ReactNode description?: React.ReactNode onReset?: () => void multiple?: boolean } function truncateFile(file: File, index: number) { const parts = file.name.split('.') const extension = parts.pop() const name = parts.join('.') return (
{name}. {extension}
) } function selectedTitle(files: File[]) { if (files.length === 1) { return 'File selected' } return `${files.length} files selected` } export default function FileUploadButton({ id, dropZoneClassName, currentStep, onChange, showUploadStatus, accept = '*', value, disabled, clickToSelectLabel, description, onReset, multiple = false, }: FileUploadButtonProps) { const [isDragging, setIsDragging] = useState(false) let title: React.ReactNode = value && value.length > 0 ? selectedTitle(value) : clickToSelectLabel ?? ( <> Select a file {' '} or drag and drop ) switch (currentStep) { case 'uploading': if (showUploadStatus) { title = 'Uploading...' description = "Please don't close this window or navigate away from this page." } break case 'error': if (!description) { description = 'Sorry, there was a problem uploading your file. Please try again.' } break case 'success': if (showUploadStatus) { title = 'Upload complete' } break } let icon = if (currentStep === 'success' && showUploadStatus) { icon = } else if (currentStep === 'uploading' && showUploadStatus) { icon = } return (
{/* approximates height of icon + title + description so UI doesn't change height */}
{icon}

{title}

{description ?? value?.map(truncateFile) ?? ''}
{(currentStep === 'default' || currentStep === 'error') && ( setIsDragging(true)} onDragEnter={() => setIsDragging(true)} onDragLeave={() => setIsDragging(false)} onDragEnd={() => setIsDragging(false)} onDrop={() => setIsDragging(false)} onKeyDown={preventDefaultInputEnterKey} /> )}
{currentStep !== 'default' && onReset && (
) } ================ File: src/components/HelpText/HelpText.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'Components/HelpText', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { children: 'This is help text, it typically appears below a form field.\n\nLine breaks are supported.', } ================ File: src/components/HelpText/index.tsx ================ import classNames from 'classnames' import RenderMarkdown, { ALLOWED_INLINE_ELEMENTS } from '../RenderMarkdown' export interface ComponentHelpTextProps { id?: string className?: string children: string | React.ReactNode } export default function ComponentHelpText(props: ComponentHelpTextProps) { const isMarkdown = typeof props.children === 'string' return (
{isMarkdown ? ( ) : ( props.children )}
) } ================ File: src/components/HighlightedCodeBlock/index.tsx ================ import React, { useState, useMemo, useEffect, useCallback } from 'react' import download from 'downloadjs' import classNames from 'classnames' import { toast as notify } from 'react-hot-toast' import type Hljs from 'highlight.js/lib/common' import useCopyToClipboard from '~/utils/useCopyToClipboard' import { atom, useRecoilState } from 'recoil' import { localStorageRecoilEffect } from '~/utils/localStorage' import CheckIcon from '~/icons/compiled/Check' import CopyIcon from '~/icons/compiled/Copy' import DownloadIcon from '~/icons/compiled/DownloadsFolder' import { useTheme } from '../theme-provider' const packageManagerState = atom<'npm' | 'yarn'>({ key: 'packageManager', default: 'npm', effects: [localStorageRecoilEffect('iv_packageManager')], }) export interface HighlightedCodeBlockProps { code: string language?: string canCopy?: boolean canDownload?: boolean fileName?: string extension?: string className?: string shouldReplacePackageManager?: boolean shouldDisplayFileName?: boolean theme?: 'dark' | 'light' } function guessExtension(language?: string) { // based on documented languages accepted for io.display.code switch (language) { case undefined: return 'txt' case 'csharp': return 'cs' case 'javascript': return 'js' case 'typescript': return 'ts' case 'rust': return 'rs' case 'python': case 'python-repl': return 'py' case 'markdown': return 'md' case 'kotlin': return 'kt' case 'plaintext': return 'txt' case 'ruby': return 'rb' case 'shell': return 'sh' case 'swift': return 'swe' case 'php-template': return 'php' case 'makefile': return 'Makefile' default: return language } } export default function HighlightedCodeBlock({ code, language, shouldReplacePackageManager = true, shouldDisplayFileName = true, canCopy = true, canDownload = true, fileName, extension, className, }: HighlightedCodeBlockProps) { const { isDark } = useTheme() const theme = isDark ? 'dark' : 'light' const [packageManager, setPackageManager] = useRecoilState(packageManagerState) const doesIncludePackageManager = useMemo( () => new RegExp(/(npm |npx )/).test(code), [code] ) const formattedCode = useMemo(() => { let formatted = code if (shouldReplacePackageManager && packageManager === 'yarn') { // order is important! // global install: formatted = formatted.replace(/npm install -g/, 'yarn global add') formatted = formatted.replace(/npm i -g/, 'yarn global add') // install all: formatted = formatted.replace(/npm install &&/, 'yarn &&') formatted = formatted.replace(/npm i &&/, 'yarn &&') formatted = formatted.replace(/^npm install$/, 'yarn') // install specific: formatted = formatted.replace(/npm install /, 'yarn add ') formatted = formatted.replace(/npm i /, 'yarn add ') // yarn implied run: formatted = formatted.replace(/npm run/, 'yarn') // create formatted = formatted.replace(/npx create-/, 'yarn create ') } return formatted }, [code, shouldReplacePackageManager, packageManager]) const { onCopyClick, isCopied } = useCopyToClipboard() const handleDownload = useCallback(() => { try { download( formattedCode, `${fileName ?? 'code'}.${extension ?? guessExtension(language)}`, 'text/plain' ) } catch (err) { console.error('Failed generating download', err) notify.error('Failed generating the download.') } }, [formattedCode, fileName, extension, language]) return (
{shouldDisplayFileName && fileName && (
{fileName}
)}
            
          
{canCopy && (
)} {canDownload && (
)}
{shouldReplacePackageManager && doesIncludePackageManager && ( )}
) } interface HighlightedCodeElements extends React.HTMLAttributes { code: string language?: string theme: 'dark' | 'light' } interface CodeTheme { '--mono1': string '--hue2': string '--hue3': string '--hue4': string '--entity': string '--substr': string '--constant': string } interface CodeThemes { dark: CodeTheme light: CodeTheme } const themes: CodeThemes = { dark: { '--mono1': '#abb2bf', '--hue2': '#c792ea', '--hue3': '#f78c6c', '--hue4': '#a5d6ff', '--constant': '#79c0ff', '--entity': '#79c0ff', '--substr': '#c9d1d9', }, light: { '--mono1': '#383a42', '--hue2': '#D94856', '--hue3': '#2F30B0', '--hue4': '#E52D7D', '--constant': '#3B3C36', '--entity': '#38ADAB', '--substr': '#3B3C36', }, } export function HighlightedCodeElement({ code, language, className, theme, ...rest }: HighlightedCodeElements) { const [hljs, setHljs] = useState(null) useEffect(() => { import('highlight.js/lib/common') .then(r => { setHljs(r.default) }) .catch(err => { console.error('Failed loading highlight.js', err) }) }, []) const __html = useMemo(() => { if (hljs) { if (language) { try { return hljs.highlight(code, { language }).value } catch (err) { console.error( `Failed highlighting with given language "${language}", falling back to auto`, err ) } } return hljs.highlightAuto(code).value } return code }, [hljs, code, language]) return ( ) } ================ File: src/components/HostStatusIndicator/index.tsx ================ import classNames from 'classnames' import { useState, useEffect } from 'react' import { UI_STATE } from '../TransactionUI/useTransaction' export default function HostStatusIndicator({ state, }: { state: UI_STATE | null }) { const [lastStatusStr, setLastStatusStr] = useState('') useEffect(() => { let statusStr = '' if ( state === 'COMPLETED' || state === 'USURPED' || // usurped is still online state === 'IN_PROGRESS' ) { statusStr = 'Connected' } else if (state === 'HOST_DROPPED') { statusStr = 'Reconnecting...' } else if ( state === 'HOST_NOT_FOUND' || state === 'SERVER_DROPPED' || state === 'IDLE' ) { statusStr = 'Offline' } else if (state === 'CONNECTING') { statusStr = 'Connecting' } if (statusStr) setLastStatusStr(statusStr) }, [state]) return (
Host status:  {lastStatusStr}
) } ================ File: src/components/io-methods/Confirm/Confirm.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Confirm', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Delete this user account?', onUpdatePendingReturnValue: () => { /**/ }, } export const WithHelpText = Template.bind({}) WithHelpText.args = { ...Default.args, helpText: 'All of their data will be deleted immediately and cannot be recovered.', } export const Inline = Template.bind({}) Inline.args = { ...Default.args, shouldUseAppendUi: true, } export const InlineWithHelpText = Template.bind({}) InlineWithHelpText.args = { ...Inline.args, helpText: 'All of their data will be deleted **immediately** and cannot be recovered.\n\n- Account will be deleted\n- All of their data will be deleted\n- This cannot be undone\n\nSee here for more info: https://docs.forgeapp.io/', } ================ File: src/components/io-methods/Confirm/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' import IVButton from '~/components/IVButton' import IVDialog, { useDialogState } from '~/components/IVDialog' import XCircleIcon from '~/icons/compiled/XCircle' import ErrorCircleIcon from '~/icons/compiled/ErrorCircle' import CheckCircleIcon from '~/icons/compiled/CheckCircleOutline' import RenderMarkdown, { ALLOWED_INLINE_ELEMENTS, } from '~/components/RenderMarkdown' const AFFIRMATIVE_RESPONSE_LABEL = 'Confirm' const NEGATIVE_RESPONSE_LABEL = 'Cancel' function InlineConfirmPrompt( props: RCTResponderProps<'CONFIRM'> & { onRespond: (value: boolean) => void } ) { if (props.isCurrentCall === false) { const Icon = props.value ? CheckCircleIcon : XCircleIcon return (

{props.label}

{props.value ? AFFIRMATIVE_RESPONSE_LABEL : NEGATIVE_RESPONSE_LABEL}
) } return (

{props.label}

{props.helpText && (
)}
props.onRespond(true)} disabled={props.isSubmitting} loading={props.isSubmitting && props.value === true} autoFocus /> props.onRespond(false)} disabled={props.isSubmitting} loading={props.isSubmitting && props.value === false} />
) } export default function Confirm(props: RCTResponderProps<'CONFIRM'>) { const dialog = useDialogState({ visible: !props.disabled && !props.shouldUseAppendUi, // non-modals are rendered within their parent component, not a modal: props.context === 'transaction', }) const onRespond = (value: boolean) => { props.onUpdatePendingReturnValue(value) if (props.context !== 'docs') dialog.hide() } if (props.shouldUseAppendUi) { return (
) } return ( {props.helpText && (
)}
onRespond(true)} autoFocus /> onRespond(false)} />
) } ================ File: src/components/io-methods/display-chart/display-chart.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Chart', component: Component, } as Meta const chartData = [ { month: 'January', desktop: 186, mobile: 80 }, { month: 'February', desktop: 305, mobile: 200 }, { month: 'March', desktop: 237, mobile: 120 }, { month: 'April', desktop: 73, mobile: 190 }, { month: 'May', desktop: 209, mobile: 130 }, { month: 'June', desktop: 214, mobile: 140 }, ] const Template: StoryFn = args => export const BarChart = Template.bind({}) BarChart.args = { label: 'Customer information', type: 'bar', data: chartData, dataKeys: ['desktop', 'mobile'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } export const AreaChart = Template.bind({}) AreaChart.args = { label: 'Customer information', type: 'area', data: chartData, dataKeys: ['desktop', 'mobile'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } export const LineChart = Template.bind({}) LineChart.args = { label: 'Customer information', type: 'line', data: chartData, dataKeys: ['desktop', 'mobile'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } export const PieChart = Template.bind({}) PieChart.args = { label: 'Customer information', type: 'pie', data: [ { month: 'January', desktop: 186 }, { month: 'February', desktop: 305 }, { month: 'March', desktop: 237 }, { month: 'April', desktop: 73 }, { month: 'May', desktop: 209 }, { month: 'June', desktop: 214 }, ], dataKeys: ['desktop'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } export const RadarChart = Template.bind({}) RadarChart.args = { label: 'Customer information', type: 'radar', data: chartData, dataKeys: ['desktop', 'mobile'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } export const RadialChart = Template.bind({}) RadialChart.args = { label: 'Customer information', type: 'radial', data: [ { month: 'January', desktop: 186 }, { month: 'February', desktop: 305 }, { month: 'March', desktop: 237 }, { month: 'April', desktop: 73 }, { month: 'May', desktop: 209 }, { month: 'June', desktop: 214 }, ], dataKeys: ['desktop'], dataLabelKey: 'month', showLegend: true, showTooltip: true, } ================ File: src/components/io-methods/display-chart/index.tsx ================ import React from 'react' import { Area, AreaChart, Bar, BarChart, Line, LineChart, Pie, PieChart, Radar, RadarChart, RadialBar, RadialBarChart, XAxis, YAxis, CartesianGrid, PolarGrid, PolarAngleAxis, Cell, } from 'recharts' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart' import { RCTResponderProps } from '~/components/RenderIOCall' export type ChartType = 'area' | 'bar' | 'line' | 'pie' | 'radar' | 'radial' export interface DataPoint { [key: string]: string | number } const defaultColors = [ 'hsl(var(--chart-1))', 'hsl(var(--chart-2))', 'hsl(var(--chart-3))', 'hsl(var(--chart-4))', 'hsl(var(--chart-5))', ] export default function DisplayChart({ type, data = [], dataKeys, dataLabelKey, label, description, width = '100%', height = 250, showLegend = true, showTooltip = true, colors = defaultColors, }: RCTResponderProps<'DISPLAY_CHART'>): React.ReactNode { // construct chart config - this is structured differently for single timeseries charts const config = type === 'pie' || type === 'radial' ? data.reduce( ( acc: Record, data, index: number ) => { acc[data?.[dataLabelKey]] = { label: data?.[dataLabelKey].toString(), color: colors[index % colors.length], } return acc }, {} ) : dataKeys.reduce((acc, key, index) => { acc[key] = { label: key, color: colors[index % colors.length], } return acc }, {} as Record) const renderChart = () => { switch (type) { case 'area': return ( {showTooltip && } />} {dataKeys.map((key, index) => ( ))} {showLegend && } />} ) case 'line': return ( {showTooltip && } />} {dataKeys.map((key, index) => ( ))} {showLegend && } />} ) case 'pie': return ( {showTooltip && ( } /> )} {data.map((_, index) => ( ))} {showLegend && ( } className="-translate-y-2 flex-wrap gap-2 [&>*]:basis-1/4 [&>*]:justify-center" /> )} ) case 'radar': return ( {showTooltip && } />} {/* */} {dataKeys.map((key, index) => ( ))} {showLegend && } />} ) case 'radial': return ( {showTooltip && ( } /> )} {data.map((_, index) => ( ))} ) case 'bar': default: return ( {showTooltip && } />} {dataKeys.map((key, index) => ( ))} {showLegend && } />} ) } } return ( {label} {!!description && {description}} {renderChart()} ) } ================ File: src/components/io-methods/DisplayCode/DisplayCode.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Code', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Customer information', code: 'console.log("Hello world")', language: 'javascript', } ================ File: src/components/io-methods/DisplayCode/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' import HighlightedCodeBlock from '~/components/HighlightedCodeBlock' export default function DisplayCode(props: RCTResponderProps<'DISPLAY_CODE'>) { return (
{props.label}
) } ================ File: src/components/io-methods/DisplayGrid/index.tsx ================ import useTable from '~/components/IVTable/useTable' import useDisplayTableState from '../DisplayTable/useDisplayTableState' import IVMediaGrid from '~/components/IVMediaGrid' import { GridItem } from '@forgeapp/sdk/dist/ioSchema' import { RCTResponderProps } from '~/components/RenderIOCall' import ComponentHelpText from '~/components/HelpText' export default function DisplayGrid(props: RCTResponderProps<'DISPLAY_GRID'>) { const table = useTable({ columns: [], data: props.data, defaultPageSize: props.defaultPageSize ?? 20, totalRecords: props.totalRecords, isRemote: props.isAsync || 'totalRecords' in props, isFilterable: props.isFilterable, shouldCacheRecords: !props.isAsync, }) useDisplayTableState(table, props) return (

{props.label}

{props.helpText && ( {props.helpText} )}
) } ================ File: src/components/io-methods/DisplayHeading/DisplayHeading.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Heading', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Customer information', } ================ File: src/components/io-methods/DisplayHeading/index.tsx ================ import SectionHeading from '~/components/SectionHeading' import usePageMenuItems from '~/utils/usePageMenuItems' import { RCTResponderProps } from '~/components/RenderIOCall' export default function DisplayHeading( props: RCTResponderProps<'DISPLAY_HEADING'> ) { let headingLevel: keyof JSX.IntrinsicElements = 'h2' switch (props.level) { case 2: headingLevel = 'h2' break case 3: headingLevel = 'h3' break case 4: headingLevel = 'h4' break } let actions = usePageMenuItems(props.menuItems ?? [], { defaultButtonTheme: 'secondary', }) if (props.context === 'docs') { actions = actions?.map(({ href, ...a }) => ({ ...a, onClick: () => { /* */ }, absolute: true, })) } return (
) } ================ File: src/components/io-methods/DisplayHTML/DisplayHTML.stories.tsx ================ import React, { Suspense } from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.HTML', component: Component, } as Meta const Template: StoryFn = args => ( Loading...

}>
) export const Default = Template.bind({}) Default.args = { label: 'One line', html: '

Hello, world!

', } Default.parameters = { docs: { source: { code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554', }, }, } export const Multiline = Template.bind({}) Multiline.args = { label: 'Multiple lines', html: `

Hello, world!

Hello, world!

Hello, world!

`, } Multiline.parameters = { docs: { source: { code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554', }, }, } ================ File: src/components/io-methods/DisplayHTML/index.tsx ================ import { memo } from 'react' import { RCTResponderProps } from '~/components/RenderIOCall' import RenderHTML from '~/components/RenderHTML' const DisplayMarkdown = memo(function DisplayHTML({ label, html, }: RCTResponderProps<'DISPLAY_HTML'>) { return (

{label}

) }) export default DisplayMarkdown ================ File: src/components/io-methods/DisplayHTML/lazy.tsx ================ import { lazy } from 'react' const DisplayHTML = lazy(() => // @ts-ignore import.meta.env.SSR ? import('./stub') : import('.') ) export { DisplayHTML as default } ================ File: src/components/io-methods/DisplayHTML/stub.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' export default function DisplayHTMLStub( _props: RCTResponderProps<'DISPLAY_HTML'> ) { return
} ================ File: src/components/io-methods/DisplayImage/DisplayImage.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Image', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Sample image', url: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', } ================ File: src/components/io-methods/DisplayImage/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' import classNames from 'classnames' export default function DisplayImage( props: RCTResponderProps<'DISPLAY_IMAGE'> ) { return (

{props.label}

{props.alt}
) } ================ File: src/components/io-methods/DisplayLink/DisplayLink.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Link', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Customer information', } ================ File: src/components/io-methods/DisplayLink/index.tsx ================ import IVButton from '~/components/IVButton' import useRenderContext from '~/components/RenderContext' import useIsomorphicLocation from '~/utils/useIsomorphicLocation' import { RCTResponderProps } from '~/components/RenderIOCall' import { getCurrentPath } from '~/utils/url' export function useActionUrl(props: { base: string url?: string href?: string route?: string action?: string }): string { const { getActionUrl } = useRenderContext() const location = useIsomorphicLocation()() // don't link to actions in docs examples if (location.pathname.startsWith('/component-preview')) { return '' } if (props.url) return props.url if (props.href) return props.href const slug = props.route ?? props.action ?? '' return getActionUrl({ ...props, slug, }) } export function getRouteOrAction(props: RCTResponderProps<'DISPLAY_LINK'>) { const route = 'route' in props ? props.route : 'action' in props ? props.action : null if (route) { return { route, params: 'params' in props ? props.params : undefined, } } return props } export default function DisplayLink(props: RCTResponderProps<'DISPLAY_LINK'>) { const actionUrl = useActionUrl({ base: window.location.origin, ...getRouteOrAction(props), }) const url = 'url' in props ? props.url : 'href' in props ? props.href : null const newTab = 'url' in props || 'href' in props return (
{ /* */ } : undefined } theme={props.theme === 'default' ? 'secondary' : props.theme} absolute={newTab} state={{ // Disables the confirmation dialog when exiting transactions shouldShowPrompt: false, // Create a new transaction if linking to current action shouldCreateNewTransaction: true, // used for back button navigation backPath: getCurrentPath(), }} />
) } ================ File: src/components/io-methods/DisplayMarkdown/DisplayMarkdown.stories.tsx ================ import React, { Suspense } from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Markdown', component: Component, } as Meta const Template: StoryFn = args => ( Loading...

}>
) export const Default = Template.bind({}) Default.args = { label: '**Warning:** this _will_ erase user data.', } Default.parameters = { docs: { source: { code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554', }, }, } export const Multiline = Template.bind({}) Multiline.args = { label: ` ## Heading 2 ### Heading 3 - Bullet 1 - Bullet 2 > Blockquote inside list 1. Ordered 2. Ordered 3. \`inline_code\` [Link](https://forgeapp.io) ~~~ Here's an untagged code block ~~~ ~~~ts io.display.markdown("Here's a TypeScript code block") ~~~ `, } Multiline.parameters = { docs: { source: { code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554', }, }, } ================ File: src/components/io-methods/DisplayMarkdown/index.tsx ================ import { memo } from 'react' import dedent from 'ts-dedent' import { RCTResponderProps } from '~/components/RenderIOCall' import RenderMarkdown from '~/components/RenderMarkdown' const DisplayMarkdown = memo(function DisplayMarkdown({ label, }: RCTResponderProps<'DISPLAY_MARKDOWN'>) { return (
) }) export default DisplayMarkdown ================ File: src/components/io-methods/DisplayMarkdown/lazy.tsx ================ import { lazy } from 'react' const DisplayMarkdown = lazy(() => // @ts-ignore import.meta.env.SSR ? import('./stub') : import('.') ) export { DisplayMarkdown as default } ================ File: src/components/io-methods/DisplayMarkdown/stub.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' export default function DisplayMarkdownStub( _props: RCTResponderProps<'DISPLAY_MARKDOWN'> ) { return
} ================ File: src/components/io-methods/DisplayMetadata/DisplayMetadata.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' import { faker } from '@faker-js/faker' export default { title: 'TransactionUI/Display.Metadata', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Customer information', data: [ { label: 'Starts at', value: faker.date.future(), }, { label: 'Location', value: faker.address.city(), }, { label: 'Duration', value: 60, }, { label: 'Amount', value: '$25', }, { label: 'Supported types', value: ['Card', 'Apple Pay', 'Google Pay'].join(', '), }, { label: 'Description', value: faker.lorem.paragraphs(2, '\n\n'), }, { label: 'Long string', value: faker.random.alpha(256), }, { label: 'veryLongUnbrokenKeyString', value: faker.random.alpha(32), }, { label: 'Very long label wrapping to two lines', value: faker.random.alpha(32), }, { label: 'Long URL', value: 'https://forgeapp.io/dashboard/forge/actions/classes/view/' + faker.random.alpha(60), }, ], } export const CardLayout = Template.bind({}) CardLayout.args = { layout: 'card', data: [ { label: 'Users', value: 27, }, { label: 'Purchases', value: 7230, }, { label: 'Revenue', value: '$1,000,000', }, { label: 'Long value', value: faker.lorem.words(6), }, { label: 'Long string', value: faker.random.alpha(60), }, { label: 'URL', value: 'https://forgeapp.io/dashboard/forge/actions/classes/view', }, ], } export const ListLayout = Template.bind({}) ListLayout.args = { layout: 'list', data: Default.args.data, } ================ File: src/components/io-methods/DisplayMetadata/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' import classNames from 'classnames' import { T_IO_PROPS } from '@forgeapp/sdk/dist/ioSchema' import MetadataCardList from '~/components/MetadataCardList' import MetadataValue from '~/components/MetadataCardList/MetadataValue' function MetadataItem({ layout, item, }: { layout: T_IO_PROPS<'DISPLAY_METADATA'>['layout'] item: T_IO_PROPS<'DISPLAY_METADATA'>['data'][0] }) { return (
{item.label}
) } function getGridClassName(itemsCount: number) { if (itemsCount === 2) return 'gap-6 grid-cols-2' if (itemsCount === 3) return 'gap-6 sm:grid-cols-3' if (itemsCount >= 4) return 'gap-6 grid-cols-2 md:grid-cols-4' return '' } export default function DisplayMetadata({ label, layout = 'grid', data, }: RCTResponderProps<'DISPLAY_METADATA'>) { if (layout === 'card') { return } return (
{label && (

{label}

)}
{data.map((col, i) => ( ))}
) } ================ File: src/components/io-methods/DisplayObject/DisplayObject.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Object', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { data: { isTrue: true, isFalse: false, number: 15, nullValue: null, nested: { name: 'Forge', }, longList: Array(100) .fill(0) .map((_, i) => `Item ${i}`), }, } ================ File: src/components/io-methods/DisplayObject/index.tsx ================ import { useCallback } from 'react' import download from 'downloadjs' import { RCTResponderProps } from '~/components/RenderIOCall' import ObjectViewer from '~/components/ObjectViewer' import { toast as notify } from 'react-hot-toast' import useCopyToClipboard from '~/utils/useCopyToClipboard' import CheckIcon from '~/icons/compiled/Check' import CopyIcon from '~/icons/compiled/Copy' import DownloadIcon from '~/icons/compiled/DownloadsFolder' export default function DisplayObject({ label, data, }: RCTResponderProps<'DISPLAY_OBJECT'>) { const { onCopyClick, isCopied } = useCopyToClipboard() const handleCopyJson = useCallback(() => { try { onCopyClick(JSON.stringify(data, null, 2)) } catch (err) { console.error('Failed generating JSON', err) notify.error('Failed generating text to copy.') } }, [data, onCopyClick]) const handleDownloadJson = useCallback(() => { try { download( JSON.stringify(data, null, 2), `${label ?? 'data'}.json`, 'application/json' ) } catch (err) { console.error('Failed generating download', err) notify.error('Failed generating the download.') } }, [data, label]) return (
{label}
) } ================ File: src/components/io-methods/DisplayProgressIndeterminate/DisplayProgressIndeterminate.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Progress.Indeterminate', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Fetching communities...', } ================ File: src/components/io-methods/DisplayProgressIndeterminate/index.tsx ================ import IVSpinner from '~/components/IVSpinner' import { RCTResponderProps } from '~/components/RenderIOCall' export default function DisplayProgressIndeterminate( props: RCTResponderProps<'DISPLAY_PROGRESS_INDETERMINATE'> ) { return (

{props.label}

) } ================ File: src/components/io-methods/DisplayProgressSteps/DisplayProgressSteps.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.ProgressSteps', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Exporting communities', subTitle: "We're exporting all communities. This may take a while.", currentStep: 'movement-studio', steps: { completed: 1, total: 3, }, } ================ File: src/components/io-methods/DisplayProgressSteps/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' export default function DisplayProgress( props: RCTResponderProps<'DISPLAY_PROGRESS_STEPS'> ) { const percentComplete = `${( (props.steps.completed / props.steps.total) * 100 ).toFixed(2)}%` return (

{props.label}

{props.subTitle && (
{props.subTitle}
)}
{props.currentStep} {props.steps.completed}/{props.steps.total}
) } ================ File: src/components/io-methods/DisplayTable/DisplayTable.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' import { generateData, generateDenseData, mockColumns, } from '~/../test/data/table' import { faker } from '@faker-js/faker' import { IVTableCells } from '~/components/IVTable/useTable' export default { title: 'TransactionUI/Display.Table', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Data table', helpText: 'This table contains examples of the four allowed data types.', data: generateData(5), } export const CustomColumnNames = Template.bind({}) CustomColumnNames.args = { label: 'Data table', columns: mockColumns.slice(1), data: generateData(5), } export const ManyColumns = Template.bind({}) ManyColumns.args = { label: 'Data table', data: generateDenseData(100), } export const EmptyTable = Template.bind({}) EmptyTable.args = { label: 'Data table', data: [], } export const VerticalTable = Template.bind({}) VerticalTable.args = { orientation: 'vertical', data: generateData(10), } export const HighlightedTable = Template.bind({}) HighlightedTable.args = { data: generateData(10).map((row, i) => { const status = faker.helpers.arrayElement([ 'Online', 'Offline', 'Error', 'Unresponsive', ]) const region = faker.helpers.arrayElement([ 'US East', 'US West', 'Europe', 'Asia', ]) const ip_address = faker.internet.ip() const ec2_instance_id = faker.datatype.uuid() const application = faker.helpers.arrayElement([ 'docs', 'marketing-site', 'api', ]) const data: IVTableCells = { name: { label: `production-${i.toString().padStart(2, '0')}`, }, status: { label: status, highlightColor: status === 'Online' ? 'green' : status === 'Error' ? 'red' : status === 'Unresponsive' ? 'yellow' : 'gray', }, application: { label: application, // highlightColor: // application === 'docs' // ? 'blue' // : application === 'api' // ? 'pink' // : 'orange', }, region, ip_address: { label: ip_address, // highlightColor: faker.helpers.arrayElement([ // 'red', // 'orange', // 'yellow', // 'green', // 'blue', // 'pink', // 'purple', // 'gray', // ]), url: `https://www.google.com/search?q=${ip_address}`, }, ec2_instance_id, } return { key: row.key, data, } }), } ================ File: src/components/io-methods/DisplayTable/index.tsx ================ import useTable from '~/components/IVTable/useTable' import useDisplayTableState from './useDisplayTableState' import useTableSerializer from '~/components/IVTable/useTableSerializer' import { RCTResponderProps } from '~/components/RenderIOCall' import IVTable from '~/components/IVTable' import ComponentHelpText from '~/components/HelpText' // IVTable also has an empty state, but we want a different UI for empty tables in the transaction UI. export function TransactionTableEmptyState({ message }: { message?: string }) { return (
{message ?? 'There are no items to display.'}
) } export default function DisplayTable( props: RCTResponderProps<'DISPLAY_TABLE'> ) { const { data, columns } = useTableSerializer(props) const table = useTable({ data, columns, isDownloadable: !props.isAsync, defaultPageSize: props.defaultPageSize ?? (props.orientation === 'vertical' ? 5 : 20), totalRecords: props.totalRecords, isRemote: props.isAsync || 'totalRecords' in props, shouldCacheRecords: !props.isAsync, isSortable: props.isSortable ?? true, isFilterable: props.isFilterable ?? true, }) useDisplayTableState(table, props) return (

{props.label}

{props.helpText && ( {props.helpText} )}
) } ================ File: src/components/io-methods/DisplayVideo/DisplayVideo.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Display.Video', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Sample video', url: 'https://upload.wikimedia.org/wikipedia/commons/a/ad/The_Kid_scenes.ogv', } ================ File: src/components/io-methods/DisplayVideo/index.tsx ================ import { RCTResponderProps } from '~/components/RenderIOCall' import { imageSizeToPx } from '~/utils/number' import classNames from 'classnames' export default function DisplayVideo( props: RCTResponderProps<'DISPLAY_VIDEO'> ) { return (

{props.label}

) } ================ File: src/components/io-methods/InputBoolean/index.tsx ================ import { useState } from 'react' import IVCheckbox from '~/components/IVCheckbox' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' export default function InputBoolean( props: RCTResponderProps<'INPUT_BOOLEAN'> ) { const [isChecked, setIsChecked] = useState( !(props.value instanceof IOComponentError) ? props.value : false ) const toggleChecked = (value: boolean) => { setIsChecked(value) props.onUpdatePendingReturnValue(value) } return (
toggleChecked(e.target.checked)} />
) } ================ File: src/components/io-methods/InputBoolean/InputBoolean.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Input.Boolean', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Add to mailing list', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } export const WithHelpText = Template.bind({}) WithHelpText.args = { label: 'Send confirmation email', helpText: 'Will be delivered to alex@forgeapp.io', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } ================ File: src/components/io-methods/InputDate/index.tsx ================ import { useState, useCallback, useMemo } from 'react' import IVDatePicker from '~/components/IVDatePicker' import { compareDateObjects, objectToDate, DatePickerValue, } from '~/components/IVDateTimePicker/datePickerUtils' import IVInputField from '~/components/IVInputField' import { dateFormatter } from '~/utils/formatters' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import useInput from '~/utils/useInput' export default function InputDate(props: RCTResponderProps<'INPUT_DATE'>) { const [value, setValue] = useState( !(props.value instanceof IOComponentError) ? getInitialState(props.value) : null ) const { errorMessage } = useInput(props) const { onUpdatePendingReturnValue, isOptional, min, max } = props const constraintDetails = useMemo(() => { if (min && max) { return `between ${dateFormatter.format( objectToDate(min) )} and ${dateFormatter.format(objectToDate(max))}` } else if (min) { return `later than or equal to ${dateFormatter.format(objectToDate(min))}` } else if (max) { return `earlier than or equal to ${dateFormatter.format( objectToDate(max) )}` } return undefined }, [min, max]) const constraints = useMemo( () => (constraintDetails ? `Must be ${constraintDetails}.` : undefined), [constraintDetails] ) const onChange = useCallback( (value: DatePickerValue | null) => { setValue(value?.jsDate ?? null) if (value) { if ( (min && compareDateObjects(value, min) < 0) || (max && compareDateObjects(value, max) > 0) ) { onUpdatePendingReturnValue( new IOComponentError(`Please enter a date ${constraintDetails}.`) ) } else { onUpdatePendingReturnValue(value) } } else if (isOptional) { onUpdatePendingReturnValue(undefined) } else { onUpdatePendingReturnValue( new IOComponentError(`Please enter a valid date.`) ) } }, [onUpdatePendingReturnValue, isOptional, min, max, constraintDetails] ) return (
) } function getInitialState( defaultValue: RCTResponderProps<'INPUT_DATE'>['defaultValue'] ) { if (!defaultValue) { return null } if (defaultValue instanceof Date) { return defaultValue } return objectToDate(defaultValue) } ================ File: src/components/io-methods/InputDateTime/index.tsx ================ import { useState, useMemo, useCallback } from 'react' import { ioSchema } from '@forgeapp/sdk/dist/ioSchema' import { z } from 'zod' import IVDateTimePicker from '~/components/IVDateTimePicker' import { dateTimeFormatter } from '~/utils/formatters' import { compareDateObjects, IVDateTimeChangeValue, IVDateTimePickerProps, objectToDate, } from '~/components/IVDateTimePicker/datePickerUtils' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import IVInputField from '~/components/IVInputField' import useInput from '~/utils/useInput' export default function InputDateTime( props: RCTResponderProps<'INPUT_DATETIME'> ) { const [value, setValue] = useState( !(props.value instanceof IOComponentError) ? getInitialState(props.value) : null ) const { errorMessage } = useInput(props) const { onUpdatePendingReturnValue, isOptional, min, max } = props const constraintDetails = useMemo(() => { if (min && max) { return `between ${dateTimeFormatter.format( objectToDate(min) )} and ${dateTimeFormatter.format(objectToDate(max))}` } else if (min) { return `later than or equal to ${dateTimeFormatter.format( objectToDate(min) )}` } else if (max) { return `earlier than or equal to ${dateTimeFormatter.format( objectToDate(max) )}` } return undefined }, [min, max]) const constraints = useMemo( () => (constraintDetails ? `Must be ${constraintDetails}.` : undefined), [constraintDetails] ) const onChange = useCallback( (value: IVDateTimeChangeValue | null) => { setValue(value?.jsDate ?? null) if (value) { const returnValue = getReturnValue(value) if (!returnValue) { onUpdatePendingReturnValue( isOptional ? undefined : new IOComponentError() ) } else if ( (min && compareDateObjects(returnValue, min) < 0) || (max && compareDateObjects(returnValue, max) > 0) ) { onUpdatePendingReturnValue( new IOComponentError(`Please enter a date ${constraintDetails}.`) ) } else { onUpdatePendingReturnValue(returnValue) } } else if (isOptional) { onUpdatePendingReturnValue(undefined) } else { onUpdatePendingReturnValue( new IOComponentError(`Please enter a valid date.`) ) } }, [onUpdatePendingReturnValue, isOptional, min, max, constraintDetails] ) return (
) } function getInitialState( defaultValue: RCTResponderProps<'INPUT_DATETIME'>['defaultValue'] | null ): IVDateTimePickerProps['value'] | null { if (!defaultValue) { return null } if (defaultValue instanceof Date) { return defaultValue } return objectToDate(defaultValue) } function getReturnValue( value: IVDateTimeChangeValue | null ): z.infer | null { if (!value) return null const { year, month, day, hour, minute, jsDate } = value // do not return an object unless all values are present if ( year === null || month === null || day === null || hour === null || minute === null || jsDate === null ) { return null } return { year, month, day, hour, minute } } ================ File: src/components/io-methods/InputEmail/index.tsx ================ import { ChangeEvent, useState } from 'react' import IVInputField from '~/components/IVInputField' import IVTextInput from '~/components/IVTextInput' import { isEmail } from '~/utils/validate' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import useInput from '~/utils/useInput' export default function InputEmail(props: RCTResponderProps<'INPUT_EMAIL'>) { const [state, setState] = useState( (!(props.value instanceof IOComponentError) ? props.value : '') ?? '' ) const { errorMessage } = useInput(props) const onChange = (e: ChangeEvent) => { const v = e.target.value const isValid = (isEmail(v) && v.length > 0) || (v.length === 0 && props.isOptional) setState(v) props.onUpdatePendingReturnValue( isValid ? v : new IOComponentError('Please enter a valid email.') ) } return ( ) } ================ File: src/components/io-methods/InputEmail/InputEmail.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Input.Email', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Email address', placeholder: 'you@example.com', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } ================ File: src/components/io-methods/InputNumber/index.tsx ================ import { useState, useCallback, useMemo } from 'react' import IVInputField from '~/components/IVInputField' import IVTextInput from '~/components/IVTextInput' import { InvalidNumberError, validateNumber } from '~/utils/validate' import { getCurrencySymbol } from '~/utils/currency' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import useInput from '~/utils/useInput' import classNames from 'classnames' export default function InputNumber(props: RCTResponderProps<'INPUT_NUMBER'>) { const [state, setState] = useState( (!(props.value instanceof IOComponentError) ? props.value : null) ?? '' ) const { errorMessage } = useInput(props) const { min, max, isOptional, onUpdatePendingReturnValue } = props const decimals = props.decimals ?? (props.currency ? 2 : undefined) const constraintDetails = useMemo(() => { const constraints: string[] = [] if (min !== undefined && max !== undefined) { constraints.push(`between ${min} and ${max}`) } else if (min !== undefined) { constraints.push(`greater than or equal to ${min}`) } else if (max !== undefined) { constraints.push(`less than or equal to ${max}`) } if (decimals) { return [ 'a number', ...constraints, `with up to ${decimals} decimals`, ].join(' ') } else { if (constraints.length) { return ['a whole number', ...constraints] } else { return undefined } } }, [min, max, decimals]) const constraints = useMemo( () => (constraintDetails ? `Must be ${constraintDetails}.` : undefined), [constraintDetails] ) const onChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.value.replaceAll(',', '') // perform validity checks unless empty + optional if (value.length === 0 && isOptional) { onUpdatePendingReturnValue(undefined) } else if (value.length === 0) { onUpdatePendingReturnValue(new IOComponentError()) } else { try { onUpdatePendingReturnValue( validateNumber(value, { min, max, decimals }) ) } catch (err) { if (err instanceof InvalidNumberError) { onUpdatePendingReturnValue(new IOComponentError(err.message)) } else { console.error(err) } } } // With commas unstripped setState(event.target.value) }, [min, max, isOptional, decimals, onUpdatePendingReturnValue] ) return (
{props.currency} ) } className={classNames({ 'pr-14': props.currency, })} value={state} inputMode={decimals ? 'decimal' : 'numeric'} pattern={decimals ? undefined : '-?[0-9]*'} placeholder={props.isCurrentCall ? props.placeholder : undefined} disabled={props.disabled || props.isSubmitting} onChange={onChange} />
) } ================ File: src/components/io-methods/InputNumber/InputNumber.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Input.Number', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { label: 'Enter a number', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } export const MinMax = Template.bind({}) MinMax.args = { label: 'Enter a number between 1-10', min: 1, max: 10, onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } export const Money = Template.bind({}) Money.args = { label: 'Amount', placeholder: '5', currency: 'USD', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } ================ File: src/components/io-methods/InputRichText/index.tsx ================ import IVInputField from '~/components/IVInputField' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import IVRichTextEditor from '~/components/IVRichTextEditor/lazy' import useInput from '~/utils/useInput' export default function InputText(props: RCTResponderProps<'INPUT_RICH_TEXT'>) { const { errorMessage } = useInput(props) return ( { props.onUpdatePendingReturnValue( text.length > 0 ? value : props.isOptional ? undefined : new IOComponentError() ) }} /> ) } ================ File: src/components/io-methods/InputRichText/InputRichText.stories.tsx ================ import React, { Suspense } from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' import IVSpinner from '~/components/IVSpinner' export default { title: 'TransactionUI/Input.RichText', component: Component, } as Meta const Template: StoryFn = args => (
}>
) export const Default = Template.bind({}) Default.parameters = { docs: { source: { code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554', }, }, } Default.args = { label: 'Email body', placeholder: 'Hello, world!', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } ================ File: src/components/io-methods/InputSlider/index.tsx ================ import { useState, useCallback } from 'react' import IVInputField from '~/components/IVInputField' import { InvalidNumberError, validateNumber } from '~/utils/validate' import { RCTResponderProps } from '~/components/RenderIOCall' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import useInput from '~/utils/useInput' import { useRef, useEffect } from 'react' function useRangeControl({ onChange }: { onChange: (value: string) => void }) { const ref = useRef(null) // select all of the text when the input is focused useEffect(() => { const handleFocus = () => { setTimeout(() => { if (ref.current) { ref.current.select() } }, 10) } const input = ref.current if (input) { input.addEventListener('focus', handleFocus) return () => { input.removeEventListener('focus', handleFocus) } } }, [ref]) // Support typing a numeric value with the number keys while the range is focused. // A timer is set after each keystroke. Additional keystrokes within the timer are appended // to the current value. After the timer expires, new keystrokes are treated as new input. const threshold = 1500 const timeoutId = useRef(null) // We need to keep track of the typed value in state because the number input // will clear trailing decimal points while the user is typing, e.g. "1." -> "1" const [typedValue, setTypedValue] = useState('') function onKeyDown(event: React.KeyboardEvent) { if (event.key.match(/[0-9.]/)) { const nextValue = typedValue + event.key onChange(nextValue) setTypedValue(nextValue) timeoutId.current = setTimeout(() => { setTypedValue('') }, threshold) } } function onBlur() { if (timeoutId.current) { clearTimeout(timeoutId.current) } } return { ref, onKeyDown, onBlur } } export default function InputSlider(props: RCTResponderProps<'INPUT_SLIDER'>) { const initialValue = props.value instanceof IOComponentError ? null : props.value const { min = 0, max = 100, isOptional, onUpdatePendingReturnValue } = props const [state, setState] = useState( initialValue ?? Math.min(min, max) ) const { errorMessage } = useInput(props) const decimals = props.step?.toString().split('.')[1]?.length ?? 0 const onChange = useCallback( (val: string) => { const value = val.replaceAll(',', '') // perform validity checks unless empty + optional if (value.length === 0 && isOptional) { onUpdatePendingReturnValue(undefined) } else if (value.length === 0) { onUpdatePendingReturnValue(new IOComponentError()) } else { try { onUpdatePendingReturnValue( validateNumber(value, { min, max, decimals }) ) } catch (err) { if (err instanceof InvalidNumberError) { onUpdatePendingReturnValue(new IOComponentError(err.message)) } else { console.error(err) } } } setState(val) }, [min, max, isOptional, decimals, onUpdatePendingReturnValue] ) const { ref: numberInputRef, onKeyDown, onBlur, } = useRangeControl({ onChange }) return (
onChange(e.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} min={props.min} max={props.max} step={props.step} disabled={props.disabled || props.isSubmitting} style={{ backgroundSize: ((Number(state) - min) * 100) / (max - min) + '% 100%', }} />
{/* sizer element */} {state} onChange(e.target.value)} onBlur={onBlur} ref={numberInputRef} min={props.min} max={props.max} step={props.step} disabled={props.disabled || props.isSubmitting} tabIndex={-1} />
) } ================ File: src/components/io-methods/InputSlider/InputSlider.stories.tsx ================ import React from 'react' import { StoryFn, Meta } from '@storybook/react' import Component from '.' export default { title: 'TransactionUI/Input.Slider', component: Component, } as Meta const Template: StoryFn = args => export const Default = Template.bind({}) Default.args = { id: 'range', label: 'Enter a number', min: 0, max: 10, onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } export const Decimals = Template.bind({}) Decimals.args = { id: 'range', label: 'Temperature', min: 0, max: 2, step: 0.1, helpText: 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', onUpdatePendingReturnValue: () => { /**/ }, onStateChange: () => { /**/ }, } ================ File: src/components/io-methods/InputText/index.tsx ================ import React, { useCallback, useMemo, useState } from 'react' import { IOComponentError } from '~/components/RenderIOCall/ComponentError' import { RCTResponderProps } from '~/components/RenderIOCall' import useInput from '~/utils/useInput' import IVInputField from '~/components/IVInputField' import { preventDefaultInputEnterKey } from '~/utils/preventDefaultInputEnter' export default function InputText(props: RCTResponderProps<'INPUT_TEXT'>) { const [state, setState] = useState( (!(props.value instanceof IOComponentError) ? props.value : '') ?? '' ) const { errorMessage } = useInput(props) const { onUpdatePendingReturnValue, isOptional, minLength, maxLength } = props const constraintDetails = useMemo(() => { if (minLength && maxLength) { return `between ${minLength} and ${maxLength} characters` } else if (minLength) { return `at least ${minLength} characters` } else if (maxLength) { return `at most ${maxLength} characters` } return undefined }, [minLength, maxLength]) const constraints = useMemo( () => (constraintDetails ? `Must be ${constraintDetails}.` : undefined), [constraintDetails] ) const shared = { id: props.id, value: state, placeholder: props.isCurrentCall ? props.placeholder : undefined, disabled: props.disabled || props.isSubmitting, onChange: useCallback( (e: any) => { const v = e.target.value setState(v) if (v.length > 0) { if ( (minLength && v.length < minLength) || (maxLength && v.length > maxLength) ) { onUpdatePendingReturnValue( new IOComponentError( `Please enter a value with ${constraintDetails}.` ) ) } else { onUpdatePendingReturnValue(v) } } else if (isOptional) { onUpdatePendingReturnValue(undefined) } else { onUpdatePendingReturnValue(new IOComponentError()) } }, [ onUpdatePendingReturnValue, isOptional, minLength, maxLength, constraintDetails, ] ), className: 'form-input', ...(props.autoFocus && { 'data-autofocus-target': true }), } return ( {props.multiline ? (