-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: add url input modal * style: use function directive to avoid react eslint warning * refactor: input border width will be now 2px in default * chore: delete debug code * fix: apply reviewed changes * test: separate adding and deleting url * test: add search params test for url validation * fix: component height changes when error message appeared * style: delete unnecessary variable name definition * feat: applied new design
- Loading branch information
Showing
15 changed files
with
631 additions
and
1 deletion.
There are no files selected for viewing
98 changes: 98 additions & 0 deletions
98
src/components/model/Work/components/URLInput/hooks/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import { useState } from 'react'; | ||
import type { ChangeEvent } from 'react'; | ||
|
||
import { isValidUrl } from '../logics'; | ||
|
||
type UseURLInputProps = { | ||
links: string[]; | ||
setLinks: (links: string[]) => void; | ||
}; | ||
|
||
type IUseURLInput = { | ||
deleteLink: (link: string) => void; | ||
input: string; | ||
warning: Warning; | ||
handleChange: (e: ChangeEvent<HTMLInputElement>) => void; | ||
handleSubmit: () => void; | ||
}; | ||
|
||
export const warningMessages = { | ||
ok: '', | ||
invalidUrl: '正しいURLを入力してください', | ||
duplicateUrl: 'このURLは既に登録されています', | ||
empty: 'URLを入力してください', | ||
} as const; | ||
|
||
type WarningStatus = 'ok' | 'invalid' | 'duplicate' | 'empty'; | ||
type WarningMessage = (typeof warningMessages)[keyof typeof warningMessages]; | ||
export type Warning = { | ||
status: WarningStatus; | ||
message: WarningMessage; | ||
}; | ||
|
||
export const useURLInput = ({ | ||
links, | ||
setLinks, | ||
}: UseURLInputProps): IUseURLInput => { | ||
const [input, setInput] = useState<string>(''); | ||
const [warning, setWarning] = useState<Warning>({ | ||
status: 'ok', | ||
message: warningMessages.ok, | ||
}); | ||
|
||
const addLink = (link: string): void => { | ||
setLinks([...links, link]); | ||
}; | ||
|
||
const deleteLink = (link: string): void => { | ||
setLinks(links.filter((item) => item !== link)); | ||
}; | ||
|
||
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => { | ||
const value = e.target.value; | ||
setInput(value); | ||
|
||
// 入力中は警告を消す | ||
if (warning.status !== 'ok') { | ||
setWarning({ | ||
status: 'ok', | ||
message: warningMessages.ok, | ||
}); | ||
} | ||
}; | ||
|
||
const handleSubmit = (): void => { | ||
if (input === '') { | ||
setWarning({ | ||
status: 'empty', | ||
message: warningMessages.empty, | ||
}); | ||
return; | ||
} | ||
|
||
if (!isValidUrl(input)) { | ||
setWarning({ | ||
status: 'invalid', | ||
message: warningMessages.invalidUrl, | ||
}); | ||
return; | ||
} else if (links.includes(input)) { | ||
setWarning({ | ||
status: 'duplicate', | ||
message: warningMessages.duplicateUrl, | ||
}); | ||
return; | ||
} | ||
|
||
addLink(input); | ||
setInput(''); | ||
}; | ||
|
||
return { | ||
deleteLink, | ||
input, | ||
warning, | ||
handleChange, | ||
handleSubmit, | ||
}; | ||
}; |
118 changes: 118 additions & 0 deletions
118
src/components/model/Work/components/URLInput/index.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import { useState } from 'react'; | ||
|
||
import { useURLInput, warningMessages } from './hooks'; | ||
import { URLInputPresentation } from './presentations'; | ||
|
||
import { URLInput } from '.'; | ||
|
||
import type { Warning } from './hooks'; | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
|
||
const meta: Meta<typeof URLInput> = { | ||
component: URLInput, | ||
parameters: { | ||
layout: 'centered', | ||
}, | ||
tags: ['autodocs'], | ||
args: { | ||
maxAmount: 5, | ||
}, | ||
}; | ||
|
||
export default meta; | ||
|
||
type Story = StoryObj<typeof URLInput>; | ||
|
||
export const Default: Story = { | ||
args: {}, | ||
render: function Render(args) { | ||
const [links, setLinks] = useState<string[]>([]); | ||
return <URLInput {...args} links={links} setLinks={setLinks} />; | ||
}, | ||
}; | ||
|
||
export const Full: Story = { | ||
args: { | ||
maxAmount: 3, | ||
}, | ||
render: function Render(args) { | ||
const [links, setLinks] = useState<string[]>([ | ||
'https://example.com/1', | ||
'https://example.com/2', | ||
'https://example.com/3', | ||
]); | ||
|
||
return <URLInput {...args} links={links} setLinks={setLinks} />; | ||
}, | ||
}; | ||
|
||
export const InvalidUrl: Story = { | ||
args: { | ||
maxAmount: 5, | ||
}, | ||
render: function Render({ maxAmount }) { | ||
const [links, setLinks] = useState<string[]>([]); | ||
const { deleteLink, handleChange, handleSubmit, input, warning } = | ||
useURLInput({ | ||
links, | ||
setLinks, | ||
}); | ||
|
||
warning.status = 'invalid'; | ||
warning.message = warningMessages.invalidUrl; | ||
|
||
return ( | ||
<URLInputPresentation | ||
deleteLink={deleteLink} | ||
handleChange={handleChange} | ||
handleSubmit={handleSubmit} | ||
input={input} | ||
links={links} | ||
maxAmount={maxAmount} | ||
warning={warning} | ||
/> | ||
); | ||
}, | ||
}; | ||
|
||
export const DuplicateUrl: Story = { | ||
args: { | ||
maxAmount: 5, | ||
}, | ||
render: function Render({ maxAmount }) { | ||
const [links, setLinks] = useState<string[]>([]); | ||
const { deleteLink, handleChange, handleSubmit, input } = useURLInput({ | ||
links, | ||
setLinks, | ||
}); | ||
|
||
const warning: Warning = { | ||
status: 'duplicate', | ||
message: warningMessages.duplicateUrl, | ||
}; | ||
|
||
return ( | ||
<URLInputPresentation | ||
deleteLink={deleteLink} | ||
handleChange={handleChange} | ||
handleSubmit={handleSubmit} | ||
input={input} | ||
links={links} | ||
maxAmount={maxAmount} | ||
warning={warning} | ||
/> | ||
); | ||
}, | ||
}; | ||
|
||
export const ResponsiveCheck: Story = { | ||
args: {}, | ||
render: function Render(args) { | ||
const [links, setLinks] = useState<string[]>([]); | ||
return ( | ||
<div className="w-[80vw] max-w-[800px]"> | ||
<URLInput {...args} links={links} setLinks={setLinks} /> | ||
</div> | ||
); | ||
}, | ||
}; |
162 changes: 162 additions & 0 deletions
162
src/components/model/Work/components/URLInput/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { render, screen } from '@testing-library/react'; | ||
import '@testing-library/jest-dom'; | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
import { URLInput } from '.'; | ||
|
||
const useLinksAndSetLinks = ( | ||
defaultLinks?: string[] | ||
): [string[], jest.Mock] => { | ||
const links: string[] = defaultLinks ? [...defaultLinks] : []; | ||
const setLinks = jest.fn().mockImplementation((newLinks: string[]): void => { | ||
links.splice(0, links.length); | ||
links.push(...newLinks); | ||
}); | ||
|
||
return [links, setLinks]; | ||
}; | ||
|
||
describe('model/URLInput', () => { | ||
it('renders input and added links correctly', async () => { | ||
const url = 'https://example.com'; | ||
|
||
const [links, setLinks] = useLinksAndSetLinks(); | ||
|
||
const { container } = render( | ||
<URLInput links={links} setLinks={setLinks} maxAmount={5} /> | ||
); | ||
|
||
const inputElement = container.querySelector('input'); | ||
expect(inputElement).toBeInTheDocument(); | ||
|
||
await userEvent.type(inputElement as HTMLInputElement, `${url}{Enter}`); | ||
expect(screen.getByText(url)).toBeInTheDocument(); | ||
}); | ||
|
||
it('calls setLinks when a url added', async () => { | ||
const baseUrl = 'https://example.com'; | ||
|
||
const [links, mockSetLinks] = useLinksAndSetLinks(); | ||
|
||
const { container } = render( | ||
<URLInput links={links} setLinks={mockSetLinks} maxAmount={5} /> | ||
); | ||
|
||
const inputElement = container.querySelector('input'); | ||
expect(inputElement).toBeInTheDocument(); | ||
|
||
// First url addition | ||
const firstUrl = `${baseUrl}/1`; | ||
await userEvent.type( | ||
inputElement as HTMLInputElement, | ||
`${firstUrl}{Enter}` | ||
); | ||
|
||
expect(mockSetLinks).toHaveBeenCalledTimes(1); | ||
|
||
const firstCallArgs: string[][] = mockSetLinks.mock.calls[0] as string[][]; | ||
expect(firstCallArgs[0] as string[]).toEqual([firstUrl]); | ||
|
||
// Second url addition | ||
const secondUrl = `${baseUrl}/2`; | ||
await userEvent.type( | ||
inputElement as HTMLInputElement, | ||
`${secondUrl}{Enter}` | ||
); | ||
|
||
expect(mockSetLinks).toHaveBeenCalledTimes(2); | ||
|
||
const secondCallArgs: string[][] = mockSetLinks.mock.calls[1] as string[][]; | ||
expect(secondCallArgs[0] as string[]).toEqual([firstUrl, secondUrl]); | ||
}); | ||
|
||
it('calls setLinks when a url added', async () => { | ||
const deleteUrl = 'https://example.com/1'; | ||
const remainUrl = 'https://example.com/2'; | ||
const [links, mockSetLinks] = useLinksAndSetLinks([deleteUrl, remainUrl]); | ||
|
||
render(<URLInput links={links} setLinks={mockSetLinks} maxAmount={5} />); | ||
|
||
const firstUrlAddedElement = await screen.findByText(deleteUrl); | ||
const parent = firstUrlAddedElement.parentElement; | ||
expect(parent).not.toBeNull(); | ||
|
||
const deleteButton = (parent as HTMLElement).querySelector('svg.lucide-x'); | ||
expect(deleteButton).not.toBeNull(); | ||
|
||
await userEvent.click(deleteButton as SVGSVGElement); | ||
|
||
expect(mockSetLinks).toHaveBeenCalledTimes(1); | ||
|
||
const thirdCallArgs: string[][] = mockSetLinks.mock.calls[0] as string[][]; | ||
expect(thirdCallArgs[0] as string[]).toEqual([remainUrl]); | ||
}); | ||
|
||
it('rejects user input when the amount of links reached maxAmount', async () => { | ||
const baseUrl = 'https://example.com'; | ||
|
||
const [links, mockSetLinks] = useLinksAndSetLinks(); | ||
|
||
const { container } = render( | ||
<URLInput links={links} setLinks={mockSetLinks} maxAmount={1} /> | ||
); | ||
|
||
const inputElement = container.querySelector('input'); | ||
expect(inputElement).toBeInTheDocument(); | ||
|
||
const firstUrl = `${baseUrl}/1`; | ||
await userEvent.type( | ||
inputElement as HTMLInputElement, | ||
`${firstUrl}{Enter}` | ||
); | ||
expect(screen.getByText(firstUrl)).toBeInTheDocument(); | ||
|
||
expect(inputElement).toBeDisabled(); | ||
|
||
const secondUrl = `${baseUrl}/2`; | ||
await userEvent.type( | ||
inputElement as HTMLInputElement, | ||
`${secondUrl}{Enter}` | ||
); | ||
expect(screen.queryByText(secondUrl)).not.toBeInTheDocument(); | ||
}); | ||
|
||
it('rejects when duplicate url is tried to be added', async () => { | ||
const url = 'https://example.com'; | ||
|
||
const [links, mockSetLinks] = useLinksAndSetLinks(); | ||
|
||
const { container } = render( | ||
<URLInput links={links} setLinks={mockSetLinks} maxAmount={5} /> | ||
); | ||
|
||
const inputElement = container.querySelector('input'); | ||
expect(inputElement).toBeInTheDocument(); | ||
|
||
await userEvent.type(inputElement as HTMLInputElement, `${url}{Enter}`); | ||
expect(screen.getByText(url)).toBeInTheDocument(); | ||
|
||
await userEvent.type(inputElement as HTMLInputElement, `${url}{Enter}`); | ||
expect(await screen.findAllByText(url)).toHaveLength(1); | ||
expect((inputElement as HTMLInputElement).value).toBe(url); | ||
}); | ||
|
||
it('rejects when invalid text is tried to be added', async () => { | ||
const invalidText = 'this text is not a url'; | ||
|
||
const [links, mockSetLinks] = useLinksAndSetLinks(); | ||
|
||
const { container } = render( | ||
<URLInput links={links} setLinks={mockSetLinks} maxAmount={5} /> | ||
); | ||
|
||
const inputElement = container.querySelector('input'); | ||
expect(inputElement).toBeInTheDocument(); | ||
|
||
await userEvent.type( | ||
inputElement as HTMLInputElement, | ||
`${invalidText}{Enter}` | ||
); | ||
expect(screen.queryByText(invalidText)).not.toBeInTheDocument(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import type { FC } from 'react'; | ||
|
||
import { URLInputContainer } from './presentations'; | ||
|
||
type Props = { | ||
links: string[]; | ||
setLinks: (links: string[]) => void; | ||
maxAmount: number; | ||
}; | ||
|
||
export const URLInput: FC<Props> = (props: Props) => ( | ||
<URLInputContainer {...props} /> | ||
); |
Oops, something went wrong.