diff --git a/src/group-configurations/GroupConfigurations.test.jsx b/src/group-configurations/GroupConfigurations.test.jsx index a17d5badc8..aca98646fc 100644 --- a/src/group-configurations/GroupConfigurations.test.jsx +++ b/src/group-configurations/GroupConfigurations.test.jsx @@ -7,7 +7,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import initializeStore from '../store'; import { executeThunk } from '../utils'; -import { getGroupConfigurationsApiUrl } from './data/api'; +import { getContentStoreApiUrl } from './data/api'; import { fetchGroupConfigurationsQuery } from './data/thunk'; import { groupConfigurationResponseMock } from './__mocks__'; import messages from './messages'; @@ -43,7 +43,7 @@ describe('', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock - .onGet(getGroupConfigurationsApiUrl(courseId)) + .onGet(getContentStoreApiUrl(courseId)) .reply(200, groupConfigurationResponseMock); await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch); }); @@ -80,7 +80,7 @@ describe('', () => { shouldShowEnrollmentTrack: false, }; axiosMock - .onGet(getGroupConfigurationsApiUrl(courseId)) + .onGet(getContentStoreApiUrl(courseId)) .reply(200, shouldNotShowEnrollmentTrackResponse); const { queryByTestId } = renderComponent(); diff --git a/src/group-configurations/constants.js b/src/group-configurations/constants.js new file mode 100644 index 0000000000..5e90a65886 --- /dev/null +++ b/src/group-configurations/constants.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; + +const availableGroupPropTypes = { + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ), + id: PropTypes.number, + name: PropTypes.string, + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + readOnly: PropTypes.bool, + scheme: PropTypes.string, + version: PropTypes.number, +}; + +// eslint-disable-next-line import/prefer-default-export +export { availableGroupPropTypes }; diff --git a/src/group-configurations/content-groups-section/ContentGroupContainer.jsx b/src/group-configurations/content-groups-section/ContentGroupContainer.jsx new file mode 100644 index 0000000000..bae323c7fc --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupContainer.jsx @@ -0,0 +1,151 @@ +import PropTypes from 'prop-types'; +import { Formik } from 'formik'; +import * as Yup from 'yup'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, + ActionRow, + Button, + Form, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; + +import { WarningFilled as WarningFilledIcon } from '@openedx/paragon/icons'; + +import PromptIfDirty from '../../generic/PromptIfDirty'; +import { isAlreadyExistsGroup } from './utils'; +import messages from './messages'; + +const ContentGroupContainer = ({ + isEditMode, + groupNames, + isUsedInLocation, + overrideValue, + onCreateClick, + onCancelClick, + onDeleteClick, + onEditClick, +}) => { + const { formatMessage } = useIntl(); + const initialValues = { newGroupName: overrideValue }; + const validationSchema = Yup.object().shape({ + newGroupName: Yup.string() + .required(formatMessage(messages.requiredError)) + .test( + 'unique-name-restriction', + formatMessage(messages.invalidMessage), + (value) => overrideValue === value || !isAlreadyExistsGroup(groupNames, value), + ), + }); + const onSubmitForm = isEditMode ? onEditClick : onCreateClick; + + return ( +
+
+

{formatMessage(messages.newGroupHeader)}

+
+ + {({ + values, errors, dirty, handleChange, handleSubmit, + }) => { + const isInvalid = !!errors.newGroupName; + + return ( + <> + + + {isInvalid && ( + + {errors.newGroupName} + + )} + + {isUsedInLocation && ( + +

{formatMessage(messages.alertGroupInUsage)}

+
+ )} + + {isEditMode && ( + + {formatMessage( + isUsedInLocation + ? messages.deleteRestriction + : messages.deleteButton, + )} + + )} + > + + + )} + + + + + + + ); + }} +
+
+ ); +}; + +ContentGroupContainer.defaultProps = { + groupNames: [], + overrideValue: '', + isEditMode: false, + isUsedInLocation: false, + onCreateClick: null, + onDeleteClick: null, + onEditClick: null, +}; + +ContentGroupContainer.propTypes = { + groupNames: PropTypes.arrayOf(PropTypes.string), + isEditMode: PropTypes.bool, + isUsedInLocation: PropTypes.bool, + overrideValue: PropTypes.string, + onCreateClick: PropTypes.func, + onCancelClick: PropTypes.func.isRequired, + onDeleteClick: PropTypes.func, + onEditClick: PropTypes.func, +}; + +export default ContentGroupContainer; diff --git a/src/group-configurations/content-groups-section/ContentGroupContainer.test.jsx b/src/group-configurations/content-groups-section/ContentGroupContainer.test.jsx new file mode 100644 index 0000000000..2339af6beb --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupContainer.test.jsx @@ -0,0 +1,196 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/react'; + +import { contentGroupsMock } from '../__mocks__'; +import messages from './messages'; +import ContentGroupContainer from './ContentGroupContainer'; + +const onCreateClickMock = jest.fn(); +const onCancelClickMock = jest.fn(); +const onDeleteClickMock = jest.fn(); +const onEditClickMock = jest.fn(); + +const renderComponent = (props = {}) => render( + + group.name)} + onCreateClick={onCreateClickMock} + onCancelClick={onCancelClickMock} + onDeleteClick={onDeleteClickMock} + onEditClick={onEditClickMock} + {...props} + /> + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + + expect(getByTestId('content-group-new')).toBeInTheDocument(); + expect( + getByText(messages.newGroupHeader.defaultMessage), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.cancelButton.defaultMessage }), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.createButton.defaultMessage }), + ).toBeInTheDocument(); + }); + + it('renders component in edit mode', () => { + const { + getByText, queryByText, getByRole, getByPlaceholderText, + } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + }); + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + + expect(newGroupInput).toBeInTheDocument(); + expect( + getByText(messages.newGroupHeader.defaultMessage), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.deleteButton.defaultMessage }), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.saveButton.defaultMessage }), + ).toBeInTheDocument(); + expect( + queryByText(messages.alertGroupInUsage.defaultMessage), + ).not.toBeInTheDocument(); + }); + + it('shows alert if group is used in location with edit mode', () => { + const { getByText, getByRole } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + isUsedInLocation: true, + }); + const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage }); + expect( + getByText(messages.alertGroupInUsage.defaultMessage), + ).toBeInTheDocument(); + expect(deleteButton).toBeDisabled(); + }); + + it('calls onCreate when the "Create" button is clicked with a valid form', async () => { + const { + getByRole, getByPlaceholderText, queryByText, + } = renderComponent(); + const newGroupNameText = 'New group name'; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const createButton = getByRole('button', { + name: messages.createButton.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect(onCreateClickMock).toHaveBeenCalledTimes(1); + }); + expect( + queryByText(messages.requiredError.defaultMessage), + ).not.toBeInTheDocument(); + }); + + it('shows error when the "Create" button is clicked with an invalid form', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderComponent(); + const newGroupNameText = ''; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const createButton = getByRole('button', { + name: messages.createButton.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText(messages.requiredError.defaultMessage), + ).toBeInTheDocument(); + }); + }); + + it('calls onEdit when the "Save" button is clicked with a valid form', async () => { + const { getByRole, getByPlaceholderText, queryByText } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + }); + const newGroupNameText = 'Updated group name'; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const saveButton = getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveButton).toBeInTheDocument(); + userEvent.click(saveButton); + + await waitFor(() => { + expect( + queryByText(messages.requiredError.defaultMessage), + ).not.toBeInTheDocument(); + }); + expect(onEditClickMock).toHaveBeenCalledTimes(1); + }); + + it('shows error when the "Save" button is clicked with an invalid duplicate form', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderComponent({ + isEditMode: true, + overrideValue: contentGroupsMock.groups[0].name, + }); + const newGroupNameText = contentGroupsMock.groups[2].name; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.clear(newGroupInput); + userEvent.type(newGroupInput, newGroupNameText); + const saveButton = getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveButton).toBeInTheDocument(); + userEvent.click(saveButton); + + await waitFor(() => { + expect( + getByText(messages.invalidMessage.defaultMessage), + ).toBeInTheDocument(); + }); + }); + it('calls onDelete when the "Delete" button is clicked', async () => { + const { getByRole } = renderComponent({ + isEditMode: true, + overrideValue: contentGroupsMock.groups[0].name, + }); + const deleteButton = getByRole('button', { + name: messages.deleteButton.defaultMessage, + }); + expect(deleteButton).toBeInTheDocument(); + userEvent.click(deleteButton); + + expect(onDeleteClickMock).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when the "Cancel" button is clicked', async () => { + const { getByRole } = renderComponent(); + const cancelButton = getByRole('button', { + name: messages.cancelButton.defaultMessage, + }); + expect(cancelButton).toBeInTheDocument(); + userEvent.click(cancelButton); + + expect(onCancelClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx index 8c51818162..489a30ca0b 100644 --- a/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx +++ b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx @@ -1,7 +1,9 @@ -import { render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; import { contentGroupsMock } from '../__mocks__'; +import placeholderMessages from '../empty-placeholder/messages'; import messages from './messages'; import ContentGroupsSection from '.'; @@ -30,4 +32,20 @@ describe('', () => { getByTestId('group-configurations-empty-placeholder'), ).toBeInTheDocument(); }); + + it('renders container with new group on create click if section is empty', async () => { + const { getByRole, getByTestId } = renderComponent({ availableGroup: {} }); + userEvent.click( + getByRole('button', { name: placeholderMessages.button.defaultMessage }), + ); + expect(getByTestId('content-group-new')).toBeInTheDocument(); + }); + + it('renders container with new group on create click if section has groups', async () => { + const { getByRole, getByTestId } = renderComponent(); + userEvent.click( + getByRole('button', { name: messages.addNewGroup.defaultMessage }), + ); + expect(getByTestId('content-group-new')).toBeInTheDocument(); + }); }); diff --git a/src/group-configurations/content-groups-section/index.jsx b/src/group-configurations/content-groups-section/index.jsx index 7b2d8accdf..75c03a8b93 100644 --- a/src/group-configurations/content-groups-section/index.jsx +++ b/src/group-configurations/content-groups-section/index.jsx @@ -1,64 +1,94 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button } from '@edx/paragon'; -import { Add as AddIcon } from '@edx/paragon/icons'; +import { Button, useToggle } from '@openedx/paragon'; +import { Add as AddIcon } from '@openedx/paragon/icons'; import GroupConfigurationContainer from '../group-configuration-container'; +import { availableGroupPropTypes } from '../constants'; +import ContentGroupContainer from './ContentGroupContainer'; import EmptyPlaceholder from '../empty-placeholder'; +import { initialContentGroupObject } from './utils'; import messages from './messages'; -const ContentGroupsSection = ({ availableGroup: { groups, name } }) => { +const ContentGroupsSection = ({ + availableGroup, + groupConfigurationsActions, +}) => { const { formatMessage } = useIntl(); + const [isNewGroupVisible, openNewGroup, hideNewGroup] = useToggle(false); + const { id: parentGroupId, groups, name } = availableGroup; + const groupNames = groups?.map((group) => group.name); + + const handleCreateNewGroup = (values) => { + const updatedContentGroups = { + ...availableGroup, + groups: [ + ...availableGroup.groups, + initialContentGroupObject(values.newGroupName), + ], + }; + groupConfigurationsActions.handleCreateContentGroup(updatedContentGroups, hideNewGroup); + }; + + const handleEditContentGroup = (id, { newGroupName }, callbackToClose) => { + const updatedContentGroups = { + ...availableGroup, + groups: availableGroup.groups.map((group) => (group.id === id ? { ...group, name: newGroupName } : group)), + }; + groupConfigurationsActions.handleEditContentGroup(updatedContentGroups, callbackToClose); + }; + return (
-

{name}

+

+ {name} +

{groups?.length ? ( <> {groups.map((group) => ( - + ))} - + {!isNewGroupVisible && ( + + )} ) : ( - ({})} /> + !isNewGroupVisible && ( + + ) + )} + {isNewGroupVisible && ( + )}
); }; ContentGroupsSection.propTypes = { - availableGroup: PropTypes.shape({ - active: PropTypes.bool, - description: PropTypes.string, - groups: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - usage: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string, - url: PropTypes.string, - }), - ), - version: PropTypes.number, - }), - ), - id: PropTypes.number, - name: PropTypes.string, - parameters: PropTypes.shape({ - courseId: PropTypes.string, - }), - readOnly: PropTypes.bool, - scheme: PropTypes.string, - version: PropTypes.number, + availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired, + groupConfigurationsActions: PropTypes.shape({ + handleCreateContentGroup: PropTypes.func, + handleDeleteContentGroup: PropTypes.func, + handleEditContentGroup: PropTypes.func, }).isRequired, }; diff --git a/src/group-configurations/content-groups-section/messages.js b/src/group-configurations/content-groups-section/messages.js index a70ed2bc3e..30a7695fe6 100644 --- a/src/group-configurations/content-groups-section/messages.js +++ b/src/group-configurations/content-groups-section/messages.js @@ -5,6 +5,46 @@ const messages = defineMessages({ id: 'course-authoring.group-configurations.content-groups.add-new-group', defaultMessage: 'New content group', }, + newGroupHeader: { + id: 'course-authoring.group-configurations.content-groups.new-group.header', + defaultMessage: 'Content group name *', + }, + newGroupInputPlaceholder: { + id: 'course-authoring.group-configurations.content-groups.new-group.input.placeholder', + defaultMessage: 'This is the name of the group', + }, + invalidMessage: { + id: 'course-authoring.group-configurations.content-groups.new-group.invalid-message', + defaultMessage: 'All groups must have a unique name.', + }, + cancelButton: { + id: 'course-authoring.group-configurations.content-groups.new-group.cancel', + defaultMessage: 'Cancel', + }, + deleteButton: { + id: 'course-authoring.group-configurations.content-groups.edit-group.delete', + defaultMessage: 'Delete', + }, + createButton: { + id: 'course-authoring.group-configurations.content-groups.new-group.create', + defaultMessage: 'Create', + }, + saveButton: { + id: 'course-authoring.group-configurations.content-groups.edit-group.save', + defaultMessage: 'Save', + }, + requiredError: { + id: 'course-authoring.group-configurations.content-groups.new-group.required-error', + defaultMessage: 'Group name is required', + }, + alertGroupInUsage: { + id: 'course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage', + defaultMessage: 'This content group is used in one or more units.', + }, + deleteRestriction: { + id: 'course-authoring.group-configurations.content-groups.delete-restriction', + defaultMessage: 'Cannot delete when in use by a unit', + }, }); export default messages; diff --git a/src/group-configurations/content-groups-section/utils.js b/src/group-configurations/content-groups-section/utils.js new file mode 100644 index 0000000000..eeecd16067 --- /dev/null +++ b/src/group-configurations/content-groups-section/utils.js @@ -0,0 +1,9 @@ +const isAlreadyExistsGroup = (groupNames, group) => groupNames.some((name) => name === group); + +const initialContentGroupObject = (groupName) => ({ + name: groupName, + version: 1, + usage: [], +}); + +export { isAlreadyExistsGroup, initialContentGroupObject }; diff --git a/src/group-configurations/data/api.js b/src/group-configurations/data/api.js index 90a3bc15dd..7414ef6fa0 100644 --- a/src/group-configurations/data/api.js +++ b/src/group-configurations/data/api.js @@ -4,7 +4,13 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const API_PATH_PATTERN = 'group_configurations'; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getGroupConfigurationsApiUrl = (courseId) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`; +export const getContentStoreApiUrl = (courseId) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`; +export const getLegacyApiUrl = (courseId, parentGroupId, groupId) => { + const parentUrlPath = `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`; + const parentGroupPath = `${parentGroupId ? `/${parentGroupId}` : ''}`; + const groupPath = `${groupId ? `/${groupId}` : ''}`; + return `${parentUrlPath}${parentGroupPath}${groupPath}`; +}; /** * Get content groups and experimental group configurations for course. @@ -13,7 +19,52 @@ export const getGroupConfigurationsApiUrl = (courseId) => `${getStudioBaseUrl()} */ export async function getGroupConfigurations(courseId) { const { data } = await getAuthenticatedHttpClient().get( - getGroupConfigurationsApiUrl(courseId), + getContentStoreApiUrl(courseId), + ); + + return camelCaseObject(data); +} + +/** + * Create new content group for course. + * @param {string} courseId + * @param {object} group + * @returns {Promise} + */ +export async function createContentGroup(courseId, group) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId, group.id), + group, + ); + + return camelCaseObject(data); +} + +/** + * Edit exists content group in course. + * @param {string} courseId + * @param {object} group + * @returns {Promise} + */ +export async function editContentGroup(courseId, group) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId, group.id), + group, + ); + + return camelCaseObject(data); +} + +/** + * Delete exists content group from the course. + * @param {string} courseId + * @param {number} parentGroupId + * @param {number} groupId + * @returns {Promise} + */ +export async function deleteContentGroup(courseId, parentGroupId, groupId) { + const { data } = await getAuthenticatedHttpClient().delete( + getLegacyApiUrl(courseId, parentGroupId, groupId), ); return camelCaseObject(data); diff --git a/src/group-configurations/data/selectors.js b/src/group-configurations/data/selectors.js index 926fe91617..7f3f0d230d 100644 --- a/src/group-configurations/data/selectors.js +++ b/src/group-configurations/data/selectors.js @@ -1,2 +1,3 @@ export const getGroupConfigurationsData = (state) => state.groupConfigurations.groupConfigurations; export const getLoadingStatus = (state) => state.groupConfigurations.loadingStatus; +export const getSavingStatus = (state) => state.groupConfigurations.savingStatus; diff --git a/src/group-configurations/data/slice.js b/src/group-configurations/data/slice.js index d022f176b9..e4d9d16c59 100644 --- a/src/group-configurations/data/slice.js +++ b/src/group-configurations/data/slice.js @@ -6,6 +6,7 @@ import { RequestStatus } from '../../data/constants'; const slice = createSlice({ name: 'groupConfigurations', initialState: { + savingStatus: '', loadingStatus: RequestStatus.IN_PROGRESS, groupConfigurations: {}, }, @@ -16,12 +17,16 @@ const slice = createSlice({ updateLoadingStatus: (state, { payload }) => { state.loadingStatus = payload.status; }, + updateSavingStatuses: (state, { payload }) => { + state.savingStatus = payload.status; + }, }, }); export const { fetchGroupConfigurations, updateLoadingStatus, + updateSavingStatuses, } = slice.actions; export const { reducer } = slice; diff --git a/src/group-configurations/data/thunk.js b/src/group-configurations/data/thunk.js index bde9ad4101..8f5321c6d4 100644 --- a/src/group-configurations/data/thunk.js +++ b/src/group-configurations/data/thunk.js @@ -1,8 +1,21 @@ import { RequestStatus } from '../../data/constants'; -import { getGroupConfigurations } from './api'; -import { fetchGroupConfigurations, updateLoadingStatus } from './slice'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { + hideProcessingNotification, + showProcessingNotification, +} from '../../generic/processing-notification/data/slice'; +import { + getGroupConfigurations, + createContentGroup, + editContentGroup, + deleteContentGroup, +} from './api'; +import { + fetchGroupConfigurations, + updateLoadingStatus, + updateSavingStatuses, +} from './slice'; -// eslint-disable-next-line import/prefer-default-export export function fetchGroupConfigurationsQuery(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -16,3 +29,55 @@ export function fetchGroupConfigurationsQuery(courseId) { } }; } + +export function createContentGroupQuery(courseId, group) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await createContentGroup(courseId, group); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function editContentGroupQuery(courseId, group) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await editContentGroup(courseId, group); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function deleteContentGroupQuery(courseId, parentGroupId, groupId) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + + try { + await deleteContentGroup(courseId, parentGroupId, groupId); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/group-configurations/enrollment-track-groups-section/index.jsx b/src/group-configurations/enrollment-track-groups-section/index.jsx index 5381536fb6..46642f1f99 100644 --- a/src/group-configurations/enrollment-track-groups-section/index.jsx +++ b/src/group-configurations/enrollment-track-groups-section/index.jsx @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; +import { availableGroupPropTypes } from '../constants'; import GroupConfigurationContainer from '../group-configuration-container'; const EnrollmentTrackGroupsSection = ({ availableGroup: { groups, name } }) => ( @@ -16,31 +17,7 @@ const EnrollmentTrackGroupsSection = ({ availableGroup: { groups, name } }) => ( ); EnrollmentTrackGroupsSection.propTypes = { - availableGroup: PropTypes.shape({ - active: PropTypes.bool, - description: PropTypes.string, - groups: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - usage: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string, - url: PropTypes.string, - }), - ), - version: PropTypes.number, - }), - ), - id: PropTypes.number, - name: PropTypes.string, - parameters: PropTypes.shape({ - courseId: PropTypes.string, - }), - readOnly: PropTypes.bool, - scheme: PropTypes.string, - version: PropTypes.number, - }).isRequired, + availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired, }; export default EnrollmentTrackGroupsSection; diff --git a/src/group-configurations/experiment-configurations-section/index.jsx b/src/group-configurations/experiment-configurations-section/index.jsx index 009f792a7e..e948807b0b 100644 --- a/src/group-configurations/experiment-configurations-section/index.jsx +++ b/src/group-configurations/experiment-configurations-section/index.jsx @@ -12,7 +12,7 @@ const ExperimentConfigurationsSection = ({ availableGroups }) => { return (
-

{formatMessage(messages.title)}

+

{formatMessage(messages.title)}

{availableGroups.length ? ( <> {availableGroups.map((group) => ( diff --git a/src/group-configurations/group-configuration-container/ExperimentGroupStack.jsx b/src/group-configurations/group-configuration-container/ExperimentGroupStack.jsx index 4a06dfa37f..7e71932a49 100644 --- a/src/group-configurations/group-configuration-container/ExperimentGroupStack.jsx +++ b/src/group-configurations/group-configuration-container/ExperimentGroupStack.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { Stack } from '@edx/paragon'; +import { Stack, Truncate } from '@openedx/paragon'; const ExperimentGroupStack = ({ itemList }) => ( @@ -9,7 +9,7 @@ const ExperimentGroupStack = ({ itemList }) => ( data-testid="configuration-card-content__experiment-stack" key={item.id} > - {item.name} + {item.name} 25%
))} diff --git a/src/group-configurations/group-configuration-container/GroupConfigurationContainer.scss b/src/group-configurations/group-configuration-container/GroupConfigurationContainer.scss index 0101bb3af7..03da3f4040 100644 --- a/src/group-configurations/group-configuration-container/GroupConfigurationContainer.scss +++ b/src/group-configurations/group-configuration-container/GroupConfigurationContainer.scss @@ -8,9 +8,9 @@ .configuration-card-header { display: flex; - flex-wrap: wrap; align-items: center; align-content: center; + justify-content: space-between; .configuration-card-header__button { display: flex; @@ -63,6 +63,10 @@ color: $primary-700; } } + + .configuration-card-header__delete-tooltip { + pointer-events: all; + } } .configuration-card-content { @@ -74,10 +78,19 @@ padding: map-get($spacers, 2\.5) 0; margin: 0; color: $primary-500; + gap: $spacer; &:not(:last-child) { border-bottom: .063rem solid $light-400; } } } + + .content-group-new__input .pgn__form-text-invalid .pgn__icon { + display: none; + } + + .pgn__form-control-decorator-group { + margin-inline-end: 0; + } } diff --git a/src/group-configurations/group-configuration-container/TitleButton.jsx b/src/group-configurations/group-configuration-container/TitleButton.jsx index a7ba84ea04..64922c02c8 100644 --- a/src/group-configurations/group-configuration-container/TitleButton.jsx +++ b/src/group-configurations/group-configuration-container/TitleButton.jsx @@ -1,10 +1,12 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button, Stack, Badge } from '@edx/paragon'; +import { + Button, Stack, Badge, Truncate, +} from '@openedx/paragon'; import { ArrowDropDown as ArrowDownIcon, ArrowRight as ArrowRightIcon, -} from '@edx/paragon/icons'; +} from '@openedx/paragon/icons'; import { getCombinedBadgeList } from './utils'; import messages from './messages'; @@ -24,7 +26,7 @@ const TitleButton = ({ onClick={onTitleClick} >
-

{name}

+

{name}

{formatMessage(messages.titleId, { id })} diff --git a/src/group-configurations/group-configuration-container/UsageList.jsx b/src/group-configurations/group-configuration-container/UsageList.jsx index a8eba8caa5..0c15ce32e9 100644 --- a/src/group-configurations/group-configuration-container/UsageList.jsx +++ b/src/group-configurations/group-configuration-container/UsageList.jsx @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import { useParams } from 'react-router'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink, Stack } from '@edx/paragon'; @@ -8,7 +7,6 @@ import messages from './messages'; const UsageList = ({ className, itemList, isExperiment }) => { const { formatMessage } = useIntl(); - const { courseId } = useParams(); const usageDescription = isExperiment ? messages.experimentAccessTo : messages.accessTo; @@ -22,8 +20,8 @@ const UsageList = ({ className, itemList, isExperiment }) => { {itemList.map(({ url, label }) => ( {label} diff --git a/src/group-configurations/group-configuration-container/index.jsx b/src/group-configurations/group-configuration-container/index.jsx index e73161d5a5..bb9a441340 100644 --- a/src/group-configurations/group-configuration-container/index.jsx +++ b/src/group-configurations/group-configuration-container/index.jsx @@ -8,22 +8,36 @@ import { Hyperlink, Icon, IconButtonWithTooltip, -} from '@edx/paragon'; + useToggle, +} from '@openedx/paragon'; import { DeleteOutline as DeleteOutlineIcon, EditOutline as EditOutlineIcon, -} from '@edx/paragon/icons'; +} from '@openedx/paragon/icons'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ContentGroupContainer from '../content-groups-section/ContentGroupContainer'; import ExperimentGroupStack from './ExperimentGroupStack'; import TitleButton from './TitleButton'; import UsageList from './UsageList'; import messages from './messages'; -const GroupConfigurationContainer = ({ group, isExperiment, readOnly }) => { +const GroupConfigurationContainer = ({ + group, + groupNames, + parentGroupId, + isExperiment, + readOnly, + groupConfigurationsActions, + handleEditGroup, +}) => { const { formatMessage } = useIntl(); const { courseId } = useParams(); const [isExpanded, setIsExpanded] = useState(false); + const [isEditMode, switchOnEditMode, switchOffEditMode] = useToggle(false); + const [isOpenDeleteModal, openDeleteModal, closeDeleteModal] = useToggle(false); const { groups: groupsControl, description, usage } = group; + const isUsedInLocation = !!usage.length; const { href: outlineUrl } = new URL( `/course/${courseId}`, @@ -60,64 +74,110 @@ const GroupConfigurationContainer = ({ group, isExperiment, readOnly }) => { setIsExpanded((prevState) => !prevState); }; + const handleDeleteGroup = () => { + groupConfigurationsActions.handleDeleteContentGroup( + parentGroupId, + group.id, + ); + closeDeleteModal(); + }; + return ( -
-
- + {isEditMode ? ( + handleEditGroup(group.id, values, switchOffEditMode)} /> - {!readOnly && ( - - ({})} - data-testid="configuration-card-header-edit" - /> - ({})} - data-testid="configuration-card-header-delete" - /> - - )} -
- {isExpanded && ( -
- {isExperiment && ( - {description} - )} - {isExperiment && } - {usage?.length ? ( - +
+ - ) : ( - displayGuide + {!readOnly && ( + + + + + )} +
+ {isExpanded && ( +
+ {isExperiment && ( + {description} + )} + {isExperiment && ( + + )} + {usage?.length ? ( + + ) : ( + displayGuide + )} +
)}
)} -
+ + ); }; GroupConfigurationContainer.defaultProps = { + group: { + id: undefined, + name: '', + usage: [], + version: undefined, + }, isExperiment: false, readOnly: false, + groupNames: [], + parentGroupId: null, + handleEditGroup: () => ({}), + groupConfigurationsActions: {}, }; GroupConfigurationContainer.propTypes = { @@ -151,9 +211,17 @@ GroupConfigurationContainer.propTypes = { }), readOnly: PropTypes.bool, scheme: PropTypes.string, - }).isRequired, + }), + groupNames: PropTypes.arrayOf(PropTypes.string), + parentGroupId: PropTypes.number, isExperiment: PropTypes.bool, readOnly: PropTypes.bool, + handleEditGroup: PropTypes.func, + groupConfigurationsActions: PropTypes.shape({ + handleCreateContentGroup: PropTypes.func, + handleDeleteContentGroup: PropTypes.func, + handleEditContentGroup: PropTypes.func, + }), }; export default GroupConfigurationContainer; diff --git a/src/group-configurations/group-configuration-container/messages.js b/src/group-configurations/group-configuration-container/messages.js index b369b32aae..98edf1c0d6 100644 --- a/src/group-configurations/group-configuration-container/messages.js +++ b/src/group-configurations/group-configuration-container/messages.js @@ -55,6 +55,14 @@ const messages = defineMessages({ id: 'course-authoring.group-configurations.container.title-id', defaultMessage: 'ID: {id}', }, + subtitleModalDelete: { + id: 'course-authoring.group-configurations.container.delete-modal.subtitle', + defaultMessage: 'content group', + }, + deleteRestriction: { + id: 'course-authoring.group-configurations.container.delete-restriction', + defaultMessage: 'Cannot delete when in use by a unit', + }, }); export default messages; diff --git a/src/group-configurations/group-configuration-container/utils.js b/src/group-configurations/group-configuration-container/utils.js index d67ad2d85b..2084d299f5 100644 --- a/src/group-configurations/group-configuration-container/utils.js +++ b/src/group-configurations/group-configuration-container/utils.js @@ -1,12 +1,13 @@ +import { getConfig } from '@edx/frontend-platform'; + import messages from './messages'; /** * Formats the given URL to a unit page URL. * @param {string} url - The original part of URL. - * @param {string} courseId - The ID of the course. * @returns {string} - The formatted unit page URL. */ -const formatUrlToUnitPage = (url, courseId) => `/course/${courseId}${url}`; +const formatUrlToUnitPage = (url) => new URL(url, getConfig().STUDIO_BASE_URL).href; /** * Retrieves a list of group count based on the number of items. diff --git a/src/group-configurations/hooks.jsx b/src/group-configurations/hooks.jsx index 40ab8869e0..41f99a9c0d 100644 --- a/src/group-configurations/hooks.jsx +++ b/src/group-configurations/hooks.jsx @@ -1,15 +1,61 @@ -import { useDispatch, useSelector } from 'react-redux'; import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import { RequestStatus } from '../data/constants'; -import { fetchGroupConfigurationsQuery } from './data/thunk'; -import { getGroupConfigurationsData, getLoadingStatus } from './data/selectors'; +import { + fetchGroupConfigurationsQuery, + createContentGroupQuery, + editContentGroupQuery, + deleteContentGroupQuery, +} from './data/thunk'; +import { updateSavingStatuses } from './data/slice'; +import { + getGroupConfigurationsData, + getLoadingStatus, + getSavingStatus, +} from './data/selectors'; const useGroupConfigurations = (courseId) => { const dispatch = useDispatch(); - const groupConfigurations = useSelector(getGroupConfigurationsData); const loadingStatus = useSelector(getLoadingStatus); + const savingStatus = useSelector(getSavingStatus); + const { + isShow: isShowProcessingNotification, + title: processingNotificationTitle, + } = useSelector(getProcessingNotification); + + const handleInternetConnectionFailed = () => { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + }; + + const groupConfigurationsActions = { + handleCreateContentGroup: (group, callbackToClose) => { + dispatch(createContentGroupQuery(courseId, group)).then((result) => { + if (result) { + callbackToClose(); + } + }); + }, + handleEditContentGroup: (group, callbackToClose) => { + dispatch(editContentGroupQuery(courseId, group)).then((result) => { + if (result) { + callbackToClose(); + } + }); + }, + handleDeleteContentGroup: (parentGroupId, groupId) => { + dispatch(deleteContentGroupQuery(courseId, parentGroupId, groupId)); + }, + }; + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + dispatch(fetchGroupConfigurationsQuery(courseId)); + dispatch(updateSavingStatuses({ status: '' })); + } + }, [savingStatus]); useEffect(() => { dispatch(fetchGroupConfigurationsQuery(courseId)); @@ -17,7 +63,12 @@ const useGroupConfigurations = (courseId) => { return { isLoading: loadingStatus === RequestStatus.IN_PROGRESS, + savingStatus, + groupConfigurationsActions, groupConfigurations, + isShowProcessingNotification, + processingNotificationTitle, + handleInternetConnectionFailed, }; }; diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx index fe50277d42..c70f58a483 100644 --- a/src/group-configurations/index.jsx +++ b/src/group-configurations/index.jsx @@ -4,10 +4,13 @@ import { Container, Layout, Stack, Row, } from '@edx/paragon'; +import { RequestStatus } from '../data/constants'; import { LoadingSpinner } from '../generic/Loading'; import { useModel } from '../generic/model-store'; import SubHeader from '../generic/sub-header/SubHeader'; import getPageHeadTitle from '../generic/utils'; +import ProcessingNotification from '../generic/processing-notification'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; import messages from './messages'; import ContentGroupsSection from './content-groups-section'; import ExperimentConfigurationsSection from './experiment-configurations-section'; @@ -20,12 +23,17 @@ const GroupConfigurations = ({ courseId }) => { const courseDetails = useModel('courseDetails', courseId); const { isLoading, + savingStatus, + groupConfigurationsActions, + processingNotificationTitle, + isShowProcessingNotification, groupConfigurations: { allGroupConfigurations, shouldShowEnrollmentTrack, shouldShowExperimentGroups, experimentGroupConfigurations, }, + handleInternetConnectionFailed, } = useGroupConfigurations(courseId); document.title = getPageHeadTitle( @@ -68,7 +76,10 @@ const GroupConfigurations = ({ courseId }) => { /> )} {!!contentGroup && ( - + )} {shouldShowExperimentGroups && ( { /> +
+ + +
); }; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7bfe3a9eac..85bddfefbc 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1193,5 +1193,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index e80b779864..487ad1addb 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 87e9e8b69b..bbbdec9f27 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index 44d6f55b87..0c6b077204 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -216,5 +216,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index f104bc0923..d5c45a3390 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index b0ee834f64..3a4f0aedc7 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index a41cdf6031..93685ca002 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4cbc68a89a..496c379204 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1194,5 +1194,17 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "": "Group name already exists", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index df61cd2186..4d814a54e8 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 698b93c41b..1c0520c0c3 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -1194,5 +1194,16 @@ "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", - "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files", + "course-authoring.group-configurations.container.delete-modal.subtitle": "content group", + "course-authoring.group-configurations.container.delete-restriction": "Cannot delete when in use by a unit", + "course-authoring.group-configurations.content-groups.new-group.header": "Content group name *", + "course-authoring.group-configurations.content-groups.new-group.input.placeholder": "This is the name of the group", + "course-authoring.group-configurations.content-groups.new-group.invalid-message": "All groups must have a unique name.", + "course-authoring.group-configurations.content-groups.new-group.cancel": "Cancel", + "course-authoring.group-configurations.content-groups.edit-group.delete": "Delete", + "course-authoring.group-configurations.content-groups.new-group.create": "Create", + "course-authoring.group-configurations.content-groups.edit-group.save": "Save", + "course-authoring.group-configurations.content-groups.new-group.required-error": "Group name is required", + "course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage": "This content group is used in one or more units." }