Skip to content

Submit to Community Library panel#5405

Merged
AlexVelezLl merged 14 commits intolearningequality:unstablefrom
Jakoma02:submit-to-community-library-panel
Sep 29, 2025
Merged

Submit to Community Library panel#5405
AlexVelezLl merged 14 commits intolearningequality:unstablefrom
Jakoma02:submit-to-community-library-panel

Conversation

@Jakoma02
Copy link
Copy Markdown
Contributor

Summary

This PR implements a side panel for submitting channels to the Community Library.

image

I manually tested the changes by interacting with it in the browser, tried network throttling simulation to check the experience for users on slow networks, and tried switching the interface to Arabic to see how it handles RTL languages.

Detailed changes

  • Implemented the SubmitToCommunityLibrarySidePanel component
  • Added a "Share" dropdown in TreeViewBase
  • Added addPublishedData method to the existing Channel resource
  • Added a new CommunityLibrarySubmission resource
  • Added constants for Community Library submission statuses
  • Added shared string for Community Library submission statuses
  • Moved a configured i18n-iso-countries instance to a separate util file to allow reuse outside CountryField
  • Added a fullWidth prop to CountryField to allow stretching the whole parent container width
  • Moved metadata strings from a mixins file to a separate shared strings file
  • Moved metadata translation logic from a mixin to a standalone translateMetadataString util function (motivated by making it easier to use this logic from components using the composition API, where using mixins is not practical)

References

Resolves #5262.

Reviewer guidance

The UX of the panel when it is loading data it needs to display was invented by me and needs to be especially checked from a design perspective. Also, warnings for when the channel is already public or when the latest published version of the channel already has a submission to the community library were not present in the initial design and should be especially checked.

Unresolved questions

  • The KTextbox component from KDS does not display correctly when an RTL language is used
image
  • The categories cannot be changed in the submission form, but are allowed to be changed on the backend; the backend implementation should be changed so that this is not supported

Copy link
Copy Markdown
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Jakoma02! Code changes looks good! I have left some nitpick comments, we can handle them in the scope of this PR if you would have some time to take a look, otherwise no worries! We can open follow up issues for them 👐.

expect(infoBoxes.length).toBe(1);
const infoBox = infoBoxes.wrappers[0];
expect(infoBox.text()).toContain(wrapper.vm.$tr('submittedPrimaryInfo'));
expect(infoBox.text()).toContain(wrapper.vm.$tr('reviewersWillSeeLatestFirst'));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Nitpick) I think it'd be better to test these messages with the data-test property, as we may not want the $trs objects being used within the component (see below).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed these to using the functions from the strings file for now in 608d3b9. I decided to check the strings content instead of something like data-test because a single box component is used for all the states, and only its contents (like the strings) are dynamically changed. It is done this way because the box is first shown as loading while the data is not available yet, and then the dynamic content appears inside it.

For this reason, it is not possible to set the data-test attribute directly on the box component in the template. I could dynamically assign the data-test similarly to how I assign the texts, but the tests would then test just whether the data-test attribute is assigned correctly and not whether the correct text is indeed displayed.

Do you think the current solution is still inappropriate after using the functions for the translated strings instead of the $tr method on the component instance? If so, do you have a better approach in mind?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me!

Comment on lines +20 to +29
<div
key="box-message"
class="box-message"
>
{{ infoBoxPrimaryText }}
<span
v-if="infoBoxSecondaryText"
class="info-box-secondary-text"
>
{{ infoBoxSecondaryText }}</span>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding these two text variables like this will end up being in a single paragraph with two sentences separated by a space. But this isn't always correct for i18n, as some languages may have other characters to separate sentences in the same paragraph. The usual suggestion is to have all sentences in a single string, so it can be translated correctly.

So, for this, it'b be better to have just one string in a single span/div node, and set a data-test property for that node so we can assert against that property in the tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, the info-box-secondary-text has display: block applied, so the secondary text will be separated by a line break. This is aligned with the messages appearing on separate lines in the Figma design. I cannot find the SO answer that inspired me to do this, but this one recommends this approach as well.

Do you think this is still an issue if the sentences are not separated by a space?

PS: I was not aware that there were languages with other sentence separators than a space, thanks for the link!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh didn't see the display block. If it is a block element, then we can perhaps change the span node to be a div! Span elements are inline by definition, so perhaps a div or a p elements would communicate better the intention of the separation of these sentences.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to use a div in 6cb77b5.

Comment on lines +4 to +8
export function useCommunityLibrarySubmissions(channelId) {
return useFetch({
asyncFetchFunc: () => CommunityLibrarySubmission.fetchCollection({ channel: channelId }),
});
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this side panel I think we don't need to load all previous submissions, right? As this viewset supports pagination, we can probably get some pagination/ordering params as arguments of this composable and load just the last submission in the side panel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are of course correct, I am not sure why I did not think of that. Changed in 2709c46.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rename this file to useFetch to match the useFetch composable? And since this is a more general composable, we can probably place this in a contentcuration/frontend/channelEdit/composables folder?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 91850d6.

</div>
<template #chip>
<StatusChip
v-if="latestSubmissionStatus && latestSubmissionStatus !== 'none'"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Nitpick) It was a bit difficult for me to fully understand the intention of this "none" magic string, and the reason for it I think was because we are trying to represent too many things with the same variable:

  • The variable is null if the data hasn't been loaded yet.
  • The variable is "none" if the data has loaded and we have confirmed that there isn't any previous submission.
  • In any other case it represents the actual status of the latest submission.

I think a strategy that can declare more verbosely what the intention of the component would be to directly check the variable submissionsAreFinished in the template. So we would have

              <template v-if="submissionsAreFinished && !latestSubmissionStatus">
                 ...
              </template>
             ...
            <template #chip>
              <StatusChip
                v-if="submissionsAreFinished && latestSubmissionStatus"
             ...

and is a bit easier to read that the intention is to show these nodes just once the load of the submissions has finished. Another option would be to create a computed property for checking this. Let me know what you think!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for using "none" in the case where there is no previous submission was to allow using it as a key of an object, as null is not a valid key. But I agree that this is confusing. I will try to structure this better.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I imagined it was because of that. Perhaps using just a computed property to return the needed values are a more readable option without using the "none" magic string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in bf8a914 by using a computed property instead of indexing an object.

Comment on lines +409 to +450
// NOTE: Uses of translated strings inside setup are not picked up by ESLint
$trs: {
panelTitle: 'Submit to Community Library',
// eslint-disable-next-line kolibri/vue-no-unused-translations
submittedPrimaryInfo: 'A previous version is still pending review.',
// eslint-disable-next-line kolibri/vue-no-unused-translations
reviewersWillSeeLatestFirst: 'Reviewers will see the latest submission first.',
// eslint-disable-next-line kolibri/vue-no-unused-translations
approvedPrimaryInfo: 'A previous version is live in the Community Library.',
// eslint-disable-next-line kolibri/vue-no-unused-translations
flaggedPrimaryInfo:
'A previous version was flagged for review. Ensure you have fixed all highlighted issues before submitting.',
// eslint-disable-next-line kolibri/vue-no-unused-translations
nonePrimaryInfo:
'Your published channel will be added to the Community Library review queue.',
moreDetailsButton: 'More details about the Community Library',
lessDetailsButton: 'Show less',
moreDetails:
"The Kolibri Community Library features channels created by the community. Share your openly licensed work for review, and once it's approved, it will be available to users in your selected region or language.",
notPublishedWarning:
"This channel isn't published to Kolibri Studio yet. Publish first, then submit to the Community Library.",
publicWarning:
'This channel is currently public in the Content Library. It is not possible to submit public channels to the Community Library.',
alreadySubmittedWarning:
'This version of the channel has already been submitted to the Community Library. Please wait for review or make changes and publish a new version before submitting again.',
descriptionLabel: "Describe what's new in this submission",
descriptionRequired: 'Description is required',
submitButton: 'Submit for review',
cancelButton: 'Cancel',
// eslint-disable-next-line kolibri/vue-no-unused-translations
submittingSnackbar: 'Submitting channel to Community Library...',
// eslint-disable-next-line kolibri/vue-no-unused-translations
submittedSnackbar: 'Channel submitted to Community Library',
// eslint-disable-next-line kolibri/vue-no-unused-translations
errorSnackbar: 'There was an error submitting the channel',
countryLabel: 'Country',
languagesDetected: 'Language(s) detected',
licensesDetected: 'License(s) detected',
categoriesDetected: 'Categories',
// eslint-disable-next-line kolibri/vue-no-unused-translations
none: 'None',
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// NOTE: Uses of translated strings inside setup are not picked up by ESLint

This is one of the reasons why we now use strings files for strings 😅.

One of the requirements described in the issue is not being met:

All new strings should be created in the communityChannelsStrings file.

This is the new standard that we are using for user-facing strings to reuse these strings across multiple components in the same feature, and allows better organization for the translation process.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I missed the note that the strings file should be used. Changed in 608d3b9.

Comment on lines +226 to +227
if (!submissionsData.value) return undefined;
if (submissionsData.value.length === 0) return null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't seem like we are using the conceptual difference between undefined and null anywhere in the setup method, so this perhaps can be rewritten to

  if (!submissionsData.value?.length) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely not relevant anymore due to changes in 2709c46.

However, I would still like to explain my thought process here, because I use similar reasoning at other places as well. Even though the undefined and null are treated the same way in the code, they convey different semantic meanings: the null value means that there is no latest submission, while undefined means the latest submission is not known yet. I see value in distinguishing these mainly during debugging issues. If I print out latestSubmission.value, I will not be misled by it having a null value, that there is no latest submission when there was an error in retrieving it, or conversely.

Do you think there is a mistake in this thought process, and should I approach these situations differently?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a matter of preferences, and definitely, there isn't a correct answer! We can live with the conceptual difference between undefined and null represented in this computed property, just in case it's needed.

I've flagged it just because we should also consider how future readers of this code will react when they encounter it. e.g., suppose I have no context for this code and read these conditions. In that case, I would think that we are explicitly returning these two different values because that difference will somehow impact the final result in the UI, and would try to look at the setup method or any other child component until I finally discover that the different values does not impact the final result in the UI, these can perhaps take couple of minutes that we would have saved if we would have declare the intention better just returning the same value for both cases!

But... It's just an assumption! There's no need to change it, but it's worth also considering this aspect of "what will future maintainers think when they read my code?"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(A reason why a future maintainer would like to check the references of this property instead of just assuming that they are intentionally set to undefined or null just because this is the correct semantic meaning is that in general the null vs undefined seems to be a general debate, and even standards api's have not been using them consistently, so people may not be fully used to see code that differentiates the actual semantic meaning.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for elaborating, this is a good point. I was not aware that the distinction between null and undefined was not universally accepted. I added comments clarifying the intention behind the distinction in 86bd2fc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @Jakoma02!

Comment on lines +352 to +374
const timer = setTimeout(() => {
CommunityLibrarySubmission.create({
description: description.value,
channel: props.channel.id,
countries: countries.value.map(country => countriesUtil.getAlpha2Code(country, 'en')),
categories: latestPublishedData.value.included_categories,
})
.then(() => {
showSnackbar({ text: $tr('submittedSnackbar') });
})
.catch(() => {
showSnackbar({ text: $tr('errorSnackbar') });
});
}, submitDelayMs);

showSnackbar({
text: $tr('submittingSnackbar'),
duration: null,
actionText: $tr('cancelButton'),
actionCallback: () => {
clearTimeout(timer);
},
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great if we have some comments here regarding the logic behind using a delay before actually sending the submission.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a simple comment in b200765.

Comment on lines +94 to +109
Share
</KButton>
</template>
<VList>
<VListTile @click="showSubmitToCommunityLibrarySidePanel = true">
<VListTileTitle>Submit to community library</VListTileTitle>
</VListTile>
<VListTile
:to="shareChannelLink"
@click="trackClickEvent('Share channel')"
>
<VListTileTitle>Invite collaborators</VListTileTitle>
</VListTile>
<VListTile @click="showTokenModal = true">
<VListTileTitle>Share token</VListTileTitle>
</VListTile>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we have some unwrapped strings here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I missed these. Fixed in 608d3b9.

Copy link
Copy Markdown
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for all your hard work, @Jakoma02! And thank you for all your contributions to Studio! It has been a pleasure working with you on this project over the past few months! LGTM!

@AlexVelezLl AlexVelezLl merged commit efb1de6 into learningequality:unstable Sep 29, 2025
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ESoCC: Create Submit to Community Library Side Panel

3 participants