Skip to content

Commit dfd0ae1

Browse files
feat(course-outline): add inline rename functionality for components in UnitCard (#101)
* feat(course-outline): enhance ComponentMenu and UnitCard with rename functionality - Introduced a new inline rename feature for components in UnitCard, allowing users to edit component names directly. - Updated ComponentMenu to manage menu state and actions more effectively, including closing menus when another is opened. - Added tests to verify the new rename functionality and menu behavior. - Enhanced styles for better UI/UX in the component layout. - Added new messages for internationalization related to renaming components. * fix(course-outline): simplify hooks by removing unitId parameter - Updated hooks in AddComponentWidget, ComponentMenu, and UnitCard to eliminate the unitId parameter, streamlining their usage. - Adjusted related components to ensure compatibility with the new hook signatures. - Improved code readability and maintainability by reducing complexity in hook implementations.
1 parent a0d3190 commit dfd0ae1

8 files changed

Lines changed: 419 additions & 132 deletions

File tree

src/course-outline/unit-card/AddComponentWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const AddComponentWidget = ({
5151
}: AddComponentWidgetProps) => {
5252
const intl = useIntl();
5353
const { showToast } = useContext(ToastContext);
54-
const { mutateAsync: createXBlock, isPending } = useCreateXBlockInUnit(unitId);
54+
const { mutateAsync: createXBlock, isPending } = useCreateXBlockInUnit();
5555

5656
// State for template selection modal – shown when a component type has multiple templates
5757
const [isModalOpen, setIsModalOpen] = useState(false);

src/course-outline/unit-card/ComponentMenu.test.tsx

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
act, fireEvent, initializeMocks, render, screen, waitFor,
33
} from '@src/testUtils';
4+
import { useState } from 'react';
45

56
import cardHeaderMessages from '@src/course-outline/card-header/messages';
67
import ComponentMenu from './ComponentMenu';
@@ -25,22 +26,33 @@ jest.mock('./data/hooks', () => ({
2526
}),
2627
}));
2728

29+
const ComponentMenuHarness = (props: Partial<React.ComponentProps<typeof ComponentMenu>>) => {
30+
const [openBlockId, setOpenBlockId] = useState<string | null>(null);
31+
const blockId = props.blockId ?? 'block-v1:test+type@html+block@1';
32+
33+
return (
34+
<ComponentMenu
35+
unitId="block-v1:test+type@vertical+block@unit1"
36+
blockId={blockId}
37+
displayName="Test Component"
38+
blockType="html"
39+
actions={{
40+
canCopy: true,
41+
canDuplicate: true,
42+
canDelete: true,
43+
canMove: false,
44+
canManageAccess: false,
45+
}}
46+
onActionComplete={mockOnActionComplete}
47+
isMenuOpen={openBlockId === blockId}
48+
onMenuToggle={setOpenBlockId}
49+
{...props}
50+
/>
51+
);
52+
};
53+
2854
const renderComponentMenu = (props?: Partial<React.ComponentProps<typeof ComponentMenu>>) => render(
29-
<ComponentMenu
30-
unitId="block-v1:test+type@vertical+block@unit1"
31-
blockId="block-v1:test+type@html+block@1"
32-
displayName="Test Component"
33-
blockType="html"
34-
actions={{
35-
canCopy: true,
36-
canDuplicate: true,
37-
canDelete: true,
38-
canMove: false,
39-
canManageAccess: false,
40-
}}
41-
onActionComplete={mockOnActionComplete}
42-
{...props}
43-
/>,
55+
<ComponentMenuHarness {...props} />,
4456
);
4557

4658
describe('<ComponentMenu />', () => {
@@ -108,16 +120,62 @@ describe('<ComponentMenu />', () => {
108120
});
109121

110122
it('does not render when no actions are available', () => {
111-
renderComponentMenu({
112-
actions: {
113-
canCopy: false,
114-
canDuplicate: false,
115-
canDelete: false,
116-
canMove: false,
117-
canManageAccess: false,
118-
},
119-
});
123+
render(
124+
<ComponentMenuHarness
125+
actions={{
126+
canCopy: false,
127+
canDuplicate: false,
128+
canDelete: false,
129+
canMove: false,
130+
canManageAccess: false,
131+
}}
132+
/>,
133+
);
120134

121135
expect(screen.queryByTestId('component-menu')).not.toBeInTheDocument();
122136
});
137+
138+
it('closes the menu when another component menu is opened', async () => {
139+
const SharedMenus = () => {
140+
const [openBlockId, setOpenBlockId] = useState<string | null>(null);
141+
const sharedProps = {
142+
unitId: 'block-v1:test+type@vertical+block@unit1',
143+
displayName: 'Test Component',
144+
blockType: 'html',
145+
actions: {
146+
canCopy: true,
147+
canDuplicate: true,
148+
canDelete: true,
149+
canMove: false,
150+
canManageAccess: true,
151+
},
152+
onActionComplete: mockOnActionComplete,
153+
onMenuToggle: setOpenBlockId,
154+
};
155+
156+
return (
157+
<>
158+
<ComponentMenu
159+
{...sharedProps}
160+
blockId="block-v1:test+type@html+block@1"
161+
isMenuOpen={openBlockId === 'block-v1:test+type@html+block@1'}
162+
/>
163+
<ComponentMenu
164+
{...sharedProps}
165+
blockId="block-v1:test+type@html+block@2"
166+
isMenuOpen={openBlockId === 'block-v1:test+type@html+block@2'}
167+
/>
168+
</>
169+
);
170+
};
171+
172+
render(<SharedMenus />);
173+
174+
const [firstToggle, secondToggle] = screen.getAllByTestId('component-menu-toggle');
175+
await act(async () => fireEvent.click(firstToggle));
176+
expect(screen.getByTestId('component-menu-manage-access')).toBeInTheDocument();
177+
178+
await act(async () => fireEvent.click(secondToggle));
179+
expect(screen.getAllByTestId('component-menu-manage-access')).toHaveLength(1);
180+
});
123181
});

src/course-outline/unit-card/ComponentMenu.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ interface ComponentMenuProps {
3939
userPartitions?: XBlockTypes['userPartitions'];
4040
actions: UnitComponentActions;
4141
onActionComplete: (targetUnitId?: string) => void;
42+
isMenuOpen: boolean;
43+
onMenuToggle: (blockId: string | null) => void;
4244
}
4345

4446
const stopMenuEvent = (event: React.SyntheticEvent) => {
@@ -54,6 +56,8 @@ const ComponentMenu = ({
5456
userPartitions,
5557
actions,
5658
onActionComplete,
59+
isMenuOpen,
60+
onMenuToggle,
5761
}: ComponentMenuProps) => {
5862
const intl = useIntl();
5963
const dispatch = useDispatch();
@@ -65,7 +69,7 @@ const ComponentMenu = ({
6569
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
6670
const [moveRequest, setMoveRequest] = useState<IMoveRequestPayload | null>(null);
6771
const [configureItemData, setConfigureItemData] = useState<FormattedAccessManagedXBlockDataTypes | null>(null);
68-
const { mutateAsync: deleteComponent } = useDeleteUnitComponent(unitId);
72+
const { mutateAsync: deleteComponent } = useDeleteUnitComponent();
6973
const { mutateAsync: duplicateComponent } = useDuplicateUnitComponent(unitId);
7074

7175
const handleManageAccess = useCallback((event: React.MouseEvent) => {
@@ -171,8 +175,13 @@ const ComponentMenu = ({
171175
>
172176
<Dropdown
173177
id={`component-menu-${blockId}`}
178+
key={`${blockId}-${isMenuOpen}`}
174179
data-testid="component-menu"
175-
onToggle={(_isOpen, event) => event?.stopPropagation()}
180+
show={isMenuOpen}
181+
onToggle={(isOpen, event) => {
182+
event?.stopPropagation();
183+
onMenuToggle(isOpen ? blockId : null);
184+
}}
176185
>
177186
<Dropdown.Toggle
178187
id={`component-menu-toggle-${blockId}`}

src/course-outline/unit-card/UnitCard.scss

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,27 @@
2525
display: flex;
2626
align-items: center;
2727

28-
>.flex-grow-1 {
28+
.item-card-header,
29+
.form-group {
2930
min-width: 0;
30-
white-space: nowrap;
31-
overflow: hidden;
32-
text-overflow: ellipsis;
33-
max-width: 80%;
3431
}
3532

36-
>a.flex-grow-1 {
33+
.component-title-group {
34+
flex: 0 1 auto;
35+
align-items: center;
36+
37+
.item-card-header__title-btn {
38+
flex: 0 1 auto;
39+
min-width: 0;
40+
margin-right: .5rem;
41+
}
42+
43+
.item-card-button-icon {
44+
flex-shrink: 0;
45+
}
46+
}
47+
48+
>a.item-card-header__title-btn {
3749
color: inherit;
3850
text-decoration: none;
3951

@@ -42,21 +54,71 @@
4254
}
4355
}
4456

45-
>a.component-card-button-icon,
46-
.component-menu-wrapper .component-card-button-icon {
47-
text-decoration: none;
48-
color: var(--pgn-color-primary-500);
57+
.item-card-header__title-btn .truncate-1-line {
58+
min-width: 0;
59+
display: block;
60+
white-space: nowrap;
61+
overflow: hidden;
62+
text-overflow: ellipsis;
63+
}
64+
65+
.item-card-button-icon {
66+
opacity: 0;
67+
transition: opacity .3s linear;
4968

50-
&:hover,
5169
&:focus {
52-
text-decoration: none;
53-
color: var(--pgn-color-white);
54-
background-color: var(--pgn-color-primary-500);
70+
opacity: 1;
5571
}
5672
}
5773

74+
.component-rename-button {
75+
margin-right: .5rem;
76+
}
77+
78+
&:hover .item-card-button-icon {
79+
opacity: 1;
80+
}
81+
82+
.component-header-actions {
83+
flex-shrink: 0;
84+
opacity: 1;
85+
margin-left: .25rem;
86+
align-items: center;
87+
}
88+
89+
.component-edit-button,
90+
.component-menu-wrapper,
91+
.btn-icon.btn-icon-md {
92+
align-self: center;
93+
}
94+
95+
.component-edit-button {
96+
width: 4.125rem;
97+
height: 2.25rem;
98+
white-space: nowrap;
99+
}
100+
101+
.btn-icon.btn-icon-md {
102+
width: 2.5rem;
103+
height: 2.5rem;
104+
}
105+
58106
.component-menu-wrapper {
59107
flex-shrink: 0;
108+
opacity: 1;
109+
110+
.component-card-button-icon {
111+
opacity: 1;
112+
text-decoration: none;
113+
color: var(--pgn-color-primary-500);
114+
115+
&:hover,
116+
&:focus {
117+
text-decoration: none;
118+
color: var(--pgn-color-white);
119+
background-color: var(--pgn-color-primary-500);
120+
}
121+
}
60122
}
61123
}
62124
}
@@ -96,6 +158,10 @@
96158
.component-card-button-icon:hover {
97159
background-color: var(--pgn-color-icon-button-bg-primary-hover);
98160
}
161+
162+
.component-edit-button {
163+
white-space: nowrap;
164+
}
99165
}
100166
}
101167

src/course-outline/unit-card/UnitCard.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getConfig } from '@edx/frontend-platform';
55
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
66

77
import { XBlock } from '@src/data/types';
8+
import headerNavigationsMessages from '@src/course-unit/header-navigations/messages';
89
import UnitCard from './UnitCard';
910
import cardMessages from '../card-header/messages';
1011
import componentMessages from './messages';
@@ -16,6 +17,7 @@ const mockUseComponentTemplates = jest.fn();
1617
const mockCreateXBlock = jest.fn();
1718
const mockPasteBlock = jest.fn();
1819
const mockCopyToClipboard = jest.fn();
20+
const mockRenameComponent = jest.fn();
1921
const mockShowPasteXBlock = { current: false };
2022

2123
jest.mock('@src/course-unit/data/apiHooks', () => ({
@@ -40,6 +42,10 @@ jest.mock('./data/hooks', () => ({
4042
useDuplicateUnitComponent: () => ({
4143
mutateAsync: jest.fn(),
4244
}),
45+
useRenameUnitComponent: () => ({
46+
mutateAsync: mockRenameComponent,
47+
isPending: false,
48+
}),
4349
}));
4450

4551
// Mock pasteBlock API call used by handlePasteComponent
@@ -171,6 +177,8 @@ describe('<UnitCard />', () => {
171177
mockUseComponentTemplates.mockReturnValue({ data: undefined });
172178
mockPasteBlock.mockReset();
173179
mockCreateXBlock.mockReset();
180+
mockRenameComponent.mockReset();
181+
mockRenameComponent.mockResolvedValue(undefined);
174182
mockShowPasteXBlock.current = false;
175183
});
176184

@@ -363,6 +371,7 @@ describe('<UnitCard />', () => {
363371
const editButton = await screen.findByTestId('component-edit-button');
364372
expect(editButton.tagName).toBe('A');
365373
expect(editButton).toHaveAttribute('href', `/course/5/editor/html/${htmlComponent.blockId}`);
374+
expect(editButton).toHaveTextContent(headerNavigationsMessages.editButton.defaultMessage);
366375
});
367376

368377
it('renders edit button with legacy Studio URL for non-MFE types', async () => {
@@ -398,6 +407,29 @@ describe('<UnitCard />', () => {
398407
const prevented = !editButton.dispatchEvent(clickEvent);
399408
expect(prevented).toBe(false);
400409
});
410+
411+
it('shows inline rename field after clicking the rename button', async () => {
412+
await setupExpandedView([htmlComponent]);
413+
414+
const renameButton = await screen.findByTestId('component-rename-button');
415+
fireEvent.click(renameButton);
416+
417+
const renameField = await screen.findByTestId('component-rename-field');
418+
expect(renameField).toBeInTheDocument();
419+
expect(renameField).toHaveValue('HTML Component');
420+
421+
fireEvent.change(renameField, { target: { value: 'Renamed Component' } });
422+
expect(renameField).toHaveValue('Renamed Component');
423+
424+
fireEvent.blur(renameField);
425+
426+
await waitFor(() => {
427+
expect(mockRenameComponent).toHaveBeenCalledWith({
428+
blockId: htmlComponent.blockId,
429+
displayName: 'Renamed Component',
430+
});
431+
});
432+
});
401433
});
402434

403435
describe('preview button', () => {

0 commit comments

Comments
 (0)