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

refactor(COREL): update title & date validation #7148

Merged
merged 12 commits into from
Jul 15, 2024
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
Loading