diff --git a/src/constants.ts b/src/constants.ts index 12e65d401d..be5eeca609 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,3 +116,9 @@ export const BROKEN = 'broken'; export const LOCKED = 'locked'; export const MANUAL = 'manual'; + +export enum AgreementGated { + UPLOAD = 'upload', + UPLOAD_VIDEOS = 'upload.videos', + UPLOAD_FILES = 'upload.files', +} diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 7389263044..dad989010e 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -15,6 +15,8 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; import { usePasteFileNotices } from '@src/course-outline/data/apiHooks'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '../../constants'; import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot'; import advancedSettingsMessages from '../../advanced-settings/messages'; import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; @@ -441,6 +443,9 @@ const PageAlerts = ({ {conflictingFilesPasteAlert()} {newFilesPasteAlert()} {renderOutOfSyncAlert()} + ); diff --git a/src/data/api.ts b/src/data/api.ts index 4f74a7196b..0bd1ff414b 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -208,3 +208,25 @@ export async function getPreviewModulestoreMigration( const { data } = await client.get(getPreviewModulestoreMigrationUrl(), { params }); return camelCaseObject(data); } + +export const getUserAgreementRecordApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement_record/${agreementType}`; + +export async function getUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export async function updateUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export const getUserAgreementApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement/${agreementType}/`; + +export async function getUserAgreement(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementApi(agreementType)); + return camelCaseObject(data); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 211b9eede5..b55d98143b 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -1,16 +1,19 @@ -import { - skipToken, useMutation, useQuery, useQueryClient, -} from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { UserAgreement, UserAgreementRecord } from '@src/data/types'; import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks'; import { - getWaffleFlags, - waffleFlagDefaults, - bulkModulestoreMigrate, - getModulestoreMigrationStatus, + skipToken, useMutation, useQueries, useQuery, useQueryClient, UseQueryOptions, +} from '@tanstack/react-query'; +import { BulkMigrateRequestData, + bulkModulestoreMigrate, getCourseDetails, - getPreviewModulestoreMigration, + getModulestoreMigrationStatus, + getPreviewModulestoreMigration, getUserAgreement, + getUserAgreementRecord, + getWaffleFlags, updateUserAgreementRecord, + waffleFlagDefaults, } from './api'; import { RequestStatus, RequestStatusType } from './constants'; @@ -165,3 +168,47 @@ export function createGlobalState( return { data, setData, resetData }; }; } + +export const getGatingAgreementTypes = (gatingTypes: string[]): string[] => ( + [...new Set( + gatingTypes + .flatMap(gatingType => getConfig().AGREEMENT_GATING?.[gatingType]) + .filter(item => Boolean(item)), + )] +); + +export const useUserAgreementRecord = (agreementType:string) => ( + useQuery({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + }) +); + +export const useUserAgreementRecords = (agreementTypes:string[]) => ( + useQueries({ + queries: agreementTypes.map>(agreementType => ({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + })), + }) +); + +export const useUserAgreementRecordUpdater = (agreementType:string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => updateUserAgreementRecord(agreementType), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agreement-record', agreementType] }); + }, + }); +}; + +export const useUserAgreement = (agreementType:string) => ( + useQuery({ + queryKey: ['agreements', agreementType], + queryFn: () => getUserAgreement(agreementType), + retry: false, + }) +); diff --git a/src/data/types.ts b/src/data/types.ts index c13205a6a0..7dd267f4f8 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -201,3 +201,19 @@ export type AccessManagedXBlockDataTypes = { onlineProctoringRules?: string; discussionEnabled?: boolean; }; + +export interface UserAgreementRecord { + username: string; + agreementType: string; + acceptedAt: string | null; + isCurrent: boolean; +} + +export interface UserAgreement { + type: string; + name: string; + summary: string; + hasText: boolean; + url: string; + updated: string; +} diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx index d71b90476e..1fc9e885eb 100644 --- a/src/files-and-videos/files-page/CourseFilesTable.tsx +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -1,5 +1,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { CheckboxFilter } from '@openedx/paragon'; +import { AgreementGated, UPLOAD_FILE_MAX_SIZE } from '@src/constants'; import { addAssetFile, deleteAssetFile, @@ -20,13 +21,13 @@ import { FileTable, ThumbnailColumn, } from '@src/files-and-videos/generic'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import { getFileSizeToClosestByte } from '@src/utils'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { UPLOAD_FILE_MAX_SIZE } from '@src/constants'; export const CourseFilesTable = () => { const intl = useIntl(); @@ -159,26 +160,28 @@ export const CourseFilesTable = () => { return null; } return ( - <> - - - + + <> + + + + ); }; diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 41af98f34e..210c43bdc1 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,7 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Container } from '@openedx/paragon'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; @@ -10,6 +10,8 @@ import Placeholder from '@src/editors/Placeholder'; import { RequestStatus } from '@src/data/constants'; import getPageHeadTitle from '@src/generic/utils'; import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '@src/constants'; import { EditFileErrors } from '../generic'; import { fetchAssets, resetErrors } from './data/thunks'; @@ -55,6 +57,9 @@ const FilesPage = () => { updateFileStatus={updateAssetStatus} loadingStatus={loadingStatus} /> +
{intl.formatMessage(messages.heading)} diff --git a/src/files-and-videos/videos-page/CourseVideosTable.tsx b/src/files-and-videos/videos-page/CourseVideosTable.tsx index d1667ef13f..fe14b2645b 100644 --- a/src/files-and-videos/videos-page/CourseVideosTable.tsx +++ b/src/files-and-videos/videos-page/CourseVideosTable.tsx @@ -2,6 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, CheckboxFilter, useToggle, } from '@openedx/paragon'; +import { AgreementGated } from '@src/constants'; import { RequestStatus } from '@src/data/constants'; import { ActiveColumn, @@ -29,6 +30,7 @@ import messages from '@src/files-and-videos/videos-page/messages'; import TranscriptSettings from '@src/files-and-videos/videos-page/transcript-settings'; import UploadModal from '@src/files-and-videos/videos-page/upload-modal'; import VideoThumbnail from '@src/files-and-videos/videos-page/VideoThumbnail'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import React, { useEffect, useRef } from 'react'; @@ -224,23 +226,24 @@ export const CourseVideosTable = () => { ]; return ( - <> - - - {isVideoTranscriptEnabled ? ( - - ) : null} - - { + + <> + + + {isVideoTranscriptEnabled ? ( + + ) : null} + + { loadingStatus !== RequestStatus.FAILED && ( <> {isVideoTranscriptEnabled && ( @@ -275,14 +278,15 @@ export const CourseVideosTable = () => { ) } - - + + + ); }; diff --git a/src/files-and-videos/videos-page/VideosPage.tsx b/src/files-and-videos/videos-page/VideosPage.tsx index 53d32322c8..b2faa26e07 100644 --- a/src/files-and-videos/videos-page/VideosPage.tsx +++ b/src/files-and-videos/videos-page/VideosPage.tsx @@ -1,4 +1,6 @@ -import { useEffect } from 'react'; +import { AgreementGated } from '@src/constants'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import React, { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; @@ -57,6 +59,9 @@ const VideosPage = () => { updateFileStatus={updateVideoStatus} loadingStatus={loadingStatus} /> +

{intl.formatMessage(messages.heading)}

diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx new file mode 100644 index 0000000000..78c525b66f --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx @@ -0,0 +1,142 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementApi, getUserAgreementRecordApi } from '@src/data/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + , + , + ); +} + +describe('AlertAgreementGatedFeature', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementApi('agreement1')).reply(200, { + type: 'agreement1', + name: 'agreement1', + summary: 'summary1', + has_text: true, + url: 'https://example.com/agreement1', + updated: '2023-01-01T00:00:00Z', + }); + axiosMock.onGet(getUserAgreementApi('agreement2')).reply(200, { + type: 'agreement2', + name: 'agreement2', + summary: 'summary2', + has_text: true, + url: 'https://example.com/agreement2', + }); + axiosMock.onGet(getUserAgreementApi('agreement3')).reply(404); + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, {}); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('renders no alerts when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when gatingTypes have no associated agreement', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when associated agreement does not exist', async () => { + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD_FILES]: ['agreement3'], + }, + }); + await renderComponent([AgreementGated.UPLOAD_FILES]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders an alert for each agreement type associated with the gating types', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('renders skips alerts for agreements that have already been accepted', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(1); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.queryByText('agreement2')).not.toBeInTheDocument(); + expect(screen.queryByText('summary2')).not.toBeInTheDocument(); + }); + + it('does not duplicate alert if multiple gating types have the same agreement type', async () => { + const gatingTypes = [AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('posts a request to mark acceptance when user clicks Agree', async () => { + const user = userEvent.setup(); + const gatingTypes = [AgreementGated.UPLOAD_VIDEOS]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + axiosMock.onPost(new RegExp(getUserAgreementRecordApi('*'))).reply(201, {}); + await user.click(screen.getByRole('button', { name: 'Agree' })); + expect(axiosMock.history.post[0].url).toBe(getUserAgreementRecordApi('agreement2')); + }); +}); diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx new file mode 100644 index 0000000000..6df1f30321 --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx @@ -0,0 +1,66 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Alert, Button, Hyperlink } from '@openedx/paragon'; +import { Policy } from '@openedx/paragon/icons'; +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreement, + useUserAgreementRecord, + useUserAgreementRecordUpdater, +} from '@src/data/apiHooks'; +import messages from './messages'; + +const AlertAgreement = ({ agreementType }: { agreementType: string }) => { + const intl = useIntl(); + const { data, isLoading, isError } = useUserAgreement(agreementType); + const mutation = useUserAgreementRecordUpdater(agreementType); + const showAlert = data && !isLoading && !isError; + const handleAcceptAgreement = async () => { + try { + await mutation.mutateAsync(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error accepting agreement', e); + } + }; + if (!showAlert) { return null; } + const { url, name, summary } = data; + return ( + {intl.formatMessage(messages.learnMoreLinkLabel)}, + , + ]} + > + {name} + {summary} + + ); +}; + +const AlertAgreementWrapper = ( + { agreementType }: { agreementType: string }, +) => { + const { data, isLoading, isError } = useUserAgreementRecord(agreementType); + const showAlert = !data?.isCurrent && !isLoading && !isError; + if (!showAlert) { return null; } + return ; +}; + +export const AlertAgreementGatedFeature = ( + { gatingTypes }: { gatingTypes: AgreementGated[] }, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + return ( + <> + {agreementTypes.map((agreementType) => ( + + ))} + + ); +}; diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx new file mode 100644 index 0000000000..bd00233f92 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx @@ -0,0 +1,79 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementRecordApi } from '@src/data/api'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature/GatedComponentWrapper'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + + + , + , + ); +} + +describe('GatedComponentWrapper', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + [AgreementGated.UPLOAD_FILES]: ['agreement1'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('applies no gating when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies no gating when associated agreement has been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_VIDEOS]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies gating when associated agreement has not been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx new file mode 100644 index 0000000000..bddd352e40 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx @@ -0,0 +1,30 @@ +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreementRecords, +} from '@src/data/apiHooks'; + +interface GatedComponentWrapperProps { + gatingTypes: AgreementGated[]; + children: React.ReactElement; +} + +export const GatedComponentWrapper = ( + { gatingTypes, children }: GatedComponentWrapperProps, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + const results = useUserAgreementRecords(agreementTypes); + const isNotGated = results.every((result) => !!result?.data?.isCurrent); + return isNotGated ? children : ( +
+ {children} +
+ ); +}; diff --git a/src/generic/agreement-gated-feature/index.ts b/src/generic/agreement-gated-feature/index.ts new file mode 100644 index 0000000000..9c3265f27e --- /dev/null +++ b/src/generic/agreement-gated-feature/index.ts @@ -0,0 +1,2 @@ +export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; +export { GatedComponentWrapper } from './GatedComponentWrapper'; diff --git a/src/generic/agreement-gated-feature/messages.ts b/src/generic/agreement-gated-feature/messages.ts new file mode 100644 index 0000000000..c17ac47c75 --- /dev/null +++ b/src/generic/agreement-gated-feature/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + agreeButtonLabel: { + id: 'authoring.agreement-gated-feature.agree', + defaultMessage: 'Agree', + description: 'The label for the Agree button on an alert asking users to agree with terms.', + }, + learnMoreLinkLabel: { + id: 'authoring.agreement-gated-feature.learn-more', + defaultMessage: 'Learn more', + description: 'The label for a "learn more" link on an alert asking users to agree with terms.', + }, +}); + +export default messages;