Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui: DestinationInputDirect component #3588

Merged
merged 7 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions web/src/app/selection/DestinationInputDirect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import DestinationInputDirect from './DestinationInputDirect'
import { expect } from '@storybook/jest'
import { within, userEvent } from '@storybook/testing-library'
import { handleDefaultConfig } from '../storybook/graphql'
import { HttpResponse, graphql } from 'msw'
import { useArgs } from '@storybook/preview-api'

const meta = {
title: 'util/DestinationInputDirect',
component: DestinationInputDirect,
tags: ['autodocs'],
argTypes: {
inputType: {
control: 'select',
options: ['text', 'url', 'tel', 'email'],
description: 'The type of input to use. tel will only allow numbers.',
},
},
parameters: {
msw: {
handlers: [
handleDefaultConfig,
graphql.query('ValidateDestination', ({ variables: vars }) => {
return HttpResponse.json({
data: {
destinationFieldValidate:
vars.input.value === 'https://test.com' ||
vars.input.value === '+12225558989' ||
vars.input.value === 'valid@email.com',
},
})
}),
],
},
},
render: function Component(args) {
const [, setArgs] = useArgs()
const onChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (args.onChange) args.onChange(e)
setArgs({ value: e.target.value })
}
return <DestinationInputDirect {...args} onChange={onChange} />
},
} satisfies Meta<typeof DestinationInputDirect>

export default meta
type Story = StoryObj<typeof meta>

export const WebookWithDocLink: Story = {
args: {
value: '',

fieldID: 'webhook-url',
hint: 'Webhook Documentation',
hintURL: '/docs#webhooks',
inputType: 'url',
labelSingular: 'Webhook URL',
placeholderText: 'https://example.com',
prefix: '',
supportsValidation: true,
isSearchSelectable: false,
labelPlural: 'Webhook URLs',

destType: 'builtin-webhook',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

// ensure placeholder and href loads correctly
await expect(
canvas.getByPlaceholderText('https://example.com'),
).toBeVisible()
await expect(canvas.getByLabelText('Webhook URL')).toBeVisible()
await expect(canvas.getByText('Webhook Documentation')).toHaveAttribute(
'href',
'/docs#webhooks',
)

// ensure check icon for valid URL
await userEvent.clear(canvas.getByLabelText('Webhook URL'))
await userEvent.type(
canvas.getByLabelText('Webhook URL'),
'https://test.com',
)
await expect(await canvas.findByTestId('CheckIcon')).toBeVisible()

// ensure close icon for invalid URL
await userEvent.clear(canvas.getByLabelText('Webhook URL'))
await userEvent.type(
canvas.getByLabelText('Webhook URL'),
'not_a_valid_url',
)
await expect(await canvas.findByTestId('CloseIcon')).toBeVisible()
},
}

export const PhoneNumbers: Story = {
args: {
value: '',

fieldID: 'phone-number',
hint: 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)',
hintURL: '',
inputType: 'tel',
labelSingular: 'Phone Number',
placeholderText: '11235550123',
prefix: '+',
supportsValidation: true,
labelPlural: 'Phone Numbers',
isSearchSelectable: false,

destType: 'builtin-twilio-sms',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

// ensure information renders correctly
await expect(canvas.getByLabelText('Phone Number')).toBeVisible()
await expect(
canvas.getByText(
'Include country code e.g. +1 (USA), +91 (India), +44 (UK)',
),
).toBeVisible()
await expect(canvas.getByText('+')).toBeVisible()
await expect(canvas.getByPlaceholderText('11235550123')).toBeVisible()

// ensure check icon for valid number
await userEvent.clear(canvas.getByLabelText('Phone Number'))
await userEvent.type(canvas.getByLabelText('Phone Number'), '12225558989')
await expect(await canvas.findByTestId('CheckIcon')).toBeVisible()

// ensure close icon for invalid number
await userEvent.clear(canvas.getByLabelText('Phone Number'))
await userEvent.type(canvas.getByLabelText('Phone Number'), '123')
await expect(await canvas.findByTestId('CloseIcon')).toBeVisible()

// ensure only numbers are allowed
await userEvent.clear(canvas.getByLabelText('Phone Number'))
await userEvent.type(canvas.getByLabelText('Phone Number'), 'A4B5C6')
await expect(
canvas.getByLabelText('Phone Number').getAttribute('value'),
).toContain('456')
},
}

export const Email: Story = {
args: {
value: '',

fieldID: 'email-address',
hint: '',
hintURL: '',
inputType: 'email',
labelSingular: 'Email Address',
placeholderText: 'foobar@example.com',
prefix: '',
supportsValidation: true,
isSearchSelectable: false,
labelPlural: 'Email Addresses',

destType: 'builtin-smtp-email',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

// ensure information renders correctly
await expect(
canvas.getByPlaceholderText('foobar@example.com'),
).toBeVisible()
await expect(canvas.getByLabelText('Email Address')).toBeVisible()

// ensure check icon for valid email
await userEvent.clear(canvas.getByLabelText('Email Address'))
await userEvent.type(
canvas.getByLabelText('Email Address'),
'valid@email.com',
)
await expect(await canvas.findByTestId('CheckIcon')).toBeVisible()

// ensure close icon for invalid email
await userEvent.clear(canvas.getByLabelText('Email Address'))
await userEvent.type(canvas.getByLabelText('Email Address'), 'notvalid')
await expect(await canvas.findByTestId('CloseIcon')).toBeVisible()
},
}
137 changes: 137 additions & 0 deletions web/src/app/selection/DestinationInputDirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react'
import { useQuery, gql } from 'urql'
import TextField from '@mui/material/TextField'
import { InputProps } from '@mui/material/Input'
import { Check, Close } from '@mui/icons-material'
import InputAdornment from '@mui/material/InputAdornment'
import { DEBOUNCE_DELAY } from '../config'
import { DestinationFieldConfig, DestinationType } from '../../schema'
import AppLink from '../util/AppLink'
import { green, red } from '@mui/material/colors'

const isValidValue = gql`
query ValidateDestination($input: DestinationFieldValidateInput!) {
destinationFieldValidate(input: $input)
}
`

const noSuspense = { suspense: false }

function trimPrefix(value: string, prefix: string): string {
if (!prefix) return value
if (!value) return value
if (value.startsWith(prefix)) return value.slice(prefix.length)
return value
}

export type DestinationInputDirectProps = DestinationFieldConfig & {
value: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
destType: DestinationType

disabled?: boolean
}

/**
* DestinationInputDirect is a text field that allows the user to enter a
* destination directly. It supports validation and live feedback.
*
* You should almost never use this component directly. Instead, use
* DestinationField, which will select the correct component based on the
* destination type.
*/
export default function DestinationInputDirect(
props: DestinationInputDirectProps,
): JSX.Element {
const [debouncedValue, setDebouncedValue] = useState(props.value)

// debounce the input
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(props.value)
}, DEBOUNCE_DELAY)
return () => {
clearTimeout(handler)
}
}, [props.value])

// check validation of the input phoneNumber through graphql
const [{ data }] = useQuery<{ destinationFieldValidate: boolean }>({
query: isValidValue,
variables: {
input: {
destType: props.destType,
value: debouncedValue,
fieldID: props.fieldID,
},
},
requestPolicy: 'cache-first',
pause: !props.value || props.disabled || !props.supportsValidation,
context: noSuspense,
})

// fetch validation
const valid = !!data?.destinationFieldValidate

let adorn
if (!props.value || !props.supportsValidation) {
// no adornment if empty
} else if (valid) {
adorn = <Check sx={{ fill: green[500] }} />
} else if (valid === false) {
adorn = <Close sx={{ fill: red[500] }} />
}

let iprops: Partial<InputProps> = {}

if (props.prefix) {
iprops.startAdornment = (
<InputAdornment position='start' sx={{ mb: '0.1em' }}>
{props.prefix}
</InputAdornment>
)
}

// add live validation icon to the right of the textfield as an endAdornment
if (adorn && props.value === debouncedValue) {
iprops = {
endAdornment: <InputAdornment position='end'>{adorn}</InputAdornment>,
...iprops,
}
}

// remove unwanted character
function handleChange(e: React.ChangeEvent<HTMLInputElement>): void {
if (!props.onChange) return
if (!e.target.value) return props.onChange(e)

if (props.inputType === 'tel') {
e.target.value = e.target.value.replace(/[^0-9+]/g, '')
}

e.target.value = props.prefix + e.target.value
return props.onChange(e)
}

return (
<TextField
fullWidth
disabled={props.disabled}
InputProps={iprops}
type={props.inputType}
placeholder={props.placeholderText}
label={props.labelSingular}
helperText={
props.hintURL ? (
<AppLink newTab to={props.hintURL}>
{props.hint}
</AppLink>
) : (
props.hint
)
}
onChange={handleChange}
value={trimPrefix(props.value, props.prefix)}
/>
)
}
Loading