feat: rename component on library unit page (#1823)
This commit is contained in:
@@ -24,8 +24,8 @@ describe('<InplaceTextEditor />', () => {
|
||||
expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the edit button if showEditButton is true', () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} showEditButton />);
|
||||
it('should render the edit button if alwaysShowEditButton is true', () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} alwaysShowEditButton />);
|
||||
|
||||
expect(screen.getByText('Test text')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import {
|
||||
Form,
|
||||
Icon,
|
||||
IconButton,
|
||||
OverlayTrigger,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
@@ -10,12 +16,33 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
interface IconWrapperProps {
|
||||
popper: any;
|
||||
children: React.ReactNode;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const IconWrapper = forwardRef<HTMLDivElement, IconWrapperProps>(({ 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 (
|
||||
<div ref={ref} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface InplaceTextEditorProps {
|
||||
text: string;
|
||||
onSave: (newText: string) => void;
|
||||
readOnly?: boolean;
|
||||
textClassName?: string;
|
||||
showEditButton?: boolean;
|
||||
alwaysShowEditButton?: boolean;
|
||||
}
|
||||
|
||||
export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
|
||||
@@ -23,7 +50,7 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
|
||||
onSave,
|
||||
readOnly = false,
|
||||
textClassName,
|
||||
showEditButton = false,
|
||||
alwaysShowEditButton = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
@@ -39,7 +66,7 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
|
||||
[text],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const handleEdit = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
@@ -51,41 +78,86 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<span className={textClassName}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (alwaysShowEditButton) {
|
||||
return (
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={1}
|
||||
>
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
type="text"
|
||||
aria-label="Text input"
|
||||
defaultValue={text}
|
||||
onBlur={handleOnChangeText}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span className={textClassName}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editTextButtonAlt)}
|
||||
onClick={handleEdit}
|
||||
size="inline"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal">
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
type="text"
|
||||
aria-label="Text input"
|
||||
defaultValue={text}
|
||||
onBlur={handleOnChangeText}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
<OverlayTrigger
|
||||
trigger={['hover', 'focus']}
|
||||
placement="right"
|
||||
overlay={(
|
||||
<IconWrapper>
|
||||
<Icon
|
||||
id="edit-text-icon"
|
||||
src={Edit}
|
||||
className="ml-1.5"
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
</IconWrapper>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
type="text"
|
||||
aria-label="Text input"
|
||||
defaultValue={text}
|
||||
onBlur={handleOnChangeText}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
onClick={handleEdit}
|
||||
onKeyDown={handleEdit}
|
||||
className={textClassName}
|
||||
role="button"
|
||||
onClick={!readOnly ? handleClick : undefined}
|
||||
onKeyDown={!readOnly ? handleClick : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{!readOnly && showEditButton && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editTextButtonAlt)}
|
||||
onClick={handleClick}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ const CollectionInfoHeader = () => {
|
||||
text={collection.title}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
alwaysShowEditButton
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('<ComponentInfoHeader />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should close edit library title on press Escape', async () => {
|
||||
it('should close edit component title on press Escape', async () => {
|
||||
const url = getXBlockFieldsVersionApiUrl(usageKey, 'draft');
|
||||
axiosMock.onPost(url).reply(200);
|
||||
render();
|
||||
@@ -117,7 +117,7 @@ describe('<ComponentInfoHeader />', () => {
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toEqual(0));
|
||||
});
|
||||
|
||||
it('should show error on edit library tittle', async () => {
|
||||
it('should show error on edit component tittle', async () => {
|
||||
const url = getXBlockFieldsApiUrl(usageKey);
|
||||
axiosMock.onPatch(url).reply(500);
|
||||
render();
|
||||
|
||||
@@ -48,7 +48,7 @@ const ComponentInfoHeader = () => {
|
||||
text={xblockFields?.displayName}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
alwaysShowEditButton
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ const ContainerInfoHeader = () => {
|
||||
text={container.displayName}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
alwaysShowEditButton
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('library api hooks', () => {
|
||||
});
|
||||
|
||||
it('should delete a container', async () => {
|
||||
const containerId = 'lct:org:lib1';
|
||||
const containerId = 'lct:org:lib:unit:unit1';
|
||||
const url = getLibraryContainerApiUrl(containerId);
|
||||
|
||||
axiosMock.onDelete(url).reply(200);
|
||||
@@ -176,7 +176,7 @@ describe('library api hooks', () => {
|
||||
});
|
||||
|
||||
it('should restore a container', async () => {
|
||||
const containerId = 'lct:org:lib1';
|
||||
const containerId = 'lct:org:lib:unit:unit1';
|
||||
const url = getLibraryContainerRestoreApiUrl(containerId);
|
||||
|
||||
axiosMock.onPost(url).reply(200);
|
||||
@@ -272,7 +272,7 @@ describe('library api hooks', () => {
|
||||
});
|
||||
|
||||
it('should update container children', async () => {
|
||||
const containerId = 'lct:org:lib1';
|
||||
const containerId = 'lct:org:lib:unit:unit-1';
|
||||
const url = getLibraryContainerChildrenApiUrl(containerId);
|
||||
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
@@ -329,9 +329,13 @@ export const useUpdateXBlockFields = (usageKey: string) => {
|
||||
mutationFn: (data: api.UpdateXBlockFieldsRequest) => api.updateXBlockFields(usageKey, data),
|
||||
onMutate: async (data) => {
|
||||
const queryKey = xblockQueryKeys.xblockFields(usageKey);
|
||||
const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as api.XBlockFields;
|
||||
const previousBlockData = queryClient.getQueriesData(queryKey)?.[0]?.[1] as api.XBlockFields | undefined;
|
||||
const formatedData = camelCaseObject(data);
|
||||
|
||||
if (!previousBlockData) {
|
||||
return { previousBlockData };
|
||||
}
|
||||
|
||||
const newBlockData = {
|
||||
...previousBlockData,
|
||||
...(formatedData.metadata?.displayName && { displayName: formatedData.metadata.displayName }),
|
||||
@@ -343,7 +347,7 @@ export const useUpdateXBlockFields = (usageKey: string) => {
|
||||
|
||||
queryClient.setQueryData(queryKey, newBlockData);
|
||||
|
||||
return { previousBlockData, newBlockData };
|
||||
return { previousBlockData };
|
||||
},
|
||||
onError: (_err, _data, context) => {
|
||||
queryClient.setQueryData(
|
||||
|
||||
@@ -14,13 +14,19 @@ import ErrorAlert from '../../generic/alert-error';
|
||||
import { getItemIcon } from '../../generic/block-type-utils';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
|
||||
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
|
||||
import Loading from '../../generic/Loading';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { PickLibraryContentModal } from '../add-content';
|
||||
import ComponentMenu from '../components';
|
||||
import { LibraryBlockMetadata } from '../data/api';
|
||||
import { libraryAuthoringQueryKeys, useContainerChildren, useUpdateContainerChildren } from '../data/apiHooks';
|
||||
import {
|
||||
libraryAuthoringQueryKeys,
|
||||
useContainerChildren,
|
||||
useUpdateContainerChildren,
|
||||
useUpdateXBlockFields,
|
||||
} from '../data/apiHooks';
|
||||
import { LibraryBlock } from '../LibraryBlock';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import messages from './messages';
|
||||
@@ -43,30 +49,52 @@ interface BlockHeaderProps {
|
||||
}
|
||||
|
||||
/** Component header, split out to reuse in drag overlay */
|
||||
const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => (
|
||||
<>
|
||||
<Stack direction="horizontal" gap={2} className="font-weight-bold">
|
||||
<Icon src={getItemIcon(block.blockType)} />
|
||||
{block.displayName}
|
||||
</Stack>
|
||||
<ActionRow.Spacer />
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{block.hasUnpublishedChanges && (
|
||||
<Badge
|
||||
className="px-2 pt-1"
|
||||
variant="warning"
|
||||
>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon className="mb-1" size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.draftChipText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
)}
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={onTagClick} />
|
||||
<ComponentMenu usageKey={block.id} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const updateMutation = useUpdateXBlockFields(block.id);
|
||||
|
||||
const handleSaveDisplayName = (newDisplayName: string) => {
|
||||
updateMutation.mutateAsync({
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="horizontal" gap={2} className="font-weight-bold">
|
||||
<Icon src={getItemIcon(block.blockType)} />
|
||||
<InplaceTextEditor
|
||||
onSave={handleSaveDisplayName}
|
||||
text={block.displayName}
|
||||
/>
|
||||
</Stack>
|
||||
<ActionRow.Spacer />
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{block.hasUnpublishedChanges && (
|
||||
<Badge
|
||||
className="px-2 pt-1"
|
||||
variant="warning"
|
||||
>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon className="mb-1" size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.draftChipText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
)}
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={onTagClick} />
|
||||
<ComponentMenu usageKey={block.id} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface LibraryUnitBlocksProps {
|
||||
/** set to true if it is rendered as preview
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '../../testUtils';
|
||||
import { getLibraryContainerApiUrl, getLibraryContainerChildrenApiUrl } from '../data/api';
|
||||
import {
|
||||
getLibraryContainerApiUrl,
|
||||
getLibraryContainerChildrenApiUrl,
|
||||
getXBlockFieldsApiUrl,
|
||||
} from '../data/api';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockXBlockFields,
|
||||
@@ -198,6 +202,74 @@ describe('<LibraryUnitPage />', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should rename component while clicking on name', async () => {
|
||||
const url = getXBlockFieldsApiUrl('lb:org1:Demo_course:html:text-0');
|
||||
axiosMock.onPost(url).reply(200);
|
||||
renderLibraryUnitPage();
|
||||
|
||||
// Wait loading of the component
|
||||
await screen.findByText('text block 0');
|
||||
|
||||
const componentTitle = screen.getAllByRole(
|
||||
'button',
|
||||
{ name: 'text block 0' },
|
||||
)[0];
|
||||
fireEvent.click(componentTitle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
expect(textBox).toBeInTheDocument();
|
||||
fireEvent.change(textBox, { target: { value: 'New Component Title' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
|
||||
metadata: { display_name: 'New Component Title' },
|
||||
}));
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Component updated successfully.');
|
||||
});
|
||||
|
||||
it('should show error while updating component name', async () => {
|
||||
const url = getXBlockFieldsApiUrl('lb:org1:Demo_course:html:text-0');
|
||||
axiosMock.onPost(url).reply(400);
|
||||
renderLibraryUnitPage();
|
||||
|
||||
// Wait loading of the component
|
||||
await screen.findByText('text block 0');
|
||||
|
||||
const componentTitle = screen.getAllByRole(
|
||||
'button',
|
||||
{ name: 'text block 0' },
|
||||
)[0];
|
||||
fireEvent.click(componentTitle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
expect(textBox).toBeInTheDocument();
|
||||
fireEvent.change(textBox, { target: { value: 'New Component Title' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
|
||||
metadata: { display_name: 'New Component Title' },
|
||||
}));
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error updating the component.');
|
||||
});
|
||||
|
||||
it('should call update order api on dragging component', async () => {
|
||||
renderLibraryUnitPage();
|
||||
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
|
||||
|
||||
@@ -31,6 +31,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Draft',
|
||||
description: 'Chip in components in unit page that is shown when component has unpublished changes',
|
||||
},
|
||||
updateComponentSuccessMsg: {
|
||||
id: 'course-authoring.library-authoring.unit-component.update.success',
|
||||
defaultMessage: 'Component updated successfully.',
|
||||
description: 'Message when the component is updated successfully',
|
||||
},
|
||||
updateComponentErrorMsg: {
|
||||
id: 'course-authoring.library-authoring.unit-component.update.error',
|
||||
defaultMessage: 'There was an error updating the component.',
|
||||
description: 'Message when there is an error when updating the component',
|
||||
},
|
||||
updateContainerSuccessMsg: {
|
||||
id: 'course-authoring.library-authoring.update-container-success-msg',
|
||||
defaultMessage: 'Container updated successfully.',
|
||||
|
||||
Reference in New Issue
Block a user