Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [FC-0044] group configurations MFE page #929

5 changes: 5 additions & 0 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';

/**
* As of this writing, these routes are mounted at a path prefixed with the following:
Expand Down Expand Up @@ -100,6 +101,10 @@ const CourseAuthoringRoutes = () => {
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
Expand Down
24 changes: 24 additions & 0 deletions src/generic/prompt-if-dirty/PromptIfDirty.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import PropTypes from 'prop-types';

const PromptIfDirty = ({ dirty }) => {
useEffect(() => {
// eslint-disable-next-line consistent-return
const handleBeforeUnload = (event) => {
if (dirty) {
event.preventDefault();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [dirty]);

return null;
};
PromptIfDirty.propTypes = {
dirty: PropTypes.bool.isRequired,
};
export default PromptIfDirty;
72 changes: 72 additions & 0 deletions src/generic/prompt-if-dirty/PromptIfDirty.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
import PromptIfDirty from './PromptIfDirty';

describe('PromptIfDirty', () => {
let container = null;
let mockEvent = null;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
mockEvent = new Event('beforeunload');
jest.spyOn(window, 'addEventListener');
jest.spyOn(window, 'removeEventListener');
jest.spyOn(mockEvent, 'preventDefault');
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
mockEvent.returnValue = '';
});

afterEach(() => {
window.addEventListener.mockRestore();
window.removeEventListener.mockRestore();
mockEvent.preventDefault.mockRestore();
mockEvent = null;
unmountComponentAtNode(container);
container.remove();
container = null;
});

it('should add event listener on mount', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});

expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});

it('should remove event listener on unmount', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});
act(() => {
unmountComponentAtNode(container);
});

expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});

it('should call preventDefault and set returnValue when dirty is true', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});
act(() => {
window.dispatchEvent(mockEvent);
});

expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.returnValue).toBe('');
});

it('should not call preventDefault when dirty is false', () => {
act(() => {
render(<PromptIfDirty dirty={false} />, container);
});
act(() => {
window.dispatchEvent(mockEvent);
});

expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
});
130 changes: 130 additions & 0 deletions src/group-configurations/GroupConfigurations.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
@import "./empty-placeholder/EmptyPlaceholder";

.configuration-section-name {
text-transform: lowercase;

&::first-letter {
text-transform: capitalize;
}

.group-percentage-container {
width: 1rem;
}
}

.configuration-card {
@include pgn-box-shadow(1, "down");

background: $white;
border-radius: .375rem;
padding: map-get($spacers, 4);
margin-bottom: map-get($spacers, 4);

.configuration-card-header {
display: flex;
align-items: center;
align-content: center;
justify-content: space-between;

.configuration-card-header__button {
display: flex;
align-items: flex-start;
padding: 0;
height: auto;
color: $black;

&:focus::before {
display: none;
}

.pgn__icon {
display: inline-block;
margin-right: map-get($spacers, 1);
margin-bottom: map-get($spacers, 2\.5);
}

.pgn__hstack {
align-items: baseline;
}

&:hover {
background: transparent;
}
}

.configuration-card-header__title {
text-align: left;

h3 {
margin-bottom: map-get($spacers, 2);
}
}

.configuration-card-header__badge {
display: flex;
padding: .125rem map-get($spacers, 2);
justify-content: center;
align-items: center;
border-radius: $border-radius;
border: .063rem solid $light-300;
background: $white;

&:first-child {
margin-left: map-get($spacers, 2\.5);
}

& span:last-child {
color: $primary-700;
}
}

.configuration-card-header__delete-tooltip {
pointer-events: all;
}
}

.configuration-card-content {
margin: 0 map-get($spacers, 2) 0 map-get($spacers, 4);

.configuration-card-content__experiment-stack {
display: flex;
justify-content: space-between;
padding: map-get($spacers, 2\.5) 0;
margin: 0;
color: $primary-500;
gap: $spacer;

&:not(:last-child) {
border-bottom: .063rem solid $light-400;
}
}
}

.pgn__form-control-decorator-group {
margin-inline-end: 0;
}

.configuration-form-group {
.pgn__form-label {
font: normal $font-weight-bold .875rem/1.25rem $font-family-base;
color: $gray-700;
margin-bottom: .875rem;
}

.pgn__form-control-description,
.pgn__form-text {
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
color: $gray-500;
margin-top: .625rem;
}

.pgn__form-text-invalid {
color: $form-feedback-invalid-color;
}
}

.experiment-configuration-form-percentage {
width: 5rem;
text-align: center;
}
}
106 changes: 106 additions & 0 deletions src/group-configurations/GroupConfigurations.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import MockAdapter from 'axios-mock-adapter';
import { render, waitFor, within } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

import { RequestStatus } from '../data/constants';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { getContentStoreApiUrl } from './data/api';
import { fetchGroupConfigurationsQuery } from './data/thunk';
import { groupConfigurationResponseMock } from './__mocks__';
import messages from './messages';
import experimentMessages from './experiment-configurations-section/messages';
import contentGroupsMessages from './content-groups-section/messages';
import GroupConfigurations from '.';

let axiosMock;
let store;
const courseId = 'course-v1:org+101+101';
const enrollmentTrackGroups = groupConfigurationResponseMock.allGroupConfigurations[0];
const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1];

const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<GroupConfigurations courseId={courseId} />
</IntlProvider>
</AppProvider>,
);

describe('<GroupConfigurations />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});

store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getContentStoreApiUrl(courseId))
.reply(200, groupConfigurationResponseMock);
await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch);
});

it('renders component correctly', async () => {
const { getByText, getAllByText, getByTestId } = renderComponent();

await waitFor(() => {
const mainContent = getByTestId('group-configurations-main-content-wrapper');
const groupConfigurationsElements = getAllByText(messages.headingTitle.defaultMessage);
const groupConfigurationsTitle = groupConfigurationsElements[0];

expect(groupConfigurationsTitle).toBeInTheDocument();
expect(
getByText(messages.headingSubtitle.defaultMessage),
).toBeInTheDocument();
expect(
within(mainContent).getByText(contentGroupsMessages.addNewGroup.defaultMessage),
).toBeInTheDocument();
expect(
within(mainContent).getByText(experimentMessages.addNewGroup.defaultMessage),
).toBeInTheDocument();
expect(
within(mainContent).getByText(experimentMessages.title.defaultMessage),
).toBeInTheDocument();
expect(getByText(contentGroups.name)).toBeInTheDocument();
expect(getByText(enrollmentTrackGroups.name)).toBeInTheDocument();
});
});

it('does not render an empty section for enrollment track groups if it is empty', () => {
const shouldNotShowEnrollmentTrackResponse = {
...groupConfigurationResponseMock,
shouldShowEnrollmentTrack: false,
};
axiosMock
.onGet(getContentStoreApiUrl(courseId))
.reply(200, shouldNotShowEnrollmentTrackResponse);

const { queryByTestId } = renderComponent();
expect(
queryByTestId('group-configurations-empty-placeholder'),
).not.toBeInTheDocument();
});

it('updates loading status if request fails', async () => {
axiosMock
.onGet(getContentStoreApiUrl(courseId))
.reply(404, groupConfigurationResponseMock);

renderComponent();

await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch);

expect(store.getState().groupConfigurations.loadingStatus).toBe(
RequestStatus.FAILED,
);
});
});
44 changes: 44 additions & 0 deletions src/group-configurations/__mocks__/contentGroupsMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module.exports = {
active: true,
description: 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.',
groups: [
{
id: 593758473,
name: 'My Content Group 1',
usage: [],
version: 1,
},
{
id: 256741177,
name: 'My Content Group 2',
usage: [
{
label: 'Unit / Blank Problem',
url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e',
},
{
label: 'Unit / Drag and Drop',
url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348w2743b6ac36ac4af354de0e',
},
],
version: 1,
},
{
id: 646686987,
name: 'My Content Group 3',
usage: [
{
label: 'Unit / Drag and Drop',
url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e',
},
],
version: 1,
},
],
id: 1791848226,
name: 'Content Groups',
parameters: {},
readOnly: false,
scheme: 'cohort',
version: 3,
};
Loading
Loading