feat: adds agreement-gated feature with support across files and videos pages
Adds new generic components for gating certain features based on acceptance or acknowledgement of user agreements. It adds one alert that can be displayed where a feature (such as uploading) is blocked based on user agreeement, and it adds a wrapper component that disables the components inside it till the agreement has been accepted.
This commit is contained in:
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS, AgreementGated.UPLOAD_FILES]}
|
||||
/>
|
||||
<CourseOutlinePageAlertsSlot />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
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<UserAgreementRecord, Error>({
|
||||
queryKey: ['agreement-record', agreementType],
|
||||
queryFn: () => getUserAgreementRecord(agreementType),
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
|
||||
export const useUserAgreementRecords = (agreementTypes:string[]) => (
|
||||
useQueries({
|
||||
queries: agreementTypes.map<UseQueryOptions<UserAgreementRecord, Error>>(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<UserAgreement, Error>({
|
||||
queryKey: ['agreements', agreementType],
|
||||
queryFn: () => getUserAgreement(agreementType),
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleLockFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
infoModalSidebar,
|
||||
files: assets,
|
||||
}}
|
||||
/>
|
||||
<FileValidationModal {...{ handleFileOverwrite }} />
|
||||
</>
|
||||
<GatedComponentWrapper gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]}>
|
||||
<>
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleLockFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
infoModalSidebar,
|
||||
files: assets,
|
||||
}}
|
||||
/>
|
||||
<FileValidationModal {...{ handleFileOverwrite }} />
|
||||
</>
|
||||
</GatedComponentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]}
|
||||
/>
|
||||
<EditFileAlertsSlot />
|
||||
<div className="h2">
|
||||
{intl.formatMessage(messages.heading)}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
{isVideoTranscriptEnabled ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
openTranscriptSettings();
|
||||
handleErrorReset({ errorType: 'transcript' });
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.transcriptSettingsButtonLabel)}
|
||||
</Button>
|
||||
) : null}
|
||||
</ActionRow>
|
||||
{
|
||||
<GatedComponentWrapper gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS]}>
|
||||
<>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
{isVideoTranscriptEnabled ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
openTranscriptSettings();
|
||||
handleErrorReset({ errorType: 'transcript' });
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.transcriptSettingsButtonLabel)}
|
||||
</Button>
|
||||
) : null}
|
||||
</ActionRow>
|
||||
{
|
||||
loadingStatus !== RequestStatus.FAILED && (
|
||||
<>
|
||||
{isVideoTranscriptEnabled && (
|
||||
@@ -275,14 +278,15 @@ export const CourseVideosTable = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
<UploadModal
|
||||
{...{
|
||||
isUploadTrackerOpen,
|
||||
currentUploadingIdsRef: uploadingIdsRef.current,
|
||||
handleUploadCancel,
|
||||
addVideoStatus,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<UploadModal
|
||||
{...{
|
||||
isUploadTrackerOpen,
|
||||
currentUploadingIdsRef: uploadingIdsRef.current,
|
||||
handleUploadCancel,
|
||||
addVideoStatus,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</GatedComponentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS]}
|
||||
/>
|
||||
<EditVideoAlertsSlot />
|
||||
<h2>{intl.formatMessage(messages.heading)}</h2>
|
||||
<CourseVideosSlot />
|
||||
|
||||
@@ -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(
|
||||
<AppProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertAgreementGatedFeature gatingTypes={gatingTypes} />
|
||||
</QueryClientProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Alert
|
||||
variant="warning"
|
||||
icon={Policy}
|
||||
actions={[
|
||||
<Hyperlink destination={url}>{intl.formatMessage(messages.learnMoreLinkLabel)}</Hyperlink>,
|
||||
<Button onClick={handleAcceptAgreement}>{intl.formatMessage(messages.agreeButtonLabel)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert.Heading>{name}</Alert.Heading>
|
||||
{summary}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const AlertAgreementWrapper = (
|
||||
{ agreementType }: { agreementType: string },
|
||||
) => {
|
||||
const { data, isLoading, isError } = useUserAgreementRecord(agreementType);
|
||||
const showAlert = !data?.isCurrent && !isLoading && !isError;
|
||||
if (!showAlert) { return null; }
|
||||
return <AlertAgreement agreementType={agreementType} />;
|
||||
};
|
||||
|
||||
export const AlertAgreementGatedFeature = (
|
||||
{ gatingTypes }: { gatingTypes: AgreementGated[] },
|
||||
) => {
|
||||
const agreementTypes = getGatingAgreementTypes(gatingTypes);
|
||||
return (
|
||||
<>
|
||||
{agreementTypes.map((agreementType) => (
|
||||
<AlertAgreementWrapper
|
||||
key={agreementType}
|
||||
agreementType={agreementType}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
<AppProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GatedComponentWrapper gatingTypes={gatingTypes}>
|
||||
<button type="button">
|
||||
Test button
|
||||
</button>
|
||||
</GatedComponentWrapper>
|
||||
</QueryClientProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 : (
|
||||
<div
|
||||
aria-disabled
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
filter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/generic/agreement-gated-feature/index.ts
Normal file
2
src/generic/agreement-gated-feature/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature';
|
||||
export { GatedComponentWrapper } from './GatedComponentWrapper';
|
||||
16
src/generic/agreement-gated-feature/messages.ts
Normal file
16
src/generic/agreement-gated-feature/messages.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user