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