Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/classroom/ui-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ Google または Microsoft アカウントでサインインする画面。先
| フェーズルート | — | `classroom-phase-teacher-detail` | — |
| 戻るリンク | 「< 戻る」 | `classroom-back` | → teacher-dashboard |
| クラス名 | 「第3回 チャットアプリを作ろう」 | `classroom-detail-name` | — |
| 課題名入力 | 課題名(編集可) | `classroom-detail-assignment-name` | blur で保存 |
| 課題配信ボタン | 「課題を配信」 | `classroom-post-assignment` | → teacher-post-assignment。**未配信のとき**表示 |
| 課題確認リンク | 「課題を確認」 | `classroom-view-assignment` | 配信済みのとき表示(新しいタブ) |
| 参加コード表示 | 「参加コード: 3cexm5」 | `classroom-detail-join-code` | 大きなフォントで中央表示 |
| コード拡大ボタン | ⛶ アイコン(ツールチップ: 「全画面表示」) | `classroom-detail-expand-code` | 全画面コード表示 |
| 有効期限 | 「有効期限: 2026/4/6」 | — | — |
Expand All @@ -184,6 +187,8 @@ Google または Microsoft アカウントでサインインする画面。先
| 全作品ダウンロード | 「全作品ダウンロード」 | `classroom-download-all` | 左寄せ |
| クラス削除ボタン | 「クラスを削除」 | `classroom-delete-classroom` | 赤枠ボタン、右寄せ |

**課題配信ボタンの表示条件:** Google Classroom 連携はクラス(group)単位に移行したため、配信ボタン(`classroom-post-assignment`)は **クラスが GC 連携済み(`group.googleClassroomCourseId`)** であれば、その課題自体に courseId が無くても表示されます(課題の投稿先はクラスのコース)。課題単位の `googleClassroomCourseId`(v2 以前のフォールバック)が有る場合も表示されます。どちらの courseId も無い(非連携クラス)ときは表示されません。配信済み(課題に `googleClassroomAlternateLink` が保存済み)になると「Google Classroom で確認」リンク(`classroom-view-assignment`)に切り替わります。

**座席グリッド:**

座席番号が格子状に並び(1行 10列)、各セルの背景色で状態を表します。
Expand Down
26 changes: 23 additions & 3 deletions infra/smalruby-classroom/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1901,6 +1901,25 @@ async function handleImportGoogleClassroom(
};
}

/**
* Resolve the Google Classroom course an assignment should be posted to.
*
* v2 moved the GC link from the assignment (classroom) to the class (group),
* so an assignment usually has no courseId of its own. The assignment's own
* field is kept only as a pre-v2 fallback and, when present, wins. Returns an
* empty string when neither is linked.
*/
export function resolveGoogleCourseId(
classroomItem: Record<string, unknown> | undefined,
groupItem: Record<string, unknown> | undefined,
): string {
const own = classroomItem?.googleClassroomCourseId;
if (typeof own === 'string' && own) return own;
const group = groupItem?.googleClassroomCourseId;
if (typeof group === 'string' && group) return group;
return '';
}

async function handlePostAssignment(
identity: TeacherIdentity,
accessToken: string,
Expand All @@ -1916,14 +1935,15 @@ async function handlePostAssignment(
throw new NotFoundError('Classroom not found');
}
// v2: the GC link lives on the class (group); the assignment's own field
// remains as a pre-v2 fallback.
let courseId = result.Item.googleClassroomCourseId as string;
// remains as a pre-v2 fallback. Only look the group up when the assignment
// itself carries no courseId.
let courseId = resolveGoogleCourseId(result.Item, undefined);
if (!courseId && typeof result.Item.groupId === 'string' && result.Item.groupId) {
const groupResult = await docClient.send(new GetCommand({
TableName: GROUPS_TABLE,
Key: { groupId: result.Item.groupId },
}));
courseId = (groupResult.Item?.googleClassroomCourseId as string) || '';
courseId = resolveGoogleCourseId(result.Item, groupResult.Item);
}
if (!courseId) {
throw new ValidationError('This classroom is not linked to Google Classroom');
Expand Down
33 changes: 33 additions & 0 deletions infra/smalruby-classroom/lambda/tests/handler-assignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
validateAssignmentPages,
hasAssignmentContent,
getCorsHeaders,
resolveGoogleCourseId,
} from '../handler';

const CLASSROOM_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
Expand Down Expand Up @@ -127,6 +128,38 @@ describe('hasAssignmentContent', () => {
});
});

describe('resolveGoogleCourseId (post assignment target)', () => {
test('falls back to the group course when the assignment has no courseId (v2)', () => {
// The reported bug: v2 moved the GC link to the class (group), so the
// assignment itself is null but posting must still target the group course.
expect(
resolveGoogleCourseId(
{ googleClassroomCourseId: null, groupId: 'g1' },
{ googleClassroomCourseId: 'course-123' },
),
).toBe('course-123');
});

test('prefers the assignment own courseId (pre-v2 fallback wins)', () => {
expect(
resolveGoogleCourseId(
{ googleClassroomCourseId: 'course-own' },
{ googleClassroomCourseId: 'course-group' },
),
).toBe('course-own');
});

test('returns empty string when neither the assignment nor the group is linked', () => {
expect(resolveGoogleCourseId({ googleClassroomCourseId: null }, undefined)).toBe('');
expect(resolveGoogleCourseId({}, {})).toBe('');
expect(resolveGoogleCourseId(undefined, undefined)).toBe('');
});

test('ignores a non-string courseId', () => {
expect(resolveGoogleCourseId({ googleClassroomCourseId: 42 }, undefined)).toBe('');
});
});

describe('getCorsHeaders (assignment routes)', () => {
test('allows PUT for the assignment endpoint', () => {
const headers = getCorsHeaders('https://smalruby.app');
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ test/unit/components/*
!test/unit/components/connected-step.test.jsx
!test/unit/components/mesh-self-sensor-notice.test.jsx
!test/unit/components/student-assignment-panel.test.jsx
!test/unit/components/teacher-class-detail.test.jsx
!test/unit/components/mobile-bottom-tabs.test.jsx
!test/unit/components/mobile-drawer.test.jsx
!test/unit/components/mobile-mode-notice.test.jsx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,13 @@ const TeacherClassDetail = ({
onBlur={handleAssignmentNameBlur}
onChange={handleAssignmentNameChange}
/>
{selectedClassroom.googleClassroomCourseId &&
{/* Google Classroom linkage now lives on the class
(group), so an assignment posts to the group's
course even when it has no courseId of its own.
The posted/unposted branch below still keys off
the assignment's own alternateLink. */}
{(selectedClassroom.googleClassroomCourseId ||
(group && group.googleClassroomCourseId)) &&
(selectedClassroom.googleClassroomAlternateLink ? (
<a
className={
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* eslint-env jest */
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import React from 'react';
import { IntlProvider } from 'react-intl';
import TeacherClassDetail from '../../../src/components/classroom-modal/teacher-class-detail.jsx';

const classroom = (over = {}) => ({
classroomId: 'c1',
className: '2年1組',
assignmentName: '課題1',
joinCode: 'ABCDEF',
studentCount: 3,
googleClassroomCourseId: null,
googleClassroomAlternateLink: null,
...over,
});

const defaultProps = () => ({
selectedClassroom: classroom(),
members: [],
isLoading: false,
onBack: jest.fn(),
onSelectMember: jest.fn(),
onDeleteMember: jest.fn(),
onDeleteClassroom: jest.fn(),
onOpenSubmission: jest.fn(),
onRefresh: jest.fn(),
onReturnSubmission: jest.fn(),
onDownloadAll: jest.fn(),
onShowCodeDisplay: jest.fn(),
onCloseCodeDisplay: jest.fn(),
onCopyInviteLink: jest.fn(),
onToggleCodeFullscreen: jest.fn(),
onShowPostAssignment: jest.fn(),
onUpdateAssignmentName: jest.fn(),
});

const renderDetail = (props) =>
render(
<IntlProvider locale="en">
<TeacherClassDetail {...defaultProps()} {...props} />
</IntlProvider>,
);

describe('TeacherClassDetail — Google Classroom post button', () => {
test('should show the post button when only the class (group) is linked to Google Classroom', () => {
// Post-refactor: the assignment itself has no courseId; the link lives on the group.
renderDetail({
selectedClassroom: classroom({ googleClassroomCourseId: null }),
group: { groupId: 'g1', googleClassroomCourseId: 'course-123' },
});
expect(document.querySelector('[data-testid="classroom-post-assignment"]')).toBeInTheDocument();
});

test('should show the post button when the assignment itself has a courseId', () => {
renderDetail({
selectedClassroom: classroom({ googleClassroomCourseId: 'course-123' }),
group: { groupId: 'g1', googleClassroomCourseId: null },
});
expect(document.querySelector('[data-testid="classroom-post-assignment"]')).toBeInTheDocument();
});

test('should hide the post button when neither the class nor the assignment is linked', () => {
renderDetail({
selectedClassroom: classroom({ googleClassroomCourseId: null }),
group: { groupId: 'g1', googleClassroomCourseId: null },
});
expect(document.querySelector('[data-testid="classroom-post-assignment"]')).not.toBeInTheDocument();
});

test('should show the view-assignment link (not the post button) once this assignment was posted', () => {
renderDetail({
selectedClassroom: classroom({
googleClassroomCourseId: null,
googleClassroomAlternateLink: 'https://classroom.google.com/c/x/a/y',
}),
group: { groupId: 'g1', googleClassroomCourseId: 'course-123' },
});
expect(document.querySelector('[data-testid="classroom-view-assignment"]')).toBeInTheDocument();
expect(document.querySelector('[data-testid="classroom-post-assignment"]')).not.toBeInTheDocument();
});
});
Loading