Skip to content

Commit

Permalink
refactor(COREL): update title & date validation (#7148)
Browse files Browse the repository at this point in the history
* refactor(sanity): refactor error setting

* refactor(sanity): refactor title error setting

* refactor(sanity): refactor date error setting

* docs(sanity): update comments

* chore(sanity): clean up

* test(sanity): update bundleiconeditorpicker test

* test(sanity): update bundleForm test

* test(sanity): update bundleForm & createBundleDialog test

* test: update form and update tests
  • Loading branch information
RitaDias authored and bjoerge committed Aug 2, 2024
1 parent 688a740 commit 1172543
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 55 deletions.
110 changes: 71 additions & 39 deletions packages/sanity/src/core/bundles/components/dialog/BundleForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable i18next/no-literal-string */
import {CalendarIcon} from '@sanity/icons'
import {Box, Button, Card, Flex, Popover, Stack, Text, TextArea, TextInput} from '@sanity/ui'
import {Box, Button, Flex, Popover, Stack, Text, TextArea, TextInput} from '@sanity/ui'
import {useCallback, useMemo, useState} from 'react'
import {useDateTimeFormat, useTranslation} from 'sanity'
import {
FormFieldHeaderText,
type FormNodeValidation,
useBundles,
useDateTimeFormat,
useTranslation,
} from 'sanity'
import speakingurl from 'speakingurl'

import {type CalendarLabels} from '../../../form/inputs/DateInputs/base/calendar/types'
Expand All @@ -14,16 +20,24 @@ import {BundleIconEditorPicker, type BundleIconEditorPickerValue} from './Bundle

export function BundleForm(props: {
onChange: (params: Partial<BundleDocument>) => void
onError: (errorsExist: boolean) => void
value: Partial<BundleDocument>
}): JSX.Element {
const {onChange, value} = props
const {onChange, onError, value} = props
const {title, description, icon, hue, publishAt} = value

const dateFormatter = useDateTimeFormat()

const [showTitleValidation, setShowTitleValidation] = useState(false)
const [showDateValidation, setShowDateValidation] = useState(false)
const [showDatePicker, setShowDatePicker] = useState(false)
const [showBundleExists, setShowBundleExists] = useState(false)
const [showIsDraftPublishError, setShowIsDraftPublishError] = useState(false)

const [isInitialRender, setIsInitialRender] = useState(true)
const {data} = useBundles()

const [titleErrors, setTitleErrors] = useState<FormNodeValidation[]>([])
const [dateErrors, setDateErrors] = useState<FormNodeValidation[]>([])

const publishAtDisplayValue = useMemo(() => {
if (!publishAt) return ''
Expand All @@ -45,16 +59,44 @@ export function BundleForm(props: {
const handleBundleTitleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const pickedTitle = event.target.value

if (isDraftOrPublished(pickedTitle)) {
setShowTitleValidation(true)
const pickedNameExists =
data && data.find((bundle) => bundle.name === speakingurl(pickedTitle))
const isEmptyTitle = pickedTitle.trim() === '' && !isInitialRender

if (
isDraftOrPublished(pickedTitle) ||
pickedNameExists ||
(isEmptyTitle && !isInitialRender)
) {
if (isEmptyTitle && !isInitialRender) {
// if the title is empty and it's not the first opening of the dialog, show an error
// TODO localize text

setTitleErrors([{level: 'error', message: 'Bundle needs a name', path: []}])
}
if (isDraftOrPublished(pickedTitle)) {
// if the title is 'drafts' or 'published', show an error
// TODO localize text
setTitleErrors([
{level: 'error', message: "Title cannot be 'drafts' or 'published'", path: []},
])
}
if (pickedNameExists) {
// if the bundle already exists, show an error
// TODO localize text
setTitleErrors([{level: 'error', message: 'Bundle already exists', path: []}])
}

onError(true)
} else {
setShowTitleValidation(false)
setTitleErrors([])
onError(false)
}

setIsInitialRender(false)
onChange({...value, title: pickedTitle, name: speakingurl(pickedTitle)})
},
[onChange, value],
[data, isInitialRender, onChange, onError, value],
)

const handleBundleDescriptionChange = useCallback(
Expand Down Expand Up @@ -88,15 +130,25 @@ export function BundleForm(props: {
// needs to check that the date is not invalid & not empty
// in which case it can update the input value but not the actual bundle value
if (new Date(event.target.value).toString() === 'Invalid Date' && dateValue !== '') {
setShowDateValidation(true)
// if the date is invalid, show an error
// TODO localize text
setDateErrors([
{
level: 'error',
message: 'Should be an empty or valid date',
path: [],
},
])
setDisplayDate(dateValue)
onError(true)
} else {
setShowDateValidation(false)
setDateErrors([])
setDisplayDate(dateValue)
onChange({...value, publishAt: dateValue})
onError(false)
}
},
[onChange, value],
[onChange, value, onError],
)

const handleIconValueChange = useCallback(
Expand All @@ -112,24 +164,13 @@ export function BundleForm(props: {
<BundleIconEditorPicker onChange={handleIconValueChange} value={iconValue} />
</Flex>
<Stack space={3}>
{showTitleValidation && (
<Card tone="critical" padding={3} radius={2}>
<Text align="center" muted size={1}>
{/* localize & validate copy & UI */}
Title cannot be "drafts" or "published"
</Text>
</Card>
)}

{/* TODO ADD CHECK FOR EXISTING NAMES AND AVOID DUPLICATES */}
<Text size={1} weight="medium">
{/* localize text */}
Title
</Text>
{/* localize text */}
<FormFieldHeaderText title="Title" validation={titleErrors} />
<TextInput
data-testid="bundle-form-title"
onChange={handleBundleTitleChange}
customValidity={titleErrors.length > 0 ? 'error' : undefined}
value={title}
data-testid="bundle-form-title"
/>
</Stack>

Expand All @@ -146,18 +187,8 @@ export function BundleForm(props: {
</Stack>

<Stack space={3}>
<Text size={1} weight="medium">
{/* localize text */}
Schedule for publishing at
</Text>
{showDateValidation && (
<Card tone="critical" padding={3} radius={2}>
<Text align="center" muted size={1}>
{/* localize & validate copy & UI */}
Should be an empty or valid date
</Text>
</Card>
)}
{/* localize text */}
<FormFieldHeaderText title="Schedule for publishing at" validation={dateErrors} />

<TextInput
suffix={
Expand Down Expand Up @@ -190,6 +221,7 @@ export function BundleForm(props: {
value={displayDate}
onChange={handlePublishAtInputChange}
data-testid="bundle-form-publish-at"
customValidity={dateErrors.length > 0 ? 'error' : undefined}
/>
</Stack>
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {type FormEvent, useCallback, useState} from 'react'
import {type BundleDocument} from '../../../store/bundles/types'
import {useBundleOperations} from '../../../store/bundles/useBundleOperations'
import {usePerspective} from '../../hooks/usePerspective'
import {isDraftOrPublished} from '../../util/dummyGetters'
import {BundleForm} from './BundleForm'

interface CreateBundleDialogProps {
Expand All @@ -16,6 +15,7 @@ interface CreateBundleDialogProps {
export function CreateBundleDialog(props: CreateBundleDialogProps): JSX.Element {
const {onCancel, onCreate} = props
const {createBundle} = useBundleOperations()
const [hasErrors, setHasErrors] = useState(false)

const [value, setValue] = useState<Partial<BundleDocument>>({
name: '',
Expand Down Expand Up @@ -53,6 +53,10 @@ export function CreateBundleDialog(props: CreateBundleDialogProps): JSX.Element
setValue(changedValue)
}, [])

const handleOnError = useCallback((errorsExist: boolean) => {
setHasErrors(errorsExist)
}, [])

return (
<Dialog
animate
Expand All @@ -64,11 +68,11 @@ export function CreateBundleDialog(props: CreateBundleDialogProps): JSX.Element
>
<form onSubmit={handleOnSubmit}>
<Box padding={6}>
<BundleForm onChange={handleOnChange} value={value} />
<BundleForm onChange={handleOnChange} onError={handleOnError} value={value} />
</Box>
<Flex justify="flex-end" padding={3}>
<Button
disabled={!value.title || isDraftOrPublished(value.title) || isCreating}
disabled={!value.title || isCreating || hasErrors}
iconRight={ArrowRightIcon}
type="submit"
// localize Text
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {fireEvent, render, screen} from '@testing-library/react'
import {type BundleDocument} from 'sanity'
import {type BundleDocument, useDateTimeFormat} from 'sanity'

import {useBundles} from '../../../../store/bundles'
import {createWrapper} from '../../../util/tests/createWrapper'
import {BundleForm} from '../BundleForm'

jest.mock('sanity', () => ({
useDateTimeFormat: jest.fn().mockReturnValue({format: jest.fn().mockReturnValue('Mocked date')}),
useTranslation: jest.fn().mockReturnValue({t: jest.fn().mockReturnValue('Mocked translation')}),
jest.mock('../../../../../core/hooks/useDateTimeFormat', () => ({
useDateTimeFormat: jest.fn(),
}))

jest.mock('../../../../store/bundles', () => ({
useBundles: jest.fn(),
}))

const mockUseBundleStore = useBundles as jest.Mock<typeof useBundles>
const mockUseDateTimeFormat = useDateTimeFormat as jest.Mock

describe('BundleForm', () => {
const onChangeMock = jest.fn()
const onErrorMock = jest.fn()
const valueMock: Partial<BundleDocument> = {
title: '',
description: '',
Expand All @@ -22,12 +30,36 @@ describe('BundleForm', () => {

beforeEach(async () => {
onChangeMock.mockClear()
onErrorMock.mockClear()

// Mock the data returned by useBundles hook
const mockData: BundleDocument[] = [
{
description: 'What a spring drop, allergies galore 🌸',
_updatedAt: '2024-07-12T10:39:32Z',
_rev: 'HdJONGqRccLIid3oECLjYZ',
authorId: 'pzAhBTkNX',
title: 'Spring Drop',
icon: 'heart-filled',
_id: 'db76c50e-358b-445c-a57c-8344c588a5d5',
_type: 'bundle',
name: 'spring-drop',
hue: 'magenta',
_createdAt: '2024-07-02T11:37:51Z',
},
// Add more mock data if needed
]
mockUseBundleStore.mockReturnValue({data: mockData, loading: false, dispatch: jest.fn()})

mockUseDateTimeFormat.mockReturnValue({format: jest.fn().mockReturnValue('Mocked date')})

const wrapper = await createWrapper()
render(<BundleForm onChange={onChangeMock} value={valueMock} />, {wrapper})
render(<BundleForm onChange={onChangeMock} value={valueMock} onError={onErrorMock} />, {
wrapper,
})
})

it('should render the form fields', async () => {
it('should render the form fields', () => {
expect(screen.getByTestId('bundle-form-title')).toBeInTheDocument()
expect(screen.getByTestId('bundle-form-description')).toBeInTheDocument()
expect(screen.getByTestId('bundle-form-publish-at')).toBeInTheDocument()
Expand Down Expand Up @@ -60,4 +92,41 @@ describe('BundleForm', () => {

expect(onChangeMock).toHaveBeenCalledWith({...valueMock, publishAt: ''})
})

it('should show an error when the title is "drafts"', () => {
const titleInput = screen.getByTestId('bundle-form-title')

fireEvent.change(titleInput, {target: {value: 'drafts'}})

expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
})

it('should show an error when the title is "published"', () => {
const titleInput = screen.getByTestId('bundle-form-title')
fireEvent.change(titleInput, {target: {value: 'published'}})

expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
})

it('should show an error when the bundle already exists', () => {
const titleInput = screen.getByTestId('bundle-form-title')
fireEvent.change(titleInput, {target: {value: 'Spring Drop'}})

expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
})

it('should show an error when the title is empty', () => {
const titleInput = screen.getByTestId('bundle-form-title')
fireEvent.change(titleInput, {target: {value: 'test'}}) // Set a valid title first
fireEvent.change(titleInput, {target: {value: ' '}}) // remove the title

expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
})

it('should show an error when the publishAt input value is invalid', () => {
const publishAtInput = screen.getByTestId('bundle-form-publish-at')
fireEvent.change(publishAtInput, {target: {value: 'invalid-date'}})

expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {fireEvent, render, screen} from '@testing-library/react'
import {type BundleDocument} from 'sanity'

import {createWrapper} from '../../../util/tests/createWrapper'
import {BundleIconEditorPicker} from '../BundleIconEditorPicker'
import {BundleIconEditorPicker, type BundleIconEditorPickerValue} from '../BundleIconEditorPicker'

describe('BundleIconEditorPicker', () => {
const onChangeMock = jest.fn()
const valueMock: Partial<BundleDocument> = {
const valueMock: BundleIconEditorPickerValue = {
hue: 'gray',
icon: 'cube',
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {fireEvent, render, screen} from '@testing-library/react'
import {type BundleDocument} from 'sanity'
import {type BundleDocument, useBundles, useDateTimeFormat} from 'sanity'

import {useBundleOperations} from '../../../../store/bundles/useBundleOperations'
import {usePerspective} from '../../../hooks/usePerspective'
import {createWrapper} from '../../../util/tests/createWrapper'
import {CreateBundleDialog} from '../CreateBundleDialog'

jest.mock('sanity', () => ({
useDateTimeFormat: jest.fn().mockReturnValue({format: jest.fn().mockReturnValue('Mocked date')}),
useTranslation: jest.fn().mockReturnValue({t: jest.fn().mockReturnValue('Mocked translation')}),
jest.mock('../../../../../core/hooks/useDateTimeFormat', () => ({
useDateTimeFormat: jest.fn(),
}))

jest.mock('../../../../store/bundles', () => ({
useBundles: jest.fn(),
}))

jest.mock('../../../../store/bundles/useBundleOperations', () => ({
Expand All @@ -24,6 +27,9 @@ jest.mock('../../../hooks/usePerspective', () => ({
}),
}))

const mockUseBundleStore = useBundles as jest.Mock<typeof useBundles>
const mockUseDateTimeFormat = useDateTimeFormat as jest.Mock

describe('CreateBundleDialog', () => {
const onCancelMock = jest.fn()
const onCreateMock = jest.fn()
Expand All @@ -32,6 +38,14 @@ describe('CreateBundleDialog', () => {
onCancelMock.mockClear()
onCreateMock.mockClear()

mockUseBundleStore.mockReturnValue({
data: [],
loading: true,
dispatch: jest.fn(),
})

mockUseDateTimeFormat.mockReturnValue({format: jest.fn().mockReturnValue('Mocked date')})

const wrapper = await createWrapper()
render(<CreateBundleDialog onCancel={onCancelMock} onCreate={onCreateMock} />, {wrapper})
})
Expand Down

0 comments on commit 1172543

Please sign in to comment.