@aiquants/daily-report 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.mts +81 -3
- package/dist/client.d.ts +81 -3
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +4 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +434 -6
- package/dist/server.d.ts +434 -6
- package/dist/server.js +6 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +6 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.tsx +2 -0
- package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +2 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ts +426 -1
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +308 -25
- package/src/server/test-helpers/handlers-config.ts +142 -0
- package/src/server.ts +16 -1
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* useFetcher ベースのデータフックで日報詳細を提供する仮想スクロールビュー。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { type
|
|
6
|
+
import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Handle type augmented with E2E test helper methods.
|
|
@@ -25,8 +25,10 @@ import { useDailyReportDetail, useDailyReportPrefetch } from "../hooks/use-daily
|
|
|
25
25
|
import { useDailyReportComments } from "../hooks/use-daily-report-comments"
|
|
26
26
|
import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
|
|
27
27
|
import { cn } from "../ui/cn"
|
|
28
|
-
import { EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
|
|
28
|
+
import { CONTENT_DRAG_POINTER_INPUTS, EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, SCROLL_BAR_WIDTH, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT, WHEEL_RESET_TIMEOUT_MS } from "../utils/constants"
|
|
29
29
|
import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
|
|
30
|
+
import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
|
|
31
|
+
import { DailyReportAttachmentList } from "./daily-report-attachment-list"
|
|
30
32
|
import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
|
|
31
33
|
import { DailyReportEditForm } from "./daily-report-edit-form"
|
|
32
34
|
import { UnreadIndicator } from "./unread-indicator"
|
|
@@ -230,7 +232,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
230
232
|
wheelResetTimerRef.current = window.setTimeout(() => {
|
|
231
233
|
setIsWheelScrollActive(false)
|
|
232
234
|
wheelResetTimerRef.current = null
|
|
233
|
-
},
|
|
235
|
+
}, WHEEL_RESET_TIMEOUT_MS)
|
|
234
236
|
}
|
|
235
237
|
el.addEventListener("wheel", onWheel, { passive: true })
|
|
236
238
|
return () => {
|
|
@@ -270,8 +272,6 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
270
272
|
[getItem],
|
|
271
273
|
)
|
|
272
274
|
|
|
273
|
-
const tapScrollCircleOptions = useMemo<ScrollBarTapCircleOptions>(() => ({ enabled: true, renderVisual: tapScrollCircleSampleVisual }), [])
|
|
274
|
-
|
|
275
275
|
/**
|
|
276
276
|
* Updates viewport height when item count changes.
|
|
277
277
|
* アイテム数変更時にビューポート高さを更新する。
|
|
@@ -311,11 +311,14 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
311
311
|
initialScrollOffset={initialScrollOffset}
|
|
312
312
|
overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
|
|
313
313
|
scrollBarOptions={{
|
|
314
|
-
width:
|
|
315
|
-
tapScrollCircleOptions:
|
|
314
|
+
width: SCROLL_BAR_WIDTH,
|
|
315
|
+
tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
|
|
316
316
|
renderThumbOverlay: renderThumbOverlay,
|
|
317
317
|
}}
|
|
318
318
|
behaviorOptions={{
|
|
319
|
+
// ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
|
|
320
|
+
// 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
|
|
321
|
+
pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
|
|
319
322
|
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
320
323
|
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
321
324
|
}}
|
|
@@ -472,6 +475,9 @@ const DailyReportHeader = ({
|
|
|
472
475
|
const { sourceTypeConfigs } = useDailyReportConfig()
|
|
473
476
|
return (
|
|
474
477
|
<div className="flex flex-wrap items-center gap-2 font-medium text-slate-600 text-xs">
|
|
478
|
+
{/* 添付の有無を一目判別できるようにする。仕様書が定める
|
|
479
|
+
[sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
|
|
480
|
+
<DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
|
|
475
481
|
{(() => {
|
|
476
482
|
const sourceType = itemSourceType ?? report?.sourceType
|
|
477
483
|
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
@@ -673,6 +679,7 @@ const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubl
|
|
|
673
679
|
</ul>
|
|
674
680
|
</div>
|
|
675
681
|
)}
|
|
682
|
+
<DailyReportAttachmentList attachments={report.attachments} />
|
|
676
683
|
{uiComments.length > 0 && (
|
|
677
684
|
<div className="flex flex-col gap-2">
|
|
678
685
|
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.comments}</span>
|
|
@@ -13,9 +13,11 @@ import { useDailyReportActionContext } from "../contexts/daily-report-action-con
|
|
|
13
13
|
import { useDailyReportDetail } from "../hooks/use-daily-report"
|
|
14
14
|
import { useDailyReportComments } from "../hooks/use-daily-report-comments"
|
|
15
15
|
import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
|
|
16
|
+
import { useIsMobileLayout } from "../hooks/use-responsive-layout"
|
|
16
17
|
import { cn } from "../ui/cn"
|
|
17
18
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
|
18
19
|
import {
|
|
20
|
+
CONTENT_DRAG_POINTER_INPUTS,
|
|
19
21
|
DEFAULT_ITEM_HEIGHT,
|
|
20
22
|
EMPTY_DETAIL,
|
|
21
23
|
EMPTY_ITEM,
|
|
@@ -30,6 +32,8 @@ import {
|
|
|
30
32
|
WHEEL_RESET_TIMEOUT_MS,
|
|
31
33
|
} from "../utils/constants"
|
|
32
34
|
import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
|
|
35
|
+
import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
|
|
36
|
+
import { DailyReportAttachmentList } from "./daily-report-attachment-list"
|
|
33
37
|
import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
|
|
34
38
|
import { DailyReportEditForm } from "./daily-report-edit-form"
|
|
35
39
|
import { UnreadIndicator } from "./unread-indicator"
|
|
@@ -236,7 +240,7 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
236
240
|
const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
|
|
237
241
|
const [overlayLabel, setOverlayLabel] = useState<string>("")
|
|
238
242
|
const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
|
|
239
|
-
const
|
|
243
|
+
const isMobileLayout = useIsMobileLayout()
|
|
240
244
|
const wheelResetTimerRef = useRef<number | null>(null)
|
|
241
245
|
const appliedInitialSignatureRef = useRef<string | null>(null)
|
|
242
246
|
const itemCount = dailyReportItems.length
|
|
@@ -247,21 +251,17 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
247
251
|
const columnHeightStyle = useMemo<CSSProperties | undefined>(() => (Number.isFinite(viewportHeight) && viewportHeight > 0 ? { height: viewportHeight } : undefined), [viewportHeight])
|
|
248
252
|
|
|
249
253
|
/**
|
|
250
|
-
* Manages global event listeners (
|
|
251
|
-
*
|
|
254
|
+
* Manages global event listeners (Wheel, Resize).
|
|
255
|
+
* ホイール・リサイズのグローバルイベントリスナーを管理する副作用。
|
|
252
256
|
*
|
|
253
|
-
* Purpose: To
|
|
257
|
+
* Purpose: To keep the viewport height in sync and to detect wheel scrolling.
|
|
258
|
+
* (レスポンシブ判定は use-responsive-layout のフックが担当する。)
|
|
254
259
|
* Dependencies: [updateViewportHeight]
|
|
255
260
|
* Cleanup: Removes all event listeners and clears timers.
|
|
256
261
|
*/
|
|
257
262
|
useEffect(() => {
|
|
258
263
|
if (typeof window === "undefined") return
|
|
259
264
|
|
|
260
|
-
const mq = window.matchMedia("(max-width: 639px)")
|
|
261
|
-
const onMediaChange = (e: MediaQueryListEvent) => setIsMobileLayout(e.matches)
|
|
262
|
-
setIsMobileLayout(mq.matches)
|
|
263
|
-
mq.addEventListener("change", onMediaChange)
|
|
264
|
-
|
|
265
265
|
const onResize = () => requestAnimationFrame(updateViewportHeight)
|
|
266
266
|
window.addEventListener("resize", onResize)
|
|
267
267
|
updateViewportHeight()
|
|
@@ -278,7 +278,6 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
278
278
|
el?.addEventListener("wheel", onWheel, { passive: true })
|
|
279
279
|
|
|
280
280
|
return () => {
|
|
281
|
-
mq.removeEventListener("change", onMediaChange)
|
|
282
281
|
window.removeEventListener("resize", onResize)
|
|
283
282
|
el?.removeEventListener("wheel", onWheel)
|
|
284
283
|
if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
|
|
@@ -453,6 +452,9 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
453
452
|
renderThumbOverlay: renderThumbOverlay,
|
|
454
453
|
}}
|
|
455
454
|
behaviorOptions={{
|
|
455
|
+
// ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
|
|
456
|
+
// 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
|
|
457
|
+
pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
|
|
456
458
|
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
457
459
|
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
458
460
|
}}
|
|
@@ -811,6 +813,9 @@ const DailyReportDisplayPane = ({
|
|
|
811
813
|
)}
|
|
812
814
|
</div>
|
|
813
815
|
</div>
|
|
816
|
+
{/* 添付節。DetailList タブと同じ位置 (面談者・本文の後、コメントの前) に置く。
|
|
817
|
+
片方のタブにしか出さないと、利用者は「添付が無い日報」と誤認する */}
|
|
818
|
+
{report && <DailyReportAttachmentList attachments={report.attachments} />}
|
|
814
819
|
{report && (
|
|
815
820
|
<div className="fade-in animate-in space-y-2 border-slate-100 border-t pt-4 duration-500 dark:border-slate-800">
|
|
816
821
|
<div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.comments}</div>
|
|
@@ -934,7 +939,7 @@ const extractPreviewLines = (content: string | null): string[] => {
|
|
|
934
939
|
* List タブ内で表示する日報サマリーカード。
|
|
935
940
|
*/
|
|
936
941
|
const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
|
|
937
|
-
const { sourceTypeConfigs } = useDailyReportConfig()
|
|
942
|
+
const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
|
|
938
943
|
const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
|
|
939
944
|
const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
|
|
940
945
|
|
|
@@ -1022,6 +1027,9 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1022
1027
|
<span className="truncate font-medium text-slate-900 dark:text-slate-100">{creator}</span>
|
|
1023
1028
|
</span>
|
|
1024
1029
|
<div className="ml-auto flex items-center gap-1.5">
|
|
1030
|
+
{/* 添付の有無を一覧で一目判別できるようにする。
|
|
1031
|
+
仕様書が定める [sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
|
|
1032
|
+
<DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
|
|
1025
1033
|
{(() => {
|
|
1026
1034
|
const sourceType = item.sourceType ?? report?.sourceType
|
|
1027
1035
|
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering guards for the daily-report views (List / DetailList).
|
|
3
|
+
* 日報ビュー (List / DetailList) の描画ガード。
|
|
4
|
+
*
|
|
5
|
+
* 実際に起きた欠陥: 添付節を DetailList タブにだけ入れて List タブのサイドペインに入れ忘れ、
|
|
6
|
+
* List タブしか使わない利用者からは「添付が 1 件も無い」ように見えていた。型チェックでも
|
|
7
|
+
* 既存のどのテストでも検出できず、画面を開いて初めて分かる種類の抜けである。
|
|
8
|
+
*
|
|
9
|
+
* ❗ **ここはソース文字列の grep では守れない。** 以前の版は
|
|
10
|
+
* `expect(source).toContain("<DailyReportAttachmentList ... />")` というソース検査だったが、
|
|
11
|
+
* それでは `{false && ...}` や JSX コメントで**描画だけ止めた**場合に素通りする
|
|
12
|
+
* (= 上記の欠陥そのものを再現しても全 green)。逆に、属性の並べ替えや改行といった
|
|
13
|
+
* 挙動の変わらない整形で赤くなる。そこで実際に描画し、
|
|
14
|
+
* `VirtualScroll` へ渡した設定も差し替えたモックで捕まえて値を検査する。
|
|
15
|
+
*
|
|
16
|
+
* 添付 1 件分の見た目そのものは `daily-report-attachment-list.spec.tsx` が担当する。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// biome-ignore lint/correctness/noNodejsModules: ビュー一覧の網羅性をディレクトリ走査で機械的に確かめるため (テスト専用)
|
|
20
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
21
|
+
// biome-ignore lint/correctness/noNodejsModules: 同上
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
// biome-ignore lint/correctness/noNodejsModules: 同上
|
|
24
|
+
import { fileURLToPath } from "node:url"
|
|
25
|
+
import { act, render, screen } from "@testing-library/react"
|
|
26
|
+
import type { ReactNode } from "react"
|
|
27
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
28
|
+
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
29
|
+
import { CONTENT_DRAG_POINTER_INPUTS, ITEM_VERTICAL_PADDING, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `VirtualScroll` が受け取った props。ビューごとに 1 件積まれる。
|
|
33
|
+
*
|
|
34
|
+
* ❗ **代役は窓計算を捨てて全件描画するため、渡し忘れても描画結果が変わらない props がある。**
|
|
35
|
+
* それらは値を記録して個別に検査しないと「モックだから通る」状態になる
|
|
36
|
+
* (実際に `onRangeChange` を no-op へ差し替えると窓フェッチが止まり一覧が空になるが、
|
|
37
|
+
* 記録しなければテストは緑のまま)。
|
|
38
|
+
*/
|
|
39
|
+
type CapturedVirtualScrollProps = {
|
|
40
|
+
testId?: string
|
|
41
|
+
itemCount: number
|
|
42
|
+
behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown }
|
|
43
|
+
scrollBarOptions?: { tapScrollCircleOptions?: unknown; renderThumbOverlay?: (props: Record<string, unknown>) => ReactNode }
|
|
44
|
+
getItemKey?: unknown
|
|
45
|
+
getItemHeight?: unknown
|
|
46
|
+
viewportSize?: unknown
|
|
47
|
+
overscanCount?: unknown
|
|
48
|
+
onRangeChange?: unknown
|
|
49
|
+
onScroll?: unknown
|
|
50
|
+
contentInsets?: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const captured: CapturedVirtualScrollProps[] = []
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stand-in for VirtualScroll that renders every item and records its options.
|
|
57
|
+
* 全アイテムを描画しつつ設定を記録する `VirtualScroll` の代役。
|
|
58
|
+
*
|
|
59
|
+
* 本物は要素の実測高さに依存するため jsdom では 1 件も描画されない。
|
|
60
|
+
* 「どのペインに何が描かれるか」を検査したいので、窓の計算は捨てて全件描画する。
|
|
61
|
+
*/
|
|
62
|
+
vi.mock("@aiquants/virtualscroll", async () => {
|
|
63
|
+
const { forwardRef: fwd, useImperativeHandle: useHandle } = await import("react")
|
|
64
|
+
return {
|
|
65
|
+
tapScrollCircleSampleVisual: () => null,
|
|
66
|
+
VirtualScroll: fwd(
|
|
67
|
+
(
|
|
68
|
+
props: {
|
|
69
|
+
itemCount: number
|
|
70
|
+
getItem: (index: number) => unknown
|
|
71
|
+
children: (item: unknown, index: number) => ReactNode
|
|
72
|
+
} & CapturedVirtualScrollProps,
|
|
73
|
+
ref: React.Ref<unknown>,
|
|
74
|
+
) => {
|
|
75
|
+
captured.push({
|
|
76
|
+
testId: props.testId,
|
|
77
|
+
itemCount: props.itemCount,
|
|
78
|
+
behaviorOptions: props.behaviorOptions,
|
|
79
|
+
scrollBarOptions: props.scrollBarOptions,
|
|
80
|
+
getItemKey: props.getItemKey,
|
|
81
|
+
getItemHeight: props.getItemHeight,
|
|
82
|
+
viewportSize: props.viewportSize,
|
|
83
|
+
overscanCount: props.overscanCount,
|
|
84
|
+
onRangeChange: props.onRangeChange,
|
|
85
|
+
onScroll: props.onScroll,
|
|
86
|
+
contentInsets: props.contentInsets,
|
|
87
|
+
})
|
|
88
|
+
useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
|
|
89
|
+
return (
|
|
90
|
+
<div data-testid={props.testId}>
|
|
91
|
+
{Array.from({ length: props.itemCount }, (_, index) => (
|
|
92
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
|
|
93
|
+
<div key={index}>{props.children(props.getItem(index), index)}</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** 日報アクションはネットワーク/ルーターに依存するため、呼び出しを受け止めるだけの代役にする。 */
|
|
103
|
+
vi.mock("../contexts/daily-report-action-context", async (importOriginal) => {
|
|
104
|
+
const actual = await importOriginal<typeof import("../contexts/daily-report-action-context")>()
|
|
105
|
+
const noop = async () => {}
|
|
106
|
+
return {
|
|
107
|
+
...actual,
|
|
108
|
+
useDailyReportActionContext: () => ({
|
|
109
|
+
// 楽観的更新の台帳はすべて ref。1 つでも欠けると描画が例外で落ちる
|
|
110
|
+
pendingAddCommentIds: { current: new Map() },
|
|
111
|
+
pendingDeleteCommentIds: { current: new Map() },
|
|
112
|
+
pendingReadUpdates: { current: new Map() },
|
|
113
|
+
pendingStarUpdates: { current: new Map() },
|
|
114
|
+
resolvedIdMap: { current: new Map() },
|
|
115
|
+
version: 0,
|
|
116
|
+
items: [],
|
|
117
|
+
user: null,
|
|
118
|
+
isSseEnabled: false,
|
|
119
|
+
toggleSse: () => {},
|
|
120
|
+
addComment: noop,
|
|
121
|
+
deleteComment: noop,
|
|
122
|
+
toggleStar: noop,
|
|
123
|
+
toggleRead: noop,
|
|
124
|
+
createReport: async () => 0,
|
|
125
|
+
updateReport: noop,
|
|
126
|
+
publishReport: noop,
|
|
127
|
+
deleteReport: noop,
|
|
128
|
+
removeStaleItem: () => {},
|
|
129
|
+
refetchData: noop,
|
|
130
|
+
clearCacheAndRefetch: noop,
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** 詳細取得はキャッシュ層ごと差し替え、常に用意した 1 件を返す。 */
|
|
136
|
+
const detailRef: { current: DailyReportDetail | null } = { current: null }
|
|
137
|
+
vi.mock("../hooks/use-daily-report", async (importOriginal) => {
|
|
138
|
+
const actual = await importOriginal<typeof import("../hooks/use-daily-report")>()
|
|
139
|
+
return {
|
|
140
|
+
...actual,
|
|
141
|
+
useDailyReportDetail: () => ({ report: detailRef.current, error: null, isLoading: false, isRefetching: false }),
|
|
142
|
+
useDailyReportPrefetch: () => () => {},
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const { DailyReportList } = await import("./daily-report-list")
|
|
147
|
+
const { DailyReportDetailList } = await import("./daily-report-detail-list")
|
|
148
|
+
const { DailyReportConfigProvider } = await import("../config-context")
|
|
149
|
+
|
|
150
|
+
const componentsDir = dirname(fileURLToPath(import.meta.url))
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 日報の詳細を描画するビューの一覧。**すべての検査はこの表から導出する。**
|
|
154
|
+
*
|
|
155
|
+
* ❗ 追加したらここへ足すこと。足し忘れは下の網羅テストが検知し、足せば
|
|
156
|
+
* 添付節・マーカー・スクロール結線の全検査が自動的にそのビューへ適用される
|
|
157
|
+
* (表への登録だけで黙らせられる、という抜け道を作らない)。
|
|
158
|
+
*/
|
|
159
|
+
const REPORT_DETAIL_VIEWS = [
|
|
160
|
+
{ file: "daily-report-list.tsx", testId: "daily-report-list", render: () => <DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} /> },
|
|
161
|
+
{ file: "daily-report-detail-list.tsx", testId: "daily-report-detail-list", render: () => <DailyReportDetailList dailyReportItems={[item]} /> },
|
|
162
|
+
] as const
|
|
163
|
+
|
|
164
|
+
/** `it.each` のタイトル用に (名前, 定義) の組へ落とす。 */
|
|
165
|
+
const VIEW_CASES = REPORT_DETAIL_VIEWS.map((view) => [view.file, view] as const)
|
|
166
|
+
|
|
167
|
+
const REPORT_HUB_ID = 1
|
|
168
|
+
|
|
169
|
+
const item: DailyReportItem = { reportHubId: REPORT_HUB_ID, businessDate: "2026-07-31" }
|
|
170
|
+
|
|
171
|
+
/** 添付 1 件を持つ日報詳細。 */
|
|
172
|
+
const buildDetail = (): DailyReportDetail => ({
|
|
173
|
+
reportHubId: REPORT_HUB_ID,
|
|
174
|
+
date: "2026-07-31",
|
|
175
|
+
interviewers: [],
|
|
176
|
+
comments: [],
|
|
177
|
+
isRead: false,
|
|
178
|
+
isStarred: false,
|
|
179
|
+
labels: [],
|
|
180
|
+
commentItems: [],
|
|
181
|
+
attachments: [{ id: "TOKEN1", fileName: "report.png", fileType: "image/png", fileSize: 2048, createdAt: "2026-07-31 00:00:00", state: "unknown" }],
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/** 設定コンテキストを与えて描画する。 */
|
|
185
|
+
const renderView = (node: ReactNode) =>
|
|
186
|
+
render(
|
|
187
|
+
<DailyReportConfigProvider
|
|
188
|
+
config={{
|
|
189
|
+
apiBasePath: "/daily_report/api",
|
|
190
|
+
fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" },
|
|
191
|
+
}}>
|
|
192
|
+
{node}
|
|
193
|
+
</DailyReportConfigProvider>,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 指定 testId のビューが受け取った `VirtualScroll` の props (最新のレンダー分)。
|
|
198
|
+
*
|
|
199
|
+
* ❗ 再レンダーのたびに積まれるので**最後の 1 件**を返す。先頭を返すと、
|
|
200
|
+
* 状態更新後に渡し直されたコールバックではなく古いクロージャを掴んでしまう。
|
|
201
|
+
*/
|
|
202
|
+
const optionsOf = (testId: string) => {
|
|
203
|
+
const props = [...captured].reverse().find((entry) => entry.testId === testId)
|
|
204
|
+
if (!props) throw new Error(`VirtualScroll props for ${testId} were not captured`)
|
|
205
|
+
return props
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
captured.length = 0
|
|
210
|
+
detailRef.current = buildDetail()
|
|
211
|
+
// DetailList は行の実測に ResizeObserver を使う (jsdom には無い)
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
"ResizeObserver",
|
|
214
|
+
class {
|
|
215
|
+
observe() {}
|
|
216
|
+
unobserve() {}
|
|
217
|
+
disconnect() {}
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
vi.stubGlobal(
|
|
221
|
+
"matchMedia",
|
|
222
|
+
vi.fn((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })),
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe("日報ビューの描画と結線", () => {
|
|
227
|
+
it.each(VIEW_CASES)("%s は添付節を描画する", (_file, view) => {
|
|
228
|
+
renderView(view.render())
|
|
229
|
+
expect(screen.getAllByTestId("daily-report-attachment-section").length).toBeGreaterThan(0)
|
|
230
|
+
expect(screen.getAllByTestId("daily-report-attachment-item").length).toBe(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.each(VIEW_CASES)("%s は添付マーカーを描画する", (_file, view) => {
|
|
234
|
+
// 節そのものはカードを開かないと見えないため、スクロールしながら判別できる目印が要る
|
|
235
|
+
renderView(view.render())
|
|
236
|
+
expect(screen.getAllByTestId("daily-report-attachment-indicator").length).toBeGreaterThan(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("添付が 0 件のときは節もマーカーも描画されない (誤検知の裏取り)", () => {
|
|
240
|
+
detailRef.current = { ...buildDetail(), attachments: [] }
|
|
241
|
+
renderView(<DailyReportDetailList dailyReportItems={[item]} />)
|
|
242
|
+
expect(screen.queryByTestId("daily-report-attachment-section")).toBeNull()
|
|
243
|
+
expect(screen.queryByTestId("daily-report-attachment-indicator")).toBeNull()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it.each(VIEW_CASES)("%s はドラッグを無効化せずポインタ種別で絞る", (_file, view) => {
|
|
247
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
248
|
+
// ❗ `enablePointerDrag` を切ってはならない。ScrollPane は transform 描画で
|
|
249
|
+
// ネイティブのスクロール領域を持たないため、切るとタッチ端末から
|
|
250
|
+
// スクロール手段が完全に消える。マウスのドラッグだけを外すのが正しい
|
|
251
|
+
renderView(renderNode())
|
|
252
|
+
const { behaviorOptions } = optionsOf(testId)
|
|
253
|
+
expect(behaviorOptions?.pointerDragInputs).toBe(CONTENT_DRAG_POINTER_INPUTS)
|
|
254
|
+
expect(behaviorOptions?.enablePointerDrag).toBeUndefined()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it.each(VIEW_CASES)("%s は共有のタップスクロール設定を使う", (_file, view) => {
|
|
258
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
259
|
+
// 片方だけ独自定義にすると、同じ指の位置でタブごとに速度が何十倍も違う
|
|
260
|
+
// (是正前は実測で最大 77 倍の差があった)
|
|
261
|
+
renderView(renderNode())
|
|
262
|
+
expect(optionsOf(testId).scrollBarOptions?.tapScrollCircleOptions).toBe(TAP_SCROLL_CIRCLE_OPTIONS)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it.each(VIEW_CASES)("%s は仮想スクロールの必須結線を渡している", (_file, view) => {
|
|
266
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
267
|
+
// 代役は全件描画するため、これらを壊しても描画結果は変わらない。値そのものを検査する
|
|
268
|
+
renderView(renderNode())
|
|
269
|
+
const props = optionsOf(testId)
|
|
270
|
+
// 窓フェッチとプリフェッチの起点。no-op にすると一覧が空のままになる
|
|
271
|
+
expect(typeof props.onRangeChange).toBe("function")
|
|
272
|
+
expect(typeof props.onScroll).toBe("function")
|
|
273
|
+
expect(typeof props.getItemHeight).toBe("function")
|
|
274
|
+
// 白飛び防止のオーバースキャン。0 にするとスクロール中に空白が出る
|
|
275
|
+
expect(props.overscanCount).toBe(VIRTUAL_SCROLL_OVERSCAN_COUNT)
|
|
276
|
+
expect(props.viewportSize).toEqual(expect.any(Number))
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("❗ onRangeChange は実際のハンドラである (型が function なだけでは足りない)", () => {
|
|
280
|
+
// no-op へ差し替えると窓の日付表示が止まる。`typeof === "function"` は
|
|
281
|
+
// no-op も通してしまうので、**呼んだ結果が観測できること**まで確かめる
|
|
282
|
+
const second: DailyReportItem = { reportHubId: 2, businessDate: "2026-08-05" }
|
|
283
|
+
renderView(<DailyReportList dailyReportItems={[item, second]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
284
|
+
|
|
285
|
+
/** つまみのバブルに出ている文言。 */
|
|
286
|
+
const overlayText = () => {
|
|
287
|
+
const renderThumbOverlay = optionsOf("daily-report-list").scrollBarOptions?.renderThumbOverlay
|
|
288
|
+
if (typeof renderThumbOverlay !== "function") throw new Error("renderThumbOverlay was not captured")
|
|
289
|
+
const view = render(<div>{renderThumbOverlay({ orientation: "vertical", thumbCenter: 0, isDragging: true, isTapScrollActive: false })}</div>)
|
|
290
|
+
const text = view.container.textContent ?? ""
|
|
291
|
+
view.unmount()
|
|
292
|
+
return text
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
expect(overlayText()).not.toContain("2026-08-05")
|
|
296
|
+
|
|
297
|
+
act(() => {
|
|
298
|
+
;(optionsOf("daily-report-list").onRangeChange as (range: { visibleStartIndex: number }) => void)({ visibleStartIndex: 1 })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// no-op へ差し替えると、ここが古い日付のまま変わらない
|
|
302
|
+
expect(overlayText()).toContain("2026-08-05")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("DetailList は行の同一性を保つキーを渡している (React の再利用が壊れない)", () => {
|
|
306
|
+
// 可変高さのカードは key が潰れると再利用が壊れ、実測高さが別の行へ付く
|
|
307
|
+
renderView(<DailyReportDetailList dailyReportItems={[item]} />)
|
|
308
|
+
const { getItemKey } = optionsOf("daily-report-detail-list")
|
|
309
|
+
expect(typeof getItemKey).toBe("function")
|
|
310
|
+
expect((getItemKey as (index: number) => unknown)(0)).toBe(REPORT_HUB_ID)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("List は上下のインセットを渡している (先頭・末尾の行が縁に貼り付かない)", () => {
|
|
314
|
+
renderView(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
315
|
+
expect(optionsOf("daily-report-list").contentInsets).toEqual({ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("レイアウト判定の閾値を各ビューが独自に持たない (単一の情報源にする)", () => {
|
|
319
|
+
// 空白の有無に左右されないよう、空白を除去してから照合する
|
|
320
|
+
for (const view of REPORT_DETAIL_VIEWS) {
|
|
321
|
+
const source = readFileSync(join(componentsDir, view.file), "utf8").replace(/\s+/g, "")
|
|
322
|
+
expect(source, view.file).not.toContain("max-width:639px")
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it("詳細を描画するビューを網羅している (新しいビューの追加を検知する)", () => {
|
|
327
|
+
// ❗ 走査対象はディレクトリの実体。登録済みリストを絞り込む書き方だと
|
|
328
|
+
// 「新しく増えたファイル」は原理的に視界へ入らない。
|
|
329
|
+
//
|
|
330
|
+
// 判定は **`use-daily-report-comments` を import しているか**で行う。
|
|
331
|
+
// 日報のコメントを取りに行くのは「その日報の詳細を描画するビュー」だけであり、
|
|
332
|
+
// import 文なので `fieldLabels.comments` のような字面の揺れ
|
|
333
|
+
// (`fieldLabels["comments"]` / 分割代入) に左右されない
|
|
334
|
+
const detailViews = readdirSync(componentsDir)
|
|
335
|
+
.filter((file) => file.endsWith(".tsx") && !file.endsWith(".spec.tsx"))
|
|
336
|
+
.filter((file) => readFileSync(join(componentsDir, file), "utf8").includes('from "../hooks/use-daily-report-comments"'))
|
|
337
|
+
.sort()
|
|
338
|
+
expect(detailViews).toEqual(REPORT_DETAIL_VIEWS.map((view) => view.file).sort())
|
|
339
|
+
})
|
|
340
|
+
})
|
|
@@ -33,6 +33,11 @@ export type DailyReportFieldLabels = {
|
|
|
33
33
|
content: string
|
|
34
34
|
interviewers: string
|
|
35
35
|
comments: string
|
|
36
|
+
attachments: string
|
|
37
|
+
/** 実体の消失が確認された添付に添える注記。 */
|
|
38
|
+
attachmentMissing: string
|
|
39
|
+
/** 添付をダウンロードするリンクのラベル。 */
|
|
40
|
+
attachmentDownload: string
|
|
36
41
|
tabArticle: string
|
|
37
42
|
tabRelations: string
|
|
38
43
|
relationsEmpty: string
|
|
@@ -92,6 +97,9 @@ export const defaultDailyReportFieldLabels: DailyReportFieldLabels = {
|
|
|
92
97
|
content: "Content",
|
|
93
98
|
interviewers: "Attendees",
|
|
94
99
|
comments: "Comments",
|
|
100
|
+
attachments: "Attachments",
|
|
101
|
+
attachmentMissing: "File is missing",
|
|
102
|
+
attachmentDownload: "Download",
|
|
95
103
|
tabArticle: "Overview",
|
|
96
104
|
tabRelations: "Related",
|
|
97
105
|
relationsEmpty: "No related view configured yet.",
|
|
@@ -773,6 +773,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
773
773
|
isStarred: false,
|
|
774
774
|
labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
|
|
775
775
|
commentItems: [],
|
|
776
|
+
// 楽観更新の仮レポート。作成直後に添付は存在しない
|
|
777
|
+
attachments: [],
|
|
776
778
|
}
|
|
777
779
|
writeCache(dummyReport, Date.now() + 300000, false)
|
|
778
780
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared responsive-layout hooks.
|
|
3
|
+
* 共有のレスポンシブ判定フックのテスト。
|
|
4
|
+
*
|
|
5
|
+
* 閾値は 1 箇所でだけ定義する。各ビューが独自に `matchMedia` を持つと、片方だけ変更されて
|
|
6
|
+
* レイアウト分岐が食い違う。
|
|
7
|
+
*
|
|
8
|
+
* ❗ スクロール操作の可否はここで決めない (画面幅では「主入力がマウスのタッチ対応ノート」を
|
|
9
|
+
* 取りこぼす)。ドラッグはポインタ種別ごとに `pointerDragInputs` で制御する。
|
|
10
|
+
*/
|
|
11
|
+
import { act, renderHook } from "@testing-library/react"
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
13
|
+
import { MOBILE_LAYOUT_MEDIA_QUERY, useIsMobileLayout } from "./use-responsive-layout"
|
|
14
|
+
|
|
15
|
+
/** 購読中のリスナー。`change` を発火させて再評価させるために保持する。 */
|
|
16
|
+
type Listener = () => void
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Installs a `matchMedia` stub whose result is driven by the given query set.
|
|
20
|
+
* 指定したクエリ集合で結果が決まる `matchMedia` スタブを差し込む処理。
|
|
21
|
+
*
|
|
22
|
+
* @param matching Queries that should report a match. 一致させるクエリ群。
|
|
23
|
+
* @returns A setter to change the matching set and re-notify. 一致集合を差し替えて再通知するための関数。
|
|
24
|
+
*/
|
|
25
|
+
const stubMatchMedia = (matching: string[]) => {
|
|
26
|
+
let current = new Set(matching)
|
|
27
|
+
const listeners: Listener[] = []
|
|
28
|
+
|
|
29
|
+
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
30
|
+
get matches() {
|
|
31
|
+
return current.has(query)
|
|
32
|
+
},
|
|
33
|
+
media: query,
|
|
34
|
+
addEventListener: (_type: string, listener: Listener) => listeners.push(listener),
|
|
35
|
+
removeEventListener: (_type: string, listener: Listener) => {
|
|
36
|
+
const index = listeners.indexOf(listener)
|
|
37
|
+
if (index >= 0) listeners.splice(index, 1)
|
|
38
|
+
},
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
set: (next: string[]) => {
|
|
43
|
+
current = new Set(next)
|
|
44
|
+
act(() => {
|
|
45
|
+
for (const listener of [...listeners]) listener()
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
listenerCount: () => listeners.length,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("useIsMobileLayout", () => {
|
|
57
|
+
it("ビューポートが閾値以下ならモバイルレイアウトと判定する", () => {
|
|
58
|
+
stubMatchMedia([MOBILE_LAYOUT_MEDIA_QUERY])
|
|
59
|
+
expect(renderHook(() => useIsMobileLayout()).result.current).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("閾値を超えるならデスクトップレイアウトと判定する", () => {
|
|
63
|
+
stubMatchMedia([])
|
|
64
|
+
expect(renderHook(() => useIsMobileLayout()).result.current).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("リサイズ (change) に追随する", () => {
|
|
68
|
+
const media = stubMatchMedia([])
|
|
69
|
+
const { result } = renderHook(() => useIsMobileLayout())
|
|
70
|
+
expect(result.current).toBe(false)
|
|
71
|
+
media.set([MOBILE_LAYOUT_MEDIA_QUERY])
|
|
72
|
+
expect(result.current).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|