Compare commits
3 Commits
dependabot
...
kshitij/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11b1d9bac | ||
|
|
7157d17a4e | ||
|
|
65096fde0e |
27
package-lock.json
generated
27
package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
@@ -5159,6 +5160,10 @@
|
||||
"resolved": "plugins/course-apps/calculator",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openedx-plugins/course-app-dates": {
|
||||
"resolved": "plugins/course-apps/dates",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openedx-plugins/course-app-edxnotes": {
|
||||
"resolved": "plugins/course-apps/edxnotes",
|
||||
"link": true
|
||||
@@ -9356,9 +9361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001774",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
||||
"version": "1.0.30001775",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -24716,6 +24721,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins/course-apps/dates": {
|
||||
"name": "@openedx-plugins/course-app-dates",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins/course-apps/edxnotes": {
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
|
||||
29
plugins/course-apps/dates/Settings.tsx
Normal file
29
plugins/course-apps/dates/Settings.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
type DatesSettingsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const DatesSettings: React.FC<DatesSettingsProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="dates"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableAppHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableAppLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.learnMore)}
|
||||
onClose={onClose}
|
||||
validationSchema={{}}
|
||||
initialValues={{}}
|
||||
onSettingsSave={async () => true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatesSettings;
|
||||
26
plugins/course-apps/dates/messages.ts
Normal file
26
plugins/course-apps/dates/messages.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.dates.heading',
|
||||
defaultMessage: 'Configure dates',
|
||||
description: 'Heading for the Dates settings modal shown in Pages & Resources.',
|
||||
},
|
||||
enableAppLabel: {
|
||||
id: 'course-authoring.pages-resources.dates.enable-app.label',
|
||||
defaultMessage: 'Dates',
|
||||
description: 'Label for the toggle that enables the Dates experience.',
|
||||
},
|
||||
enableAppHelp: {
|
||||
id: 'course-authoring.pages-resources.dates.enable-app.help',
|
||||
defaultMessage: 'Show the Dates tab in course navigation, where learners can view important course dates.',
|
||||
description: 'Helper text explaining what enabling the Dates experience does.',
|
||||
},
|
||||
learnMore: {
|
||||
id: 'course-authoring.pages-resources.dates.learn-more',
|
||||
defaultMessage: 'Learn more about dates',
|
||||
description: 'Link text that leads to documentation about the Dates experience.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
17
plugins/course-apps/dates/package.json
Normal file
17
plugins/course-apps/dates/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-dates",
|
||||
"version": "0.1.0",
|
||||
"description": "Dates configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -17,7 +17,7 @@ const SettingsComponent = ({ url }) => {
|
||||
|
||||
const LazyLoadedComponent = React.useMemo(
|
||||
() => React.lazy(() =>
|
||||
import(`@openedx-plugins/course-app-${appId}/Settings.jsx`).catch((err) => { // eslint-disable-line
|
||||
import(`@openedx-plugins/course-app-${appId}/Settings`).catch((err) => { // eslint-disable-line
|
||||
// If we couldn't load this plugin, log the details to the console.
|
||||
console.trace(err); // eslint-disable-line no-console
|
||||
return { default: PluginLoadFailedError };
|
||||
|
||||
Reference in New Issue
Block a user