diff --git a/docs/classroom-notification-center/screenshots/0102-notification-panel.png b/docs/classroom-notification-center/screenshots/0102-notification-panel.png index a49ad33901..6f7cb06f28 100644 Binary files a/docs/classroom-notification-center/screenshots/0102-notification-panel.png and b/docs/classroom-notification-center/screenshots/0102-notification-panel.png differ diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index a30c14d425..4c45ab63b9 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -172,9 +172,10 @@ Google または Microsoft アカウントでサインインする画面。先 クラス管理のタイトルバー右上(× の左隣)は **アバターメニュー**(メール頭文字 + ▼)に固定。その左に **白一色のベル**(お知らせ)を置く。運営(Admin)からのお知らせが届くと未読数バッジが付く。 - **アバターメニュー**: メール頭文字(`kouji@…`→`K` / `kouji.takao@…`→`KT`)の丸アイコン + ▼。クリックでポップアップ(メール表示 + ログアウト。将来の設定項目もここに集約)。紫背景で視認できる白丸 + 紫文字 -- ベルのクリックで一覧パネルを開閉。**開いた時点で全既読**になりバッジが消える(その場では未読ドットで新着を見分けられる) -- パネルは **先頭 5 件**のみ表示。6 件以上あるときは「すべて見る(N件)」で **全件一覧ページ**(`teacher-notifications` フェーズ・**10 件/ページ**)へ遷移 +- ベルのクリックで一覧パネルを開閉。**開くだけでは既読にしない**(バッジは残る)。既読はパネルヘッダーの **⋯ メニュー**から明示的に行う +- パネルは **先頭 5 件**のみプレビュー表示。ヘッダーの **⋯ メニュー**に「**すべて既読にする**」「**お知らせを開く**(全件一覧ページ・`teacher-notifications` フェーズ・**10 件/ページ**)」。一覧は件数に関係なく開ける - お知らせ本文をクリックすると、`link` の種類に応じて該当画面へジャンプ(`kind: 'classroom'` → そのクラスを選択して課題詳細へ / `kind: 'shared-mine'` → みんなの課題の自分の投稿へ)。未知の kind は無視(前方互換) +- **全件一覧ページ**は「**クラス管理 > お知らせ**」パンくず(先頭「クラス管理」でトップ=クラス一覧へ戻れる)+ **左下に「戻る」ボタン**(キャンセルが不適切な画面のポリシー) - **取得は 1 日 1 回**(コスト削減): その日はじめてクラス管理を開いたときだけ `GET /notifications` を 1 回呼び、localStorage に日付つきでキャッシュ。同じ日の再オープンは API を叩かない(旧 60 秒ポーリングは廃止)。共有 PC 対策として先生メールでキャッシュを識別。取得エラーはクラス管理本体に影響させない(表示しない)。※その日の初回取得後に届いたお知らせは翌日反映(運営連絡は多くて週 1 回程度の前提) - 送信側は Admin SPA(クラス詳細の「先生へのお知らせ」フォーム → `POST /admin/notifications`)。`docs/admin/README.md` を参照 @@ -186,12 +187,17 @@ Google または Microsoft アカウントでサインインする画面。先 | ログアウト | `classroom-teacher-logout` | アカウントメニュー内 | | ベルボタン | `classroom-notifications-button` | クリックでパネル開閉 | | 未読バッジ | `classroom-notifications-badge` | 未読数(10 以上は「9+」)。未読 0 で非表示 | -| 一覧パネル | `classroom-notifications-panel` | 先頭 5 件 | -| すべて見る | `classroom-notifications-see-all` | 全件一覧ページへ(6 件以上のとき) | +| 一覧パネル | `classroom-notifications-panel` | 先頭 5 件プレビュー | +| ⋯ メニューボタン | `classroom-notifications-menu-button` | パネルヘッダー右の三点 | +| ⋯ メニュー | `classroom-notifications-menu` | — | +| すべて既読にする | `classroom-notifications-mark-all-read` | 全件既読(未読 0 で無効) | +| お知らせを開く | `classroom-notifications-open-all` | 全件一覧ページへ(件数不問) | | お知らせ 1 件(パネル) | `classroom-notification-item-{notificationId}` | クリックでリンク先へジャンプ + パネルを閉じる | | 未読ドット | `classroom-notification-unread-dot` | 未読アイテムのみ | | 空メッセージ | `classroom-notifications-empty` | お知らせ 0 件のとき | | 全件一覧ページ | `classroom-notifications-page` | `teacher-notifications` フェーズ | +| パンくず先頭 | `classroom-breadcrumb-top` | 「クラス管理」→ トップ(クラス一覧)へ | +| 左下戻る | `classroom-notifications-back` | トップ(クラス一覧)へ戻る | | ページ内 1 件 | `classroom-notification-page-item-{notificationId}` | クリックでリンク先へジャンプ | | ページャ | `classroom-notifications-pager` / `-prev` / `-next` | 10 件/ページ | diff --git a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.css b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.css index c7953a3ce3..403da15dd9 100644 --- a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.css +++ b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.css @@ -544,8 +544,8 @@ z-index: 1001; width: 22rem; max-width: calc(100vw - 2rem); - max-height: 60vh; - overflow-y: auto; + /* プレビューは最大 5 件で高さが有界なのでパネルはスクロールさせない + (overflow を付けるとヘッダーの ⋯ ポップアップがクリップされる)。 */ background-color: white; border: 1px solid #d9d9d9; border-radius: 0.5rem; @@ -555,12 +555,71 @@ } .notifications-header { + display: flex; + align-items: center; + justify-content: space-between; padding: 0.6rem 0.8rem; font-size: 0.9rem; font-weight: bold; border-bottom: 1px solid #e0e0e0; } +/* ヘッダーの ⋯ メニュー(すべて既読にする / お知らせを開く)。 */ +.notifications-menu { + position: relative; +} + +.notifications-menu-button { + border: none; + background: none; + color: #888; + font-size: 1.1rem; + line-height: 1; + padding: 0.1rem 0.3rem; + border-radius: 0.3rem; + cursor: pointer; +} + +.notifications-menu-button:hover { + background: #f0f0f6; + color: #575e75; +} + +.notifications-menu-popup { + position: absolute; + top: 1.8rem; + right: 0; + z-index: 1002; + min-width: 12rem; + background: white; + border: 1px solid #d9d9d9; + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.notifications-menu-item { + display: block; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: none; + text-align: left; + font-size: 0.85rem; + font-weight: normal; + color: #575e75; + cursor: pointer; +} + +.notifications-menu-item:hover:not(:disabled) { + background: #f0f6ff; +} + +.notifications-menu-item:disabled { + color: #bbb; + cursor: default; +} + .notifications-empty { padding: 1rem; font-size: 0.85rem; @@ -626,25 +685,7 @@ padding: 1.25rem 2rem 0; } -/* お知らせパネルの「すべて見る」リンク + 全件一覧ページ + ページネーション。 */ -.notifications-see-all { - display: block; - width: 100%; - padding: 0.6rem 0.8rem; - border: none; - border-top: 1px solid #e0e0e0; - background: none; - text-align: center; - font-size: 0.8rem; - font-weight: bold; - color: #4c97ff; - cursor: pointer; -} - -.notifications-see-all:hover { - background-color: #f0f6ff; -} - +/* お知らせ全件一覧ページ + ページネーション。 */ .notifications-page { padding: 1.25rem 2rem; max-width: 48rem; @@ -709,6 +750,25 @@ font-variant-numeric: tabular-nums; } +/* キャンセルが不適切な画面の左下戻るボタン(ポリシー・#1111 レビュー)。 */ +.notifications-page-footer { + margin-top: 1.5rem; +} + +.notifications-back-button { + border: 1px solid #d0d7e3; + border-radius: 0.4rem; + background: white; + color: #575e75; + padding: 0.4rem 1.1rem; + font-size: 0.9rem; + cursor: pointer; +} + +.notifications-back-button:hover { + background: #f0f6ff; +} + .google-courses-view { padding: 1.25rem 2rem; max-width: 60rem; diff --git a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx index 36da14724d..d18feba23d 100644 --- a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx +++ b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx @@ -209,12 +209,14 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => { return (
+ {/* ポリシー: パンくずの先頭「クラス管理」でいつでもトップ + (クラス一覧)へ戻れる(#1111 レビュー)。 */} {
@@ -498,6 +501,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => { isOpen={notificationsCenter.isOpen} notifications={notificationsCenter.notifications} unreadCount={notificationsCenter.unreadCount} + onMarkAllRead={notificationsCenter.handleMarkAllRead} onOpenLink={notificationsCenter.handleOpenLink} onShowAll={notificationsCenter.handleShowAll} onToggle={notificationsCenter.handleToggleNotifications} diff --git a/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications-list.jsx b/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications-list.jsx index fc0897b148..ecbf9e1801 100644 --- a/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications-list.jsx +++ b/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications-list.jsx @@ -52,7 +52,7 @@ NotificationRow.propTypes = { onOpenLink: PropTypes.func.isRequired, }; -const TeacherNotificationsList = ({ notifications, onOpenLink }) => { +const TeacherNotificationsList = ({ notifications, onBack, onOpenLink }) => { const [page, setPage] = useState(0); const totalPages = Math.max(1, Math.ceil(notifications.length / PAGE_SIZE)); const clampedPage = Math.min(page, totalPages - 1); @@ -114,12 +114,31 @@ const TeacherNotificationsList = ({ notifications, onOpenLink }) => { )} + {/* キャンセルが不適切な画面なので、左下に戻るボタンを置く(ポリシー・ + #1111 レビュー)。パンくずの「クラス管理」と同じくトップへ戻る。 */} + {onBack ? ( +
+ +
+ ) : null} ); }; TeacherNotificationsList.propTypes = { notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + onBack: PropTypes.func, onOpenLink: PropTypes.func.isRequired, }; diff --git a/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications.jsx b/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications.jsx index 7414dbc8bd..338bf4154c 100644 --- a/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications.jsx +++ b/packages/scratch-gui/src/components/classroom-teacher-modal/teacher-notifications.jsx @@ -3,10 +3,11 @@ * * クラス管理タイトルバー右上、アバターの左に置く**白一色のベル**ボタン * (+ 未読バッジ)。クリックでドロップダウンパネルを開き、**先頭 5 件**の - * お知らせを表示。6 件以上あるときは「すべて見る」で全件一覧ページへ遷移。 + * お知らせを表示。ヘッダーの ⋯ メニューから「すべて既読にする」「お知らせを + * 開く(全件一覧ページ)」を選べる(件数に関係なく一覧へ行ける・レビュー指摘)。 */ import PropTypes from 'prop-types'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import styles from './classroom-teacher-modal.css'; @@ -19,6 +20,11 @@ const messages = defineMessages({ description: 'Aria label for the notification bell button in the class management title bar', id: 'gui.classroom.notifications.buttonLabel', }, + menuLabel: { + defaultMessage: 'Notification menu', + description: 'Aria label for the three-dot menu in the notification panel', + id: 'gui.classroom.notifications.menuLabel', + }, }); // ローカルタイムで表示する(ISO の slice だと UTC のまま出て日本では 9 時間 @@ -80,10 +86,96 @@ NotificationItem.propTypes = { onOpenLink: PropTypes.func.isRequired, }; -const TeacherNotifications = ({ isOpen, notifications, unreadCount, onOpenLink, onShowAll, onToggle }) => { +// パネルヘッダーの ⋯ メニュー(すべて既読にする / お知らせを開く)。 +const PanelMenu = ({ unreadCount, onMarkAllRead, onShowAll }) => { + const intl = useIntl(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return () => {}; + const onDown = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [open]); + + const toggle = useCallback(() => setOpen((o) => !o), []); + const handleMark = useCallback(() => { + setOpen(false); + onMarkAllRead(); + }, [onMarkAllRead]); + const handleOpen = useCallback(() => { + setOpen(false); + onShowAll(); + }, [onShowAll]); + + return ( +
+ + {open && ( +
+ + +
+ )} +
+ ); +}; + +PanelMenu.propTypes = { + unreadCount: PropTypes.number.isRequired, + onMarkAllRead: PropTypes.func.isRequired, + onShowAll: PropTypes.func.isRequired, +}; + +const TeacherNotifications = ({ + isOpen, + notifications, + unreadCount, + onMarkAllRead, + onOpenLink, + onShowAll, + onToggle, +}) => { const intl = useIntl(); const preview = notifications.slice(0, PANEL_PREVIEW_COUNT); - const hasMore = notifications.length > PANEL_PREVIEW_COUNT; return ( @@ -104,10 +196,17 @@ const TeacherNotifications = ({ isOpen, notifications, unreadCount, onOpenLink, {isOpen && (
- + + +
{notifications.length === 0 ? ( @@ -119,32 +218,15 @@ const TeacherNotifications = ({ isOpen, notifications, unreadCount, onOpenLink, />
) : ( - - - {hasMore && ( - - )} - + )} )} @@ -156,6 +238,7 @@ TeacherNotifications.propTypes = { isOpen: PropTypes.bool.isRequired, notifications: PropTypes.arrayOf(PropTypes.object).isRequired, unreadCount: PropTypes.number.isRequired, + onMarkAllRead: PropTypes.func.isRequired, onOpenLink: PropTypes.func.isRequired, onShowAll: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired, diff --git a/packages/scratch-gui/src/containers/use-teacher-classroom.js b/packages/scratch-gui/src/containers/use-teacher-classroom.js index d1e58ba1e5..ef60e333bc 100644 --- a/packages/scratch-gui/src/containers/use-teacher-classroom.js +++ b/packages/scratch-gui/src/containers/use-teacher-classroom.js @@ -312,6 +312,7 @@ const useTeacherClassroom = ({ unreadCount: notifications.unreadCount, isOpen: notifications.isOpen, handleToggleNotifications: notifications.handleToggleNotifications, + handleMarkAllRead: notifications.handleMarkAllRead, handleOpenLink: handleOpenNotificationLink, handleShowAll: handleShowAllNotifications, }, diff --git a/packages/scratch-gui/src/containers/use-teacher-notifications.js b/packages/scratch-gui/src/containers/use-teacher-notifications.js index 4e1844bb92..3864f9d5d1 100644 --- a/packages/scratch-gui/src/containers/use-teacher-notifications.js +++ b/packages/scratch-gui/src/containers/use-teacher-notifications.js @@ -8,8 +8,8 @@ * 同じ日の再オープンはキャッシュを読むだけで API を叩かない(旧実装の 60 秒 * ポーリングは廃止)。 * - * パネルを開くと全件を既読化する(バッジは即消える。未読ドットはその表示 - * 中だけ残す)。 + * パネルを開くだけでは既読にしない(バッジは残る)。既読はパネルの ⋯ メニュー + * 「すべて既読にする」で明示的に行う(handleMarkAllRead)。 */ import { useCallback, useEffect, useState } from 'react'; import classroomAPI from '../lib/classroom-api.js'; @@ -98,23 +98,30 @@ const useTeacherNotifications = ({ idToken, handleTeacher401 }) => { }; }, [idToken, teacherKey, handleTeacher401]); - const handleToggleNotifications = useCallback(() => { - // 副作用は state updater の外(StrictMode / concurrent での二重実行対策)。 - const next = !isOpen; - if (next && unreadCount > 0) { - // パネルを開いた = すべて見た。バッジを即消し、サーバーへ既読を永続化。 - // 当日キャッシュの未読数も 0 にして同日再オープンと整合させる(未読 - // ドットは今の表示中だけ残すため in-memory の readAt は書き換えない)。 - setUnreadCount(0); - classroomAPI.markNotificationsRead(idToken).catch(() => {}); - const cache = readCache(); - if (cache) writeCache({ ...cache, unreadCount: 0 }); - } - setIsOpen(next); - }, [idToken, isOpen, unreadCount]); + // パネルは開くだけ(自動既読はしない)。既読はパネルの ⋯ メニュー + // 「すべて既読にする」で明示的に行う(#1111 レビュー: 参考 UI 準拠)。 + const handleToggleNotifications = useCallback(() => setIsOpen((o) => !o), []); const handleCloseNotifications = useCallback(() => setIsOpen(false), []); + // ⋯ メニュー「すべて既読にする」: バッジを消し、サーバーへ既読を永続化し、 + // in-memory の readAt とキャッシュも既読に揃える(同日再オープンと整合)。 + const handleMarkAllRead = useCallback(() => { + if (unreadCount === 0) return; + const now = new Date().toISOString(); + setUnreadCount(0); + setNotifications((prev) => prev.map((n) => (n.readAt ? n : { ...n, readAt: now }))); + classroomAPI.markNotificationsRead(idToken).catch(() => {}); + const cache = readCache(); + if (cache) { + writeCache({ + ...cache, + unreadCount: 0, + notifications: (cache.notifications || []).map((n) => (n.readAt ? n : { ...n, readAt: now })), + }); + } + }, [idToken, unreadCount]); + // Reset in-memory state (logout / go-to-login). 日次キャッシュは残す // (同じ先生が同じ日に入り直しても取得は 1 回に保つ)。 const resetNotifications = useCallback(() => { @@ -129,6 +136,7 @@ const useTeacherNotifications = ({ idToken, handleTeacher401 }) => { isOpen, handleToggleNotifications, handleCloseNotifications, + handleMarkAllRead, resetNotifications, }; }; diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 13b74b8a14..0f00609dda 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -262,7 +262,10 @@ export default { 'gui.classroom.notifications.title': 'おしらせ', 'gui.classroom.notifications.empty': 'おしらせは ありません', 'gui.classroom.avatar.buttonLabel': 'アカウントメニュー', - 'gui.classroom.notifications.seeAll': 'すべて みる({count}けん)', + 'gui.classroom.notifications.menuLabel': 'おしらせメニュー', + 'gui.classroom.notifications.markAllRead': 'すべて きどくにする', + 'gui.classroom.notifications.back': 'もどる', + 'gui.classroom.notifications.openAll': 'おしらせを ひらく', 'gui.classroom.notifications.pageIndicator': '{current} / {total}', 'gui.classroom.classList.title': 'あなたのクラス', 'gui.classroom.classList.hint': diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 8f1be87753..a018aff8d6 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -257,7 +257,10 @@ export default { 'gui.classroom.notifications.title': 'お知らせ', 'gui.classroom.notifications.empty': 'お知らせはありません', 'gui.classroom.avatar.buttonLabel': 'アカウントメニュー', - 'gui.classroom.notifications.seeAll': 'すべて見る({count}件)', + 'gui.classroom.notifications.menuLabel': 'お知らせメニュー', + 'gui.classroom.notifications.markAllRead': 'すべて既読にする', + 'gui.classroom.notifications.back': '戻る', + 'gui.classroom.notifications.openAll': 'お知らせを開く', 'gui.classroom.notifications.pageIndicator': '{current} / {total}', 'gui.classroom.classList.title': 'あなたのクラス', 'gui.classroom.classList.hint': diff --git a/packages/scratch-gui/test/unit/components/teacher-notifications-list.test.jsx b/packages/scratch-gui/test/unit/components/teacher-notifications-list.test.jsx index bad0690290..4a10602bce 100644 --- a/packages/scratch-gui/test/unit/components/teacher-notifications-list.test.jsx +++ b/packages/scratch-gui/test/unit/components/teacher-notifications-list.test.jsx @@ -16,10 +16,10 @@ const many = (n) => createdAt: '2026-07-26T00:00:00.000Z', })); -const renderList = (notifications, onOpenLink = jest.fn()) => +const renderList = (notifications, onOpenLink = jest.fn(), onBack = jest.fn()) => render( - + , ); @@ -60,4 +60,16 @@ describe('TeacherNotificationsList (#1111 レビュー・10件/頁)', () => { fireEvent.click(screen.getByTestId('classroom-notification-page-item-id-1')); expect(onOpenLink).toHaveBeenCalledWith({ kind: 'classroom', classroomId: 'c1' }); }); + + test('左下の戻るボタンが onBack を呼ぶ (#1111 レビュー)', () => { + const onBack = jest.fn(); + renderList(many(3), jest.fn(), onBack); + fireEvent.click(screen.getByTestId('classroom-notifications-back')); + expect(onBack).toHaveBeenCalled(); + }); + + test('空でも戻るボタンは出る', () => { + renderList([]); + expect(screen.getByTestId('classroom-notifications-back')).toBeInTheDocument(); + }); }); diff --git a/packages/scratch-gui/test/unit/components/teacher-notifications.test.jsx b/packages/scratch-gui/test/unit/components/teacher-notifications.test.jsx index 0ba07e39fb..ac26274887 100644 --- a/packages/scratch-gui/test/unit/components/teacher-notifications.test.jsx +++ b/packages/scratch-gui/test/unit/components/teacher-notifications.test.jsx @@ -20,6 +20,7 @@ const defaultProps = () => ({ isOpen: false, notifications: [], unreadCount: 0, + onMarkAllRead: jest.fn(), onOpenLink: jest.fn(), onShowAll: jest.fn(), onToggle: jest.fn(), @@ -82,20 +83,31 @@ describe('TeacherNotifications (EPIC #1111)', () => { expect(onOpenLink).toHaveBeenCalledWith({ kind: 'classroom', classroomId: 'c1' }); }); - test('パネルは先頭5件のみ表示し、6件以上で「すべて見る」を出す (#1111 レビュー)', () => { + test('パネルは先頭5件のみプレビュー表示する (#1111)', () => { + renderNotifications({ isOpen: true, notifications: many(8) }); + expect(screen.getAllByTestId(/classroom-notification-item-/)).toHaveLength(5); + }); + + test('⋯メニューから「すべて既読にする」「お知らせを開く」を選べる (#1111 レビュー)', () => { + const onMarkAllRead = jest.fn(); const onShowAll = jest.fn(); - renderNotifications({ isOpen: true, notifications: many(8), onShowAll }); - // 5 件だけ表示。 - const items = screen.getAllByTestId(/classroom-notification-item-/); - expect(items).toHaveLength(5); - const seeAll = screen.getByTestId('classroom-notifications-see-all'); - expect(seeAll).toHaveTextContent('8'); - fireEvent.click(seeAll); + renderNotifications({ isOpen: true, notifications: many(3), unreadCount: 3, onMarkAllRead, onShowAll }); + // メニューは閉じている。 + expect(screen.queryByTestId('classroom-notifications-menu')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('classroom-notifications-menu-button')); + // すべて既読にする + fireEvent.click(screen.getByTestId('classroom-notifications-mark-all-read')); + expect(onMarkAllRead).toHaveBeenCalled(); + // 再度開いて お知らせを開く + fireEvent.click(screen.getByTestId('classroom-notifications-menu-button')); + fireEvent.click(screen.getByTestId('classroom-notifications-open-all')); expect(onShowAll).toHaveBeenCalled(); }); - test('5件以下なら「すべて見る」を出さない', () => { - renderNotifications({ isOpen: true, notifications: many(5) }); - expect(screen.queryByTestId('classroom-notifications-see-all')).not.toBeInTheDocument(); + test('未読0なら「すべて既読にする」は無効。1件でも一覧は件数に依らず開ける', () => { + renderNotifications({ isOpen: true, notifications: many(1), unreadCount: 0 }); + fireEvent.click(screen.getByTestId('classroom-notifications-menu-button')); + expect(screen.getByTestId('classroom-notifications-mark-all-read')).toBeDisabled(); + expect(screen.getByTestId('classroom-notifications-open-all')).toBeEnabled(); }); }); diff --git a/packages/scratch-gui/test/unit/containers/use-teacher-notifications.test.js b/packages/scratch-gui/test/unit/containers/use-teacher-notifications.test.js index d0b6466c37..df16f44efd 100644 --- a/packages/scratch-gui/test/unit/containers/use-teacher-notifications.test.js +++ b/packages/scratch-gui/test/unit/containers/use-teacher-notifications.test.js @@ -129,20 +129,33 @@ describe('useTeacherNotifications (EPIC #1111 / 日次取得)', () => { } }); - test('開封でバッジを消し既読を永続化。キャッシュの未読も 0 になる', async () => { + test('開くだけでは既読にしない(自動既読を廃止・レビュー指摘)', async () => { mockListNotifications.mockResolvedValue({ notifications: [notice('n1')], unreadCount: 1 }); const { result } = renderHook(() => useTeacherNotifications({ idToken: TOKEN, handleTeacher401 })); await waitFor(() => expect(result.current.unreadCount).toBe(1)); act(() => result.current.handleToggleNotifications()); expect(result.current.isOpen).toBe(true); + // バッジは残る。API も呼ばない。 + expect(result.current.unreadCount).toBe(1); + expect(mockMarkNotificationsRead).not.toHaveBeenCalled(); + }); + + test('handleMarkAllRead でバッジを消し既読を永続化。キャッシュとドットも既読に', async () => { + mockListNotifications.mockResolvedValue({ notifications: [notice('n1')], unreadCount: 1 }); + const { result } = renderHook(() => useTeacherNotifications({ idToken: TOKEN, handleTeacher401 })); + await waitFor(() => expect(result.current.unreadCount).toBe(1)); + + act(() => result.current.handleMarkAllRead()); expect(result.current.unreadCount).toBe(0); + expect(result.current.notifications[0].readAt).toBeTruthy(); expect(mockMarkNotificationsRead).toHaveBeenCalledWith(TOKEN); - expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)).unreadCount).toBe(0); + const cache = JSON.parse(window.localStorage.getItem(STORAGE_KEY)); + expect(cache.unreadCount).toBe(0); + expect(cache.notifications[0].readAt).toBeTruthy(); - // 既読 0 件の再オープンでは再送しない。 - act(() => result.current.handleToggleNotifications()); - act(() => result.current.handleToggleNotifications()); + // 既読 0 件で再度呼んでも API を再送しない。 + act(() => result.current.handleMarkAllRead()); expect(mockMarkNotificationsRead).toHaveBeenCalledTimes(1); });