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

{intl.formatMessage(messages.heading)}

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