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..a405b91971 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 @@ -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(); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index 6dc5300cb3..dae3d422e9 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -16,7 +16,7 @@ > _channelTypeFilter.value.value || undefined, + set: value => { + _channelTypeFilter.value = + channelTypeOptions.value.find(option => option.value === value) || {}; + }, + }); const { - filter: channelStatusFilter, - filters: channelStatusFilters, + 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, @@ -254,8 +282,8 @@ } = useKeywordSearch(); watch(channelTypeFilter, () => { - 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 +306,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..f7fdf07722 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue @@ -31,7 +31,7 @@ > _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, @@ -233,7 +253,7 @@ return { userTypeFilter, - userTypeFilters, + userTypeOptions, locationDropdown, locationFilter, keywordInput, 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/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/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/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/__tests__/useFilter.spec.js b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js new file mode 100644 index 0000000000..22a80617b6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useFilter.spec.js @@ -0,0 +1,88 @@ +/* eslint-disable vue/one-component-per-file */ +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +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, + }); +} + +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 = { key: 'a', value: 'a', label: '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.value).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.value).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 = { key: 'a', value: 'a', label: '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', value: 'a', label: 'A' }, + { key: 'b', value: '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/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/administration/composables/useFilter.js b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js similarity index 67% rename from contentcuration/contentcuration/frontend/administration/composables/useFilter.js rename to contentcuration/contentcuration/frontend/shared/composables/useFilter.js index 2a9ea67e4e..fbc07698c9 100644 --- a/contentcuration/contentcuration/frontend/administration/composables/useFilter.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useFilter.js @@ -4,10 +4,12 @@ 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}> - * >} filters List of available filters. + * 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,28 +41,35 @@ 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 filters = computed(() => { + const options = computed(() => { return Object.entries(unref(filterMap)).map(([key, value]) => { - return { key, label: value.label }; + // 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 { filter, - filters, + options, fetchQueryParams, }; } diff --git a/contentcuration/contentcuration/frontend/administration/composables/useKeywordSearch.js b/contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js similarity index 80% rename from contentcuration/contentcuration/frontend/administration/composables/useKeywordSearch.js rename to contentcuration/contentcuration/frontend/shared/composables/useKeywordSearch.js index ee4e6da1ce..0bdc06372b 100644 --- a/contentcuration/contentcuration/frontend/administration/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/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 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/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/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 431c12838a..1cd51d9399 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -336,3 +336,13 @@ 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', +}; + +export const Modals = { + NOTIFICATIONS: 'NOTIFICATIONS', +}; 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/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 7b3137c2e2..14f9d7518a 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -6,8 +6,16 @@ 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', }, + genericErrorMessage: { + message: 'Sorry! Something went wrong, please try again.', + context: 'Default error message for operation errors.', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index c116a76a82..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', @@ -303,4 +307,83 @@ 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', + }, + 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 } ({ 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/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index fc29cefd07..306059860a 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 }} + + + + + + + + import { mapActions, mapState, mapGetters } from 'vuex'; + import WithNotificationIndicator from './WithNotificationIndicator.vue'; import Tabs from 'shared/views/Tabs'; import MainNavigationDrawer from 'shared/views/MainNavigationDrawer'; import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; + import { Modals } from 'shared/constants'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; export default { name: 'AppBar', @@ -173,6 +189,13 @@ LanguageSwitcherModal, MainNavigationDrawer, Tabs, + WithNotificationIndicator, + }, + setup() { + const { notificationsLabel$ } = communityChannelsStrings; + return { + notificationsLabel$, + }; }, props: { title: { @@ -204,6 +227,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..05435e8341 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue +++ b/contentcuration/contentcuration/frontend/shared/views/MainNavigationDrawer.vue @@ -52,6 +52,22 @@ {{ $tr('administrationLink') }} + + + + + + + + {{ notificationsLabel$() }} + + + + @@ -142,12 +160,24 @@ + + + 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..4283bbd342 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -0,0 +1,139 @@ + + + + + + + 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..d39b337246 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/__tests__/useCommunityLibraryUpdates.spec.js @@ -0,0 +1,462 @@ +import { ref } from 'vue'; +import useCommunityLibraryUpdates from '../useCommunityLibraryUpdates'; +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(), + }, +})); + +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 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, + 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..1e5a4e8176 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js @@ -0,0 +1,205 @@ +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'; +import { commonStrings } from 'shared/strings/commonStrings'; +import useSnackbar from 'shared/composables/useSnackbar'; + +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, +}; + +/** + * 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) + * 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 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); + if (isLoadingMore.value) { + params = moreObject.value; + } else { + const _params = unref(queryParams); + params = pickBy({ + date_updated__lte: _params?.date_updated__lte, + date_updated__gte: getNewerDate(_params?.date_updated__gte, _params?.lastRead), + status__in: _params?.status__in, + search: _params?.keywords, + max_results: MAX_RESULTS_PER_PAGE, + }); + } + 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; + } + }; + + 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, + }; +} 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..393bd9085f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -0,0 +1,282 @@ + + + + + + + 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..67796236e6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue @@ -0,0 +1,68 @@ + + + + + + + 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..56bb1ff2a2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue @@ -0,0 +1,63 @@ + + + + + + + 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..23758860ae --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue @@ -0,0 +1,73 @@ + + + + + + + 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..378eb0875c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/NotificationBase.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue b/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue new file mode 100644 index 0000000000..358f60b45c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/WithNotificationIndicator.vue @@ -0,0 +1,43 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/Chip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/Chip.vue new file mode 100644 index 0000000000..7804a2e846 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/Chip.vue @@ -0,0 +1,64 @@ + + + + + + + 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/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue similarity index 66% rename from contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue rename to contentcuration/contentcuration/frontend/shared/views/communityLibrary/StatusChip.vue index 8e2b8c4d37..f3b928529f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/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 @@ }; - - - 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) { 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"), 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