Compare commits

..

1 Commits

Author SHA1 Message Date
Alie Langston
8a5687bfe3 feat: gate learning assistant if learner is in exam 2023-11-13 14:36:49 -06:00
264 changed files with 12448 additions and 10305 deletions

2
.env
View File

@@ -4,7 +4,6 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
AI_TRANSLATIONS_URL=''
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''
@@ -14,7 +13,6 @@ DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''

View File

@@ -4,7 +4,6 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -14,7 +13,6 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''

View File

@@ -4,7 +4,6 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -14,7 +13,6 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'

View File

@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', {
rules: {
@@ -12,13 +12,6 @@ const config = createConfig('eslint', {
'react/no-unknown-property': 'off',
'func-names': 'off',
},
settings: {
'import/resolver': {
webpack: {
config: 'webpack.prod.config.js',
},
},
},
});
module.exports = config;

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

5
.gitignore vendored
View File

@@ -6,7 +6,6 @@
node_modules
npm-debug.log
coverage
env.config.*
dist/
src/i18n/transifex_input.json
@@ -26,7 +25,3 @@ module.config.js
# Local environment overrides
.env.private
src/i18n/messages/
env.config.jsx

9
.tx/config Normal file
View File

@@ -0,0 +1,9 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-learning]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,17 +1,20 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
precommit:
npm run lint
npm audit
requirements:
npm ci
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -29,21 +32,35 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,6 +1,6 @@
#####################
frontend-app-learning
#####################
######################
|codecov| |license|
@@ -84,10 +84,10 @@ file (which is git-ignored) that defines where to find your local modules, for i
may want to use "src" if the module installs React as a peer/dev dependency.
*/
localModules: [
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
],
};

View File

@@ -1,18 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-learning'
description: "This is the Learning MFE, which renders all learner-facing course pages."
links:
- url: "https://github.com/openedx/frontend-app-learning"
title: "Learning MFE"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:2u-aurora
type: 'website'
lifecycle: 'production'

View File

@@ -1,24 +0,0 @@
import UnitTranslationPlugin from '@plugins/UnitTranslationPlugin';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
// Load environment variables from .env file
const config = {
...process.env,
pluginSlots: {
unit_title_plugin: {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'unit_title_plugin',
type: DIRECT_PLUGIN,
priority: 1,
RenderWidget: UnitTranslationPlugin,
},
},
],
},
},
};
export default config;

View File

@@ -1,4 +0,0 @@
// Force all tests to run in UTC to prevent tests from being sensitive to host timezone.
module.exports = async () => {
process.env.TZ = 'UTC';
};

View File

@@ -1,6 +1,6 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('jest', {
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
@@ -14,31 +14,7 @@ const config = createConfig('jest', {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
'@src/(.*)': '<rootDir>/src/$1',
'@plugins/(.*)': '<rootDir>/plugins/$1',
},
testTimeout: 30000,
globalSetup: "./global-setup.js",
verbose: true,
testEnvironment: 'jsdom',
testEnvironment: 'jsdom'
});
// delete config.testURL;
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
// change this setting if need to see less details for each test
// reportType: "summary" | "details",
// enable: true | false,
afterEachTest: {
enable: true,
filePaths: false,
reportType: "details",
},
afterAllTests: {
reportType: "summary",
enable: true,
filePaths: true,
},
}]];
module.exports = config;

8493
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
@@ -29,23 +29,19 @@
"url": "https://github.com/openedx/frontend-app-learning/issues"
},
"dependencies": {
"@datadog/browser-logs": "^5.14.0",
"@datadog/browser-rum": "^5.14.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^13.0.4",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-lib-learning-assistant": "^2.0.0",
"@edx/frontend-lib-special-exams": "^3.0.0",
"@edx/frontend-platform": "^7.1.2",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.17.0",
"@edx/frontend-lib-special-exams": "2.23.3",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
@@ -71,8 +67,8 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "13.0.30",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
@@ -81,16 +77,8 @@
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"eslint-import-resolver-webpack": "^0.13.8",
"husky": "7.0.4",
"jest": "^26.6.3",
"jest-console-group-reporter": "^1.0.1",
"jest-when": "^3.6.0",
"postcss-loader": "^8.1.1",
"rosie": "2.1.1",
"sass": "^1.72.0",
"sass-loader": "^14.1.1",
"source-map-loader": "^5.0.0",
"style-loader": "^3.3.4"
"jest": "29.5.0",
"rosie": "2.1.0"
}
}

View File

@@ -1,17 +0,0 @@
## How to develop plugin
You can define plugin in `env.config.jsx` see `example.env.config.jsx` as example.
## Current caveat
- The way for how I deal with override method is still wonky
- The redux still require middleware to ignore the plugin's action from serializing
- I am not sure how it behave with useCallback, useMemo, ...etc
- There are still open question on how to write it properly
## Current work that should consider core part and extendable for the future plugin framework
- `usePluingsCallback` is the callback supose to be some level of equality to be using `React.useCallback`. It would try to execute the function, then any plugin that try `registerOverrideMethod`. The order of the it being run isn't the determined. There are a couple things I want to add:
- I might consider testing it with `zustand` library to make sure it is portable and not rely on `redux`. I tried to do this with provider, but it seems to run into infinite loop of trigger changed.
- `registerOverrideMethod` is working like a way to register callback that behave like a middleware. It ran the default one, then pass the result of the default one to the plugin. Any plugin that register the override can update the value. Alternatively, we can override the function completely instead applying each affect. Or we can support both. But it requires a bit more thought out architecture.

View File

@@ -1,15 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UnitTranslationPlugin /> render TranslationSelection when translation is enabled and language is available 1`] = `
<TranslationSelection
availableLanguages={
Array [
"en",
]
}
courseId="courseId"
id="id"
language="en"
unitId="unitId"
/>
`;

View File

@@ -1,90 +0,0 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
export const fetchTranslationConfig = async (courseId) => {
const url = `${
getConfig().LMS_BASE_URL
}/api/translatable_xblocks/config/?course_id=${encodeURIComponent(courseId)}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return {
enabled: data.feature_enabled,
availableLanguages: data.available_translation_languages || [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
} catch (error) {
logError(`Translation plugin fail to fetch from ${url}`, error);
return {
enabled: false,
availableLanguages: [],
};
}
};
export async function getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
}) {
const params = stringify({
translation_language: translationLanguage,
course_id: encodeURIComponent(courseId),
unit_id: encodeURIComponent(unitId),
user_id: userId,
});
const fetchFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback?${params}`;
try {
const { data } = await getAuthenticatedHttpClient().get(fetchFeedbackUrl);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to fetch from ${fetchFeedbackUrl}`,
error,
);
return {};
}
}
export async function createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
}) {
const createFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback/`;
try {
const { data } = await getAuthenticatedHttpClient().post(
createFeedbackUrl,
{
course_id: courseId,
feedback_value: feedbackValue,
translation_language: translationLanguage,
unit_id: unitId,
user_id: userId,
},
);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to create feedback from ${createFeedbackUrl}`,
error,
);
return {};
}
}

View File

@@ -1,125 +0,0 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
import {
fetchTranslationConfig,
getTranslationFeedback,
createTranslationFeedback,
} from './api';
const mockGetMethod = jest.fn();
const mockPostMethod = jest.fn();
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: () => ({
get: mockGetMethod,
post: mockPostMethod,
}),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
describe('UnitTranslation api', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('fetchTranslationConfig', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedResponse = {
feature_enabled: true,
available_translation_languages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('should fetch translation config', async () => {
const expectedUrl = `http://localhost:18000/api/translatable_xblocks/config/?course_id=${encodeURIComponent(
courseId,
)}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: true,
availableLanguages: expectedResponse.available_translation_languages,
});
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return disabled and unavailable languages on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: false,
availableLanguages: [],
});
expect(logError).toHaveBeenCalled();
});
});
describe('getTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
const expectedResponse = {
feedback: 'good',
};
it('should fetch translation feedback', async () => {
const params = stringify({
translation_language: props.translationLanguage,
course_id: encodeURIComponent(props.courseId),
unit_id: encodeURIComponent(props.unitId),
user_id: props.userId,
});
const expectedUrl = `http://localhost:18760/api/v1/whole-course-translation-feedback?${params}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await getTranslationFeedback(props);
expect(result).toEqual(camelCaseObject(expectedResponse));
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return empty object on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await getTranslationFeedback(props);
expect(result).toEqual({});
expect(logError).toHaveBeenCalled();
});
});
describe('createTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
feedbackValue: 'good',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
it('should create translation feedback', async () => {
const expectedUrl = 'http://localhost:18760/api/v1/whole-course-translation-feedback/';
mockPostMethod.mockResolvedValueOnce({});
await createTranslationFeedback(props);
expect(mockPostMethod).toHaveBeenCalledWith(expectedUrl, {
course_id: props.courseId,
feedback_value: props.feedbackValue,
translation_language: props.translationLanguage,
unit_id: props.unitId,
user_id: props.userId,
});
});
it('should log error on failure', async () => {
mockPostMethod.mockRejectedValueOnce(new Error('error'));
await createTranslationFeedback(props);
expect(logError).toHaveBeenCalled();
});
});
});

View File

@@ -1,204 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FeedbackWidget /> render feedback widget 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> render gratitude text 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders hidden by default 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders show when elemReady is true 1`] = `
<div
className="sequence-container d-inline-flex flex-row w-100"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;

View File

@@ -1,116 +0,0 @@
import React, {
useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, IconButton, Icon } from '@openedx/paragon';
import { Close, ThumbUpOutline, ThumbDownOffAlt } from '@openedx/paragon/icons';
import './index.scss';
import messages from './messages';
import useFeedbackWidget from './useFeedbackWidget';
const FeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const { formatMessage } = useIntl();
const ref = useRef(null);
const [elemReady, setElemReady] = useState(false);
const {
closeFeedbackWidget,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
} = useFeedbackWidget({
courseId,
translationLanguage,
unitId,
userId,
});
useEffect(() => {
if (ref.current) {
const domNode = document.getElementById('whole-course-translation-feedback-widget');
domNode.appendChild(ref.current);
setElemReady(true);
}
}, [ref.current]);
return (
<div ref={ref} className={(elemReady) ? 'sequence-container d-inline-flex flex-row w-100' : 'd-none'}>
{(showFeedbackWidget || showGratitudeText) ? (
<div className="sequence w-100">
{
showFeedbackWidget && (
<div className="ml-4 mr-2">
<ActionRow>
{formatMessage(messages.rateTranslationText)}
<ActionRow.Spacer />
<div>
<IconButton
src={ThumbUpOutline}
iconAs={Icon}
alt="positive-feedback"
onClick={onThumbsUpClick}
variant="secondary"
className="m-1"
id="positive-feedback-button"
/>
<IconButton
src={ThumbDownOffAlt}
iconAs={Icon}
alt="negative-feedback"
onClick={onThumbsDownClick}
variant="secondary"
className="mr-2"
id="negative-feedback-button"
/>
</div>
<div className="mb-1 text-light action-row-divider">
|
</div>
<div>
<IconButton
src={Close}
iconAs={Icon}
alt="close-feedback"
onClick={closeFeedbackWidget}
variant="secondary"
className="ml-1 mr-2 float-right"
id="close-feedback-button"
/>
</div>
</ActionRow>
</div>
)
}
{
showGratitudeText && (
<div className="ml-4 mr-4">
<ActionRow className="m-2 justify-content-center">
{formatMessage(messages.gratitudeText)}
</ActionRow>
</div>
)
}
</div>
) : null}
</div>
);
};
FeedbackWidget.propTypes = {
courseId: PropTypes.string.isRequired,
translationLanguage: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
FeedbackWidget.defaultProps = {};
export default FeedbackWidget;

View File

@@ -1,4 +0,0 @@
.action-row-divider {
font-size: 31px;
font-weight: 100;
}

View File

@@ -1,107 +0,0 @@
import { useState } from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import FeedbackWidget from './index';
import useFeedbackWidget from './useFeedbackWidget';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn((value) => [value, jest.fn()]),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
ActionRow: {
Spacer: 'Spacer',
},
IconButton: 'IconButton',
Icon: 'Icon',
}));
jest.mock('@openedx/paragon/icons', () => ({
Close: 'Close',
ThumbUpOutline: 'ThumbUpOutline',
ThumbDownOffAlt: 'ThumbDownOffAlt',
}));
jest.mock('./useFeedbackWidget');
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('<FeedbackWidget />', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId:
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@37b72b3915204b70acb00c55b604b563',
userId: '123',
};
const mockUseFeedbackWidget = ({ showFeedbackWidget, showGratitudeText }) => {
useFeedbackWidget.mockReturnValueOnce({
closeFeedbackWidget: jest.fn().mockName('closeFeedbackWidget'),
sendFeedback: jest.fn().mockName('sendFeedback'),
onThumbsUpClick: jest.fn().mockName('onThumbsUpClick'),
onThumbsDownClick: jest.fn().mockName('onThumbsDownClick'),
showFeedbackWidget,
showGratitudeText,
});
};
it('renders hidden by default', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).toContain(
'd-none',
);
});
it('renders show when elemReady is true', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).not.toContain(
'd-none',
);
});
it('render empty when showFeedbackWidget and showGratitudeText are false', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: false,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.instance.findByType('div')[0].children.length).toBe(0);
});
it('render feedback widget', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: false,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
it('render gratitude text', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rateTranslationText: {
id: 'feedbackWidget.rateTranslationText',
defaultMessage: 'Rate this page translation',
description: 'Title for the feedback widget action row.',
},
gratitudeText: {
id: 'feedbackWidget.gratitudeText',
defaultMessage: 'Thank you! Your feedback matters.',
description: 'Title for secondary action row.',
},
});
export default messages;

View File

@@ -1,82 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
const useFeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const [showFeedbackWidget, setShowFeedbackWidget] = useState(false);
const [showGratitudeText, setShowGratitudeText] = useState(false);
const closeFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(false);
}, [setShowFeedbackWidget]);
const openFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(true);
}, [setShowFeedbackWidget]);
useEffect(async () => {
const translationFeedback = await getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
});
setShowFeedbackWidget(!translationFeedback);
}, [
courseId,
translationLanguage,
unitId,
userId,
]);
const openGratitudeText = useCallback(() => {
setShowGratitudeText(true);
setTimeout(() => {
setShowGratitudeText(false);
}, 3000);
}, [setShowGratitudeText]);
const sendFeedback = useCallback(async (feedbackValue) => {
await createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
});
closeFeedbackWidget();
openGratitudeText();
}, [
courseId,
translationLanguage,
unitId,
userId,
closeFeedbackWidget,
openGratitudeText,
]);
const onThumbsUpClick = useCallback(() => {
sendFeedback(true);
}, [sendFeedback]);
const onThumbsDownClick = useCallback(() => {
sendFeedback(false);
}, [sendFeedback]);
return {
closeFeedbackWidget,
openFeedbackWidget,
openGratitudeText,
sendFeedback,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
};
};
export default useFeedbackWidget;

View File

@@ -1,163 +0,0 @@
import { renderHook, act } from '@testing-library/react-hooks';
import useFeedbackWidget from './useFeedbackWidget';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
jest.mock('../data/api', () => ({
createTranslationFeedback: jest.fn(),
getTranslationFeedback: jest.fn(),
}));
const initialProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
const newProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'fr',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
describe('useFeedbackWidget', () => {
beforeEach(async () => {
getTranslationFeedback.mockReturnValue('');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('closeFeedbackWidget behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
});
test('openFeedbackWidget behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
act(() => {
result.current.openFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(true);
});
test('openGratitudeText behavior', async () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.openGratitudeText();
});
expect(result.current.showGratitudeText).toBe(true);
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('sendFeedback behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
const feedbackValue = true;
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.sendFeedback(feedbackValue);
});
waitFor(() => {
expect(result.current.showFeedbackWidget).toBe(false);
expect(result.current.showGratitudeText).toBe(true);
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('onThumbsUpClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsUpClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: true,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('onThumbsDownClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsDownClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: false,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('fetch feedback on initialization', () => {
const { waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
});
test('fetch feedback on props update', () => {
const { rerender, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
rerender(newProps);
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: newProps.courseId,
translationLanguage: newProps.translationLanguage,
unitId: newProps.unitId,
userId: newProps.userId,
});
});
});
});

View File

@@ -1,43 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useModel } from '@src/generic/model-store';
import TranslationSelection from './translation-selection';
import { fetchTranslationConfig } from './data/api';
const UnitTranslationPlugin = ({ id, courseId, unitId }) => {
const { language } = useModel('coursewareMeta', courseId);
const [translationConfig, setTranslationConfig] = useState({
enabled: false,
availableLanguages: [],
});
useEffect(() => {
fetchTranslationConfig(courseId).then(setTranslationConfig);
}, []);
const { enabled, availableLanguages } = translationConfig;
if (!enabled || !language || !availableLanguages.length) {
return null;
}
return (
<TranslationSelection
id={id}
courseId={courseId}
language={language}
availableLanguages={availableLanguages}
unitId={unitId}
/>
);
};
UnitTranslationPlugin.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
export default UnitTranslationPlugin;

View File

@@ -1,62 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import { useState } from 'react';
import { useModel } from '@src/generic/model-store';
import UnitTranslationPlugin from './index';
jest.mock('@src/generic/model-store');
jest.mock('./data/api', () => ({
fetchTranslationConfig: jest.fn(),
}));
jest.mock('./translation-selection', () => 'TranslationSelection');
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
describe('<UnitTranslationPlugin />', () => {
const props = {
id: 'id',
courseId: 'courseId',
unitId: 'unitId',
};
const mockInitialState = ({ enabled = true, availableLanguages = ['en'] }) => {
useState.mockReturnValue([{ enabled, availableLanguages }, jest.fn()]);
};
it('render empty when translation is not enabled', () => {
useModel.mockReturnValue({ language: 'en' });
mockInitialState({ enabled: false });
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when available languages is empty', () => {
useModel.mockReturnValue({ language: 'fr' });
mockInitialState({
availableLanguages: [],
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when course language has not been set', () => {
useModel.mockReturnValue({ language: undefined });
mockInitialState({});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render TranslationSelection when translation is enabled and language is available', () => {
useModel.mockReturnValue({ language: 'en' });
mockInitialState({});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,82 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
StandardModal,
ActionRow,
Button,
Icon,
ListBox,
ListBoxOption,
} from '@openedx/paragon';
import { Check } from '@openedx/paragon/icons';
import useTranslationModal from './useTranslationModal';
import messages from './messages';
import './TranslationModal.scss';
const TranslationModal = ({
isOpen,
close,
selectedLanguage,
setSelectedLanguage,
availableLanguages,
}) => {
const { formatMessage } = useIntl();
const { selectedIndex, setSelectedIndex, onSubmit } = useTranslationModal({
selectedLanguage,
setSelectedLanguage,
close,
availableLanguages,
});
return (
<StandardModal
title={formatMessage(messages.languageSelectionModalTitle)}
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<ActionRow.Spacer />
<Button variant="tertiary" onClick={close}>
{formatMessage(messages.cancelButtonText)}
</Button>
<Button onClick={onSubmit}>
{formatMessage(messages.submitButtonText)}
</Button>
</ActionRow>
)}
>
<ListBox className="listbox-container">
{availableLanguages.map(({ code, label }, index) => (
<ListBoxOption
className="d-flex justify-content-between"
key={code}
selectedOptionIndex={selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{label}
{selectedIndex === index && <Icon src={Check} />}
</ListBoxOption>
))}
</ListBox>
</StandardModal>
);
};
TranslationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
selectedLanguage: PropTypes.string.isRequired,
setSelectedLanguage: PropTypes.func.isRequired,
availableLanguages: PropTypes.arrayOf(
PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
).isRequired,
};
export default TranslationModal;

View File

@@ -1,7 +0,0 @@
.listbox-container {
max-height: 400px;
:last-child {
margin-bottom: 5px;
}
}

View File

@@ -1,59 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import TranslationModal from './TranslationModal';
jest.mock('./useTranslationModal', () => ({
__esModule: true,
default: () => ({
selectedIndex: 0,
setSelectedIndex: jest.fn(),
onSubmit: jest.fn().mockName('onSubmit'),
}),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
StandardModal: 'StandardModal',
ActionRow: {
Spacer: 'Spacer',
},
Button: 'Button',
Icon: 'Icon',
ListBox: 'ListBox',
ListBoxOption: 'ListBoxOption',
}));
jest.mock('@openedx/paragon/icons', () => ({
Check: jest.fn().mockName('icons.Check'),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('TranslationModal', () => {
const props = {
isOpen: true,
close: jest.fn().mockName('close'),
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('renders correctly', () => {
const wrapper = shallow(<TranslationModal {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('ListBoxOption')).toHaveLength(2);
});
});

View File

@@ -1,49 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TranslationModal renders correctly 1`] = `
<StandardModal
footerNode={
<ActionRow>
<Spacer />
<Button
onClick={[MockFunction close]}
variant="tertiary"
>
Cancel
</Button>
<Button
onClick={[MockFunction onSubmit]}
>
Submit
</Button>
</ActionRow>
}
isOpen={true}
onClose={[MockFunction close]}
title="Translate this course"
>
<ListBox
className="listbox-container"
>
<ListBoxOption
className="d-flex justify-content-between"
key="en"
onSelect={[Function]}
selectedOptionIndex={0}
>
English
<Icon
src={[MockFunction icons.Check]}
/>
</ListBoxOption>
<ListBoxOption
className="d-flex justify-content-between"
key="es"
onSelect={[Function]}
selectedOptionIndex={0}
>
Spanish
</ListBoxOption>
</ListBox>
</StandardModal>
`;

View File

@@ -1,50 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TranslationSelection /> renders 1`] = `
<Fragment>
<ProductTour
tours={
Array [
Object {
"abitrarily": "defined",
},
]
}
/>
<IconButton
alt="change-language"
className="mr-2 mb-2 float-right"
iconAs="Icon"
id="translation-selection-button"
onClick={[MockFunction open]}
src="Language"
variant="primary"
/>
<TranslationModal
availableLanguages={
Array [
Object {
"code": "en",
"label": "English",
},
Object {
"code": "es",
"label": "Spanish",
},
]
}
close={[MockFunction close]}
courseId="course-v1:edX+DemoX+Demo_Course"
id="plugin-test-id"
isOpen={false}
selectedLanguage="en"
setSelectedLanguage={[MockFunction setSelectedLanguage]}
/>
<FeedbackWidget
courseId="course-v1:edX+DemoX+Demo_Course"
translationLanguage="en"
unitId="unit-test-id"
userId="123"
/>
</Fragment>
`;

View File

@@ -1,100 +0,0 @@
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { IconButton, Icon, ProductTour } from '@openedx/paragon';
import { Language } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
import { stringifyUrl } from 'query-string';
import { registerOverrideMethod } from '@src/generic/plugin-store';
import TranslationModal from './TranslationModal';
import useTranslationTour from './useTranslationTour';
import useSelectLanguage from './useSelectLanguage';
import FeedbackWidget from '../feedback-widget';
const TranslationSelection = ({
id, courseId, language, availableLanguages, unitId,
}) => {
const {
authenticatedUser: { userId },
} = useContext(AppContext);
const dispatch = useDispatch();
const {
translationTour, isOpen, open, close,
} = useTranslationTour();
const { selectedLanguage, setSelectedLanguage } = useSelectLanguage({
courseId,
language,
});
useEffect(() => {
dispatch(
registerOverrideMethod({
pluginName: id,
methodName: 'getIFrameUrl',
method: (iframeUrl) => {
const finalUrl = stringifyUrl({
url: iframeUrl,
query: {
...(language
&& selectedLanguage
&& language !== selectedLanguage && {
src_lang: language,
dest_lang: selectedLanguage,
}),
},
});
return finalUrl;
},
}),
);
}, [language, selectedLanguage]);
return (
<>
<ProductTour tours={[translationTour]} />
<IconButton
src={Language}
iconAs={Icon}
alt="change-language"
onClick={open}
variant="primary"
className="mr-2 mb-2 float-right"
id="translation-selection-button"
/>
<TranslationModal
isOpen={isOpen}
close={close}
courseId={courseId}
selectedLanguage={selectedLanguage}
setSelectedLanguage={setSelectedLanguage}
availableLanguages={availableLanguages}
id={id}
/>
<FeedbackWidget
courseId={courseId}
translationLanguage={selectedLanguage}
unitId={unitId}
userId={userId}
/>
</>
);
};
TranslationSelection.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
language: PropTypes.string.isRequired,
availableLanguages: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})).isRequired,
};
TranslationSelection.defaultProps = {};
export default TranslationSelection;

View File

@@ -1,63 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import TranslationSelection from './index';
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn().mockName('useContext').mockReturnValue({
authenticatedUser: {
userId: '123',
},
}),
}));
jest.mock('@openedx/paragon', () => ({
IconButton: 'IconButton',
Icon: 'Icon',
ProductTour: 'ProductTour',
}));
jest.mock('@openedx/paragon/icons', () => ({
Language: 'Language',
}));
jest.mock('./useTranslationTour', () => () => ({
translationTour: {
abitrarily: 'defined',
},
isOpen: false,
open: jest.fn().mockName('open'),
close: jest.fn().mockName('close'),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn().mockName('useDispatch'),
}));
jest.mock('@src/generic/plugin-store', () => ({
registerOverrideMethod: jest.fn().mockName('registerOverrideMethod'),
}));
jest.mock('./TranslationModal', () => 'TranslationModal');
jest.mock('./useSelectLanguage', () => () => ({
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
}));
jest.mock('../feedback-widget', () => 'FeedbackWidget');
describe('<TranslationSelection />', () => {
const props = {
id: 'plugin-test-id',
courseId: 'course-v1:edX+DemoX+Demo_Course',
language: 'en',
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
unitId: 'unit-test-id',
};
it('renders', () => {
const wrapper = shallow(<TranslationSelection {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,41 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
translationTourModalTitle: {
id: 'translationSelection.translationTourModalTitle',
defaultMessage: 'This is a standard modal dialog',
description: 'Title for the translation modal.',
},
translationTourModalBody: {
id: 'translationSelection.translationTourModalBody',
defaultMessage: 'Now you can easily translate course content.',
description: 'Body for the translation modal.',
},
tryItButtonText: {
id: 'translationSelection.tryItButtonText',
defaultMessage: 'Try it',
description: 'Button text for the translation modal.',
},
dismissButtonText: {
id: 'translationSelection.dismissButtonText',
defaultMessage: 'Dismiss',
description: 'Button text for the translation modal.',
},
languageSelectionModalTitle: {
id: 'translationSelection.languageSelectionModalTitle',
defaultMessage: 'Translate this course',
description: 'Title for the translation modal.',
},
cancelButtonText: {
id: 'translationSelection.cancelButtonText',
defaultMessage: 'Cancel',
description: 'Button text for the translation modal.',
},
submitButtonText: {
id: 'translationSelection.submitButtonText',
defaultMessage: 'Submit',
description: 'Button text for the translation modal.',
},
});
export default messages;

View File

@@ -1,35 +0,0 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
export const selectedLanguageKey = 'selectedLanguages';
export const stateKeys = StrictDict({
selectedLanguage: 'selectedLanguage',
});
const useSelectLanguage = ({ courseId, language }) => {
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
const [selectedLanguage, updateSelectedLanguage] = useKeyedState(
stateKeys.selectedLanguage,
selectedLanguageItem[courseId] || language,
);
const setSelectedLanguage = useCallback((newSelectedLanguage) => {
setLocalStorage(selectedLanguageKey, {
...selectedLanguageItem,
[courseId]: newSelectedLanguage,
});
updateSelectedLanguage(newSelectedLanguage);
});
return {
selectedLanguage,
setSelectedLanguage,
};
};
export default useSelectLanguage;

View File

@@ -1,63 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
import useSelectLanguage, {
stateKeys,
selectedLanguageKey,
} from './useSelectLanguage';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => [
cb(...args),
{ cb, prereqs },
]),
}));
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
describe('useSelectLanguage', () => {
const props = {
courseId: 'test-course-id',
language: 'en',
};
const languages = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
];
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
languages.forEach(({ code, label }) => {
it(`initializes selectedLanguage to the selected language (${label})`, () => {
getLocalStorage.mockReturnValueOnce({ [props.courseId]: code });
const { selectedLanguage } = useSelectLanguage(props);
state.expectInitializedWith(stateKeys.selectedLanguage, code);
expect(selectedLanguage).toBe(code);
});
});
test('setSelectedLanguage behavior', () => {
const { setSelectedLanguage } = useSelectLanguage(props);
setSelectedLanguage('es');
state.expectSetStateCalledWith(stateKeys.selectedLanguage, 'es');
expect(setLocalStorage).toHaveBeenCalledWith(selectedLanguageKey, {
[props.courseId]: 'es',
});
});
});

View File

@@ -1,29 +0,0 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
export const stateKeys = StrictDict({
selectedIndex: 'selectedIndex',
});
const useTranslationModal = ({
selectedLanguage, setSelectedLanguage, close, availableLanguages,
}) => {
const [selectedIndex, setSelectedIndex] = useKeyedState(
stateKeys.selectedIndex,
availableLanguages.findIndex((lang) => lang.code === selectedLanguage),
);
const onSubmit = useCallback(() => {
const newSelectedLanguage = availableLanguages[selectedIndex].code;
setSelectedLanguage(newSelectedLanguage);
close();
}, [selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
onSubmit,
};
};
export default useTranslationModal;

View File

@@ -1,49 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import useTranslationModal, { stateKeys } from './useTranslationModal';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => ([
cb(...args), { cb, prereqs },
])),
}));
describe('useTranslationModal', () => {
const props = {
selectedLanguage: 'en',
setSelectedLanguage: jest.fn(),
close: jest.fn(),
availableLanguages: [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
],
};
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
it('initializes selectedIndex to the index of the selected language', () => {
const { selectedIndex } = useTranslationModal(props);
state.expectInitializedWith(stateKeys.selectedIndex, 0);
expect(selectedIndex).toBe(0);
});
it('onSubmit updates the selected language and closes the modal', () => {
const { onSubmit } = useTranslationModal({
...props,
selectedLanguage: 'es',
});
onSubmit();
expect(props.setSelectedLanguage).toHaveBeenCalledWith('es');
expect(props.close).toHaveBeenCalled();
});
});

View File

@@ -1,62 +0,0 @@
import { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import messages from './messages';
const hasSeenTranslationTourKey = 'hasSeenTranslationTour';
export const stateKeys = StrictDict({
showTranslationTour: 'showTranslationTour',
});
const useTranslationTour = () => {
const { formatMessage } = useIntl();
const [isTourEnabled, setIsTourEnabled] = useKeyedState(
stateKeys.showTranslationTour,
global.localStorage.getItem(hasSeenTranslationTourKey) !== 'true',
);
const [isOpen, open, close] = useToggle(false);
const endTour = useCallback(() => {
global.localStorage.setItem(hasSeenTranslationTourKey, 'true');
setIsTourEnabled(false);
}, [isTourEnabled, setIsTourEnabled]);
const tryIt = useCallback(() => {
endTour();
open();
}, [endTour, open]);
const translationTour = isTourEnabled
? {
tourId: 'translation',
enabled: isTourEnabled,
onDismiss: endTour,
onEnd: tryIt,
checkpoints: [
{
title: formatMessage(messages.translationTourModalTitle),
body: formatMessage(messages.translationTourModalBody),
placement: 'bottom',
target: '#translation-selection-button',
showDismissButton: true,
endButtonText: formatMessage(messages.tryItButtonText),
dismissButtonText: formatMessage(messages.dismissButtonText),
},
],
}
: {};
return {
translationTour,
isOpen,
open,
close,
};
};
export default useTranslationTour;

View File

@@ -1,95 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useToggle } from '@openedx/paragon';
import useTranslationTour, { stateKeys } from './useTranslationTour';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => () => {
cb();
return { useCallback: { cb, prereqs } };
}),
}));
jest.mock('@openedx/paragon', () => ({
useToggle: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
// this provide consistent for the test on different platform/timezone
const formatDate = jest.fn(date => new Date(date).toISOString()).mockName('useIntl.formatDate');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
formatDate,
})),
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
};
});
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useTranslationSelection', () => {
const mockLocalStroage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
const toggleOpen = jest.fn();
const toggleClose = jest.fn();
useToggle.mockReturnValue([false, toggleOpen, toggleClose]);
beforeEach(() => {
jest.clearAllMocks();
state.mock();
window.localStorage = mockLocalStroage;
});
afterEach(() => {
state.resetVals();
delete window.localStorage;
});
it('do not have translation tour if user already seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
expect(translationTour.enabled).toBe(true);
});
it('show translation tour if user has not seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('true');
const { translationTour } = useTranslationTour();
expect(translationTour).toMatchObject({});
});
test('open and close as pass from useToggle', () => {
const { isOpen, open, close } = useTranslationTour();
expect(isOpen).toBe(false);
expect(toggleOpen).toBe(open);
expect(toggleClose).toBe(close);
});
test('end tour on dismiss button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onDismiss();
expect(mockLocalStroage.setItem).toHaveBeenCalledWith(
'hasSeenTranslationTour',
'true',
);
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
});
test('end tour and open modal on try it button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onEnd();
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
expect(toggleOpen).toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,7 @@
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}

View File

@@ -3,8 +3,8 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@openedx/paragon';
import { PageBanner } from '@edx/paragon';
const AccessExpirationMasqueradeBanner = ({ payload }) => {
const {

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';

View File

@@ -6,8 +6,8 @@ import {
FormattedRelativeTime,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@openedx/paragon';
import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Button } from '@openedx/paragon';
import { Info, WarningFilled } from '@openedx/paragon/icons';
import { Alert, Button } from '@edx/paragon';
import { Info, WarningFilled } from '@edx/paragon/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';

View File

@@ -7,8 +7,8 @@ import {
Button,
Spinner,
Icon,
} from '@openedx/paragon';
import { Check, ArrowForward } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import genericMessages from '../../generic/messages';

View File

@@ -22,13 +22,11 @@ export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',

View File

@@ -1,72 +1,54 @@
import React, { useMemo } from 'react';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@openedx/paragon';
import { Tabs, Tab } from '@edx/paragon';
import { useParams } from 'react-router';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import { useCoursewareSearchParams } from './hooks';
import { useModel } from '../../generic/model-store';
const filterAll = 'all';
const filterTypes = ['text', 'video', 'sequence'];
const filterOther = 'other';
const validFilters = [filterAll, ...filterTypes, filterOther];
const noFilterKey = 'none';
const noFilterLabel = 'All content';
export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) => (
key === noFilterKey ? results : results.filter(({ type }) => type === key)
);
export const CoursewareSearchResultsFilter = ({ intl }) => {
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
if (!lastSearch) { return null; }
if (!lastSearch || !lastSearch?.results?.length) { return null; }
const { results: data = [] } = lastSearch;
const { total, results } = lastSearch;
// If there's no data, we show an empty result.
if (!data.length) { return <CoursewareSearchResults />; }
const filters = [
{
key: noFilterKey,
label: noFilterLabel,
count: total,
},
...lastSearch.filters,
];
const results = useMemo(() => {
// This reducer distributes the data into different groups to make it easy to
// use on the filters.
// All results are added to the "all" key and then to its proper group key as well.
const grouped = data.reduce((acc, { type, ...rest }) => {
const resultType = filterTypes.includes(type) ? type : filterOther;
acc[filterAll].push({ type: resultType, ...rest });
acc[resultType] = [...(acc[resultType] || []), { type: resultType, ...rest }];
return acc;
}, { [filterAll]: [] });
// This is just to format the output object with the expected tab order.
const output = {};
validFilters.forEach(key => { if (grouped[key]) { output[key] = grouped[key]; } });
return output;
}, [lastSearch]);
const tabKeys = Object.keys(results);
// Filter has no use if it has only 2 tabs (The "all" tab and another one with the same items).
if (tabKeys.length < 3) { return <CoursewareSearchResults results={results[filterAll]} />; }
const filters = useMemo(() => tabKeys.map((key) => ({
key,
label: intl.formatMessage(messages[`filter:${key}`]),
count: results[key].length,
})), [results]);
const activeKey = validFilters.includes(filterKeyword) ? filterKeyword : filterAll;
const getFilterTitle = (key, fallback) => {
const msg = messages[`filter:${key}`];
if (!msg) { return fallback; }
return intl.formatMessage(msg);
};
return (
<Tabs
id="courseware-search-results-tabs"
className="courseware-search-results-tabs"
data-testid="courseware-search-results-tabs"
variant="tabs"
activeKey={activeKey}
onSelect={setFilter}
defaultActiveKey={noFilterKey}
>
{filters.filter(({ count }) => (count > 0)).map(({ key, label }) => (
<Tab key={key} eventKey={key} title={label} data-testid={`courseware-search-results-tabs-${key}`}>
<CoursewareSearchResults results={results[key]} />
{filters.map(({ key, label }) => (
<Tab key={key} eventKey={key} title={getFilterTitle(key, label)}>
<CoursewareSearchResults
results={filteredResultsBySelection({ key, results })}
/>
</Tab>
))}
</Tabs>

View File

@@ -8,22 +8,34 @@ import {
screen,
waitFor,
} from '../../setupTest';
import { CoursewareSearchResultsFilter } from './CoursewareResultsFilter';
import { useCoursewareSearchParams } from './hooks';
import { CoursewareSearchResultsFilter, filteredResultsBySelection } from './CoursewareResultsFilter';
import initializeStore from '../../store';
import { useModel } from '../../generic/model-store';
import searchResultsFactory from './test-data/search-results-factory';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
useModel: jest.fn(),
}));
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
const mockResults = [
{
id: 'video-1', type: 'video', title: 'video_title', score: 3, contentHits: 1, url: '/video-1', location: ['path1', 'path2'],
},
{
id: 'video-2', type: 'video', title: 'video_title2', score: 2, contentHits: 1, url: '/video-2', location: ['path1', 'path2'],
},
{
id: 'document-1', type: 'document', title: 'document_title', score: 3, contentHits: 1, url: '/document-1', location: ['path1', 'path2'],
},
{
id: 'text-1', type: 'text', title: 'text_title1', score: 3, contentHits: 1, url: '/text-1', location: ['path1', 'path2'],
},
{
id: 'text-2', type: 'text', title: 'text_title2', score: 2, contentHits: 1, url: '/text-2', location: ['path1', 'path2'],
},
{
id: 'text-3', type: 'text', title: 'text_title3', score: 1, contentHits: 1, url: '/text-3', location: ['path1', 'path2'],
},
];
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
@@ -34,14 +46,6 @@ const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
function renderComponent(props = {}) {
const store = initializeStore();
history.push(pathname);
@@ -58,75 +62,56 @@ function renderComponent(props = {}) {
describe('CoursewareSearchResultsFilter', () => {
beforeAll(initializeMockApp);
beforeEach(() => {
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
describe('filteredResultsBySelection', () => {
it('returns a no values array when no results are provided', () => {
const results = filteredResultsBySelection({ results: [] });
expect(results.length).toEqual(0);
});
it('returns all values when no key value is provided', () => {
const results = filteredResultsBySelection({ results: mockResults });
expect(results.length).toEqual(mockResults.length);
});
it('returns only "video"-typed elements when the key value "video" is given', () => {
const results = filteredResultsBySelection({ key: 'video', results: mockResults });
expect(results.length).toEqual(2);
});
it('returns only "course_outline"-typed elements when the key value "document" is given', () => {
const results = filteredResultsBySelection({ key: 'document', results: mockResults });
expect(results.length).toEqual(1);
});
it('returns only "text"-typed elements when the key value "text" is given', () => {
const results = filteredResultsBySelection({ key: 'text', results: mockResults });
expect(results.length).toEqual(3);
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when returning full results', () => {
describe('</CoursewareSearchResultsFilter />', () => {
beforeEach(() => {
useModel.mockReturnValue(searchResultsFactory());
renderComponent();
jest.clearAllMocks();
});
it('should render without errors', async () => {
await waitFor(() => {
expect(useCoursewareSearchParams).toBeCalled();
});
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-all')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-text')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-video')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-sequence')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-other')).toBeInTheDocument();
});
});
describe('when returning only one result type', () => {
beforeEach(async () => {
// Get results for only videos
const data = searchResultsFactory();
const onlyVideos = data.results.filter(({ type }) => type === 'video');
const filteredResults = {
...data,
results: onlyVideos,
};
useModel.mockReturnValue(filteredResults);
await renderComponent();
});
it('should not render', async () => {
await waitFor(() => {
expect(useCoursewareSearchParams).toBeCalled();
});
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
});
});
describe('when there are not results', () => {
beforeEach(async () => {
useModel.mockReturnValue(searchResultsFactory('blah', {
results: [],
it('should render', async () => {
useModel.mockReturnValue({
total: 6,
results: mockResults,
filters: [],
total: 0,
maxScore: null,
ms: 5,
}));
await renderComponent();
});
it('should not render', async () => {
await waitFor(() => {
expect(useCoursewareSearchParams).toBeCalled();
});
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
await renderComponent();
await waitFor(() => {
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
expect(screen.queryByText(/All content/)).toBeInTheDocument();
});
});
});
});

View File

@@ -1,16 +1,16 @@
import React, { useEffect } from 'react';
import React, { useState } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Alert, Button, Icon, Spinner,
} from '@openedx/paragon';
} from '@edx/paragon';
import {
Close,
} from '@openedx/paragon/icons';
} from '@edx/paragon/icons';
import { setShowSearch } from '../data/slice';
import { useCoursewareSearchParams, useElementBoundingBox, useLockScroll } from './hooks';
import { useElementBoundingBox, useLockScroll } from './hooks';
import messages from './messages';
import CoursewareSearchForm from './CoursewareSearchForm';
@@ -20,7 +20,6 @@ import { searchCourseContent } from '../data/thunks';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const { courseId } = useParams();
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
const dispatch = useDispatch();
const { org } = useModel('courseHomeMeta', courseId);
const {
@@ -29,6 +28,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
errors,
total,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);
useLockScroll();
@@ -36,7 +36,6 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
const top = info ? `${Math.floor(info.top)}px` : 0;
const clearSearch = () => {
clearSearchParams();
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
@@ -49,35 +48,34 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
}));
};
const handleSubmit = (value) => {
if (!value) {
const handleSubmit = () => {
if (!searchKeyword) {
clearSearch();
return;
}
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
...eventProperties,
event_type: 'searchKeyword',
keyword: value,
keyword: searchKeyword,
});
dispatch(searchCourseContent(courseId, value));
setQuery(value);
dispatch(searchCourseContent(courseId, searchKeyword));
};
useEffect(() => {
handleSubmit(searchKeyword);
}, []);
const handleOnChange = (value) => {
if (value === searchKeyword) { return; }
if (!value) { clearSearch(); }
};
const handleSearchCloseClick = () => {
clearSearch();
dispatch(setShowSearch(false));
setSearchKeyword(value);
if (!value) {
clearSearch();
}
};
let status = 'idle';
@@ -96,14 +94,14 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={handleSearchCloseClick}
onClick={() => dispatch(setShowSearch(false))}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<div className="courseware-search__outer-content">
<div className="courseware-search__content">
<h1 className="h2">{intl.formatMessage(messages.searchModuleTitle)}</h1>
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
@@ -111,27 +109,27 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<div className="courseware-search__spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
<Alert className="mt-4" variant="danger">
{intl.formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
{total > 0 ? (
<div
className="courseware-search__results-summary"
aria-live="polite"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{intl.formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
</div>
) : null}
<div className="courseware-search__results-summary">{total > 0
? (
intl.formatMessage(
total === 1
? messages.searchResultsSingular
: messages.searchResultsPlural,
{ total, keyword: lastSearchKeyword },
)
) : intl.formatMessage(messages.searchResultsNone)}
</div>
<CoursewareSearchResultsFilterContainer />
</>
) : null}

View File

@@ -2,45 +2,21 @@ import React from 'react';
import { history } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Route, Routes } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import {
initializeMockApp,
render,
screen,
waitFor,
fireEvent,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
import { useElementBoundingBox, useLockScroll } from './hooks';
import initializeStore from '../../store';
import { searchCourseContent } from '../data/thunks';
import { setShowSearch } from '../data/slice';
import { updateModel, useModel } from '../../generic/model-store';
import { useModel } from '../../generic/model-store';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
updateModel: jest.fn(),
useModel: jest.fn(),
}));
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackingLogEvent: jest.fn(),
}));
jest.mock('../data/thunks', () => ({
searchCourseContent: jest.fn(),
}));
jest.mock('../data/slice', () => ({
setShowSearch: jest.fn(),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
@@ -56,14 +32,6 @@ const defaultProps = {
total: 0,
};
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
@@ -82,22 +50,7 @@ function renderComponent(props = {}) {
}
const mockModels = ((props = defaultProps) => {
useModel.mockReturnValue({
...defaultProps,
...props,
});
updateModel.mockReturnValue({
type: 'MOCK_ACTION',
payload: {
modelType: 'contentSearchResults',
model: defaultProps,
},
});
});
const mockSearchParams = ((props = coursewareSearch) => {
useCoursewareSearchParams.mockReturnValue(props);
useModel.mockReturnValue(props);
});
describe('CoursewareSearch', () => {
@@ -112,18 +65,16 @@ describe('CoursewareSearch', () => {
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
});
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
mockSearchParams();
renderComponent();
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
@@ -131,26 +82,11 @@ describe('CoursewareSearch', () => {
});
});
describe('when clicking on the "Close" button', () => {
it('should dispatch setShowSearch(false)', async () => {
mockModels();
renderComponent();
await waitFor(() => {
const close = screen.queryByTestId('courseware-search-close-button');
fireEvent.click(close);
});
expect(setShowSearch).toBeCalledWith(false);
});
});
describe('when CourseTabsNavigation is not present', () => {
it('should use "--modal-top-position: 0" if nce element is not present', () => {
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
@@ -159,127 +95,12 @@ describe('CoursewareSearch', () => {
});
describe('when passing extra props', () => {
it('should pass on extra props to section element', () => {
it('Should pass on extra props to section element', () => {
mockModels();
mockSearchParams();
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
describe('when submitting an empty search', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels();
renderComponent();
await waitFor(() => {
const submit = screen.queryByTestId('courseware-search-form-submit');
fireEvent.click(submit);
});
expect(updateModel).toHaveBeenCalledWith({
modelType: 'contentSearchResults',
model: {
id: decodedCourseId,
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
},
});
});
});
describe('when submitting a search', () => {
it('should show a loading state', () => {
mockModels({
loading: true,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-spinner')).toBeInTheDocument();
});
it('should call searchCourseContent', async () => {
mockModels();
renderComponent();
const searchKeyword = 'course';
await waitFor(() => {
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
fireEvent.change(input, { target: { value: searchKeyword } });
});
await waitFor(() => {
const submit = screen.queryByTestId('courseware-search-form-submit');
fireEvent.click(submit);
});
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.home.courseware_search.submit', {
org_key: defaultProps.org,
courserun_key: decodedCourseId,
event_type: 'searchKeyword',
keyword: searchKeyword,
});
expect(searchCourseContent).toHaveBeenCalledWith(decodedCourseId, searchKeyword);
});
it('should show an error state if any', () => {
mockModels({
errors: ['foo'],
});
renderComponent();
expect(screen.queryByTestId('courseware-search-error')).toBeInTheDocument();
});
it('should not show a summary if there are no results', () => {
mockModels({
searchKeyword: 'test',
total: 0,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-summary')).not.toBeInTheDocument();
});
it('should show a summary for the results', () => {
mockModels({
searchKeyword: 'fubar',
total: 1,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
});
});
describe('when clearing the search input', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels({
searchKeyword: 'fubar',
total: 2,
});
renderComponent();
await waitFor(() => {
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
fireEvent.change(input, { target: { value: '' } });
});
expect(updateModel).toHaveBeenCalledWith({
modelType: 'contentSearchResults',
model: {
id: decodedCourseId,
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
},
});
});
});
});

View File

@@ -1,24 +0,0 @@
import React from 'react';
import {
initializeMockApp,
render,
screen,
} from '../../setupTest';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
function renderComponent() {
const { container } = render(<CoursewareSearchEmpty />);
return container;
}
describe('CoursewareSearchEmpty', () => {
beforeAll(async () => {
initializeMockApp();
});
it('should match the snapshot', () => {
renderComponent();
expect(screen.getByTestId('no-results')).toMatchSnapshot();
});
});

View File

@@ -1,11 +1,8 @@
import React from 'react';
import { SearchField } from '@edx/paragon';
import PropTypes from 'prop-types';
import { SearchField } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchForm = ({
intl,
searchTerm,
onSubmit,
onChange,
@@ -17,27 +14,17 @@ const CoursewareSearchForm = ({
onChange={onChange}
submitButtonLocation="external"
className="courseware-search-form"
screenReaderText={{
label: intl.formatMessage(messages.searchSubmitLabel),
clearButton: intl.formatMessage(messages.searchClearAction),
submitButton: null, // Remove the sr-only label in the button.
}}
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton
buttonText={intl.formatMessage(messages.searchSubmitLabel)}
submitButtonLocation="external"
data-testid="courseware-search-form-submit"
/>
<SearchField.SubmitButton submitButtonLocation="external" />
</SearchField.Advanced>
);
CoursewareSearchForm.propTypes = {
intl: intlShape.isRequired,
searchTerm: PropTypes.string,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
@@ -51,4 +38,4 @@ CoursewareSearchForm.defaultProps = {
placeholder: undefined,
};
export default injectIntl(CoursewareSearchForm);
export default CoursewareSearchForm;

View File

@@ -47,8 +47,8 @@ describe('CoursewareSearchToggle', () => {
it('should call onSubmit handler when submit is clicked', async () => {
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
await waitFor(async () => {
const element = await screen.findByTestId('courseware-search-form-submit');
await waitFor(() => {
const element = screen.queryAllByText('Search')[0];
fireEvent.click(element);
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,22 +1,21 @@
import React from 'react';
import {
Folder, TextFields, VideoCamera, Article,
} from '@openedx/paragon/icons';
} from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { Icon } from '@openedx/paragon';
import { Icon } from '@edx/paragon';
import PropTypes from 'prop-types';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
const iconTypeMapping = {
document: Folder,
text: TextFields,
video: VideoCamera,
sequence: Folder,
other: Article,
};
const defaultIcon = Article;
const CoursewareSearchResults = ({ results = [] }) => {
const CoursewareSearchResults = ({ results }) => {
if (!results?.length) {
return <CoursewareSearchEmpty />;
}
@@ -35,7 +34,9 @@ const CoursewareSearchResults = ({ results = [] }) => {
}) => {
const key = type.toLowerCase();
const icon = iconTypeMapping[key] || defaultIcon;
const isExternal = !url.startsWith('/');
const linkProps = isExternal ? {
href: url,
target: '_blank',
@@ -69,13 +70,14 @@ const CoursewareSearchResults = ({ results = [] }) => {
};
CoursewareSearchResults.propTypes = {
results: PropTypes.arrayOf(PropTypes.shape({
results: PropTypes.arrayOf(PropTypes.objectOf({
id: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
location: PropTypes.arrayOf(PropTypes.string),
url: PropTypes.string,
contentHits: PropTypes.number,
score: PropTypes.number,
})),
};

View File

@@ -6,7 +6,7 @@ import {
} from '../../setupTest';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import searchResultsFactory from './test-data/search-results-factory';
// import mockedData from './test-data/mockedResults'; // TODO: Update this test.
jest.mock('react-redux');
@@ -28,14 +28,11 @@ describe('CoursewareSearchResults', () => {
});
});
describe('when list of results is provided', () => {
beforeEach(() => {
const { results } = searchResultsFactory('course');
renderComponent({ results });
});
/* describe('when list of results is provided', () => {
beforeEach(() => { renderComponent({ results: mockedData }); });
it('should match the snapshot', () => {
expect(screen.getByTestId('search-results')).toMatchSnapshot();
});
});
}); */
});

View File

@@ -1,26 +1,17 @@
import React, { useEffect } from 'react';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { useDispatch } from 'react-redux';
import messages from './messages';
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
import { setShowSearch } from '../data/slice';
import messages from './messages';
import { useCoursewareSearchFeatureFlag } from './hooks';
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
const { query } = useCoursewareSearchParams();
const handleSearchOpenClick = () => {
dispatch(setShowSearch(true));
};
useEffect(() => {
if (enabled && !!query) { handleSearchOpenClick(); }
}, [enabled]);
if (!enabled) { return null; }
@@ -31,7 +22,7 @@ const CoursewareSearchToggle = ({
size="sm"
className="p-1 mt-2 mr-2 rounded-lg"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={handleSearchOpenClick}
onClick={() => dispatch(setShowSearch(true))}
data-testid="courseware-search-open-button"
>
<Icon src={Search} />

View File

@@ -12,33 +12,14 @@ import { setShowSearch } from '../data/slice';
import { CoursewareSearchToggle } from './index';
const mockDispatch = jest.fn();
const mockCoursewareSearchParams = jest.fn();
jest.mock('../data/thunks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
function renderComponent() {
const { container } = render(<CoursewareSearchToggle />);
return container;
@@ -55,7 +36,6 @@ describe('CoursewareSearchToggle', () => {
it('Should not render when the waffle flag is disabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
mockSearchParams();
await act(async () => renderComponent());
await waitFor(() => {
@@ -66,8 +46,6 @@ describe('CoursewareSearchToggle', () => {
it('Should render when the waffle flag is enabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
mockSearchParams();
await act(async () => renderComponent());
await waitFor(() => {
@@ -78,8 +56,6 @@ describe('CoursewareSearchToggle', () => {
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
mockSearchParams();
await act(async () => renderComponent());
const button = await screen.findByTestId('courseware-search-open-button');
fireEvent.click(button);

View File

@@ -1,10 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchEmpty should match the snapshot 1`] = `
<p
class="courseware-search-results__empty"
data-testid="no-results"
>
No results found.
</p>
`;

View File

@@ -1,1238 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchResults when list of results is provided should match the snapshot 1`] = `
<div
class="courseware-search-results"
data-testid="search-results"
>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Demo Course Overview
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
<em>
1
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Text Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Text input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Pointing on a Picture
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Pointing on a Picture
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Getting Answers
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Getting Answers
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Welcome!
</span>
<em>
30
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Multiple Choice Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Multiple Choice Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Numerical Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Numerical Input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Connecting a Circuit and a Circuit Diagram
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Video Presentation Styles
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
CAPA
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 2: Get Interactive
</div>
</li>
<li>
<div>
Homework - Labs and Demos
</div>
</li>
<li>
<div>
Code Grader
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Interactive Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Interactive Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
6
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Discussion Forums
</span>
<em>
5
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Discussion Forums
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Overall Grade
</span>
<em>
7
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Overall Grade Performance
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Find Your Study Buddy
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Be Social
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Be Social
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
EdX Exams
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
EdX Exams
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
When Are Your Exams?
</span>
<em>
2
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
When Are Your Exams?
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="https://www.edx.org"
rel="nofollow"
target="_blank"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
External Course Link Test
</span>
</div>
</div>
</a>
</div>
`;

View File

@@ -18,11 +18,6 @@ Object {
"key": "text",
"label": "Text",
},
Object {
"count": 1,
"key": "unknown",
"label": "Unknown",
},
Object {
"count": 2,
"key": "video",
@@ -291,15 +286,6 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
Object {
"contentHits": 0,
"id": "random-element-id",
"location": null,
"score": 0.82610154,
"title": "External Course Link Test",
"type": "unknown",
"url": "https://www.edx.org",
},
],
"total": 29,
}

View File

@@ -6,7 +6,7 @@
right: 0;
bottom: 0;
border-top: 1px solid $light-300;
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
z-index: 200;
&__close {
position: absolute !important; // For some reason it gets overridden
@@ -35,7 +35,7 @@
&__results-summary {
font-size: .9rem;
color: $gray-500;
color: $gray-400;
padding: 1rem 0 .5rem;
}
@@ -51,8 +51,6 @@
&__empty {
color: $gray-500;
padding: 6rem 0;
text-align: center;
}
&__item {
@@ -113,7 +111,7 @@
&__breadcrumbs {
display: flex;
gap: 1.25rem;
color: $gray-500;
color: $gray-400;
overflow: hidden;
list-style: none;
padding: 0;
@@ -143,14 +141,6 @@
}
}
.courseware-search-results-tabs {
border-bottom-color: $gray-400 !important;
&.nav-tabs .nav-link.active {
border-bottom-width: 4px !important;
}
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search__content {
padding-top: 8rem;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { debounce } from 'lodash';
import { fetchCoursewareSearchSettings } from '../data/thunks';
@@ -69,19 +69,3 @@ export function useLockScroll() {
};
}, []);
}
const initSearchParams = { q: '', f: '' };
export function useCoursewareSearchParams() {
const [searchParams, setSearchParams] = useSearchParams(initSearchParams);
const clearSearchParams = () => setSearchParams(initSearchParams);
const query = searchParams.get('q');
const filter = searchParams.get('f')?.toLowerCase();
const setQuery = (q) => setSearchParams((params) => ({ q, f: params.get('f') }));
const setFilter = (f) => setSearchParams((params) => ({ q: params.get('q'), f }));
return {
query, filter, setQuery, setFilter, clearSearchParams,
};
}

View File

@@ -1,13 +1,9 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { fetchCoursewareSearchSettings } from '../data/thunks';
import {
useCoursewareSearchFeatureFlag,
useCoursewareSearchParams,
useCoursewareSearchState,
useElementBoundingBox,
useLockScroll,
useCoursewareSearchFeatureFlag, useCoursewareSearchState, useElementBoundingBox, useLockScroll,
} from './hooks';
jest.mock('react-redux');
@@ -188,45 +184,4 @@ describe('CoursewareSearch Hooks', () => {
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
});
});
describe('useSearchParams', () => {
const initSearch = { q: '', f: '' };
const q = { value: '' };
const f = { value: '' };
const mockedQuery = { q, f };
const searchParams = { get: (prop) => mockedQuery[prop].value };
const setSearchParams = jest.fn();
beforeEach(() => {
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
});
it('should init the search params properly', () => {
const {
query, filter, setQuery, setFilter, clearSearchParams,
} = useCoursewareSearchParams();
expect(useSearchParams).toBeCalledWith(initSearch);
expect(query).toBe('');
expect(filter).toBe('');
setQuery('setQuery');
expect(setSearchParams).toBeCalledWith(expect.any(Function));
setFilter('setFilter');
expect(setSearchParams).toBeCalledWith(expect.any(Function));
clearSearchParams();
expect(setSearchParams).toBeCalledWith(initSearch);
});
it('should return the query and lowercase filter if any', () => {
q.value = '42';
f.value = 'LOWERCASE';
const { query, filter } = useCoursewareSearchParams();
expect(query).toBe('42');
expect(filter).toBe('lowercase');
});
});
});

View File

@@ -1,8 +1,8 @@
const Joi = require('joi');
const endpointSchema = Joi.object({
took: Joi.number().required(),
total: Joi.number().required(),
took: Joi.number(),
total: Joi.number(),
maxScore: Joi.number().allow(null),
results: Joi.array().items(Joi.object({
id: Joi.string(),

View File

@@ -19,7 +19,6 @@ describe('mapSearchResponse', () => {
{ key: 'capa', label: 'CAPA', count: 7 },
{ key: 'sequence', label: 'Sequence', count: 2 },
{ key: 'text', label: 'Text', count: 9 },
{ key: 'unknown', label: 'Unknown', count: 1 },
{ key: 'video', label: 'Video', count: 2 },
];
expect(response.filters).toEqual(expectedFilters);

View File

@@ -6,16 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
searchSubmitLabel: {
id: 'learn.coursewareSerch.submitLabel',
defaultMessage: 'Search',
description: 'Button label that will submit Courseware Search.',
},
searchClearAction: {
id: 'learn.coursewareSerch.clearAction',
defaultMessage: 'Clear search',
description: 'Button label that will the current Courseware Search input.',
},
searchCloseAction: {
id: 'learn.coursewareSerch.closeAction',
defaultMessage: 'Close the search form',
@@ -41,10 +31,15 @@ const messages = defineMessages({
defaultMessage: 'No results found.',
description: 'Text to show when the Courseware Search found no results matching the criteria.',
},
searchResultsLabel: {
id: 'learn.coursewareSerch.searchResultsLabel',
defaultMessage: 'Results for "{keyword}":',
description: 'Text to show above the search results response list.',
searchResultsSingular: {
id: 'learn.coursewareSerch.searchResultsSingular',
defaultMessage: '1 match found for "{keyword}":',
description: 'Text to show when the Courseware Search found only one result matching the criteria.',
},
searchResultsPlural: {
id: 'learn.coursewareSerch.searchResultsPlural',
defaultMessage: '{total} matches found for "{keyword}":',
description: 'Text to show when the Courseware Search found multiple results matching the criteria.',
},
searchResultsError: {
id: 'learn.coursewareSerch.searchResultsError',
@@ -53,8 +48,8 @@ const messages = defineMessages({
},
// These are translations for labeling the filters
'filter:all': {
id: 'learn.coursewareSerch.filter:all',
'filter:none': {
id: 'learn.coursewareSerch.filter:none',
defaultMessage: 'All content',
description: 'Label for the search results filter that shows all content (no filter).',
},
@@ -68,16 +63,6 @@ const messages = defineMessages({
defaultMessage: 'Video',
description: 'Label for the search results filter that shows results with video content.',
},
'filter:sequence': {
id: 'learn.coursewareSerch.filter:sequence',
defaultMessage: 'Section',
description: 'Label for the search results filter that shows results with section content.',
},
'filter:other': {
id: 'learn.coursewareSerch.filter:other',
defaultMessage: 'Other',
description: 'Label for the search results filter that shows results with other content.',
},
});
export default messages;

View File

@@ -542,28 +542,6 @@
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
},
"score": 0.82610154
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "some-external-reference",
"data": {
"course": "External Course",
"org": "edX",
"content": {
"display_name": "External Course Link Test",
"html_content": "This should open a new tab when following the link."
},
"content_type": "Unknown",
"id": "random-element-id",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": null,
"excerpt": "Just testing external links",
"url": "https://www.edx.org"
},
"score": 0.82610154
}
],
"access_denied_count": 0

View File

@@ -1,17 +0,0 @@
import { camelCaseObject } from '@edx/frontend-platform';
import mockedData from './mocked-response.json';
import mapSearchResponse from '../map-search-response';
function searchResultsFactory(searchKeywords = '', moreInfo = {}) {
const data = camelCaseObject(mockedData);
const info = mapSearchResponse(data, searchKeywords);
const result = {
...info,
...moreInfo,
};
return result;
}
export default searchResultsFactory;

View File

@@ -309,74 +309,9 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": Array [],
"name": "",
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
@@ -500,7 +435,6 @@ Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course",
"hideFromTOC": undefined,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
@@ -516,10 +450,8 @@ Object {
"due": null,
"effortActivities": 2,
"effortTime": 15,
"hideFromTOC": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"navigationDisabled": undefined,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
@@ -577,74 +509,9 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": Array [],
"name": "",
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
@@ -848,74 +715,9 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": Array [],
"name": "",
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,

View File

@@ -136,7 +136,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
title: block.display_name,
resumeBlock: block.resume_block,
sequenceIds: block.children || [],
hideFromTOC: block.hide_from_toc,
};
break;
@@ -153,8 +152,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
showLink: !!block.lms_web_url,
title: block.display_name,
hideFromTOC: block.hide_from_toc,
navigationDisabled: block.navigation_disabled,
};
break;

View File

@@ -55,29 +55,6 @@ describe('Data layer integration tests', () => {
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('should result in fetch failed if course metadata call errored', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('should result in fetch failed if course metadata call errored', async () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
@@ -101,14 +78,18 @@ describe('Data layer integration tests', () => {
});
it.each([401, 403, 404])(
'should result in fetch denied if course access is denied, regardless of dates API status',
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
@@ -148,14 +129,18 @@ describe('Data layer integration tests', () => {
});
it.each([401, 403, 404])(
'should result in fetch denied if course access is denied, regardless of outline API status',
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});

View File

@@ -38,41 +38,28 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
try {
const promisesToFulfill = [getCourseHomeCourseMetadata(courseId, 'outline')];
if (getTabData) {
promisesToFulfill.push(getTabData(courseId, targetUserId));
}
const [
courseHomeCourseMetadataResult,
tabDataResult,
] = await Promise.allSettled(promisesToFulfill);
if (courseHomeCourseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
}
if (tabDataResult?.status === 'fulfilled') {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadata,
},
}));
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
if (tabDataResult) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
...tabDataResult,
},
}));
}
if (courseHomeCourseMetadataResult.status === 'rejected') {
throw courseHomeCourseMetadataResult.reason;
} else if (!courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
// If the learner does not have access to the course, short cut to dispatch to a denied response regardless of
// the tabDataResult.
// Disable the access-denied path for now - it caused a regression
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (tabDataResult?.status === 'rejected') {
throw tabDataResult.reason;
} else {
} else if (tabDataResult || !getTabData) {
dispatch(fetchTabSuccess({
courseId,
targetUserId,

View File

@@ -8,7 +8,7 @@ import {
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@openedx/paragon';
import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

View File

@@ -2,7 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { Badge } from '@openedx/paragon';
import { Badge } from '@edx/paragon';
import messages from '../messages';
import { daycmp, isLearnerAssignment } from '../utils';
@@ -38,21 +38,21 @@ function getBadgeListAndColor(date, intl, item, items) {
message: messages.today,
shownForDay: isToday,
bg: 'bg-warning-300',
className: 'text-dark',
className: 'text-black',
},
{
message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-light-500',
className: 'text-dark',
className: 'text-black',
},
{
message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200',
className: 'text-dark',
className: 'text-white',
},
{
message: messages.dueNext,

View File

@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@openedx/paragon';
import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';

View File

@@ -9,9 +9,8 @@ const LmsHtmlFragment = ({
title,
...rest
}) => {
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
const wholePage = `
<html dir="${direction}">
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
@@ -30,7 +29,7 @@ const LmsHtmlFragment = ({
const iframe = useRef(null);
function resetIframeHeight() {
if (iframe?.current?.contentWindow?.document?.body) {
iframe.current.height = iframe.current.contentWindow.document.body.parentNode.scrollHeight;
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
}

View File

@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';

View File

@@ -23,26 +23,8 @@ import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateS
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
const mockCoursewareSearchParams = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../courseware-search/hooks', () => ({
...jest.requireActual('../courseware-search/hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
describe('Outline Tab', () => {
let axiosMock;
@@ -95,16 +77,9 @@ describe('Outline Tab', () => {
expiration_date: null,
});
// Mock courseware search params
mockSearchParams();
logUnhandledRequests(axiosMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
@@ -282,22 +257,6 @@ describe('Outline Tab', () => {
});
});
it('ignores comments and misformatted HTML', async () => {
setTabData({
welcome_message_html: '<p class="additional-spaces-in-tag" >'
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
+ 'Test welcome message that happens to be longer than one hundred words because of comments but displayed content is less.'
+ 'It should not be shortened.'
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
+ '</p>',
});
await fetchAndRender();
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
expect(showMoreButton).not.toBeInTheDocument();
});
it('does not display if no update available', async () => {
setTabData({ welcome_message_html: null });
await fetchAndRender();
@@ -1285,97 +1244,5 @@ describe('Outline Tab', () => {
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
});
it('section should show hidden from toc message when hide_from_toc is true', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
const courseBlocksIds = Object.keys(courseBlocks.blocks);
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
...blocks,
[blockId]: {
...courseBlocks.blocks[blockId],
hide_from_toc: true,
},
}), {});
setTabData({
course_blocks: { blocks: newCourseBlocks },
});
await fetchAndRender();
const iconHiddenFromTocSectionNode = screen.getByTestId('hide-from-toc-section-icon');
const textHiddenFromTocSectionNode = screen.getByTestId('hide-from-toc-section-text');
expect(iconHiddenFromTocSectionNode).toBeInTheDocument();
expect(textHiddenFromTocSectionNode).toBeInTheDocument();
expect(textHiddenFromTocSectionNode.textContent).toBe('Hidden in Course Outline, accessible via link');
});
it('section should not show hidden from toc message when hide_from_toc is false', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
const courseBlocksIds = Object.keys(courseBlocks.blocks);
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
...blocks,
[blockId]: {
...courseBlocks.blocks[blockId],
hide_from_toc: false,
},
}), {});
setTabData({
course_blocks: { blocks: newCourseBlocks },
});
await fetchAndRender();
const iconHiddenFromTocSectionNode = screen.queryByTestId('hide-from-toc-section-icon');
const textHiddenFromTocSectionNode = screen.queryByTestId('hide-from-toc-section-text');
expect(iconHiddenFromTocSectionNode).not.toBeInTheDocument();
expect(textHiddenFromTocSectionNode).not.toBeInTheDocument();
});
it('sequence link should show hidden from toc message when hide_from_toc is true', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
const courseBlocksIds = Object.keys(courseBlocks.blocks);
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
...blocks,
[blockId]: {
...courseBlocks.blocks[blockId],
hide_from_toc: true,
},
}), {});
setTabData({
course_blocks: { blocks: newCourseBlocks },
});
await fetchAndRender();
const iconHiddenFromTocSequenceLinkNode = screen.getByTestId('hide-from-toc-sequence-link-icon');
const textHiddenFromTocSequenceLink = screen.getByTestId('hide-from-toc-sequence-link-text');
expect(iconHiddenFromTocSequenceLinkNode).toBeInTheDocument();
expect(textHiddenFromTocSequenceLink).toBeInTheDocument();
expect(textHiddenFromTocSequenceLink.textContent).toBe('Subsections are not navigable between each other, they can only be accessed through their link.');
});
it('sequence link not show hidden from toc message when hide_from_toc is false', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
const courseBlocksIds = Object.keys(courseBlocks.blocks);
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
...blocks,
[blockId]: {
...courseBlocks.blocks[blockId],
hide_from_toc: false,
},
}), {});
setTabData({
course_blocks: { blocks: newCourseBlocks },
});
await fetchAndRender();
const iconHiddenFromTocSequenceLink = screen.queryByTestId('hide-from-toc-sequence-link-icon');
const textHiddenFromTocSequenceLink = screen.queryByTestId('hide-from-toc-sequence-link-text');
expect(iconHiddenFromTocSequenceLink).not.toBeInTheDocument();
expect(textHiddenFromTocSequenceLink).not.toBeInTheDocument();
});
});
});

View File

@@ -1,12 +1,11 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton, Icon } from '@openedx/paragon';
import { Collapsible, IconButton } from '@edx/paragon';
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { DisabledVisible } from '@openedx/paragon/icons';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
@@ -24,7 +23,6 @@ const Section = ({
complete,
sequenceIds,
title,
hideFromTOC,
} = section;
const {
courseBlocks: {
@@ -44,7 +42,7 @@ const Section = ({
}, []);
const sectionTitle = (
<div className="d-flex row w-100 m-0">
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
@@ -64,24 +62,12 @@ const Section = ({
/>
)}
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
{hideFromTOC && (
<div className="row">
{hideFromTOC && (
<span className="small d-flex align-content-end">
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
<span data-testid="hide-from-toc-section-text">
{intl.formatMessage(messages.hiddenSection)}
</span>
</span>
)}
</div>
)}
</div>
);

View File

@@ -12,8 +12,6 @@ import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-ico
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Icon } from '@openedx/paragon';
import { Block } from '@openedx/paragon/icons';
import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
@@ -31,7 +29,6 @@ const SequenceLink = ({
due,
showLink,
title,
hideFromTOC,
} = sequence;
const {
userTimezone,
@@ -117,16 +114,6 @@ const SequenceLink = ({
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{hideFromTOC && (
<div className="row w-100 my-2 mx-4 pl-3">
<span className="small d-flex">
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
<span data-testid="hide-from-toc-sequence-link-text">
{intl.formatMessage(messages.hiddenSequenceLink)}
</span>
</span>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}

View File

@@ -6,7 +6,7 @@ import {
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@openedx/paragon';
import { Alert, Button } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

View File

@@ -6,8 +6,8 @@ import {
FormattedRelativeTime,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Button, Hyperlink } from '@openedx/paragon';
import { Alert, Button, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';

View File

@@ -1,5 +1,5 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@openedx/paragon';
import { Alert, Button } from '@edx/paragon';
import React from 'react';
import PropTypes from 'prop-types';

View File

@@ -36,16 +36,6 @@ const messages = defineMessages({
defaultMessage: 'Completed section',
description: 'Text used to describe the green checkmark icon in front of a section title',
},
hiddenSection: {
id: 'learning.outline.hiddenSection',
defaultMessage: 'Hidden in Course Outline, accessible via link',
description: 'Label for hidden section in course outline',
},
hiddenSequenceLink: {
id: 'learning.outline.hiddenSequenceLink',
defaultMessage: 'Subsections are not navigable between each other, they can only be accessed through their link.',
description: 'Label for hidden sequence in course outline',
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Important dates',

View File

@@ -1,5 +1,5 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
.flag-button {

View File

@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button, Card } from '@openedx/paragon';
import { Button, Card } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';

View File

@@ -2,12 +2,12 @@ import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@openedx/paragon';
import { Form, Card, Icon } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Email } from '@openedx/paragon/icons';
import { Email } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import messages from '../messages';
import LearningGoalButton from './LearningGoalButton';

View File

@@ -1,8 +1,8 @@
import React, { useState, useMemo } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@openedx/paragon';
import { Alert, Button, TransitionReplace } from '@edx/paragon';
import truncate from 'truncate-html';
import { useDispatch } from 'react-redux';
@@ -18,22 +18,8 @@ const WelcomeMessage = ({ courseId, intl }) => {
const [display, setDisplay] = useState(true);
// welcomeMessageHtml can contain comments or malformatted HTML which can impact the length that determines
// messageCanBeShortened. We clean it by calling truncate with a length of welcomeMessageHtml.length which
// will not result in a truncation but a formatting into 'truncate-html' canonical format.
const cleanedWelcomeMessageHtml = useMemo(
() => truncate(welcomeMessageHtml, welcomeMessageHtml.length, { keepWhitespaces: true }),
[welcomeMessageHtml],
);
const shortWelcomeMessageHtml = useMemo(
() => truncate(cleanedWelcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true }),
[cleanedWelcomeMessageHtml],
);
const messageCanBeShortened = useMemo(
() => (shortWelcomeMessageHtml.length < cleanedWelcomeMessageHtml.length),
[cleanedWelcomeMessageHtml, shortWelcomeMessageHtml],
);
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const dispatch = useDispatch();
@@ -77,7 +63,7 @@ const WelcomeMessage = ({ courseId, intl }) => {
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={cleanedWelcomeMessageHtml}
html={welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}

View File

@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Button } from '@edx/paragon';
import { useModel } from '../../generic/model-store';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { breakpoints, useWindowSize } from '@edx/paragon';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';

View File

@@ -3,7 +3,7 @@ import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@openedx/paragon';
import { breakpoints } from '@edx/paragon';
import MockAdapter from 'axios-mock-adapter';
import {
@@ -16,26 +16,8 @@ import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
import messages from './grades/messages';
const mockCoursewareSearchParams = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../courseware-search/hooks', () => ({
...jest.requireActual('../courseware-search/hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
describe('Progress Tab', () => {
let axiosMock;
@@ -76,16 +58,9 @@ describe('Progress Tab', () => {
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock courseware search params
mockSearchParams();
logUnhandledRequests(axiosMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Related links', () => {
beforeEach(() => {
sendTrackEvent.mockClear();

View File

@@ -6,7 +6,7 @@ import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Button, Card } from '@openedx/paragon';
import { Button, Card } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../generic/model-store';
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
@@ -154,7 +154,7 @@ const CertificateStatus = ({ intl }) => {
certAvailabilityDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
body = (
<FormattedMessage
id="progress.certificateStatus.notAvailable.endDate"
id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
scheduled to be available after {certAvailabilityDate}."
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"

Some files were not shown because too many files have changed in this diff Show More