fix: ui bugs (#542)

This commit is contained in:
Kristin Aoki
2023-07-31 17:23:07 -04:00
committed by GitHub
parent 8bfc3f2945
commit 9f4422d1b9
9 changed files with 253 additions and 143 deletions

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton,
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@edx/paragon';
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -25,10 +25,6 @@ import messages from './messages';
import ModalError from './modal-error/ModalError';
const AdvancedSettings = ({ intl, courseId }) => {
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
@@ -36,10 +32,21 @@ const AdvancedSettings = ({ intl, courseId }) => {
const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const loadingSettingsStatus = useSelector(getLoadingStatus);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
@@ -49,20 +56,14 @@ const AdvancedSettings = ({ intl, courseId }) => {
disabledStates: ['pending'],
};
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
if (!isEditableState) {
showSaveSettingsPrompt(false);
}
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
@@ -81,26 +82,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
);
}
const handleSettingChange = (e, settingName) => {
const { value } = e.target;
if (!saveSettingsPrompt) {
showSaveSettingsPrompt(true);
}
setIsEditableState(true);
setShowSuccessAlert(false);
setEditedSettings((prevEditedSettings) => ({
...prevEditedSettings,
[settingName]: value,
}));
};
const handleResetSettingsValues = () => {
setIsEditableState(false);
showErrorModal(false);
setEditedSettings({});
showSaveSettingsPrompt(false);
setInternetConnectionError(false);
setIsQueryPending(false);
};
const handleSettingBlur = () => {
@@ -111,9 +97,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setIsQueryPending(true);
setIsEditableState(false);
} else {
setIsQueryPending(false);
showSaveSettingsPrompt(false);
showErrorModal(!errorModal);
}
@@ -123,7 +107,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
setInternetConnectionError(true);
showSaveSettingsPrompt(false);
setShowSuccessAlert(false);
setIsQueryPending(false);
};
const handleQueryProcessing = () => {
@@ -132,15 +115,13 @@ const AdvancedSettings = ({ intl, courseId }) => {
};
const handleManuallyChangeClick = (setToState) => {
setIsEditableState(true);
showErrorModal(setToState);
showSaveSettingsPrompt(true);
setIsQueryPending(false);
};
return (
<>
<Container size="xl" className="m-4">
<Container size="xl" className="px-4">
<div className="setting-header mt-5">
{(proctoringExamErrors?.length > 0) && (
<AlertProctoringError
@@ -151,17 +132,27 @@ const AdvancedSettings = ({ intl, courseId }) => {
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
/>
)}
<AlertMessage
show={showSuccessAlert}
variant="success"
icon={CheckCircle}
title={intl.formatMessage(messages.alertSuccess)}
description={intl.formatMessage(messages.alertSuccessDescriptions)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
/>
<TransitionReplace>
{showSuccessAlert ? (
<AlertMessage
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
show={showSuccessAlert}
variant="success"
icon={CheckCircle}
title={intl.formatMessage(messages.alertSuccess)}
description={intl.formatMessage(messages.alertSuccessDescriptions)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
/>
) : null}
</TransitionReplace>
</div>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
@@ -174,18 +165,13 @@ const AdvancedSettings = ({ intl, courseId }) => {
<article>
<div>
<section className="setting-items-policies">
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
instruction={(
<FormattedMessage
id="course-authoring.advanced-settings.policies.description"
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
values={{ notice: <strong>Warning: </strong> }}
/>
)}
/>
<div className="small">
<FormattedMessage
id="course-authoring.advanced-settings.policies.description"
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
values={{ notice: <strong>Warning: </strong> }}
/>
</div>
<div className="setting-items-deprecated-setting">
<Button
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
@@ -204,20 +190,22 @@ const AdvancedSettings = ({ intl, courseId }) => {
</Button>
</div>
<ul className="setting-items-list p-0">
{Object.keys(advancedSettingsData).sort().map((settingName) => {
{Object.keys(advancedSettingsData).map((settingName) => {
const settingData = advancedSettingsData[settingName];
const editedValue = editedSettings[settingName] !== undefined
? editedSettings[settingName] : JSON.stringify(settingData.value, null, 4);
if (settingData.deprecated && !showDeprecated) {
return null;
}
return (
<SettingCard
key={settingName}
settingData={settingData}
onChange={(e) => handleSettingChange(e, settingName)}
showDeprecated={showDeprecated}
name={settingName}
value={editedValue}
showSaveSettingsPrompt={showSaveSettingsPrompt}
saveSettingsPrompt={saveSettingsPrompt}
setEdited={setEditedSettings}
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
/>
);
})}
@@ -233,7 +221,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
</section>
</Container>
<div className="alert-toast">
{!isEditableState && (
{isQueryPending && (
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}

View File

@@ -7,8 +7,10 @@ import { render, fireEvent, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
@@ -70,10 +72,11 @@ describe('<AdvancedSettings />', () => {
});
});
it('should render setting element', async () => {
const { getByText } = render(<RootWrapper />);
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
@@ -112,24 +115,50 @@ describe('<AdvancedSettings />', () => {
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1' } });
fireEvent.click(getByText(messages.buttonSaveText.defaultMessage));
fireEvent.click(getByText(/Change manually/i));
expect(textarea.value).toBe('[3, 2, 1');
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

@@ -6,4 +6,11 @@ module.exports = {
hideOnEnabledPublisher: false,
value: [],
},
certHtmlViewEnabled: {
deprecated: true,
display_name: 'Certificate web/html view enabled',
help: 'If true, certificate Web/HTML views are enabled for the course.',
hide_on_enabled_publisher: false,
value: true,
},
};

View File

@@ -19,7 +19,20 @@ export function fetchCourseAppSettings(courseId) {
try {
const settingValues = await getCourseAdvancedSettings(courseId);
dispatch(fetchCourseAppsSettingsSuccess(settingValues));
const sortedDisplayName = [];
Object.values(settingValues).forEach(value => {
const { displayName } = value;
sortedDisplayName.push(displayName);
});
const sortedSettingValues = {};
sortedDisplayName.sort().forEach((displayName => {
Object.entries(settingValues).forEach(([key, value]) => {
if (value.displayName === displayName) {
sortedSettingValues[key] = value;
}
});
}));
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {

View File

@@ -8,8 +8,8 @@
.instructions,
strong {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
color: $text-color-base;
font-weight: 400;
}
}
@@ -18,11 +18,6 @@
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
padding-top: .625rem;
}
.pgn__icon {
color: $headings-color;
}
}
@@ -44,7 +39,6 @@
}
.form-control {
resize: none;
min-height: 2.75rem;
width: $setting-form-control-width;
}
@@ -54,7 +48,8 @@
}
.pgn__card-header {
padding: 0 1.5rem;
padding: 0 0 0 1.5rem;
flex-grow: 1;
}
.pgn__card-status {
@@ -66,17 +61,10 @@
margin-bottom: 1.438rem;
display: flex;
flex-direction: revert;
.pgn__card-header-subtitle-md {
margin-left: .375rem;
margin-top: .125rem;
}
}
}
.setting-sidebar-supplementary {
margin-top: 1.875rem;
.setting-sidebar-supplementary-about {
.setting-sidebar-supplementary-about-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
@@ -123,3 +111,15 @@
align-items: center;
}
}
.modal-popup-content {
max-width: 200px;
color: $white;
background-color: $black;
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
font-weight: 400;
}
.pgn__modal-popup__arrow::after {
border-top-color: $black;
}

View File

@@ -1,10 +1,15 @@
import React from 'react';
import React, { useState } from 'react';
import {
Card, Form, Icon, IconButton, OverlayTrigger, Popover,
ActionRow,
Card,
Form,
Icon,
IconButton,
ModalPopup,
useToggle,
} from '@edx/paragon';
import { Info, Warning } from '@edx/paragon/icons';
import { InfoOutline, Warning } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { capitalize } from 'lodash';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';
@@ -12,47 +17,87 @@ import TextareaAutosize from 'react-textarea-autosize';
import messages from './messages';
const SettingCard = ({
intl, showDeprecated, name, onChange, value, settingData, handleBlur,
name,
settingData,
handleBlur,
setEdited,
showSaveSettingsPrompt,
saveSettingsPrompt,
isEditableState,
setIsEditableState,
// injected
intl,
}) => {
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
const { value } = e.target;
setNewValue(e.target.value);
if (value !== initialValue) {
if (!saveSettingsPrompt) {
showSaveSettingsPrompt(true);
}
if (!isEditableState) {
setIsEditableState(true);
}
}
};
const handleCardBlur = () => {
setEdited((prevEditedSettings) => ({
...prevEditedSettings,
[name]: newValue,
}));
handleBlur();
};
return (
<li className={classNames('field-group course-advanced-policy-list-item', { 'd-none': deprecated && !showDeprecated })}>
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex justify-content-between">
<Card.Body className="d-flex">
<Card.Header
title={capitalize(displayName)}
subtitle={(
<OverlayTrigger
trigger="click"
rootClose
placement="bottom"
overlay={(
<Popover id="popover-positioned">
<Popover.Content>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: help }} />
</Popover.Content>
</Popover>
)}
>
title={(
<ActionRow>
{capitalize(displayName)}
<IconButton
src={Info}
ref={setTarget}
onClick={open}
src={InfoOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.helpButtonText)}
variant="light"
variant="primary"
className=" ml-1 mr-2"
/>
</OverlayTrigger>
<ModalPopup
hasArrow
placement="right"
positionRef={target}
isOpen={isOpen}
onClose={close}
className="pgn__modal-popup__arrow"
>
<div
className="p-2 x-small rounded modal-popup-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
</ActionRow>
)}
/>
<Card.Section>
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}
value={value}
value={isEditableState ? newValue : initialValue}
name={name}
onChange={onChange}
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleBlur}
onBlur={handleCardBlur}
/>
</Form.Group>
</Card.Section>
@@ -73,22 +118,21 @@ SettingCard.propTypes = {
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.object,
PropTypes.array,
]),
}).isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.object,
PropTypes.array,
]),
onChange: PropTypes.func.isRequired,
showDeprecated: PropTypes.bool.isRequired,
setEdited: PropTypes.func.isRequired,
showSaveSettingsPrompt: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
handleBlur: PropTypes.func.isRequired,
};
SettingCard.defaultProps = {
value: undefined,
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
};
export default injectIntl(SettingCard);

View File

@@ -1,16 +1,21 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import SettingCard from './SettingCard';
import messages from './messages';
const handleChange = jest.fn();
const setEdited = jest.fn();
const showSaveSettingsPrompt = jest.fn();
const setIsEditableState = jest.fn();
const handleBlur = jest.fn();
const settingData = {
deprecated: false,
help: 'This is a help message',
displayName: 'Setting Name',
value: 'Setting Value',
};
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
@@ -27,9 +32,13 @@ const RootWrapper = () => (
intl={{}}
isOn
name="settingName"
onChange={handleChange}
value="Setting Value"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={settingData}
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
/>
</IntlProvider>
);
@@ -42,7 +51,7 @@ describe('<SettingCard />', () => {
const input = getByLabelText(/Setting Name/i);
expect(cardTitle).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(input.value).toBe('Setting Value');
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
});
it('displays the deprecated status when the setting is deprecated', () => {
const deprecatedSettingData = { ...settingData, deprecated: true };
@@ -52,9 +61,13 @@ describe('<SettingCard />', () => {
intl={{}}
isOn
name="settingName"
onChange={handleChange}
value="Setting Value"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={deprecatedSettingData}
handleBlur={handleBlur}
isEditable={false}
saveSettingsPrompt
/>
</IntlProvider>,
);
@@ -65,4 +78,19 @@ describe('<SettingCard />', () => {
const { queryByText } = render(<RootWrapper />);
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
});
it('calls setEdited on blur', async () => {
const { getByLabelText } = render(<RootWrapper />);
const inputBox = getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
userEvent.clear(inputBox);
userEvent.type(inputBox, '3, 2, 1');
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -16,6 +16,7 @@ import {
useToggle,
Image,
ModalDialog,
Container,
} from '@edx/paragon';
import { Add, SpinnerSimple } from '@edx/paragon/icons';
import Placeholder, {
@@ -110,7 +111,7 @@ const CustomPages = ({
}
return (
<CustomPagesProvider courseId={courseId}>
<main className="container container-mw-xl p-4 pt-5">
<Container size="xl" className="p-4 pt-5">
<div className="small gray-700">
<Breadcrumb
ariaLabel="Custom Page breadcrumbs"
@@ -255,7 +256,7 @@ const CustomPages = ({
)}
</PageRoute>
</Switch>
</main>
</Container>
</CustomPagesProvider>
);
};

View File

@@ -6,5 +6,5 @@
@import "~@edx/frontend-component-footer/dist/footer";
@import "proctored-exam-settings/proctoredExamSettings";
@import "pages-and-resources/discussions/app-list/AppList";
@import "advanced-settings/scss/AdvencedSettings";
@import "advanced-settings/scss/AdvancedSettings";
@import "generic/styles";