diff --git a/codecov.yml b/codecov.yml index 64b8f80010..3202455e6d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,4 +10,5 @@ coverage: threshold: 0% ignore: - "src/grading-settings/grading-scale/react-ranger.js" + - "src/generic/DraggableList/verticalSortableList.ts" - "src/index.js" diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 39808ab8a8..93968edfc9 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -27,6 +27,7 @@ import CourseChecklist from './course-checklist'; import GroupConfigurations from './group-configurations'; import { CourseLibraries } from './course-libraries'; import { IframeProvider } from './generic/hooks/context/iFrameContext'; +import { AiAssistantProvider } from './assistant/context/AIAssistantProvider'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -48,101 +49,103 @@ const CourseAuthoringRoutes = () => { const { courseId } = useParams(); return ( - - - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - } - /> - } - /> - {DECODED_ROUTES.COURSE_UNIT.map((path) => ( - } - /> - ))} - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - - + + + + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + } + /> + } + /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + + + ); }; diff --git a/src/advanced-settings/data/api.js b/src/advanced-settings/data/api.js index f240357c9f..92a8a97272 100644 --- a/src/advanced-settings/data/api.js +++ b/src/advanced-settings/data/api.js @@ -1,5 +1,10 @@ -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +/* eslint-disable import/prefer-default-export */ +import { + camelCaseObject, + getConfig, +} from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCase } from 'lodash'; import { convertObjectToSnakeCase } from '../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -14,7 +19,19 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/ export async function getCourseAdvancedSettings(courseId) { const { data } = await getAuthenticatedHttpClient() .get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } /** @@ -26,7 +43,19 @@ export async function getCourseAdvancedSettings(courseId) { export async function updateCourseAdvancedSettings(courseId, settings) { const { data } = await getAuthenticatedHttpClient() .patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings)); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } /** @@ -36,5 +65,17 @@ export async function updateCourseAdvancedSettings(courseId, settings) { */ export async function getProctoringExamErrors(courseId) { const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } diff --git a/src/advanced-settings/data/api.test.js b/src/advanced-settings/data/api.test.js new file mode 100644 index 0000000000..5679d3ebfa --- /dev/null +++ b/src/advanced-settings/data/api.test.js @@ -0,0 +1,236 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + getCourseAdvancedSettings, + updateCourseAdvancedSettings, + getProctoringExamErrors, +} from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('courseSettings API', () => { + const mockHttpClient = { + get: jest.fn(), + patch: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + getAuthenticatedHttpClient.mockReturnValue(mockHttpClient); + }); + + describe('getCourseAdvancedSettings', () => { + it('should fetch and unformat course advanced settings', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', + UPPERCASE: 'To come lowercase', + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.get.mockResolvedValue({ data: fakeData }); + + const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024'); + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`, + ); + expect(result).toEqual(expected); + }); + }); + + describe('updateCourseAdvancedSettings', () => { + it('should update and unformat course advanced settings', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', // because already be camelCase + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted + UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.patch.mockResolvedValue({ data: fakeData }); + + const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {}); + expect(mockHttpClient.patch).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`, + {}, + ); + expect(result).toEqual(expected); + }); + }); + + describe('getProctoringExamErrors', () => { + it('should fetch proctoring errors and return unformat object', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', + UPPERCASE: 'To come lowercase', + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.get.mockResolvedValue({ data: fakeData }); + + const result = await getProctoringExamErrors('course-v1:Test+T101+2024'); + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`, + ); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/src/assistant/AIAssistantChat/AIAssistantChat.scss b/src/assistant/AIAssistantChat/AIAssistantChat.scss new file mode 100644 index 0000000000..9a424ce068 --- /dev/null +++ b/src/assistant/AIAssistantChat/AIAssistantChat.scss @@ -0,0 +1,96 @@ +.ai-assistant-chat { + width: 100%; + max-width: 360px; + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: .875rem; + background-color: #FFFFFF; + box-shadow: 0 4px 12px rgb(0 0 0 / .15); + + &__message-area { + height: 350px; + overflow-y: auto; + padding: 12px; + background-color: #F9F9F9; + display: flex; + flex-direction: column; + gap: 12px; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: rgb(0 0 0 / .2); + border-radius: 3px; + } + } + + &__message { + padding: 8px 12px; + border-radius: 12px; + max-width: 85%; + word-break: break-word; + line-height: 1.4; + color: white; + font-size: 14px; + + &--user { + align-self: flex-end; + background-color: #6C757D; + border-bottom-right-radius: 2px; + } + + &--ai { + align-self: flex-start; + background-color: #007BFF; + border-bottom-left-radius: 2px; + } + } + + &__form { + display: flex; + padding: 12px; + border-top: 1px solid #E0E0E0; + gap: 8px; + align-items: flex-end; + background-color: #FFFFFF; + } + + &__input-wrapper { + flex-grow: 1; + position: relative; + } + + &__textarea { + width: 100%; + resize: none; + min-height: 40px; + max-height: 150px; + padding: 2px !important; + border-radius: 8px !important; + + &:focus { + box-shadow: 0 0 0 .2rem rgb(0 123 255 / .25); + } + + & textarea { + font-size: 16px !important; + max-height: 150px; + } + } + + &__send-btn { + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 !important; + border-radius: 8px !important; + flex-shrink: 0; + } +} diff --git a/src/assistant/AIAssistantChat/hooks.ts b/src/assistant/AIAssistantChat/hooks.ts new file mode 100644 index 0000000000..1780f61363 --- /dev/null +++ b/src/assistant/AIAssistantChat/hooks.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; + +import { useAiAssistant } from '../context/hooks'; +import { UseAIAssistantChatPropsI, AssistantMessage, GenerateAiContentReqI } from '../types'; + +const useAIAssistantChat = ({ xblockType }: UseAIAssistantChatPropsI) => { + const [messages, setMessages] = useState([]); + + const { + generateContent, isAiLoading, aiData, courseId, + } = useAiAssistant(); + + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + + if (lastMessage?.type === 'user' && !isAiLoading) { + const newMessageId = `ai-${Date.now()}`; + + if (aiData) { + setMessages((prev) => [ + ...prev, + { + id: newMessageId, + type: 'ai', + text: 'The content has been successfully generated and inserted into the editor.', + variant: 'success', + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + id: newMessageId, + type: 'ai', + text: 'Failed to generate content. Please try again.', + variant: 'danger', + }, + ]); + } + } + }, [isAiLoading, aiData]); + + const handleSend = (prompt: string) => { + const userPrompt = prompt.trim(); + if (!userPrompt || isAiLoading || !courseId) { return; } + + setMessages((prev) => [ + ...prev, + { + id: `user-${Date.now()}`, + type: 'user', + text: userPrompt, + }, + ]); + + const payload: GenerateAiContentReqI = { + course_id: courseId, + xblock_type: xblockType, + prompt: userPrompt, + }; + generateContent(payload); + }; + + return { + messages, + handleSend, + isAiLoading, + isReady: !!courseId, + }; +}; + +export default useAIAssistantChat; diff --git a/src/assistant/AIAssistantChat/index.tsx b/src/assistant/AIAssistantChat/index.tsx new file mode 100644 index 0000000000..edb30e741e --- /dev/null +++ b/src/assistant/AIAssistantChat/index.tsx @@ -0,0 +1,117 @@ +import { Button, Form, Spinner } from '@openedx/paragon'; +import { Send } from '@openedx/paragon/icons'; + +import { Formik } from 'formik'; +import { Fragment, useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +import { AssistantEditorFormPropI } from '../types'; + +const AIAssistantChat = ({ + messages, + onSend, + isLoading, + isReady, + placeholder, +}: AssistantEditorFormPropI) => { + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (messages && messages.length > 0) { + messagesEndRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }, [messages]); + + const adjustTextareaHeight = (el: HTMLTextAreaElement) => { + if (el) { + // eslint-disable-next-line no-param-reassign + el.style.height = 'auto'; + // eslint-disable-next-line no-param-reassign + el.style.height = `${Math.min(el.scrollHeight, 150)}px`; + } + }; + + return ( +
+
+ {messages && messages.map((msg) => ( + +
+ {msg.text} +
+
+ ))} +
+
+ { + if (!values.prompt.trim()) { + return; + } + onSend(values.prompt); + resetForm(); + + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }} + > + {({ values, handleChange, handleSubmit }) => ( +
+
+ { + handleChange(e); + adjustTextareaHeight(e.target as HTMLTextAreaElement); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ +
+ )} +
+
+ ); +}; + +export default AIAssistantChat; diff --git a/src/assistant/AIAssistantChat/tests/hooks.test.tsx b/src/assistant/AIAssistantChat/tests/hooks.test.tsx new file mode 100644 index 0000000000..636a601d77 --- /dev/null +++ b/src/assistant/AIAssistantChat/tests/hooks.test.tsx @@ -0,0 +1,89 @@ +import useAIAssistantChat from '../hooks'; +import { act, renderHook } from '../../../testUtils'; +import { useAiAssistant } from '../../context/hooks'; + +jest.mock('../../context/hooks', () => ({ + __esModule: true, + useAiAssistant: jest.fn(), +})); + +const mockUseAiAssistant = useAiAssistant as jest.Mock; + +describe('useAIAssistantChat', () => { + const mockGenerateContent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: undefined, + courseId: 'course-v1:test', + }); + }); + + test('should initialize with empty messages', () => { + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + expect(result.current.messages).toEqual([]); + expect(result.current.isReady).toBe(true); + }); + + test('should add user message and call generateContent on handleSend', () => { + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Test prompt'); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].text).toBe('Test prompt'); + expect(result.current.messages[0].type).toBe('user'); + + expect(mockGenerateContent).toHaveBeenCalledWith({ + course_id: 'course-v1:test', + xblock_type: 'html', + prompt: 'Test prompt', + }); + }); + + test('should not send message if courseId is missing', () => { + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: undefined, + courseId: undefined, + }); + + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Test prompt'); + }); + + expect(result.current.messages).toHaveLength(0); + expect(mockGenerateContent).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(false); + }); + + test('should add AI success message when loading finishes with data', () => { + const { result, rerender } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Hello'); + }); + + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: { content: 'Generated HTML' }, + courseId: 'course-v1:test', + }); + + rerender(); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1].type).toBe('ai'); + expect(result.current.messages[1].variant).toBe('success'); + }); +}); diff --git a/src/assistant/AIAssistantChat/tests/index.test.tsx b/src/assistant/AIAssistantChat/tests/index.test.tsx new file mode 100644 index 0000000000..7b9690b7a6 --- /dev/null +++ b/src/assistant/AIAssistantChat/tests/index.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import AIAssistantChat from '..'; +import { + render, screen, waitFor, initializeMocks, +} from '../../../testUtils'; + +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +jest.spyOn(React, 'useEffect').mockImplementation(React.useLayoutEffect); + +describe('AIAssistantChat', () => { + const mockOnSend = jest.fn(); + + const defaultProps = { + messages: [ + { id: '1', type: 'user' as const, text: 'Hello, explain React components.' }, + { id: '2', type: 'ai' as const, text: 'React components are reusable building blocks.' }, + ], + onSend: mockOnSend, + isLoading: false, + isReady: true, + placeholder: 'Type your question...', + }; + + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('should render the chat component and display messages correctly', () => { + render(); + + expect(screen.getByPlaceholderText(/Type your question/i)).toBeInTheDocument(); + expect(screen.getByText(/Hello, explain React components/i)).toBeInTheDocument(); + expect(screen.getByText(/React components are reusable building blocks/i)).toBeInTheDocument(); + }); + + test('should call onSend with prompt and reset the form on submission', async () => { + render(); + + const input = screen.getByPlaceholderText(/Type your question/i); + + const sendButton = screen.getByRole('button'); + + const testPrompt = 'Write a test case for Formik.'; + + userEvent.type(input, testPrompt); + expect(input).toHaveValue(testPrompt); + + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockOnSend).toHaveBeenCalledTimes(1); + }); + + expect(mockOnSend).toHaveBeenCalledWith(testPrompt); + + await waitFor(() => { + expect(input).toHaveValue(''); + }); + }); + + test('should disable the send button when isReady is false', () => { + render(); + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + }); + + test('should display Spinner and disable the button when isLoading is true', () => { + render(); + + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + test('should disable the send button when the input is empty or only whitespace', async () => { + render(); + + const input = screen.getByPlaceholderText(/Type your question/i); + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + + userEvent.type(input, 'test'); + expect(sendButton).not.toBeDisabled(); + + userEvent.clear(input); + expect(sendButton).toBeDisabled(); + + userEvent.type(input, ' '); + expect(sendButton).toBeDisabled(); + }); + + test('should use the provided placeholder text', () => { + const customPlaceholder = 'Ask the AI to do something cool.'; + render(); + + expect(screen.getByPlaceholderText(customPlaceholder)).toBeInTheDocument(); + }); +}); diff --git a/src/assistant/AIAssistantWidget/index.tsx b/src/assistant/AIAssistantWidget/index.tsx new file mode 100644 index 0000000000..eda6d83f6e --- /dev/null +++ b/src/assistant/AIAssistantWidget/index.tsx @@ -0,0 +1,50 @@ +import { Popover, OverlayTrigger, Button } from '@openedx/paragon'; +import { Chat } from '@openedx/paragon/icons'; + +import AIAssistantChat from '../AIAssistantChat'; +import useAIAssistantChat from '../AIAssistantChat/hooks'; +import { UseAIAssistantChatPropsI } from '../types'; + +const AIAssistantWidget = ({ xblockType }: UseAIAssistantChatPropsI) => { + const { + messages, + handleSend, + isAiLoading, + isReady, + } = useAIAssistantChat({ xblockType }); + + const chatPopover = ( + +
+ +
+
+ ); + + return ( + + + + ); +}; + +export default AIAssistantWidget; diff --git a/src/assistant/context/AIAssistantContext.ts b/src/assistant/context/AIAssistantContext.ts new file mode 100644 index 0000000000..d32998fffc --- /dev/null +++ b/src/assistant/context/AIAssistantContext.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +import { AiAssistantContextType } from '../types'; + +export const AiAssistantContext = createContext(null); diff --git a/src/assistant/context/AIAssistantProvider.tsx b/src/assistant/context/AIAssistantProvider.tsx new file mode 100644 index 0000000000..213c496b06 --- /dev/null +++ b/src/assistant/context/AIAssistantProvider.tsx @@ -0,0 +1,29 @@ +import { useParams } from 'react-router-dom'; +import { useMemo } from 'react'; + +import { AiAssistantContext } from './AIAssistantContext'; +import { useGenerateAiContent } from '../data/apiHooks'; + +export const AiAssistantProvider = ({ children }: { children: React.ReactNode }) => { + const { + mutate: generateContent, + isLoading: isAiLoading, + data: aiData, + } = useGenerateAiContent(); + + const params = useParams(); + const courseId = params?.courseId; + + const value = useMemo(() => ({ + generateContent, + isAiLoading, + aiData, + courseId, + }), [generateContent, isAiLoading, aiData, courseId]); + + return ( + + {children} + + ); +}; diff --git a/src/assistant/context/__mocks__/hooks.ts b/src/assistant/context/__mocks__/hooks.ts new file mode 100644 index 0000000000..f594d6d193 --- /dev/null +++ b/src/assistant/context/__mocks__/hooks.ts @@ -0,0 +1,10 @@ +import { AiAssistantContextType } from '../../types'; + +const defaultMockContext: AiAssistantContextType = { + generateContent: jest.fn(), + isAiLoading: false, + aiData: undefined, + courseId: 'mocked-course-v1:Global+MFE+001', +}; + +export const useAiAssistant = jest.fn(() => defaultMockContext); diff --git a/src/assistant/context/hooks.ts b/src/assistant/context/hooks.ts new file mode 100644 index 0000000000..3db5e8de92 --- /dev/null +++ b/src/assistant/context/hooks.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { AiAssistantContext } from './AIAssistantContext'; + +export const useAiAssistant = () => { + const context = useContext(AiAssistantContext); + if (context === null) { + throw new Error('useAiAssistant() must be used within an AiAssistantProvider'); + } + return context; +}; diff --git a/src/assistant/context/tests/AIAssistantProvider.test.tsx b/src/assistant/context/tests/AIAssistantProvider.test.tsx new file mode 100644 index 0000000000..170fb2cbbd --- /dev/null +++ b/src/assistant/context/tests/AIAssistantProvider.test.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'react-router-dom'; + +import { useAiAssistant } from '../hooks'; +import { AiAssistantProvider } from '../AIAssistantProvider'; +import { render, screen, initializeMocks } from '../../../testUtils'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + __esModule: true, + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('../../data/apiHooks', () => ({ + useGenerateAiContent: () => ({ + mutate: jest.fn(), + isLoading: false, + data: undefined, + }), +})); + +const TestConsumer = () => { + const { courseId } = useAiAssistant(); + return
Course ID: {courseId || 'No Course ID'}
; +}; + +const mockUseParams = useParams as jest.Mock; + +describe('AiAssistantProvider', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('renders children without crashing', () => { + mockUseParams.mockReturnValue({ courseId: 'course-v1:test' }); + + render( + +
Test Child
+
, + ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + test('provides courseId from URL params to consumers', () => { + mockUseParams.mockReturnValue({ courseId: 'course-v1:edX+DemoX+Demo_Course' }); + + render( + + + , + ); + + expect(screen.getByText('Course ID: course-v1:edX+DemoX+Demo_Course')).toBeInTheDocument(); + }); + + test('handles missing params gracefully (no crash in unit tests)', () => { + mockUseParams.mockReturnValue({}); + + render( + + + , + ); + + expect(screen.getByText('Course ID: No Course ID')).toBeInTheDocument(); + }); +}); diff --git a/src/assistant/data/api.ts b/src/assistant/data/api.ts new file mode 100644 index 0000000000..0c06f4f167 --- /dev/null +++ b/src/assistant/data/api.ts @@ -0,0 +1,17 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig, camelCaseObject } from '@edx/frontend-platform'; + +import { GenerateAiContentReqI, GenerateAiContentResI } from '../types'; + +const getApiBaseUrl = () => getConfig()?.LMS_BASE_URL || ''; + +export const generateAiContent = async ( + payload: GenerateAiContentReqI, +): Promise => { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post( + `${getApiBaseUrl()}/oex_ai_content_assistant/api/ai-content/generate/`, + payload, + ); + return camelCaseObject(data); +}; diff --git a/src/assistant/data/apiHooks.ts b/src/assistant/data/apiHooks.ts new file mode 100644 index 0000000000..801a95b0e1 --- /dev/null +++ b/src/assistant/data/apiHooks.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { generateAiContent } from './api'; +import { GenerateAiContentReqI } from '../types'; + +export const useGenerateAiContent = () => ( + useMutation({ + mutationFn: (payload: GenerateAiContentReqI) => generateAiContent(payload), + }) +); diff --git a/src/assistant/types.ts b/src/assistant/types.ts new file mode 100644 index 0000000000..10c0df6fc9 --- /dev/null +++ b/src/assistant/types.ts @@ -0,0 +1,37 @@ +export type MessageType = 'user' | 'ai'; + +export interface AssistantMessage { + id: string; + type: MessageType; + text: string; + variant?: 'success' | 'danger'; +} + +export interface GenerateAiContentReqI { + course_id: string; + xblock_type: string; + prompt: string; +} + +export interface GenerateAiContentResI { + content: string; +} + +export interface AiAssistantContextType { + generateContent: (values: GenerateAiContentReqI) => void; + isAiLoading: boolean; + aiData: GenerateAiContentResI | undefined; + courseId: string | undefined; +} + +export interface UseAIAssistantChatPropsI { + xblockType: string; +} + +export interface AssistantEditorFormPropI { + messages: AssistantMessage[]; + onSend: (prompt: string) => void; + isLoading: boolean; + isReady: boolean; + placeholder?: string; +} diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 000badea95..5035b2fe43 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -21,6 +21,7 @@ const path = '/content/:contentId?/*'; const mockOnClose = jest.fn(); const mockSetBlockingSheet = jest.fn(); const mockNavigate = jest.fn(); +const mockSidebarAction = jest.fn(); mockContentTaxonomyTagsData.applyMock(); mockTaxonomyListData.applyMock(); mockTaxonomyTagsData.applyMock(); @@ -40,6 +41,11 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); +jest.mock('../library-authoring/common/context/SidebarContext', () => ({ + ...jest.requireActual('../library-authoring/common/context/SidebarContext'), + useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }), +})); + const renderDrawer = (contentId, drawerParams = {}) => ( render( @@ -184,6 +190,26 @@ describe('', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); + it('should change to edit mode sidebar action is set to JumpToManageTags', async () => { + mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags'); + renderDrawer(stagedTagsId, { variant: 'component' }); + expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + + // Show delete tag buttons + expect(screen.getAllByRole('button', { + name: /delete/i, + }).length).toBe(2); + + // Show add a tag select + expect(screen.getByText(/add a tag/i)).toBeInTheDocument(); + + // Show cancel button + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + + // Show save button + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + it('should change to read mode when click on `Cancel` on drawer variant', async () => { renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); diff --git a/src/content-tags-drawer/ContentTagsDrawer.tsx b/src/content-tags-drawer/ContentTagsDrawer.tsx index 9253205b36..7b278dcce0 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.tsx +++ b/src/content-tags-drawer/ContentTagsDrawer.tsx @@ -14,6 +14,7 @@ import ContentTagsCollapsible from './ContentTagsCollapsible'; import Loading from '../generic/Loading'; import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper'; import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; +import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext'; interface TaxonomyListProps { contentId: string; @@ -244,6 +245,7 @@ const ContentTagsDrawer = ({ if (contentId === undefined) { throw new Error('Error: contentId cannot be null.'); } + const { sidebarAction } = useSidebarContext(); const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer'); const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); @@ -260,6 +262,7 @@ const ContentTagsDrawer = ({ closeToast, setCollapsibleToInitalState, otherTaxonomies, + toEditMode, } = context; let onCloseDrawer: () => void; @@ -302,8 +305,13 @@ const ContentTagsDrawer = ({ // First call of the initial collapsible states React.useEffect(() => { - setCollapsibleToInitalState(); - }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); + // Open tag edit mode when sidebarAction is JumpToManageTags + if (sidebarAction === SidebarActions.JumpToManageTags) { + toEditMode(); + } else { + setCollapsibleToInitalState(); + } + }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]); const renderFooter = () => { if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index c32be8e3f7..01ef3a3a7b 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -7,6 +7,7 @@ import { useMutation, useQueryClient, } from '@tanstack/react-query'; +import { useParams } from 'react-router'; import { getTaxonomyTagsData, getContentTaxonomyTagsData, @@ -14,7 +15,7 @@ import { updateContentTaxonomyTags, getContentTaxonomyTagsCount, } from './api'; -import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; +import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; import { getLibraryId } from '../../generic/key-utils'; /** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */ @@ -129,6 +130,7 @@ export const useContentData = (contentId, enabled) => ( export const useContentTaxonomyTagsUpdater = (contentId) => { const queryClient = useQueryClient(); const unitIframe = window.frames['xblock-iframe']; + const { unitId } = useParams(); return useMutation({ /** @@ -158,6 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => { queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId)); // Invalidate content search to update tags count queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again. + if (unitId) { + queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId)); + } } }, onSuccess: /* istanbul ignore next */ () => { diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 2ce95d8465..ebfaf9e779 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -157,7 +157,7 @@ describe('useContentTaxonomyTagsUpdater', () => { const contentId = 'testerContent'; const taxonomyId = 123; - const mutation = useContentTaxonomyTagsUpdater(contentId); + const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current; const tagsData = [{ taxonomy: taxonomyId, tags: ['tag1', 'tag2'], diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index 5df2fe3134..4c44144246 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -118,6 +118,46 @@ describe('', () => { userEvent.click(reviewActionBtn); expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); }); + + it('show alert if max lastPublishedDate is greated than the local storage value', async () => { + const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z'); + localStorage.setItem( + `outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`, + String(lastPublishedDate.getTime() - 1000), + ); + + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + // review tab should be open by default as outOfSyncCount is greater than 0 + expect(reviewTab).toHaveAttribute('aria-selected', 'true'); + + userEvent.click(allTab); + const alert = await screen.findByRole('alert'); + expect(await within(alert).findByText( + '5 library components are out of sync. Review updates to accept or ignore changes', + )).toBeInTheDocument(); + }); + + it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => { + const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z'); + localStorage.setItem( + `outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`, + String(lastPublishedDate.getTime() + 1000), + ); + + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + // review tab should be open by default as outOfSyncCount is greater than 0 + expect(reviewTab).toHaveAttribute('aria-selected', 'true'); + userEvent.click(allTab); + expect(allTab).toHaveAttribute('aria-selected', 'true'); + + screen.logTestingPlaygroundURL(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); }); describe('', () => { @@ -160,7 +200,7 @@ describe('', () => { it('update changes works', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); @@ -176,7 +216,7 @@ describe('', () => { it('update changes works in preview modal', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); @@ -195,7 +235,7 @@ describe('', () => { it('ignore change works', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); @@ -218,7 +258,7 @@ describe('', () => { it('ignore change works in preview', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index 3c1a0730fe..2d0cfadf0f 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -164,7 +164,7 @@ export const CourseLibraries: React.FC = ({ courseId }) => { if (tabKey !== CourseLibraryTabs.review) { return null; } - if (!outOfSyncCount || outOfSyncCount === 0) { + if (!outOfSyncCount) { return ( diff --git a/src/course-libraries/OutOfSyncAlert.tsx b/src/course-libraries/OutOfSyncAlert.tsx index 27b88f2c83..da36c40869 100644 --- a/src/course-libraries/OutOfSyncAlert.tsx +++ b/src/course-libraries/OutOfSyncAlert.tsx @@ -18,12 +18,11 @@ interface OutOfSyncAlertProps { * in course can be updated. Following are the conditions for displaying the alert. * * * The alert is displayed if components are out of sync. -* * If the user clicks on dismiss button, the state is stored in localstorage of user -* in this format: outOfSyncCountAlert-${courseId} = . -* * If the number of sync components don't change for the course and the user opens outline +* * If the user clicks on dismiss button, the state of dismiss is stored in localstorage of user +* in this format: outOfSyncCountAlert-${courseId} = . +* * If there are not new published components for the course and the user opens outline * in the same browser, they don't see the alert again. -* * If the number changes, i.e., if a new component is out of sync or the user updates or ignores -* a component, the alert is displayed again. +* * If there is a new published component upstream, the alert is displayed again. */ export const OutOfSyncAlert: React.FC = ({ showAlert, @@ -35,6 +34,8 @@ export const OutOfSyncAlert: React.FC = ({ const intl = useIntl(); const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId); const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0); + const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime()) + .reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0); const alertKey = `outOfSyncCountAlert-${courseId}`; useEffect(() => { @@ -46,13 +47,14 @@ export const OutOfSyncAlert: React.FC = ({ setShowAlert(false); return; } - const dismissedAlert = localStorage.getItem(alertKey); - setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount); - }, [outOfSyncCount, isLoading, data]); + const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10); + + setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate); + }, [outOfSyncCount, lastPublishedDate, isLoading, data]); const dismissAlert = () => { setShowAlert(false); - localStorage.setItem(alertKey, String(outOfSyncCount)); + localStorage.setItem(alertKey, Date.now().toString()); onDismiss?.(); }; diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index 1c756c5ca9..36634335bd 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -1,5 +1,5 @@ import React, { - useCallback, useContext, useEffect, useMemo, useState, + useCallback, useContext, useMemo, useState, } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -14,11 +14,9 @@ import { useToggle, } from '@openedx/paragon'; -import { - tail, keyBy, orderBy, merge, omitBy, -} from 'lodash'; +import { tail, keyBy } from 'lodash'; import { useQueryClient } from '@tanstack/react-query'; -import { Loop, Warning } from '@openedx/paragon/icons'; +import { Loop } from '@openedx/paragon/icons'; import messages from './messages'; import previewChangesMessages from '../course-unit/preview-changes/messages'; import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks'; @@ -37,7 +35,6 @@ import { useLoadOnScroll } from '../hooks'; import DeleteModal from '../generic/delete-modal/DeleteModal'; import { PublishableEntityLink } from './data/api'; import AlertError from '../generic/alert-error'; -import AlertMessage from '../generic/alert-message'; interface Props { courseId: string; @@ -102,10 +99,8 @@ const BlockCard: React.FC = ({ info, actions }) => { const ComponentReviewList = ({ outOfSyncComponents, - onSearchUpdate, }: { outOfSyncComponents: PublishableEntityLink[]; - onSearchUpdate: () => void; }) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); @@ -113,24 +108,15 @@ const ComponentReviewList = ({ // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); const { - hits: downstreamInfo, + hits, isLoading: isIndexDataLoading, - searchKeywords, - searchSortOrder, hasError, hasNextPage, isFetchingNextPage, fetchNextPage, - } = useSearchContext() as { - hits: ContentHit[]; - isLoading: boolean; - searchKeywords: string; - searchSortOrder: SearchSortOption; - hasError: boolean; - hasNextPage: boolean | undefined, - isFetchingNextPage: boolean; - fetchNextPage: () => void; - }; + } = useSearchContext(); + + const downstreamInfo = hits as ContentHit[]; useLoadOnScroll( hasNextPage, @@ -143,24 +129,14 @@ const ComponentReviewList = ({ () => keyBy(outOfSyncComponents, 'downstreamUsageKey'), [outOfSyncComponents], ); - const downstreamInfoByKey = useMemo( - () => keyBy(downstreamInfo, 'usageKey'), - [downstreamInfo], - ); const queryClient = useQueryClient(); - useEffect(() => { - if (searchKeywords) { - onSearchUpdate(); - } - }, [searchKeywords]); - // Toggle preview changes modal const [isModalOpen, openModal, closeModal] = useToggle(false); const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const setSeletecdBlockData = (info: ContentHit) => { + const setSelectedBlockData = useCallback((info: ContentHit) => { setBlockData({ displayName: info.displayName, downstreamBlockId: info.usageKey, @@ -168,17 +144,18 @@ const ComponentReviewList = ({ upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced, isVertical: info.blockType === 'vertical', }); - }; + }, [outOfSyncComponentsByKey]); + // Show preview changes on review const onReview = useCallback((info: ContentHit) => { - setSeletecdBlockData(info); + setSelectedBlockData(info); openModal(); - }, [setSeletecdBlockData, openModal]); + }, [setSelectedBlockData, openModal]); const onIgnoreClick = useCallback((info: ContentHit) => { - setSeletecdBlockData(info); + setSelectedBlockData(info); openConfirmModal(); - }, [setSeletecdBlockData, openConfirmModal]); + }, [setSelectedBlockData, openConfirmModal]); const reloadLinks = useCallback((usageKey: string) => { const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey; @@ -236,19 +213,6 @@ const ComponentReviewList = ({ } }, [blockData]); - const orderInfo = useMemo(() => { - if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) { - return downstreamInfo; - } - if (isIndexDataLoading) { - return []; - } - let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey); - merged = omitBy(merged, (o) => !o.displayName); - const ordered = orderBy(Object.values(merged), 'updated', 'desc'); - return ordered; - }, [downstreamInfoByKey, outOfSyncComponentsByKey]); - if (isIndexDataLoading) { return ; } @@ -259,7 +223,7 @@ const ComponentReviewList = ({ return ( <> - {orderInfo?.map((info) => ( + {downstreamInfo?.map((info) => ( ))} - - )} - /> + {blockData && ( + + )} { const intl = useIntl(); const { - data: linkPages, + data: outOfSyncComponents, isLoading: isSyncComponentsLoading, - hasNextPage, - isFetchingNextPage, - fetchNextPage, isError, error, } = useEntityLinks({ courseId, readyToSync: true }); - const outOfSyncComponents = useMemo( - () => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [], - [linkPages], - ); const downstreamKeys = useMemo( () => outOfSyncComponents?.map(link => link.downstreamUsageKey), [outOfSyncComponents], ); - useLoadOnScroll( - hasNextPage, - isFetchingNextPage, - fetchNextPage, - true, - ); - - const onSearchUpdate = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - const disableSortOptions = [ SearchSortOption.RELEVANCE, SearchSortOption.OLDEST, @@ -384,7 +322,6 @@ const ReviewTabContent = ({ courseId }: Props) => { ); diff --git a/src/course-libraries/__mocks__/linkCourseSummary.json b/src/course-libraries/__mocks__/linkCourseSummary.json index 32e98e8987..05039086d2 100644 --- a/src/course-libraries/__mocks__/linkCourseSummary.json +++ b/src/course-libraries/__mocks__/linkCourseSummary.json @@ -3,17 +3,20 @@ "upstreamContextTitle": "CS problems 3", "upstreamContextKey": "lib:OpenedX:CSPROB3", "readyToSyncCount": 5, - "totalCount": 14 + "totalCount": 14, + "lastPublishedAt": "2025-05-01T20:20:44.989042Z" }, { "upstreamContextTitle": "CS problems 2", "upstreamContextKey": "lib:OpenedX:CSPROB2", "readyToSyncCount": 0, - "totalCount": 21 + "totalCount": 21, + "lastPublishedAt": "2025-05-01T21:20:44.989042Z" }, { "upstreamContextTitle": "CS problems", "upstreamContextKey": "lib:OpenedX:CSPROB", - "totalCount": 3 + "totalCount": 3, + "lastPublishedAt": "2025-05-01T22:20:44.989042Z" } ] diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json index 9988dee71a..1dac4b2dbd 100644 --- a/src/course-libraries/__mocks__/publishableEntityLinks.json +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -1,79 +1,72 @@ -{ - "count": 7, - "next": null, - "previous": null, - "num_pages": 1, - "current_page": 1, - "results": [ - { - "id": 875, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 876, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 884, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 26, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 16, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 889, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 890, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - } - ] -} +[ + { + "id": 875, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 876, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 884, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 26, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 16, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 889, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 890, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + } +] diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts index 1f4d0e5bac..3614a51517 100644 --- a/src/course-libraries/data/api.mocks.ts +++ b/src/course-libraries/data/api.mocks.ts @@ -28,27 +28,17 @@ export async function mockGetEntityLinks( case mockGetEntityLinks.courseKeyLoading: return new Promise(() => {}); case mockGetEntityLinks.courseKeyEmpty: - return Promise.resolve({ - next: null, - previous: null, - nextPageNum: null, - previousPageNum: null, - count: 0, - numPages: 0, - currentPage: 0, - results: [], - }); + return Promise.resolve([]); default: { - const { response } = mockGetEntityLinks; + let { response } = mockGetEntityLinks; if (readyToSync !== undefined) { - response.results = response.results.filter((o) => o.readyToSync === readyToSync); - response.count = response.results.length; + response = response.filter((o) => o.readyToSync === readyToSync); } return Promise.resolve(response); } } } -mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey; mockGetEntityLinks.invalidCourseKey = 'course_key_error'; mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading'; mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty'; @@ -85,7 +75,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext( return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response); } } -mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey; mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 4dd04c9bd5..af8108c534 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -38,32 +38,13 @@ export interface PublishableEntityLinkSummary { upstreamContextTitle: string; readyToSyncCount: number; totalCount: number; + lastPublishedAt: string; } export const getEntityLinks = async ( downstreamContextKey?: string, readyToSync?: boolean, upstreamUsageKey?: string, - pageParam?: number, - pageSize?: number, -): Promise> => { - const { data } = await getAuthenticatedHttpClient() - .get(getEntityLinksByDownstreamContextUrl(), { - params: { - course_id: downstreamContextKey, - ready_to_sync: readyToSync, - upstream_usage_key: upstreamUsageKey, - page_size: pageSize, - page: pageParam, - }, - }); - return camelCaseObject(data); -}; - -export const getUnpaginatedEntityLinks = async ( - downstreamContextKey?: string, - readyToSync?: boolean, - upstreamUsageKey?: string, ): Promise => { const { data } = await getAuthenticatedHttpClient() .get(getEntityLinksByDownstreamContextUrl(), { diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx index b46f87c3fa..f1063ce803 100644 --- a/src/course-libraries/data/apiHooks.test.tsx +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { renderHook, waitFor } from '@testing-library/react'; import { getEntityLinksByDownstreamContextUrl } from './api'; -import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks'; +import { useEntityLinks } from './apiHooks'; let axiosMock: MockAdapter; @@ -39,26 +39,11 @@ describe('course libraries api hooks', () => { axiosMock.reset(); }); - it('should return paginated links for course', async () => { - const courseId = 'course-v1:some+key'; - const url = getEntityLinksByDownstreamContextUrl(); - const expectedResult = { - next: null, results: [], previous: null, total: 0, - }; - axiosMock.onGet(url).reply(200, expectedResult); - const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper }); - await waitFor(() => { - expect(result.current.isLoading).toBeFalsy(); - }); - expect(result.current.data?.pages).toEqual([expectedResult]); - expect(axiosMock.history.get[0].url).toEqual(url); - }); - it('should return links for course', async () => { const courseId = 'course-v1:some+key'; const url = getEntityLinksByDownstreamContextUrl(); axiosMock.onGet(url).reply(200, []); - const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper }); + const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBeFalsy(); }); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 2d6b0c0444..093a63121e 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,8 +1,7 @@ import { - useInfiniteQuery, useQuery, } from '@tanstack/react-query'; -import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api'; +import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], @@ -29,39 +28,10 @@ export const courseLibrariesQueryKeys = { }; /** - * Hook to fetch publishable entity links by course key. + * Hook to fetch list of publishable entity links by course key. * (That is, get a list of the library components used in the given course.) */ export const useEntityLinks = ({ - courseId, readyToSync, upstreamUsageKey, pageSize, -}: { - courseId?: string, - readyToSync?: boolean, - upstreamUsageKey?: string, - pageSize?: number -}) => ( - useInfiniteQuery({ - queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ - courseId, - readyToSync, - upstreamUsageKey, - }), - queryFn: ({ pageParam }) => getEntityLinks( - courseId, - readyToSync, - upstreamUsageKey, - pageParam, - pageSize, - ), - getNextPageParam: (lastPage) => lastPage.nextPageNum, - enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined, - }) -); - -/** - * Hook to fetch unpaginated list of publishable entity links by course key. - */ -export const useUnpaginatedEntityLinks = ({ courseId, readyToSync, upstreamUsageKey, }: { courseId?: string, @@ -74,7 +44,7 @@ export const useUnpaginatedEntityLinks = ({ readyToSync, upstreamUsageKey, }), - queryFn: () => getUnpaginatedEntityLinks( + queryFn: () => getEntityLinks( courseId, readyToSync, upstreamUsageKey, diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts index 803084f166..8dc7ab0980 100644 --- a/src/course-libraries/messages.ts +++ b/src/course-libraries/messages.ts @@ -116,11 +116,6 @@ const messages = defineMessages({ defaultMessage: 'Something went wrong! Could not fetch results.', description: 'Generic error message displayed when fetching link data fails.', }, - olderVersionPreviewAlert: { - id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert', - defaultMessage: 'The old version preview is the previous library version', - description: 'Alert message stating that older version in preview is of library block', - }, }); export default messages; diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index e374388cde..2a5e33b09e 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -375,6 +375,7 @@ const CourseOutline = ({ courseId }) => { section, section.childInfo.children, )} + isSectionsExpanded={isSectionsExpanded} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 055f56cdb5..ed6b324000 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash'; import { closestCorners } from '@dnd-kit/core'; import { useLocation } from 'react-router-dom'; -import userEvent from '@testing-library/user-event'; import { getCourseBestPracticesApiUrl, getCourseLaunchApiUrl, @@ -97,12 +96,6 @@ jest.mock('./data/api', () => ({ getTagsCount: () => jest.fn().mockResolvedValue({}), })); -jest.mock('../studio-home/hooks', () => ({ - useStudioHome: () => ({ - librariesV2Enabled: true, - }), -})); - // Mock ComponentPicker to call onComponentSelected on click jest.mock('../library-authoring/component-picker', () => ({ ComponentPicker: (props) => { @@ -160,7 +153,9 @@ describe('', () => { pathname: mockPathname, }); - store = initializeStore(); + store = initializeStore({ + studioHome: { studioHomeData: { librariesV2Enabled: true } }, + }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) @@ -179,6 +174,10 @@ describe('', () => { await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('render CourseOutline component correctly', async () => { const { getByText } = render(); @@ -289,13 +288,15 @@ describe('', () => { }); it('check that new section list is saved when dragged', async () => { - const { findAllByRole } = render(); - const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const { findAllByRole, findByTestId } = render(); + const expandAllButton = await findByTestId('expand-collapse-all-button'); + fireEvent.click(expandAllButton); + const [section] = store.getState().courseOutline.sectionsList; const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = sectionsDraggers[6]; + const draggableButton = sectionsDraggers[1]; axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) + .onPut(getCourseBlockApiUrl(section.id)) .reply(200, { dummy: 'value' }); const section1 = store.getState().courseOutline.sectionsList[0].id; @@ -314,13 +315,15 @@ describe('', () => { }); it('check section list is restored to original order when API call fails', async () => { - const { findAllByRole } = render(); - const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const { findAllByRole, findByTestId } = render(); + const expandAllButton = await findByTestId('expand-collapse-all-button'); + fireEvent.click(expandAllButton); + const [section] = store.getState().courseOutline.sectionsList; const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = sectionsDraggers[6]; + const draggableButton = sectionsDraggers[1]; axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) + .onPut(getCourseBlockApiUrl(section.id)) .reply(500); const section1 = store.getState().courseOutline.sectionsList[0].id; @@ -395,8 +398,6 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const units = await within(subsectionElement).findAllByTestId('unit-card'); expect(units.length).toBe(1); @@ -421,8 +422,6 @@ describe('', () => { render(); const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const units = await within(subsectionElement).findAllByTestId('unit-card'); expect(units.length).toBe(1); @@ -646,8 +645,6 @@ describe('', () => { await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); // check unit - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); @@ -660,8 +657,6 @@ describe('', () => { const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -700,8 +695,6 @@ describe('', () => { const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -771,8 +764,6 @@ describe('', () => { const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1481,8 +1472,6 @@ describe('', () => { const [firstSection] = await findAllByTestId('section-card'); const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card'); - const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(subsectionExpandButton); const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card'); const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button'); @@ -1842,8 +1831,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [, subsection] = section.childInfo.children; const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [, secondUnit] = subsection.childInfo.children; const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1883,8 +1870,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [firstSubsection, subsection] = section.childInfo.children; const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1920,8 +1905,6 @@ describe('', () => { const [subsection] = secondSection.childInfo.children; const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1]; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1966,8 +1949,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [firstSubsection, subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const lastUnitIdx = firstSubsection.childInfo.children.length - 1; const unit = firstSubsection.childInfo.children[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; @@ -2005,8 +1986,6 @@ describe('', () => { const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex]; const thirdSectionFirstSubsection = thirdSection.childInfo.children[0]; const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex]; - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1; const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; @@ -2051,8 +2030,6 @@ describe('', () => { const sections = await findAllByTestId('section-card'); const [sectionElement] = sections; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); // get first and only unit in the subsection const [firstUnit] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -2072,8 +2049,6 @@ describe('', () => { const lastSection = sections[sections.length - 1]; // it has only one subsection const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card'); - const lastExpandBtn = await within(lastSubsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(lastExpandBtn)); // get last and the only unit in the subsection const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card'); @@ -2094,6 +2069,9 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); const [section] = store.getState().courseOutline.sectionsList; const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; @@ -2125,6 +2103,9 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); const [section] = store.getState().courseOutline.sectionsList; const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; @@ -2154,8 +2135,6 @@ describe('', () => { const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const section = store.getState().courseOutline.sectionsList[2]; const [subsection] = section.childInfo.children; - const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = unitDraggers[1]; const sections = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -2190,8 +2169,6 @@ describe('', () => { const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const section = store.getState().courseOutline.sectionsList[2]; const [subsection] = section.childInfo.children; - const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = unitDraggers[1]; const sections = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -2229,8 +2206,6 @@ describe('', () => { .onGet(getXBlockApiUrl(section.id)) .reply(200, courseSectionMock); let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await userEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.jsx index a6d71bd5f0..9f7ee50e77 100644 --- a/src/course-outline/header-navigations/HeaderNavigations.jsx +++ b/src/course-outline/header-navigations/HeaderNavigations.jsx @@ -66,6 +66,8 @@ const HeaderNavigations = ({ {hasSections && (
)} - {isExpanded && ( + {(isExpanded) && (
({ }), })); -jest.mock('../../studio-home/hooks', () => ({ - useStudioHome: () => ({ +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: () => ({ librariesV2Enabled: true, }), })); diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 82df0d28fe..25c9bfd5b7 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -229,12 +229,14 @@ const UnitCard = ({
- + {blockSyncData && ( + + )} ); }; diff --git a/src/course-team/course-team-member/CourseTeamMember.test.jsx b/src/course-team/course-team-member/CourseTeamMember.test.jsx index be6719ac6a..59c0c2dbc3 100644 --- a/src/course-team/course-team-member/CourseTeamMember.test.jsx +++ b/src/course-team/course-team-member/CourseTeamMember.test.jsx @@ -6,6 +6,8 @@ import { USER_ROLES } from '../../constants'; import CourseTeamMember from './CourseTeamMember'; import messages from './messages'; +jest.mock('../../assistant/context/hooks'); + const userNameMock = 'User'; const emailMock = 'user@example.com'; const currentUserEmailMock = 'user@example.com'; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index f63dad09e6..0054a77e24 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -3,8 +3,7 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { - Container, Layout, Stack, Button, TransitionReplace, - Alert, + Alert, Container, Layout, Button, TransitionReplace, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -27,8 +26,6 @@ import AddComponent from './add-component/AddComponent'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import Sequence from './course-sequence'; -import Sidebar from './sidebar'; -import SplitTestSidebarInfo from './sidebar/SplitTestSidebarInfo'; import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks'; import messages from './messages'; import { PasteNotificationAlert } from './clipboard'; @@ -44,6 +41,7 @@ const CourseUnit = ({ courseId }) => { courseUnit, isLoading, sequenceId, + courseUnitLoadingStatus, unitTitle, unitCategory, errorMessage, @@ -140,7 +138,7 @@ const CourseUnit = ({ courseId }) => { /> ) : null} - {courseUnit.upstreamInfo.upstreamLink && ( + {courseUnit.upstreamInfo?.upstreamLink && ( { courseId={courseId} blockId={blockId} isUnitVerticalType={isUnitVerticalType} + courseUnitLoadingStatus={courseUnitLoadingStatus} unitXBlockActions={unitXBlockActions} courseVerticalChildren={courseVerticalChildren.children} handleConfigureSubmit={handleConfigureSubmit} @@ -244,22 +243,15 @@ const CourseUnit = ({ courseId }) => { - - {isUnitVerticalType && ( - - )} - {isSplitTestType && ( - - - - )} - + diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 7ffdd93d72..6985d3ce09 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -65,6 +65,7 @@ import xblockContainerIframeMessages from './xblock-container-iframe/messages'; import headerNavigationsMessages from './header-navigations/messages'; import sidebarMessages from './sidebar/messages'; import messages from './messages'; +import * as selectors from '../data/selectors'; let axiosMock; let store; @@ -166,27 +167,27 @@ describe('', () => { }); it('render CourseUnit component correctly', async () => { - const { getByText, getByRole, getByTestId } = render(); + render(); const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; await waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); }); }); it('renders the course unit iframe with correct attributes', async () => { - const { getByTitle } = render(); + render(); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); expect(iframe).toHaveAttribute('style', 'height: 0px;'); @@ -210,27 +211,27 @@ describe('', () => { }); it('displays an error alert when a studioAjaxError message is received', async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.studioAjaxError, { error: 'Some error text...', }); }); - expect(getByTestId('saving-error-alert')).toBeInTheDocument(); + expect(screen.getByTestId('saving-error-alert')).toBeInTheDocument(); }); it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => { - const { getByTitle } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId }); - const legacyXBlockEditModalIframe = getByTitle( + const legacyXBlockEditModalIframe = screen.getByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).toBeInTheDocument(); @@ -248,14 +249,14 @@ describe('', () => { }); it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => { - const { getByTitle, queryByTitle } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId }); - const legacyXBlockEditModalIframe = queryByTitle( + const legacyXBlockEditModalIframe = screen.queryByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); @@ -263,14 +264,14 @@ describe('', () => { }); it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => { - const { getByTitle, queryByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.saveEditedXBlockData); - const legacyXBlockEditModalIframe = queryByTitle( + const legacyXBlockEditModalIframe = screen.queryByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); @@ -285,7 +286,7 @@ describe('', () => { }); await waitFor(() => { - const courseUnitSidebar = getByTestId('course-unit-sidebar'); + const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); expect( within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), ).toBeInTheDocument(); @@ -304,10 +305,10 @@ describe('', () => { }); it('updates course unit sidebar after receiving refreshPositions message', async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.refreshPositions); }); @@ -321,7 +322,7 @@ describe('', () => { }); await waitFor(() => { - const courseUnitSidebar = getByTestId('course-unit-sidebar'); + const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); expect( within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), ).toBeInTheDocument(); @@ -340,12 +341,10 @@ describe('', () => { }); it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { - const { - getByTitle, getByText, queryByRole, getByRole, - } = render(); + render(); await waitFor(async () => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -356,10 +355,10 @@ describe('', () => { usageId: courseVerticalChildrenMock.children[0].block_id, }); - expect(getByText(/Delete this component?/i)).toBeInTheDocument(); - expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); + expect(screen.getByText(/Delete this component?/i)).toBeInTheDocument(); + expect(screen.getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); - const dialog = getByRole('dialog'); + const dialog = screen.getByRole('dialog'); expect(dialog).toBeInTheDocument(); // Find the Cancel and Delete buttons within the iframe by their specific classes @@ -372,7 +371,7 @@ describe('', () => { usageId: courseVerticalChildrenMock.children[0].block_id, }); - expect(getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); userEvent.click(deleteButton); }); @@ -393,14 +392,14 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); axiosMock @@ -431,28 +430,28 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage .replace('{xblockCount}', updatedCourseVerticalChildren.length), ); // after removing the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -460,14 +459,16 @@ describe('', () => { }); it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { - const { - getByTitle, getByRole, getByText, queryByRole, - } = render(); + render(); simulatePostMessageEvent(messageTypes.duplicateXBlock, { id: courseVerticalChildrenMock.children[0].block_id, }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + axiosMock .onPost(postXBlockBaseApiUrl({ parent_locator: blockId, @@ -478,8 +479,14 @@ describe('', () => { const updatedCourseVerticalChildren = [ ...courseVerticalChildrenMock.children, { - ...courseVerticalChildrenMock.children[0], name: 'New Cloned XBlock', + block_id: '1234567890', + block_type: 'drag-and-drop-v2', + user_partition_info: { + selectable_partitions: [], + selected_partition_index: -1, + selected_groups_label: '', + }, }, ]; @@ -491,9 +498,9 @@ describe('', () => { }); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -522,14 +529,14 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); axiosMock @@ -538,7 +545,7 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -546,21 +553,21 @@ describe('', () => { ); // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -570,19 +577,19 @@ describe('', () => { it('handles CourseUnit header action buttons', async () => { const { open } = window; window.open = jest.fn(); - const { getByRole } = render(); + render(); const { draft_preview_link: draftPreviewLink, published_preview_link: publishedPreviewLink, } = courseSectionVerticalMock; await waitFor(() => { - const viewLiveButton = getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); + const viewLiveButton = screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); userEvent.click(viewLiveButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); - const previewButton = getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); + const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); userEvent.click(previewButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); @@ -592,12 +599,7 @@ describe('', () => { }); it('checks courseUnit title changing when edit query is successfully', async () => { - const { - findByText, - queryByRole, - getByRole, - getByTestId, - } = render(); + render(); let editTitleButton = null; let titleEditField = null; const newDisplayName = `${unitDisplayName} new`; @@ -633,7 +635,7 @@ describe('', () => { }); await waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); editTitleButton = within(unitHeaderTitle) .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); titleEditField = within(unitHeaderTitle) @@ -641,7 +643,7 @@ describe('', () => { }); expect(titleEditField).not.toBeInTheDocument(); userEvent.click(editTitleButton); - titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); await userEvent.clear(titleEditField); await userEvent.type(titleEditField, newDisplayName); @@ -649,9 +651,10 @@ describe('', () => { expect(titleEditField).toHaveValue(newDisplayName); - titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); expect(titleEditField).not.toBeInTheDocument(); - expect(await findByText(newDisplayName)).toBeInTheDocument(); + expect(await screen.findByText(newDisplayName)).toBeInTheDocument(); }); it('doesn\'t handle creating xblock and displays an error message', async () => { @@ -671,15 +674,14 @@ describe('', () => { }); }); - it('handle creating Problem xblock and navigate to editor page', async () => { - const { courseKey, locator } = courseCreateXblockMock; + it('handle creating Problem xblock and showing editor modal', async () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) .reply(200, courseCreateXblockMock); - const { getByText, getByRole } = render(); + render(); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); }); axiosMock @@ -699,13 +701,12 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const problemButton = getByRole('button', { + const problemButton = screen.getByRole('button', { name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), + hidden: true, }); userEvent.click(problemButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/problem/${locator}`); }); axiosMock @@ -715,66 +716,28 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating problem xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); }); - it('handle creating Text xblock and saves scroll position in localStorage', async () => { - const { getByText, getByRole } = render(); - const xblockType = 'text'; - - axiosMock - .onPost(postXBlockBaseApiUrl({ type: xblockType, category: 'html', parentLocator: blockId })) - .reply(200, courseCreateXblockMock); - - window.scrollTo(0, 250); - Object.defineProperty(window, 'scrollY', { value: 250, configurable: true }); - - await waitFor(() => { - const textButton = screen.getByRole('button', { name: /Text/i }); - - expect(getByText(addComponentMessages.title.defaultMessage)).toBeInTheDocument(); - - userEvent.click(textButton); - - const addXBlockDialog = getByRole('dialog'); - expect(addXBlockDialog).toBeInTheDocument(); - - expect(getByText( - addComponentMessages.modalContainerTitle.defaultMessage.replace('{componentTitle}', xblockType), - )).toBeInTheDocument(); - - const textRadio = screen.getByRole('radio', { name: /Text/i }); - userEvent.click(textRadio); - expect(textRadio).toBeChecked(); - - const selectBtn = getByRole('button', { name: addComponentMessages.modalBtnText.defaultMessage }); - expect(selectBtn).toBeInTheDocument(); - - userEvent.click(selectBtn); - }); - - expect(localStorage.getItem('createXBlockLastYPosition')).toBe('250'); - }); - it('correct addition of a new course unit after click on the "Add new unit" button', async () => { - const { getByRole, getAllByTestId } = render(); + render(); let units = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -784,7 +747,7 @@ describe('', () => { ]); await waitFor(async () => { - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; expect(units).toHaveLength(courseUnits.length); }); @@ -801,8 +764,8 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); - units = getAllByTestId('course-unit-btn'); + const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); + units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData .xblock_info.ancestor_info.ancestors[0].child_info.children; @@ -814,7 +777,7 @@ describe('', () => { }); it('the sequence unit is updated after changing the unit header', async () => { - const { getAllByTestId, getByTestId } = render(); + render(); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ @@ -846,7 +809,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const unitHeaderTitle = getByTestId('unit-header-title'); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); userEvent.click(editTitleButton); @@ -858,20 +821,95 @@ describe('', () => { await userEvent.tab(); await waitFor(async () => { - const units = getAllByTestId('course-unit-btn'); + const units = screen.getAllByTestId('course-unit-btn'); expect(units.some(unit => unit.title === newDisplayName)).toBe(true); }); }); - it('handles creating Video xblock and navigates to editor page', async () => { - const { courseKey, locator } = courseCreateXblockMock; + it('handles creating Video xblock and showing editor modal using videogalleryflow', async () => { + const waffleSpy = jest.spyOn(selectors, 'getWaffleFlags').mockReturnValue({ useVideoGalleryFlow: true }); + + axiosMock + .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .reply(200, courseCreateXblockMock); + render(); + + await waitFor(() => { + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + }); + + expect(screen.getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + + const videoButton = screen.getByRole('button', { + name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), + hidden: true, + }); + + userEvent.click(videoButton); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + // after creating video xblock, the sidebar status changes to Draft (unpublished changes) + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(screen.getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); + waffleSpy.mockRestore(); + }); + + it('handles creating Video xblock and showing editor modal', async () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .reply(200, courseCreateXblockMock); - const { getByText, queryByRole, getByRole } = render(); + render(); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); }); axiosMock @@ -892,23 +930,28 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - const videoButton = getByRole('button', { + const videoButton = screen.getByRole('button', { name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), + hidden: true, }); userEvent.click(videoButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); }); + /** TODO -- fix this test. + await waitFor(() => { + expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument(); + }); + */ + axiosMock .onGet(getCourseUnitApiUrl(blockId)) .reply(200, courseUnitIndexMock); @@ -916,45 +959,45 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); }); it('renders course unit details for a draft with unpublished changes', async () => { - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -962,14 +1005,14 @@ describe('', () => { }); it('renders course unit details in the sidebar', async () => { - const { getByText } = render(); + render(); const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id); await waitFor(() => { - expect(getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitLocationId)).toBeInTheDocument(); - expect(getByText(sidebarMessages.unitLocationDescription.defaultMessage + expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitLocationId)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.unitLocationDescription.defaultMessage .replace('{id}', courseUnitLocationId))).toBeInTheDocument(); }); }); @@ -1007,13 +1050,13 @@ describe('', () => { }); it('should toggle visibility from sidebar and update course unit state accordingly', async () => { - const { getByRole, getByTestId } = render(); + render(); let courseUnitSidebar; let draftUnpublishedChangesHeading; let visibilityCheckbox; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); draftUnpublishedChangesHeading = within(courseUnitSidebar) .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); @@ -1050,7 +1093,7 @@ describe('', () => { userEvent.click(visibilityCheckbox); - const modalNotification = getByRole('dialog'); + const modalNotification = screen.getByRole('dialog'); const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage }); const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage }); const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' }); @@ -1082,12 +1125,12 @@ describe('', () => { }); it('should publish course unit after click on the "Publish" button', async () => { - const { getByTestId } = render(); + render(); let courseUnitSidebar; let publishBtn; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }); expect(publishBtn).toBeInTheDocument(); @@ -1121,12 +1164,12 @@ describe('', () => { }); it('should discard changes after click on the "Discard changes" button', async () => { - const { getByTestId, getByRole } = render(); + render(); let courseUnitSidebar; let discardChangesBtn; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); const draftUnpublishedChangesHeading = within(courseUnitSidebar) .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); @@ -1136,7 +1179,7 @@ describe('', () => { userEvent.click(discardChangesBtn); - const modalNotification = getByRole('dialog'); + const modalNotification = screen.getByRole('dialog'); expect(modalNotification).toBeInTheDocument(); expect(within(modalNotification) .getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument(); @@ -1173,7 +1216,7 @@ describe('', () => { }); it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { - const { getByRole, getByTestId } = render(); + render(); let courseUnitSidebar; let sidebarVisibilityCheckbox; let modalVisibilityCheckbox; @@ -1181,16 +1224,16 @@ describe('', () => { let restrictAccessSelect; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); sidebarVisibilityCheckbox = within(courseUnitSidebar) .getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage); expect(sidebarVisibilityCheckbox).not.toBeChecked(); - const headerConfigureBtn = getByRole('button', { name: /settings/i }); + const headerConfigureBtn = screen.getByRole('button', { name: /settings/i }); expect(headerConfigureBtn).toBeInTheDocument(); userEvent.click(headerConfigureBtn); - configureModal = getByTestId('configure-modal'); + configureModal = screen.getByTestId('configure-modal'); restrictAccessSelect = within(configureModal) .getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); expect(within(configureModal) @@ -1246,8 +1289,8 @@ describe('', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', }); - const { getByText } = render(); - await waitFor(() => { expect(getByText('Unit tags')).toBeInTheDocument(); }); + render(); + await waitFor(() => { expect(screen.getByText('Unit tags')).toBeInTheDocument(); }); }); it('hides the Tags sidebar when not enabled', async () => { @@ -1255,15 +1298,13 @@ describe('', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'false', }); - const { queryByText } = render(); - await waitFor(() => { expect(queryByText('Unit tags')).not.toBeInTheDocument(); }); + render(); + await waitFor(() => { expect(screen.queryByText('Unit tags')).not.toBeInTheDocument(); }); }); describe('Copy paste functionality', () => { it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { - const { - getAllByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1275,8 +1316,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); let units = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); @@ -1287,7 +1328,7 @@ describe('', () => { ]); await waitFor(() => { - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; expect(units).toHaveLength(courseUnits.length); }); @@ -1303,7 +1344,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData .xblock_info.ancestor_info.ancestors[0].child_info.children; @@ -1314,7 +1355,7 @@ describe('', () => { }); it('should increase the number of course XBlocks after copying and pasting a block', async () => { - const { getByRole, getByTitle } = render(); + render(); simulatePostMessageEvent(messageTypes.copyXBlock, { id: courseVerticalChildrenMock.children[0].block_id, @@ -1333,11 +1374,11 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: messages.pasteButtonText.defaultMessage })); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -1373,7 +1414,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -1383,9 +1424,7 @@ describe('', () => { }); it('displays a notification about new files after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1397,8 +1436,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1417,7 +1456,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const newFilesAlert = getByTestId('has-new-files-alert'); + const newFilesAlert = screen.getByTestId('has-new-files-alert'); expect(within(newFilesAlert) .getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument(); @@ -1431,13 +1470,11 @@ describe('', () => { userEvent.click(within(newFilesAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-new-files-alert')).toBeNull(); + expect(screen.queryByTestId('has-new-files-alert')).toBeNull(); }); it('displays a notification about conflicting errors after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1449,8 +1486,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1471,7 +1508,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert'); + const conflictingErrorsAlert = screen.getByTestId('has-conflicting-errors-alert'); expect(within(conflictingErrorsAlert) .getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument(); @@ -1485,13 +1522,11 @@ describe('', () => { userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-conflicting-errors-alert')).toBeNull(); + expect(screen.queryByTestId('has-conflicting-errors-alert')).toBeNull(); }); it('displays a notification about error files after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1503,8 +1538,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1525,7 +1560,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const errorFilesAlert = getByTestId('has-error-files-alert'); + const errorFilesAlert = screen.getByTestId('has-error-files-alert'); expect(within(errorFilesAlert) .getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument(); @@ -1534,11 +1569,11 @@ describe('', () => { userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-error-files')).toBeNull(); + expect(screen.queryByTestId('has-error-files')).toBeNull(); }); it('should hide the "Paste component" block if canPasteComponent is false', async () => { - const { queryByText, queryByRole } = render(); + render(); axiosMock .onGet(getCourseVerticalChildrenApiUrl(blockId)) @@ -1549,10 +1584,10 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); - expect(queryByRole('button', { + expect(screen.queryByRole('button', { name: messages.pasteButtonText.defaultMessage, })).not.toBeInTheDocument(); - expect(queryByText( + expect(screen.queryByText( pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, )).not.toBeInTheDocument(); }); @@ -1586,9 +1621,7 @@ describe('', () => { }); it('should display "Move Modal" on receive trigger message', async () => { - const { - getByRole, - } = render(); + render(); await screen.findByText(unitDisplayName); @@ -1602,15 +1635,12 @@ describe('', () => { await screen.findByText( moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), ); - expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); }); it('should navigates to xBlock current unit', async () => { - const { - getByText, - getByRole, - } = render(); + render(); await screen.findByText(unitDisplayName); @@ -1626,7 +1656,7 @@ describe('', () => { ); const currentSection = courseOutlineInfoMock.child_info.children[1]; - const currentSectionItemBtn = getByRole('button', { + const currentSectionItemBtn = screen.getByRole('button', { name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSectionItemBtn).toBeInTheDocument(); @@ -1634,7 +1664,7 @@ describe('', () => { await waitFor(() => { const currentSubsection = currentSection.child_info.children[0]; - const currentSubsectionItemBtn = getByRole('button', { + const currentSubsectionItemBtn = screen.getByRole('button', { name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSubsectionItemBtn).toBeInTheDocument(); @@ -1642,7 +1672,7 @@ describe('', () => { }); await waitFor(() => { - const currentComponentLocationText = getByText( + const currentComponentLocationText = screen.getByText( moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage, ); expect(currentComponentLocationText).toBeInTheDocument(); @@ -1650,9 +1680,7 @@ describe('', () => { }); it('should allow move operation and handles it successfully', async () => { - const { - getByRole, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1676,7 +1704,7 @@ describe('', () => { ); const currentSection = courseOutlineInfoMock.child_info.children[1]; - const currentSectionItemBtn = getByRole('button', { + const currentSectionItemBtn = screen.getByRole('button', { name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSectionItemBtn).toBeInTheDocument(); @@ -1684,7 +1712,7 @@ describe('', () => { const currentSubsection = currentSection.child_info.children[1]; await waitFor(() => { - const currentSubsectionItemBtn = getByRole('button', { + const currentSubsectionItemBtn = screen.getByRole('button', { name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSubsectionItemBtn).toBeInTheDocument(); @@ -1693,14 +1721,14 @@ describe('', () => { await waitFor(() => { const currentUnit = currentSubsection.child_info.children[0]; - const currentUnitItemBtn = getByRole('button', { + const currentUnitItemBtn = screen.getByRole('button', { name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentUnitItemBtn).toBeInTheDocument(); userEvent.click(currentUnitItemBtn); }); - const moveModalBtn = getByRole('button', { + const moveModalBtn = screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage, }); expect(moveModalBtn).toBeInTheDocument(); @@ -1714,10 +1742,7 @@ describe('', () => { }); it('should display "Move Confirmation" alert after moving and undo operations', async () => { - const { - queryByRole, - getByText, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1734,18 +1759,18 @@ describe('', () => { simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator }); - const dismissButton = queryByRole('button', { + const dismissButton = screen.queryByRole('button', { name: /dismiss/i, hidden: true, }); - const undoButton = queryByRole('button', { + const undoButton = screen.queryByRole('button', { name: messages.undoMoveButton.defaultMessage, hidden: true, }); - const newLocationButton = queryByRole('button', { + const newLocationButton = screen.queryByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); - expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); + expect(screen.getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument(); expect(undoButton).toBeInTheDocument(); expect(newLocationButton).toBeInTheDocument(); @@ -1753,9 +1778,9 @@ describe('', () => { userEvent.click(undoButton); await waitFor(() => { - expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); }); - expect(getByText( + expect(screen.getByText( messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title), )).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument(); @@ -1764,9 +1789,7 @@ describe('', () => { }); it('should navigate to new location by button click', async () => { - const { - queryByRole, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1781,7 +1804,7 @@ describe('', () => { callbackFn: requestData.callbackFn, }), store.dispatch); - const newLocationButton = queryByRole('button', { + const newLocationButton = screen.queryByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); userEvent.click(newLocationButton); @@ -1794,16 +1817,14 @@ describe('', () => { describe('XBlock restrict access', () => { it('opens xblock restrict access modal successfully', async () => { - const { - getByTitle, getByTestId, - } = render(); + render(); const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const usageId = courseVerticalChildrenMock.children[0].block_id; expect(iframe).toBeInTheDocument(); @@ -1813,7 +1834,7 @@ describe('', () => { }); await waitFor(() => { - const configureModal = getByTestId('configure-modal'); + const configureModal = screen.getByTestId('configure-modal'); expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); @@ -1822,12 +1843,10 @@ describe('', () => { }); it('closes xblock restrict access modal when cancel button is clicked', async () => { - const { - getByTitle, queryByTestId, getByTestId, - } = render(); + render(); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.manageXBlockAccess, { usageId: courseVerticalChildrenMock.children[0].block_id, @@ -1835,7 +1854,7 @@ describe('', () => { }); await waitFor(() => { - const configureModal = getByTestId('configure-modal'); + const configureModal = screen.getByTestId('configure-modal'); expect(configureModal).toBeInTheDocument(); userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.cancelButton.defaultMessage, @@ -1843,7 +1862,7 @@ describe('', () => { expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); }); - expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); }); it('handles submit xblock restrict access data when save button is clicked', async () => { @@ -1854,15 +1873,13 @@ describe('', () => { }) .reply(200, { dummy: 'value' }); - const { - getByTitle, getByRole, getByTestId, queryByTestId, - } = render(); + render(); const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); }); @@ -1872,13 +1889,13 @@ describe('', () => { }); }); - const configureModal = await waitFor(() => getByTestId('configure-modal')); + const configureModal = await waitFor(() => screen.getByTestId('configure-modal')); expect(configureModal).toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); - const restrictAccessSelect = getByRole('combobox', { + const restrictAccessSelect = screen.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage, }); @@ -1908,17 +1925,17 @@ describe('', () => { expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id)); }); - expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); }); }); const checkLegacyEditModalOnEditMessage = async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const editButton = getByTestId('header-edit-button'); + const editButton = screen.getByTestId('header-edit-button'); expect(editButton).toBeInTheDocument(); - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); userEvent.click(editButton); }); @@ -2105,46 +2122,65 @@ describe('', () => { }); it('should render split test content page correctly', async () => { - const { - getByText, - getByRole, - queryByRole, - getByTestId, - queryByText, - } = render(); + render(); const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components'; waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); - expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); // Sidebar const sidebarContent = [ - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage.replaceAll('{bold_tag}', '') }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage }, - { query: queryByRole, type: 'link', name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }, + { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage }, + { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') }, + { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage }, + { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage + .replaceAll('{bold_tag}', ''), + }, + { + query: screen.queryByRole, + type: 'heading', + name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage, + }, + { + query: screen.queryByRole, + type: 'heading', + name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage, + }, + { + query: screen.queryByRole, + type: 'link', + name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage, + }, ]; sidebarContent.forEach(({ query, type, name }) => { @@ -2152,7 +2188,7 @@ describe('', () => { }); expect( - queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }), + screen.queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }), ).toHaveAttribute('href', helpLinkUrl); }); }); @@ -2165,7 +2201,7 @@ describe('', () => { }); it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { - const { getByTitle } = render(); + render(); const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id; @@ -2174,6 +2210,17 @@ describe('', () => { ? { ...child, block_type: 'html' } : child)); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + + axiosMock + .onPost(postXBlockBaseApiUrl({ + parent_locator: blockId, + duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + })) + .replyOnce(200, { locator: '1234567890' }); + axiosMock .onGet(getCourseVerticalChildrenApiUrl(blockId)) .reply(200, updatedCourseVerticalChildrenMock); @@ -2181,7 +2228,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.currentXBlockId, { id: targetBlockId, diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 3c44f743fb..1b3240a195 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -1,13 +1,14 @@ import { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, StandardModal, useToggle, } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; +import { getWaffleFlags } from '../../data/selectors'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; @@ -16,6 +17,8 @@ import { ComponentPicker } from '../../library-authoring/component-picker'; import { messageTypes } from '../constants'; import { useIframe } from '../../generic/hooks/context/hooks'; import { useEventListener } from '../../generic/hooks'; +import VideoSelectorPage from '../../editors/VideoSelectorPage'; +import EditorPage from '../../editors/EditorPage'; const AddComponent = ({ parentLocator, @@ -24,7 +27,6 @@ const AddComponent = ({ addComponentTemplateData, handleCreateNewCourseXBlock, }) => { - const navigate = useNavigate(); const intl = useIntl(); const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false); @@ -32,10 +34,17 @@ const AddComponent = ({ const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const blockId = addComponentTemplateData.parentLocator || parentLocator; const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); + const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); + + const [blockType, setBlockType] = useState(null); + const [courseId, setCourseId] = useState(null); + const [newBlockId, setNewBlockId] = useState(null); const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle(); const [selectedComponents, setSelectedComponents] = useState([]); const [usageId, setUsageId] = useState(null); const { sendMessageToIframe } = useIframe(); + const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags); const receiveMessage = useCallback(({ data: { type, payload } }) => { if (type === messageTypes.showMultipleComponentPicker) { @@ -54,6 +63,12 @@ const AddComponent = ({ closeSelectLibraryContentModal(); }, [selectedComponents]); + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + const handleLibraryV2Selection = useCallback((selection) => { handleCreateNewCourseXBlock({ type: COMPONENT_TYPES.libraryV2, @@ -71,12 +86,28 @@ const AddComponent = ({ handleCreateNewCourseXBlock({ type, parentLocator: blockId }); break; case COMPONENT_TYPES.problem: - case COMPONENT_TYPES.video: handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { - localStorage.setItem('createXBlockLastYPosition', window.scrollY); - navigate(`/course/${courseKey}/editor/${type}/${locator}`); + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); }); break; + case COMPONENT_TYPES.video: + handleCreateNewCourseXBlock( + { type, parentLocator: blockId }, + /* istanbul ignore next */ ({ courseKey, locator }) => { + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + if (useVideoGalleryFlow) { + showVideoSelectorModal(); + } else { + showXBlockEditorModal(); + } + }, + ); + break; // TODO: The library functional will be a bit different of current legacy (CMS) // behaviour and this ticket is on hold (blocked by other development team). case COMPONENT_TYPES.library: @@ -99,9 +130,11 @@ const AddComponent = ({ type, boilerplate: moduleName, parentLocator: blockId, - }, ({ courseKey, locator }) => { - localStorage.setItem('createXBlockLastYPosition', window.scrollY); - navigate(`/course/${courseKey}/editor/html/${locator}`); + }, /* istanbul ignore next */ ({ courseKey, locator }) => { + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); }); break; default: @@ -201,6 +234,38 @@ const AddComponent = ({ onChangeComponentSelection={setSelectedComponents} /> + +
+ onXBlockSave} + /> +
+
+ {isXBlockEditorModalOpen && ( +
+ onXBlockSave} + /> +
+ )} ); } diff --git a/src/course-unit/add-component/messages.js b/src/course-unit/add-component/messages.js index 31c37238df..117430edbb 100644 --- a/src/course-unit/add-component/messages.js +++ b/src/course-unit/add-component/messages.js @@ -31,6 +31,11 @@ const messages = defineMessages({ defaultMessage: 'Add selected components', description: 'Problem bank component add button text.', }, + videoPickerModalTitle: { + id: 'course-authoring.course-unit.modal.video-title.text', + defaultMessage: 'Select video', + description: 'Video picker modal title.', + }, modalContainerTitle: { id: 'course-authoring.course-unit.modal.container.title', defaultMessage: 'Add {componentTitle} component', diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index 28035e1afd..cb541f1ab8 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre const isLastUnit = !nextUrl; const sequenceIds = useSelector(getSequenceIds); const sequenceIndex = sequenceIds.indexOf(currentSequenceId); - const unitIndex = sequence.unitIds.indexOf(currentUnitId); + let unitIndex = sequence?.unitIds.indexOf(currentUnitId); const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null; - + if (!unitIndex) { + // Handle case where unitIndex is not found + unitIndex = 0; + } let nextLink; const nextIndex = unitIndex + 1; - if (nextIndex < sequence.unitIds.length) { - const nextUnitId = sequence.unitIds[nextIndex]; + if (nextIndex < sequence?.unitIds.length) { + const nextUnitId = sequence?.unitIds[nextIndex]; nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`; } else if (nextSequenceId) { const pathToNextUnit = decodeURIComponent(nextUrl); @@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre const previousIndex = unitIndex - 1; if (previousIndex >= 0) { - const previousUnitId = sequence.unitIds[previousIndex]; + const previousUnitId = sequence?.unitIds[previousIndex]; previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`; } else if (previousSequenceId) { const pathToPreviousUnit = decodeURIComponent(prevUrl); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index 0fa15fa29e..0af7ef63bf 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -35,7 +35,7 @@ const SequenceNavigation = ({ const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; const renderUnitButtons = () => { - if (sequence.unitIds?.length === 0 || unitId === null) { + if (sequence?.unitIds?.length === 0 || unitId === null) { return (
); @@ -43,7 +43,7 @@ const SequenceNavigation = ({ return ( getConfig().STUDIO_BASE_URL; @@ -24,7 +24,9 @@ export async function getCourseUnitData(unitId) { const { data } = await getAuthenticatedHttpClient() .get(getCourseUnitApiUrl(unitId)); - return camelCaseObject(data); + const result = camelCaseObject(data); + result.readOnly = isUnitReadOnly(result); + return result; } /** diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 824b4545d4..872bc85305 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -16,7 +16,7 @@ export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerti export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo; export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus; export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams; -const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; +export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; export const getIsLoading = createSelector( [getLoadingStatuses], loadingStatus => Object.values(loadingStatus) diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index a0c1dc54ec..481b9c6ca8 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -38,7 +38,7 @@ import { updateCourseOutlineInfoLoadingStatus, updateMovedXBlockParams, } from './slice'; -import { getNotificationMessage, isUnitReadOnly } from './utils'; +import { getNotificationMessage } from './utils'; export function fetchCourseUnitQuery(courseId) { return async (dispatch) => { @@ -46,7 +46,6 @@ export function fetchCourseUnitQuery(courseId) { try { const courseUnit = await getCourseUnitData(courseId); - courseUnit.readOnly = isUnitReadOnly(courseUnit); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL })); @@ -262,6 +261,8 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) { callback(courseKey, locator); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); + const courseVerticalChildrenData = await getCourseVerticalChildren(itemId); + dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index fc8fe092eb..f707bae452 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -35,6 +35,7 @@ import { getSavingStatus, getSequenceStatus, getStaticFileNotices, + getLoadingStatuses, } from './data/selectors'; import { changeEditTitleFormOpen, @@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false); const courseUnit = useSelector(getCourseUnitData); + const courseUnitLoadingStatus = useSelector(getLoadingStatuses); const savingStatus = useSelector(getSavingStatus); const isLoading = useSelector(getIsLoading); const errorMessage = useSelector(getErrorMessage); @@ -215,9 +217,28 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }, [isMoveModalOpen]); + useEffect(() => { + const handlePageRefreshUsingStorage = (event) => { + // ignoring tests for if block, because it triggers when someone + // edits the component using editor which has a separate store + /* istanbul ignore next */ + if (event.key === 'courseRefreshTriggerOnComponentEditSave') { + dispatch(fetchCourseSectionVerticalData(blockId, sequenceId)); + dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType)); + localStorage.removeItem(event.key); + } + }; + + window.addEventListener('storage', handlePageRefreshUsingStorage); + return () => { + window.removeEventListener('storage', handlePageRefreshUsingStorage); + }; + }, [blockId, sequenceId, isSplitTestType]); + return { sequenceId, courseUnit, + courseUnitLoadingStatus, unitTitle, unitCategory, errorMessage, diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index d28f02575f..a9775ab83c 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -12,7 +12,6 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.' import { messageTypes } from '../constants'; import { libraryBlockChangesUrl } from '../data/api'; import { ToastActionData } from '../../generic/toast-context'; -import { getLibraryBlockMetadataUrl, getLibraryContainerApiUrl } from '../../library-authoring/data/api'; const usageKey = 'some-id'; const defaultEventData: LibraryChangesMessageData = { @@ -66,7 +65,7 @@ describe('', () => { expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument(); }); - it('renders displayName for units', async () => { + it('renders default displayName for units with no displayName', async () => { render({ ...defaultEventData, isVertical: true, displayName: '' }); expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument(); @@ -78,24 +77,6 @@ describe('', () => { expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument(); }); - it('renders both new and old title if they are different', async () => { - axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, { - displayName: 'New test block', - }); - render(); - - expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument(); - }); - - it('renders both new and old title if they are different on units', async () => { - axiosMock.onGet(getLibraryContainerApiUrl(defaultEventData.upstreamBlockId)).reply(200, { - displayName: 'New test Unit', - }); - render({ ...defaultEventData, isVertical: true, displayName: 'Test Unit' }); - - expect(await screen.findByText('Preview changes: Test Unit -> New test Unit')).toBeInTheDocument(); - }); - it('accept changes works', async () => { axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); render(); @@ -104,7 +85,10 @@ describe('', () => { const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); userEvent.click(acceptBtn); await waitFor(() => { - expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); + expect(mockSendMessageToIframe).toHaveBeenCalledWith( + messageTypes.completeXBlockEditing, + { locator: usageKey }, + ); expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); @@ -119,7 +103,6 @@ describe('', () => { const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); userEvent.click(acceptBtn); await waitFor(() => { - expect(mockSendMessageToIframe).not.toHaveBeenCalledWith(messageTypes.refreshXBlock, null); expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); @@ -137,7 +120,10 @@ describe('', () => { const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' }); userEvent.click(ignoreConfirmBtn); await waitFor(() => { - expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); + expect(mockSendMessageToIframe).toHaveBeenCalledWith( + messageTypes.completeXBlockEditing, + { locator: usageKey }, + ); expect(axiosMock.history.delete.length).toEqual(1); expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index 157aaeab0d..ea3ba6dac6 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -1,20 +1,21 @@ -import React, { useCallback, useContext, useState } from 'react'; +import { useCallback, useContext, useState } from 'react'; import { ActionRow, Button, ModalDialog, useToggle, } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useEventListener } from '../../generic/hooks'; import { messageTypes } from '../constants'; import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget'; import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks'; +import AlertMessage from '../../generic/alert-message'; import { useIframe } from '../../generic/hooks/context/hooks'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; import messages from './messages'; import { ToastContext } from '../../generic/toast-context'; import LoadingButton from '../../generic/loading-button'; import Loading from '../../generic/Loading'; -import { useContainer, useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks'; export interface LibraryChangesMessageData { displayName: string, @@ -25,11 +26,10 @@ export interface LibraryChangesMessageData { } export interface PreviewLibraryXBlockChangesProps { - blockData?: LibraryChangesMessageData, + blockData: LibraryChangesMessageData, isModalOpen: boolean, closeModal: () => void, postChange: (accept: boolean) => void, - alertNode?: React.ReactNode, } /** @@ -41,7 +41,6 @@ export const PreviewLibraryXBlockChanges = ({ isModalOpen, closeModal, postChange, - alertNode, }: PreviewLibraryXBlockChangesProps) => { const { showToast } = useContext(ToastContext); const intl = useIntl(); @@ -49,32 +48,9 @@ export const PreviewLibraryXBlockChanges = ({ // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); - // TODO: Split into two different components to avoid making these two calls in which - // one will definitely fail - const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId); - const { data: unitMetadata } = useContainer(blockData?.upstreamBlockId); - - const metadata = blockData?.isVertical ? unitMetadata : componentMetadata; - const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const getTitle = useCallback(() => { - const oldName = blockData?.displayName; - const newName = metadata?.displayName; - - if (!oldName) { - if (blockData?.isVertical) { - return intl.formatMessage(messages.defaultUnitTitle); - } - return intl.formatMessage(messages.defaultComponentTitle); - } - if (oldName === newName || !newName) { - return intl.formatMessage(messages.title, { blockTitle: oldName }); - } - return intl.formatMessage(messages.diffTitle, { oldName, newName }); - }, [blockData, metadata]); - const getBody = useCallback(() => { if (!blockData) { return ; @@ -108,12 +84,21 @@ export const PreviewLibraryXBlockChanges = ({ } }, [blockData]); + const defaultTitle = intl.formatMessage( + blockData.isVertical + ? messages.defaultUnitTitle + : messages.defaultComponentTitle, + ); + const title = blockData.displayName + ? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName }) + : defaultTitle; + return ( - {getTitle()} + {title} - {alertNode} + {getBody()} @@ -186,12 +176,18 @@ const IframePreviewLibraryXBlockChanges = () => { useEventListener('message', receiveMessage); + if (!blockData) { + return null; + } + + const blockPayload = { locator: blockData.downstreamBlockId }; + return ( sendMessageToIframe(messageTypes.refreshXBlock, null)} + postChange={() => sendMessageToIframe(messageTypes.completeXBlockEditing, blockPayload)} /> ); }; diff --git a/src/course-unit/preview-changes/messages.ts b/src/course-unit/preview-changes/messages.ts index 6b0d778c26..d4d8007338 100644 --- a/src/course-unit/preview-changes/messages.ts +++ b/src/course-unit/preview-changes/messages.ts @@ -6,11 +6,6 @@ const messages = defineMessages({ defaultMessage: 'Preview changes: {blockTitle}', description: 'Preview changes modal title text', }, - diffTitle: { - id: 'authoring.course-unit.preview-changes.modal-diff-title', - defaultMessage: 'Preview changes: {oldName} -> {newName}', - description: 'Preview changes modal title text', - }, defaultUnitTitle: { id: 'authoring.course-unit.preview-changes.modal-default-unit-title', defaultMessage: 'Preview changes: Unit', @@ -61,6 +56,11 @@ const messages = defineMessages({ defaultMessage: 'Ignore', description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.', }, + olderVersionPreviewAlert: { + id: 'course-authoring.review-tab.preview.old-version-alert', + defaultMessage: 'The old version preview is the previous library version', + description: 'Alert message stating that older version in preview is of library block', + }, }); export default messages; diff --git a/src/course-unit/sidebar/utils.js b/src/course-unit/sidebar/utils.js index af3263861f..390d9a3160 100644 --- a/src/course-unit/sidebar/utils.js +++ b/src/course-unit/sidebar/utils.js @@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => { * @param {string} id - The course unit ID. * @returns {string} The clear course unit ID extracted from the provided data. */ -export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1]; +export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1]; diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts index 4775673c1c..3c54a90f29 100644 --- a/src/course-unit/xblock-container-iframe/hooks/types.ts +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -1,11 +1,11 @@ export type UseMessageHandlersTypes = { courseId: string; - navigate: (path: string) => void; dispatch: (action: any) => void; setIframeOffset: (height: number) => void; handleDeleteXBlock: (usageId: string) => void; handleScrollToXBlock: (scrollOffset: number) => void; - handleDuplicateXBlock: (blockType: string, usageId: string) => void; + handleDuplicateXBlock: (usageId: string) => void; + handleEditXBlock: (blockType: string, usageId: string) => void; handleManageXBlockAccess: (usageId: string) => void; handleShowLegacyEditXBlockModal: (id: string) => void; handleCloseLegacyEditorXBlockModal: () => void; @@ -14,7 +14,6 @@ export type UseMessageHandlersTypes = { handleOpenManageTagsModal: (id: string) => void; handleShowProcessingNotification: (variant: string) => void; handleHideProcessingNotification: () => void; - handleRedirectToXBlockEditPage: (payload: { type: string, locator: string }) => void; }; export type MessageHandlersTypes = Record void>; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx index 075418a5ce..daae1b8223 100644 --- a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -16,7 +16,6 @@ import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; */ export const useMessageHandlers = ({ courseId, - navigate, dispatch, setIframeOffset, handleDeleteXBlock, @@ -30,15 +29,15 @@ export const useMessageHandlers = ({ handleOpenManageTagsModal, handleShowProcessingNotification, handleHideProcessingNotification, - handleRedirectToXBlockEditPage, + handleEditXBlock, }: UseMessageHandlersTypes): MessageHandlersTypes => { const { copyToClipboard } = useClipboard(); return useMemo(() => ({ [messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId), [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), - [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), - [messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), + [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => handleEditXBlock(blockType, usageId), + [messageTypes.duplicateXBlock]: ({ usageId }) => handleDuplicateXBlock(usageId), [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), [messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000), [messageTypes.toggleCourseXBlockDropdown]: ({ @@ -52,9 +51,14 @@ export const useMessageHandlers = ({ [messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId), [messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding), [messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting), - [messageTypes.copyXBlockLegacy]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.copying), + [messageTypes.copyXBlockLegacy]: /* istanbul ignore next */ () => handleShowProcessingNotification( + NOTIFICATION_MESSAGES.copying, + ), [messageTypes.hideProcessingNotification]: handleHideProcessingNotification, - [messageTypes.handleRedirectToXBlockEditPage]: (payload) => handleRedirectToXBlockEditPage(payload), + [messageTypes.handleRedirectToXBlockEditPage]: /* istanbul ignore next */ (payload) => handleEditXBlock( + payload.type, + payload.locator, + ), }), [ courseId, handleDeleteXBlock, diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 9e95ee8829..ac6fc92933 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,10 +1,10 @@ +import { getConfig } from '@edx/frontend-platform'; import { FC, useEffect, useState, useMemo, useCallback, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle, Sheet } from '@openedx/paragon'; -import { useDispatch } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { useToggle, Sheet, StandardModal } from '@openedx/paragon'; +import { useDispatch, useSelector } from 'react-redux'; import { hideProcessingNotification, @@ -13,9 +13,9 @@ import { import DeleteModal from '../../generic/delete-modal/DeleteModal'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; import ModalIframe from '../../generic/modal-iframe'; +import { getWaffleFlags } from '../../data/selectors'; import { IFRAME_FEATURE_POLICY } from '../../constants'; import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; -import supportedEditors from '../../editors/supportedEditors'; import { useIframe } from '../../generic/hooks/context/hooks'; import { fetchCourseSectionVerticalData, @@ -35,16 +35,29 @@ import messages from './messages'; import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; import { useIframeContent } from '../../generic/hooks/useIframeContent'; import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; +import VideoSelectorPage from '../../editors/VideoSelectorPage'; +import EditorPage from '../../editors/EditorPage'; +import { RequestStatus } from '../../data/constants'; const XBlockContainerIframe: FC = ({ - courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, + courseId, + blockId, + unitXBlockActions, + courseVerticalChildren, + handleConfigureSubmit, + isUnitVerticalType, + courseUnitLoadingStatus, }) => { const intl = useIntl(); const dispatch = useDispatch(); - const navigate = useNavigate(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); + const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); + const [blockType, setBlockType] = useState(''); + const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags); + const [newBlockId, setNewBlockId] = useState(''); const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); const [iframeOffset, setIframeOffset] = useState(0); const [deleteXBlockId, setDeleteXBlockId] = useState(null); @@ -64,14 +77,44 @@ const XBlockContainerIframe: FC = ({ setIframeRef(iframeRef); }, [setIframeRef]); + useEffect(() => { + const iframe = iframeRef?.current; + if (!iframe) { return undefined; } + + const handleIframeLoad = () => { + if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) { + window.location.reload(); + } + }; + + iframe.addEventListener('load', handleIframeLoad); + + return () => { + iframe.removeEventListener('load', handleIframeLoad); + }; + }, [iframeRef]); + + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + + const handleEditXBlock = useCallback((type: string, id: string) => { + setBlockType(type); + setNewBlockId(id); + if (type === 'video' && useVideoGalleryFlow) { + showVideoSelectorModal(); + } else { + showXBlockEditorModal(); + } + }, [showVideoSelectorModal, showXBlockEditorModal]); + const handleDuplicateXBlock = useCallback( - (blockType: string, usageId: string) => { + (usageId: string) => { unitXBlockActions.handleDuplicate(usageId); - if (supportedEditors[blockType]) { - navigate(`/course/${courseId}/editor/${blockType}/${usageId}`); - } }, - [unitXBlockActions, courseId, navigate], + [unitXBlockActions, courseId], ); const handleDeleteXBlock = (usageId: string) => { @@ -147,13 +190,8 @@ const XBlockContainerIframe: FC = ({ dispatch(hideProcessingNotification()); }; - const handleRedirectToXBlockEditPage = (payload: { type: string, locator: string }) => { - navigate(`/course/${courseId}/editor/${payload.type}/${payload.locator}`); - }; - const messageHandlers = useMessageHandlers({ courseId, - navigate, dispatch, setIframeOffset, handleDeleteXBlock, @@ -167,7 +205,7 @@ const XBlockContainerIframe: FC = ({ handleOpenManageTagsModal, handleShowProcessingNotification, handleHideProcessingNotification, - handleRedirectToXBlockEditPage, + handleEditXBlock, }); useIframeMessages(messageHandlers); @@ -186,6 +224,38 @@ const XBlockContainerIframe: FC = ({ close={closeDeleteModal} onDeleteSubmit={onDeleteSubmit} /> + +
+ onXBlockSave} + /> +
+
+ {isXBlockEditorModalOpen && ( +
+ onXBlockSave} + /> +
+ )} {Object.keys(accessManagedXBlockData).length ? ( void; handleDuplicate: (XBlockId: string | null) => void; diff --git a/src/data/slice.js b/src/data/slice.js index 5953d6d3fa..4f858916b8 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -26,6 +26,7 @@ const slice = createSlice({ useNewCertificatesPage: true, useNewTextbooksPage: true, useNewGroupConfigurationsPage: true, + useVideoGalleryFlow: false, }, }, reducers: { diff --git a/src/editors/Editor.tsx b/src/editors/Editor.tsx index 404fe6c017..4c565451bf 100644 --- a/src/editors/Editor.tsx +++ b/src/editors/Editor.tsx @@ -7,7 +7,6 @@ import * as hooks from './hooks'; import supportedEditors from './supportedEditors'; import type { EditorComponent } from './EditorComponent'; -import { useEditorContext } from './EditorContext'; import AdvancedEditor from './AdvancedEditor'; export interface Props extends EditorComponent { @@ -17,7 +16,6 @@ export interface Props extends EditorComponent { learningContextId: string | null; lmsEndpointUrl: string | null; studioEndpointUrl: string | null; - fullScreen?: boolean; // eslint-disable-line react/no-unused-prop-types } const Editor: React.FC = ({ @@ -42,7 +40,6 @@ const Editor: React.FC = ({ studioEndpointUrl, }, }); - const { fullScreen } = useEditorContext(); const EditorComponent = supportedEditors[blockType]; @@ -60,24 +57,7 @@ const Editor: React.FC = ({ ); } - const innerEditor = ; - - if (fullScreen) { - return ( -
-
- {innerEditor} -
-
- ); - } - return innerEditor; + return ; }; export default Editor; diff --git a/src/editors/EditorContext.tsx b/src/editors/EditorContext.tsx index e43b60a815..7a39298c7d 100644 --- a/src/editors/EditorContext.tsx +++ b/src/editors/EditorContext.tsx @@ -7,14 +7,6 @@ import React from 'react'; */ export interface EditorContext { learningContextId: string; - /** - * When editing components in the libraries part of the Authoring MFE, we show - * the editors in a modal (fullScreen = false). This is the preferred approach - * so that authors can see context behind the modal. - * However, when making edits from the legacy course view, we display the - * editors in a fullscreen view. This approach is deprecated. - */ - fullScreen: boolean; } const context = React.createContext(undefined); @@ -32,7 +24,6 @@ export function useEditorContext() { export const EditorContextProvider: React.FC<{ children: React.ReactNode, learningContextId: string; - fullScreen: boolean; }> = ({ children, ...contextData }) => { const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []); return {children}; diff --git a/src/editors/EditorPage.test.tsx b/src/editors/EditorPage.test.tsx index 66d7ffac95..887cab69dc 100644 --- a/src/editors/EditorPage.test.tsx +++ b/src/editors/EditorPage.test.tsx @@ -8,6 +8,8 @@ import editorCmsApi from './data/services/cms/api'; import EditorPage from './EditorPage'; +jest.mock('../assistant/context/hooks'); + // Mock this plugins component: jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: @@ -37,7 +39,6 @@ const defaultPropsHtml = { lmsEndpointUrl: 'http://lms.test.none/', studioEndpointUrl: 'http://cms.test.none/', onClose: jest.fn(), - fullScreen: false, }; const fieldsHtml = { displayName: 'Introduction to Testing', @@ -66,22 +67,6 @@ describe('EditorPage', () => { expect(modalElement.classList).not.toContain('pgn__modal-fullscreen'); }); - test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => { - jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( - { status: 200, data: snakeCaseObject(fieldsHtml) } - )); - - render(); - - // Then the editor should open - expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument(); - - const modalElement = screen.getByRole('dialog'); - expect(modalElement.classList).toContain('pgn__modal-fullscreen'); - expect(modalElement.classList).not.toContain('pgn__modal'); - expect(modalElement.classList).not.toContain('pgn__modal-xl'); - }); - test('it shows the Advanced Editor if there is no corresponding editor', async () => { jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line { status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } } diff --git a/src/editors/EditorPage.tsx b/src/editors/EditorPage.tsx index bb78903f4d..d7c80ed4e4 100644 --- a/src/editors/EditorPage.tsx +++ b/src/editors/EditorPage.tsx @@ -14,7 +14,6 @@ interface Props extends EditorComponent { isMarkdownEditorEnabledForCourse?: boolean; lmsEndpointUrl?: string; studioEndpointUrl?: string; - fullScreen?: boolean; children?: never; } @@ -31,7 +30,6 @@ const EditorPage: React.FC = ({ studioEndpointUrl = null, onClose = null, returnFunction = null, - fullScreen = true, }) => ( = ({ studioEndpointUrl, }} > - + { const dispatch = useDispatch(); const loading = hooks.useInitializeApp({ @@ -26,7 +28,7 @@ const VideoSelector = ({ return null; } return ( - + ); }; @@ -35,6 +37,8 @@ VideoSelector.propTypes = { learningContextId: PropTypes.string.isRequired, lmsEndpointUrl: PropTypes.string.isRequired, studioEndpointUrl: PropTypes.string.isRequired, + returnFunction: PropTypes.func, + onCancel: PropTypes.func, }; export default VideoSelector; diff --git a/src/editors/VideoSelectorPage.jsx b/src/editors/VideoSelectorPage.jsx index 0d9609b045..1f2fe1a768 100644 --- a/src/editors/VideoSelectorPage.jsx +++ b/src/editors/VideoSelectorPage.jsx @@ -10,6 +10,8 @@ const VideoSelectorPage = ({ courseId, lmsEndpointUrl, studioEndpointUrl, + returnFunction, + onCancel, }) => ( @@ -42,6 +46,8 @@ VideoSelectorPage.propTypes = { courseId: PropTypes.string, lmsEndpointUrl: PropTypes.string, studioEndpointUrl: PropTypes.string, + returnFunction: PropTypes.func, + onCancel: PropTypes.func, }; export default VideoSelectorPage; diff --git a/src/editors/containers/EditorContainer/index.test.tsx b/src/editors/containers/EditorContainer/index.test.tsx index 403f27f85a..c9a899e25c 100644 --- a/src/editors/containers/EditorContainer/index.test.tsx +++ b/src/editors/containers/EditorContainer/index.test.tsx @@ -11,6 +11,13 @@ import editorCmsApi from '../../data/services/cms/api'; import EditorPage from '../../EditorPage'; import * as hooks from './hooks'; +jest.mock('../../../assistant/context/hooks'); + +jest.mock('../../../assistant/AIAssistantWidget', () => ({ + __esModule: true, + default: () =>
, +})); + // Mock this plugins component: jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: @@ -32,7 +39,6 @@ const defaultPropsHtml = { lmsEndpointUrl: 'http://lms.test.none/', studioEndpointUrl: 'http://cms.test.none/', onClose: jest.fn(), - fullScreen: false, }; const fieldsHtml = { displayName: 'Introduction to Testing', diff --git a/src/editors/containers/EditorContainer/index.tsx b/src/editors/containers/EditorContainer/index.tsx index 670eb6ec1f..1e48cd9ce3 100644 --- a/src/editors/containers/EditorContainer/index.tsx +++ b/src/editors/containers/EditorContainer/index.tsx @@ -14,7 +14,6 @@ import { Close } from '@openedx/paragon/icons'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { EditorComponent } from '../../EditorComponent'; -import { useEditorContext } from '../../EditorContext'; import TitleHeader from './components/TitleHeader'; import * as hooks from './hooks'; import messages from './messages'; @@ -24,43 +23,25 @@ import libraryMessages from '../../../library-authoring/add-content/messages'; import './index.scss'; import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty'; import CancelConfirmModal from './components/CancelConfirmModal'; +import AIAssistantWidget from '../../../assistant/AIAssistantWidget'; interface WrapperProps { children: React.ReactNode; } export const EditorModalWrapper: React.FC void }> = ({ children, onClose }) => { - const { fullScreen } = useEditorContext(); const intl = useIntl(); - if (fullScreen) { - return ( -
- {children} -
- ); - } + const title = intl.formatMessage(messages.modalTitle); return ( {children} ); }; -export const EditorModalBody: React.FC = ({ children }) => { - const { fullScreen } = useEditorContext(); - return { children }; -}; +export const EditorModalBody: React.FC = ({ children }) => { children }; -export const FooterWrapper: React.FC = ({ children }) => { - const { fullScreen } = useEditorContext(); - if (fullScreen) { - return
{children}
; - } - // eslint-disable-next-line react/jsx-no-useless-fragment - return <>{ children }; -}; +// eslint-disable-next-line react/jsx-no-useless-fragment +export const FooterWrapper: React.FC = ({ children }) => <>{ children }; interface Props extends EditorComponent { children: React.ReactNode; @@ -150,12 +131,17 @@ const EditorContainer: React.FC = ({

- +
+ + +
diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx index ce2fbd7037..2cef8c36b8 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx @@ -20,7 +20,7 @@ describe('SelectTypeModal', () => { jest.spyOn(hooks, 'onSelect').mockImplementation(mockSelect); // This is a new-style test, unlike most of the old snapshot-based editor tests. render( - + diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index d6c6e81617..2e35f8426c 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -50,16 +50,16 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" />
@@ -226,16 +226,16 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" /> @@ -292,16 +292,16 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" /> diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx index 10bab16dbd..b2d73b1226 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; @@ -18,6 +18,7 @@ import * as hooks from './hooks'; import messages from './messages'; import TinyMceWidget from '../../sharedComponents/TinyMceWidget'; import { prepareEditorRef, replaceStaticWithAsset } from '../../sharedComponents/TinyMceWidget/hooks'; +import { useAiAssistant } from '../../../assistant/context/hooks'; const TextEditor = ({ onClose, @@ -35,8 +36,38 @@ const TextEditor = ({ // inject intl, }) => { - const { editorRef, refReady, setEditorRef } = prepareEditorRef(); - const initialContent = blockValue ? blockValue.data.data : ''; + const { aiData } = useAiAssistant(); + const aiGeneratedContent = aiData?.content ?? aiData; + + const { editorRef, refReady, setEditorRef: internalSetEditorRef } = prepareEditorRef(); + + const setEditorRef = useCallback((ref) => { + internalSetEditorRef(ref); + }, [internalSetEditorRef]); + + const contentFromBlock = blockValue?.data?.data || ''; + const initialContent = contentFromBlock; + + useEffect(() => { + const isEditorReady = showRawEditor + ? !!editorRef.current + : (refReady && !!editorRef.current); + + if (aiGeneratedContent && isEditorReady) { + if (showRawEditor) { + editorRef.current.dispatch({ + changes: { + from: 0, + to: editorRef.current.state.doc.length, + insert: aiGeneratedContent, + }, + }); + } else { + editorRef.current.setContent(aiGeneratedContent); + } + } + }, [aiGeneratedContent, refReady, showRawEditor, editorRef, aiData]); + const newContent = replaceStaticWithAsset({ initialContent, learningContextId, @@ -65,7 +96,7 @@ const TextEditor = ({ editorContentHtml={editorContent} setEditorRef={setEditorRef} minHeight={500} - height="100%" + maxHeight={500} initializeEditor={initializeEditor} {...{ images, diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx index 2b386b3bb8..7675f04ba2 100644 --- a/src/editors/containers/TextEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/index.test.jsx @@ -76,6 +76,8 @@ jest.mock('../../data/redux', () => ({ }, })); +jest.mock('../../../assistant/context/hooks'); + describe('TextEditor', () => { const props = { onClose: jest.fn().mockName('props.onClose'), diff --git a/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap b/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap index 4f5e00fd45..a1f9a2506f 100644 --- a/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap +++ b/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap @@ -18,6 +18,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = ` "useSelector": [MockFunction], } } + onClose={[MockFunction props.onClose]} /> diff --git a/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx b/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx index 66f95b0ab5..55d36c3556 100644 --- a/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx +++ b/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx @@ -7,7 +7,9 @@ import VideoSettingsModal from './VideoSettingsModal'; import { RequestKeys } from '../../../data/constants/requests'; interface Props { + onReturn?: (() => void); isLibrary: boolean; + onClose?: (() => void) | null; } export const { @@ -27,13 +29,15 @@ export const hooks = { const VideoEditorModal: React.FC = ({ isLibrary, + onClose, + onReturn, }) => { const dispatch = useDispatch(); const location = useLocation(); const searchParams = new URLSearchParams(location.search); const selectedVideoId = searchParams.get('selectedVideoId'); const selectedVideoUrl = searchParams.get('selectedVideoUrl'); - const onReturn = hooks.useReturnToGallery(); + const onSettingsReturn = onReturn || hooks.useReturnToGallery(); const isLoaded = useSelector( (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), ); @@ -44,8 +48,9 @@ const VideoEditorModal: React.FC = ({ return ( ); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx index 7edbcb8be2..3718b3ca54 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx @@ -20,11 +20,13 @@ import messages from '../../messages'; interface Props { onReturn: () => void; isLibrary: boolean; + onClose?: (() => void) | null; } const VideoSettingsModal: React.FC = ({ onReturn, isLibrary, + onClose, }) => ( <> {!isLibrary && ( @@ -32,7 +34,7 @@ const VideoSettingsModal: React.FC = ({ variant="link" className="text-primary-500" size="sm" - onClick={onReturn} + onClick={onClose || onReturn} style={{ textDecoration: 'none', marginLeft: '3px', diff --git a/src/editors/containers/VideoEditor/index.tsx b/src/editors/containers/VideoEditor/index.tsx index 0b3018812d..b9d291022c 100644 --- a/src/editors/containers/VideoEditor/index.tsx +++ b/src/editors/containers/VideoEditor/index.tsx @@ -39,7 +39,7 @@ const VideoEditor: React.FC = ({ > {(isCreateWorkflow || studioViewFinished) ? (
- +
) : (
{ const [highlighted, setHighlighted] = React.useState(null); const [ @@ -128,7 +129,10 @@ export const useVideoListProps = ({ }, selectBtnProps: { onClick: () => { - if (highlighted) { + /* istanbul ignore next */ + if (returnFunction) { + returnFunction()(); + } else if (highlighted) { navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`); } else { setShowSelectVideoError(true); @@ -138,10 +142,15 @@ export const useVideoListProps = ({ }; }; -export const useVideoUploadHandler = ({ replace }) => { +export const useVideoUploadHandler = ({ replace, uploadHandler }) => { const learningContextId = useSelector(selectors.app.learningContextId); const blockId = useSelector(selectors.app.blockId); const path = `/course/${learningContextId}/editor/video_upload/${blockId}`; + if (uploadHandler) { + return () => { + uploadHandler(); + }; + } if (replace) { return () => window.location.replace(path); } @@ -191,11 +200,12 @@ export const getstatusBadgeVariant = ({ status }) => { export const getStatusMessage = ({ status }) => Object.values(filterMessages).find((m) => m.defaultMessage === status); -export const useVideoProps = ({ videos }) => { +export const useVideoProps = ({ videos, uploadHandler, returnFunction }) => { const searchSortProps = useSearchAndSortProps(); const videoList = useVideoListProps({ searchSortProps, videos, + returnFunction, }); const { galleryError, @@ -203,7 +213,7 @@ export const useVideoProps = ({ videos }) => { inputError, selectBtnProps, } = videoList; - const fileInput = { click: useVideoUploadHandler({ replace: false }) }; + const fileInput = { click: useVideoUploadHandler({ replace: false, uploadHandler }) }; return { galleryError, diff --git a/src/editors/containers/VideoGallery/index.jsx b/src/editors/containers/VideoGallery/index.jsx index 5f5a8a8ca8..ac25c85268 100644 --- a/src/editors/containers/VideoGallery/index.jsx +++ b/src/editors/containers/VideoGallery/index.jsx @@ -1,5 +1,10 @@ -import React, { useEffect } from 'react'; -import { Image } from '@openedx/paragon'; +import React, { useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Image, useToggle, StandardModal, +} from '@openedx/paragon'; +import { useSearchParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { selectors } from '../../data/redux'; import * as hooks from './hooks'; @@ -8,8 +13,11 @@ import { acceptedImgKeys } from './utils'; import messages from './messages'; import { RequestKeys } from '../../data/constants/requests'; import videoThumbnail from '../../data/images/videoThumbnail.svg'; +import VideoUploadEditor from '../VideoUploadEditor'; +import VideoEditor from '../VideoEditor'; -const VideoGallery = () => { +const VideoGallery = ({ returnFunction, onCancel }) => { + const intl = useIntl(); const rawVideos = useSelector(selectors.app.videos); const isLoaded = useSelector( (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), @@ -21,14 +29,27 @@ const VideoGallery = () => { (state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }), ); const videos = hooks.buildVideos({ rawVideos }); - const handleVideoUpload = hooks.useVideoUploadHandler({ replace: true }); + const [isVideoUploadModalOpen, showVideoUploadModal, closeVideoUploadModal] = useToggle(); + const [isVideoEditorModalOpen, showVideoEditorModal, closeVideoEditorModal] = useToggle(); + const setSearchParams = useSearchParams()[1]; useEffect(() => { - // If no videos exists redirects to the video upload screen + // If no videos exists opens to the video upload modal if (isLoaded && videos.length === 0) { - handleVideoUpload(); + showVideoUploadModal(); } }, [isLoaded]); + + const onVideoUpload = useCallback((videoUrl) => { + closeVideoUploadModal(); + showVideoEditorModal(); + setSearchParams({ selectedVideoUrl: videoUrl }); + }, [closeVideoUploadModal, showVideoEditorModal, setSearchParams]); + + const uploadHandler = useCallback(() => { + showVideoUploadModal(); + }); + const { galleryError, inputError, @@ -36,7 +57,7 @@ const VideoGallery = () => { galleryProps, searchSortProps, selectBtnProps, - } = hooks.useVideoProps({ videos }); + } = hooks.useVideoProps({ videos, uploadHandler, returnFunction }); const handleCancel = hooks.useCancelHandler(); const modalMessages = { @@ -60,8 +81,8 @@ const VideoGallery = () => { { isFetchError, }} /> + +
+ +
+
+ {isVideoEditorModalOpen && ( + + )}
); }; -VideoGallery.propTypes = {}; +VideoGallery.propTypes = { + onCancel: PropTypes.func, + returnFunction: PropTypes.func, +}; export default VideoGallery; diff --git a/src/editors/containers/VideoGallery/index.test.jsx b/src/editors/containers/VideoGallery/index.test.jsx index 1cffffea82..6e45a02d27 100644 --- a/src/editors/containers/VideoGallery/index.test.jsx +++ b/src/editors/containers/VideoGallery/index.test.jsx @@ -6,6 +6,8 @@ import React from 'react'; import { act, fireEvent, render, screen, } from '@testing-library/react'; +import * as reactRouterDom from 'react-router-dom'; +import * as reduxThunks from '../../data/redux'; import VideoGallery from './index'; @@ -120,11 +122,10 @@ describe('VideoGallery', () => { expect(screen.getByText(video.client_video_id)).toBeInTheDocument() )); }); - it('navigates to video upload page when there are no videos', async () => { - expect(window.location.replace).not.toHaveBeenCalled(); + it('renders video upload modal when there are no videos', async () => { updateState({ videos: [] }); await renderComponent(); - expect(window.location.replace).toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: /upload or embed a new video/i })).toBeInTheDocument(); }); it.each([ [/newest/i, [2, 1, 3]], @@ -191,5 +192,36 @@ describe('VideoGallery', () => { expect(screen.queryByText('client_id_1')).not.toBeInTheDocument(); expect(screen.queryByText('client_id_3')).not.toBeInTheDocument(); }); + + it('calls onVideoUpload correctly when a video is uploaded', async () => { + // Mock useSearchParams + const setSearchParams = jest.fn(); + jest.spyOn(reactRouterDom, 'useSearchParams').mockReturnValue([{}, setSearchParams]); + + // Mock the uploadVideo thunk to immediately call postUploadRedirect + jest.spyOn(reduxThunks.thunkActions.video, 'uploadVideo').mockImplementation( + ({ postUploadRedirect }) => () => { + if (postUploadRedirect) { + postUploadRedirect('http://test.video/url.mp4'); + } + return { type: 'MOCK_UPLOAD_VIDEO' }; + }, + ); + + await renderComponent(); + + // Open the upload modal by clicking the button + const openModalButton = screen.getByRole('button', { name: /upload or embed a new video/i }); + fireEvent.click(openModalButton); + + // Wait for the input to appear in the modal + const urlInput = await screen.findByPlaceholderText('Paste your video ID or URL'); + fireEvent.change(urlInput, { target: { value: 'http://test.video/url.mp4' } }); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + fireEvent.click(submitButton); + + expect(setSearchParams).toHaveBeenCalledWith({ selectedVideoUrl: 'http://test.video/url.mp4' }); + }); }); }); diff --git a/src/editors/containers/VideoGallery/messages.js b/src/editors/containers/VideoGallery/messages.js index e26dd63db3..3dd446b7c9 100644 --- a/src/editors/containers/VideoGallery/messages.js +++ b/src/editors/containers/VideoGallery/messages.js @@ -21,7 +21,16 @@ const messages = { defaultMessage: 'Upload or embed a new video', description: 'Label for upload button', }, - + videoUploadModalTitle: { + id: 'authoring.selectvideomodal.upload.title', + defaultMessage: 'Upload or embed a new video', + description: 'Label for upload modal', + }, + videoEditorModalTitle: { + id: 'authoring.selectvideomodal.edit.title', + defaultMessage: 'Edit selected video', + description: 'Label for editor modal', + }, // Sort Dropdown sortByDateNewest: { id: 'authoring.selectvideomodal.sort.datenewest.label', diff --git a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx index 028d1c085a..09d943db83 100644 --- a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx +++ b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx @@ -10,9 +10,9 @@ import { thunkActions } from '../../data/redux'; import * as hooks from './hooks'; import messages from './messages'; -const URLUploader = () => { +const URLUploader = ({ onUpload }) => { const [textInputValue, setTextInputValue] = React.useState(''); - const onURLUpload = hooks.onVideoUpload('selectedVideoUrl'); + const onURLUpload = hooks.onVideoUpload('selectedVideoUrl', onUpload); const intl = useIntl(); return (
@@ -58,16 +58,16 @@ const URLUploader = () => { ); }; -export const VideoUploader = ({ setLoading }) => { +export const VideoUploader = ({ setLoading, onUpload, onClose }) => { const dispatch = useDispatch(); const intl = useIntl(); - const goBack = hooks.useHistoryGoBack(); + const goBack = onClose || hooks.useHistoryGoBack(); const handleProcessUpload = ({ fileData }) => { dispatch(thunkActions.video.uploadVideo({ supportedFiles: [fileData], setLoadSpinner: setLoading, - postUploadRedirect: hooks.onVideoUpload('selectedVideoId'), + postUploadRedirect: hooks.onVideoUpload('selectedVideoId', onUpload), })); }; @@ -85,14 +85,20 @@ export const VideoUploader = ({ setLoading }) => { } + inputComponent={} />
); }; +URLUploader.propTypes = { + onUpload: PropTypes.func, +}; + VideoUploader.propTypes = { setLoading: PropTypes.func.isRequired, + onUpload: PropTypes.func, + onClose: PropTypes.func, }; export default VideoUploader; diff --git a/src/editors/containers/VideoUploadEditor/hooks.js b/src/editors/containers/VideoUploadEditor/hooks.js index a2774d9c60..3cc1f8468e 100644 --- a/src/editors/containers/VideoUploadEditor/hooks.js +++ b/src/editors/containers/VideoUploadEditor/hooks.js @@ -11,15 +11,20 @@ export const { navigateTo, } = appHooks; -export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl') => { +export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl', onUpload = null) => { const learningContextId = selectors.app.learningContextId(storeState); const blockId = selectors.app.blockId(storeState); + if (onUpload) { + return (videoUrl) => { + onUpload(videoUrl, learningContextId, blockId); + }; + } return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?${uploadType}=${videoUrl}`); }; -export const onVideoUpload = (uploadType) => { +export const onVideoUpload = (uploadType, onUpload) => { const storeState = store.getState(); - return module.postUploadRedirect(storeState, uploadType); + return module.postUploadRedirect(storeState, uploadType, onUpload); }; export const useUploadVideo = async ({ diff --git a/src/editors/containers/VideoUploadEditor/index.jsx b/src/editors/containers/VideoUploadEditor/index.jsx index ae2be7b5fb..91664d3e0a 100644 --- a/src/editors/containers/VideoUploadEditor/index.jsx +++ b/src/editors/containers/VideoUploadEditor/index.jsx @@ -1,17 +1,18 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Spinner } from '@openedx/paragon'; import './index.scss'; import messages from './messages'; import { VideoUploader } from './VideoUploader'; -const VideoUploadEditor = () => { +const VideoUploadEditor = ({ onUpload, onClose }) => { const [loading, setLoading] = React.useState(false); const intl = useIntl(); return (!loading) ? (
- +
) : (
{ ); }; +VideoUploadEditor.propTypes = { + onUpload: PropTypes.func, + onClose: PropTypes.func, +}; + export default VideoUploadEditor; diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 8da9f24b71..4c5079a5a9 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -125,6 +125,16 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => { content, onSuccess: (response) => { dispatch(actions.app.setSaveResponse(response)); + const parsedData = JSON.parse(response.config.data); + if (parsedData?.has_changes) { + const storageKey = 'courseRefreshTriggerOnComponentEditSave'; + localStorage.setItem(storageKey, Date.now()); + + window.dispatchEvent(new StorageEvent('storage', { + key: storageKey, + newValue: Date.now().toString(), + })); + } returnToUnit(response.data); }, })); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 35debc7f3c..3f8dc10c9e 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -352,7 +352,11 @@ describe('app thunkActions', () => { }); it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => { dispatch.mockClear(); - const response = 'testRESPONSE'; + const mockParsedData = { has_changes: true }; + const response = { + config: { data: JSON.stringify(mockParsedData) }, + data: {}, + }; calls[1][0].saveBlock.onSuccess(response); expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response)); expect(returnToUnit).toHaveBeenCalled(); diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index 17f7f85b0a..3c7edbe52c 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -10,7 +10,6 @@ import { } from './problem'; import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData'; import { ProblemTypeKeys } from '../../constants/problem'; -import * as requests from './requests'; const mockOlx = 'SOmEVALue'; const mockBuildOlx = jest.fn(() => mockOlx); @@ -72,22 +71,13 @@ describe('problem thunkActions', () => { ); }); test('switchToMarkdownEditor dispatches correct actions', () => { - switchToMarkdownEditor()(dispatch, getState); + switchToMarkdownEditor()(dispatch); expect(dispatch).toHaveBeenCalledWith( actions.problem.updateField({ isMarkdownEditorEnabled: true, }), ); - - expect(dispatch).toHaveBeenCalledWith( - requests.saveBlock({ - content: { - settings: { markdown_edited: true }, - olx: blockValue.data.data, - }, - }), - ); }); describe('switchEditor', () => { @@ -110,7 +100,7 @@ describe('problem thunkActions', () => { test('dispatches switchToMarkdownEditor when editorType is markdown', () => { switchEditor('markdown')(dispatch, getState); - expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch, getState); + expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch); }); }); diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts index 28ba7b34fc..74876fdb1d 100644 --- a/src/editors/data/redux/thunkActions/problem.ts +++ b/src/editors/data/redux/thunkActions/problem.ts @@ -24,17 +24,17 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => { dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX })); }; -export const switchToMarkdownEditor = () => (dispatch, getState) => { - const state = getState(); +export const switchToMarkdownEditor = () => (dispatch) => { dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true })); - const { blockValue } = state.app; - const olx = get(blockValue, 'data.data', ''); - const content = { settings: { markdown_edited: true }, olx }; - // Sending a request to save the problem block with the updated markdown_edited value - dispatch(requests.saveBlock({ content })); }; -export const switchEditor = (editorType) => (dispatch, getState) => (editorType === 'advanced' ? switchToAdvancedEditor : switchToMarkdownEditor)()(dispatch, getState); +export const switchEditor = (editorType) => (dispatch, getState) => { + if (editorType === 'advanced') { + switchToAdvancedEditor()(dispatch, getState); + } else { + switchToMarkdownEditor()(dispatch); + } +}; export const isBlankProblem = ({ rawOLX }) => { if (['', ''].includes(rawOLX.replace(/\s/g, ''))) { diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js index 0eb25a7cc0..9fb92ca1bc 100644 --- a/src/editors/sharedComponents/CodeEditor/hooks.js +++ b/src/editors/sharedComponents/CodeEditor/hooks.js @@ -100,7 +100,7 @@ export const createCodeMirrorDomNode = ({ }) => { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - const languageExtension = CODEMIRROR_LANGUAGES[lang](); + const languageExtension = CODEMIRROR_LANGUAGES[lang] ? CODEMIRROR_LANGUAGES[lang]() : xml(); const cleanText = cleanHTML({ initialText }); const newState = EditorState.create({ doc: cleanText, @@ -115,13 +115,25 @@ export const createCodeMirrorDomNode = ({ ], }); const view = new EditorView({ state: newState, parent: ref.current }); - // eslint-disable-next-line no-param-reassign - upstreamRef.current = view; + + if (typeof upstreamRef === 'function') { + upstreamRef(view); + } else if (upstreamRef) { + // eslint-disable-next-line no-param-reassign + upstreamRef.current = view; + } + view.focus(); return () => { // called on cleanup view.destroy(); + if (typeof upstreamRef === 'function') { + upstreamRef(null); + } else if (upstreamRef) { + // eslint-disable-next-line no-param-reassign + upstreamRef.current = null; + } }; }, []); }; diff --git a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap index 94cfe04497..91ea6175ac 100644 --- a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap @@ -41,6 +41,7 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = ` >
diff --git a/src/editors/sharedComponents/SourceCodeModal/index.jsx b/src/editors/sharedComponents/SourceCodeModal/index.jsx index 88f7ff9f8e..9282a180c4 100644 --- a/src/editors/sharedComponents/SourceCodeModal/index.jsx +++ b/src/editors/sharedComponents/SourceCodeModal/index.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { @@ -41,6 +40,7 @@ const SourceCodeModal = ({ diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js index 5afbba011c..3730e809b5 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js @@ -304,6 +304,7 @@ export const editorConfig = ({ updateContent, content, minHeight, + maxHeight, learningContextId, staticRootUrl, enableImageUpload, @@ -335,6 +336,7 @@ export const editorConfig = ({ content_css: false, content_style: tinyMCEStyles + a11ycheckerCss, min_height: minHeight, + max_height: maxHeight, contextmenu: 'link table', directionality: isLocaleRtl ? 'rtl' : 'ltr', document_base_url: baseURL, diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index be100bd55b..eee448d73d 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -32,6 +32,7 @@ import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; import FileValidationModal from './FileValidationModal'; +import './FilesPage.scss'; const FilesPage = ({ courseId, diff --git a/src/files-and-videos/files-page/FilesPage.scss b/src/files-and-videos/files-page/FilesPage.scss new file mode 100644 index 0000000000..6b27f995ae --- /dev/null +++ b/src/files-and-videos/files-page/FilesPage.scss @@ -0,0 +1,5 @@ +.files-table { + .pgn__data-table-container { + overflow-x: visible; + } +} diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 80bb8f1f8c..948d36a35e 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -70,15 +70,6 @@ const mockStore = async ( } renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); - - // Finish loading the expected files into the data table before returning, - // because loading new files can disrupt things like accessing file menus. - if (status === RequestStatus.SUCCESSFUL) { - const numFiles = skipNextPageFetch ? 13 : 15; - await waitFor(() => { - expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument(); - }); - } }; const emptyMockStore = async (status) => { diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index 3a96779185..4fbe4915c9 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -28,7 +28,7 @@ const slice = createSlice({ if (isEmpty(state.assetIds)) { state.assetIds = payload.assetIds; } else { - state.assetIds = [...state.assetIds, ...payload.assetIds]; + state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])]; } }, setSortedAssetIds: (state, { payload }) => { diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx index bbcb3ab7e1..ffea226564 100644 --- a/src/files-and-videos/generic/DeleteConfirmationModal.jsx +++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -7,6 +7,7 @@ import { AlertModal, Button, Collapsible, + DataTableContext, Hyperlink, Truncate, } from '@openedx/paragon'; @@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({ // injected intl, }) => { + const { clearSelection } = useContext(DataTableContext); + + const handleConfirmDeletion = () => { + handleBulkDelete(); + clearSelection(); + }; + const firstSelectedRow = selectedRows[0]?.original; let activeContentRows = []; if (Array.isArray(selectedRows)) { @@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({ - diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index ded6884a83..219148fd7e 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -273,6 +273,16 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + + {!isEmpty(selectedRows) && ( @@ -286,15 +296,7 @@ const FileTable = ({ sidebar={infoModalSidebar} /> )} - + ); }; diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index 3e813e6c4c..0663ea8498 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -26,13 +26,18 @@ const TableActions = ({ intl, }) => { const [isSortOpen, openSort, closeSort] = useToggle(false); - const { state } = useContext(DataTableContext); + const { state, clearSelection } = useContext(DataTableContext); // This useEffect saves DataTable state so it can persist after table re-renders due to data reload. useEffect(() => { setInitialState(state); }, [state]); + const handleOpenFileSelector = () => { + fileInputControl.click(); + clearSelection(); + }; + return ( <> diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx new file mode 100644 index 0000000000..d97f3b2bcb --- /dev/null +++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMocks, render } from '../../../testUtils'; +import TableActions from './TableActions'; +import messages from '../messages'; + +const defaultProps = { + selectedFlatRows: [], + fileInputControl: { click: jest.fn() }, + handleOpenDeleteConfirmation: jest.fn(), + handleBulkDownload: jest.fn(), + encodingsDownloadUrl: null, + handleSort: jest.fn(), + fileType: 'video', + setInitialState: jest.fn(), + intl: { + formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''), + }, +}; + +const mockColumns = [ + { + id: 'wrapperType', + Header: 'Type', + accessor: 'wrapperType', + filter: 'includes', + }, +]; + +const renderWithContext = (props = {}, contextOverrides = {}) => { + const contextValue = { + state: { + selectedRowIds: {}, + filters: [], + ...contextOverrides.state, + }, + clearSelection: jest.fn(), + gotoPage: jest.fn(), + setAllFilters: jest.fn(), + columns: mockColumns, + ...contextOverrides, + }; + + return render( + + + , + ); +}; + +describe('TableActions', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('renders buttons and dropdown', () => { + renderWithContext(); + + expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument(); + }); + + test('disables bulk and delete actions if no rows selected', () => { + renderWithContext(); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + + const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage); + const deleteButton = screen.getByTestId('open-delete-confirmation-button'); + + expect(downloadOption).toHaveAttribute('aria-disabled', 'true'); + expect(downloadOption).toHaveClass('disabled'); + + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + expect(deleteButton).toHaveClass('disabled'); + }); + + test('enables bulk and delete actions when rows are selected', () => { + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled(); + expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled(); + }); + + test('calls file input click and clears selection when add button clicked', () => { + const mockClick = jest.fn(); + const mockClear = jest.fn(); + + renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear); + fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })); + expect(mockClick).toHaveBeenCalled(); + }); + + test('opens sort modal when sort button clicked', () => { + renderWithContext(); + fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + test('calls handleBulkDownload when selected and clicked', () => { + const handleBulkDownload = jest.fn(); + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + handleBulkDownload, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage)); + expect(handleBulkDownload).toHaveBeenCalled(); + }); + + test('calls handleOpenDeleteConfirmation when clicked', () => { + const handleOpenDeleteConfirmation = jest.fn(); + const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }]; + renderWithContext({ + selectedFlatRows, + handleOpenDeleteConfirmation, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows); + }); + + test('shows encoding download link when provided', () => { + const encodingsDownloadUrl = '/some/path/to/encoding.zip'; + renderWithContext({ encodingsDownloadUrl }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl)); + }); +}); diff --git a/src/generic/DraggableList/DraggableList.jsx b/src/generic/DraggableList/DraggableList.jsx index 1515f29a06..ef86c45f03 100644 --- a/src/generic/DraggableList/DraggableList.jsx +++ b/src/generic/DraggableList/DraggableList.jsx @@ -1,10 +1,9 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import PropTypes from 'prop-types'; import { createPortal } from 'react-dom'; import { DndContext, - closestCenter, KeyboardSensor, PointerSensor, useSensor, @@ -18,6 +17,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { verticalSortableListCollisionDetection } from './verticalSortableList'; const DraggableList = ({ itemList, @@ -56,13 +56,20 @@ const DraggableList = ({ setActiveId?.(event.active.id); }, [setActiveId]); + const handleDragCancel = useCallback(() => { + setActiveId?.(null); + }, [setActiveId]); + return ( { @@ -45,14 +46,15 @@ const SortableItem = ({ }; return ( + /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
{actions} @@ -93,6 +95,7 @@ SortableItem.propTypes = { isClickable: PropTypes.bool, onClick: PropTypes.func, disabled: PropTypes.bool, + cardClassName: PropTypes.string, // injected intl: intlShape.isRequired, }; diff --git a/src/generic/DraggableList/verticalSortableList.ts b/src/generic/DraggableList/verticalSortableList.ts new file mode 100644 index 0000000000..0d3409e32a --- /dev/null +++ b/src/generic/DraggableList/verticalSortableList.ts @@ -0,0 +1,80 @@ +/* istanbul ignore file */ +/** +This sorting strategy was copied over from https://github.com/clauderic/dnd-kit/pull/805 +to resolve issues with variable sized draggables. +*/ +import { CollisionDetection, DroppableContainer } from '@dnd-kit/core'; +import { sortBy } from 'lodash'; + +const collision = (dropppableContainer?: DroppableContainer) => ({ + id: dropppableContainer?.id ?? '', + value: dropppableContainer, +}); + +// Look for the first (/ furthest up / highest) droppable container that is at least +// 50% covered by the top edge of the dragging container. +const highestDroppableContainerMajorityCovered: CollisionDetection = ({ + droppableContainers, + collisionRect, +}) => { + const ascendingDroppabaleContainers = sortBy( + droppableContainers, + (c) => c?.rect.current?.top, + ); + + for (const droppableContainer of ascendingDroppabaleContainers) { + const { + rect: { current: droppableRect }, + } = droppableContainer; + + if (droppableRect) { + const coveredPercentage = (droppableRect.top + droppableRect.height - collisionRect.top) + / droppableRect.height; + + if (coveredPercentage > 0.5) { + return [collision(droppableContainer)]; + } + } + } + + // if we haven't found anything then we are off the top, so return the first item + return [collision(ascendingDroppabaleContainers[0])]; +}; + +// Look for the last (/ furthest down / lowest) droppable container that is at least +// 50% covered by the bottom edge of the dragging container. +const lowestDroppableContainerMajorityCovered: CollisionDetection = ({ + droppableContainers, + collisionRect, +}) => { + const descendingDroppabaleContainers = sortBy( + droppableContainers, + (c) => c?.rect.current?.top, + ).reverse(); + + for (const droppableContainer of descendingDroppabaleContainers) { + const { + rect: { current: droppableRect }, + } = droppableContainer; + + if (droppableRect) { + const coveredPercentage = (collisionRect.bottom - droppableRect.top) / droppableRect.height; + + if (coveredPercentage > 0.5) { + return [collision(droppableContainer)]; + } + } + } + + // if we haven't found anything then we are off the bottom, so return the last item + return [collision(descendingDroppabaleContainers[0])]; +}; + +export const verticalSortableListCollisionDetection: CollisionDetection = ( + args, +) => { + if (args.collisionRect.top < (args.active.rect.current?.initial?.top ?? 0)) { + return highestDroppableContainerMajorityCovered(args); + } + return lowestDroppableContainerMajorityCovered(args); +}; diff --git a/src/generic/alert-message/index.scss b/src/generic/alert-message/index.scss new file mode 100644 index 0000000000..394e6d2598 --- /dev/null +++ b/src/generic/alert-message/index.scss @@ -0,0 +1,6 @@ +// TODO: remove this after upstream fix merging: https://github.com/openedx/paragon/pull/3562 +.alert { + .alert-message-content { + align-self: baseline; + } +} diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.tsx similarity index 71% rename from src/generic/delete-modal/DeleteModal.jsx rename to src/generic/delete-modal/DeleteModal.tsx index 4159d9d3f2..cf81b96363 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { ActionRow, Button, @@ -9,17 +8,29 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import LoadingButton from '../loading-button'; +interface DeleteModalProps { + isOpen: boolean; + close: () => void; + category?: string; + onDeleteSubmit: () => void | Promise; + title?: string; + description?: React.ReactNode | React.ReactNode[]; + variant?: string; + btnLabel?: string; + icon?: React.ElementType; +} + const DeleteModal = ({ - category, + category = '', isOpen, close, onDeleteSubmit, title, description, - variant, + variant = 'default', btnLabel, icon, -}) => { +}: DeleteModalProps) => { const intl = useIntl(); const modalTitle = title || intl.formatMessage(messages.title, { category }); @@ -62,28 +73,4 @@ const DeleteModal = ({ ); }; -DeleteModal.defaultProps = { - category: '', - title: '', - description: '', - variant: 'default', - btnLabel: '', - icon: null, -}; - -DeleteModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - close: PropTypes.func.isRequired, - category: PropTypes.string, - onDeleteSubmit: PropTypes.func.isRequired, - title: PropTypes.string, - description: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.string, - ]), - variant: PropTypes.string, - btnLabel: PropTypes.string, - icon: PropTypes.elementType, -}; - export default DeleteModal; diff --git a/src/generic/delete-modal/messages.js b/src/generic/delete-modal/messages.ts similarity index 100% rename from src/generic/delete-modal/messages.js rename to src/generic/delete-modal/messages.ts diff --git a/src/generic/hooks/tests/hooks.test.tsx b/src/generic/hooks/tests/hooks.test.tsx index bf15d83ba2..d2e83f06fc 100644 --- a/src/generic/hooks/tests/hooks.test.tsx +++ b/src/generic/hooks/tests/hooks.test.tsx @@ -96,7 +96,8 @@ describe('useIframeBehavior', () => { window.dispatchEvent(new MessageEvent('message', message)); }); - expect(setIframeHeight).toHaveBeenCalledWith(500); + // +10 padding + expect(setIframeHeight).toHaveBeenCalledWith(510); expect(setHasLoaded).toHaveBeenCalledWith(true); }); diff --git a/src/generic/hooks/useIframeBehavior.tsx b/src/generic/hooks/useIframeBehavior.tsx index 2c327a23dd..1e60a6f943 100644 --- a/src/generic/hooks/useIframeBehavior.tsx +++ b/src/generic/hooks/useIframeBehavior.tsx @@ -46,7 +46,8 @@ export const useIframeBehavior = ({ switch (type) { case iframeMessageTypes.resize: - setIframeHeight(payload.height); + // Adding 10px as padding + setIframeHeight(payload.height + 10); if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { setHasLoaded(true); } diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx index 8914ad6de5..9b7a55c281 100644 --- a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx +++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { fireEvent, render as baseRender, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render as baseRender, + screen, +} from '@testing-library/react'; import { InplaceTextEditor } from '.'; const mockOnSave = jest.fn(); @@ -24,8 +29,8 @@ describe('', () => { expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument(); }); - it('should render the edit button if alwaysShowEditButton is true', () => { - render(); + it('should render the edit button', () => { + render(); expect(screen.getByText('Test text')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); @@ -36,7 +41,10 @@ describe('', () => { const title = screen.getByText('Test text'); expect(title).toBeInTheDocument(); - fireEvent.click(title); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); const textBox = screen.getByRole('textbox'); @@ -52,7 +60,10 @@ describe('', () => { const title = screen.getByText('Test text'); expect(title).toBeInTheDocument(); - fireEvent.click(title); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); const textBox = screen.getByRole('textbox'); @@ -62,4 +73,62 @@ describe('', () => { expect(textBox).not.toBeInTheDocument(); expect(mockOnSave).not.toHaveBeenCalled(); }); + + it('should show the new text while processing and roolback in case of error', async () => { + let rejecter: (err: Error) => void; + const longMockOnSave = jest.fn().mockReturnValue( + new Promise((_resolve, reject) => { + rejecter = reject; + }), + ); + render(); + + const text = screen.getByText('Test text'); + expect(text).toBeInTheDocument(); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + + const textBox = screen.getByRole('textbox'); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(longMockOnSave).toHaveBeenCalledWith('New text'); + + // Show pending new text + const newText = screen.getByText('New text'); + expect(newText).toBeInTheDocument(); + + await act(async () => { rejecter(new Error('error')); }); + + // Remove pending new text on error + expect(newText).not.toBeInTheDocument(); + + // Show original text + expect(screen.getByText('Test text')).toBeInTheDocument(); + }); + + it('should disappear edit button while editing', async () => { + render(); + + const title = screen.getByText('Test text'); + expect(title).toBeInTheDocument(); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + + const textBox = screen.getByRole('textbox'); + expect(editButton).not.toBeInTheDocument(); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(mockOnSave).toHaveBeenCalledWith('New text'); + expect(await screen.findByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); }); diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx index 8caecd550f..cb07d2ffa9 100644 --- a/src/generic/inplace-text-editor/index.tsx +++ b/src/generic/inplace-text-editor/index.tsx @@ -1,14 +1,12 @@ import React, { useCallback, - useEffect, useState, - forwardRef, } from 'react'; import { Form, Icon, IconButton, - OverlayTrigger, + Truncate, Stack, } from '@openedx/paragon'; import { Edit } from '@openedx/paragon/icons'; @@ -16,33 +14,11 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -interface IconWrapperProps { - popper: any; - children: React.ReactNode; - [key: string]: any; -} - -const IconWrapper = forwardRef(({ popper, children, ...props }, ref) => { - useEffect(() => { - // This is a workaround to force the popper to update its position when - // the editor is opened. - // Ref: https://react-bootstrap.netlify.app/docs/components/overlays/#updating-position-dynamically - popper.scheduleUpdate(); - }, [popper, children]); - - return ( -
- {children} -
- ); -}); - interface InplaceTextEditorProps { text: string; - onSave: (newText: string) => void; + onSave: (newText: string) => Promise; readOnly?: boolean; textClassName?: string; - alwaysShowEditButton?: boolean; } export const InplaceTextEditor: React.FC = ({ @@ -50,18 +26,29 @@ export const InplaceTextEditor: React.FC = ({ onSave, readOnly = false, textClassName, - alwaysShowEditButton = false, }) => { const intl = useIntl(); const [inputIsActive, setIsActive] = useState(false); + const [pendingSaveText, setPendingSaveText] = useState(); // state with the new text while updating const handleOnChangeText = useCallback( - (event) => { - const newText = event.target.value; - if (newText && newText !== text) { - onSave(newText); - } + async (event: React.ChangeEvent | React.KeyboardEvent) => { + const inputText = event.currentTarget.value; setIsActive(false); + if (inputText && inputText !== text) { + // NOTE: While using react query for optimistic updates would be the best approach, + // it could not be possible in some cases. For that reason, we use the `pendingSaveText` state + // to show the new text while saving. + setPendingSaveText(inputText); + try { + await onSave(inputText); + } catch { + // don't propagate the exception + } finally { + // reset the pending save text + setPendingSaveText(undefined); + } + } }, [text], ); @@ -78,86 +65,46 @@ export const InplaceTextEditor: React.FC = ({ } }; - if (readOnly) { - return ( - - {text} - - ); - } - - if (alwaysShowEditButton) { + // If we have the `pendingSaveText` state it means that we are in the process of saving the new text. + // In that case, we show the new text instead of the original in read-only mode as an optimistic update. + if (readOnly || pendingSaveText) { return ( - - {inputIsActive - ? ( - - ) - : ( - - {text} - - )} - - + + {pendingSaveText || text} + ); } return ( - - - - )} + -
- {inputIsActive - ? ( - - ) - : ( - + {inputIsActive + ? ( + + ) + : ( + <> + {text} - - )} -
-
+ + + + )} + ); }; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 00ef459221..22756b9ad1 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -12,4 +12,5 @@ @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; @import "./block-type-utils"; -@import "./modal-iframe" +@import "./modal-iframe"; +@import "./alert-message"; diff --git a/src/index.scss b/src/index.scss index c5d9bcb769..8dfac35824 100644 --- a/src/index.scss +++ b/src/index.scss @@ -32,6 +32,7 @@ @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; @import "optimizer-page/scan-results/ScanResults"; +@import "assistant/AIAssistantChat/AIAssistantChat"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ce07e0c70f..f0c0404c90 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -55,6 +55,10 @@ const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + beforeEach(async () => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; @@ -78,6 +82,10 @@ describe('', () => { }); }); + afterAll(() => { + jest.useRealTimers(); + }); + const renderLibraryPage = async () => { render(, { path, params: { libraryId: mockContentLibrary.libraryId } }); @@ -362,7 +370,7 @@ describe('', () => { fireEvent.change(searchBox, { target: { value: 'words to find' } }); // Default sort option changes to "Most Relevant" - expect(screen.getAllByText('Most Relevant').length).toEqual(2); + expect((await screen.findAllByText('Most Relevant')).length).toEqual(2); await waitFor(() => { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { body: expect.stringContaining('"sort":[]'), @@ -392,7 +400,7 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => { + it('should open component sidebar, showing manage tab on clicking add to collection menu item - component', async () => { const mockResult0 = { ...mockResult }.results[0].hits[0]; const displayName = 'Introduction to Testing'; expect(mockResult0.display_name).toStrictEqual(displayName); @@ -407,9 +415,10 @@ describe('', () => { const sidebar = screen.getByTestId('library-sidebar'); - const { getByRole, queryByText } = within(sidebar); + const { getByRole, findByText } = within(sidebar); - await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); + expect(await findByText(displayName)).toBeInTheDocument(); + jest.advanceTimersByTime(300); expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); @@ -417,7 +426,7 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => { + it('should open component sidebar, showing manage tab on clicking add to collection menu item - unit', async () => { const displayName = 'Test Unit'; await renderLibraryPage(); @@ -430,10 +439,11 @@ describe('', () => { const sidebar = screen.getByTestId('library-sidebar'); - const { getByRole, queryByText } = within(sidebar); + const { getByRole, findByText } = within(sidebar); - await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); - expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize'); + expect(await findByText(displayName)).toBeInTheDocument(); + jest.advanceTimersByTime(300); + expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 6ffc4182de..b49ae07e76 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -15,12 +15,11 @@ import { Breadcrumb, Button, Container, - Icon, Stack, Tab, Tabs, } from '@openedx/paragon'; -import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons'; +import { Add, InfoOutline } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import Loading from '../generic/Loading'; @@ -32,7 +31,6 @@ import { ClearFiltersButton, FilterByBlockType, FilterByTags, - FilterByPublished, SearchContextProvider, SearchKeywordsField, SearchSortWidget, @@ -46,6 +44,7 @@ import { SidebarBodyComponentId, useSidebarContext } from './common/context/Side import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; +import LibraryFilterByPublished from './generic/filter-by-published'; const HeaderActions = () => { const intl = useIntl(); @@ -114,7 +113,7 @@ export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { const showReadOnlyBadge = readOnly && !componentPickerMode; return ( - + {title} {showReadOnlyBadge && (
@@ -214,16 +213,11 @@ const LibraryAuthoringPage = ({ const breadcumbs = componentPickerMode && !restrictToLibrary ? ( } linkAs={Link} /> ) : undefined; @@ -246,6 +240,17 @@ const LibraryAuthoringPage = ({ extraFilter.push(activeTypeFilters[activeKey]); } + /* + + ( - + )); return ( @@ -299,7 +304,14 @@ const LibraryAuthoringPage = ({ {!(insideCollections || insideUnits) && } - + diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index c87091acdb..52e0794e03 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -16,6 +17,7 @@ interface LibraryBlockProps { view?: string; scrolling?: string; minHeight?: string; + scrollIntoView?: boolean; } /** * React component that displays an XBlock in a sandboxed IFrame. @@ -33,6 +35,7 @@ export const LibraryBlock = ({ view, minHeight, scrolling = 'no', + scrollIntoView = false, }: LibraryBlockProps) => { const { iframeRef, setIframeRef } = useIframe(); const xblockView = view ?? 'student_view'; @@ -49,6 +52,13 @@ export const LibraryBlock = ({ onBlockNotification, }); + useEffect(() => { + /* istanbul ignore next */ + if (scrollIntoView) { + iframeRef?.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [scrollIntoView]); + useIframeContent(iframeRef, setIframeRef); return ( diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index 9eee970312..ba27af2185 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -494,7 +494,7 @@ ], "created": 1742221203.895054, "modified": 1742221203.895054, - "usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", + "usage_key": "lct:org:lib:unit:test-unit-9a207", "block_type": "unit", "context_key": "lib:Axim:TEST", "org": "Axim", @@ -512,12 +512,18 @@ ], "created": "1742221203.895054", "modified": "1742221203.895054", - "usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", + "usage_key": "lct:org:lib:unit:test-unit-9a207", "block_type": "unit", "context_key": "lib:Axim:TEST", "org": "Axim", "access_id": "15", - "num_children": "0" + "num_children": "0", + "published": { + "display_name": "Published Test Unit" + } + }, + "published": { + "display_name": "Published Test Unit" } } ], diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 8820981c0e..eab893dc4e 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -32,6 +32,8 @@ import * as textEditorHooks from '../../editors/containers/TextEditor/hooks'; // Mocks for ComponentEditorModal to work in tests. jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); +jest.mock('../../assistant/context/hooks'); + const { libraryId } = mockContentLibrary; const render = (collectionId?: string) => { const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId }; @@ -272,7 +274,7 @@ describe('', () => { await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1)); await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl)); - expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.'); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.'); }); it('should stop user from pasting unsupported blocks and show toast', async () => { diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 7610b743f6..8ddbdae354 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -29,6 +29,8 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { PickLibraryContentModal } from './PickLibraryContentModal'; import { blockTypes } from '../../editors/data/constants/app'; +import { ContentType as LibraryContentTypes } from '../routes'; +import genericMessages from '../generic/messages'; import messages from './messages'; import type { BlockTypeMetadata } from '../data/api'; import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils'; @@ -114,6 +116,9 @@ const AddContentView = ({ blockType: 'libraryContent', }; + const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined; + const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined; + return ( <> {(collectionId || unitId) && componentPicker && ( @@ -123,6 +128,8 @@ const AddContentView = ({ )} @@ -301,7 +308,7 @@ const AddContent = () => { const linkComponent = (opaqueKey: string) => { if (collectionId) { addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => { - showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); } if (unitId) { diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx index 8244fc7167..364de33f71 100644 --- a/src/library-authoring/add-content/AddContentWorkflow.test.tsx +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -23,6 +23,8 @@ import { studioHomeMock } from '../../studio-home/__mocks__'; import { getStudioHomeApiUrl } from '../../studio-home/data/api'; import LibraryLayout from '../LibraryLayout'; +jest.mock('../../assistant/context/hooks'); + mockContentSearchConfig.applyMock(); mockClipboardEmpty.applyMock(); mockContentLibrary.applyMock(); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx index a73ce8118e..982b657e8b 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx @@ -92,7 +92,10 @@ describe('', () => { } }); expect(onClose).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.'); + const text = context === 'collection' + ? 'Content added to collection.' + : 'Content linked successfully.'; + expect(mockShowToast).toHaveBeenCalledWith(text); }); it(`show error when api call fails (${context})`, async () => { @@ -130,8 +133,10 @@ describe('', () => { } }); expect(onClose).toHaveBeenCalled(); - const name = context === 'collection' ? 'collection' : 'container'; - expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`); + const text = context === 'collection' + ? 'Failed to add content to collection.' + : 'There was an error linking the content to this container.'; + expect(mockShowToast).toHaveBeenCalledWith(text); }); }); }); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index f71f40d081..4f243f5bf1 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -6,6 +6,8 @@ import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext'; import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks'; +import genericMessages from '../generic/messages'; +import type { ContentType } from '../routes'; import messages from './messages'; interface PickLibraryContentModalFooterProps { @@ -32,12 +34,14 @@ interface PickLibraryContentModalProps { isOpen: boolean; onClose: () => void; extraFilter?: string[]; + visibleTabs?: ContentType[], } export const PickLibraryContentModal: React.FC = ({ isOpen, onClose, extraFilter, + visibleTabs, }) => { const intl = useIntl(); @@ -69,16 +73,16 @@ export const PickLibraryContentModal: React.FC = ( if (collectionId) { updateCollectionItemsMutation.mutateAsync(usageKeys) .then(() => { - showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess)); }) .catch(() => { - showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); } if (unitId) { updateUnitComponentsMutation.mutateAsync(usageKeys) .then(() => { - showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + showToast(intl.formatMessage(messages.successAssociateComponentToContainerMessage)); }) .catch(() => { showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); @@ -109,6 +113,7 @@ export const PickLibraryContentModal: React.FC = ( componentPickerMode="multiple" onChangeComponentSelection={setSelectedComponents} extraFilter={extraFilter} + visibleTabs={visibleTabs} /> ); diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 120b5896fb..cd7e688c5e 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -84,15 +84,10 @@ const messages = defineMessages({ + ' The {detail} text provides more information about the error.' ), }, - successAssociateComponentMessage: { - id: 'course-authoring.library-authoring.associate-collection-content.success.text', + successAssociateComponentToContainerMessage: { + id: 'course-authoring.library-authoring.associate-container-content.success.text', defaultMessage: 'Content linked successfully.', - description: 'Message when linking of content to a collection in library is success', - }, - errorAssociateComponentToCollectionMessage: { - id: 'course-authoring.library-authoring.associate-collection-content.error.text', - defaultMessage: 'There was an error linking the content to this collection.', - description: 'Message when linking of content to a collection in library fails', + description: 'Message when linking of content to a container in library is success', }, errorAssociateComponentToContainerMessage: { id: 'course-authoring.library-authoring.associate-container-content.error.text', diff --git a/src/library-authoring/collections/CollectionInfo.tsx b/src/library-authoring/collections/CollectionInfo.tsx index 277a6fc1c6..68d2dcb075 100644 --- a/src/library-authoring/collections/CollectionInfo.tsx +++ b/src/library-authoring/collections/CollectionInfo.tsx @@ -48,7 +48,8 @@ const CollectionInfo = () => { if (componentPickerMode) { setCollectionId(collectionId); } else { - navigateTo({ collectionId }); + /* istanbul ignore next */ + navigateTo({ collectionId, doubleClicked: true }); } }, [componentPickerMode, navigateTo]); diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index 8f476d35ef..b885d72da2 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -26,14 +26,16 @@ const CollectionInfoHeader = () => { const updateMutation = useUpdateCollection(libraryId, collectionId); const { showToast } = useContext(ToastContext); - const handleSaveTitle = (newTitle: string) => { - updateMutation.mutateAsync({ - title: newTitle, - }).then(() => { + const handleSaveTitle = async (newTitle: string) => { + try { + await updateMutation.mutateAsync({ + title: newTitle, + }); showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); - }); + throw err; + } }; if (!collection) { @@ -46,7 +48,6 @@ const CollectionInfoHeader = () => { text={collection.title} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index 3c3bf45474..eb102d2ed4 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -315,7 +315,7 @@ describe('', () => { fireEvent.change(searchBox, { target: { value: 'words to find' } }); // Default sort option changes to "Most Relevant" - expect(screen.getAllByText('Most Relevant').length).toEqual(2); + expect((await screen.findAllByText('Most Relevant')).length).toEqual(2); await waitFor(() => { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { body: expect.stringContaining('"sort":[]'), diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index de3c7ce234..943664788d 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -22,7 +22,6 @@ import NotFoundAlert from '../../generic/NotFoundAlert'; import { ClearFiltersButton, FilterByBlockType, - FilterByPublished, FilterByTags, SearchContextProvider, SearchKeywordsField, @@ -36,6 +35,7 @@ import { SidebarBodyComponentId, useSidebarContext } from '../common/context/Sid import messages from './messages'; import { LibrarySidebar } from '../library-sidebar'; import LibraryCollectionComponents from './LibraryCollectionComponents'; +import LibraryFilterByPublished from '../generic/filter-by-published'; const HeaderActions = () => { const intl = useIntl(); @@ -218,7 +218,7 @@ const LibraryCollectionPage = () => { - + diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index 83e545e8eb..0657842876 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -36,7 +36,7 @@ export const isComponentInfoTab = (tab: string): tab is ComponentInfoTab => ( export const UNIT_INFO_TABS = { Preview: 'preview', - Organize: 'organize', + Manage: 'manage', Usage: 'usage', Settings: 'settings', } as const; @@ -63,7 +63,8 @@ export interface SidebarComponentInfo { } export enum SidebarActions { - JumpToAddCollections = 'jump-to-add-collections', + JumpToManageCollections = 'jump-to-manage-collections', + JumpToManageTags = 'jump-to-manage-tags', ManageTeam = 'manage-team', None = '', } diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 53671cd7e4..7f7b1e3bb4 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -8,7 +8,7 @@ import { import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockContentLibrary, - mockGetUnpaginatedEntityLinks, + mockGetEntityLinks, mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, @@ -21,7 +21,7 @@ mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); -mockGetUnpaginatedEntityLinks.applyMock(); +mockGetEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); const render = (usageKey: string) => baseRender(, { diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index 2206be3e50..0427e1b345 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -7,7 +7,7 @@ import { import { mockContentLibrary, mockLibraryBlockMetadata, - mockGetUnpaginatedEntityLinks, + mockGetEntityLinks, } from '../data/api.mocks'; import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; @@ -18,7 +18,7 @@ import { getXBlockPublishApiUrl } from '../data/api'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); -mockGetUnpaginatedEntityLinks.applyMock(); +mockGetEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); jest.mock('./ComponentPreview', () => ({ __esModule: true, // Required when mocking 'default' export diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 1b542c6956..635e54cc7e 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, @@ -17,7 +17,6 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { type ComponentInfoTab, COMPONENT_INFO_TABS, - SidebarActions, isComponentInfoTab, useSidebarContext, } from '../common/context/SidebarContext'; @@ -107,9 +106,9 @@ const ComponentInfo = () => { sidebarTab, setSidebarTab, sidebarComponentInfo, - sidebarAction, defaultTab, hiddenTabs, + resetSidebarAction, } = useSidebarContext(); const [ isPublishConfirmationOpen, @@ -117,20 +116,16 @@ const ComponentInfo = () => { closePublishConfirmation, ] = useToggle(false); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; - const tab: ComponentInfoTab = ( isComponentInfoTab(sidebarTab) ? sidebarTab : defaultTab.component ); - useEffect(() => { - // Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo - if (jumpToCollections) { - setSidebarTab(COMPONENT_INFO_TABS.Manage); - } - }, [jumpToCollections, setSidebarTab]); + const handleTabChange = (newTab: ComponentInfoTab) => { + resetSidebarAction(); + setSidebarTab(newTab); + }; const usageKey = sidebarComponentInfo?.id; // istanbul ignore if: this should never happen @@ -198,7 +193,7 @@ const ComponentInfo = () => { className="my-3 d-flex justify-content-around" defaultActiveKey={defaultTab.component} activeKey={tab} - onSelect={setSidebarTab} + onSelect={handleTabChange} > {renderTab(COMPONENT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} {renderTab(COMPONENT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index 0757c9775d..11b3256945 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -26,16 +26,18 @@ const ComponentInfoHeader = () => { const updateMutation = useUpdateXBlockFields(usageKey); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - metadata: { - display_name: newDisplayName, - }, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }); showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - }); + throw err; + } }; if (!xblockFields) { @@ -48,7 +50,6 @@ const ComponentInfoHeader = () => { text={xblockFields?.displayName} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx index 9e070cde96..0e44912652 100644 --- a/src/library-authoring/component-info/ComponentManagement.test.tsx +++ b/src/library-authoring/component-info/ComponentManagement.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; -import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; +import { SidebarActions, SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks'; import ComponentManagement from './ComponentManagement'; @@ -19,6 +19,16 @@ jest.mock('../../content-tags-drawer', () => ({ ), })); +const mockSearchParam = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useSearchParams: () => [ + { getAll: (paramName: string) => mockSearchParam(paramName) }, + () => {}, + ], +})); + mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockContentTaxonomyTagsData.applyMock(); @@ -55,6 +65,11 @@ const render = (usageKey: string, libraryId?: string) => baseRender(', () => { beforeEach(() => { initializeMocks(); + mockSearchParam.mockResolvedValue([undefined, () => {}]); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should render draft status', async () => { @@ -119,4 +134,34 @@ describe('', () => { render(mockLibraryBlockMetadata.usageKeyWithCollections); expect(await screen.findByText('Collections (1)')).toBeInTheDocument(); }); + + it('should open collection section when sidebarAction = JumpToManageCollections', async () => { + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + mockSearchParam.mockReturnValue([SidebarActions.JumpToManageCollections]); + render(mockLibraryBlockMetadata.usageKeyWithCollections); + expect(await screen.findByText('Collections (1)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument(); + const tagsSection = await screen.findByRole('button', { name: 'Tags (0)' }); + expect(tagsSection).toHaveAttribute('aria-expanded', 'false'); + const collectionsSection = await screen.findByRole('button', { name: 'Collections (1)' }); + expect(collectionsSection).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should open tags section when sidebarAction = JumpToManageTags', async () => { + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + mockSearchParam.mockReturnValue([SidebarActions.JumpToManageTags]); + render(mockLibraryBlockMetadata.usageKeyForTags); + expect(await screen.findByText('Collections (0)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument(); + const tagsSection = await screen.findByRole('button', { name: 'Tags (6)' }); + expect(tagsSection).toHaveAttribute('aria-expanded', 'true'); + const collectionsSection = await screen.findByRole('button', { name: 'Collections (0)' }); + expect(collectionsSection).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index c0eccdc5e4..5c3b6a664b 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -18,7 +18,8 @@ const ComponentManagement = () => { const intl = useIntl(); const { readOnly, isLoadingLibraryData } = useLibraryContext(); const { sidebarComponentInfo, sidebarAction, resetSidebarAction } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; + const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags; const [tagsCollapseIsOpen, setTagsCollapseOpen] = React.useState(!jumpToCollections); const [collectionsCollapseIsOpen, setCollectionsCollapseOpen] = React.useState(true); @@ -26,8 +27,11 @@ const ComponentManagement = () => { if (jumpToCollections) { setTagsCollapseOpen(false); setCollectionsCollapseOpen(true); + } else if (jumpToTags) { + setTagsCollapseOpen(true); + setCollectionsCollapseOpen(false); } - }, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]); + }, [jumpToCollections, jumpToTags]); useEffect(() => { // This is required to redo actions. diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index 48c97fba8b..13e59565f1 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -2,7 +2,7 @@ import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Collapsible, Hyperlink, Stack } from '@openedx/paragon'; import { useMemo } from 'react'; -import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; +import { useEntityLinks } from '../../course-libraries/data/apiHooks'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; @@ -34,7 +34,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { isError: isErrorDownstreamLinks, error: errorDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamUsageKey: usageKey }); const downstreamKeys = useMemo( () => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [], diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx index 8e9fbbaf9d..cd7f497a37 100644 --- a/src/library-authoring/component-picker/ComponentPicker.test.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx @@ -14,6 +14,7 @@ import { mockGetCollectionMetadata, mockGetContentLibraryV2List, mockLibraryBlockMetadata, + mockGetContainerMetadata, } from '../data/api.mocks'; import { ComponentPicker } from './ComponentPicker'; @@ -40,6 +41,7 @@ mockContentSearchConfig.applyMock(); mockGetCollectionMetadata.applyMock(); mockGetContentLibraryV2List.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockGetContainerMetadata.applyMock(); let postMessageSpy: jest.SpyInstance; @@ -99,6 +101,24 @@ describe('', () => { }, '*'); }); + it('should open the unit sidebar', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + await screen.findByText(/Change Library/i); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + + // Click on the unit card to open the sidebar + fireEvent.click((await screen.findByText('Published Test Unit'))); + + const sidebar = await screen.findByTestId('library-sidebar'); + expect(sidebar).toBeInTheDocument(); + await waitFor(() => expect(within(sidebar).getByText('Published Test Unit')).toBeInTheDocument()); + }); + it('should pick component inside a collection using the card', async () => { render(); @@ -302,4 +322,43 @@ describe('', () => { expect(screen.queryByRole('tab', { name: /collections/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /components/i })).not.toBeInTheDocument(); }); + + it('should not display never published filter', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + const filterButton = await screen.findByRole('button', { name: /publish status/i }); + fireEvent.click(filterButton); + + // Verify the filters. Note: It's hard to verify the `published` filter, + // because there are many components with that text on the screen, but that's not the important thing. + expect(screen.getByText(/modified since publish/i)).toBeInTheDocument(); + expect(screen.queryByText(/never published/i)).not.toBeInTheDocument(); + }); + + it('should not display never published filter in collection page', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + await screen.findByText(/Change Library/i); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + + // Click on the collection card to open the sidebar + fireEvent.click(screen.queryAllByText('Collection 1')[0]); + + // Wait for the content library to load + const filterButton = await screen.findByRole('button', { name: /publish status/i }); + fireEvent.click(filterButton); + + // Verify the filters. Note: It's hard to verify the `published` filter, + // because there are many components with that text on the screen, but that's not the important thing. + expect(screen.getByText(/modified since publish/i)).toBeInTheDocument(); + expect(screen.queryByText(/never published/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/BaseCard.scss b/src/library-authoring/components/BaseCard.scss index 3a13bd14cb..5208ea221a 100644 --- a/src/library-authoring/components/BaseCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -2,24 +2,41 @@ .pgn__card { height: 100%; min-width: 15rem; - } - .library-item-header { - border-top-left-radius: .375rem; - border-top-right-radius: .375rem; - padding: 0 .5rem 0 1.25rem; + &::before { + border: none !important; // Remove default focus + } + + &.selected:not(:focus) { + outline: 2px $gray-700 solid; + } - .library-item-header-icon { - width: 2.3rem; - height: 2.3rem; + &.selected:focus { + outline: 3px $gray-700 solid; } - .pgn__card-header-content { - margin-top: .55rem; + &:not(.selected):focus { + outline: 1px $gray-200 solid; + outline-offset: 2px; } - .pgn__card-header-actions { - margin: .25rem 0 .25rem 1rem; + .library-item-header { + padding: 0 .5rem 0 1.25rem; + border-top-left-radius: .375rem; + border-top-right-radius: .375rem; + + .library-item-header-icon { + width: 2.3rem; + height: 2.3rem; + } + + .pgn__card-header-content { + margin-top: .55rem; + } + + .pgn__card-header-actions { + margin: .25rem 0 .25rem 1rem; + } } } diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index 2b15937891..ef7dc5828c 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -22,7 +22,8 @@ type BaseCardProps = { tags: ContentHitTags; actions: React.ReactNode; hasUnpublishedChanges?: boolean; - onSelect: () => void + onSelect: (e?: React.MouseEvent) => void; + selected?: boolean; }; const BaseCard = ({ @@ -33,6 +34,7 @@ const BaseCard = ({ tags, actions, onSelect, + selected = false, ...props } : BaseCardProps) => { const tagCount = useMemo(() => { @@ -47,7 +49,7 @@ const BaseCard = ({ const intl = useIntl(); return ( - + { const { componentPickerMode } = useComponentPickerContext(); - const { showOnlyPublished } = useLibraryContext(); - const { openCollectionInfoSidebar } = useSidebarContext(); + const { showOnlyPublished, setCollectionId } = useLibraryContext(); + const { openCollectionInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { type: itemType, @@ -132,12 +132,19 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { const { displayName = '', description = '' } = formatted; + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.CollectionInfo + && sidebarComponentInfo.id === collectionId; + const { navigateTo } = useLibraryRoutes(); - const openCollection = useCallback(() => { + const openCollection = useCallback((e?: React.MouseEvent) => { openCollectionInfoSidebar(collectionId); + const doubleClicked = (e?.detail || 0) > 1; if (!componentPickerMode) { - navigateTo({ collectionId }); + navigateTo({ collectionId, doubleClicked }); + } else if (doubleClicked) { + /* istanbul ignore next */ + setCollectionId(collectionId); } }, [collectionId, navigateTo, openCollectionInfoSidebar]); @@ -154,6 +161,7 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { )} onSelect={openCollection} + selected={selected} /> ); }; diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index 087b0ad20a..e0e2c44e20 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -11,6 +11,13 @@ import { ContentHit } from '../../search-manager'; import ComponentCard from './ComponentCard'; import { PublishStatus } from '../../search-manager/data/api'; +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useNavigate: () => mockNavigate, +})); + const contentHit: ContentHit = { id: '1', usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d', @@ -41,6 +48,8 @@ const contentHit: ContentHit = { const libraryId = 'lib:org1:Demo_Course'; const render = () => baseRender(, { + path: '/library/:libraryId', + params: { libraryId }, extraWrapper: ({ children }) => ( { children } @@ -104,4 +113,24 @@ describe('', () => { expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard'); }); }); + + it('should select component on clicking edit menu option', async () => { + initializeMocks(); + render(); + + // Open menu + const menu = await screen.findByTestId('component-card-menu-toggle'); + expect(menu).toBeInTheDocument(); + fireEvent.click(menu); + + // Click copy to clipboard + const editOption = await screen.findByRole('button', { name: 'Edit' }); + expect(editOption).toBeInTheDocument(); + fireEvent.click(editOption); + // Verify that the url is updated to component url i.e. component is selected + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: `/library/${libraryId}/component/${contentHit.usageKey}`, + search: '', + }); + }); }); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 86dccbfd4f..4b58147fbf 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -6,7 +6,7 @@ import { import { type ContentHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; @@ -18,7 +18,7 @@ type ComponentCardProps = { const ComponentCard = ({ hit }: ComponentCardProps) => { const { showOnlyPublished } = useLibraryContext(); - const { openComponentInfoSidebar } = useSidebarContext(); + const { openComponentInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { componentPickerMode } = useComponentPickerContext(); const { @@ -44,6 +44,9 @@ const ComponentCard = ({ hit }: ComponentCardProps) => { } }, [usageKey, navigateTo, openComponentInfoSidebar]); + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo + && sidebarComponentInfo.id === usageKey; + return ( { )} hasUnpublishedChanges={publishStatus !== PublishStatus.Published} onSelect={openComponent} + selected={selected} /> ); }; diff --git a/src/library-authoring/components/ComponentDeleter.tsx b/src/library-authoring/components/ComponentDeleter.tsx index 2ea3a99e8e..c48dedec1b 100644 --- a/src/library-authoring/components/ComponentDeleter.tsx +++ b/src/library-authoring/components/ComponentDeleter.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Warning } from '@openedx/paragon/icons'; +import { Icon } from '@openedx/paragon'; +import { CalendarViewDay, School, Warning } from '@openedx/paragon/icons'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock } from '../data/apiHooks'; @@ -66,6 +67,22 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => { return null; } + const deleteText = intl.formatMessage(messages.deleteComponentConfirm, { + componentName: , + message: ( + <> +
+ + {intl.formatMessage(messages.deleteComponentConfirmMsg1)} +
+
+ + {intl.formatMessage(messages.deleteComponentConfirmMsg2)} +
+ + ), + }); + return ( { variant="warning" title={intl.formatMessage(messages.deleteComponentWarningTitle)} icon={Warning} - description={( - - ), - }} - /> -)} + description={deleteText} onDeleteSubmit={doDelete} /> ); diff --git a/src/library-authoring/components/ComponentEditorModal.tsx b/src/library-authoring/components/ComponentEditorModal.tsx index 74ffc85383..08df29a760 100644 --- a/src/library-authoring/components/ComponentEditorModal.tsx +++ b/src/library-authoring/components/ComponentEditorModal.tsx @@ -1,7 +1,9 @@ import { getConfig } from '@edx/frontend-platform'; import React from 'react'; - +import { useSelector } from 'react-redux'; import { useQueryClient } from '@tanstack/react-query'; + +import { getWaffleFlags } from '../../data/selectors'; import EditorPage from '../../editors/EditorPage'; import { getBlockType } from '../../generic/key-utils'; import { useLibraryContext } from '../common/context/LibraryContext'; @@ -21,6 +23,7 @@ export function canEditComponent(usageKey: string): boolean { export const ComponentEditorModal: React.FC> = () => { const { componentBeingEdited, closeComponentEditor, libraryId } = useLibraryContext(); const queryClient = useQueryClient(); + const { useReactMarkdownEditor } = useSelector(getWaffleFlags); if (componentBeingEdited === undefined) { return null; @@ -37,11 +40,11 @@ export const ComponentEditorModal: React.FC> = () => { courseId={libraryId} blockType={blockType} blockId={componentBeingEdited.usageKey} + isMarkdownEditorEnabledForCourse={useReactMarkdownEditor} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={onClose} returnFunction={() => onClose} - fullScreen={false} /> ); }; diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index 589901326b..ba37117119 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -20,6 +20,8 @@ import { import { canEditComponent } from './ComponentEditorModal'; import ComponentDeleter from './ComponentDeleter'; import messages from './messages'; +import { useLibraryRoutes } from '../routes'; +import { useRunOnNextRender } from '../../utils'; export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { const intl = useIntl(); @@ -36,6 +38,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { closeLibrarySidebar, setSidebarAction, } = useSidebarContext(); + const { navigateTo } = useLibraryRoutes(); const canEdit = usageKey && canEditComponent(usageKey); const { showToast } = useContext(ToastContext); @@ -87,10 +90,28 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { }); }; + const handleEdit = useCallback(() => { + navigateTo({ componentId: usageKey }); + openComponentInfoSidebar(usageKey); + openComponentEditor(usageKey); + }, [usageKey]); + + const scheduleJumpToCollection = useRunOnNextRender(() => { + // TODO: Ugly hack to make sure sidebar shows add to collection section + // This needs to run after all changes to url takes place to avoid conflicts. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections), 250); + }); + const showManageCollections = useCallback(() => { - setSidebarAction(SidebarActions.JumpToAddCollections); + navigateTo({ componentId: usageKey }); openComponentInfoSidebar(usageKey); - }, [setSidebarAction, openComponentInfoSidebar, usageKey]); + scheduleJumpToCollection(); + }, [ + scheduleJumpToCollection, + openComponentInfoSidebar, + usageKey, + navigateTo, + ]); return ( @@ -104,7 +125,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { data-testid="component-card-menu-toggle" /> - openComponentEditor(usageKey) } : { disabled: true })}> + @@ -123,11 +144,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { )} - {!unitId && ( - - - - )} + + + diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index a76601e1d0..a8716d7ad1 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -6,7 +6,7 @@ import { fireEvent, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; -import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks'; +import { mockContentLibrary } from '../data/api.mocks'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import ContainerCard from './ContainerCard'; import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api'; @@ -40,7 +40,6 @@ let axiosMock: MockAdapter; let mockShowToast; mockContentLibrary.applyMock(); -mockGetContainerChildren.applyMock(); const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { extraWrapper: ({ children }) => ( @@ -155,29 +154,54 @@ describe('', () => { it('should render no child blocks in card preview', async () => { render(); - expect(screen.queryByTitle('text block')).not.toBeInTheDocument(); + expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument(); expect(screen.queryByText('+0')).not.toBeInTheDocument(); }); it('should render <=5 child blocks in card preview', async () => { const containerWith5Children = { ...containerHitSample, - usageKey: mockGetContainerChildren.fiveChildren, - }; + content: { + childUsageKeys: Array(5).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + } satisfies ContainerHit; render(); - expect((await screen.findAllByTitle(/text block */)).length).toBe(5); + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(5); expect(screen.queryByText('+0')).not.toBeInTheDocument(); }); it('should render >5 child blocks with +N in card preview', async () => { const containerWith6Children = { ...containerHitSample, - usageKey: mockGetContainerChildren.sixChildren, - }; + content: { + childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + } satisfies ContainerHit; render(); - expect((await screen.findAllByTitle(/text block */)).length).toBe(4); + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(4); expect(screen.queryByText('+2')).toBeInTheDocument(); }); + + it('should render published child blocks when rendering a published card preview', async () => { + const containerWithPublishedChildren = { + ...containerHitSample, + content: { + childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + published: { + content: { + childUsageKeys: Array(2).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + }, + } satisfies ContainerHit; + render( + , + true, + ); + + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(2); + expect(screen.queryByText('+2')).not.toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 45268aa150..ebcbbbeaf3 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -12,17 +12,19 @@ import { MoreVert } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import { getBlockType } from '../../generic/key-utils'; import { ToastContext } from '../../generic/toast-context'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; -import { useContainerChildren, useRemoveItemsFromCollection } from '../data/apiHooks'; +import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; +import { useRemoveItemsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; import messages from './messages'; import ContainerDeleter from './ContainerDeleter'; +import { useRunOnNextRender } from '../../utils'; type ContainerMenuProps = { hit: ContainerHit, @@ -44,6 +46,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { } = useSidebarContext(); const { showToast } = useContext(ToastContext); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); + const { navigateTo } = useLibraryRoutes(); const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); @@ -59,10 +62,17 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { }); }; + const scheduleJumpToCollection = useRunOnNextRender(() => { + // TODO: Ugly hack to make sure sidebar shows add to collection section + // This needs to run after all changes to url takes place to avoid conflicts. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections)); + }); + const showManageCollections = useCallback(() => { - setSidebarAction(SidebarActions.JumpToAddCollections); + navigateTo({ unitId: containerId }); openUnitInfoSidebar(containerId); - }, [setSidebarAction, openUnitInfoSidebar, containerId]); + scheduleJumpToCollection(); + }, [scheduleJumpToCollection, navigateTo, openUnitInfoSidebar, containerId]); return ( <> @@ -107,21 +117,17 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { }; type ContainerCardPreviewProps = { - containerId: string; + childUsageKeys: Array; showMaxChildren?: number; }; -const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => { - const { data, isLoading, isError } = useContainerChildren(containerId); - if (isLoading || isError) { - return null; - } - - const hiddenChildren = data.length - showMaxChildren; +const ContainerCardPreview = ({ childUsageKeys, showMaxChildren = 5 }: ContainerCardPreviewProps) => { + const hiddenChildren = childUsageKeys.length - showMaxChildren; return ( { - data.slice(0, showMaxChildren).map(({ id, blockType, displayName }, idx) => { + childUsageKeys.slice(0, showMaxChildren).map((usageKey, idx) => { + const blockType = getBlockType(usageKey); let blockPreview: ReactNode; let classNames; @@ -133,7 +139,7 @@ const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCar ); } else { @@ -147,7 +153,9 @@ const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCar } return (
{blockPreview} @@ -166,7 +174,7 @@ type ContainerCardProps = { const ContainerCard = ({ hit } : ContainerCardProps) => { const { componentPickerMode } = useComponentPickerContext(); const { setUnitId, showOnlyPublished } = useLibraryContext(); - const { openUnitInfoSidebar } = useSidebarContext(); + const { openUnitInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { blockType: itemType, @@ -176,6 +184,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { published, publishStatus, usageKey: unitId, + content, } = hit; const numChildrenCount = showOnlyPublished ? ( @@ -186,13 +195,22 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { showOnlyPublished ? formatted.published?.displayName : formatted.displayName ) ?? ''; + const childUsageKeys: Array = ( + showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys + ) ?? []; + + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo + && sidebarComponentInfo.id === unitId; + const { navigateTo } = useLibraryRoutes(); - const openContainer = useCallback(() => { + const openContainer = useCallback((e?: React.MouseEvent) => { if (itemType === 'unit') { openUnitInfoSidebar(unitId); setUnitId(unitId); - navigateTo({ unitId }); + if (!componentPickerMode) { + navigateTo({ unitId, doubleClicked: (e?.detail || 0) > 1 }); + } } }, [unitId, itemType, openUnitInfoSidebar, navigateTo]); @@ -200,7 +218,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { } + preview={} tags={tags} numChildren={numChildrenCount} actions={( @@ -214,6 +232,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { )} hasUnpublishedChanges={publishStatus !== PublishStatus.Published} onSelect={openContainer} + selected={selected} /> ); }; diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index 9b7d5db05a..a4a1affc15 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Warning, School, Widgets } from '@openedx/paragon/icons'; @@ -47,7 +47,7 @@ const ContainerDeleter = ({
), - }) as ReactNode as string; + }); const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess); const deleteError = intl.formatMessage(messages.deleteUnitFailed); const undoDeleteError = messages.undoDeleteUnitToastFailed; diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx index 8c98e234fe..eeadc84e9a 100644 --- a/src/library-authoring/components/PublishConfirmationModal.tsx +++ b/src/library-authoring/components/PublishConfirmationModal.tsx @@ -5,7 +5,7 @@ import BaseModal from '../../editors/sharedComponents/BaseModal'; import messages from './messages'; import infoMessages from '../component-info/messages'; import { ComponentUsage } from '../component-info/ComponentUsage'; -import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; +import { useEntityLinks } from '../../course-libraries/data/apiHooks'; interface PublishConfirmationModalProps { isOpen: boolean, @@ -29,7 +29,7 @@ const PublishConfirmationModal = ({ const { data: dataDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamUsageKey: usageKey }); const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 2507930770..8248b4f43c 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -68,9 +68,19 @@ const messages = defineMessages({ }, deleteComponentConfirm: { id: 'course-authoring.library-authoring.component.delete-confirmation-text', - defaultMessage: 'Delete {componentName}? If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.', + defaultMessage: 'Delete {componentName}? {message}', description: 'Confirmation text to display before deleting a component', }, + deleteComponentConfirmMsg1: { + id: 'course-authoring.library-authoring.component.delete-confirmation-msg-1', + defaultMessage: 'If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.', + description: 'First part of confirmation message to display before deleting a component', + }, + deleteComponentConfirmMsg2: { + id: 'course-authoring.library-authoring.component.delete-confirmation-msg-2', + defaultMessage: 'If this component has been used in any units, it will also be deleted from those units.', + description: 'Second part of confirmation message to display before deleting a component', + }, deleteComponentCancelButton: { id: 'course-authoring.library-authoring.component.cancel-delete-button', defaultMessage: 'Cancel', diff --git a/src/library-authoring/containers/ContainerEditableTitle.tsx b/src/library-authoring/containers/ContainerEditableTitle.tsx new file mode 100644 index 0000000000..5a1ea0f6df --- /dev/null +++ b/src/library-authoring/containers/ContainerEditableTitle.tsx @@ -0,0 +1,48 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useContext } from 'react'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; +import { ToastContext } from '../../generic/toast-context'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContainer, useUpdateContainer } from '../data/apiHooks'; +import messages from './messages'; + +interface EditableTitleProps { + containerId: string; + textClassName?: string; +} + +export const ContainerEditableTitle = ({ containerId, textClassName }: EditableTitleProps) => { + const intl = useIntl(); + + const { readOnly, showOnlyPublished } = useLibraryContext(); + + const { data: container } = useContainer(containerId); + + const updateMutation = useUpdateContainer(containerId); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + } catch (err) { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + } + }; + + // istanbul ignore if: this should never happen + if (!container) { + return null; + } + + return ( + + ); +}; diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index 39d590db6c..c47a4ee6b9 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -1,17 +1,7 @@ -import { useContext } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { InplaceTextEditor } from '../../generic/inplace-text-editor'; -import { ToastContext } from '../../generic/toast-context'; -import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useContainer, useUpdateContainer } from '../data/apiHooks'; -import messages from './messages'; +import { ContainerEditableTitle } from './ContainerEditableTitle'; const ContainerInfoHeader = () => { - const intl = useIntl(); - - const { readOnly } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); const containerId = sidebarComponentInfo?.id; @@ -20,32 +10,10 @@ const ContainerInfoHeader = () => { throw new Error('containerId is required'); } - const { data: container } = useContainer(containerId); - - const updateMutation = useUpdateContainer(containerId); - const { showToast } = useContext(ToastContext); - - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - displayName: newDisplayName, - }).then(() => { - showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - }).catch(() => { - showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - }); - }; - - if (!container) { - return null; - } - return ( - ); }; diff --git a/src/library-authoring/containers/ContainerOrganize.tsx b/src/library-authoring/containers/ContainerOrganize.tsx index e6585785e2..6419bfd430 100644 --- a/src/library-authoring/containers/ContainerOrganize.tsx +++ b/src/library-authoring/containers/ContainerOrganize.tsx @@ -28,7 +28,7 @@ const ContainerOrganize = () => { const { readOnly } = useLibraryContext(); const { sidebarComponentInfo, sidebarAction } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; const containerId = sidebarComponentInfo?.id; // istanbul ignore if: this should never happen @@ -85,7 +85,7 @@ const ContainerOrganize = () => { > - {intl.formatMessage(messages.organizeTabTagsTitle, { count: tagsCount })} + {intl.formatMessage(messages.manageTabTagsTitle, { count: tagsCount })} @@ -113,7 +113,7 @@ const ContainerOrganize = () => { > - {intl.formatMessage(messages.organizeTabCollectionsTitle, { count: collectionsCount })} + {intl.formatMessage(messages.manageTabCollectionsTitle, { count: collectionsCount })} diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx index 677063f851..e20e27f518 100644 --- a/src/library-authoring/containers/UnitInfo.test.tsx +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -5,7 +5,7 @@ import { initializeMocks, render as baseRender, screen, waitFor, fireEvent, } from '../../testUtils'; -import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks'; +import { mockContentLibrary, mockGetContainerChildren, mockGetContainerMetadata } from '../data/api.mocks'; import { LibraryProvider } from '../common/context/LibraryContext'; import UnitInfo from './UnitInfo'; import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api'; @@ -14,26 +14,33 @@ import { SidebarBodyComponentId, SidebarProvider } from '../common/context/Sideb mockGetContainerMetadata.applyMock(); mockContentLibrary.applyMock(); mockGetContainerMetadata.applyMock(); +mockGetContainerChildren.applyMock(); const { libraryId } = mockContentLibrary; const { containerId } = mockGetContainerMetadata; -const render = () => baseRender(, { - extraWrapper: ({ children }) => ( - - { + const params: { libraryId: string, unitId?: string } = { libraryId, unitId: containerId }; + return baseRender(, { + path: '/library/:libraryId/:unitId?', + params, + extraWrapper: ({ children }) => ( + - {children} - - - ), -}); + + {children} + +
+ ), + }); +}; let axiosMock: MockAdapter; let mockShowToast; @@ -95,4 +102,26 @@ describe('', () => { }); expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes'); }); + + it('show only published content', async () => { + render(true); + expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument(); + expect(screen.getByText(/text block published 1/i)).toBeInTheDocument(); + }); + + it('shows the preview tab by default and the component are readonly', async () => { + render(); + const previewTab = await screen.findByText('Preview'); + expect(previewTab).toBeInTheDocument(); + expect(previewTab).toHaveAttribute('aria-selected', 'true'); + + // Check that there are no edit buttons for components titles + expect(screen.queryAllByRole('button', { name: /edit/i }).length).toBe(0); + + // Check that there are no drag handle for components + expect(screen.queryAllByRole('button', { name: 'Drag to reorder' }).length).toBe(0); + + // Check that there are no menu buttons for components + expect(screen.queryAllByRole('button', { name: /component actions menu/i }).length).toBe(0); + }); }); diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index 164962fcc6..cdf5c2d080 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -9,7 +9,7 @@ import { IconButton, useToggle, } from '@openedx/paragon'; -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { Link } from 'react-router-dom'; import { MoreVert } from '@openedx/paragon/icons'; @@ -17,7 +17,6 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont import { useLibraryContext } from '../common/context/LibraryContext'; import { type UnitInfoTab, - SidebarActions, UNIT_INFO_TABS, isUnitInfoTab, useSidebarContext, @@ -81,9 +80,8 @@ const UnitInfo = () => { sidebarTab, setSidebarTab, sidebarComponentInfo, - sidebarAction, + resetSidebarAction, } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; const { insideUnit } = useLibraryRoutes(); const tab: UnitInfoTab = ( @@ -96,6 +94,12 @@ const UnitInfo = () => { const showOpenUnitButton = !insideUnit && !componentPickerMode; + /* istanbul ignore next */ + const handleTabChange = (newTab: UnitInfoTab) => { + resetSidebarAction(); + setSidebarTab(newTab); + }; + const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => { if (hiddenTabs.includes(infoTab)) { // For some reason, returning anything other than empty list breaks the tab style @@ -117,13 +121,6 @@ const UnitInfo = () => { } }, [publishContainer]); - useEffect(() => { - // Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo - if (jumpToCollections) { - setSidebarTab(UNIT_INFO_TABS.Organize); - } - }, [jumpToCollections, setSidebarTab]); - if (!container || !unitId) { return null; } @@ -163,10 +160,14 @@ const UnitInfo = () => { className="my-3 d-flex justify-content-around" defaultActiveKey={defaultTab.unit} activeKey={tab} - onSelect={setSidebarTab} + onSelect={handleTabChange} > - {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} - {renderTab(UNIT_INFO_TABS.Organize, , intl.formatMessage(messages.organizeTabTitle))} + {renderTab( + UNIT_INFO_TABS.Preview, + , + intl.formatMessage(messages.previewTabTitle), + )} + {renderTab(UNIT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} {renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))} diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 9ebae29e1b..fe8e0139ed 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -11,20 +11,20 @@ const messages = defineMessages({ defaultMessage: 'Preview', description: 'Title for preview tab', }, - organizeTabTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.title', - defaultMessage: 'Organize', - description: 'Title for organize tab', + manageTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.title', + defaultMessage: 'Manage', + description: 'Title for manage tab', }, - organizeTabTagsTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.tags.title', + manageTabTagsTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.tags.title', defaultMessage: 'Tags ({count})', - description: 'Title for tags section in organize tab', + description: 'Title for tags section in manage tab', }, - organizeTabCollectionsTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.collections.title', + manageTabCollectionsTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.collections.title', defaultMessage: 'Collections ({count})', - description: 'Title for collections section in organize tab', + description: 'Title for collections section in manage tab', }, publishContainerButton: { id: 'course-authoring.library-authoring.container-sidebar.publish-button', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index eb5f60188e..92220b140b 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -188,6 +188,7 @@ mockCreateLibraryBlock.newHtmlData = { id: 'lb:Axim:TEST:html:123', blockType: 'html', displayName: 'New Text Component', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -202,6 +203,7 @@ mockCreateLibraryBlock.newProblemData = { id: 'lb:Axim:TEST:problem:prob1', blockType: 'problem', displayName: 'New Problem', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -216,6 +218,7 @@ mockCreateLibraryBlock.newVideoData = { id: 'lb:Axim:TEST:video:vid1', blockType: 'video', displayName: 'New Video', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -348,6 +351,7 @@ mockLibraryBlockMetadata.dataNeverPublished = { id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1', blockType: 'html', displayName: 'Introduction to Testing 1', + publishedDisplayName: null, lastPublished: null, publishedBy: null, lastDraftCreated: null, @@ -363,6 +367,7 @@ mockLibraryBlockMetadata.dataPublished = { id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: 'Introduction to Testing 2', lastPublished: '2024-06-22T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -391,6 +396,7 @@ mockLibraryBlockMetadata.dataWithCollections = { id: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: null, lastPublished: '2024-06-21T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -407,6 +413,7 @@ mockLibraryBlockMetadata.dataPublishedWithChanges = { id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fvv', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: 'Introduction to Testing 3', lastPublished: '2024-06-22T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -488,6 +495,7 @@ mockGetContainerMetadata.containerData = { id: 'lct:org:lib:unit:test-unit-9a2072', containerType: 'unit', displayName: 'Test Unit', + publishedDisplayName: 'Published Test Unit', created: '2024-09-19T10:00:00Z', createdBy: 'test_author', lastPublished: '2024-09-20T10:00:00Z', @@ -536,6 +544,7 @@ export async function mockGetContainerChildren(containerId: string): Promise jest.spyOn(api, 'getBlockTypes').mockImplementation(mockBlockTypesMetadata); -export async function mockGetUnpaginatedEntityLinks( +export async function mockGetEntityLinks( _downstreamContextKey?: string, _readyToSync?: boolean, upstreamUsageKey?: string, -): ReturnType { - const thisMock = mockGetUnpaginatedEntityLinks; +): ReturnType { + const thisMock = mockGetEntityLinks; switch (upstreamUsageKey) { case thisMock.upstreamUsageKey: return thisMock.response; case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response; @@ -688,8 +698,8 @@ export async function mockGetUnpaginatedEntityLinks( default: return []; } } -mockGetUnpaginatedEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; -mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ +mockGetEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; +mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ id: 875, upstreamContextTitle: 'CS problems 3', upstreamVersion: 10, @@ -703,10 +713,10 @@ mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map( created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', })); -mockGetUnpaginatedEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty'; -mockGetUnpaginatedEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[]; +mockGetEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty'; +mockGetEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[]; -mockGetUnpaginatedEntityLinks.applyMock = () => jest.spyOn( +mockGetEntityLinks.applyMock = () => jest.spyOn( courseLibApi, - 'getUnpaginatedEntityLinks', -).mockImplementation(mockGetUnpaginatedEntityLinks); + 'getEntityLinks', +).mockImplementation(mockGetEntityLinks); diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index f5882ee678..ece0773f4d 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -142,4 +142,13 @@ describe('library data API', () => { await api.removeLibraryContainerChildren(containerId, ['test']); expect(axiosMock.history.delete[0].url).toEqual(url); }); + + it('getContentLibraryV2List', async () => { + const url = api.getContentLibraryV2ListApiUrl(); + + axiosMock.onGet(url).reply(200, { some: 'data' }); + + await api.getContentLibraryV2List({ type: 'complex' }); + expect(axiosMock.history.get[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 220ce0a3f9..dd8a8f7430 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -119,7 +119,7 @@ export const getLibraryContainerRestoreApiUrl = (containerId: string) => `${getL /** * Get the URL for a single container children api. */ -export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}children/`; +export const getLibraryContainerChildrenApiUrl = (containerId: string, published: boolean = false) => `${getLibraryContainerApiUrl(containerId)}children/?published=${published}`; /** * Get the URL for library container collections. */ @@ -250,6 +250,7 @@ export interface LibraryBlockMetadata { id: string; blockType: string; displayName: string; + publishedDisplayName: string | null; lastPublished: string | null; publishedBy: string | null; lastDraftCreated: string | null; @@ -259,6 +260,9 @@ export interface LibraryBlockMetadata { modified: string | null; tagsCount: number; collections: CollectionMetadata[]; + // Local only variable set to true when a new block is added + // NOTE: Currently only updated when a new component is added inside a unit + isNew?: boolean; } export interface UpdateLibraryDataRequest { @@ -596,6 +600,7 @@ export interface Container { id: string; containerType: 'unit'; displayName: string; + publishedDisplayName: string; lastPublished: string | null; publishedBy: string | null; createdBy: string | null; @@ -649,8 +654,13 @@ export async function restoreContainer(containerId: string) { /** * Fetch a library container's children's metadata. */ -export async function getLibraryContainerChildren(containerId: string): Promise { - const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerChildrenApiUrl(containerId)); +export async function getLibraryContainerChildren( + containerId: string, + published: boolean = false, +): Promise { + const { data } = await getAuthenticatedHttpClient().get( + getLibraryContainerChildrenApiUrl(containerId, published), + ); return camelCaseObject(data); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index e9a5202b14..3fd1f0633e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -5,6 +5,7 @@ import { useQueryClient, type Query, type QueryClient, + replaceEqualDeep, } from '@tanstack/react-query'; import { useCallback } from 'react'; @@ -90,7 +91,11 @@ export const xblockQueryKeys = { componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'], componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'], - /** Predicate used to invalidate all metadata only */ + /** + * Predicate used to invalidate all metadata only (not OLX, fields, assets, etc.). + * Affects all libraries; we could do a more complex version that affects only one library, but it would require + * introspecting the usage keys. + */ allComponentMetadata: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'componentMetadata', }; @@ -207,23 +212,32 @@ export const useContentLibraryV2List = (customParams: api.GetLibrariesV2CustomPa }) ); +/** Publish all changes in the library. */ export const useCommitLibraryChanges = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.commitLibraryChanges, onSettled: (_data, _error, libraryId) => { + // Invalidate all content-related metadata and search results for the whole library. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); }, }); }; +/** Discard all un-published changes in the library */ export const useRevertLibraryChanges = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.revertLibraryChanges, onSettled: (_data, _error, libraryId) => { + // Invalidate all content-related metadata and search results for the whole library. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); }, }); }; @@ -458,15 +472,28 @@ export const useCollection = (libraryId: string, collectionId: string) => ( */ export const useUpdateCollection = (libraryId: string, collectionId: string) => { const queryClient = useQueryClient(); + const collectionQueryKey = libraryAuthoringQueryKeys.collection(libraryId, collectionId); return useMutation({ mutationFn: (data: api.UpdateCollectionComponentsRequest) => ( api.updateCollectionMetadata(libraryId, collectionId, data) ), + onMutate: (data) => { + const previousData = queryClient.getQueryData(collectionQueryKey) as api.CollectionMetadata; + queryClient.setQueryData(collectionQueryKey, { + ...previousData, + ...data, + }); + + return { previousData }; + }, + onError: (_err, _data, context) => { + queryClient.setQueryData(collectionQueryKey, context?.previousData); + }, onSettled: () => { // NOTE: We invalidate the library query here because we need to update the library's // collection list. queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId) }); + queryClient.invalidateQueries({ queryKey: collectionQueryKey }); }, }); }; @@ -584,13 +611,26 @@ export const useContainer = (containerId?: string) => ( export const useUpdateContainer = (containerId: string) => { const libraryId = getLibraryId(containerId); const queryClient = useQueryClient(); + const containerQueryKey = libraryAuthoringQueryKeys.container(containerId); return useMutation({ mutationFn: (data: api.UpdateContainerDataRequest) => api.updateContainerMetadata(containerId, data), + onMutate: (data) => { + const previousData = queryClient.getQueryData(containerQueryKey) as api.Container; + queryClient.setQueryData(containerQueryKey, { + ...previousData, + ...data, + }); + + return { previousData }; + }, + onError: (_err, _data, context) => { + queryClient.setQueryData(containerQueryKey, context?.previousData); + }, onSettled: () => { // NOTE: We invalidate the library query here because we need to update the library's // container list. queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) }); + queryClient.invalidateQueries({ queryKey: containerQueryKey }); }, }); }; @@ -627,11 +667,27 @@ export const useRestoreContainer = (containerId: string) => { /** * Get the metadata and children for a container in a library */ -export const useContainerChildren = (containerId?: string) => ( +export const useContainerChildren = (containerId?: string, published: boolean = false) => ( useQuery({ enabled: !!containerId, queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!), - queryFn: () => api.getLibraryContainerChildren(containerId!), + queryFn: () => api.getLibraryContainerChildren(containerId!, published), + structuralSharing: (oldData: api.LibraryBlockMetadata[], newData: api.LibraryBlockMetadata[]) => { + // This just sets `isNew` flag to new children components + if (oldData) { + const oldDataIds = oldData.map((obj) => obj.id); + // eslint-disable-next-line no-param-reassign + newData = newData.map((newObj) => { + if (!oldDataIds.includes(newObj.id)) { + // Set isNew = true if we have new child on refetch + // eslint-disable-next-line no-param-reassign + newObj.isNew = true; + } + return newObj; + }); + } + return replaceEqualDeep(oldData, newData); + }, }) ); @@ -642,13 +698,22 @@ export const useAddComponentsToContainer = (containerId?: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (componentIds: string[]) => { - if (containerId !== undefined) { - return api.addComponentsToContainer(containerId, componentIds); + // istanbul ignore if: this should never happen + if (!containerId) { + return undefined; } - return undefined; + return api.addComponentsToContainer(containerId, componentIds); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!) }); + // istanbul ignore if: this should never happen + if (!containerId) { + return; + } + // NOTE: We invalidate the library query here because we need to update the library's + // container list. + const libraryId = getLibraryId(containerId); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); }, }); }; diff --git a/src/library-authoring/generic/filter-by-published/index.tsx b/src/library-authoring/generic/filter-by-published/index.tsx new file mode 100644 index 0000000000..825ac56f4d --- /dev/null +++ b/src/library-authoring/generic/filter-by-published/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useLibraryContext } from '../../common/context/LibraryContext'; +import { FilterByPublished, PublishStatus } from '../../../search-manager'; + +/** + * When browsing library content for insertion into a course, we only show published + * content. In that case, there is no need for a 'Never Published' filter, which will + * never show results. This component removes that option from FilterByPublished + * when not relevant. + */ +const LibraryFilterByPublished : React.FC> = () => { + const { showOnlyPublished } = useLibraryContext(); + + if (showOnlyPublished) { + return ( + + ); + } + + return ; +}; + +export default LibraryFilterByPublished; diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx index b73dd0d837..7f7b79d436 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx @@ -77,7 +77,7 @@ describe('', () => { await waitFor(() => { expect(axiosMock.history.patch.length).toEqual(1); }); - expect(mockShowToast).toHaveBeenCalledWith('Item collections updated'); + expect(mockShowToast).toHaveBeenCalledWith('Content added to collection.'); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-first-collection', 'my-second-collection'], }); @@ -103,7 +103,7 @@ describe('', () => { await waitFor(() => { expect(axiosMock.history.patch.length).toEqual(1); }); - expect(mockShowToast).toHaveBeenCalledWith('Item collections updated'); + expect(mockShowToast).toHaveBeenCalledWith('Content added to collection.'); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-first-collection', 'my-second-collection'], }); @@ -133,7 +133,7 @@ describe('', () => { expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-second-collection'], }); - expect(mockShowToast).toHaveBeenCalledWith('Failed to update item collections'); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.'); expect(screen.queryByRole('search')).not.toBeInTheDocument(); }); diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.tsx index 41bc36ce7c..4cafadfa47 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.tsx @@ -16,6 +16,7 @@ import { ToastContext } from '../../../generic/toast-context'; import { CollectionMetadata } from '../../data/api'; import { useLibraryContext } from '../../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../../common/context/SidebarContext'; +import genericMessages from '../messages'; import messages from './messages'; interface ManageCollectionsProps { @@ -50,9 +51,9 @@ const CollectionsSelectableBox = ({ const handleConfirmation = () => { setBtnState('pending'); updateCollectionsMutation.mutateAsync(selectedCollections).then(() => { - showToast(intl.formatMessage(messages.manageCollectionsToComponentSuccess)); + showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess)); }).catch(() => { - showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }).finally(() => { setBtnState('default'); onClose(); @@ -205,7 +206,7 @@ const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }: const collectionNames = collections.map((collection) => collection.title); return ( - sidebarAction === SidebarActions.JumpToAddCollections + sidebarAction === SidebarActions.JumpToManageCollections ? ( setSidebarAction(SidebarActions.JumpToAddCollections)} + onManageClick={() => setSidebarAction(SidebarActions.JumpToManageCollections)} /> ) ); diff --git a/src/library-authoring/generic/manage-collections/messages.ts b/src/library-authoring/generic/manage-collections/messages.ts index c9b998be47..1afefa4967 100644 --- a/src/library-authoring/generic/manage-collections/messages.ts +++ b/src/library-authoring/generic/manage-collections/messages.ts @@ -21,16 +21,6 @@ const messages = defineMessages({ defaultMessage: 'Collection selection', description: 'Aria label text for collection selection box', }, - manageCollectionsToComponentSuccess: { - id: 'course-authoring.library-authoring.manage-collections.add-success', - defaultMessage: 'Item collections updated', - description: 'Message to display on updating item collections', - }, - manageCollectionsToComponentFailed: { - id: 'course-authoring.library-authoring.manage-collections.add-failed', - defaultMessage: 'Failed to update item collections', - description: 'Message to display on failure of updating item collections', - }, manageCollectionsToComponentConfirmBtn: { id: 'course-authoring.library-authoring.manage-collections.add-confirm-btn', defaultMessage: 'Confirm', diff --git a/src/library-authoring/generic/messages.ts b/src/library-authoring/generic/messages.ts new file mode 100644 index 0000000000..e1aec050f0 --- /dev/null +++ b/src/library-authoring/generic/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + manageCollectionsSuccess: { + id: 'course-authoring.library-authoring.manage-collections.success', + defaultMessage: 'Content added to collection.', + description: 'Message to display on updating item collections', + }, + manageCollectionsFailed: { + id: 'course-authoring.library-authoring.manage-collections.failed', + defaultMessage: 'Failed to add content to collection.', + description: 'Message to display on failure of updating item collections', + }, +}); + +export default messages; diff --git a/src/library-authoring/generic/status-widget/index.tsx b/src/library-authoring/generic/status-widget/index.tsx index 19fdc71444..3694e2836c 100644 --- a/src/library-authoring/generic/status-widget/index.tsx +++ b/src/library-authoring/generic/status-widget/index.tsx @@ -85,6 +85,7 @@ type StatusWidgedProps = { publishedBy: string | null; numBlocks?: number; onCommit?: () => void; + onCommitLabel?: string; onRevert?: () => void; }; @@ -114,6 +115,7 @@ const StatusWidget = ({ publishedBy, numBlocks, onCommit, + onCommitLabel, onRevert, }: StatusWidgedProps) => { const intl = useIntl(); @@ -188,7 +190,7 @@ const StatusWidget = ({ {onCommit && ( )} {onRevert && ( diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index 89ac4e8468..b6ee64738e 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -51,6 +51,7 @@ const LibraryPublishStatus = () => { void; +} + /** * Sidebar container for library pages. * @@ -24,9 +30,25 @@ import messages from '../messages'; * You can add more components in `bodyComponentMap`. * Use the returned actions to open and close this sidebar. */ -const LibrarySidebar = () => { +const LibrarySidebar = ({ onSidebarClose }: LibrarySidebarProps) => { const intl = useIntl(); - const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext(); + const { + sidebarAction, + setSidebarTab, + sidebarComponentInfo, + closeLibrarySidebar, + } = useSidebarContext(); + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; + const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags; + + React.useEffect(() => { + // Show Manage tab if JumpToManageCollections or JumpToManageTags action is set + if (jumpToCollections || jumpToTags) { + // COMPONENT_INFO_TABS.Manage works for containers as well as its value + // is same as UNIT_INFO_TABS.Manage. + setSidebarTab(COMPONENT_INFO_TABS.Manage); + } + }, [jumpToCollections, setSidebarTab, jumpToTags]); const bodyComponentMap = { [SidebarBodyComponentId.AddContent]: , @@ -49,6 +71,11 @@ const LibrarySidebar = () => { const buildBody = () : React.ReactNode => bodyComponentMap[sidebarComponentInfo?.type || 'unknown']; const buildHeader = (): React.ReactNode => headerComponentMap[sidebarComponentInfo?.type || 'unknown']; + const handleSidebarClose = () => { + closeLibrarySidebar(); + onSidebarClose?.(); + }; + return ( @@ -58,7 +85,7 @@ const LibrarySidebar = () => { src={Close} iconAs={Icon} alt={intl.formatMessage(messages.closeButtonAlt)} - onClick={closeLibrarySidebar} + onClick={handleSidebarClose} size="inline" /> diff --git a/src/library-authoring/routes.test.tsx b/src/library-authoring/routes.test.tsx index 8a58f3a41f..ad03cbc4c3 100644 --- a/src/library-authoring/routes.test.tsx +++ b/src/library-authoring/routes.test.tsx @@ -15,6 +15,14 @@ jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, })); +jest.mock('./common/context/LibraryContext', () => ({ + ...jest.requireActual('./common/context/LibraryContext'), + useLibraryContext: () => ({ + setComponentId: jest.fn(), + setUnitId: jest.fn(), + setCollectionId: jest.fn(), + }), +})); mockContentLibrary.applyMock(); diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 1bee5219ab..4615f229d3 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -11,6 +11,7 @@ import { useSearchParams, type PathMatch, } from 'react-router-dom'; +import { useLibraryContext } from './common/context/LibraryContext'; export const BASE_ROUTE = '/library/:libraryId'; @@ -48,6 +49,7 @@ export type NavigateToData = { collectionId?: string, contentType?: ContentType, unitId?: string, + doubleClicked?: boolean, }; export type LibraryRoutesData = { @@ -66,6 +68,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const params = useParams(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const { setComponentId, setUnitId, setCollectionId } = useLibraryContext(); const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname); const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname); @@ -78,6 +81,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { collectionId, unitId, contentType, + doubleClicked, }: NavigateToData = {}) => { const { collectionId: urlCollectionId, @@ -99,6 +103,18 @@ export const useLibraryRoutes = (): LibraryRoutesData => { }; let route: string; + // Update componentId, unitId, collectionId in library context if is not undefined. + // Ids can be cleared from route by passing in empty string so we need to set it. + if (componentId !== undefined) { + setComponentId(componentId); + } + if (unitId !== undefined) { + setUnitId(unitId); + } + if (collectionId !== undefined) { + setCollectionId(collectionId); + } + // Providing contentType overrides the current route so we can change tabs. if (contentType === ContentType.components) { route = ROUTES.COMPONENTS; @@ -111,7 +127,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { } else if (insideCollections) { // We're inside the Collections tab, route = ( - (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) + (collectionId && doubleClicked) // now open the previously-selected collection, ? ROUTES.COLLECTION // or stay there to list all collections, or a selected collection. @@ -128,7 +144,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { } else if (insideUnits) { // We're inside the units tab, route = ( - (unitId && unitId === (urlUnitId || urlSelectedItemId)) + (unitId && doubleClicked) // now open the previously-selected unit, ? ROUTES.UNIT // or stay there to list all units, or a selected unit. @@ -142,10 +158,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => { // We're inside the All Content tab, so stay there, // and select a component. route = ROUTES.COMPONENT; - } else if (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) { + } else if (collectionId && doubleClicked) { // now open the previously-selected collection route = ROUTES.COLLECTION; - } else if (unitId && unitId === (urlUnitId || urlSelectedItemId)) { + } else if (unitId && doubleClicked) { // now open the previously-selected unit route = ROUTES.UNIT; } else { @@ -158,7 +174,15 @@ export const useLibraryRoutes = (): LibraryRoutesData => { pathname: newPath, search: searchParams.toString(), }); - }, [navigate, params, searchParams, pathname]); + }, [ + navigate, + params, + searchParams, + pathname, + setComponentId, + setUnitId, + setCollectionId, + ]); return { navigateTo, diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 1ef20fa39a..e2d654cacd 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -1,12 +1,12 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - ActionRow, Badge, Button, Icon, IconButton, Stack, useToggle, + ActionRow, Badge, Button, Icon, Stack, useToggle, } from '@openedx/paragon'; -import { Add, Description, DragIndicator } from '@openedx/paragon/icons'; -import { useQueryClient } from '@tanstack/react-query'; +import { Add, Description } from '@openedx/paragon/icons'; import classNames from 'classnames'; -import { useContext, useEffect, useState } from 'react'; -import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; +import { + useCallback, useContext, useEffect, useState, +} from 'react'; import { blockTypes } from '../../editors/data/constants/app'; import DraggableList, { SortableItem } from '../../generic/DraggableList'; @@ -22,17 +22,17 @@ import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; import { - libraryAuthoringQueryKeys, useContainerChildren, useUpdateContainerChildren, useUpdateXBlockFields, } from '../data/apiHooks'; import { LibraryBlock } from '../LibraryBlock'; -import { useLibraryRoutes } from '../routes'; +import { useLibraryRoutes, ContentType } from '../routes'; import messages from './messages'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { ToastContext } from '../../generic/toast-context'; import { canEditComponent } from '../components/ComponentEditorModal'; +import { useRunOnNextRender } from '../../utils'; /** Components that need large min height in preview */ const LARGE_COMPONENTS = [ @@ -43,195 +43,247 @@ const LARGE_COMPONENTS = [ 'lti_consumer', ]; -interface BlockHeaderProps { - block: LibraryBlockMetadata; - onTagClick: () => void; +interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata { + originalId: string; +} + +interface ComponentBlockProps { + block: LibraryBlockMetadataWithUniqueId; + readOnly?: boolean; + isDragging?: boolean; } -/** Component header, split out to reuse in drag overlay */ -const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => { +/** Component header */ +const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => { const intl = useIntl(); + const { showOnlyPublished } = useLibraryContext(); const { showToast } = useContext(ToastContext); + const { navigateTo } = useLibraryRoutes(); + const { openComponentInfoSidebar, setSidebarAction } = useSidebarContext(); - const updateMutation = useUpdateXBlockFields(block.id); + const updateMutation = useUpdateXBlockFields(block.originalId); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - metadata: { - display_name: newDisplayName, - }, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }); showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - }); + throw err; + } + }; + + /* istanbul ignore next */ + const scheduleJumpToTags = useRunOnNextRender(() => { + // TODO: Ugly hack to make sure sidebar shows manage tags section + // This needs to run after all changes to url takes place to avoid conflicts. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageTags), 250); + }); + + /* istanbul ignore next */ + const jumpToManageTags = () => { + navigateTo({ componentId: block.originalId }); + openComponentInfoSidebar(block.originalId); + scheduleJumpToTags(); }; return ( <> - + e.stopPropagation()} + > - - {block.hasUnpublishedChanges && ( + e.stopPropagation()} + > + {!showOnlyPublished && block.hasUnpublishedChanges && ( - + )} - - + + {!readOnly && } ); }; -interface LibraryUnitBlocksProps { - /** set to true if it is rendered as preview - * This disables drag and drop - */ - preview?: boolean; -} - -export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { - const intl = useIntl(); - const [orderedBlocks, setOrderedBlocks] = useState([]); - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); - - const [hidePreviewFor, setHidePreviewFor] = useState(null); +/** ComponentBlock to render preview of given component under Unit */ +const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => { + const { showOnlyPublished } = useLibraryContext(); const { navigateTo } = useLibraryRoutes(); - const { showToast } = useContext(ToastContext); const { - unitId, - showOnlyPublished, - componentId, - readOnly, - setComponentId, - openComponentEditor, + unitId, collectionId, componentId, openComponentEditor, } = useLibraryContext(); - const { - openAddContentSidebar, - } = useSidebarContext(); - - const queryClient = useQueryClient(); - const orderMutator = useUpdateContainerChildren(unitId); - const { - data: blocks, - isLoading, - isError, - error, - } = useContainerChildren(unitId); - - useEffect(() => setOrderedBlocks(blocks || []), [blocks]); - - if (isLoading) { - return ; - } - - if (isError) { - // istanbul ignore next - return ; - } - - const handleReorder = () => async (newOrder: LibraryBlockMetadata[]) => { - const usageKeys = newOrder.map((o) => o.id); - try { - await orderMutator.mutateAsync(usageKeys); - showToast(intl.formatMessage(messages.orderUpdatedMsg)); - } catch (e) { - showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); - } - }; - - const onTagSidebarClose = () => { - queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!)); - closeManageTagsDrawer(); - }; + const { openInfoSidebar, sidebarComponentInfo } = useSidebarContext(); - const handleComponentSelection = (block: LibraryBlockMetadata, numberOfClicks: number) => { - setComponentId(block.id); - navigateTo({ componentId: block.id }); - const canEdit = canEditComponent(block.id); + const handleComponentSelection = useCallback((numberOfClicks: number) => { + navigateTo({ componentId: block.originalId }); + const canEdit = canEditComponent(block.originalId); if (numberOfClicks > 1 && canEdit) { // Open editor on double click. - openComponentEditor(block.id); + openComponentEditor(block.originalId); + } else { + // open current component sidebar + openInfoSidebar(block.originalId, collectionId, unitId); } - }; + }, [block, collectionId, unitId, navigateTo, canEditComponent, openComponentEditor, openInfoSidebar]); + + useEffect(() => { + if (block.isNew) { + handleComponentSelection(1); + } + }, [block]); /* istanbul ignore next */ - const calculateMinHeight = (block: LibraryBlockMetadata) => { + const calculateMinHeight = () => { if (LARGE_COMPONENTS.includes(block.blockType)) { return '700px'; } return '200px'; }; - const renderOverlay = (activeId: string | null) => { - if (!activeId) { - return null; - } - const block = orderedBlocks?.find((val) => val.id === activeId); - if (!block) { - return null; + const getComponentStyle = useCallback(() => { + if (isDragging) { + return { + outline: '2px dashed gray', + maxHeight: '200px', + overflowY: 'hidden', + }; } - return ( - - - - - ); - }; + return {}; + }, [isDragging, componentId, block]); + + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo + && sidebarComponentInfo?.id === block.originalId; - const renderedBlocks = orderedBlocks?.map((block) => ( - + return ( + } + componentStyle={getComponentStyle()} + actions={} actionStyle={{ borderRadius: '8px 8px 0px 0px', padding: '0.5rem 1rem', background: '#FBFAF9', borderBottom: 'solid 1px #E1DDDB', - outline: hidePreviewFor === block.id && '2px dashed gray', }} - isClickable - onClick={(e: { detail: number; }) => handleComponentSelection(block, e.detail)} - disabled={preview} + isClickable={!readOnly} + onClick={!readOnly ? (e: { detail: number; }) => handleComponentSelection(e.detail) : undefined} + disabled={readOnly} + cardClassName={selected ? 'selected' : undefined} > - {hidePreviewFor !== block.id && ( -
e.stopPropagation()} >
- )}
- )); + ); +}; + +interface LibraryUnitBlocksProps { + /** set to true if it is rendered as preview + * This disables drag and drop, title edit and menus + */ + readOnly?: boolean; +} + +export const LibraryUnitBlocks = ({ readOnly: componentReadOnly }: LibraryUnitBlocksProps) => { + const intl = useIntl(); + const [orderedBlocks, setOrderedBlocks] = useState([]); + const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + + const [hidePreviewFor, setHidePreviewFor] = useState(null); + const { showToast } = useContext(ToastContext); + + const { unitId, readOnly: libraryReadOnly, showOnlyPublished } = useLibraryContext(); + + const readOnly = componentReadOnly || libraryReadOnly; + + const { openAddContentSidebar } = useSidebarContext(); + + const orderMutator = useUpdateContainerChildren(unitId); + const { + data: blocks, + isLoading, + isError, + error, + } = useContainerChildren(unitId, showOnlyPublished); + + const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => { + if (!newOrder) { + return; + } + const usageKeys = newOrder.map((o) => o.originalId); + try { + await orderMutator.mutateAsync(usageKeys); + showToast(intl.formatMessage(messages.orderUpdatedMsg)); + } catch (e) { + showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); + } + }, [orderMutator]); + + useEffect(() => { + // Create new ids which are unique using index. + // This is required to support multiple components with same id under a unit. + const newBlocks = blocks?.map((block, idx) => { + const newBlock: LibraryBlockMetadataWithUniqueId = { + ...block, + id: `${block.id}----${idx}`, + originalId: block.id, + }; + return newBlock; + }); + return setOrderedBlocks(newBlocks || []); + }, [blocks, setOrderedBlocks]); + + if (isLoading) { + return ; + } + + if (isError) { + // istanbul ignore next + return ; + } return (
@@ -239,13 +291,22 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder} - renderOverlay={renderOverlay} activeId={hidePreviewFor} setActiveId={setHidePreviewFor} > - {renderedBlocks} + {orderedBlocks?.map((block, idx) => ( + // A container can have multiple instances of the same block + // eslint-disable-next-line react/no-array-index-key + + ))} - { !preview && ( + {!readOnly && (
)} -
); }; diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index f90dcedd12..0ef0a43d49 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -30,6 +30,8 @@ import { ToastActionData } from '../../generic/toast-context'; const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; +jest.mock('../../assistant/context/hooks'); + let axiosMock: MockAdapter; let mockShowToast: (message: string, action?: ToastActionData | undefined) => void; @@ -42,14 +44,14 @@ mockContentLibrary.applyMock(); mockXBlockFields.applyMock(); mockLibraryBlockMetadata.applyMock(); -const closestCenter = jest.fn(); -jest.mock('@dnd-kit/core', () => ({ - ...jest.requireActual('@dnd-kit/core'), +const verticalSortableListCollisionDetection = jest.fn(); +jest.mock('../../generic/DraggableList/verticalSortableList', () => ({ + ...jest.requireActual('../../generic/DraggableList/verticalSortableList'), // Since jsdom (used by jest) does not support getBoundingClientRect function // which is required for drag-n-drop calculations, we mock closestCorners fn // from dnd-kit to return collided elements as per the test. This allows us to // test all drag-n-drop handlers. - closestCenter: () => closestCenter(), + verticalSortableListCollisionDetection: () => verticalSortableListCollisionDetection(), })); describe('', () => { @@ -106,12 +108,12 @@ describe('', () => { it('can rename unit', async () => { renderLibraryUnitPage(); expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - // Unit title - const unitTitle = screen.getAllByRole( + + const editUnitTitleButton = screen.getAllByRole( 'button', - { name: mockGetContainerMetadata.containerData.displayName }, - )[0]; - fireEvent.click(unitTitle); + { name: /edit/i }, + )[0]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editUnitTitleButton); const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); axiosMock.onPatch(url).reply(200); @@ -137,12 +139,12 @@ describe('', () => { it('show error if renaming unit fails', async () => { renderLibraryUnitPage(); expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - // Unit title - const unitTitle = screen.getAllByRole( + + const editUnitTitleButton = screen.getAllByRole( 'button', - { name: mockGetContainerMetadata.containerData.displayName }, - )[0]; - fireEvent.click(unitTitle); + { name: /edit/i }, + )[0]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editUnitTitleButton); const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); axiosMock.onPatch(url).reply(400); @@ -187,9 +189,9 @@ describe('', () => { it('should open and close component sidebar on component selection', async () => { renderLibraryUnitPage(); - const component = await screen.findByText('text block 0'); - userEvent.click(component); + // Card is 3 levels up the component name div + userEvent.click(component.parentElement!.parentElement!.parentElement!); const sidebar = await screen.findByTestId('library-sidebar'); const { findByRole, findByText } = within(sidebar); @@ -210,11 +212,11 @@ describe('', () => { // Wait loading of the component await screen.findByText('text block 0'); - const componentTitle = screen.getAllByRole( + const editButton = screen.getAllByRole( 'button', - { name: 'text block 0' }, - )[0]; - fireEvent.click(componentTitle); + { name: /edit/i }, + )[1]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editButton); await waitFor(() => { expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); @@ -244,11 +246,11 @@ describe('', () => { // Wait loading of the component await screen.findByText('text block 0'); - const componentTitle = screen.getAllByRole( + const editButton = screen.getAllByRole( 'button', - { name: 'text block 0' }, - )[0]; - fireEvent.click(componentTitle); + { name: /edit/i }, + )[1]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editButton); await waitFor(() => { expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); @@ -276,14 +278,26 @@ describe('', () => { axiosMock .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) .reply(200); - closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + }); + + it('should cancel update order api on cancelling dragging component', async () => { + renderLibraryUnitPage(); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) + .reply(200); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); - await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Escape' })); + await waitFor(() => expect(mockShowToast).not.toHaveBeenLastCalledWith('Order updated')); }); it('should show toast error message on update order failure', async () => { @@ -292,13 +306,11 @@ describe('', () => { axiosMock .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) .reply(500); - closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); - await act(async () => { - fireEvent.keyDown(firstDragHandle, { code: 'Space' }); - }); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order')); }); @@ -311,7 +323,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -342,7 +354,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -360,7 +372,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -388,7 +400,7 @@ describe('', () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0'); - userEvent.click(component); + userEvent.click(component.parentElement!.parentElement!.parentElement!); const sidebar = await screen.findByTestId('library-sidebar'); const { findByRole, findByText } = within(sidebar); @@ -409,7 +421,7 @@ describe('', () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0'); // trigger double click - userEvent.click(component, undefined, { clickCount: 2 }); + userEvent.click(component.parentElement!.parentElement!.parentElement!, undefined, { clickCount: 2 }); expect(await screen.findByRole('dialog', { name: 'Editor Dialog' })).toBeInTheDocument(); }); }); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index e362cdb900..c8d337314b 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -41,14 +41,16 @@ const EditableTitle = ({ unitId }: EditableTitleProps) => { const updateMutation = useUpdateContainer(unitId); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - displayName: newDisplayName, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - }); + throw err; + } }; // istanbul ignore if: this should never happen @@ -91,7 +93,7 @@ const HeaderActions = () => { } else { openUnitInfoSidebar(unitId); } - navigateTo({ unitId }); + navigateTo({ unitId, componentId: '' }); }, [unitId, infoSidebarIsOpen]); return ( @@ -123,21 +125,30 @@ export const LibraryUnitPage = () => { const { libraryId, unitId, - collectionId, componentId, + collectionId, } = useLibraryContext(); const { - sidebarComponentInfo, openInfoSidebar, + sidebarComponentInfo, setDefaultTab, setHiddenTabs, } = useSidebarContext(); + const { navigateTo } = useLibraryRoutes(); + + // Open unit or component sidebar on mount + useEffect(() => { + // includes componentId to open correct sidebar on page mount from url + openInfoSidebar(componentId, collectionId, unitId); + // avoid including componentId in dependencies to prevent flicker on closing sidebar. + // See below useEffect that clears componentId on closing sidebar. + }, [unitId, collectionId]); useEffect(() => { setDefaultTab({ collection: COLLECTION_INFO_TABS.Details, component: COMPONENT_INFO_TABS.Manage, - unit: UNIT_INFO_TABS.Organize, + unit: UNIT_INFO_TABS.Manage, }); setHiddenTabs([COMPONENT_INFO_TABS.Preview, UNIT_INFO_TABS.Preview]); return () => { @@ -150,10 +161,6 @@ export const LibraryUnitPage = () => { }; }, [setDefaultTab, setHiddenTabs]); - useEffect(() => { - openInfoSidebar(componentId, collectionId, unitId); - }, [componentId, unitId, collectionId]); - if (!unitId || !libraryId) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. throw new Error('Rendered without unitId or libraryId URL parameter'); @@ -232,7 +239,7 @@ export const LibraryUnitPage = () => { className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar" > - + navigateTo({ componentId: '' })} />
)}
diff --git a/src/library-authoring/units/index.scss b/src/library-authoring/units/index.scss index 91b5d78b1f..749e60b447 100644 --- a/src/library-authoring/units/index.scss +++ b/src/library-authoring/units/index.scss @@ -4,6 +4,7 @@ padding: 0; margin-bottom: 1rem; border: solid 1px $light-500; + } .pgn__card.clickable { @@ -15,6 +16,23 @@ // this is required for clicks to be passed to underlying iframe component pointer-events: none; } + + &.selected:not(:focus) { + outline: 2px $gray-700 solid; + } + + &.selected:focus { + outline: 3px $gray-700 solid; + } + + &:not(.selected):focus { + outline: 1px $gray-200 solid; + outline-offset: 2px; + } + + &::before { + border: none !important; // Remove default focus + } } .pgn__action-row { diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 2382be67a5..56ba8a63a7 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -92,7 +92,7 @@ const PagesAndResources = ({ courseId }) => { } /> - + } courseId={courseId} /> { (contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin) && ( @@ -100,7 +100,7 @@ const PagesAndResources = ({ courseId }) => {

{intl.formatMessage(messages.contentPermissions)}

- + } /> ) } diff --git a/src/pages-and-resources/PagesAndResources.test.jsx b/src/pages-and-resources/PagesAndResources.test.jsx index 180c3f8674..0e662e3f86 100644 --- a/src/pages-and-resources/PagesAndResources.test.jsx +++ b/src/pages-and-resources/PagesAndResources.test.jsx @@ -1,16 +1,39 @@ import { screen, waitFor } from '@testing-library/react'; +import { getConfig, setConfig } from '@edx/frontend-platform'; +import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework'; import { PagesAndResources } from '.'; import { render } from './utils.test'; +const mockPlugin = (identifier) => ({ + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'mock-plugin-1', + type: DIRECT_PLUGIN, + priority: 1, + RenderWidget: () =>
HELLO
, + }, + }, + ], +}); + const courseId = 'course-v1:edX+TestX+Test_Course'; describe('PagesAndResources', () => { beforeEach(() => { jest.clearAllMocks(); + setConfig({ + ...getConfig(), + pluginSlots: { + 'org.openedx.frontend.authoring.additional_course_plugin.v1': mockPlugin('additional_course_plugin'), + 'org.openedx.frontend.authoring.additional_course_content_plugin.v1': mockPlugin('additional_course_content_plugin'), + }, + }); }); - it('doesn\'t show content permissions section if relevant apps are not enabled', () => { + it('doesn\'t show content permissions section if relevant apps are not enabled', async () => { const initialState = { models: { courseApps: {}, @@ -25,8 +48,11 @@ describe('PagesAndResources', () => { { preloadedState: initialState }, ); - expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).not.toBeInTheDocument()); }); + it('show content permissions section if Learning Assistant app is enabled', async () => { const initialState = { models: { @@ -56,6 +82,8 @@ describe('PagesAndResources', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument()); }); it('show content permissions section if Xpert learning summaries app is enabled', async () => { @@ -89,5 +117,7 @@ describe('PagesAndResources', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument()); }); }); diff --git a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md index e467dc5061..e132e646ca 100644 --- a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md +++ b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md @@ -3,4 +3,4 @@ ### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1` ### Slot ID Aliases -* `additional_course_content_plugin` +* `additional_course_content_plugin` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx b/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx index 74a6f55b5a..c98ba0c725 100644 --- a/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx +++ b/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx @@ -1,5 +1,4 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework/dist'; -import React from 'react'; export const AdditionalCourseContentPluginSlot = () => ( ( + + + slot props course + + )} + actions={} + size="sm" + /> + + + Additional course from slot props description. + Or anything else. + + + + ), + }, + }, + ] + } + }, +} + +export default config; +``` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png b/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png new file mode 100644 index 0000000000..6dab83f755 Binary files /dev/null and b/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png differ diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/index.tsx b/src/plugin-slots/AdditionalCoursePluginSlot/index.tsx index a1cdfdc41d..b39f7603d6 100644 --- a/src/plugin-slots/AdditionalCoursePluginSlot/index.tsx +++ b/src/plugin-slots/AdditionalCoursePluginSlot/index.tsx @@ -1,5 +1,4 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework/dist'; -import React from 'react'; export const AdditionalCoursePluginSlot = () => ( ( +
+ setIsAiTranslations(courseId === 'anyId')} + > + + Custom transcript 💬 + + + +
+); + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.video_transcript_additional_translations_component.v1': { + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_additional_translation_id', + type: DIRECT_PLUGIN, + RenderWidget: TranslationsBlock, + }, + }, + ], + }, + }, +} + +export default config; +``` diff --git a/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png b/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png new file mode 100644 index 0000000000..fa79a1610c Binary files /dev/null and b/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png differ diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md index ee00e7ba16..091af0d8df 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md @@ -1,9 +1,8 @@ # CourseAuthoringUnitSidebarSlot -### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v1` +### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v2` -### Slot ID Aliases -* `course_authoring_unit_sidebar_slot` +### Previous Version: [`org.openedx.frontend.authoring.course_unit_sidebar.v1`](./README.v1.md) ### Plugin Props: @@ -12,6 +11,8 @@ * `unitTitle` - String. The name of the current unit being viewed / edited. * `xBlocks` - Array of Objects. List of XBlocks in the Unit. Object structure defined in `index.tsx`. * `readOnly` - Boolean. True if the user should not be able to edit the contents of the unit. +* `isUnitVerticalType` - Boolean. If the unit category is `vertical`. +* `isSplitTestType` - Boolean. If the unit category is `split_test`. ## Description @@ -29,7 +30,7 @@ import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; const config = { pluginSlots: { - 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + 'org.openedx.frontend.authoring.course_unit_sidebar.v2': { keepDefault: true, plugins: [ { @@ -63,11 +64,11 @@ const ProblemBlocks = ({unitTitle, xBlocks}) => ( } -); +); const config = { pluginSlots: { - 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + 'org.openedx.frontend.authoring.course_unit_sidebar.v2': { keepDefault: true, plugins: [ { diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md new file mode 100644 index 0000000000..aafccfb461 --- /dev/null +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md @@ -0,0 +1,95 @@ +# CourseAuthoringUnitSidebarSlot + +### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v1` + +### Slot ID Aliases: `course_authoring_unit_sidebar_slot` + +### Plugin Props: + +* `courseId` - String. +* `blockId` - String. The usage id of the current unit being viewed / edited. +* `unitTitle` - String. The name of the current unit being viewed / edited. +* `xBlocks` - Array of Objects. List of XBlocks in the Unit. Object structure defined in `index.tsx`. +* `readOnly` - Boolean. True if the user should not be able to edit the contents of the unit. + +### Description + +The slot wraps the sidebar that is displayed on the unit editor page. It can +be used to add additional sidebar components or modify the existing sidebar. + +> [!IMPORTANT] +> This document describes an older version `v1` of the `CourseAuthoringUnitSidebarSlot`. +> It is recommended to use the `org.openedx.frontend.authoring.course_unit_sidebar.v2` slot ID for new plugins. + +The `v1` slot has the following limitations compared to the `v2` version: +* It renders conditionally based on the `isUnitVerticalType` prop, which means the plugins won't be rendered in other scenarios like unit with library blocks. +* It does **not** wrap the `SplitTestSidebarInfo` component. So it can't be hidden from the sidebar by overriding the components in the slot. +* As it is not the primary child component of the sidebar, CSS styling for inserted components face limitations, such as an inability to be `sticky` or achieve 100% height. + +## Example 1 + +![Screenshot of the unit sidebar surrounded by border](./images/unit_sidebar_with_border.png) + +The following example configuration surrounds the sidebar in a border as shown above. + +```js +import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: ({ component }) => ( +
{component}
+ ), + }, + ], + }, + } +}; +export default config; +``` + +## Example 2 + +![Screenshot of the unit sidebar with an extra component listing all the problem blocks](./images/unit_sidebar_with_problem_blocks_list.png) + +```js +import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework'; + +const ProblemBlocks = ({unitTitle, xBlocks}) => ( + <> +

{unitTitle}: Problem Blocks

+
    + {xBlocks + .filter(block => block.blockType === "problem") + .map(block =>
  • {block.displayName}
  • ) + } +
+ +); + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget:{ + id: 'problem-blocks-list', + priority: 1, + type: DIRECT_PLUGIN, + RenderWidget: ProblemBlocks, + } + }, + ], + }, + } +}; +export default config; +``` diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx index 0bf6de14ca..562cbccb0f 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx @@ -1,9 +1,11 @@ import { getConfig } from '@edx/frontend-platform'; import { PluginSlot } from '@openedx/frontend-plugin-framework/dist'; +import { Stack } from '@openedx/paragon'; import TagsSidebarControls from '../../content-tags-drawer/tags-sidebar-controls'; import Sidebar from '../../course-unit/sidebar'; import LocationInfo from '../../course-unit/sidebar/LocationInfo'; import PublishControls from '../../course-unit/sidebar/PublishControls'; +import SplitTestSidebarInfo from '../../course-unit/sidebar/SplitTestSidebarInfo'; export const CourseAuthoringUnitSidebarSlot = ( { @@ -12,26 +14,44 @@ export const CourseAuthoringUnitSidebarSlot = ( unitTitle, xBlocks, readOnly, + isUnitVerticalType, + isSplitTestType, }: CourseAuthoringUnitSidebarSlotProps, ) => ( - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - - - - )} - - - + + {isUnitVerticalType && ( + + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + + )} + {isSplitTestType && ( + + + + )} + ); @@ -47,4 +67,6 @@ interface CourseAuthoringUnitSidebarSlotProps { unitTitle: string; xBlocks: XBlock[]; readOnly: boolean; + isUnitVerticalType: boolean; + isSplitTestType: boolean; } diff --git a/src/search-manager/FilterByPublished.tsx b/src/search-manager/FilterByPublished.tsx index f8ede2a956..079c6d43ee 100644 --- a/src/search-manager/FilterByPublished.tsx +++ b/src/search-manager/FilterByPublished.tsx @@ -10,12 +10,18 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import SearchFilterWidget from './SearchFilterWidget'; import { useSearchContext } from './SearchManager'; -import { PublishStatus } from './data/api'; +import { allPublishFilters, PublishStatus } from './data/api'; + +interface FilterByPublishedProps { + visibleFilters?: PublishStatus[], +} /** * A button with a dropdown that allows filtering the current search by publish status */ -const FilterByPublished: React.FC> = () => { +const FilterByPublished = ({ + visibleFilters = allPublishFilters, +}: FilterByPublishedProps) => { const intl = useIntl(); const { publishStatus, @@ -42,6 +48,26 @@ const FilterByPublished: React.FC> = () => { }; const appliedFilters = publishStatusFilter.map(mode => ({ label: modeToLabel[mode] })); + const filterLabels = { + [PublishStatus.Published]: intl.formatMessage(messages.publishStatusPublished), + [PublishStatus.Modified]: intl.formatMessage(messages.publishStatusModified), + [PublishStatus.NeverPublished]: intl.formatMessage(messages.publishStatusNeverPublished), + }; + + const visibleFiltersToRender = visibleFilters.map((filter) => ( + { toggleFilterMode(filter); }} + > +
+ {filterLabels[filter]} + {publishStatus[filter] ?? 0} +
+
+ )); + return ( > = () => { value={publishStatusFilter} > - { toggleFilterMode(PublishStatus.Published); }} - > -
- {intl.formatMessage(messages.publishStatusPublished)} - {publishStatus[PublishStatus.Published] ?? 0} -
-
- { toggleFilterMode(PublishStatus.Modified); }} - > -
- {intl.formatMessage(messages.publishStatusModified)} - {publishStatus[PublishStatus.Modified] ?? 0} -
-
- { toggleFilterMode(PublishStatus.NeverPublished); }} - > -
- {intl.formatMessage(messages.publishStatusNeverPublished)} - {publishStatus[PublishStatus.NeverPublished] ?? 0} -
-
+ {visibleFiltersToRender}
diff --git a/src/search-manager/SearchKeywordsField.tsx b/src/search-manager/SearchKeywordsField.tsx index 90c09fdd93..bbe3b67f82 100644 --- a/src/search-manager/SearchKeywordsField.tsx +++ b/src/search-manager/SearchKeywordsField.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SearchField } from '@openedx/paragon'; +import { debounce } from 'lodash'; import messages from './messages'; import { useSearchContext } from './SearchManager'; @@ -17,10 +18,15 @@ const SearchKeywordsField: React.FC<{ const defaultPlaceholder = usageKey ? messages.clearUsageKeyToSearch : messages.inputPlaceholder; const { placeholder = intl.formatMessage(defaultPlaceholder) } = props; + const handleSearch = React.useCallback( + debounce((term) => setSearchKeywords(term.trim()), 400), + [searchKeywords], + );// Perform search after 500ms + return ( setSearchKeywords('')} value={searchKeywords} className={props.className} diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 549054b3fe..d829b8a527 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -31,6 +31,8 @@ export enum PublishStatus { NeverPublished = 'never', } +export const allPublishFilters: PublishStatus[] = Object.values(PublishStatus); + /** * Get the content search configuration from the CMS. */ @@ -50,6 +52,7 @@ export const getContentSearchConfig = async (): Promise<{ url: string, indexName export interface ContentDetails { htmlContent?: string; capaContent?: string; + childUsageKeys?: Array; [k: string]: any; } @@ -151,9 +154,10 @@ export interface ContentHit extends BaseContentHit { * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py */ export interface ContentPublishedData { - description?: string, - displayName?: string, - numChildren?: number, + description?: string; + displayName?: string; + numChildren?: number; + content?: ContentDetails; } /** @@ -171,6 +175,9 @@ export interface CollectionHit extends BaseContentHit { * Information about a single container returned in the search results * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py */ +interface ContainerHitContent { + childUsageKeys?: string[], +} export interface ContainerHit extends BaseContentHit { type: 'library_container'; blockType: 'unit'; // This should be expanded to include other container types @@ -178,6 +185,7 @@ export interface ContainerHit extends BaseContentHit { published?: ContentPublishedData; publishStatus: PublishStatus; formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, }; + content?: ContainerHitContent; } export type HitType = ContentHit | CollectionHit | ContainerHit; diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss index d14fdac832..b58495c460 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -66,4 +66,13 @@ background-color: unset; } } + + // Fix a bug with search modal: very long text is not truncated with an ellipsis + // https://github.com/openedx/frontend-app-authoring/issues/1900 + .hit-description { + display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */ + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } } diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 032d5cda1a..6f82a0f045 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -181,7 +181,7 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
-
+
diff --git a/src/utils.js b/src/utils.js index 00daa2be31..d763a6246f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useState, useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useMediaQuery } from 'react-responsive'; import * as Yup from 'yup'; @@ -301,3 +301,22 @@ export const getFileSizeToClosestByte = (fileSize) => { const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2); return `${fileSizeFixedDecimal} ${units[divides]}`; }; + +/** +* A generic hook to run callback on next render cycle. +* @param {} callback - Callback function that needs to be run later +*/ +export const useRunOnNextRender = (callback) => { + const [scheduled, setScheduled] = useState(false); + + useEffect(() => { + if (!scheduled) { + return; + } + + setScheduled(false); + callback(); + }, [scheduled]); + + return () => setScheduled(true); +};