Add submission details page#5716
Add submission details page#5716AlexVelezLl wants to merge 11 commits intolearningequality:unstablefrom
Conversation
...ration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ExpandableContainer.vue
Show resolved
Hide resolved
fbafc5c to
e32b7f7
Compare
a74181e to
8eb02fe
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Submission details modal — solid feature addition with a few issues to address.
CI: frontend tests, linting, and build pass. Python unit tests still pending.
- blocking (2):
modal-openclass cleanup regression inStudioImmersiveModalwill leave the page unscrollable after navigating away;a.htmlscratch file accidentally committed at repo root - suggestion (5): null guard gaps in
ChannelDetails, hardcoded i18n string, missingexpandedprop declaration, fragile composable API - nitpick (1): typo in string key
- praise (2): see inline comments
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Reviewed the pull request diff checking for:
- Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
- Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
- Architecture: duplicated concerns, minimal interfaces, composition over inheritance
- Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
- Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
- Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
- Checked CI status and linked issue acceptance criteria
- For UI changes: inspected screenshots for layout, visual completeness, and consistency
| value: { | ||
| handler(newValue) { | ||
| if (newValue) { | ||
| document.documentElement.classList.add('modal-open'); |
There was a problem hiding this comment.
blocking: The refactor from mounted()/beforeDestroy to a value watcher lost the beforeDestroy cleanup. When this component is destroyed while value is true (e.g., navigating away from the modal route), modal-open stays on <html>, leaving overflow-y: hidden !important and making the destination page unscrollable.
The old code cleaned this up:
this.$once('hook:beforeDestroy', () => {
document.documentElement.classList.remove('modal-open');
});Suggested fix — add cleanup back to the existing $once block at line 95:
this.$once('hook:beforeDestroy', () => {
document.documentElement.classList.remove('modal-open');
document.removeEventListener('keydown', handleKeyDown);
});There was a problem hiding this comment.
Thanks, haven't thought about that edge case.
a.html
Outdated
| @@ -0,0 +1,167 @@ | |||
| <div class="disclosure-cards"> | |||
There was a problem hiding this comment.
blocking: This 167-line HTML file ("Musical Education Conference" disclosure cards demo) appears to be an accidentally committed scratch file. It has no relation to the community library submission feature and sits at the repo root. Please remove it from the branch.
| }); | ||
|
|
||
| const languagesString = computed(() => { | ||
| const languages = props.channelVersion.included_languages || []; |
There was a problem hiding this comment.
suggestion: props.channelVersion has default: null (line 128). If the ChannelVersion.fetchCollection call in the parent returns empty results, channelVersion will be undefined/null and this line throws TypeError: Cannot read properties of null.
Lines 229–230 and 236–237 use optional chaining (?.map) but this line does not. Similarly, the details computed (lines 192–196) and :channelVersionId="channelVersion.id" (line 39) access channelVersion without guards.
Does the backend guarantee a matching ChannelVersion always exists for every submission? If not, defensive guards are needed:
const languages = props.channelVersion?.included_languages || [];There was a problem hiding this comment.
Yes, ChannelVersion must exist for every submission. Changed the props declaration to declare this as required
| const licensesString = computed(() => { | ||
| return ( | ||
| props.channelVersion.included_licenses | ||
| ?.map(licenseId => LicensesMap.get(licenseId).license_name) |
There was a problem hiding this comment.
suggestion: LicensesMap.get(licenseId) returns undefined for an unknown license ID, causing .license_name to throw. Same issue at line 221 with LanguagesMap.get(code).readable_name. Consider optional chaining:
LanguagesMap.get(code)?.readable_name
LicensesMap.get(licenseId)?.license_name| <div class="actions"> | ||
| <KButton | ||
| v-if="adminReview && submission.status === CommunityLibraryStatus.PENDING" | ||
| text="review" |
There was a problem hiding this comment.
suggestion: text="review" is a hardcoded English string, bypassing the i18n system that every other label in this PR uses. Should this go through communityChannelsStrings or a similar translation source?
There was a problem hiding this comment.
Great catch. This was initially not a problem because this will only get rendered to admins, but if in the future we enable other users to review other submissions, then this will be a problem. Fixed!
|
|
||
| const emit = defineEmits(['open']); | ||
|
|
||
| defineProps({ |
There was a problem hiding this comment.
suggestion: ChannelDetails.vue passes :expanded="true" to this component, but defineProps only declares title. The expanded prop is silently ignored and the container always starts collapsed (isOpen = ref(false)). If the intent is for Channel Details to start expanded, add the prop and wire it:
defineProps({
title: { type: String, default: '' },
expanded: { type: Boolean, default: false },
});
const isOpen = ref(props.expanded);There was a problem hiding this comment.
expanded is not expected to be a prop, just removed it. Thanks.
| * helpers used by `SpecialPermissionsList.vue`. | ||
| */ | ||
| export function useSpecialPermissions(channelVersionId) { | ||
| export function useSpecialPermissions(channelVersionId, { distributable }) { |
There was a problem hiding this comment.
suggestion: Destructuring { distributable } without a default for the second argument means useSpecialPermissions(id) or useSpecialPermissions(id, undefined) will throw TypeError: Cannot destructure property 'distributable' of undefined. Consider:
export function useSpecialPermissions(channelVersionId, { distributable } = {}) {Also, the distributable !== null guard at line 52 won't catch undefined — if distributable is omitted, it will be undefined and the filter will be set to undefined. Using distributable != null (loose equality) would handle both.
| message: 'Submitted', | ||
| context: 'Status indicating that an Community Library submission is pending', | ||
| }, | ||
| superseededStatus: { |
There was a problem hiding this comment.
nitpick: The key superseededStatus has a spelling error — "superseded" has one 'e', not two. The message text is correct ('Superseded'), but the key name propagates the typo to CommunityLibraryStatusChip.vue. Easier to fix now than after it spreads to more consumers.
| <div> | ||
| <h1 | ||
| class="notranslate" | ||
| dir="auto" |
There was a problem hiding this comment.
praise: Good use of dir="auto" and class="notranslate" for user-generated channel names and descriptions. This correctly handles RTL content and prevents machine translation from mangling channel names.
| margin: 16px 0 0; | ||
| list-style: none; | ||
|
|
||
| li { |
There was a problem hiding this comment.
praise: The timeline connector CSS using ::before/::after pseudo-elements is elegantly implemented — clean, no JS overhead, and visually effective.
8eb02fe to
55bea6c
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Good progress — all 8 prior findings resolved.
CI: only the spreadsheet-update workflow is visible (passing). Core test/lint/build checks not shown.
- blocking (1): activity history channel filter not applied — see inline
- suggestion (1): ExpandableContainer event semantics
- praise (2): see inline
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| }); | ||
| const { hasMore, submissionsUpdates, isLoading, isLoadingMore, fetchData, fetchMore } = | ||
| useCommunityLibraryUpdates({ | ||
| channel: props.channelId, |
There was a problem hiding this comment.
blocking: useCommunityLibraryUpdates destructures { queryParams } from its options object (composable line 55), so { channel: props.channelId } silently discards the channel key — queryParams ends up undefined.
Even if it were passed inside queryParams, the composable's fetchSubmissionsUpdates only extracts date_updated__lte, date_updated__gte, status__in, search, and max_results (composable lines 153-159). The channel field is never forwarded to CommunityLibrarySubmission.fetchCollection, so the API call returns submissions across all channels.
The backend FilterSet does support a channel filter (viewset line 60). To fix, the composable needs to accept and forward the channel parameter — e.g.:
export default function useCommunityLibraryUpdates({ queryParams, channel } = {}) {
// ...
params = pickBy({
channel,
date_updated__lte: _params?.date_updated__lte,
// ...
});
}|
|
||
| async function toggleDisclosure() { | ||
| isOpen.value = !isOpen.value; | ||
| emit('open', isOpen.value); |
There was a problem hiding this comment.
suggestion: emit('open', isOpen.value) fires for both open and close. In ChannelDetails, @open="onOpen" runs onOpen on every toggle — the guard at line 253 prevents duplicate fetches, so this won't cause bugs, but the event name is misleading. Consider either renaming to toggle or only emitting when opening:
if (isOpen.value) {
emit('open');
}| @@ -455,6 +486,10 @@ | |||
| type: Boolean, | |||
There was a problem hiding this comment.
praise: The hideChannelHeader prop and the conditional v-if guards on each detail section (levels, tags, languages, etc.) cleanly allow StudioDetailsPanel to be reused for the submission details page without duplicating the component. Well done making the existing component flexible rather than creating a new one.
| gap: 8px; | ||
| } | ||
|
|
||
| .activity-list { |
There was a problem hiding this comment.
praise: The timeline CSS using ::before/::after pseudo-elements with SCSS variables is well-structured and easy to follow. Good use of named constants for spacing values.
rtibblesbot
left a comment
There was a problem hiding this comment.
Good progress — all 10 prior findings resolved.
CI: spreadsheet-update workflow passing; core test/lint/build checks not shown.
- suggestion (1): inconsistent language label string in
ChannelDetails— see inline - praise (1): see inline
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| :text="countriesString" | ||
| /> | ||
| <StudioDetailsRow | ||
| :label="languageLabel$()" |
There was a problem hiding this comment.
suggestion: This uses languageLabel$() which resolves to "Language" (singular, from the pre-existing string at communityChannelsStrings.js:72). The sibling labels all use the plural/parenthesized forms — countryLabel$() → "Country(s)", licensesLabel$() → "License(s)". The PR already added languagesLabel with message "Language(s)" (line 217), so this should likely be languagesLabel$() for consistency.
- :label="languageLabel$()"
+ :label="languagesLabel$()"| @@ -4,32 +4,33 @@ | |||
| :class="{ printing }" | |||
| data-testid="details-panel" | |||
There was a problem hiding this comment.
praise: The systematic null-guarding of detail rows (v-if on levels, categories, includes, tags, languages, authors, providers, aggregators, etc.) plus the hideChannelHeader prop make StudioDetailsPanel cleanly reusable for older channel versions where these fields are unavailable. The includesPrintable and licensesPrintable defensive checks are a nice touch too.
e060332 to
b7013da
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean feature addition — well-structured submission details page with good component decomposition.
CI passing. 11 prior finding(s) resolved.
- suggestion (1): stale
queryParamscapture inActivityHistory— see inline
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| const { hasMore, submissionsUpdates, isLoading, isLoadingMore, fetchData, fetchMore } = | ||
| useCommunityLibraryUpdates({ | ||
| queryParams: { | ||
| channel: props.channelId, |
There was a problem hiding this comment.
suggestion: props.channelId is captured by value into a plain object here, so the composable's queryParams.channel won't update if channelId changes later. The watcher at line 217 calls fetchData() on channelId changes, but the composable will still use the stale value since unref() on a plain object returns it as-is.
In practice this doesn't bite today because in-page navigation (goToLiveSubmission, goToSubmission) stays within the same channel. But if cross-channel navigation ever becomes possible, wrapping this in a computed would keep the composable in sync:
const { ... } = useCommunityLibraryUpdates({
queryParams: computed(() => ({ channel: props.channelId })),
});b7013da to
8cead6d
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean feature addition — well-structured submission details page with good component decomposition and thorough null-guarding.
CI passing. 12 prior finding(s) resolved.
- praise (1): see inline
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| v-if="expandable" | ||
| appearance="basic-link" | ||
| :aria-controls="expandableContentId" | ||
| :aria-expanded="isOpen.toString()" |
There was a problem hiding this comment.
praise: Clean ARIA disclosure pattern — aria-controls linked to the content id, aria-expanded reflecting state, and scrollIntoView on expand. Well done.
There was a problem hiding this comment.
It seems that v-bind has some difficulties when the nodes their styles are applied to are removed from the DOM, and then reinserted again. (In this case the v-if=isModalVisible update made it appear). It seems that when the nodes are removed, the CSS variables are removed too, but if they are re-inserted, the CSS variables are not. So, just came back these colors to be declared in inline styles. And flagging that this is something that can happen with Vue 2.7's v-bind method.
| // Do not emit close just yet, so that the component isn't unmounted | ||
| // this will keep the component live until the submit function finishes, allowing | ||
| // to keep communicating with the parent component, and in particular allowing the | ||
| // "change" event to be emitted. It also allows us to keep the working information | ||
| // on the component, and show the side panel in the same state if the user cancels | ||
| isModalVisible.value = true; |
There was a problem hiding this comment.
This was the best way I found to manage this. It is similar to what we were doing initially for the BUM side panels, where the side panel component was still live until the user rejected the "undo" modal.
| export function getCommunityLibrarySubmissionDetailsUrl(channelId, submissionId) { | ||
| return `/channels/#/community-library/${channelId}/${submissionId}`; | ||
| } |
There was a problem hiding this comment.
@rtibbles I have had several internal conflicts regarding whether to treat this new page as a page (i.e., with its defined path) or only as a modal opened by a query param. Since here we needed some required arguments, the submissionId and channelId, I have made it a page, but the problem is that this page needs to be opened from any plugin, because we can open this page from the NotificationsModal.
Because of this, I had to do this getCommunityLibrarySubmissionDetailsUrl to get a link to a page outside the current Vue app, similar to what we did for the my downloads redirection, but not sure if there is a better path forward, or if we should just treat this as a modal, and open this with query params.
Summary
Grabacion.de.pantalla.2026-03-03.a.la.s.4.09.09.p.m.mov
References
Closes #5455.
Reviewer guidance