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,
/>
) : (
-
-
- {preview.map((notification) => (
-
- ))}
-
- {hasMore && (
-
- )}
-
+
+ {preview.map((notification) => (
+
+ ))}
+
)}
)}
@@ -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);
});