Compare commits
5 Commits
feature/th
...
0756bba2e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0756bba2e5 | |||
| 03cf27a148 | |||
| 2588c0247b | |||
| a845f51a35 | |||
| 9d7a23ea94 |
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* カラム内のアイテム順序を一括再採番する(同一カラムの並べ替え / 他カラムからの移動)。
|
||||||
|
* body: { columnId: number, itemIds: number[] } —— itemIds の順序で orderIndex 0..n-1 を割り当てる。
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnId = Number(body.columnId);
|
||||||
|
const itemIds = Array.isArray(body.itemIds)
|
||||||
|
? body.itemIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const items = service.reorderItems(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
columnId,
|
||||||
|
itemIds
|
||||||
|
);
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,30 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { I18nProvider } from '@/lib/i18n/I18nProvider';
|
import { I18nProvider } from '@/lib/i18n/I18nProvider';
|
||||||
|
import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister';
|
||||||
import type { Locale, Theme } from '@/lib/types';
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Groupware',
|
title: 'Groupware',
|
||||||
description: 'Project-based team collaboration tool',
|
description: 'Project-based team collaboration tool',
|
||||||
|
applicationName: 'Groupware',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'Groupware',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/icon.svg',
|
||||||
|
apple: '/icon-192.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#2563eb',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveTheme(v: string | undefined): Theme {
|
function resolveTheme(v: string | undefined): Theme {
|
||||||
@ -36,6 +54,7 @@ export default async function RootLayout({
|
|||||||
<I18nProvider initialLocale={locale} initialTheme={theme}>
|
<I18nProvider initialLocale={locale} initialTheme={theme}>
|
||||||
{children}
|
{children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
51
app/manifest.ts
Normal file
51
app/manifest.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、
|
||||||
|
* <link rel="manifest"> を自動で <head> に出力する。
|
||||||
|
*/
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'Groupware',
|
||||||
|
short_name: 'Groupware',
|
||||||
|
description: 'Project-based team collaboration tool',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
background_color: '#111827',
|
||||||
|
theme_color: '#2563eb',
|
||||||
|
lang: 'en',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body: input, fileIds }),
|
body: JSON.stringify({ body: input, fileIds }),
|
||||||
});
|
});
|
||||||
setSending(false);
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
// 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。
|
||||||
|
// SSEの chat.message.created は id で重複排除する。
|
||||||
|
const data = (await res.json().catch(() => null)) as {
|
||||||
|
message?: ChatMessageWithAttachments;
|
||||||
|
} | null;
|
||||||
|
if (data?.message) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((m) => m.id === data.message!.id)
|
||||||
|
? prev
|
||||||
|
: [data.message!, ...prev]
|
||||||
|
);
|
||||||
|
}
|
||||||
setInput('');
|
setInput('');
|
||||||
pickerRef.current?.clear();
|
pickerRef.current?.clear();
|
||||||
} else {
|
} else {
|
||||||
@ -91,6 +102,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
|||||||
} | null;
|
} | null;
|
||||||
setError(b?.error?.message ?? '送信に失敗しました');
|
setError(b?.error?.message ?? '送信に失敗しました');
|
||||||
}
|
}
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -24,10 +24,10 @@ export function Header({ user }: { user: PublicUser }) {
|
|||||||
>
|
>
|
||||||
{t('app.name')}
|
{t('app.name')}
|
||||||
</a>
|
</a>
|
||||||
<nav className="flex items-center gap-4 text-sm">
|
<nav className="flex items-center gap-3 text-sm sm:gap-4">
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="text-gray-600 hover:underline dark:text-gray-300"
|
className="hidden text-gray-600 hover:underline sm:inline dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -46,7 +46,10 @@ export function ProjectNav({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800">
|
<nav
|
||||||
|
data-testid="project-nav"
|
||||||
|
className="flex gap-4 overflow-x-auto whitespace-nowrap border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const href = `/projects/${projectId}${item.href}`;
|
const href = `/projects/${projectId}${item.href}`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
27
components/pwa/ServiceWorkerRegister.tsx
Normal file
27
components/pwa/ServiceWorkerRegister.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Worker を登録する(本番環境のみ)。
|
||||||
|
* 開発環境では Next.js の HMR とSWキャッシュが衝突するため登録しない。
|
||||||
|
* 登録失敗はPWAがプログレッシブ拡張であるため無視する。
|
||||||
|
*/
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') return;
|
||||||
|
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const register = () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => undefined);
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
register();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', register, { once: true });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState, type DragEvent } from 'react';
|
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
||||||
import type { TodoColumn, TodoItem } from '@/lib/types';
|
import type { TodoColumn, TodoItem } from '@/lib/types';
|
||||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||||
import { TodoDialog } from '@/components/todo/TodoDialog';
|
import { TodoDialog } from '@/components/todo/TodoDialog';
|
||||||
@ -11,6 +11,11 @@ const PRIORITY_COLORS: Record<string, string> = {
|
|||||||
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */
|
||||||
|
function byOrder(a: TodoItem, b: TodoItem): number {
|
||||||
|
return a.orderIndex - b.orderIndex || a.id - b.id;
|
||||||
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
projectId,
|
projectId,
|
||||||
columns,
|
columns,
|
||||||
@ -25,6 +30,9 @@ export function KanbanBoard({
|
|||||||
const [items, setItems] = useState<TodoItem[]>(initialItems);
|
const [items, setItems] = useState<TodoItem[]>(initialItems);
|
||||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ
|
||||||
|
const lastDragEndAtRef = useRef(0);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
||||||
@ -40,27 +48,84 @@ export function KanbanBoard({
|
|||||||
e.dataTransfer.setData('text/plain', String(itemId));
|
e.dataTransfer.setData('text/plain', String(itemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyReorder(
|
||||||
|
prev: TodoItem[],
|
||||||
|
destColumnId: number,
|
||||||
|
newOrderIds: number[]
|
||||||
|
): TodoItem[] {
|
||||||
|
const order = new Map(newOrderIds.map((id, idx) => [id, idx]));
|
||||||
|
return prev.map((i) =>
|
||||||
|
order.has(i.id)
|
||||||
|
? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! }
|
||||||
|
: i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnItemIds(columnId: number, excludeId?: number): number[] {
|
||||||
|
return items
|
||||||
|
.filter((i) => i.columnId === columnId && i.id !== excludeId)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id)
|
||||||
|
.map((i) => i.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistReorder(
|
||||||
|
destColumnId: number,
|
||||||
|
newOrderIds: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
setItems((prev) => applyReorder(prev, destColumnId, newOrderIds));
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/todos/items/reorder`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
columnId: destColumnId,
|
||||||
|
itemIds: newOrderIds,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('並べ替えに失敗しました');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('並べ替えに失敗しました');
|
||||||
|
} finally {
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
setDraggingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// カラムの空き領域へのドロップ = 末尾に追加
|
||||||
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
|
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||||
if (!itemId) return;
|
if (!itemId) {
|
||||||
const item = items.find((i) => i.id === itemId);
|
|
||||||
if (!item || item.columnId === column.id) {
|
|
||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
|
const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
|
||||||
setItems((prev) =>
|
void persistReorder(column.id, newOrderIds);
|
||||||
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
|
}
|
||||||
);
|
|
||||||
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
|
// カード上へのドロップ = ポインタ位置で前後に挿入
|
||||||
method: 'PATCH',
|
function dropOnItem(e: DragEvent<HTMLElement>, target: TodoItem) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
e.preventDefault();
|
||||||
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
|
e.stopPropagation();
|
||||||
})
|
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||||
.catch(() => undefined)
|
if (!itemId || itemId === target.id) {
|
||||||
.finally(() => reload());
|
setDraggingId(null);
|
||||||
setDraggingId(null);
|
return;
|
||||||
|
}
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const before = e.clientY - rect.top < rect.height / 2;
|
||||||
|
const ordered = columnItemIds(target.columnId, itemId);
|
||||||
|
const insertAt = before
|
||||||
|
? ordered.indexOf(target.id)
|
||||||
|
: ordered.indexOf(target.id) + 1;
|
||||||
|
ordered.splice(insertAt, 0, itemId);
|
||||||
|
void persistReorder(target.columnId, ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTask(columnId: number, title: string) {
|
async function addTask(columnId: number, title: string) {
|
||||||
@ -82,116 +147,142 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
|
<>
|
||||||
{columns.map((column) => {
|
{error && (
|
||||||
const colItems = items.filter((i) => i.columnId === column.id);
|
<p
|
||||||
return (
|
className="mb-2 text-sm text-red-600"
|
||||||
<section
|
role="alert"
|
||||||
key={column.id}
|
data-testid="kanban-error"
|
||||||
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
|
>
|
||||||
onDragOver={(e) => e.preventDefault()}
|
{error}
|
||||||
onDrop={(e) => onDrop(e, column)}
|
</p>
|
||||||
data-testid={`kanban-column-${column.id}`}
|
|
||||||
data-column-id={column.id}
|
|
||||||
>
|
|
||||||
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
|
||||||
{column.name} ({colItems.length})
|
|
||||||
</h2>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{colItems.map((item) => {
|
|
||||||
const tags = item.tags
|
|
||||||
?.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const assignee = members.find(
|
|
||||||
(m) => m.user.id === item.assigneeId
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={item.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => onDragStart(e, item.id)}
|
|
||||||
onClick={() => setSelectedItem(item)}
|
|
||||||
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
|
|
||||||
draggingId === item.id ? 'opacity-50' : ''
|
|
||||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
|
||||||
data-testid={`todo-card-${item.id}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<span
|
|
||||||
className="text-sm hover:text-blue-600 hover:underline"
|
|
||||||
data-testid={`todo-open-${item.id}`}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleComplete(item.id);
|
|
||||||
}}
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
data-testid={`todo-complete-${item.id}`}
|
|
||||||
>
|
|
||||||
{item.completedAt ? '戻す' : '完了'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
|
||||||
PRIORITY_COLORS[item.priority] ??
|
|
||||||
PRIORITY_COLORS.normal
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.priority}
|
|
||||||
</span>
|
|
||||||
{item.dueDate && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
期限: {item.dueDate}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.startDate && (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
開始: {item.startDate}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{assignee && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
@{assignee.user.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tags && tags.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{tags.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{selectedItem && (
|
|
||||||
<TodoDialog
|
|
||||||
projectId={projectId}
|
|
||||||
item={selectedItem}
|
|
||||||
members={members}
|
|
||||||
onClose={() => setSelectedItem(null)}
|
|
||||||
onSaved={reload}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div
|
||||||
|
className="flex gap-4 overflow-x-auto pb-4"
|
||||||
|
data-testid="kanban-board"
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const colItems = items
|
||||||
|
.filter((i) => i.columnId === column.id)
|
||||||
|
.sort(byOrder);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={column.id}
|
||||||
|
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => onDrop(e, column)}
|
||||||
|
data-testid={`kanban-column-${column.id}`}
|
||||||
|
data-column-id={column.id}
|
||||||
|
>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{column.name} ({colItems.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{colItems.map((item) => {
|
||||||
|
const tags = item.tags
|
||||||
|
?.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const assignee = members.find(
|
||||||
|
(m) => m.user.id === item.assigneeId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, item.id)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
lastDragEndAtRef.current = Date.now();
|
||||||
|
setDraggingId(null);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => dropOnItem(e, item)}
|
||||||
|
onClick={() => {
|
||||||
|
// ドラッグ直後の誤クリックでダイアログが開かないようにする
|
||||||
|
if (Date.now() - lastDragEndAtRef.current < 300) return;
|
||||||
|
setSelectedItem(item);
|
||||||
|
}}
|
||||||
|
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
|
||||||
|
draggingId === item.id ? 'opacity-50' : ''
|
||||||
|
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||||
|
data-testid={`todo-card-${item.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className="text-sm hover:text-blue-600 hover:underline"
|
||||||
|
data-testid={`todo-open-${item.id}`}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleComplete(item.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
data-testid={`todo-complete-${item.id}`}
|
||||||
|
>
|
||||||
|
{item.completedAt ? '戻す' : '完了'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
|
PRIORITY_COLORS[item.priority] ??
|
||||||
|
PRIORITY_COLORS.normal
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.priority}
|
||||||
|
</span>
|
||||||
|
{item.dueDate && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
期限: {item.dueDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.startDate && (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
開始: {item.startDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{assignee && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
@{assignee.user.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<TodoDialog
|
||||||
|
projectId={projectId}
|
||||||
|
item={selectedItem}
|
||||||
|
members={members}
|
||||||
|
onClose={() => setSelectedItem(null)}
|
||||||
|
onSaved={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export default tseslint.config(
|
|||||||
'playwright-report/**',
|
'playwright-report/**',
|
||||||
'test-results/**',
|
'test-results/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
'scripts/**/*.mjs',
|
||||||
|
'public/**',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
// /login, /api, _next 静的 assets, favicon を除外
|
// /login, /api, _next 静的 assets, favicon, PWA静的リソース(manifest/SW/icon) を除外
|
||||||
'/((?!login|api|_next/static|_next/image|favicon.ico).*)',
|
'/((?!login|api|_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.webmanifest|icon\\.svg|icon-192\\.png|icon-512\\.png).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
5
public/icon.svg
Normal file
5
public/icon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Groupware">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#2563eb"/>
|
||||||
|
<circle cx="256" cy="196" r="84" fill="#ffffff"/>
|
||||||
|
<path d="M128 432c0-70.7 57.3-128 128-128s128 57.3 128 128v24H128z" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 326 B |
82
public/sw.js
Normal file
82
public/sw.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Groupware service worker: app-shell precache + network-first navigations +
|
||||||
|
// cache-first static assets for offline support and installability.
|
||||||
|
const CACHE = 'ogw-v1';
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
'/',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icon.svg',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
// 一つでも欠けていても全体が失敗しないよう個別に追加
|
||||||
|
await Promise.allSettled(PRECACHE_URLS.map((u) => cache.add(u)));
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
|
||||||
|
);
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
// クロスオリジンとAPIはキャッシュしない
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ナビゲーション: ネットワーク優先(オフライン時はキャッシュ/"/"にフォールバック)
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(req);
|
||||||
|
// エラーや認証リダイレクトの結果をキャッシュしない
|
||||||
|
if (res.ok && res.type === 'basic') {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
cache.put(req, res.clone());
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
const cached = await caches.match(req);
|
||||||
|
return cached || (await caches.match('/')) || Response.error();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静的アセット: キャッシュ優先(stale-while-revalidate)
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cached = await caches.match(req);
|
||||||
|
const network = fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.status === 200 && res.type === 'basic') {
|
||||||
|
const cache = caches.open(CACHE);
|
||||||
|
cache.then((c) => c.put(req, res.clone()));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return cached || (await network) || Response.error();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
26
scripts/generate-icons.mjs
Normal file
26
scripts/generate-icons.mjs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// One-off: rasterize public/icon.svg into 512 and 192 PNGs (any/maskable).
|
||||||
|
// Uses sharp (already a transitive dependency in this project). Run: node scripts/generate-icons.mjs
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const SVG = path.join(ROOT, 'public/icon.svg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const svgBuf = fs.readFileSync(SVG);
|
||||||
|
const sizes = [512, 192];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const out = path.join(ROOT, `public/icon-${size}.png`);
|
||||||
|
await sharp(svgBuf, { density: 384 })
|
||||||
|
.resize(size, size, { fit: 'contain' })
|
||||||
|
.png()
|
||||||
|
.toFile(out);
|
||||||
|
console.log(`wrote ${out} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -235,6 +235,45 @@ export class TodoService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定カラム内のアイテム順序を itemIds の順で再採番する。
|
||||||
|
* 同一カラムの並べ替えだけでなく、別カラムからの移動(対象カラムへの挿入)にも使う。
|
||||||
|
* すべて同一プロジェクト内であることを検証し、1トランザクションで更新する。
|
||||||
|
*/
|
||||||
|
reorderItems(
|
||||||
|
actorId: number,
|
||||||
|
projectId: number,
|
||||||
|
columnId: number,
|
||||||
|
itemIds: number[]
|
||||||
|
): TodoItem[] {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
const column = this.todoRepository.findColumnById(columnId);
|
||||||
|
if (!column || column.projectId !== projectId) {
|
||||||
|
throw new NotFoundError('TodoColumn', columnId);
|
||||||
|
}
|
||||||
|
const ids = Array.isArray(itemIds) ? itemIds : [];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
// 空の並べ替えは何も変更せず、SSE/ログも出さない
|
||||||
|
return this.todoRepository
|
||||||
|
.findItemsByProject(projectId)
|
||||||
|
.filter((i) => i.columnId === columnId);
|
||||||
|
}
|
||||||
|
this.db.transaction(() => {
|
||||||
|
ids.forEach((id, idx) => {
|
||||||
|
const item = this.todoRepository.findItemById(id);
|
||||||
|
if (!item || item.projectId !== projectId) {
|
||||||
|
throw new NotFoundError('TodoItem', id);
|
||||||
|
}
|
||||||
|
this.todoRepository.updateItem(id, { columnId, orderIndex: idx });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.logTodo(projectId, actorId, 'todo_updated', ids[0]);
|
||||||
|
this.broadcastTodo(projectId);
|
||||||
|
return this.todoRepository
|
||||||
|
.findItemsByProject(projectId)
|
||||||
|
.filter((i) => i.columnId === columnId);
|
||||||
|
}
|
||||||
|
|
||||||
toggleComplete(actorId: number, itemId: number): TodoItem {
|
toggleComplete(actorId: number, itemId: number): TodoItem {
|
||||||
const item = this.todoRepository.findItemById(itemId);
|
const item = this.todoRepository.findItemById(itemId);
|
||||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||||
|
|||||||
75
tests/e2e/pwa.spec.ts
Normal file
75
tests/e2e/pwa.spec.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
function unique(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupOwner(page: Page): Promise<number> {
|
||||||
|
const email = unique('owner') + '@example.com';
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||||
|
await page.getByLabel('表示名').fill('Owner');
|
||||||
|
await page.getByLabel('メールアドレス').fill(email);
|
||||||
|
await page.getByLabel('パスワード').fill('password123');
|
||||||
|
await page.getByRole('button', { name: '登録する' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||||
|
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||||
|
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('pwa', () => {
|
||||||
|
test('manifest, service worker and icons are publicly served', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const manifestRes = await request.get('/manifest.webmanifest');
|
||||||
|
expect(manifestRes.ok()).toBeTruthy();
|
||||||
|
const manifest = (await manifestRes.json()) as {
|
||||||
|
name: string;
|
||||||
|
short_name: string;
|
||||||
|
display: string;
|
||||||
|
start_url: string;
|
||||||
|
icons: { sizes: string; type: string; purpose?: string }[];
|
||||||
|
};
|
||||||
|
expect(manifest.name).toBe('Groupware');
|
||||||
|
expect(manifest.short_name).toBe('Groupware');
|
||||||
|
expect(manifest.display).toBe('standalone');
|
||||||
|
expect(manifest.start_url).toBe('/');
|
||||||
|
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(manifest.icons.some((i) => i.sizes === '192x192')).toBeTruthy();
|
||||||
|
expect(manifest.icons.some((i) => i.sizes === '512x512')).toBeTruthy();
|
||||||
|
expect(manifest.icons.some((i) => i.purpose === 'maskable')).toBeTruthy();
|
||||||
|
|
||||||
|
const swRes = await request.get('/sw.js');
|
||||||
|
expect(swRes.ok()).toBeTruthy();
|
||||||
|
const swText = await swRes.text();
|
||||||
|
expect(swText).toContain('fetch');
|
||||||
|
expect(swText).toContain('install');
|
||||||
|
|
||||||
|
expect((await request.get('/icon-192.png')).ok()).toBeTruthy();
|
||||||
|
expect((await request.get('/icon-512.png')).ok()).toBeTruthy();
|
||||||
|
expect((await request.get('/icon.svg')).ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project nav scrolls horizontally on mobile width (no wrap)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
storageState: 'tests/e2e/locale-ja.json',
|
||||||
|
viewport: { width: 375, height: 812 },
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await setupOwner(page);
|
||||||
|
|
||||||
|
const nav = page.getByTestId('project-nav');
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
const scrollable = await nav.evaluate((el) => ({
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
}));
|
||||||
|
// ナビ項目が多数あるため、375px では横スクロールが発生する(折り返さない)
|
||||||
|
expect(scrollable.scrollWidth).toBeGreaterThan(scrollable.clientWidth);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -171,4 +171,58 @@ test.describe('todo / kanban', () => {
|
|||||||
await expect(page.getByTestId('todo-dialog')).toBeVisible();
|
await expect(page.getByTestId('todo-dialog')).toBeVisible();
|
||||||
await expect(page.getByTestId('attachment-list')).toBeVisible();
|
await expect(page.getByTestId('attachment-list')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reorder cards within a column by drag and drop and persist', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const projectId = await setupOwner(page);
|
||||||
|
const colsRes = await page.request.get(
|
||||||
|
`/api/projects/${projectId}/todos/columns`
|
||||||
|
);
|
||||||
|
const cols = (
|
||||||
|
(await colsRes.json()) as { columns: { id: number; name: string }[] }
|
||||||
|
).columns;
|
||||||
|
const backlog = cols.find((c) => c.name === 'Backlog')!;
|
||||||
|
|
||||||
|
// 3 つのタスクを Backlog に作成(作成順 A, B, C)
|
||||||
|
const titles = ['Task-A', 'Task-B', 'Task-C'];
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const title of titles) {
|
||||||
|
const res = await page.request.post(
|
||||||
|
`/api/projects/${projectId}/todos/items`,
|
||||||
|
{ data: { title, columnId: backlog.id } }
|
||||||
|
);
|
||||||
|
const { item } = (await res.json()) as { item: { id: number } };
|
||||||
|
ids.push(item.id);
|
||||||
|
}
|
||||||
|
const [aId, bId, cId] = ids;
|
||||||
|
|
||||||
|
await page.goto(`/projects/${projectId}/todos`);
|
||||||
|
const col = page.getByTestId(`kanban-column-${backlog.id}`);
|
||||||
|
|
||||||
|
async function orderIds(): Promise<number[]> {
|
||||||
|
return col
|
||||||
|
.locator('[data-testid^="todo-open-"]')
|
||||||
|
.evaluateAll((els) =>
|
||||||
|
els.map((e) => Number(e.getAttribute('data-testid')!.slice(10)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(col.getByTestId(`todo-card-${cId}`)).toBeVisible();
|
||||||
|
// C を A の上にドラッグ(前へ挿入) -> 順序は C, A, B
|
||||||
|
await page
|
||||||
|
.getByTestId(`todo-card-${cId}`)
|
||||||
|
.dragTo(page.getByTestId(`todo-card-${aId}`), {
|
||||||
|
targetPosition: { x: 10, y: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await orderIds())[0], { timeout: 10_000 })
|
||||||
|
.toBe(cId);
|
||||||
|
expect(await orderIds()).toEqual([cId, aId, bId]);
|
||||||
|
|
||||||
|
// リロード後も順序が維持される
|
||||||
|
await page.reload();
|
||||||
|
expect(await orderIds()).toEqual([cId, aId, bId]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -308,4 +308,77 @@ describe('TodoService', () => {
|
|||||||
expect(cleared.tags).toBeNull();
|
expect(cleared.tags).toBeNull();
|
||||||
expect(cleared.assigneeId).toBeNull();
|
expect(cleared.assigneeId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reorderItems', () => {
|
||||||
|
it('renumbers items within a column in the given order', () => {
|
||||||
|
const cols = service.getColumns(authorId, projectId);
|
||||||
|
const col = cols[0];
|
||||||
|
const a = service.createItem(authorId, projectId, {
|
||||||
|
title: 'A',
|
||||||
|
columnId: col.id,
|
||||||
|
});
|
||||||
|
const b = service.createItem(authorId, projectId, {
|
||||||
|
title: 'B',
|
||||||
|
columnId: col.id,
|
||||||
|
});
|
||||||
|
const c = service.createItem(authorId, projectId, {
|
||||||
|
title: 'C',
|
||||||
|
columnId: col.id,
|
||||||
|
});
|
||||||
|
// 逆順に並べ替え
|
||||||
|
const result = service.reorderItems(authorId, projectId, col.id, [
|
||||||
|
c.id,
|
||||||
|
b.id,
|
||||||
|
a.id,
|
||||||
|
]);
|
||||||
|
expect(result.map((i) => i.title)).toEqual(['C', 'B', 'A']);
|
||||||
|
expect(result.map((i) => i.orderIndex)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an item from another column into the target position', () => {
|
||||||
|
const cols = service.getColumns(authorId, projectId);
|
||||||
|
const src = cols[0];
|
||||||
|
const dest = cols[1];
|
||||||
|
const a = service.createItem(authorId, projectId, {
|
||||||
|
title: 'A',
|
||||||
|
columnId: dest.id,
|
||||||
|
});
|
||||||
|
const b = service.createItem(authorId, projectId, {
|
||||||
|
title: 'B',
|
||||||
|
columnId: dest.id,
|
||||||
|
});
|
||||||
|
const x = service.createItem(authorId, projectId, {
|
||||||
|
title: 'X',
|
||||||
|
columnId: src.id,
|
||||||
|
});
|
||||||
|
// X を A と B の間に挿入
|
||||||
|
service.reorderItems(authorId, projectId, dest.id, [a.id, x.id, b.id]);
|
||||||
|
const destItems = service
|
||||||
|
.getItems(authorId, projectId)
|
||||||
|
.filter((i) => i.columnId === dest.id);
|
||||||
|
expect(destItems.map((i) => i.title)).toEqual(['A', 'X', 'B']);
|
||||||
|
// X は元のカラムからいなくなる
|
||||||
|
const srcItems = service
|
||||||
|
.getItems(authorId, projectId)
|
||||||
|
.filter((i) => i.columnId === src.id);
|
||||||
|
expect(srcItems.find((i) => i.id === x.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a non-member from reordering', () => {
|
||||||
|
const col = service.getColumns(authorId, projectId)[0];
|
||||||
|
expect(() =>
|
||||||
|
service.reorderItems(outsiderId, projectId, col.id, [])
|
||||||
|
).toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown column or item', () => {
|
||||||
|
const col = service.getColumns(authorId, projectId)[0];
|
||||||
|
expect(() =>
|
||||||
|
service.reorderItems(authorId, projectId, 99999, [])
|
||||||
|
).toThrow(NotFoundError);
|
||||||
|
expect(() =>
|
||||||
|
service.reorderItems(authorId, projectId, col.id, [99999])
|
||||||
|
).toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user