Skip to content

Commit

Permalink
feat: add url input model (#136)
Browse files Browse the repository at this point in the history
* 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
siloneco authored Jan 3, 2024
1 parent d75cb2b commit eab9129
Show file tree
Hide file tree
Showing 15 changed files with 631 additions and 1 deletion.
Binary file modified bun.lockb
Binary file not shown.
98 changes: 98 additions & 0 deletions src/components/model/Work/components/URLInput/hooks/index.ts
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 src/components/model/Work/components/URLInput/index.stories.tsx
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 src/components/model/Work/components/URLInput/index.test.tsx
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();
});
});
13 changes: 13 additions & 0 deletions src/components/model/Work/components/URLInput/index.tsx
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} />
);
Loading

0 comments on commit eab9129

Please sign in to comment.