Skip to content

Commit

Permalink
Merge pull request #66 from itizaworld/feature/implement-create-task-…
Browse files Browse the repository at this point in the history
…modal

TaskListを実装した
  • Loading branch information
itizawa authored Dec 21, 2023
2 parents 8e7db3d + 91978ce commit 6b9a501
Show file tree
Hide file tree
Showing 20 changed files with 316 additions and 67 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
build:
name: Node.js ubuntu-latest 14.x
name: Node.js ubuntu-latest 20.x
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
Expand All @@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20

- name: before cache
run: |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"next": "13.5.6",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.49.2",
"rehype-sanitize": "^6.0.0",
"sass": "^1.69.5",
"url-join": "^5.0.0"
Expand Down
30 changes: 21 additions & 9 deletions src/app/[slug]/[date]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Metadata } from 'next';
import { NippoBreadcrumbs } from './_components/NippoBreadcrumbs';
import { getNippoByDate } from '~/app/_actions/nippoActions';
import { getObjectiveBySlug } from '~/app/_actions/objectiveActions';
import { getObjectiveBySlug, getObjectiveTasks } from '~/app/_actions/objectiveActions';
import { URLS } from '~/app/_constants/urls';
import { generateNippoMetadata } from '~/libs/generateNippoMetadata';
import { fetchMe } from '~/app/_actions/userActions';
import { NippoEditor } from '~/app/_components/domains/Nippo/NippoEditor';
import { getDateString } from '~/libs/getDateString';
import { NippoPreview } from '~/app/_components/domains/Nippo/NippoPreview';
import { NippoShareIcon } from '~/app/_components/domains/Nippo/NippoShareIcon';
import { TaskList } from '~/app/_components/domains/Task/TaskList';

type Props = { params: { slug: string; date: string } };

Expand All @@ -25,22 +26,33 @@ export default async function Page({ params }: Props) {
getNippoByDate(params.slug, params.date),
fetchMe(),
]);
const { result: tasksPagination } = await getObjectiveTasks({ objectiveId: objective._id, page: 1 });
const dateString = getDateString(params.date);

return (
<div className="drop-shadow-sm">
<div className="min-h-[500px] max-w-[1024px] mx-auto flex gap-[16px] md:gap-[48px]">
<div className="px-[8px] pt-[8px] pb-[32px] w-[100%]">
<NippoBreadcrumbs objective={objective} date={dateString} />
<div className="mt-[32px] mb-[8px] flex justify-between">
<p className="text-xl font-bold text-gray-700">{dateString}</p>
{nippo && <NippoShareIcon slug={objective.slug} nippo={nippo} />}
<div className="flex gap-[16px] md:flex-row flex-col mt-[32px]">
<div className="flex-1">
<div className="mb-[8px] flex justify-between">
<p className="text-xl font-bold text-gray-700">{dateString}</p>
{nippo && <NippoShareIcon slug={objective.slug} nippo={nippo} />}
</div>
{currentUser?._id === objective.createdUserId ? (
<NippoEditor objectiveId={objective._id} nippo={nippo} date={params.date} />
) : (
<NippoPreview nippo={nippo} />
)}
</div>
{/* TODO:一旦非表示 */}
{false && (
<div className="md:mt-[40px] w-[100%] md:w-[200px]">
<TaskList objectiveId={objective._id} tasks={tasksPagination.docs} />
</div>
)}
</div>
{currentUser?._id === objective.createdUserId ? (
<NippoEditor objectiveId={objective._id} nippo={nippo} date={params.date} />
) : (
<NippoPreview nippo={nippo} />
)}
</div>
</div>
</div>
Expand Down
16 changes: 15 additions & 1 deletion src/app/_actions/objectiveActions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
'use server';

import { redirect } from 'next/navigation';
import { API_OBJECTIVE, API_OBJECTIVE_ID, API_OBJECTIVE_ID_NIPPO, API_OBJECTIVE_ME, API_OBJECTIVE_SLUG } from '../_constants/apiUrls';
import {
API_OBJECTIVE,
API_OBJECTIVE_ID,
API_OBJECTIVE_ID_NIPPO,
API_OBJECTIVE_ID_TASK,
API_OBJECTIVE_ME,
API_OBJECTIVE_SLUG,
} from '../_constants/apiUrls';
import { URLS } from '../_constants/urls';
import { Objective } from '~/domains/Objective';
import { apiGet, apiPost } from '~/libs/apiClient';
import { Nippo } from '~/domains/Nippo';
import { PaginationResult } from '~/domains/PaginationResult';
import { Task } from '~/domains/Task';

export const getObjectiveMe = async () => {
return await apiGet<{ objective: Objective }>(API_OBJECTIVE_ME());
Expand Down Expand Up @@ -47,3 +55,9 @@ export const getObjectiveNippos = async ({
next: isMyObjective ? undefined : { revalidate: 60 },
});
};

export const getObjectiveTasks = async ({ objectiveId, page }: { objectiveId: string; page: number }) => {
return await apiGet<{ result: PaginationResult<Task> }>(`${API_OBJECTIVE_ID_TASK(objectiveId)}?page=${page}`, {
cache: 'no-store',
});
};
26 changes: 26 additions & 0 deletions src/app/_actions/taskActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use server';

import { API_TASK } from '../_constants/apiUrls';
import { apiPost } from '~/libs/apiClient';
import { Task } from '~/domains/Task';

export const postTask = async ({
body,
title,
dueDate,
objectiveId,
}: {
body: string;
title: string;
dueDate: Date;
objectiveId: string;
}) => {
return await apiPost<{ task: Task }>(API_TASK(), {
body: JSON.stringify({
body,
title,
dueDate,
objectiveId,
}),
});
};
58 changes: 3 additions & 55 deletions src/app/_components/domains/Nippo/NippoEditor/NippoEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
'use client';

import './styles.scss';

import { Color } from '@tiptap/extension-color';
import { Link } from '@tiptap/extension-link';
import { useEffect, useState, FC, useCallback } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import TextStyle from '@tiptap/extension-text-style';
import Placeholder from '@tiptap/extension-placeholder';
import StarterKit from '@tiptap/starter-kit';
import ListItem from '@tiptap/extension-list-item';
import Heading from '@tiptap/extension-heading';
import { FC, useCallback } from 'react';
import { postNippo } from '~/app/_actions/nippoActions';
import { Nippo } from '~/domains/Nippo';
import { useDebounce } from '~/libs/useDebounce';
import { DebounceEditor } from '~/app/_components/uiParts/Editor/DebounceEditor';

type Props = {
objectiveId: string;
Expand All @@ -22,54 +12,12 @@ type Props = {
};

export const NippoEditor: FC<Props> = ({ objectiveId, nippo, date }) => {
const [inputText, setInputText] = useState<string>();
const debouncedInputText = useDebounce({ value: inputText, delay: 200 });

const handleEditorChange = useCallback(
async (body: string) => {
await postNippo({ objectiveId, date, body });
},
[date, objectiveId],
);

useEffect(() => {
debouncedInputText && handleEditorChange(debouncedInputText);
}, [debouncedInputText, handleEditorChange]);

const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
Link.configure(),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder: '振り返りを記入しましょう!',
}),
Heading.configure({
levels: [1, 2, 3, 4, 5],
}),
];

const editor = useEditor({
extensions,
content: nippo?.body,
autofocus: 'end',
onUpdate: ({ editor }) => {
setInputText(editor.getHTML());
},
editorProps: {
attributes: {
class: 'min-h-[400px]',
},
},
});

return <EditorContent editor={editor} />;
return <DebounceEditor body={nippo?.body} onChange={handleEditorChange} placeholder="振り返りを記入しましょう!" />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { FC, useCallback, useState } from 'react';
import { Button, Input, Modal, ModalBody, ModalContent, ModalFooter } from '@nextui-org/react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { TaskEditor } from '../TaskEditor';
import { postTask } from '~/app/_actions/taskActions';

type Props = {
isOpen: boolean;
onOpenChange: () => void;
objectiveId: string;
};

interface IFormInput {
title: string;
body: string;
}

export const CreateModal: FC<Props> = ({ isOpen, onOpenChange, objectiveId }) => {
const [isLoading, setIsLoading] = useState(false);

const { control, formState, handleSubmit, reset } = useForm<IFormInput>({
defaultValues: {
title: '',
body: '',
},
});

const handleOpenChange = useCallback(() => {
reset();
onOpenChange();
}, [onOpenChange, reset]);

const onSubmit: SubmitHandler<IFormInput> = useCallback(
async (inputData) => {
if (isLoading) return;
setIsLoading(true);
postTask({ title: inputData.title, body: inputData.body, dueDate: new Date(), objectiveId })
.catch((error) => {
// TODO: 本来はコンソールに出すのではなく、ユーザーにエラーを通知する
console.error(error);
})
.then(() => {
handleOpenChange();
})
.finally(() => {
setIsLoading(false);
});
},
[handleOpenChange, isLoading, objectiveId],
);

return (
<Modal isOpen={isOpen} onOpenChange={handleOpenChange} placement="center" hideCloseButton size="xl">
<ModalContent>
<ModalBody className="my-[8px]">
<Controller
name="title"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<Input
{...field}
placeholder="タスクの名前"
isInvalid={fieldState.isDirty && field.value.length === 0}
errorMessage={fieldState.isDirty && field.value.length === 0 && 'タイトルを入力してください'}
variant="underlined"
size="lg"
/>
)}
/>
<Controller
name="body"
control={control}
rules={{ required: true, minLength: 1 }}
render={({ field }) => <TaskEditor onChangeText={(body) => field.onChange(body)} />}
/>
</ModalBody>
<ModalFooter>
<Button color="primary" onClick={handleSubmit(onSubmit)} isLoading={isLoading} isDisabled={!formState.isValid}>
タスクを作成
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
1 change: 1 addition & 0 deletions src/app/_components/domains/Task/CreateTaskModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CreateModal } from './CreateTaskModal';
12 changes: 12 additions & 0 deletions src/app/_components/domains/Task/TaskEditor/TaskEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';

import { FC } from 'react';
import { Editor } from '~/app/_components/uiParts/Editor';

type Props = {
onChangeText: (body: string) => void;
};

export const TaskEditor: FC<Props> = ({ onChangeText }) => {
return <Editor onChange={onChangeText} placeholder="タスクの内容を記入しましょう" />;
};
1 change: 1 addition & 0 deletions src/app/_components/domains/Task/TaskEditor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TaskEditor } from './TaskEditor';
35 changes: 35 additions & 0 deletions src/app/_components/domains/Task/TaskList/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { Button, Card, useDisclosure } from '@nextui-org/react';
import { FC } from 'react';
import { CreateModal } from '../CreateTaskModal';
import { Icon } from '~/app/_components/uiParts/icons';
import { Task } from '~/domains/Task';

type Props = {
objectiveId: string;
tasks: Task[];
};

export const TaskList: FC<Props> = ({ objectiveId, tasks }) => {
const { isOpen, onOpen, onOpenChange } = useDisclosure();

return (
<div className="w-full">
<p className="text-md font-bold mb-[16px] text-gray-700">タスク一覧</p>
<div className="flex flex-col gap-[8px]">
{tasks.map((task) => {
return (
<Card key={task._id} className="md:p-[8px] p-[4px] flex flex-col rounded-[8px] cursor-pointer" shadow="none" radius="none">
ここにタスク一覧が表示されます
</Card>
);
})}
</div>
<Button className="mt-[8px]" size="sm" color="primary" variant="light" startContent={<Icon icon="PLUS" />} onClick={() => onOpen()}>
タスクの追加
</Button>
<CreateModal isOpen={isOpen} onOpenChange={onOpenChange} objectiveId={objectiveId} />
</div>
);
};
1 change: 1 addition & 0 deletions src/app/_components/domains/Task/TaskList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TaskList } from './TaskList';
31 changes: 31 additions & 0 deletions src/app/_components/uiParts/Editor/DebounceEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import './styles.scss';

import { useEffect, useState, FC, useCallback } from 'react';
import { Editor } from './Editor';
import { useDebounce } from '~/libs/useDebounce';

type Props = {
body?: string;
placeholder: string;
onChange: (body: string) => Promise<void>;
};

export const DebounceEditor: FC<Props> = ({ body, placeholder, onChange }) => {
const [inputText, setInputText] = useState<string>();
const debouncedInputText = useDebounce({ value: inputText, delay: 200 });

const handleEditorChange = useCallback(
async (body: string) => {
await onChange(body);
},
[onChange],
);

useEffect(() => {
debouncedInputText && handleEditorChange(debouncedInputText);
}, [debouncedInputText, handleEditorChange]);

return <Editor body={body} placeholder={placeholder} onChange={(body) => setInputText(body)} />;
};
Loading

0 comments on commit 6b9a501

Please sign in to comment.