feat: rename component on library unit page (#1823)

This commit is contained in:
Rômulo Penido
2025-04-23 02:16:38 -03:00
committed by GitHub
parent 03d732846e
commit eaa075464c
11 changed files with 255 additions and 69 deletions

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -46,7 +46,7 @@ const CollectionInfoHeader = () => {
text={collection.title}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
alwaysShowEditButton
/>
);
};

View File

@@ -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();

View File

@@ -48,7 +48,7 @@ const ComponentInfoHeader = () => {
text={xblockFields?.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
alwaysShowEditButton
/>
);
};

View File

@@ -45,7 +45,7 @@ const ContainerInfoHeader = () => {
text={container.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
alwaysShowEditButton
/>
);
};

View File

@@ -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);

View File

@@ -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(

View File

@@ -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

View File

@@ -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];

View File

@@ -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.',