Skip to content

Commit 4236e7d

Browse files
authored
Merge pull request #5963 from Abhishek-Punhani/Issue5961
feat: Initial Scaffold for QTI editor components and add a development page
2 parents c52b3a2 + 8ea08db commit 4236e7d

11 files changed

Lines changed: 973 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<template>
2+
3+
<div>
4+
<div style="padding: 16px 24px 0">
5+
<div
6+
style="
7+
padding: 16px;
8+
color: #2196f3;
9+
background-color: transparent;
10+
border: 1px solid #2196f3;
11+
border-radius: 4px;
12+
"
13+
>
14+
<strong>QTI Editor — Dev Demo</strong>
15+
&nbsp;Hardcoded items. Changes are local only and not persisted.
16+
</div>
17+
</div>
18+
19+
<QTIEditor
20+
:assessments="assessments"
21+
@update="onUpdate"
22+
/>
23+
</div>
24+
25+
</template>
26+
27+
28+
<script>
29+
30+
import { ref, defineComponent } from 'vue';
31+
import QTIEditor from 'shared/views/QTIEditor/index';
32+
import { QtiInteraction } from 'shared/views/QTIEditor/constants';
33+
34+
/**
35+
* Hardcoded items covering three interaction types so the closed-card
36+
* type label can be visually verified.
37+
*/
38+
const INITIAL_ASSESSMENTS = [
39+
{
40+
id: 'demo-item-1',
41+
type: QtiInteraction.CHOICE,
42+
title: 'Which planet is closest to the Sun?',
43+
},
44+
{
45+
id: 'demo-item-2',
46+
type: QtiInteraction.EXTENDED_TEXT,
47+
title: 'Describe the water cycle in your own words.',
48+
},
49+
{
50+
id: 'demo-item-3',
51+
type: QtiInteraction.ORDER,
52+
title: 'Arrange these events in chronological order.',
53+
},
54+
];
55+
56+
export default defineComponent({
57+
name: 'QTIDemoPage',
58+
59+
components: { QTIEditor },
60+
61+
setup() {
62+
const assessments = ref(INITIAL_ASSESSMENTS);
63+
64+
function onUpdate(newList) {
65+
assessments.value = newList;
66+
}
67+
68+
return { assessments, onUpdate };
69+
},
70+
});
71+
72+
</script>

contentcuration/contentcuration/frontend/channelEdit/router.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import TrashModal from './views/trash/TrashModal';
99
import SearchOrBrowseWindow from './views/ImportFromChannels/SearchOrBrowseWindow';
1010
import ReviewSelectionsPage from './views/ImportFromChannels/ReviewSelectionsPage';
1111
import EditModal from './components/edit/EditModal';
12+
import QTIDemoPage from './pages/QTIDemoPage';
1213
import ChannelDetailsModal from 'shared/views/channel/ChannelDetailsModal';
1314
import ChannelModal from 'shared/views/channel/ChannelModal';
1415
import { RouteNames as ChannelRouteNames } from 'frontend/channelList/constants';
@@ -244,6 +245,11 @@ const router = new VueRouter({
244245
});
245246
},
246247
},
248+
{
249+
name: 'QTI_DEMO',
250+
path: '/qti-demo',
251+
component: QTIDemoPage,
252+
},
247253
{
248254
name: RouteNames.TREE_VIEW,
249255
path: '/:nodeId/:detailNodeId?',

contentcuration/contentcuration/frontend/shared/strings/commonStrings.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ export const commonStrings = createTranslator('CommonStrings', {
4242
message: 'Copy channel token',
4343
context: 'A label for an action that copies the channel token to the clipboard',
4444
},
45+
optionsLabel: {
46+
message: 'Options',
47+
context: 'Tooltip for the generic options menu icon',
48+
},
4549
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import userEvent from '@testing-library/user-event';
3+
import VueRouter from 'vue-router';
4+
import CollapsibleToolbar from '../index.vue';
5+
import { commonStrings } from 'shared/strings/commonStrings';
6+
7+
const { optionsLabel$ } = commonStrings;
8+
9+
const makeAction = (overrides = {}) => ({
10+
id: 'action-1',
11+
icon: 'edit',
12+
label: 'Edit',
13+
handler: jest.fn(),
14+
collapsed: false,
15+
disabled: false,
16+
...overrides,
17+
});
18+
19+
const renderComponent = (actions = [], optionsLabel = null) => {
20+
return render(CollapsibleToolbar, {
21+
props: { actions, optionsLabel },
22+
routes: new VueRouter(),
23+
});
24+
};
25+
26+
describe('CollapsibleToolbar', () => {
27+
describe('visible icon actions', () => {
28+
test('renders icon buttons for non-collapsed actions with icons', () => {
29+
const actions = [
30+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }),
31+
makeAction({ id: 'a2', label: 'Move up', icon: 'chevronUp', collapsed: false }),
32+
];
33+
renderComponent(actions);
34+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
35+
expect(screen.getByRole('button', { name: 'Move up' })).toBeInTheDocument();
36+
});
37+
38+
test('does not render an icon button for collapsed actions', () => {
39+
const actions = [
40+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }),
41+
makeAction({ id: 'a2', label: 'Delete', icon: 'delete', collapsed: true }),
42+
];
43+
renderComponent(actions);
44+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
45+
// 'Delete' only appears inside the dropdown, not as a standalone icon button
46+
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
47+
});
48+
49+
test('calls the action handler when an icon button is clicked', async () => {
50+
const user = userEvent.setup();
51+
const handler = jest.fn();
52+
const actions = [
53+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, handler }),
54+
];
55+
renderComponent(actions);
56+
await user.click(screen.getByRole('button', { name: 'Edit' }));
57+
expect(handler).toHaveBeenCalledTimes(1);
58+
});
59+
});
60+
61+
describe('collapsed dropdown menu', () => {
62+
test('does not render the options button when there are no collapsed actions', () => {
63+
const actions = [makeAction({ id: 'a1', icon: 'edit', collapsed: false })];
64+
renderComponent(actions);
65+
expect(screen.queryByRole('button', { name: optionsLabel$() })).not.toBeInTheDocument();
66+
});
67+
68+
test('renders the options button when there are collapsed actions', () => {
69+
const actions = [
70+
makeAction({ id: 'a1', icon: 'edit', collapsed: false }),
71+
makeAction({ id: 'a2', icon: null, label: 'Delete', collapsed: true, handler: jest.fn() }),
72+
];
73+
renderComponent(actions);
74+
expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument();
75+
});
76+
77+
test('renders the options button when an action has no icon (forces it to menu)', () => {
78+
const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: false })];
79+
renderComponent(actions);
80+
expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument();
81+
});
82+
83+
test('uses the provided optionsLabel prop for the menu button', () => {
84+
const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: true })];
85+
renderComponent(actions, 'Custom options label');
86+
expect(screen.getByRole('button', { name: 'Custom options label' })).toBeInTheDocument();
87+
});
88+
});
89+
90+
describe('disabled state', () => {
91+
test('renders the icon button as disabled when action.disabled is true', () => {
92+
const actions = [
93+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, disabled: true }),
94+
];
95+
renderComponent(actions);
96+
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
97+
});
98+
});
99+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<template>
2+
3+
<div class="collapsible-toolbar">
4+
<div class="icon-actions-wrapper">
5+
<KIconButton
6+
v-for="action in visibleIconActions"
7+
:key="action.id"
8+
:icon="action.icon"
9+
:tooltip="action.label"
10+
:ariaLabel="action.label"
11+
:disabled="action.disabled"
12+
:color="action.disabled ? $themeTokens.textDisabled : $themePalette.grey.v_800"
13+
@click="action.handler"
14+
/>
15+
</div>
16+
17+
<KIconButton
18+
v-if="collapsedMenuActions.length > 0"
19+
icon="optionsVertical"
20+
:color="$themePalette.grey.v_800"
21+
:tooltip="optionsLabel || optionsLabel$()"
22+
:ariaLabel="optionsLabel || optionsLabel$()"
23+
>
24+
<template #menu>
25+
<KDropdownMenu
26+
:options="dropdownOptions"
27+
@select="handleSelect"
28+
/>
29+
</template>
30+
</KIconButton>
31+
</div>
32+
33+
</template>
34+
35+
36+
<script>
37+
38+
import { computed } from 'vue';
39+
import { commonStrings } from 'shared/strings/commonStrings';
40+
41+
export default {
42+
name: 'CollapsibleToolbar',
43+
44+
setup(props) {
45+
const { optionsLabel$ } = commonStrings;
46+
/** Actions with an icon that are not explicitly collapsed */
47+
const visibleIconActions = computed(() => {
48+
return props.actions.filter(a => !a.collapsed && Boolean(a.icon));
49+
});
50+
51+
/** Actions without an icon MUST go to the menu, along with explicitly collapsed ones */
52+
const collapsedMenuActions = computed(() => {
53+
return props.actions.filter(a => a.collapsed || !a.icon);
54+
});
55+
56+
const dropdownOptions = computed(() => {
57+
return collapsedMenuActions.value.map(action => ({
58+
label: action.label,
59+
value: action.id,
60+
disabled: action.disabled,
61+
}));
62+
});
63+
64+
function handleSelect(option) {
65+
const action = collapsedMenuActions.value.find(a => a.id === option.value);
66+
if (action && action.handler) {
67+
action.handler();
68+
}
69+
}
70+
71+
return {
72+
visibleIconActions,
73+
collapsedMenuActions,
74+
dropdownOptions,
75+
handleSelect,
76+
optionsLabel$,
77+
};
78+
},
79+
80+
props: {
81+
/**
82+
* Array of actions:
83+
* {
84+
* id: string,
85+
* icon: string | null,
86+
* label: string,
87+
* handler: Function,
88+
* collapsed?: boolean,
89+
* disabled?: boolean
90+
* }
91+
*/
92+
actions: {
93+
type: Array,
94+
required: true,
95+
validator(actions) {
96+
return actions.every(
97+
a =>
98+
typeof a.id === 'string' &&
99+
typeof a.label === 'string' &&
100+
typeof a.handler === 'function',
101+
);
102+
},
103+
},
104+
/** Tooltip text for the generic options menu icon */
105+
optionsLabel: {
106+
type: String,
107+
default: null,
108+
},
109+
},
110+
};
111+
112+
</script>
113+
114+
115+
<style lang="scss" scoped>
116+
117+
.collapsible-toolbar {
118+
display: flex;
119+
gap: 4px;
120+
align-items: center;
121+
justify-content: flex-end;
122+
}
123+
124+
.icon-actions-wrapper {
125+
display: flex;
126+
gap: 4px;
127+
align-items: center;
128+
}
129+
130+
</style>

0 commit comments

Comments
 (0)