diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue new file mode 100644 index 0000000000..bb11ec769f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue @@ -0,0 +1,72 @@ + + + + + + QTI Editor — Dev Demo + Hardcoded items. Changes are local only and not persisted. + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/router.js b/contentcuration/contentcuration/frontend/channelEdit/router.js index 69615e4d7b..e0bf0ddc05 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/router.js +++ b/contentcuration/contentcuration/frontend/channelEdit/router.js @@ -9,6 +9,7 @@ import TrashModal from './views/trash/TrashModal'; import SearchOrBrowseWindow from './views/ImportFromChannels/SearchOrBrowseWindow'; import ReviewSelectionsPage from './views/ImportFromChannels/ReviewSelectionsPage'; import EditModal from './components/edit/EditModal'; +import QTIDemoPage from './pages/QTIDemoPage'; import ChannelDetailsModal from 'shared/views/channel/ChannelDetailsModal'; import ChannelModal from 'shared/views/channel/ChannelModal'; import { RouteNames as ChannelRouteNames } from 'frontend/channelList/constants'; @@ -244,6 +245,11 @@ const router = new VueRouter({ }); }, }, + { + name: 'QTI_DEMO', + path: '/qti-demo', + component: QTIDemoPage, + }, { name: RouteNames.TREE_VIEW, path: '/:nodeId/:detailNodeId?', diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index f681bc4167..2399fc6be9 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -42,4 +42,8 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Copy channel token', context: 'A label for an action that copies the channel token to the clipboard', }, + optionsLabel: { + message: 'Options', + context: 'Tooltip for the generic options menu icon', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/__tests__/CollapsibleToolbar.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/__tests__/CollapsibleToolbar.spec.js new file mode 100644 index 0000000000..2c873ea465 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/__tests__/CollapsibleToolbar.spec.js @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; +import CollapsibleToolbar from '../index.vue'; +import { commonStrings } from 'shared/strings/commonStrings'; + +const { optionsLabel$ } = commonStrings; + +const makeAction = (overrides = {}) => ({ + id: 'action-1', + icon: 'edit', + label: 'Edit', + handler: jest.fn(), + collapsed: false, + disabled: false, + ...overrides, +}); + +const renderComponent = (actions = [], optionsLabel = null) => { + return render(CollapsibleToolbar, { + props: { actions, optionsLabel }, + routes: new VueRouter(), + }); +}; + +describe('CollapsibleToolbar', () => { + describe('visible icon actions', () => { + test('renders icon buttons for non-collapsed actions with icons', () => { + const actions = [ + makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }), + makeAction({ id: 'a2', label: 'Move up', icon: 'chevronUp', collapsed: false }), + ]; + renderComponent(actions); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Move up' })).toBeInTheDocument(); + }); + + test('does not render an icon button for collapsed actions', () => { + const actions = [ + makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }), + makeAction({ id: 'a2', label: 'Delete', icon: 'delete', collapsed: true }), + ]; + renderComponent(actions); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + // 'Delete' only appears inside the dropdown, not as a standalone icon button + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + test('calls the action handler when an icon button is clicked', async () => { + const user = userEvent.setup(); + const handler = jest.fn(); + const actions = [ + makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, handler }), + ]; + renderComponent(actions); + await user.click(screen.getByRole('button', { name: 'Edit' })); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('collapsed dropdown menu', () => { + test('does not render the options button when there are no collapsed actions', () => { + const actions = [makeAction({ id: 'a1', icon: 'edit', collapsed: false })]; + renderComponent(actions); + expect(screen.queryByRole('button', { name: optionsLabel$() })).not.toBeInTheDocument(); + }); + + test('renders the options button when there are collapsed actions', () => { + const actions = [ + makeAction({ id: 'a1', icon: 'edit', collapsed: false }), + makeAction({ id: 'a2', icon: null, label: 'Delete', collapsed: true, handler: jest.fn() }), + ]; + renderComponent(actions); + expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument(); + }); + + test('renders the options button when an action has no icon (forces it to menu)', () => { + const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: false })]; + renderComponent(actions); + expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument(); + }); + + test('uses the provided optionsLabel prop for the menu button', () => { + const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: true })]; + renderComponent(actions, 'Custom options label'); + expect(screen.getByRole('button', { name: 'Custom options label' })).toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + test('renders the icon button as disabled when action.disabled is true', () => { + const actions = [ + makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, disabled: true }), + ]; + renderComponent(actions); + expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/index.vue new file mode 100644 index 0000000000..d6491e2744 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/index.vue @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js new file mode 100644 index 0000000000..03122034a9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -0,0 +1,78 @@ +import { render, screen, fireEvent } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import QTIItemEditor from '../index.vue'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { QtiInteraction } from '../../../constants'; + +const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; + +const defaultProps = { + item: { + id: 'test-item-id', + type: QtiInteraction.CHOICE, + title: 'Test Choice Interaction', + }, + index: 0, + total: 5, + mode: 'view', + displayAnswersPreview: false, +}; + +const renderComponent = (props = {}, slots = {}) => { + return render(QTIItemEditor, { + props: { ...defaultProps, ...props }, + slots, + routes: new VueRouter(), + }); +}; + +describe('QTIItemEditor', () => { + describe('view mode', () => { + test('does not show the card body', () => { + renderComponent({ mode: 'view' }); + expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument(); + }); + + test('does not show the close button', () => { + renderComponent({ mode: 'view' }); + expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument(); + }); + }); + + describe('edit mode', () => { + test('shows the card body', () => { + renderComponent({ mode: 'edit' }); + expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument(); + }); + + test('shows the close button', () => { + renderComponent({ mode: 'edit' }); + expect(screen.getByRole('button', { name: closeBtnLabel$() })).toBeInTheDocument(); + }); + + test('emits a close event when the close button is clicked', async () => { + const { emitted } = renderComponent({ mode: 'edit' }); + await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() })); + expect(emitted().close).toHaveLength(1); + }); + }); + + describe('displayAnswersPreview', () => { + test('shows the card body in view mode when displayAnswersPreview is true', () => { + renderComponent({ mode: 'view', displayAnswersPreview: true }); + expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument(); + }); + + test('does not show the close button even when displayAnswersPreview is true', () => { + renderComponent({ mode: 'view', displayAnswersPreview: true }); + expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument(); + }); + }); + + describe('toolbarActions slot', () => { + test('renders content injected into the toolbarActions slot', () => { + renderComponent({}, { toolbarActions: 'Edit' }); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue new file mode 100644 index 0000000000..c4754315ec --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -0,0 +1,178 @@ + + + + + + + {{ questionNumberLabel }} + + + {{ questionNumberAndTypeLabel }} + + + + + + + + + + + {{ questionContentPlaceholder$() }} + + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js new file mode 100644 index 0000000000..76e313f8c9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -0,0 +1,28 @@ +export const Cardinality = Object.freeze({ + SINGLE: 'single', + MULTIPLE: 'multiple', + ORDERED: 'ordered', + RECORD: 'record', +}); + +export const BaseType = Object.freeze({ + IDENTIFIER: 'identifier', + BOOLEAN: 'boolean', + INTEGER: 'integer', + FLOAT: 'float', + STRING: 'string', + POINT: 'point', + PAIR: 'pair', + DIRECTED_PAIR: 'directedPair', + DURATION: 'duration', + FILE: 'file', + URI: 'uri', +}); + +export const QtiInteraction = Object.freeze({ + CHOICE: 'choiceInteraction', + ORDER: 'orderInteraction', + MATCH: 'matchInteraction', + TEXT_ENTRY: 'textEntryInteraction', + EXTENDED_TEXT: 'extendedTextInteraction', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue new file mode 100644 index 0000000000..0d2e34e32b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + {{ noQuestionsPlaceholder$() }} + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js new file mode 100644 index 0000000000..b4a852f9f7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -0,0 +1,80 @@ +import { createTranslator } from 'shared/i18n'; + +export const qtiEditorStrings = createTranslator('QTIEditorStrings', { + noQuestionsPlaceholder: { + message: 'No questions yet', + context: 'Shown when the question list is empty', + }, + newQuestionBtnLabel: { + message: 'New question', + context: 'Button that adds a new question to the list', + }, + questionNumberLabel: { + message: 'Question {number} of {total}', + context: 'Card header when card is open, e.g. "Question 2 of 5"', + }, + questionNumberAndTypeLabel: { + message: 'Question {number} of {total} \u2014 {type}', + context: 'Card header when card is closed, e.g. "Question 1 of 3 \u2014 Choice"', + }, + closeBtnLabel: { + message: 'Close', + context: 'Button that collapses the open question card', + }, + questionContentPlaceholder: { + message: 'Question content editor coming soon', + context: 'Placeholder inside an open card until interaction editors are built', + }, + showAnswers: { + message: 'Show answers', + context: 'Checkbox label to toggle displaying answers/previews', + }, + interactionTypeChoice: { + message: 'Choice', + context: 'Display name for choiceInteraction', + }, + interactionTypeOrder: { + message: 'Order', + context: 'Display name for orderInteraction', + }, + interactionTypeMatch: { + message: 'Match', + context: 'Display name for matchInteraction', + }, + interactionTypeTextEntry: { + message: 'Text entry', + context: 'Display name for textEntryInteraction', + }, + interactionTypeExtendedText: { + message: 'Extended text', + context: 'Display name for extendedTextInteraction', + }, + interactionTypeUnknown: { + message: 'Unknown type', + context: 'Fallback when an item has an unrecognised interaction type', + }, + toolbarLabelEdit: { + message: 'Edit', + context: 'Action to edit the item', + }, + toolbarLabelMoveUp: { + message: 'Move up', + context: 'Action to move the item up', + }, + toolbarLabelMoveDown: { + message: 'Move down', + context: 'Action to move the item down', + }, + toolbarLabelDelete: { + message: 'Delete', + context: 'Action to delete the item', + }, + toolbarLabelAddAbove: { + message: 'Add question above', + context: 'Action to add a new question above the current one', + }, + toolbarLabelAddBelow: { + message: 'Add question below', + context: 'Action to add a new question below the current one', + }, +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js new file mode 100644 index 0000000000..a543efb9b3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js @@ -0,0 +1,84 @@ +import { qtiEditorStrings } from './qtiEditorStrings'; + +/** + * Generates the toolbar actions array for a specific QTI item in the list. + */ +export default function useQTIEditorActions({ + items, + activeId, + windowIsSmall, + openItem, + moveItemUp, + moveItemDown, + addItem, + deleteItem, +}) { + const { + toolbarLabelEdit$, + toolbarLabelMoveUp$, + toolbarLabelMoveDown$, + toolbarLabelAddAbove$, + toolbarLabelAddBelow$, + toolbarLabelDelete$, + } = qtiEditorStrings; + + function getToolbarActions(item, idx) { + const result = []; + const isEditMode = activeId.value === item.id; + + result.push({ + id: 'edit', + icon: 'edit', + label: toolbarLabelEdit$(), + handler: () => openItem(item.id), + collapsed: false, + disabled: isEditMode, + }); + + result.push({ + id: 'move-up', + icon: 'chevronUp', + label: toolbarLabelMoveUp$(), + handler: () => moveItemUp(idx), + collapsed: windowIsSmall.value, + disabled: idx === 0, + }); + + result.push({ + id: 'move-down', + icon: 'chevronDown', + label: toolbarLabelMoveDown$(), + handler: () => moveItemDown(idx), + collapsed: windowIsSmall.value, + disabled: idx === items.value.length - 1, + }); + + result.push( + { + id: 'add-above', + icon: null, + label: toolbarLabelAddAbove$(), + handler: () => addItem({ atIndex: idx }), + collapsed: true, + }, + { + id: 'add-below', + icon: null, + label: toolbarLabelAddBelow$(), + handler: () => addItem({ atIndex: idx + 1 }), + collapsed: true, + }, + { + id: 'delete', + icon: 'close', + label: toolbarLabelDelete$(), + handler: () => deleteItem(item), + collapsed: true, + }, + ); + + return result; + } + + return { getToolbarActions }; +}
+ {{ questionContentPlaceholder$() }} +