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

TaskListを実装した #66

Merged
merged 15 commits into from
Dec 21, 2023
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
Loading