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:
kshitij.sobti
2026-01-28 15:46:41 +05:30
parent 7157d17a4e
commit e11b1d9bac
15 changed files with 506 additions and 58 deletions

View File

@@ -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',
}

View File

@@ -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 />
</>
);

View File

@@ -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);
}

View File

@@ -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,
})
);

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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)}

View File

@@ -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>
);
};

View File

@@ -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 />

View File

@@ -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'));
});
});

View File

@@ -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}
/>
))}
</>
);
};

View File

@@ -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');
});
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature';
export { GatedComponentWrapper } from './GatedComponentWrapper';

View 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;