CLUE-570: teacher-created custom comment tags#2908
Conversation
Let teachers add custom comment tags for their class (class+unit scoped) that
sync in real time and are usable for manual comments, AI evaluation, and Sort
Work grouping. Gated by a new unit authoring switch so legacy units are
unaffected. Tag editing/combining/deleting and Class-Summary suggestions are
out of scope (future).
Authoring
- New unit config `allowCustomCommentTags` (default false), end-to-end like
`defaultSharedDocuments`.
- New authoring "Comments" page (comments-settings.tsx) with the showCommentTag
toggle (previously had no UI) and the allowCustomCommentTags switch; the
comment roles/rating/tag-list editor moved here out of AI Settings. Registered
in left-nav + workspace routing. AI Settings still mirrors categories from the
tag list. No unit-JSON migration (only the authoring surface moved).
Sync (class + unit scope)
- CommentTags store + DBCommentTagsListener: Firestore onSnapshot at
commentTags/{classHash}/units/{unit}/tags, reactively (re)subscribed as
class/unit load. Firestore rule grants in-class students read.
- Effective tag list = unit-config tags + synced custom tags, used by the comment
picker/display and Sort Work. getTagsWithDocs hardened so any tag on a document
forms a group even if its label isn't loaded (orphan guard for cross-unit sort).
Add UI (Sort Work)
- Teacher-only "+ Add Tag" control below the "Not Tagged" section in the Strategy
(tag) sort; adding immediately creates a new empty sort section. Datalist
typeahead over existing tags; Add is disabled for a duplicate name.
AI
- Custom tag ids are merged into aiPrompt.categories at the client's analysis
metadata write, so `custom`-evaluation units categorize with them (no
functions-v2 change).
Tests: config getter, CommentTags store, getTagsWithDocs orphan guard, and
SortWorkAddTag (add + teacher gating + duplicate disable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds support for teacher-created custom comment tags (scoped to class + unit) that sync in real time and are usable for comment tagging, Sort Work grouping, and AI categorization, with authoring UI moved to a new Comments settings page.
Changes:
- Introduces
allowCustomCommentTagsunit config and a new authoring “Comments” page; AI Settings now mirrors categories from tags. - Implements a
CommentTagsstore + Firestore snapshot listener for per-class/unit custom tags, and updates tag consumers to use merged (config + custom) tags. - Adds a teacher-only “+ Add Tag” control in Sort Work and hardens tag grouping so documents with unknown tag ids still form groups.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utilities/sort-document-utils.ts | Prevents “orphaned” tagged docs by creating groups for unknown strategy/tag ids. |
| src/utilities/sort-document-utils.test.ts | Adds unit tests for getTagsWithDocs grouping + orphan guard. |
| src/models/stores/unit-configuration.ts | Adds allowCustomCommentTags to unit configuration schema. |
| src/models/stores/stores.ts | Registers new commentTags store in root stores. |
| src/models/stores/sorted-documents.ts | Uses merged tag list (config + custom) for sorting/grouping. |
| src/models/stores/document-group.ts | Uses merged tag list for Strategy grouping. |
| src/models/stores/document-group.test.ts | Updates store stubs to include commentTags. |
| src/models/stores/configuration-manager.ts | Adds computed getter for allowCustomCommentTags gated by showCommentTag. |
| src/models/stores/configuration-manager.test.ts | Adds tests for new config getter behavior. |
| src/models/stores/comment-tags.ts | New MobX store for custom tags + helper functions + Firestore write method. |
| src/models/stores/comment-tags.test.ts | Unit tests for id/path helpers and merge/replace behavior. |
| src/models/stores/base-stores-types.ts | Extends base store types to include commentTags. |
| src/models/stores/app-config-model.ts | Exposes allowCustomCommentTags via app config model. |
| src/lib/firebase.ts | Includes custom tag ids in AI categories when writing “custom” evaluation prompts. |
| src/lib/db-listeners/index.ts | Starts/stops the new Firestore comment-tags listener. |
| src/lib/db-listeners/db-comment-tags-listener.ts | New Firestore onSnapshot listener that syncs tag docs into the store. |
| src/components/document/sorted-section.test.tsx | Passes commentTags store into sorted-section test setup. |
| src/components/document/sort-work-view.tsx | Renders the new “Add Tag” control when sorting by Strategy. |
| src/components/document/sort-work-add-tag.tsx | New UI to add tags (teacher-only), with datalist suggestions + duplicate blocking. |
| src/components/document/sort-work-add-tag.test.tsx | Tests for teacher gating, add flow, and duplicate disable. |
| src/components/document/sort-work-add-tag.scss | Styles for the new “Add Tag” control. |
| src/components/chat/comment-card.tsx | Makes CommentCard reactive to merged tags so the picker includes custom tags. |
| src/authoring/types.ts | Adds allowCustomCommentTags to authoring unit config typing. |
| src/authoring/components/workspace/comments-settings.tsx | New authoring page for comment/tag settings; mirrors tags into AI categories. |
| src/authoring/components/workspace/ai-settings.tsx | Removes tag/role/rating authoring UI; keeps AI fields and mirrors categories from tags. |
| src/authoring/components/workspace.tsx | Adds routing to the new Comments settings page. |
| src/authoring/components/left-nav.tsx | Adds “Comments” entry in authoring left nav. |
| firestore.rules | Adds student read access to per-class/unit custom comment tags in authed mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| config.showCommentTag = data.showCommentTag; | ||
| if (data.allowCustomCommentTags) { | ||
| config.allowCustomCommentTags = true; | ||
| } else { | ||
| delete config.allowCustomCommentTags; | ||
| } |
There was a problem hiding this comment.
Fixed: the submit handler now only persists allowCustomCommentTags when showCommentTag is true, and deletes it otherwise.
| import { observer } from "mobx-react"; | ||
| import React, { useState } from "react"; | ||
| import { useStores } from "../../hooks/use-stores"; | ||
|
|
||
| import "./sort-work-add-tag.scss"; | ||
|
|
||
| // Teacher-only "+ Add Tag" control shown below the "Not Tagged" section when documents are sorted | ||
| // by tag (Strategy) in the Sort Work view. Adding a tag writes it to Firestore; it syncs back into | ||
| // the effective tag list, which immediately creates a new (empty) sort section for it. A datalist | ||
| // gives typeahead over existing tag labels so teachers reuse tags rather than creating variants. | ||
| export const SortWorkAddTag: React.FC = observer(function SortWorkAddTag() { | ||
| const { appConfig, commentTags, user } = useStores(); | ||
| const [adding, setAdding] = useState(false); | ||
| const [text, setText] = useState(""); | ||
|
|
||
| const canAdd = !!appConfig.showCommentTag && !!appConfig.allowCustomCommentTags && user.isTeacher; | ||
| if (!canAdd) return null; | ||
|
|
||
| const tags = commentTags.mergedWith(appConfig.commentTags); | ||
| const trimmed = text.trim(); | ||
| // Disallow creating a tag whose name already exists (case-insensitive), for tag hygiene. | ||
| const isDuplicate = !!trimmed && | ||
| Object.values(tags).some(label => label.trim().toLowerCase() === trimmed.toLowerCase()); | ||
|
|
There was a problem hiding this comment.
Fixed: the Add button is now also disabled when commentTagId(entered) matches an existing tag key (id collision), not just a matching display name. Added a test for the config-key-collision case.
| <input | ||
| type="text" | ||
| list="sort-work-tag-suggestions" | ||
| className="add-tag-input" | ||
| data-testid="sort-work-add-tag-input" | ||
| placeholder="New tag" | ||
| value={text} |
There was a problem hiding this comment.
Added aria-label="New tag name" to the input.
- comments-settings: don't persist allowCustomCommentTags when showCommentTag is off (the disabled checkbox could otherwise retain a true value). - sort-work-add-tag: also block a new tag whose generated id collides with an existing tag key (would override that tag's label in the merged list), not just a matching display name. - sort-work-add-tag: add an aria-label to the new-tag input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #2908 +/- ##
========================================
Coverage 85.98% 85.98%
========================================
Files 918 921 +3
Lines 52713 52854 +141
Branches 13940 13978 +38
========================================
+ Hits 45324 45447 +123
- Misses 7373 7391 +18
Partials 16 16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-570-user-created-comment-tags
|
| Run status |
|
| Run duration | 10m 34s |
| Commit |
|
| Committer | Leslie Bondaryk |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
1
|
|
|
5
|
|
|
0
|
|
|
220
|
| View all changes introduced in this branch ↗︎ | |
- DBCommentTagsListener.start() is now idempotent (disposes any existing reaction/subscription first) so a double start() can't leak them. - Document that custom tags feed AI categories only for "custom" evaluation (categorize-design/mock use fixed cloud-function categories). - Document the class-hash escaping coupling between customCommentTagsPath and the firestore.rules read rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
comment-card (now an observer reading stores.commentTags) and firebase's updateEvaluation depend on the new commentTags store; several suites use hand-rolled partial mock stores that lacked it. - Add a commentTags stub to the mock stores in comment-card / chat-panel / chat-thread / firebase tests. - updateEvaluation now only rewrites aiPrompt when there are custom categories to add (unchanged otherwise) — more conservative and avoids mangling a non-object aiPrompt. Added a test for the merge path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dougmartin
left a comment
There was a problem hiding this comment.
Nice work — the architecture here is clean: the CommentTags store / listener / merge split is easy to follow, the tag-id collision guard and the getTagsWithDocs orphan fix are thoughtful, typecheck is clean, and all the affected Jest suites pass for me.
Requesting changes on one blocker, though: the feature doesn't work in authed mode. The new Firestore rule grants only read, and parent-document rules don't cascade into subcollections, so the teacher's tag write is denied. I confirmed this against the rules emulator rather than inferring it. It passes manual testing only because demo mode is covered by the permissive /demo/{demoName}/{restOfPath=**} rule — and the failure is invisible in the UI, since the write isn't awaited or caught. Both fixes are small.
One other note: you also need to fix a merge conflict, probably from another PR that landed while you were developing this one.
Details below.
Findings
-
firestore.rules:482-487— BLOCKER: nowriterule, soaddTag()fails withPERMISSION_DENIEDinauthedmode.
The new path grants onlyallow read. Firestore rules do not cascade into subcollections without a recursive wildcard, so theallow read, writeon the parentmatch /authed/{portal}(line 123) does not apply to/authed/{portal}/commentTags/{classHash}/units/{unit}/tags/{tagId}. Confirmed against the rules emulator: a teacher write to that path fails withfalse for 'create' @ L12(the catch-all deny). Reads behave correctly. Add an explicit write grant scoped to the teacher's class/unit, and validate the payload the way the existing comment/support rules do (id == tagId,classHash == classHash,unit == unit,createdByis the requesting user,labelis a non-empty string) so an in-class teacher can't write arbitrary or mismatched fields:match /commentTags/{classHash}/units/{unit}/tags/{tagId} { allow read: if isAuthed() && request.auth.token.class_hash == classHash; allow write: if isAuthedTeacher() && (request.auth.token.class_hash == classHash || teacherIsInClass(classHash)) && isValidCommentTagRequest(classHash, unit, tagId); }Also fix the comment above the rule, which asserts the (incorrect) parent-rule inheritance. Consider whether teachers/researchers viewing a different class of theirs need read access too — the current condition is
class_hash == classHashonly, whereas the neighboringaicontentrule (lines 475-479) also allowsteacherIsInClass(classId). -
src/components/document/sort-work-add-tag.tsx:29-35andsrc/models/stores/comment-tags.ts:51-66— failed tag writes fail silently.commit()calls the asyncaddTag()without awaiting or catching it, andaddTag()has no error handling, so a rejected Firestore write surfaces only as an unhandled promise rejection while the UI clears the input and closes the row: the teacher sees nothing happen and gets no error. Makecommitasync, await the write, keep the entered text on failure, and show an error state. -
firebase-test/src/— no rules test for the new path. Every other subcollection under/authed/{portal}has one (documents-rules.test.ts,supports-rules.test.ts, …). Acomment-tags-rules.test.tscovering teacher-write / in-class-student-read / in-class-student-write-deny / out-of-class-student-read-deny would have caught the missing write rule. -
src/lib/db-listeners/db-comment-tags-listener.ts:26-33— the reaction cannot observe a unit change.const { user, unit } = this.db.stores;captures theUnitModelinstance;Stores.setUnit()(src/models/stores/stores.ts:324-326) replaces that instance wholesale, so a reaction readingunit.codeoff the captured object never re-fires when the unit changes.StoresismakeAutoObservable, so read through the store instead —() => ({ classHash: this.db.stores.user.classHash, unit: this.db.stores.unit.code })— which makes the reassignment observable. Latent today (listeners start afterunitLoadedPromise, and switching units does a full page reload), but the code comment claims the reaction "handles the unit loading asynchronously after listeners start", which is not true as written; without the fix, a unit change in place would leave the store subscribed to the previous unit's tags. -
src/utilities/sort-document-utils.ts:94-105— nit: the orphan guard uses the raw tag id as the section label (tagValue: strategy), so an unresolved tag renders as a slug (e.g.my-custom-tag) in the Sort Work heading. Fine as a fallback, but worth confirming with design that a slug heading is acceptable versus something like "Unknown tag".
…ed-comment-tags # Conflicts: # src/models/stores/document-group.ts
…ener reactivity
- firestore.rules: grant an explicit write on the commentTags subcollection (rules
don't cascade from /authed/{portal}, so the teacher write was denied in authed
mode). Scoped to teachers of the class or their networked classes, with a new
isValidCommentTagRequest validating id/classHash/unit match the path, createdBy is
the requesting teacher, and label is non-empty. Read: in-class users + networked
teachers. Fixed the misleading comment. (matches the neighboring aicontent rule)
- Add firebase-test/comment-tags-rules.test.ts (teacher write, payload validation,
in-class student read, student/other-class/unauthed denies).
- sort-work-add-tag: commit() now awaits addTag() and shows an error (keeping the
entered text) instead of silently clearing on a failed write.
- db-comment-tags-listener: read unit/classHash through the observable stores so a
Stores.setUnit() swap re-fires the reaction (was capturing the UnitModel instance).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — all addressed in 3018b16 (plus a
tsc + ESLint clean; comment-tags / document-group / sort-work Jest suites pass. |
- Confirm/clarify that researchers can read custom tags: the read rule's role-agnostic in-class clause (class_hash == classHash) already covers students, teachers, and researchers, so a researcher can see and use custom tags when commenting. Made the comment explicit and added rules tests (in-class researcher read allowed; researcher write denied). - Log a CREATE_CUSTOM_COMMENT_TAG event (with the tag name) when a teacher successfully creates a tag. - Test that a failed tag write keeps the entered text and shows an error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up (0f62c89):
I can't run the rules suite locally (no emulator), so I'll verify the write rule on staging as usual. |
dougmartin
left a comment
There was a problem hiding this comment.
Re-reviewed — approving. All four items from my last pass are genuinely fixed, and I verified the blocker against the emulator rather than taking the description's word for it.
Verified
- Firestore write rule. Ran the new
firebase-test/src/comment-tags-rules.test.tsunder the rules emulator: all 12 cases pass, including the teacher write that previously failed withPERMISSION_DENIED. I also ran the full rules suite to make sure the newisValidCommentTagRequestand the edited match block didn't regress a neighboring rule — clean. Worth noting for the record, since the tests alone don't prove it: the rule requirescreatedBy == platform_user_id, and the client writescreatedBy: user.id, which traces back touidAsStringinsrc/lib/auth.ts:279— the same identity the existing comment/support rules match on. So the real client write satisfies the rule, not just the test's hand-built payload. - Researcher read. Your correction is right: the read rule's first clause is role-agnostic, so an in-class researcher already satisfies it, and there's now a test asserting it.
- Listener reactivity. Correct fix —
StorescallsmakeAutoObservable(this)(stores.ts:124) andsetUnitreassignsthis.unit(line 324), so reading throughthis.db.stores.unit.codedoes make the unit swap observable. - Silent failure.
commit()now awaits, keeps the entered text, and surfaces an error, with a test covering the rejected-write path. - Merge conflict cleared;
tscand ESLint clean; the affected Jest suites pass (64 tests).
Non-blocking nits — fine to fold into a follow-up:
src/components/document/sort-work-add-tag.tsx:95-102— the "Tag already exists" / "Couldn't save tag" messages are plain<span>s, so a screen reader user gets no announcement when a save fails or a duplicate blocks Add; they just find the button silently disabled. Addrole="alert"to both spans (the repo already does this ingroup-management-modal.tsxandpicked-up-tile-ghost.tsx), and consideraria-invalid/aria-describedbyon the input at lines 65-79.src/models/stores/comment-tags.ts:53-57andsort-work-add-tag.tsx:31-45—addTag()returns early successfully whenclassHashorunitis missing, socommit()clears the input and closes the row as though the write happened. Throw (or return a boolean the caller checks) so it hits the same error state as a rejected write.src/components/document/sort-work-add-tag.tsx:39-41— thecatchswallows the rejection, so aPERMISSION_DENIEDin the field leaves nothing in the console to diagnose. Log the caught error alongside setting the error state.firebase-test/src/comment-tags-rules.test.ts:28-52— the networked-teacher branch (teacherIsInClassinfirestore.rules:502and504) is untested; the write cases cover only an in-class teacher and an out-of-class teacher with no network. Add a case with a teacher whose network includes the class, mirroring theaicontentrules tests.
Don't forget the npm run deploy:firestore:rules step in your deploy notes — students and teachers both need the new rule live.
…logging, networked-teacher test - sort-work-add-tag: the duplicate/error messages get role="alert" and the input gets aria-invalid + aria-describedby, so screen readers announce why Add is blocked/failed instead of leaving the button silently disabled. - addTag throws when classHash/unit is missing instead of returning as though the write happened, so commit() hits the same error state as a rejected write. - commit() logs the caught error so a PERMISSION_DENIED is diagnosable. - comment-tags-rules test: add networked-teacher (teacherIsInClass) write + read cases, seeding the class-teachers doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the latest nits in a462464:
Added unit coverage too: |
Remove comments that restate the code (the repeated "Effective tag list = ..." before mergedWith calls, "Remove spaces as user types", "Log only after the write succeeds", test comments that restate their it() description) and tighten a few verbose blocks (the add-tag header, firestore.rules tag comments) while keeping the non-obvious "why" notes (rules-don't-cascade, MobX reassignment, escapeKey, orphan-tag rationale). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Lets teachers add custom comment tags for their class (scoped to class + unit) that sync in real time and are immediately usable for manual comments, AI evaluation, and Sort Work grouping. Gated by a new unit authoring switch so legacy units are unaffected.
Out of scope (future): editing/combining/deleting tags without orphaning docs; Class-Summary tag suggestions.
Changes
Authoring
allowCustomCommentTags(default false), threaded end-to-end likedefaultSharedDocuments.comments-settings.tsx) with theshowCommentTagtoggle (previously had no UI) and theallowCustomCommentTagsswitch; the comment roles / rating / tag-list editor moved here out of AI Settings. Registered inleft-nav+workspacerouting. AI Settings still mirrors AI categories from the tag list. No unit-JSON migration — only the authoring surface moved.Sync (class + unit)
CommentTagsstore +DBCommentTagsListener: FirestoreonSnapshotatcommentTags/{classHash}/units/{unit}/tags, reactively (re)subscribed as class/unit load.firestore.rulesentry grants in-class students read (teachers/researchers already covered by the parent/authed/{portal}rule).getTagsWithDocshardened so any tag on a document forms a group even when its label isn't loaded — no document is orphaned from the tag sort at Unit/All scope.Add UI (Sort Work)
<datalist>typeahead over existing tags; Add is disabled for a duplicate name (case-insensitive).AI
aiPrompt.categoriesat the client's analysis-metadata write, socustom-evaluation units categorize with them. Nofunctions-v2change.Test plan
allowCustomCommentTagsconfig getter,CommentTagsstore (merge/replace/id/path),getTagsWithDocsorphan guard,SortWorkAddTag(add + teacher gating + duplicate disable).tsc, ESLint, and affected Jest suites are green.showCommentTag+allowCustomCommentTagson → teacher adds a tag in Sort Work → new empty section appears, tag is selectable in comments; tag a doc → it lands in that group. Demo mode (shared root) demonstrates teacher→student propagation locally.Deploy notes
npm run deploy:firestore:rules) before students can read custom tags in authed mode.🤖 Generated with Claude Code