Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,26 +1,99 @@
import type { DropdownAddNewId, ITableColumnMetadata } from '@plumber/types'

import { type FormEvent, useCallback, useState } from 'react'
import { type FormEvent, useCallback, useContext, useState } from 'react'
import { useMutation } from '@apollo/client'
import {
FormControl,
FormHelperText,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { Button, FormLabel, Input } from '@opengovsg/design-system-react'
import {
Button,
FormErrorMessage,
FormLabel,
Input,
} from '@opengovsg/design-system-react'

import { removeProblematicWhitespace } from '@/components/RichTextEditor/utils'
import { EditorContext } from '@/contexts/Editor'
import client from '@/graphql/client'
import { DYNAMIC_ACTION } from '@/graphql/mutations/dynamic-action'
import { CREATE_TABLE } from '@/graphql/mutations/tiles/create-table'
import { UPDATE_TABLE } from '@/graphql/mutations/tiles/update-table'
import { GET_DYNAMIC_DATA } from '@/graphql/queries/get-dynamic-data'

interface AddNewOptionalModalProps {
interface AddNewOptionConfig {
modalHeader: string
description?: string
inputLabel: string
buttonLabel: string
validate?: (value: string) => string | null
}

const MAX_NAME_LENGTH = 255

function validateMaxLength(value: string): string | null {
if (value.length > MAX_NAME_LENGTH) {
return `Must be ${MAX_NAME_LENGTH} characters or fewer`
}
return null
}

function validateDatabricksTableName(value: string): string | null {
const lengthError = validateMaxLength(value)
if (lengthError) {
return lengthError
}
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
return 'Only alphanumeric characters and underscores are allowed'
}
Comment on lines +52 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation regex doesn't match user-facing description. The regex /^[a-zA-Z0-9_]+$/ allows both uppercase AND lowercase letters, but line 81 tells users "Only lowercase letters, numbers and underscores allowed."

// Current regex allows uppercase:
if (!/^[a-zA-Z0-9_]+$/.test(value)) {

// Should be lowercase only:
if (!/^[a-z0-9_]+$/.test(value)) {

Users will successfully create tables with uppercase letters, which may fail in Databricks if it requires lowercase names.

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

return null
}

function validateColumnName(value: string): string | null {
const lengthError = validateMaxLength(value)
if (lengthError) {
return lengthError
}
if (!/^[a-zA-Z0-9 _\-!@#$%^&*()+=[\]{};:'",.<>/?|~]+$/.test(value)) {
return 'Only alphanumeric characters, spaces, and common special characters are allowed'
}
return null
}

const ADD_NEW_OPTION_CONFIGS: Partial<
Record<DropdownAddNewId, AddNewOptionConfig>
> = {
'tiles-createTileRow-tableId': {
modalHeader: 'Create a new tile',
inputLabel: 'Name your new table',
buttonLabel: 'Create',
validate: validateMaxLength,
},
'databricks-createTable': {
modalHeader: 'Create a new table',
inputLabel: 'Table name',
description: 'Only lowercase letters, numbers and underscores allowed.',
buttonLabel: 'Create',
validate: validateDatabricksTableName,
},
}

// Validation for inline add-new options (non-modal)
export const INLINE_ADD_NEW_VALIDATE: Partial<
Record<DropdownAddNewId, (value: string) => string | null>
> = {
'tiles-createTileRow-columnId': validateColumnName,
'databricks-createTableColumn': validateColumnName,
}

interface AddNewOptionalModalProps {
addNewId: DropdownAddNewId
onSubmit: (newValue: string) => void
onClose: () => void
}
Expand All @@ -32,8 +105,10 @@ interface CreateNewOptionProps {
}

export function useCreateNewOption(setValue: (newValue: string) => void) {
const { currentStepId } = useContext(EditorContext)
const [createTable] = useMutation(CREATE_TABLE)
const [updateTable] = useMutation(UPDATE_TABLE)
const [dynamicAction] = useMutation(DYNAMIC_ACTION)
const [isCreatingNewOption, setIsCreatingNewOption] = useState(false)
const createNewOption = useCallback(
async ({ inputValue, addNewId, parameters }: CreateNewOptionProps) => {
Expand Down Expand Up @@ -76,6 +151,45 @@ export function useCreateNewOption(setValue: (newValue: string) => void) {
)?.id
break
}

case 'databricks-createTable': {
if (!currentStepId) {
return
}
const { data } = await dynamicAction({
variables: {
input: {
stepId: currentStepId ?? '',
key: addNewId,
parameters: {
tableName: inputValue.trim(),
},
},
},
})
newValue = data?.dynamicAction?.newValue as string
break
}
case 'databricks-createTableColumn': {
const tableName = parameters?.tableName as string
if (!tableName || !currentStepId) {
return
}
const { data } = await dynamicAction({
variables: {
input: {
stepId: currentStepId ?? '',
key: addNewId,
parameters: {
tableName: parameters?.tableName as string,
columnName: inputValue.trim(),
},
},
},
})
newValue = data?.dynamicAction?.newValue as string
break
}
default:
break
}
Expand All @@ -87,30 +201,42 @@ export function useCreateNewOption(setValue: (newValue: string) => void) {
setIsCreatingNewOption(false)
}
},
[createTable, setValue, updateTable],
[createTable, setValue, updateTable, dynamicAction, currentStepId],
)
return { createNewOption, isCreatingNewOption }
}

function AddNewOptionalModal({
modalHeader,
addNewId,
onClose,
onSubmit,
}: AddNewOptionalModalProps) {
const config = ADD_NEW_OPTION_CONFIGS[addNewId]
const [inputValue, setInputValue] = useState('')
const trimmedInputValue = inputValue.trim()

const validationError = trimmedInputValue
? config?.validate?.(trimmedInputValue) ?? null
: null

const onFormSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!trimmedInputValue) {
return
}
if (config?.validate?.(trimmedInputValue)) {
return
}
onSubmit(trimmedInputValue)
},
[onSubmit, trimmedInputValue],
[onSubmit, trimmedInputValue, config],
)

if (!config) {
return null
}

return (
<Modal
isOpen={true}
Expand All @@ -122,26 +248,36 @@ function AddNewOptionalModal({
<ModalOverlay />
<ModalContent>
<form onSubmit={onFormSubmit}>
<ModalHeader>{modalHeader}</ModalHeader>
<ModalHeader>{config.modalHeader}</ModalHeader>
<ModalCloseButton />
<ModalBody pb={8}>
<FormControl display="flex" flexDir="column" gap={2}>
{/* This is hardcoded for now, will change when there are more of such actions */}
<FormLabel isRequired>Name your tile</FormLabel>
<FormControl
display="flex"
flexDir="column"
gap={2}
isInvalid={!!validationError}
>
<FormLabel isRequired>{config.inputLabel}</FormLabel>
{config.description && (
<FormHelperText mt={-2}>{config.description}</FormHelperText>
)}
<Input
autoFocus
onChange={(e) =>
setInputValue(removeProblematicWhitespace(e.target.value))
}
value={inputValue}
/>
{validationError && (
<FormErrorMessage>{validationError}</FormErrorMessage>
)}
<Button
mt={2}
type="submit"
isDisabled={!trimmedInputValue}
isDisabled={!trimmedInputValue || !!validationError}
alignSelf="flex-end"
>
Create
{config.buttonLabel}
</Button>
</FormControl>
</ModalBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import { StepExecutionsContext } from '@/contexts/StepExecutions'

import extractVariablesAsItems from '../MultiSelect/helpers/extract-variables-as-items'

import AddNewOptionModal, { useCreateNewOption } from './AddNewOptionModal'
import AddNewOptionModal, {
INLINE_ADD_NEW_VALIDATE,
useCreateNewOption,
} from './AddNewOptionModal'

export interface ControlledAutocompleteProps {
options: IFieldDropdownOption[]
Expand Down Expand Up @@ -181,6 +184,7 @@ function ControlledAutocomplete(
)}
target="_blank"
ml={1}
isExternal
>
({clickableLink.label})
</Link>
Expand Down Expand Up @@ -214,6 +218,7 @@ function ControlledAutocomplete(
? onNewOptionModalOpen
: onNewOptionInlineSelected,
isCreating: isCreatingNewOption,
validate: INLINE_ADD_NEW_VALIDATE[addNewOption.id],
}
: undefined
}
Expand All @@ -224,7 +229,7 @@ function ControlledAutocomplete(
{/* the input state in the modal is reset on unmount */}
{addNewOption?.type === 'modal' && isNewOptionModalOpen && (
<AddNewOptionModal
modalHeader={addNewOption.label}
addNewId={addNewOption.id}
onClose={onNewOptionModalClose}
onSubmit={onNewOptionModalSubmit}
/>
Expand Down
14 changes: 9 additions & 5 deletions packages/frontend/src/components/Editor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@ export const NON_EDITABLE_APP_CONNECTIONS = Object.keys(
)

/**
* Flattened array of all fields where collaborators cannot add new options.
* This is derived from NON_EDITABLE_APPS_FIELDS and used for quick field-level checks.
* Collaborators cannot add new options for the following addNewIds.
*/
export const COLLABORATOR_RESTRICTED_FIELDS = Object.values(
NON_EDITABLE_APPS_FIELDS,
).flat()
export const COLLABORATOR_RESTRICTED_ADDNEW_IDS = [
// Collaborators cannot create new tables in Databricks.
'databricks-createTable',
// Collaborators cannot create new columns in Databricks.
'databricks-createTableColumn',
// Collaborators cannot add new tiles via the dropdown.
'tiles-createTileRow-tableId',
]
10 changes: 6 additions & 4 deletions packages/frontend/src/components/InputCreator/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EditorContext } from '@/contexts/Editor'
import { useIsFieldHidden } from '@/helpers/isFieldHidden'
import useDynamicData from '@/hooks/useDynamicData'

import { COLLABORATOR_RESTRICTED_FIELDS } from '../Editor/constants'
import { COLLABORATOR_RESTRICTED_ADDNEW_IDS } from '../Editor/constants'

import BooleanRadio from './BooleanRadio'

Expand Down Expand Up @@ -58,9 +58,11 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
const { flow } = useContext(EditorContext)

const canCollaboratorAddNew =
!COLLABORATOR_RESTRICTED_FIELDS.find(
(item) => item.label === label && item.key === name,
) && flow?.role === 'editor'
schema.type === 'dropdown' &&
!COLLABORATOR_RESTRICTED_ADDNEW_IDS.find(
(addNewId) => addNewId === schema.addNewOption?.id,
) &&
flow?.role !== 'owner'
const isReadOnly =
// flow is an empty object if it is not in the editor
(Object.keys(flow).length > 0 && flow?.role !== 'owner') || readOnly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface SingleSelectProviderProps<
type: DropdownAddNewType
onSelected: (value: string) => void
isCreating: boolean
validate?: (value: string) => string | null
}
isSearchable?: boolean
isBorderless?: boolean
Expand Down Expand Up @@ -188,16 +189,18 @@ export const SingleSelectProvider = ({
const addInlineNewOption = useCallback(
(filteredItems: ComboboxItem<string>[], inputValue?: string) => {
if (inputValue?.trim() && !getItemByValue(inputValue)) {
const validationError = addNew?.validate?.(inputValue.trim())
filteredItems.push({
value: ADD_NEW_PLACEHOLDER_VALUE, // this is not referenced anywhere else
label: inputValue,
description: addNew?.label ?? 'Create new',
description: validationError ?? addNew?.label ?? 'Create new',
isAddNew: true,
icon: BxPlus,
disabled: !!validationError,
})
}
},
[addNew?.label, getItemByValue],
[addNew, getItemByValue],
)

const handleInputChange = useCallback(
Expand Down
7 changes: 7 additions & 0 deletions packages/frontend/src/graphql/mutations/dynamic-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { graphql } from '@/graphql/__generated__'

export const DYNAMIC_ACTION = graphql(`
mutation DynamicAction($input: DynamicActionInput!) {
dynamicAction(input: $input)
}
`)
Loading