From a0b69eea9c068bb859fb18ed48eb79ea8cbc4625 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 15 Dec 2025 19:37:56 -0500 Subject: [PATCH 01/13] Move useFilter and useKeywordSearch to shared folder --- .../composables/__tests__/composables.spec.js | 148 ------------------ .../administration/composables/useTable.js | 2 +- .../pages/Channels/ChannelTable.vue | 20 +-- .../administration/pages/Users/UserTable.vue | 10 +- .../composables/__tests__/useFilter.spec.js | 88 +++++++++++ .../__tests__/useKeywordSearch.spec.js | 75 +++++++++ .../composables/useFilter.js | 8 +- .../composables/useKeywordSearch.js | 0 .../composables/useQueryParams.js | 0 9 files changed, 183 insertions(+), 168 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/composables/__tests__/useKeywordSearch.spec.js rename contentcuration/contentcuration/frontend/{administration => shared}/composables/useFilter.js (93%) rename contentcuration/contentcuration/frontend/{administration => shared}/composables/useKeywordSearch.js (100%) rename contentcuration/contentcuration/frontend/{administration => shared}/composables/useQueryParams.js (100%) diff --git a/contentcuration/contentcuration/frontend/administration/composables/__tests__/composables.spec.js b/contentcuration/contentcuration/frontend/administration/composables/__tests__/composables.spec.js index d157463f49..f2a11cd956 100644 --- a/contentcuration/contentcuration/frontend/administration/composables/__tests__/composables.spec.js +++ b/contentcuration/contentcuration/frontend/administration/composables/__tests__/composables.spec.js @@ -1,53 +1,13 @@ /* eslint-disable vue/one-component-per-file */ import { defineComponent, ref } from 'vue'; import { mount } from '@vue/test-utils'; - import VueRouter from 'vue-router'; import { useTable } from '../useTable'; -import { useKeywordSearch } from '../useKeywordSearch'; -import { useFilter } from '../useFilter'; // Because we are testing composables that use the router, // we need to create a dummy component that uses the composable // and test that component with a router instance. -function makeFilterWrapper() { - const router = new VueRouter({ - routes: [], - }); - const component = defineComponent({ - setup() { - const filterMap = ref({}); - return { - ...useFilter({ name: 'testFilter', filterMap }), - // eslint-disable-next-line vue/no-unused-properties - filterMap, - }; - }, - }); - - return mount(component, { - router, - }); -} - -function makeKeywordSearchWrapper() { - const router = new VueRouter({ - routes: [], - }); - const component = defineComponent({ - setup() { - return { - ...useKeywordSearch(), - }; - }, - }); - - return mount(component, { - router, - }); -} - function makeTableWrapper() { const router = new VueRouter({ routes: [], @@ -74,114 +34,6 @@ function makeTableWrapper() { }); } -describe('useFilter', () => { - let wrapper; - beforeEach(() => { - wrapper = makeFilterWrapper(); - wrapper.vm.$router.push({ query: {} }).catch(() => {}); - }); - - it('setting filter sets query params', () => { - wrapper.vm.filterMap = { - a: { label: 'A', params: { a: '1', b: '2' } }, - b: { label: 'B', params: { b: '3', c: '4' } }, - }; - wrapper.vm.$router.push({ query: { testFilter: 'b', otherParam: 'value' } }); - - wrapper.vm.filter = 'a'; - expect(wrapper.vm.$route.query).toEqual({ testFilter: 'a', otherParam: 'value' }); - }); - - describe('filter is determined from query params', () => { - it('when filter params are provided', () => { - wrapper.vm.filterMap = { - a: { label: 'A', params: { a: '1', b: '2' } }, - b: { label: 'B', params: { b: '3', c: '4' } }, - }; - wrapper.vm.$router.push({ query: { testFilter: 'a', otherParam: 'value' } }); - expect(wrapper.vm.filter).toBe('a'); - }); - - it('when filter params are not provided', () => { - wrapper.vm.filterMap = { - a: { label: 'A', params: { a: '1', b: '2' } }, - b: { label: 'B', params: { b: '3', c: '4' } }, - }; - wrapper.vm.$router.push({ query: { otherParam: 'value' } }); - expect(wrapper.vm.filter).toBe(undefined); - }); - }); - - it('setting the filter updates fetch query params', () => { - wrapper.vm.filterMap = { - a: { label: 'A', params: { a: '1', b: '2' } }, - b: { label: 'B', params: { b: '3', c: '4' } }, - }; - wrapper.vm.filter = 'a'; - expect(wrapper.vm.fetchQueryParams).toEqual({ a: '1', b: '2' }); - }); - - it('filters are correctly computed from filterMap', () => { - wrapper.vm.filterMap = { - a: { label: 'A', params: { a: '1', b: '2' } }, - b: { label: 'B', params: { b: '3', c: '4' } }, - }; - expect(wrapper.vm.filters).toEqual([ - { key: 'a', label: 'A' }, - { key: 'b', label: 'B' }, - ]); - }); -}); - -describe('useKeywordSearch', () => { - let wrapper; - beforeEach(() => { - wrapper = makeKeywordSearchWrapper(); - wrapper.vm.$router.push({ query: {} }).catch(() => {}); - }); - - it('setting keywords sets query params', () => { - wrapper.vm.$router.push({ query: { a: '1', page: '2' } }); - wrapper.vm.keywordInput = 'test'; - - jest.useFakeTimers(); - wrapper.vm.setKeywords(); - jest.runAllTimers(); - jest.useRealTimers(); - - expect(wrapper.vm.$route.query).toEqual({ a: '1', keywords: 'test', page: '2' }); - }); - - it('setting query params sets keywords', async () => { - wrapper.vm.$router.push({ query: { keywords: 'test' } }); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.keywordInput).toBe('test'); - }); - - it('calling clearSearch clears keywords and query param', async () => { - wrapper.vm.$router.push({ query: { keywords: 'test', a: '1', page: '2' } }); - await wrapper.vm.$nextTick(); - - wrapper.vm.clearSearch(); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.keywordInput).toBe(''); - expect(wrapper.vm.$route.query).toEqual({ a: '1', page: '2' }); - }); - - it('setting keywords updates fetch query params', () => { - wrapper.vm.keywordInput = 'test'; - - jest.useFakeTimers(); - wrapper.vm.setKeywords(); - jest.runAllTimers(); - jest.useRealTimers(); - - expect(wrapper.vm.fetchQueryParams).toEqual({ keywords: 'test' }); - }); -}); - describe('useTable', () => { let wrapper; beforeEach(() => { diff --git a/contentcuration/contentcuration/frontend/administration/composables/useTable.js b/contentcuration/contentcuration/frontend/administration/composables/useTable.js index 8e293f2de8..5c31533e6b 100644 --- a/contentcuration/contentcuration/frontend/administration/composables/useTable.js +++ b/contentcuration/contentcuration/frontend/administration/composables/useTable.js @@ -1,7 +1,7 @@ import { pickBy, isEqual } from 'lodash'; import { ref, computed, unref, watch, nextTick } from 'vue'; import { useRoute } from 'vue-router/composables'; -import { useQueryParams } from './useQueryParams'; +import { useQueryParams } from 'shared/composables/useQueryParams'; /** * @typedef {Object} Pagination diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index 6dc5300cb3..2ab83748d7 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -16,7 +16,7 @@ > { - const filters = channelStatusFilters.value; - channelStatusFilter.value = filters.length ? filters[0].key : null; + const options = channelStatusOptions.value; + channelStatusFilter.value = options.length ? options[0].key : null; }); const filterFetchQueryParams = computed(() => { @@ -278,9 +278,9 @@ return { channelTypeFilter, - channelTypeFilters, + channelTypeOptions, channelStatusFilter, - channelStatusFilters, + channelStatusOptions, languageFilter, languageDropdown, keywordInput, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue index b35bf282b4..a88be6f1c6 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue @@ -31,7 +31,7 @@ > { + let wrapper; + beforeEach(() => { + wrapper = makeFilterWrapper(); + wrapper.vm.$router.push({ query: {} }).catch(() => {}); + }); + + it('setting filter sets query params', () => { + wrapper.vm.filterMap = { + a: { label: 'A', params: { a: '1', b: '2' } }, + b: { label: 'B', params: { b: '3', c: '4' } }, + }; + wrapper.vm.$router.push({ query: { testFilter: 'b', otherParam: 'value' } }); + + wrapper.vm.filter = 'a'; + expect(wrapper.vm.$route.query).toEqual({ testFilter: 'a', otherParam: 'value' }); + }); + + describe('filter is determined from query params', () => { + it('when filter params are provided', () => { + wrapper.vm.filterMap = { + a: { label: 'A', params: { a: '1', b: '2' } }, + b: { label: 'B', params: { b: '3', c: '4' } }, + }; + wrapper.vm.$router.push({ query: { testFilter: 'a', otherParam: 'value' } }); + expect(wrapper.vm.filter).toBe('a'); + }); + + it('when filter params are not provided', () => { + wrapper.vm.filterMap = { + a: { label: 'A', params: { a: '1', b: '2' } }, + b: { label: 'B', params: { b: '3', c: '4' } }, + }; + wrapper.vm.$router.push({ query: { otherParam: 'value' } }); + expect(wrapper.vm.filter).toBe(undefined); + }); + }); + + it('setting the filter updates fetch query params', () => { + wrapper.vm.filterMap = { + a: { label: 'A', params: { a: '1', b: '2' } }, + b: { label: 'B', params: { b: '3', c: '4' } }, + }; + wrapper.vm.filter = 'a'; + expect(wrapper.vm.fetchQueryParams).toEqual({ a: '1', b: '2' }); + }); + + it('options are correctly computed from filterMap', () => { + wrapper.vm.filterMap = { + a: { label: 'A', params: { a: '1', b: '2' } }, + b: { label: 'B', params: { b: '3', c: '4' } }, + }; + expect(wrapper.vm.options).toEqual([ + { key: 'a', label: 'A' }, + { key: 'b', label: 'B' }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/composables/__tests__/useKeywordSearch.spec.js b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useKeywordSearch.spec.js new file mode 100644 index 0000000000..c568a27956 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useKeywordSearch.spec.js @@ -0,0 +1,75 @@ +/* eslint-disable vue/one-component-per-file */ +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import { useKeywordSearch } from '../useKeywordSearch'; + +// Because we are testing composables that use the router, +// we need to create a dummy component that uses the composable +// and test that component with a router instance. + +function makeKeywordSearchWrapper() { + const router = new VueRouter({ + routes: [], + }); + const component = defineComponent({ + setup() { + return { + ...useKeywordSearch(), + }; + }, + }); + + return mount(component, { + router, + }); +} + +describe('useKeywordSearch', () => { + let wrapper; + beforeEach(() => { + wrapper = makeKeywordSearchWrapper(); + wrapper.vm.$router.push({ query: {} }).catch(() => {}); + }); + + it('setting keywords sets query params', () => { + wrapper.vm.$router.push({ query: { a: '1', page: '2' } }); + wrapper.vm.keywordInput = 'test'; + + jest.useFakeTimers(); + wrapper.vm.setKeywords(); + jest.runAllTimers(); + jest.useRealTimers(); + + expect(wrapper.vm.$route.query).toEqual({ a: '1', keywords: 'test', page: '2' }); + }); + + it('setting query params sets keywords', async () => { + wrapper.vm.$router.push({ query: { keywords: 'test' } }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.keywordInput).toBe('test'); + }); + + it('calling clearSearch clears keywords and query param', async () => { + wrapper.vm.$router.push({ query: { keywords: 'test', a: '1', page: '2' } }); + await wrapper.vm.$nextTick(); + + wrapper.vm.clearSearch(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.keywordInput).toBe(''); + expect(wrapper.vm.$route.query).toEqual({ a: '1', page: '2' }); + }); + + it('setting keywords updates fetch query params', () => { + wrapper.vm.keywordInput = 'test'; + + jest.useFakeTimers(); + wrapper.vm.setKeywords(); + jest.runAllTimers(); + jest.useRealTimers(); + + expect(wrapper.vm.fetchQueryParams).toEqual({ keywords: 'test' }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/administration/composables/useFilter.js b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js similarity index 93% rename from contentcuration/contentcuration/frontend/administration/composables/useFilter.js rename to contentcuration/contentcuration/frontend/shared/composables/useFilter.js index 2a9ea67e4e..2a838aec12 100644 --- a/contentcuration/contentcuration/frontend/administration/composables/useFilter.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js @@ -7,7 +7,7 @@ import { useQueryParams } from './useQueryParams'; * @property {import('vue').ComputedRef} filter Reactive settable filter value. * @property {import('vue').ComputedRef< * Array<{key: string, label: string}> - * >} filters List of available filters. + * >} options List of available options. * @property {import('vue').ComputedRef} fetchQueryParams * Reactive fetch query parameters based on the selected filter. */ @@ -48,9 +48,9 @@ export function useFilter({ name, filterMap }) { }, }); - const filters = computed(() => { + const options = computed(() => { return Object.entries(unref(filterMap)).map(([key, value]) => { - return { key, label: value.label }; + return { key, value: key, label: value.label }; }); }); @@ -60,7 +60,7 @@ export function useFilter({ name, filterMap }) { return { filter, - filters, + options, fetchQueryParams, }; } diff --git a/contentcuration/contentcuration/frontend/administration/composables/useKeywordSearch.js b/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js similarity index 100% rename from contentcuration/contentcuration/frontend/administration/composables/useKeywordSearch.js rename to contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js diff --git a/contentcuration/contentcuration/frontend/administration/composables/useQueryParams.js b/contentcuration/contentcuration/frontend/shared/composables/useQueryParams.js similarity index 100% rename from contentcuration/contentcuration/frontend/administration/composables/useQueryParams.js rename to contentcuration/contentcuration/frontend/shared/composables/useQueryParams.js From 25bb05039cc7a688b5be12c130676c132c2dc702 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 15 Dec 2025 20:21:51 -0500 Subject: [PATCH 02/13] Migrate useFilter to be compatible with KSelect --- .../pages/Channels/ChannelTable.vue | 34 +++++++++++++++++-- .../administration/pages/Users/UserTable.vue | 24 +++++++++++-- .../composables/__tests__/useFilter.spec.js | 12 +++---- .../frontend/shared/composables/useFilter.js | 19 ++++++++--- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index 2ab83748d7..dae3d422e9 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -224,27 +224,55 @@ }); const { - filter: channelTypeFilter, + filter: _channelTypeFilter, options: channelTypeOptions, fetchQueryParams: channelTypeFetchQueryParams, } = useFilter({ name: 'channelType', filterMap: channelTypeFilterMap, }); + // Temporal wrapper, must be removed after migrating to KSelect + const channelTypeFilter = computed({ + get: () => _channelTypeFilter.value.value || undefined, + set: value => { + _channelTypeFilter.value = + channelTypeOptions.value.find(option => option.value === value) || {}; + }, + }); const { - filter: channelStatusFilter, + filter: _channelStatusFilter, options: channelStatusOptions, fetchQueryParams: channelStatusFetchQueryParams, } = useFilter({ name: 'channelStatus', filterMap: statusFilterMap, }); + // Temporal wrapper, must be removed after migrating to KSelect + const channelStatusFilter = computed({ + get: () => _channelStatusFilter.value.value || undefined, + set: value => { + _channelStatusFilter.value = + channelStatusOptions.value.find(option => option.value === value) || {}; + }, + }); - const { filter: languageFilter, fetchQueryParams: languageFetchQueryParams } = useFilter({ + const { + filter: _languageFilter, + options: languageOptions, + fetchQueryParams: languageFetchQueryParams, + } = useFilter({ name: 'language', filterMap: languageFilterMap, }); + // Temporal wrapper, must be removed after migrating to KSelect + const languageFilter = computed({ + get: () => _languageFilter.value.value || undefined, + set: value => { + _languageFilter.value = + languageOptions.value.find(option => option.value === value) || {}; + }, + }); const { keywordInput, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue index a88be6f1c6..f7fdf07722 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue @@ -174,13 +174,21 @@ const store = proxy.$store; const { - filter: userTypeFilter, + filter: _userTypeFilter, options: userTypeOptions, fetchQueryParams: userTypeFetchQueryParams, } = useFilter({ name: 'userType', filterMap: userTypeFilterMap, }); + // Temporal wrapper, must be removed after migrating to KSelect + const userTypeFilter = computed({ + get: () => _userTypeFilter.value.value || undefined, + set: value => { + _userTypeFilter.value = + userTypeOptions.value.find(option => option.value === value) || {}; + }, + }); const { keywordInput, @@ -192,10 +200,22 @@ const locationFilterMap = ref({}); const locationDropdown = ref(null); - const { filter: locationFilter, fetchQueryParams: locationFetchQueryParams } = useFilter({ + const { + filter: _locationFilter, + options: locationOptions, + fetchQueryParams: locationFetchQueryParams, + } = useFilter({ name: 'location', filterMap: locationFilterMap, }); + // Temporal wrapper, must be removed after migrating to KSelect + const locationFilter = computed({ + get: () => _locationFilter.value.value || undefined, + set: value => { + _locationFilter.value = + locationOptions.value.find(option => option.value === value) || {}; + }, + }); onMounted(() => { // The locationFilterMap is built from the options in the CountryField component, diff --git a/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js index 5e3362c819..22a80617b6 100644 --- a/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js +++ b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js @@ -42,7 +42,7 @@ describe('useFilter', () => { }; wrapper.vm.$router.push({ query: { testFilter: 'b', otherParam: 'value' } }); - wrapper.vm.filter = 'a'; + wrapper.vm.filter = { key: 'a', value: 'a', label: 'A' }; expect(wrapper.vm.$route.query).toEqual({ testFilter: 'a', otherParam: 'value' }); }); @@ -53,7 +53,7 @@ describe('useFilter', () => { b: { label: 'B', params: { b: '3', c: '4' } }, }; wrapper.vm.$router.push({ query: { testFilter: 'a', otherParam: 'value' } }); - expect(wrapper.vm.filter).toBe('a'); + expect(wrapper.vm.filter.value).toBe('a'); }); it('when filter params are not provided', () => { @@ -62,7 +62,7 @@ describe('useFilter', () => { b: { label: 'B', params: { b: '3', c: '4' } }, }; wrapper.vm.$router.push({ query: { otherParam: 'value' } }); - expect(wrapper.vm.filter).toBe(undefined); + expect(wrapper.vm.filter.value).toBe(undefined); }); }); @@ -71,7 +71,7 @@ describe('useFilter', () => { a: { label: 'A', params: { a: '1', b: '2' } }, b: { label: 'B', params: { b: '3', c: '4' } }, }; - wrapper.vm.filter = 'a'; + wrapper.vm.filter = { key: 'a', value: 'a', label: 'A' }; expect(wrapper.vm.fetchQueryParams).toEqual({ a: '1', b: '2' }); }); @@ -81,8 +81,8 @@ describe('useFilter', () => { b: { label: 'B', params: { b: '3', c: '4' } }, }; expect(wrapper.vm.options).toEqual([ - { key: 'a', label: 'A' }, - { key: 'b', label: 'B' }, + { key: 'a', value: 'a', label: 'A' }, + { key: 'b', value: 'b', label: 'B' }, ]); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/composables/useFilter.js b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js index 2a838aec12..fbc07698c9 100644 --- a/contentcuration/contentcuration/frontend/shared/composables/useFilter.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js @@ -4,9 +4,11 @@ import { useQueryParams } from './useQueryParams'; /** * @typedef {Object} UseFilterReturn - * @property {import('vue').ComputedRef} filter Reactive settable filter value. + * @property {import('vue').ComputedRef<{ + * key: string, value: string, label: string + * }>} filter Reactive settable filter value. * @property {import('vue').ComputedRef< - * Array<{key: string, label: string}> + * Array<{key: string, value: string, label: string}> * >} options List of available options. * @property {import('vue').ComputedRef} fetchQueryParams * Reactive fetch query parameters based on the selected filter. @@ -39,23 +41,30 @@ export function useFilter({ name, filterMap }) { const { updateQueryParams } = useQueryParams(); const filter = computed({ - get: () => route.query[name] || undefined, + get: () => { + const routeFilter = route.query[name]; + const filterOption = options.value.find(option => option.value === routeFilter); + return filterOption || {}; + }, set: value => { updateQueryParams({ ...route.query, - [name]: value || undefined, + // `value` is a KSelect option object with `key` and `value` properties + // so we use `value.value` to get the actual filter value + [name]: value.value || undefined, }); }, }); const options = computed(() => { return Object.entries(unref(filterMap)).map(([key, value]) => { + // Adding `key` and `value` properties for compatibility with KSelect and VSelect return { key, value: key, label: value.label }; }); }); const fetchQueryParams = computed(() => { - return unref(filterMap)[filter.value]?.params || {}; + return unref(filterMap)[filter.value.value]?.params || {}; }); return { From 50de7af11e4c8b9faf0563d66c966503c37c5d23 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 15 Dec 2025 22:13:26 -0500 Subject: [PATCH 03/13] Add initial NotificationsModal implementation + NotificationsFilters --- .../frontend/shared/strings/commonStrings.js | 4 + .../strings/communityChannelsStrings.js | 42 ++++ .../frontend/shared/views/AppBar.vue | 4 + .../NotificationFilters.vue | 186 ++++++++++++++++++ .../shared/views/NotificationsModal/index.vue | 129 ++++++++++++ 5 files changed, 365 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 7b3137c2e2..0f5121df32 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -6,6 +6,10 @@ export const commonStrings = createTranslator('CommonStrings', { context: 'Indicates going back to a previous step in multi-step workflows. It can be used as a label of the back button that is displayed next to the continue button.', }, + clearAction: { + message: 'Clear', + context: 'A label for an action that clears a selection or input field', + }, closeAction: { message: 'Close', context: 'A label for an action that closes a dialog or window', diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index c116a76a82..9b99296666 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -303,4 +303,46 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: '{currentPage} of {totalPages}', context: 'Page indicator showing current page and total pages (e.g., "1 of 5")', }, + + // Notifications modal strings + notificationsLabel: { + message: 'Notifications', + context: 'Label for the notifications modal', + }, + unreadNotificationsLabel: { + message: 'Unread', + context: 'Label for the unread notifications tab in the notifications modal', + }, + allNotificationsLabel: { + message: 'All Notifications', + context: 'Label for the all notifications tab in the notifications modal', + }, + searchNotificationsLabel: { + message: 'Search notifications', + context: 'Placeholder text for the search notifications input field', + }, + filterByDateLabel: { + message: 'Filter by date', + context: 'Label for the filter by date dropdown in the notifications modal', + }, + todayLabel: { + message: 'Today', + context: 'Option label for filtering notifications from today', + }, + thisWeekLabel: { + message: 'This week', + context: 'Option label for filtering notifications from this week', + }, + thisMonthLabel: { + message: 'This month', + context: 'Option label for filtering notifications from this month', + }, + thisYearLabel: { + message: 'This year', + context: 'Option label for filtering notifications from this year', + }, + filterByStatusLabel: { + message: 'Filter by status', + context: 'Label for the filter by status dropdown in the notifications modal', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index fc29cefd07..02139ff8f3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -155,6 +155,8 @@ :style="{ color: $themeTokens.text }" @cancel="showLanguageModal = false" /> + + @@ -166,6 +168,7 @@ import Tabs from 'shared/views/Tabs'; import MainNavigationDrawer from 'shared/views/MainNavigationDrawer'; import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; + import NotificationsModal from 'shared/views/NotificationsModal'; export default { name: 'AppBar', @@ -173,6 +176,7 @@ LanguageSwitcherModal, MainNavigationDrawer, Tabs, + NotificationsModal, }, props: { title: { diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue new file mode 100644 index 0000000000..1eed365802 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue @@ -0,0 +1,186 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue new file mode 100644 index 0000000000..1ac5cd98b0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -0,0 +1,129 @@ + + + + + + + From 84fa8c872658a8c4d28c9183c8a936f662761687 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 07:30:28 -0500 Subject: [PATCH 04/13] Add useCommunityLibraryUpdates to load and process community library updates --- .../useLatestCommunityLibrarySubmission.js | 2 +- .../composables/usePublishedData.js | 2 +- .../composables/useFetch.js | 2 +- .../frontend/shared/constants.js | 6 + .../useCommunityLibraryUpdates.spec.js | 412 ++++++++++++++++++ .../composables/useCommunityLibraryUpdates.js | 149 +++++++ 6 files changed, 570 insertions(+), 3 deletions(-) rename contentcuration/contentcuration/frontend/{channelEdit => shared}/composables/useFetch.js (96%) create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js index ee65710b7f..e530e18fc7 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js @@ -1,4 +1,4 @@ -import { useFetch } from '../../../../composables/useFetch'; +import { useFetch } from 'shared/composables/useFetch'; import { CommunityLibrarySubmission } from 'shared/data/resources'; export function useLatestCommunityLibrarySubmission(channelId) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js index 3b35b3b7dc..60cddbb21b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js @@ -1,4 +1,4 @@ -import { useFetch } from '../../../../composables/useFetch'; +import { useFetch } from 'shared/composables/useFetch'; import { Channel } from 'shared/data/resources'; export function usePublishedData(channelId) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js b/contentcuration/contentcuration/frontend/shared/composables/useFetch.js similarity index 96% rename from contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js rename to contentcuration/contentcuration/frontend/shared/composables/useFetch.js index 7fef1922c8..08aec26223 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useFetch.js @@ -9,7 +9,6 @@ export function useFetch({ asyncFetchFunc }) { async function fetchData() { isLoading.value = true; isFinished.value = false; - data.value = null; error.value = null; try { data.value = await asyncFetchFunc(); @@ -17,6 +16,7 @@ export function useFetch({ asyncFetchFunc }) { isFinished.value = true; } catch (error) { error.value = error; + data.value = null; throw error; } finally { isLoading.value = false; diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 431c12838a..33d44354cf 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -336,3 +336,9 @@ export const CommunityLibraryStatus = { SUPERSEDED: 'SUPERSEDED', LIVE: 'LIVE', }; + +export const NotificationType = { + COMMUNITY_LIBRARY_SUBMISSION_CREATED: 'COMMUNITY_LIBRARY_SUBMISSION_CREATED', + COMMUNITY_LIBRARY_SUBMISSION_APPROVED: 'COMMUNITY_LIBRARY_SUBMISSION_APPROVED', + COMMUNITY_LIBRARY_SUBMISSION_REJECTED: 'COMMUNITY_LIBRARY_SUBMISSION_REJECTED', +}; diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js new file mode 100644 index 0000000000..0b4d64e6e3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js @@ -0,0 +1,412 @@ +import { ref } from 'vue'; +import useCommunityLibraryUpdates from '../useCommunityLibraryUpdates'; +import { CommunityLibraryStatus, NotificationType } from 'shared/constants'; + +import { CommunityLibrarySubmission } from 'shared/data/resources'; + +jest.mock('shared/data/resources', () => ({ + CommunityLibrarySubmission: { + fetchCollection: jest.fn(), + }, +})); + +describe('useCommunityLibraryUpdates', () => { + let mockFetchCollection; + + beforeEach(() => { + jest.clearAllMocks(); + + mockFetchCollection = CommunityLibrarySubmission.fetchCollection; + }); + + const createMockSubmission = (overrides = {}) => ({ + id: 1, + description: 'Test submission', + channel_id: 'channel-1', + channel_name: 'Test Channel', + channel_version: 1, + author_id: 'user-1', + author_name: 'John Doe', + categories: ['math'], + date_created: '2026-01-01T00:00:00Z', + status: CommunityLibraryStatus.PENDING, + resolution_reason: null, + feedback_notes: null, + date_updated: '2026-01-02T00:00:00Z', + resolved_by_id: null, + resolved_by_name: null, + countries: ['US'], + ...overrides, + }); + + describe('initialization', () => { + it('should return expected properties', () => { + const result = useCommunityLibraryUpdates(); + + expect(result).toHaveProperty('hasMore'); + expect(result).toHaveProperty('submissionsUpdates'); + expect(result).toHaveProperty('isLoading'); + expect(result).toHaveProperty('isLoadingMore'); + expect(result).toHaveProperty('fetchData'); + expect(result).toHaveProperty('fetchMore'); + }); + + it('should initialize with correct default values', () => { + const { hasMore, submissionsUpdates, isLoading, isLoadingMore } = + useCommunityLibraryUpdates(); + + expect(hasMore.value).toBe(false); + expect(submissionsUpdates.value).toEqual([]); + expect(isLoading.value).toBe(false); + expect(isLoadingMore.value).toBe(false); + }); + }); + + describe('getSubmissionsUpdates transformation', () => { + it('should create a creation update for PENDING submissions', async () => { + const CREATION_DATE = '2026-01-01T00:00:00Z'; + + const mockSubmissions = [ + createMockSubmission({ + status: CommunityLibraryStatus.PENDING, + date_created: CREATION_DATE, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates(); + + await fetchData(); + + expect(submissionsUpdates.value).toHaveLength(1); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + ); + expect(submissionsUpdates.value[0].date).toEqual(new Date(CREATION_DATE)); + }); + + it('should create two updates for APPROVED submissions', async () => { + const CREATION_DATE = '2026-01-01T00:00:00Z'; + const UPDATE_DATE = '2026-01-03T00:00:00Z'; + + const mockSubmissions = [ + createMockSubmission({ + status: CommunityLibraryStatus.APPROVED, + date_updated: UPDATE_DATE, + date_created: CREATION_DATE, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates(); + + await fetchData(); + + expect(submissionsUpdates.value).toHaveLength(2); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, + ); + expect(submissionsUpdates.value[0].date).toEqual(new Date(UPDATE_DATE)); + expect(submissionsUpdates.value[1].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + ); + expect(submissionsUpdates.value[1].date).toEqual(new Date(CREATION_DATE)); + }); + + it('should create two updates for REJECTED submissions', async () => { + const CREATION_DATE = '2026-01-01T00:00:00Z'; + const UPDATE_DATE = '2026-01-03T00:00:00Z'; + + const mockSubmissions = [ + createMockSubmission({ + status: CommunityLibraryStatus.REJECTED, + date_created: CREATION_DATE, + date_updated: UPDATE_DATE, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates(); + + await fetchData(); + + expect(submissionsUpdates.value).toHaveLength(2); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_REJECTED, + ); + expect(submissionsUpdates.value[0].date).toEqual(new Date(UPDATE_DATE)); + expect(submissionsUpdates.value[1].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + ); + expect(submissionsUpdates.value[1].date).toEqual(new Date(CREATION_DATE)); + }); + + it('should handle LIVE status correctly', async () => { + const CREATION_DATE = '2026-01-01T00:00:00Z'; + const UPDATE_DATE = '2026-01-03T00:00:00Z'; + + const mockSubmissions = [ + createMockSubmission({ + status: CommunityLibraryStatus.LIVE, + date_created: CREATION_DATE, + date_updated: UPDATE_DATE, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates(); + + await fetchData(); + + expect(submissionsUpdates.value).toHaveLength(2); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, + ); + expect(submissionsUpdates.value[0].date).toEqual(new Date(UPDATE_DATE)); + expect(submissionsUpdates.value[1].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + ); + expect(submissionsUpdates.value[1].date).toEqual(new Date(CREATION_DATE)); + }); + }); + + describe('date filtering', () => { + it('should filter by date_updated__lte', async () => { + const LTE_DATE = '2026-01-02T12:00:00Z'; + const CREATION_DATE = '2026-01-01T00:00:00Z'; + const UPDATE_DATE_BEFORE = '2026-01-02T00:00:00Z'; + const UPDATE_DATE_AFTER = '2026-01-03T00:00:00Z'; + + const queryParams = ref({ + date_updated__lte: LTE_DATE, + }); + + const mockSubmissions = [ + createMockSubmission({ + id: 1, + date_created: CREATION_DATE, + date_updated: UPDATE_DATE_BEFORE, + status: CommunityLibraryStatus.APPROVED, + }), + createMockSubmission({ + id: 2, + date_created: CREATION_DATE, + date_updated: UPDATE_DATE_AFTER, + status: CommunityLibraryStatus.APPROVED, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + // Should include: 2 creation updates + 1 approval update (the other approval is after lte) + expect(submissionsUpdates.value).toHaveLength(3); + expect(submissionsUpdates.value.every(update => update.date <= new Date(LTE_DATE))).toBe( + true, + ); + }); + + it('should filter by date_updated__gte', async () => { + const GTE_DATE = '2026-01-02T00:00:00Z'; + const CREATION_DATE = '2025-12-31T00:00:00Z'; + const UPDATE_DATE = '2026-01-02T00:00:00Z'; + + const queryParams = ref({ + date_updated__gte: GTE_DATE, + }); + + const mockSubmissions = [ + createMockSubmission({ + id: 1, + date_created: CREATION_DATE, + date_updated: UPDATE_DATE, + status: CommunityLibraryStatus.APPROVED, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + // Should include only 1 approval update (creation update is before gte) + expect(submissionsUpdates.value).toHaveLength(1); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, + ); + }); + }); + + describe('status filtering', () => { + it('should filter by status__in', async () => { + const queryParams = ref({ + status__in: `${CommunityLibraryStatus.APPROVED},${CommunityLibraryStatus.REJECTED}`, + }); + + const mockSubmissions = [ + createMockSubmission({ + id: 1, + status: CommunityLibraryStatus.APPROVED, + }), + createMockSubmission({ + id: 2, + status: CommunityLibraryStatus.PENDING, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + // Should include only the approval update (not creation updates or pending updates) + expect(submissionsUpdates.value).toHaveLength(1); + expect(submissionsUpdates.value[0].type).toBe( + NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, + ); + }); + }); + + describe('query parameters', () => { + it('should pass search keywords as search parameter', async () => { + const queryParams = ref({ + keywords: 'test search', + }); + + mockFetchCollection.mockResolvedValue({ + results: [], + more: null, + }); + + const { fetchData } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + expect(mockFetchCollection).toHaveBeenCalledWith({ + search: 'test search', + max_results: 10, + }); + }); + + it('should include date and status filters in API call', async () => { + const LTE_DATE = '2026-01-02T00:00:00Z'; + const GTE_DATE = '2026-01-01T00:00:00Z'; + + const queryParams = ref({ + date_updated__lte: LTE_DATE, + date_updated__gte: GTE_DATE, + status__in: CommunityLibraryStatus.APPROVED, + }); + + mockFetchCollection.mockResolvedValue({ + results: [], + more: null, + }); + + const { fetchData } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + expect(mockFetchCollection).toHaveBeenCalledWith({ + date_updated__lte: LTE_DATE, + date_updated__gte: GTE_DATE, + status__in: CommunityLibraryStatus.APPROVED, + max_results: 10, + }); + }); + + it('should omit undefined query parameters', async () => { + const queryParams = ref({ + keywords: undefined, + date_updated__lte: null, + }); + + mockFetchCollection.mockResolvedValue({ + results: [], + more: null, + }); + + const { fetchData } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + expect(mockFetchCollection).toHaveBeenCalledWith({ + max_results: 10, + }); + }); + }); + + describe('sorting', () => { + it('should sort updates by date in descending order', async () => { + const CREATION_DATE_1 = '2026-01-01T00:00:00Z'; + const CREATION_DATE_2 = '2026-01-02T00:00:00Z'; + const UPDATE_DATE_1 = '2026-01-03T00:00:00Z'; + const UPDATE_DATE_2 = '2026-01-04T00:00:00Z'; + + const mockSubmissions = [ + createMockSubmission({ + id: 1, + date_created: CREATION_DATE_1, + date_updated: UPDATE_DATE_1, + status: CommunityLibraryStatus.APPROVED, + }), + createMockSubmission({ + id: 2, + date_created: CREATION_DATE_2, + date_updated: UPDATE_DATE_2, + status: CommunityLibraryStatus.REJECTED, + }), + ]; + + mockFetchCollection.mockResolvedValue({ + results: mockSubmissions, + more: null, + }); + + const { fetchData, submissionsUpdates } = useCommunityLibraryUpdates(); + + await fetchData(); + + expect(submissionsUpdates.value).toHaveLength(4); + + const dates = submissionsUpdates.value.map(update => update.date.getTime()); + expect(dates).toEqual([...dates].sort((a, b) => b - a)); + + // Verify the specific order + expect(submissionsUpdates.value[0].date).toEqual(new Date(UPDATE_DATE_2)); + expect(submissionsUpdates.value[1].date).toEqual(new Date(UPDATE_DATE_1)); + expect(submissionsUpdates.value[2].date).toEqual(new Date(CREATION_DATE_2)); + expect(submissionsUpdates.value[3].date).toEqual(new Date(CREATION_DATE_1)); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js new file mode 100644 index 0000000000..1c63ccf588 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js @@ -0,0 +1,149 @@ +import { ref, computed, unref } from 'vue'; +import pickBy from 'lodash/pickBy'; +import { useFetch } from 'shared/composables/useFetch'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; +import { CommunityLibraryStatus, NotificationType } from 'shared/constants'; + +const MAX_RESULTS_PER_PAGE = 10; + +const statusToNotificationType = { + [CommunityLibraryStatus.PENDING]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + [CommunityLibraryStatus.SUPERSEDED]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + [CommunityLibraryStatus.REJECTED]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_REJECTED, + [CommunityLibraryStatus.APPROVED]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, + [CommunityLibraryStatus.LIVE]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, +}; + +export default function useCommunityLibraryUpdates({ queryParams } = {}) { + const moreObject = ref(null); + const isLoadingMore = ref(false); + + /** + * Community Library Submissions are objects that may represent two types of updates: + * 1. Creation of a new submission (status: PENDING or SUPERSEDED) + * 2. Status update (status: REJECTED, APPROVED, LIVE) + * If the submission is in a status other than PENDING or SUPERSEDED, it means an update has + * happened apart from creation, so we need to create two updates for that submission. + * + * Additionally, since backend filters only filter by `date_updated`, and this corresponds to + * the last update (not creation), we need to re-apply the date filters and sorting after + * transforming the submissions into updates. + * + * @param {*} submissions Array of submissions returned from the backend. + */ + const getSubmissionsUpdates = (submissions = []) => { + let updates = []; + for (const submission of submissions) { + submission.date_created = new Date(submission.date_created); + submission.date_updated = submission.date_updated && new Date(submission.date_updated); + + // Always add creation update + updates.push({ + ...submission, + type: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, + date: submission.date_created, + }); + + // If the status is not PENDING or SUPERSEDED, it means there is also a status update + if ( + ![CommunityLibraryStatus.PENDING, CommunityLibraryStatus.SUPERSEDED].includes( + submission.status, + ) + ) { + updates.push({ + ...submission, + type: statusToNotificationType[submission.status], + date: submission.date_updated, + }); + } + } + + const params = unref(queryParams); + // Apply date filters again, since creation updates may not be filtered yet + updates = updates.filter(update => { + if (params?.date_updated__lte) { + const lteDate = new Date(params.date_updated__lte); + if (update.date > lteDate) { + return false; + } + } + if (params?.date_updated__gte) { + const gteDate = new Date(params.date_updated__gte); + if (update.date < gteDate) { + return false; + } + } + if (params?.status__in) { + const statusList = params.status__in.split(','); + const notificationTypes = statusList.map(status => statusToNotificationType[status]); + if (!notificationTypes.includes(update.type)) { + return false; + } + } + return true; + }); + + // Since we are combining multiple updates per submission, we need to sort them again + updates.sort((a, b) => new Date(b.date) - new Date(a.date)); + + return updates; + }; + + const fetchSubmissionsUpdates = async () => { + let params; + isLoadingMore.value = Boolean(moreObject.value); + if (isLoadingMore.value) { + params = moreObject.value; + } else { + const _params = unref(queryParams); + params = pickBy({ + date_updated__lte: _params?.date_updated__lte, + date_updated__gte: _params?.date_updated__gte, + status__in: _params?.status__in, + search: _params?.keywords, + max_results: MAX_RESULTS_PER_PAGE, + }); + } + const response = await CommunityLibrarySubmission.fetchCollection(params); + + // Transforming submissions into updates before concatenation for + // performance reasons + response.results = getSubmissionsUpdates(response.results); + if (isLoadingMore.value) { + response.results = [...submissionsUpdates.value, ...response.results]; + } + moreObject.value = response.more; + isLoadingMore.value = false; + return response.results; + }; + + const { + isLoading: _isLoading, + data: _submissionsUpdates, + fetchData: _fetchData, + } = useFetch({ + asyncFetchFunc: fetchSubmissionsUpdates, + }); + const submissionsUpdates = computed(() => _submissionsUpdates.value || []); + + // Differentiate between initial loading and loading more + const isLoading = computed(() => !isLoadingMore.value && _isLoading.value); + const hasMore = computed(() => Boolean(moreObject.value)); + + const fetchData = () => { + // Reset the moreObject when doing a fresh fetch + moreObject.value = null; + return _fetchData(); + }; + // Fetch more does not reset the moreObject + const fetchMore = () => _fetchData(); + + return { + hasMore, + submissionsUpdates, + isLoading, + isLoadingMore, + fetchData, + fetchMore, + }; +} From a443416b30104e3f7fe447c55fb71ab411c36a98 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 07:31:16 -0500 Subject: [PATCH 05/13] Add base structure of NotificationsList --- .../SubmitToCommunityLibrarySidePanel.spec.js | 2 +- .../index.vue | 2 +- .../strings/communityChannelsStrings.js | 21 ++++++ .../NotificationFilters.vue | 18 ++++- .../NotificationsModal/NotificationList.vue | 64 +++++++++++++++++ .../shared/views/NotificationsModal/index.vue | 32 ++++++++- .../CommunityLibrarySubmissionApproval.vue | 45 ++++++++++++ .../CommunityLibrarySubmissionCreation.vue | 42 +++++++++++ .../CommunityLibrarySubmissionRejection.vue | 45 ++++++++++++ .../notificationTypes/NotificationBase.vue | 71 +++++++++++++++++++ .../views/communityLibrary}/StatusChip.vue | 0 11 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue rename contentcuration/contentcuration/frontend/{channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel => shared/views/communityLibrary}/StatusChip.vue (100%) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js index 3abe807c72..41feb1f28c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -4,10 +4,10 @@ import { factory } from '../../../../store'; import SubmitToCommunityLibrarySidePanel from '../'; import Box from '../Box.vue'; -import StatusChip from '../StatusChip.vue'; import { usePublishedData } from '../composables/usePublishedData'; import { useLatestCommunityLibrarySubmission } from '../composables/useLatestCommunityLibrarySubmission'; +import StatusChip from 'shared/views/communityLibrary/StatusChip.vue'; import { Categories, CommunityLibraryStatus } from 'shared/constants'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import { CommunityLibrarySubmission } from 'shared/data/resources'; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 550e5c3c8b..926925ee7f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -244,7 +244,6 @@ import Box from './Box'; import LoadingText from './LoadingText'; - import StatusChip from './StatusChip'; import { useLatestCommunityLibrarySubmission } from './composables/useLatestCommunityLibrarySubmission'; import { useLicenseAudit } from './composables/useLicenseAudit'; import { usePublishedData } from './composables/usePublishedData'; @@ -252,6 +251,7 @@ import InvalidLicensesNotice from './licenseCheck/InvalidLicensesNotice.vue'; import CompatibleLicensesNotice from './licenseCheck/CompatibleLicensesNotice.vue'; import SpecialPermissionsList from './licenseCheck/SpecialPermissionsList.vue'; + import StatusChip from 'shared/views/communityLibrary/StatusChip.vue'; import { translateMetadataString } from 'shared/utils/metadataStringsTranslation'; import countriesUtil from 'shared/utils/countries'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index 9b99296666..f16882ef50 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -345,4 +345,25 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Filter by status', context: 'Label for the filter by status dropdown in the notifications modal', }, + newLabel: { + message: 'New', + context: 'Label indicating the section for new notifications', + }, + clearAllAction: { + message: 'Clear all', + context: 'Action button to clear all notifications', + }, + viewMoreAction: { + message: 'View more', + context: 'Action button to view more about a given element', + }, + submissionCreationNotification: { + message: 'Your submission to the Community Library was successful and is now under review.', + context: + 'Notification message shown to the user when their submission to the Community Library is successful', + }, + flaggedNotification: { + message: '{ author } flagged { channelversion }', + context: 'Notification message shown when a user flags a channel version for review', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue index 1eed365802..233fdee08f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue @@ -4,6 +4,7 @@ @@ -44,6 +47,12 @@ import { commonStrings } from 'shared/strings/commonStrings'; import { CommunityLibraryStatus } from 'shared/constants'; + defineProps({ + disabled: { + type: Boolean, + default: false, + }, + }); const emit = defineEmits(['update:queryParams']); const { @@ -107,11 +116,16 @@ const CommunityLibraryStatusFilterMap = { pending: { label: pendingStatus$(), - params: { status__in: CommunityLibraryStatus.PENDING }, + params: { + // Not taking into account SUPERSEDED, because those are not pending of any action + status__in: CommunityLibraryStatus.PENDING, + }, }, approved: { label: approvedStatus$(), - params: { status__in: CommunityLibraryStatus.APPROVED }, + params: { + status__in: [CommunityLibraryStatus.APPROVED, CommunityLibraryStatus.LIVE].join(','), + }, }, flagged: { label: flaggedStatus$(), diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue new file mode 100644 index 0000000000..4aee7378fc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -0,0 +1,64 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index 1ac5cd98b0..adda6021bc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -49,7 +49,18 @@ class="notifications-page-content" :style="contentWrapperStyles" > - + + @@ -60,11 +71,13 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue new file mode 100644 index 0000000000..857076ff73 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue @@ -0,0 +1,45 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue new file mode 100644 index 0000000000..a5e5fc3c39 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue @@ -0,0 +1,42 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue new file mode 100644 index 0000000000..206c66f96a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue @@ -0,0 +1,45 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue new file mode 100644 index 0000000000..29eb0c6f93 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue @@ -0,0 +1,71 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue similarity index 100% rename from contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue rename to contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue From 529116fbd96db078a07244addf051fe374408887 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 11:03:51 -0500 Subject: [PATCH 06/13] Complete notifications list component --- .../strings/communityChannelsStrings.js | 22 ++++- .../NotificationsModal/NotificationList.vue | 94 +++++++++++++++---- .../shared/views/NotificationsModal/index.vue | 7 ++ .../CommunityLibrarySubmissionApproval.vue | 29 +++++- .../CommunityLibrarySubmissionCreation.vue | 25 ++++- .../CommunityLibrarySubmissionRejection.vue | 40 ++++++-- .../notificationTypes/NotificationBase.vue | 7 +- .../shared/views/communityLibrary/Chip.vue | 64 +++++++++++++ .../communityLibrary/CommunityLibraryChip.vue | 21 +++++ .../views/communityLibrary/StatusChip.vue | 64 +++++-------- 10 files changed, 300 insertions(+), 73 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/communityLibrary/Chip.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryChip.vue diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index f16882ef50..298a84f680 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -1,6 +1,10 @@ import { createTranslator } from 'shared/i18n'; export const communityChannelsStrings = createTranslator('CommunityChannelsStrings', { + communityLibraryLabel: { + message: 'Community Library', + context: 'Label for the Community Library', + }, // Publishing panel strings publishChannel: { message: 'Publish channel', @@ -363,7 +367,23 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin 'Notification message shown to the user when their submission to the Community Library is successful', }, flaggedNotification: { - message: '{ author } flagged { channelversion }', + message: '{ author } ({ userType }) flagged { channelVersion }', context: 'Notification message shown when a user flags a channel version for review', }, + showOlderAction: { + message: 'Show older', + context: 'Action button to load older items in a list', + }, + adminLabel: { + message: 'Admin', + context: 'Label indicating administrative status', + }, + emptyNotificationsNotice: { + message: 'You have no notifications at this time.', + context: 'Notice shown when there are no notifications to display', + }, + emptyNotificationsWithFiltersNotice: { + message: 'No notifications match the applied filters.', + context: 'Notice shown when no notifications match the current filters', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue index 4aee7378fc..b7c6713e68 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -1,22 +1,48 @@ @@ -39,6 +65,18 @@ type: Boolean, default: false, }, + isLoadingMore: { + type: Boolean, + default: false, + }, + hasMore: { + type: Boolean, + default: false, + }, + hasFiltersApplied: { + type: Boolean, + default: false, + }, }); const NotificationTypeToComponent = { @@ -46,13 +84,29 @@ [NotificationType.COMMUNITY_LIBRARY_SUBMISSION_REJECTED]: CommunityLibrarySubmissionRejection, [NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED]: CommunityLibrarySubmissionCreation, }; - const { newLabel$, clearAllAction$ } = communityChannelsStrings; + const { + newLabel$, + clearAllAction$, + showOlderAction$, + emptyNotificationsNotice$, + emptyNotificationsWithFiltersNotice$, + } = communityChannelsStrings; diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index adda6021bc..f286fecd65 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -33,12 +33,14 @@ > {{ unreadNotificationsLabel$() }} {{ allNotificationsLabel$() }} @@ -58,6 +60,7 @@ :notifications="notifications" :isLoading="isLoading" :isLoadingMore="isLoadingMore" + :hasFiltersApplied="hasFiltersApplied" @fetchData="fetchData" @fetchMore="fetchMore" /> @@ -128,6 +131,10 @@ fetchMore, } = useCommunityLibraryUpdates({ queryParams }); + const hasFiltersApplied = computed(() => { + return Object.keys(queryParams.value).length > 0; + }); + watch(queryParams, () => { fetchData(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue index 857076ff73..67796236e6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue @@ -8,10 +8,22 @@ v-if="notification.feedback_notes" #default > - {{ notification.feedback_notes }} + + @@ -24,6 +36,7 @@ import NotificationBase from './NotificationBase.vue'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import StatusChip from 'shared/views/communityLibrary/StatusChip.vue'; + import CommunityLibraryChip from 'shared/views/communityLibrary/CommunityLibraryChip.vue'; import { CommunityLibraryStatus } from 'shared/constants'; const props = defineProps({ @@ -33,7 +46,7 @@ }, }); - const { channelVersion$ } = communityChannelsStrings; + const { channelVersion$, viewMoreAction$ } = communityChannelsStrings; const title = computed(() => channelVersion$({ @@ -43,3 +56,13 @@ ); + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue index a5e5fc3c39..56bb1ff2a2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue @@ -8,7 +8,16 @@ {{ submissionCreationNotification$() }} + @@ -21,6 +30,7 @@ import NotificationBase from './NotificationBase.vue'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import StatusChip from 'shared/views/communityLibrary/StatusChip.vue'; + import CommunityLibraryChip from 'shared/views/communityLibrary/CommunityLibraryChip.vue'; import { CommunityLibraryStatus } from 'shared/constants'; const props = defineProps({ @@ -30,7 +40,8 @@ }, }); - const { channelVersion$, submissionCreationNotification$ } = communityChannelsStrings; + const { channelVersion$, submissionCreationNotification$, viewMoreAction$ } = + communityChannelsStrings; const title = computed(() => channelVersion$({ @@ -40,3 +51,13 @@ ); + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue index 206c66f96a..23758860ae 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue @@ -8,10 +8,22 @@ v-if="notification.feedback_notes" #default > - {{ notification.feedback_notes }} + + @@ -24,6 +36,7 @@ import NotificationBase from './NotificationBase.vue'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import StatusChip from 'shared/views/communityLibrary/StatusChip.vue'; + import CommunityLibraryChip from 'shared/views/communityLibrary/CommunityLibraryChip.vue'; import { CommunityLibraryStatus } from 'shared/constants'; const props = defineProps({ @@ -33,13 +46,28 @@ }, }); - const { channelVersion$ } = communityChannelsStrings; + const { flaggedNotification$, channelVersion$, viewMoreAction$, adminLabel$ } = + communityChannelsStrings; const title = computed(() => - channelVersion$({ - name: props.notification.channel_name, - version: props.notification.channel_version, + flaggedNotification$({ + author: props.notification.resolved_by_name, + userType: adminLabel$(), + channelVersion: channelVersion$({ + name: props.notification.channel_name, + version: props.notification.channel_version, + }), }), ); + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue index 29eb0c6f93..12f0eb5341 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue @@ -46,6 +46,10 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryChip.vue new file mode 100644 index 0000000000..4b53806319 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryChip.vue @@ -0,0 +1,21 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue index 8e2b8c4d37..f3b928529f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue @@ -1,13 +1,13 @@ @@ -16,11 +16,15 @@ import { themePalette } from 'kolibri-design-system/lib/styles/theme'; import { computed } from 'vue'; + import Chip from './Chip.vue'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import { CommunityLibraryStatus } from 'shared/constants'; export default { name: 'StatusChip', + components: { + Chip, + }, setup(props) { const theme = themePalette(); @@ -29,34 +33,39 @@ const configChoices = { [CommunityLibraryStatus.PENDING]: { text: pendingStatus$(), - color: theme.yellow.v_100, + backgroundColor: theme.yellow.v_100, labelColor: theme.orange.v_600, - icon: 'schedule', + borderColor: theme.orange.v_400, + icon: 'timer', }, [CommunityLibraryStatus.APPROVED]: { text: approvedStatus$(), - color: theme.green.v_100, + backgroundColor: theme.green.v_100, labelColor: theme.green.v_600, + borderColor: theme.green.v_400, icon: 'circleCheckmark', }, [CommunityLibraryStatus.REJECTED]: { text: flaggedStatus$(), - color: theme.red.v_100, + backgroundColor: theme.red.v_100, labelColor: theme.red.v_600, + borderColor: theme.red.v_400, icon: 'error', }, }; const icon = computed(() => configChoices[props.status].icon); const text = computed(() => configChoices[props.status].text); - const color = computed(() => configChoices[props.status].color); + const backgroundColor = computed(() => configChoices[props.status].backgroundColor); const labelColor = computed(() => configChoices[props.status].labelColor); + const borderColor = computed(() => configChoices[props.status].borderColor); return { icon, text, - color, + backgroundColor, labelColor, + borderColor, }; }, props: { @@ -74,30 +83,3 @@ }; - - - From 9e1461c3ad48a94c0265909c262f25dd9ac8c1ec Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 14:36:39 -0500 Subject: [PATCH 07/13] Fix multiple parallel reloads on admin channels and users table --- .../frontend/administration/composables/useTable.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/administration/composables/useTable.js b/contentcuration/contentcuration/frontend/administration/composables/useTable.js index 5c31533e6b..a405b91971 100644 --- a/contentcuration/contentcuration/frontend/administration/composables/useTable.js +++ b/contentcuration/contentcuration/frontend/administration/composables/useTable.js @@ -99,7 +99,10 @@ export function useTable({ fetchFunc, filterFetchQueryParams }) { watch( allFetchQueryParams, - () => { + (newValue, oldValue) => { + if (isEqual(newValue, oldValue)) { + return; + } // Use nextTick to ensure that pagination can be updated before fetching nextTick().then(() => { loadItems(); From 5923f1c622305b348f45afdae1b667a67b37b972 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 14:37:02 -0500 Subject: [PATCH 08/13] Connect NotificationModal to Appbar and MainNavigationDrawer --- .../shared/composables/useKeywordSearch.js | 15 +++--- .../frontend/shared/constants.js | 4 ++ .../frontend/shared/views/AppBar.vue | 29 +++++++++-- .../shared/views/MainNavigationDrawer.vue | 35 +++++++++++++ .../NotificationFilters.vue | 8 ++- .../shared/views/NotificationsModal/index.vue | 50 ++++++++++++++++--- 6 files changed, 121 insertions(+), 20 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js b/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js index ee4e6da1ce..0bdc06372b 100644 --- a/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js @@ -16,13 +16,16 @@ import { useQueryParams } from './useQueryParams'; */ /** + * @param {Object} params + * @param {string} [params.name='keywords'] The name of the query parameter to sync with. + * * Composable for managing the state keyword search input. The search input - * value is synchronized with a URL query parameter named `keywords`. + * value is synchronized with a URL query parameter named `name` (default: "keywords"). * Parameters that should be added to the backend fetch params are * returned as `fetchQueryParams`. * @returns {UseKeywordSearchReturn} */ -export function useKeywordSearch() { +export function useKeywordSearch({ name = 'keywords' } = {}) { const keywordInput = ref(''); const route = useRoute(); @@ -30,14 +33,14 @@ export function useKeywordSearch() { const keywords = computed({ get() { - return route.query.keywords || ''; + return route.query[name] || ''; }, set(value) { const params = { ...route.query }; if (value) { - params.keywords = value; + params[name] = value; } else { - delete params['keywords']; + delete params[name]; } updateQueryParams(params); }, @@ -55,7 +58,7 @@ export function useKeywordSearch() { } onBeforeMount(() => { - keywordInput.value = route.query.keywords; + keywordInput.value = route.query[name] || ''; }); watch(keywords, () => { diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 33d44354cf..1cd51d9399 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -342,3 +342,7 @@ export const NotificationType = { COMMUNITY_LIBRARY_SUBMISSION_APPROVED: 'COMMUNITY_LIBRARY_SUBMISSION_APPROVED', COMMUNITY_LIBRARY_SUBMISSION_REJECTED: 'COMMUNITY_LIBRARY_SUBMISSION_REJECTED', }; + +export const Modals = { + NOTIFICATIONS: 'NOTIFICATIONS', +}; diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index 02139ff8f3..3e3f7ee8e4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -66,6 +66,15 @@ + + + + + + - - @@ -168,7 +175,8 @@ import Tabs from 'shared/views/Tabs'; import MainNavigationDrawer from 'shared/views/MainNavigationDrawer'; import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; - import NotificationsModal from 'shared/views/NotificationsModal'; + import { Modals } from 'shared/constants'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; export default { name: 'AppBar', @@ -176,7 +184,12 @@ LanguageSwitcherModal, MainNavigationDrawer, Tabs, - NotificationsModal, + }, + setup() { + const { notificationsLabel$ } = communityChannelsStrings; + return { + notificationsLabel$, + }; }, props: { title: { @@ -208,6 +221,14 @@ }, methods: { ...mapActions(['logout']), + showNotificationsModal() { + this.$router.push({ + query: { + ...this.$route.query, + modal: Modals.NOTIFICATIONS, + }, + }); + }, }, $trs: { title: 'Kolibri Studio', diff --git a/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue b/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue index 9bd3c6d039..e47ce330d3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue +++ b/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue @@ -52,6 +52,20 @@ {{ $tr('administrationLink') }} + + + + + + {{ notificationsLabel$() }} + + + + @@ -143,11 +159,21 @@ import { mapActions, mapState } from 'vuex'; import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; + import { Modals } from 'shared/constants'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; + import NotificationsModal from 'shared/views/NotificationsModal/index.vue'; export default { name: 'MainNavigationDrawer', components: { LanguageSwitcherModal, + NotificationsModal, + }, + setup() { + const { notificationsLabel$ } = communityChannelsStrings; + return { + notificationsLabel$, + }; }, props: { value: { @@ -205,6 +231,15 @@ this.drawer = false; this.showLanguageModal = true; }, + showNotificationsModal() { + this.$router.push({ + query: { + ...this.$route.query, + modal: Modals.NOTIFICATIONS, + }, + }); + this.trackClick('Notifications'); + }, }, $trs: { channelsLink: 'Channels', diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue index 233fdee08f..a9eced8ba8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue @@ -41,6 +41,7 @@ From fdc21677f059754a7edcd2064bcad4c49acc71b2 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 16:06:33 -0500 Subject: [PATCH 09/13] Add notifications datetimes to User model --- ...d_notification_timestamps_20251217_0055.py | 23 +++++++ contentcuration/contentcuration/models.py | 51 ++++++++++++++- .../tests/viewsets/test_user.py | 62 +++++++++++++++++++ contentcuration/contentcuration/views/base.py | 10 ++- .../viewsets/community_library_submission.py | 2 + .../viewsets/sync/constants.py | 2 + .../contentcuration/viewsets/user.py | 26 ++++++++ 7 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 contentcuration/contentcuration/migrations/0160_add_notification_timestamps_20251217_0055.py diff --git a/contentcuration/contentcuration/migrations/0160_add_notification_timestamps_20251217_0055.py b/contentcuration/contentcuration/migrations/0160_add_notification_timestamps_20251217_0055.py new file mode 100644 index 0000000000..88f5f6c08f --- /dev/null +++ b/contentcuration/contentcuration/migrations/0160_add_notification_timestamps_20251217_0055.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.24 on 2025-12-17 00:55 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0159_update_community_library_submission_date_updated"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="last_read_notification_date", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="newest_notification_date", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 32727f0159..b61d7efbef 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -39,6 +39,7 @@ from django.db.models import Value from django.db.models.expressions import ExpressionList from django.db.models.expressions import RawSQL +from django.db.models.functions import Greatest from django.db.models.functions import Lower from django.db.models.indexes import IndexExpression from django.db.models.query_utils import DeferredAttribute @@ -82,6 +83,8 @@ from contentcuration.viewsets.sync.constants import ALL_TABLES from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES from contentcuration.viewsets.sync.constants import PUBLISHED +from contentcuration.viewsets.sync.constants import SESSION +from contentcuration.viewsets.sync.utils import generate_update_event EDIT_ACCESS = "edit" VIEW_ACCESS = "view" @@ -230,6 +233,9 @@ class User(AbstractBaseUser, PermissionsMixin): deleted = models.BooleanField(default=False, db_index=True) + newest_notification_date = models.DateTimeField(null=True, blank=True) + last_read_notification_date = models.DateTimeField(null=True, blank=True) + _field_updates = FieldTracker( fields=[ # Field to watch for changes @@ -509,6 +515,13 @@ def get_server_rev(self): .first() ) or 0 + def mark_notifications_read(self, timestamp): + # Greatest between last read and timestamp + self.last_read_notification_date = Greatest( + F("last_read_notification_date"), Value(timestamp) + ) + self.save(update_fields=["last_read_notification_date"]) + class Meta: verbose_name = "User" verbose_name_plural = "Users" @@ -579,6 +592,27 @@ def get_for_email(cls, email, deleted=False, **filters): user_qs = user_qs.filter(deleted=deleted) return user_qs.filter(**filters).order_by("-is_active", "-id").first() + @classmethod + def notify_users(cls, users_queryset, date): + users_queryset.update( + newest_notification_date=Greatest( + F("newest_notification_date"), Value(date) + ) + ) + + Change.create_changes( + [ + generate_update_event( + "CURRENT_USER", + SESSION, + {"newest_notification_date": user.newest_notification_date}, + user_id=user.pk, + ) + for user in users_queryset + ], + applied=True, + ) + class UUIDField(models.CharField): def __init__(self, *args, **kwargs): @@ -2649,7 +2683,11 @@ def save(self, *args, **kwargs): code="public_channel_submission", ) - if self.pk is None: + is_adding = self._state.adding + + super().save(*args, **kwargs) + + if is_adding: # When creating a new submission, ensure the channel has a versioned database # (it might not have if the channel was published before versioned databases # were introduced). @@ -2658,8 +2696,8 @@ def save(self, *args, **kwargs): channel_id=self.channel.id, channel_version=self.channel.version, ) - - super().save(*args, **kwargs) + # Notify channel editors of new submission + self.notify_update_to_channel_editors() def mark_live(self): """ @@ -2676,6 +2714,13 @@ def mark_live(self): self.status = community_library_submission.STATUS_LIVE self.save() + def notify_update_to_channel_editors(self): + """ + Notify channel editors that a submission has been updated. + """ + editors = self.channel.editors.all() + User.notify_users(editors, date=self.date_updated) + @classmethod def filter_view_queryset(cls, queryset, user): if user.is_anonymous: diff --git a/contentcuration/contentcuration/tests/viewsets/test_user.py b/contentcuration/contentcuration/tests/viewsets/test_user.py index 5e8554f35a..914404bf24 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_user.py +++ b/contentcuration/contentcuration/tests/viewsets/test_user.py @@ -173,3 +173,65 @@ def test_fetch_users_no_permissions(self): ) self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response.json(), []) + + +class MarkReadNotificationsTimestampTestCase(StudioAPITestCase): + def setUp(self): + super(MarkReadNotificationsTimestampTestCase, self).setUp() + self.user = testdata.user() + + def test_mark_read_notifications_timestamp_success(self): + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("user-mark-notifications-read"), + data={"timestamp": "2023-12-16T10:00:00Z"}, + format="json", + ) + self.assertEqual(response.status_code, 204, response.content) + + def test_mark_read_notifications_timestamp_invalid_format(self): + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("user-mark-notifications-read"), + data={"timestamp": "invalid-timestamp"}, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_mark_read_notifications_timestamp_missing_timestamp(self): + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("user-mark-notifications-read"), + data={}, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_mark_read_notifications_timestamp_unauthenticated(self): + response = self.client.post( + reverse("user-mark-notifications-read"), + data={"timestamp": "2023-12-16T10:00:00Z"}, + format="json", + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_mark_read_notifications_timestamp_updates_field(self): + timestamp = "2023-12-16T10:00:00Z" + self.client.force_authenticate(user=self.user) + + response = self.client.post( + reverse("user-mark-notifications-read"), + data={"timestamp": timestamp}, + format="json", + ) + + self.assertEqual(response.status_code, 204, response.content) + + # Refresh user from database and check the timestamp was updated + self.user.refresh_from_db() + # Check that the last_read_notification_date field was updated + self.assertIsNotNone(self.user.last_read_notification_date) + self.assertEqual( + self.user.last_read_notification_date.isoformat(), + timestamp.replace("Z", "+00:00"), + ) diff --git a/contentcuration/contentcuration/views/base.py b/contentcuration/contentcuration/views/base.py index 082d96376d..147f04e0f0 100644 --- a/contentcuration/contentcuration/views/base.py +++ b/contentcuration/contentcuration/views/base.py @@ -76,6 +76,8 @@ "clipboard_tree_id", "policies", "feature_flags", + "newest_notification_date", + "last_read_notification_date", ) @@ -83,7 +85,13 @@ def current_user_for_context(user): if not user or user.is_anonymous: return json_for_parse_from_data(None) - user_data = {field: getattr(user, field) for field in user_fields} + user_data = {} + for field in user_fields: + value = getattr(user, field) + # Convert datetime objects to ISO format strings for JSON serialization + if hasattr(value, "isoformat"): + value = value.isoformat() if value else None + user_data[field] = value user_data["user_rev"] = user.get_server_rev() diff --git a/contentcuration/contentcuration/viewsets/community_library_submission.py b/contentcuration/contentcuration/viewsets/community_library_submission.py index 64dc7aa19d..aef0d87f0a 100644 --- a/contentcuration/contentcuration/viewsets/community_library_submission.py +++ b/contentcuration/contentcuration/viewsets/community_library_submission.py @@ -321,6 +321,8 @@ def resolve(self, request, pk=None): resolved_by=request.user, ) + submission.notify_update_to_channel_editors() + if submission.status == community_library_submission_constants.STATUS_APPROVED: self._mark_previous_pending_submissions_as_superseded(submission) self._add_to_community_library(submission) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index e45d30a3e0..e52ae3682a 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -48,6 +48,7 @@ VIEWER_M2M = "viewer_m2m" SAVEDSEARCH = "savedsearch" CLIPBOARD = "clipboard" +SESSION = "session" ALL_TABLES = set( @@ -62,6 +63,7 @@ FILE, INVITATION, USER, + SESSION, SAVEDSEARCH, EDITOR_M2M, VIEWER_M2M, diff --git a/contentcuration/contentcuration/viewsets/user.py b/contentcuration/contentcuration/viewsets/user.py index c779d1be47..724493e9eb 100644 --- a/contentcuration/contentcuration/viewsets/user.py +++ b/contentcuration/contentcuration/viewsets/user.py @@ -17,6 +17,7 @@ from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import CharFilter from django_filters.rest_framework import FilterSet +from rest_framework import serializers from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.permissions import BasePermission @@ -130,6 +131,13 @@ class Meta: fields = ("ids",) +class MarkNotificationsReadSerializer(serializers.Serializer): + timestamp = serializers.DateTimeField( + required=True, + help_text="Timestamp of the last read notification.", + ) + + class UserSerializer(BulkModelSerializer): class Meta: model = User @@ -170,6 +178,24 @@ def get_storage_used(self, request): def refresh_storage_used(self, request): return Response(request.user.set_space_used()) + @action( + detail=False, + methods=["post"], + serializer_class=MarkNotificationsReadSerializer, + ) + def mark_notifications_read(self, request, pk=None): + """ + Allows a user to mark the timestamp of their last read notification. + """ + user = request.user + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + timestamp = serializer.validated_data["timestamp"] + user.mark_notifications_read(timestamp) + return Response(status=HTTP_204_NO_CONTENT) + def annotate_queryset(self, queryset): queryset = queryset.annotate( editable_channels__ids=NotNullArrayAgg("editable_channels__id"), From b85f8007de92d12e82b44ae4ea9794d2f3a8a41d Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 16 Dec 2025 16:34:30 -0500 Subject: [PATCH 10/13] Add red dot on AppBar and MainNavigationDrawer --- .../frontend/shared/composables/useStore.js | 7 +++ .../frontend/shared/views/AppBar.vue | 24 +++++++---- .../shared/views/MainNavigationDrawer.vue | 12 ++++-- .../views/WithNotificationIndicator.vue | 43 +++++++++++++++++++ .../frontend/shared/vuex/session/index.js | 17 +++++++- 5 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/composables/useStore.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue diff --git a/contentcuration/contentcuration/frontend/shared/composables/useStore.js b/contentcuration/contentcuration/frontend/shared/composables/useStore.js new file mode 100644 index 0000000000..8733f60cd2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/useStore.js @@ -0,0 +1,7 @@ +import { getCurrentInstance } from 'vue'; + +export default function useStore() { + const instance = getCurrentInstance(); + const store = instance.proxy.$store; + return store; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index 3e3f7ee8e4..bcbd8cf8fb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -40,11 +40,13 @@ style="text-transform: none" v-on="on" > - + + + {{ user.first_name }} - + + + @@ -172,6 +176,7 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/vuex/session/index.js b/contentcuration/contentcuration/frontend/shared/vuex/session/index.js index 6112870535..ac3baafb8f 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/session/index.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/session/index.js @@ -47,8 +47,8 @@ export default { ...data, }; }, - UPDATE_SESSION_FROM_INDEXEDDB(state, { id, ...mods }) { - if (id === state.currentUser.id) { + UPDATE_SESSION_FROM_INDEXEDDB(state, { CURRENT_USER, ...mods }) { + if (CURRENT_USER === 'CURRENT_USER') { state.currentUser = { ...applyMods(state.currentUser, mods) }; } }, @@ -101,6 +101,19 @@ export default { } return false; }, + hasNewNotifications(state) { + const { + newest_notification_date: newestNotificationDate, + last_read_notification_date: lastReadNotificationDate, + } = state.currentUser || {}; + if (!newestNotificationDate) { + return false; + } + if (!lastReadNotificationDate) { + return true; + } + return new Date(newestNotificationDate) > new Date(lastReadNotificationDate); + }, }, actions: { saveSession(context, currentUser) { From 3d293797cccaf489b216055c45c9007b0ac3b4e4 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 17 Dec 2025 08:55:14 -0500 Subject: [PATCH 11/13] Add support for unread/all notifications --- .../frontend/shared/data/resources.js | 6 + .../NotificationFilters.vue | 12 +- .../NotificationsModal/NotificationList.vue | 7 +- .../useCommunityLibraryUpdates.spec.js | 48 +++++++ .../composables/useCommunityLibraryUpdates.js | 12 +- .../shared/views/NotificationsModal/index.vue | 127 ++++++++++++++---- 6 files changed, 178 insertions(+), 34 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index c2cd4bbc28..18720be97b 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -2115,6 +2115,12 @@ export const User = new Resource({ getUserId(obj) { return obj.id; }, + async markNotificationsRead(timestamp) { + await client.post(window.Urls.userMarkNotificationsRead(), { + timestamp, + }); + await Session.updateSession({ last_read_notification_date: timestamp }); + }, }); export const EditorM2M = new IndexedDBResource({ diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue index a9eced8ba8..90a5e617cf 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue @@ -54,7 +54,7 @@ default: false, }, }); - const emit = defineEmits(['update:queryParams']); + const emit = defineEmits(['update:filters']); const { searchNotificationsLabel$, @@ -158,7 +158,7 @@ fetchQueryParams: keywordSearchFetchQueryParams, } = useKeywordSearch({ name: 'notification_keywords' }); - const notificationsQueryParams = computed(() => { + const notificationsFilters = computed(() => { return { ...dateFetchQueryParams.value, ...communityLibraryStatusFetchQueryParams.value, @@ -167,13 +167,13 @@ }); watch( - notificationsQueryParams, - (newParams, oldParams) => { - if (isEqual(newParams, oldParams)) { + notificationsFilters, + (newFilters, oldFilters) => { + if (isEqual(newFilters, oldFilters)) { return; } // Emit an event with the updated query params - emit('update:queryParams', newParams); + emit('update:filters', newFilters); }, { immediate: true }, ); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue index b7c6713e68..cbbcaac0d3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -12,13 +12,14 @@
{{ newLabel$() }}
@@ -77,6 +78,10 @@ type: Boolean, default: false, }, + showClearAll: { + type: Boolean, + default: false, + }, }); const NotificationTypeToComponent = { diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js index 0b4d64e6e3..b8747ba385 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js @@ -345,6 +345,54 @@ describe('useCommunityLibraryUpdates', () => { }); }); + it('should take the newest date between date_updated__lte and lastRead - 1', async () => { + const GTE_DATE__OLDER = '2026-01-01T00:00:00Z'; + const LAST_READ__NEWER = '2026-01-02T00:00:00Z'; + + const queryParams = ref({ + date_updated__gte: GTE_DATE__OLDER, + lastRead: LAST_READ__NEWER, + }); + + mockFetchCollection.mockResolvedValue({ + results: [], + more: null, + }); + + const { fetchData } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + expect(mockFetchCollection).toHaveBeenCalledWith({ + date_updated__gte: LAST_READ__NEWER, + max_results: 10, + }); + }); + + it('should take the newest date between date_updated__lte and lastRead - 2', async () => { + const GTE_DATE__NEWER = '2026-01-02T00:00:00Z'; + const LAST_READ__OLDER = '2026-01-01T00:00:00Z'; + + const queryParams = ref({ + date_updated__gte: GTE_DATE__NEWER, + lastRead: LAST_READ__OLDER, + }); + + mockFetchCollection.mockResolvedValue({ + results: [], + more: null, + }); + + const { fetchData } = useCommunityLibraryUpdates({ queryParams }); + + await fetchData(); + + expect(mockFetchCollection).toHaveBeenCalledWith({ + date_updated__gte: GTE_DATE__NEWER, + max_results: 10, + }); + }); + it('should omit undefined query parameters', async () => { const queryParams = ref({ keywords: undefined, diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js index 1c63ccf588..8e60074fcd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js @@ -89,6 +89,16 @@ export default function useCommunityLibraryUpdates({ queryParams } = {}) { return updates; }; + const getNewerDate = (date1, date2) => { + if (!date1) { + return date2; + } + if (!date2) { + return date1; + } + return new Date(date1) > new Date(date2) ? date1 : date2; + }; + const fetchSubmissionsUpdates = async () => { let params; isLoadingMore.value = Boolean(moreObject.value); @@ -98,7 +108,7 @@ export default function useCommunityLibraryUpdates({ queryParams } = {}) { const _params = unref(queryParams); params = pickBy({ date_updated__lte: _params?.date_updated__lte, - date_updated__gte: _params?.date_updated__gte, + date_updated__gte: getNewerDate(_params?.date_updated__gte, _params?.lastRead), status__in: _params?.status__in, search: _params?.keywords, max_results: MAX_RESULTS_PER_PAGE, diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index 8d8faa4795..e1b8e79039 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -30,14 +30,14 @@ > {{ unreadNotificationsLabel$() }} {{ allNotificationsLabel$() }} @@ -49,16 +49,23 @@ :style="contentWrapperStyles" > +
@@ -72,6 +79,7 @@ @@ -187,7 +263,6 @@ .notifications-page-container { max-width: 1000px; - padding: 24px; margin: 0 auto; } From ae9f6712056c84d7bb4005b8a6b4a612d8b39d83 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 17 Dec 2025 10:06:04 -0500 Subject: [PATCH 12/13] Add error handling and add documentation --- .../composables/__mocks__/useSnackbar.js | 26 +++++++ .../shared/composables/useSnackbar.js | 30 ++++++++ .../frontend/shared/strings/commonStrings.js | 4 ++ .../NotificationFilters.vue | 2 +- .../NotificationsModal/NotificationList.vue | 10 +++ .../useCommunityLibraryUpdates.spec.js | 2 + .../composables/useCommunityLibraryUpdates.js | 68 ++++++++++++++++--- .../shared/views/NotificationsModal/index.vue | 31 ++++++--- 8 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/composables/__mocks__/useSnackbar.js create mode 100644 contentcuration/contentcuration/frontend/shared/composables/useSnackbar.js diff --git a/contentcuration/contentcuration/frontend/shared/composables/__mocks__/useSnackbar.js b/contentcuration/contentcuration/frontend/shared/composables/__mocks__/useSnackbar.js new file mode 100644 index 0000000000..e57b7d41a3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/__mocks__/useSnackbar.js @@ -0,0 +1,26 @@ +import { computed } from 'vue'; + +const MOCK_DEFAULTS = { + snackbarIsVisible: false, + snackbarOptions: {}, + createSnackbar: jest.fn(), + clearSnackbar: jest.fn(), +}; + +export function useSnackbarMock(overrides = {}) { + const mocks = { + ...MOCK_DEFAULTS, + ...overrides, + }; + const computedMocks = {}; + for (const key in mocks) { + if (typeof mocks[key] !== 'function') { + computedMocks[key] = computed(() => mocks[key]); + } else { + computedMocks[key] = mocks[key]; + } + } + return computedMocks; +} + +export default jest.fn(() => useSnackbarMock()); diff --git a/contentcuration/contentcuration/frontend/shared/composables/useSnackbar.js b/contentcuration/contentcuration/frontend/shared/composables/useSnackbar.js new file mode 100644 index 0000000000..21d29a6179 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/useSnackbar.js @@ -0,0 +1,30 @@ +import { computed } from 'vue'; +import useStore from 'shared/composables/useStore'; + +// Composable wrapping snackbar Vuex store actions and getters +export default function useSnackbar() { + const store = useStore(); + const snackbarIsVisible = computed(() => store.getters.snackbarIsVisible); + const snackbarOptions = computed(() => store.getters.snackbarOptions); + + const createSnackbar = (options = {}) => { + let snackbarOptions; + if (typeof options === 'string') { + snackbarOptions = { text: options, autoDismiss: true }; + } else { + snackbarOptions = options; + } + return store.dispatch('showSnackbar', snackbarOptions); + }; + + const clearSnackbar = () => { + return store.dispatch('clearSnackbar'); + }; + + return { + snackbarIsVisible, + snackbarOptions, + createSnackbar, + clearSnackbar, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 0f5121df32..14f9d7518a 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -14,4 +14,8 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Close', context: 'A label for an action that closes a dialog or window', }, + genericErrorMessage: { + message: 'Sorry! Something went wrong, please try again.', + context: 'Default error message for operation errors.', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue index 90a5e617cf..db444f1bb5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationFilters.vue @@ -172,9 +172,9 @@ if (isEqual(newFilters, oldFilters)) { return; } - // Emit an event with the updated query params emit('update:filters', newFilters); }, + // Immediate to emit initial filters values on mount { immediate: true }, ); diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue index cbbcaac0d3..4283bbd342 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -84,11 +84,21 @@ }, }); + defineEmits(['notificationsRead', 'fetchMore']); + + /** + * Each notification type should have a corresponding component renderer. + * This mapping defines which component to use for each notification type. + * + * Each notification renderer component should reuse the `NotificationBase` component + * and accept a `notification` prop. + */ const NotificationTypeToComponent = { [NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED]: CommunityLibrarySubmissionApproval, [NotificationType.COMMUNITY_LIBRARY_SUBMISSION_REJECTED]: CommunityLibrarySubmissionRejection, [NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED]: CommunityLibrarySubmissionCreation, }; + const { newLabel$, clearAllAction$, diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js index b8747ba385..d39b337246 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js @@ -4,6 +4,8 @@ import { CommunityLibraryStatus, NotificationType } from 'shared/constants'; import { CommunityLibrarySubmission } from 'shared/data/resources'; +jest.mock('shared/composables/useSnackbar'); + jest.mock('shared/data/resources', () => ({ CommunityLibrarySubmission: { fetchCollection: jest.fn(), diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js index 8e60074fcd..1e5a4e8176 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js @@ -3,6 +3,8 @@ import pickBy from 'lodash/pickBy'; import { useFetch } from 'shared/composables/useFetch'; import { CommunityLibrarySubmission } from 'shared/data/resources'; import { CommunityLibraryStatus, NotificationType } from 'shared/constants'; +import { commonStrings } from 'shared/strings/commonStrings'; +import useSnackbar from 'shared/composables/useSnackbar'; const MAX_RESULTS_PER_PAGE = 10; @@ -14,10 +16,46 @@ const statusToNotificationType = { [CommunityLibraryStatus.LIVE]: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_APPROVED, }; +/** + * A Vue composable that manages community library submission updates and notifications. + * + * This composable handles the logic of fetching and transforming community library + * submissions into notification updates. It deals with the fact that a single submission + * can generate up to two notifications (creation + status updates) and provides pagination + * support with "load more" functionality. + * + * @param {Object} options - Configuration options for the composable + * @param {|Object} options.queryParams - Reactive or static query parameters object + * @param {string} [options.queryParams.date_updated__lte] - Filter for submissions updated + * on or before this date (ISO string) + * @param {string} [options.queryParams.date_updated__gte] - Filter for submissions updated on or + * after this date (ISO string) + * @param {string} [options.queryParams.lastRead] - Timestamp of when notifications were last read, + * if lastRead is more recent than + * date_updated__gte, it will be used instead + * @param {string} [options.queryParams.status__in] - Comma-separated list of submission statuses to + * filter by (e.g., "PENDING,APPROVED") + * @param {string} [options.queryParams.keywords] - Search keywords to filter submissions + * by channel name + * + * + * @typedef {Object} UseCommunityLibraryUpdatesObject + * @property {import('vue').ComputedRef} returns.submissionsUpdates - Array of transformed + * submission updates/notifications + * @property {import('vue').ComputedRef} returns.isLoading - True when loading initial data + * @property {import('vue').ComputedRef} returns.isLoadingMore True when loading more data + * @property {import('vue').ComputedRef} returns.hasMore - True when more pages are + * available to load + * @property {Function} returns.fetchData - Function to fetch fresh data (resets pagination) + * @property {Function} returns.fetchMore - Function to fetch the next page of data + * + * + * @returns {UseCommunityLibraryUpdatesObject} The composable return object + */ export default function useCommunityLibraryUpdates({ queryParams } = {}) { const moreObject = ref(null); const isLoadingMore = ref(false); - + const { createSnackbar } = useSnackbar(); /** * Community Library Submissions are objects that may represent two types of updates: * 1. Creation of a new submission (status: PENDING or SUPERSEDED) @@ -114,17 +152,25 @@ export default function useCommunityLibraryUpdates({ queryParams } = {}) { max_results: MAX_RESULTS_PER_PAGE, }); } - const response = await CommunityLibrarySubmission.fetchCollection(params); - - // Transforming submissions into updates before concatenation for - // performance reasons - response.results = getSubmissionsUpdates(response.results); - if (isLoadingMore.value) { - response.results = [...submissionsUpdates.value, ...response.results]; + try { + const response = await CommunityLibrarySubmission.fetchCollection(params); + + // Transforming submissions into updates before concatenation with existing updates + // for performance reasons + response.results = getSubmissionsUpdates(response.results); + if (isLoadingMore.value) { + response.results = [...submissionsUpdates.value, ...response.results]; + } + moreObject.value = response.more; + isLoadingMore.value = false; + return response.results; + } catch (error) { + const returnedResults = isLoadingMore.value ? submissionsUpdates.value : []; + isLoadingMore.value = false; + createSnackbar(commonStrings.genericErrorMessage$()); + // Do not manage any error state in the useFetch composable, just return the current results + return returnedResults; } - moreObject.value = response.more; - isLoadingMore.value = false; - return response.results; }; const { diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index e1b8e79039..29e4585135 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -94,6 +94,7 @@ import { Modals } from 'shared/constants'; import useStore from 'shared/composables/useStore'; import { User } from 'shared/data/resources'; + import useSnackbar from 'shared/composables/useSnackbar'; const NotificationsTab = { UNREAD: 0, @@ -103,6 +104,7 @@ const router = useRouter(); const route = useRoute(); const store = useStore(); + const { createSnackbar } = useSnackbar(); const previousQuery = ref(null); const isSaving = ref(false); @@ -169,18 +171,23 @@ const handleNotificationsRead = async () => { isSaving.value = true; - const [newestNotification] = notifications.value; - if (newestNotification) { - // Add 1 second to avoid precisision issues - const timestamp = new Date(newestNotification.date); - timestamp.setSeconds(timestamp.getSeconds() + 1); - await User.markNotificationsRead(timestamp.toISOString()); - - // Refresh the notifications list after notifications read timestamp is updated - // in the vuex store - await waitForLastReadUpdate(); + try { + const [newestNotification] = notifications.value; + if (newestNotification) { + // Add 1 second to avoid precisision issues + const timestamp = new Date(newestNotification.date); + timestamp.setSeconds(timestamp.getSeconds() + 1); + await User.markNotificationsRead(timestamp.toISOString()); + + // Refresh the notifications list after notifications read timestamp is updated + // in the vuex store so that the lastRead filter gets updated and the list refetched + await waitForLastReadUpdate(); + } + } catch (error) { + createSnackbar(commonStrings.genericErrorMessage$()); + } finally { + isSaving.value = false; } - isSaving.value = false; }; const isBusy = computed(() => { @@ -197,6 +204,8 @@ const queryParams = computed(() => { if (!filters.value || !isModalOpen.value) { // Filters not set yet or modal is closed + // Adding `filters` and `isModalOpen` as dependencies to re-trigger the `fetchData` watcher + // when modal is opened or filters are applied for the first time return null; } return { From 9a59b96e7db017dc41cdd4a4c02b46416d6caa00 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 17 Dec 2025 10:44:22 -0500 Subject: [PATCH 13/13] Pin KDS and small styles updates --- .../contentcuration/frontend/shared/views/AppBar.vue | 2 +- .../frontend/shared/views/MainNavigationDrawer.vue | 2 +- .../shared/views/NotificationsModal/index.vue | 2 +- .../notificationTypes/NotificationBase.vue | 1 + .../shared/views/WithNotificationIndicator.vue | 2 +- package.json | 2 +- pnpm-lock.yaml | 11 ++++++----- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index bcbd8cf8fb..306059860a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -73,7 +73,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue b/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue index 3f41379369..05435e8341 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue +++ b/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue @@ -60,7 +60,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index 29e4585135..393bd9085f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -239,7 +239,7 @@ const containerStyles = computed(() => { if (windowIsSmall.value) { return { - padding: '16px', + padding: '16px 8px', }; } return { diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue index 12f0eb5341..378eb0875c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue @@ -61,6 +61,7 @@ .notification-footer { display: flex; + gap: 8px; align-items: center; justify-content: space-between; } diff --git a/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue b/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue index 2873c136cc..358f60b45c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue +++ b/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue @@ -31,7 +31,7 @@ .notification-indicator { position: absolute; - top: 6px; + top: 8px; right: 9px; width: 11px; height: 11px; diff --git a/package.json b/package.json index facf3534ad..6f3bbb7b9d 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "jspdf": "https://github.com/parallax/jsPDF.git#b7a1d8239c596292ce86dafa77f05987bcfa2e6e", "jszip": "^3.10.1", "kolibri-constants": "^0.2.12", - "kolibri-design-system": "5.5.0", + "kolibri-design-system": "https://github.com/AlexVelezLl/kolibri-design-system.git#421aba2205decefd5d7ca36e0c8e09f64fe5b5a0", "lodash": "^4.17.21", "lowlight": "^3.3.0", "marked": "^16.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ad682ceef..7b9197ddc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^0.2.12 version: 0.2.12 kolibri-design-system: - specifier: 5.5.0 - version: 5.5.0 + specifier: https://github.com/AlexVelezLl/kolibri-design-system.git#421aba2205decefd5d7ca36e0c8e09f64fe5b5a0 + version: https://codeload.github.com/AlexVelezLl/kolibri-design-system/tar.gz/421aba2205decefd5d7ca36e0c8e09f64fe5b5a0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -4879,8 +4879,9 @@ packages: kolibri-design-system@5.0.1: resolution: {integrity: sha512-oz5gEFUj7NYZbrqr89gPjSfRFVuuilSqILA4bbWqLWwkI9ay/uVMdsh8cY2Xajhtzccjwqa1c/NxUETr5kMfHQ==} - kolibri-design-system@5.5.0: - resolution: {integrity: sha512-ykLARvIFy6FHoIPfRXk3XHUzmvn2RstDaT2ck8vc7XEawXcZkzt2w9JUVtpnNKdCEnvgg7ufL4cqr0B4Yej0mA==} + kolibri-design-system@https://codeload.github.com/AlexVelezLl/kolibri-design-system/tar.gz/421aba2205decefd5d7ca36e0c8e09f64fe5b5a0: + resolution: {tarball: https://codeload.github.com/AlexVelezLl/kolibri-design-system/tar.gz/421aba2205decefd5d7ca36e0c8e09f64fe5b5a0} + version: 5.5.0 kolibri-format@1.0.1: resolution: {integrity: sha512-yGQpsJkBAzmRueAq6MG1UOuDl9pbhEtMWNxq9ObG5pPVkG8uhWJAS1L71GCuNAeaV1XG2IWo2565Ov4yXnudeA==} @@ -13176,7 +13177,7 @@ snapshots: vue2-teleport: 1.1.4 xstate: 4.38.3 - kolibri-design-system@5.5.0: + kolibri-design-system@https://codeload.github.com/AlexVelezLl/kolibri-design-system/tar.gz/421aba2205decefd5d7ca36e0c8e09f64fe5b5a0: dependencies: aphrodite: https://codeload.github.com/learningequality/aphrodite/tar.gz/fdc8d7be8912a5cf17f74ff10f124013c52c3e32 autosize: 3.0.21