diff --git a/e2e/tutor.spec.ts b/e2e/tutor.spec.ts index 2b2f866..53bd4a5 100644 --- a/e2e/tutor.spec.ts +++ b/e2e/tutor.spec.ts @@ -164,21 +164,22 @@ test.describe('Tutor', () => { await page.goto('/tutor'); }); - test('should display input type file', async ({ page }) => { - await expect(page.getByTestId('file-input')).toBeVisible(); + test('should display the file drop zone', async ({ page }) => { + await expect(page.getByTestId('file-input')).toBeAttached(); + await expect(page.getByTestId('tutor-next-button')).toBeDisabled(); }); - test('should add more inputs files to the page', async ({ page }) => { - await page.getByRole('button', { name: '+' }).click(); - const fileInputs = page.getByTestId('file-input'); - await expect(fileInputs).toHaveCount(2); + test('should list added files and enable continue', async ({ page }) => { + await page.getByTestId('file-input').setInputFiles('./e2e/files/test_tutor.pdf'); + await expect(page.getByTestId('file-list')).toContainText('test_tutor.pdf'); + await expect(page.getByTestId('tutor-next-button')).toBeEnabled(); }); - test('should remove input when clicking on x', async ({ page }) => { - await page.getByRole('button', { name: '+' }).click(); - await expect(page.getByTestId('file-input')).toHaveCount(2); - await page.getByRole('button', { name: 'x' }).last().click(); - await expect(page.getByTestId('file-input')).toHaveCount(1); + test('should remove a file from the list', async ({ page }) => { + await page.getByTestId('file-input').setInputFiles('./e2e/files/test_tutor.pdf'); + await page.getByTestId('file-list').getByRole('button').click(); + await expect(page.getByTestId('file-list')).toHaveCount(0); + await expect(page.getByTestId('tutor-next-button')).toBeDisabled(); }); // test('should add file to input', async ({ page }) => { diff --git a/env.d.ts b/env.d.ts index 11f02fe..8fb94e5 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1 +1,4 @@ /// + +// no types published for this package +declare module 'turndown-plugin-gfm'; diff --git a/package.json b/package.json index 60694e6..7db14c6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "marked": "^12.0.2", "mixpanel-browser": "^2.80.0", "pinia": "^2.1.4", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2", "vue": "^3.3.4", "vue-i18n": "9", "vue-router": "^4.2.4" @@ -36,6 +38,7 @@ "@tsconfig/node18": "^18.2.0", "@types/jsdom": "^21.1.1", "@types/node": "^18.17.0", + "@types/turndown": "^5.0.6", "@vitejs/plugin-vue": "^4.2.3", "@vitejs/plugin-vue-jsx": "^3.0.1", "@vue/eslint-config-prettier": "^8.0.0", diff --git a/src/components/__tests__/ThirdStep.spec.ts b/src/components/__tests__/ThirdStep.spec.ts new file mode 100644 index 0000000..4e2c83b --- /dev/null +++ b/src/components/__tests__/ThirdStep.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import ThirdStep from '@/components/tutor/ThirdStep.vue'; +import { cleanSyllabus } from '@/stores/tutor'; + +const mountStep = (content: string) => + mount(ThirdStep, { + props: { + syllabus: { content, source: 'x' }, + giveFeedback: vi.fn(), + feedbackHistory: [], + action: vi.fn(), + updateSyllabus: vi.fn(), + restart: vi.fn(), + updateSyllabusInDB: vi.fn() + } + }); + +describe('ThirdStep link bubble', () => { + it('shows an "open" bubble in a new tab when a link is clicked', async () => { + const wrapper = mountStep('See [paper](https://example.org/a).'); + await wrapper.find('#syllabus a').trigger('click'); + const open = wrapper.find('.link-bubble a'); + expect(open.attributes('href')).toBe('https://example.org/a'); + expect(open.attributes('target')).toBe('_blank'); + }); + + it('hides the bubble when clicking plain text', async () => { + const wrapper = mountStep('See [paper](https://example.org/a).'); + await wrapper.find('#syllabus a').trigger('click'); + await wrapper.find('#syllabus p').trigger('click'); + expect(wrapper.find('.link-bubble').exists()).toBe(false); + }); + + it('never offers non-web links', async () => { + const wrapper = mountStep('[bad](javascript:alert(1))'); + const link = wrapper.find('#syllabus a'); + if (link.exists()) await link.trigger('click'); + expect(wrapper.find('.link-bubble').exists()).toBe(false); + }); +}); + +describe('ThirdStep editing keeps the syllabus intact', () => { + const table = '| Week | Content |\n| --- | --- |\n| 1 | Intro
- E-waste
- Hubs |'; + + it('does not rewrite the syllabus when nothing was typed (e.g. after clicking a link)', async () => { + const wrapper = mountStep(`${table}\n\nSee [paper](https://example.org/a).`); + await wrapper.find('#syllabus a').trigger('click'); + await wrapper.find('#syllabus').trigger('blur'); + expect(wrapper.props('updateSyllabus')).not.toHaveBeenCalled(); + }); + + it('keeps line breaks inside table cells after an edit, instead of turning them into bullets', async () => { + const wrapper = mountStep(table); + const syllabus = wrapper.find('#syllabus'); + await syllabus.trigger('input'); + await syllabus.trigger('blur'); + const saved = (wrapper.props('updateSyllabus') as ReturnType).mock.calls[0][0]; + expect(saved).toContain('| 1 | Intro
- E-waste
- Hubs |'); + }); +}); + +describe('cleanSyllabus', () => { + it('turns literal "\\n" into
in tables and into new lines elsewhere', () => { + expect(cleanSyllabus('| 1 | a \\n- b |\nText\\nmore')).toBe('| 1 | a
- b |\nText\nmore'); + }); +}); diff --git a/src/components/microlearning/ProgressBar.vue b/src/components/microlearning/ProgressBar.vue index 24b500e..0786cb7 100644 --- a/src/components/microlearning/ProgressBar.vue +++ b/src/components/microlearning/ProgressBar.vue @@ -5,7 +5,13 @@
-
+
- {{ $t(`microLearning.progressBarLabels.${step}`) }} + {{ labels ? step : $t(`microLearning.progressBarLabels.${step}`) }}
@@ -38,16 +44,25 @@ const STEP_LABELS = ['contribution', 'skills', 'activity', 'yourTurn']; const props = defineProps<{ currentStep: number; + // already translated labels; defaults to the micro-learning steps + labels?: string[]; + // finished steps emit "select" when clicked + clickable?: boolean; }>(); +defineEmits<{ select: [index: number] }>(); + +const steps = computed(() => props.labels || STEP_LABELS); + const progressWidth = computed(() => { - return `${(props.currentStep / (STEP_LABELS.length - 1) + 0.05) * 100}%`; + return `${(props.currentStep / (steps.value.length - 1) + 0.05) * 100}%`; }); function stepClass(index: number) { return { active: index === props.currentStep, - done: index < props.currentStep + done: index < props.currentStep, + clickable: props.clickable && index < props.currentStep }; } @@ -133,6 +148,10 @@ function stepClass(index: number) { color: #444; } +.clickable { + cursor: pointer; +} + .check { width: 16px; height: 16px; diff --git a/src/components/tutor/CursusInfo.vue b/src/components/tutor/CursusInfo.vue index 2ac61ba..5382b7a 100644 --- a/src/components/tutor/CursusInfo.vue +++ b/src/components/tutor/CursusInfo.vue @@ -1,27 +1,20 @@ -const appendNewInputFile = () => { - if (!inputGroupRef.value || !inputContainerRef.value) { - console.error('Input group is not defined'); - return; - } +