diff --git a/02-basics-2/50-selected-meetup/README.md b/02-basics-2/50-selected-meetup/README.md new file mode 100644 index 0000000..63d3359 --- /dev/null +++ b/02-basics-2/50-selected-meetup/README.md @@ -0,0 +1,31 @@ +# Выбранный митап + +🔥 _Задача повышенной сложности_\ +📚 _Закрепление материала_ + + + +Требуется создать Vue приложение, которое выводит заголовок выбранного митапа: +- На странице отображается заголовок выбранного митапа +- Данные митапа запрашиваются с API функцией `getMeetup` +- Выбор митапа осуществляется радио кнопками с выбором ID от 1 до 5 +- Кнопки "Предыдущий" и "Следующий" позволяют соответственно менять выбранный ID +- Кнопки должны быть отключены с `disabled` по достижении крайних значений +- Изначально выбран митап с ID = 1 + +Требуется минимальное работающее решение. Обрабатывать потенциальную ошибку получения данных или отображать индикацию загрузки не требуется. + +Example + + + +--- + +### Инструкция + +📝 Для решения задачи отредактируйте файл: `SelectedMeetupApp.js`. + +🚀 Команда запуска для ручного тестирования: `npm run dev`\ +Приложение будет доступно на [http://localhost:5173/02-basics-2/50-selected-meetup/](http://localhost:5173/02-basics-2/50-selected-meetup/). + +✅ Доступно автоматическое тестирование: `npm test selected-meetup` diff --git a/02-basics-2/50-selected-meetup/SelectedMeetupApp.css b/02-basics-2/50-selected-meetup/SelectedMeetupApp.css new file mode 100644 index 0000000..4401e67 --- /dev/null +++ b/02-basics-2/50-selected-meetup/SelectedMeetupApp.css @@ -0,0 +1,16 @@ +.meetup-selector { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; +} + +.meetup-selector__control { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.meetup-selector__cover { +} diff --git a/02-basics-2/50-selected-meetup/SelectedMeetupApp.js b/02-basics-2/50-selected-meetup/SelectedMeetupApp.js new file mode 100644 index 0000000..dad23b8 --- /dev/null +++ b/02-basics-2/50-selected-meetup/SelectedMeetupApp.js @@ -0,0 +1,78 @@ +import { defineComponent } from 'vue' +// import { getMeetup } from './meetupsService.ts' + +export default defineComponent({ + name: 'SelectedMeetupApp', + + setup() {}, + + template: ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

Some Meetup Title

+
+
+ +
+ `, +}) diff --git a/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts b/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts new file mode 100644 index 0000000..eca9ad9 --- /dev/null +++ b/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { MockedFunction } from 'vitest' +import { createWrapperError, flushPromises, mount } from '@vue/test-utils' +import type { VueWrapper, DOMWrapper } from '@vue/test-utils' +import SelectedMeetupApp from '@/SelectedMeetupApp.js' +import { getMeetup } from '@/meetupsService.ts' +import type { MeetupDTO } from '@/meetups.types.ts' + +vi.mock('@/meetupsService.ts') + +const mockMeetups = { + 1: { id: 1, title: 'Meetup 1' }, + 2: { id: 2, title: 'Meetup Second' }, + 3: { id: 3, title: 'Meetup III' }, + 4: { id: 4, title: '4th meetup' }, + 5: { id: 5, title: 'Meetup 5' }, +} as const as unknown as Record + +const mockGetMeetup = getMeetup as MockedFunction + +mockGetMeetup.mockImplementation((meetupId: number) => Promise.resolve(mockMeetups[meetupId])) + +function findByText(wrapper: VueWrapper, selector: string, text: string): DOMWrapper { + return wrapper.findAll(selector).find(el => el.text() === text) ?? createWrapperError>('DOMWrapper') +} + +describe('SelectedMeetupApp', () => { + let wrapper: VueWrapper + let prevButton: DOMWrapper + let nextButton: DOMWrapper + let meetupIdRadioButtons: DOMWrapper[] + let meetupIdRadioLabels: DOMWrapper[] + + beforeEach(() => { + wrapper = mount(SelectedMeetupApp) + prevButton = findByText(wrapper, 'button', 'Предыдущий') + nextButton = findByText(wrapper, 'button', 'Следующий') + meetupIdRadioLabels = wrapper.findAll('[role="radiogroup"] label') + meetupIdRadioButtons = meetupIdRadioLabels.map(label => wrapper.find(`input#${label.attributes('for')}`)) + }) + + it('должно отображать 5 радио кнопок со значениями от 1 до 5, связанных по ID и FOR, а также кнопки "Предыдущий", "Следующий"', () => { + expect(prevButton.exists()).toBe(true) + expect(nextButton.exists()).toBe(true) + expect(meetupIdRadioLabels).toHaveLength(5) + expect(meetupIdRadioButtons).toHaveLength(5) + for (let i = 1; i <= 5; i++) { + expect(meetupIdRadioLabels[i - 1].text()).toBe(i.toString()) + expect(meetupIdRadioButtons[i - 1].exists()).toBeTruthy() + } + }) + + it('должно изначально отображать первый митап выбранным и выводить его заголовок из данных, полученных функцией getMeetup', async () => { + await flushPromises() + expect(meetupIdRadioButtons[0].element.checked).toBeTruthy() + expect(wrapper.text()).toContain('Meetup 1') + }) + + it('должно отображать кнопку "Предыдущий" отключенной при изначально выбранном первом митапе', async () => { + expect(meetupIdRadioButtons[0].element.checked).toBeTruthy() + expect(prevButton.attributes('disabled')).toBeDefined() + expect(nextButton.attributes('disabled')).not.toBeDefined() + }) + + it('должно выводить заголовок 2-го митапа выбранным после выбора 2-ой радио кнопки и выводить его заголовок из данных, полученных функцией getMeetup', async () => { + await meetupIdRadioButtons[1].setValue(true) + await flushPromises() + expect(wrapper.text()).toContain('Meetup Second') + }) + + it('должно выводить заголовок 5-го митапа после выбора 5-й радио кнопки', async () => { + await meetupIdRadioButtons[4].setValue(true) + await flushPromises() + expect(wrapper.text()).toContain('Meetup 5') + }) + + it('должно отображать кнопку "Следующий" отключенной при выборе последнего митапа', async () => { + await meetupIdRadioButtons[4].setValue(true) + await flushPromises() + expect(prevButton.attributes('disabled')).not.toBeDefined() + expect(nextButton.attributes('disabled')).toBeDefined() + }) + + it('должно переключать с 3-го на 4-ый митап кнопкой "Следующий", когда был выбран 3-ий, и выводить его заголовок из данных, полученных функцией getMeetup', async () => { + await meetupIdRadioButtons[2].setValue(true) + await flushPromises() + await nextButton.trigger('click') + await flushPromises() + expect(prevButton.attributes('disabled')).not.toBeDefined() + expect(nextButton.attributes('disabled')).not.toBeDefined() + expect(meetupIdRadioButtons[2].element.checked).toBeFalsy() + expect(meetupIdRadioButtons[3].element.checked).toBeTruthy() + expect(wrapper.text()).toContain('4th meetup') + }) + + it('должно переключать с 4-го на 3-ий митап кнопкой "Предыдущий", когда выбран 4-ый, и выводить его заголовок из данных, полученных функцией getMeetup', async () => { + await meetupIdRadioButtons[3].setValue(true) + await flushPromises() + await prevButton.trigger('click') + await flushPromises() + expect(prevButton.attributes('disabled')).not.toBeDefined() + expect(nextButton.attributes('disabled')).not.toBeDefined() + expect(meetupIdRadioButtons[3].element.checked).toBeFalsy() + expect(meetupIdRadioButtons[2].element.checked).toBeTruthy() + expect(wrapper.text()).toContain('Meetup III') + }) +}) diff --git a/02-basics-2/50-selected-meetup/index.html b/02-basics-2/50-selected-meetup/index.html new file mode 100644 index 0000000..cb13e76 --- /dev/null +++ b/02-basics-2/50-selected-meetup/index.html @@ -0,0 +1,16 @@ + + + + + selected-meetup + + +
+
+

Выбранный митап

+
+
+
+ + + diff --git a/02-basics-2/50-selected-meetup/main.js b/02-basics-2/50-selected-meetup/main.js new file mode 100644 index 0000000..adc8f06 --- /dev/null +++ b/02-basics-2/50-selected-meetup/main.js @@ -0,0 +1,6 @@ +import '@shgk/vue-course-ui/meetups/style.css' +import { createApp } from 'vue' +import SelectedMeetupApp from './SelectedMeetupApp.js' +import './SelectedMeetupApp.css' + +createApp(SelectedMeetupApp).mount('#app') diff --git a/02-basics-2/50-selected-meetup/meetups.types.ts b/02-basics-2/50-selected-meetup/meetups.types.ts new file mode 100644 index 0000000..a27e7a1 --- /dev/null +++ b/02-basics-2/50-selected-meetup/meetups.types.ts @@ -0,0 +1,160 @@ +export type AgendaItemsTypes = + | 'registration' + | 'opening' + | 'talk' + | 'break' + | 'coffee' + | 'closing' + | 'afterparty' + | 'other' + +export type MeetupAgendaItemDTO = { + /** ID пункта программы */ + id: number + + /** Время начала в формате HH:MM */ + startsAt: string + + /** Время окончания в формате HH:MM */ + endsAt: string + + /** Тип пункта программы */ + type: AgendaItemsTypes + + /** Заголовок при наличии */ + title: string | null + + /** Описание при наличии */ + description: string | null + + /** Имя докладчика для типа talk */ + speaker: string | null + + /** Язык доклада для типа talk */ + language: string | null +} + +export type MeetupDTO = { + /** ID митапа */ + id: string + + /** Название митапа */ + title: string + + /** Описание митапа */ + description: string + + /** + * ID изображения митапа + * @deprecated + */ + imageId: number + + /** Ссылка на изображение митапа */ + image: string + + /** Место проведения митапа */ + place: string + + /** Организатор митапа */ + organizer: string + + /** Дата митапа в формате UNIX Timestamp в 00:00:00.000 по UTC */ + date: number + + /** Дата митапа в формате YYYY-MM-DD по UTC */ + dateIso: string + + /** Для авторизованного пользователя - является ли текущий пользователь организатором митапа */ + organizing?: boolean + + /** Для авторизованного пользователя - является ли текущий пользователь участником митапа */ + attending?: boolean + + /** Программа митапа */ + agenda: MeetupAgendaItemDTO[] +} + +export type CreateAgendaItemDTO = { + /** Время начала в формате HH:MM */ + startsAt: string + + /** Время окончания в формате HH:MM */ + endsAt: string + + /** Тип пункта программы */ + type: AgendaItemsTypes + + /** Заголовок при наличии */ + title?: string | null + + /** Описание при наличии */ + description?: string | null + + /** Имя докладчика для типа talk */ + speaker?: string + + /** Язык доклада для типа talk */ + language?: string +} + +export type CreateMeetupDTO = { + /** Название митапа */ + title: string + + /** Описание митапа */ + description: string + + /** Место проведения митапа */ + place: string + + /** Дата митапа строкой в формате YYYY-MM-DD или числом UNIX timestamp в полночь по UTC */ + date: string | number + + /** ID загруженного изображения митапа */ + imageId: number + + /** Программа митапа */ + agenda: CreateAgendaItemDTO[] +} + +export type UserDTO = { + /** ID пользователя */ + id: number + + /** Полное имя */ + fullname: string + + /** Email */ + email: string + + /** Ссылка на аватар в сервисе gravatar */ + avatar: string +} + +export type LoginDTO = { + /** Email */ + email: string + + /** Пароль */ + password: string +} + +export type RegisterDTO = { + /** Email */ + email: string + + /** Пароль */ + password: string + + /** Полное имя */ + fullname: string +} + +export type ImageDTO = { + /** ID изображения */ + id: number + + /** Ссылка на изображение */ + url: string +} diff --git a/02-basics-2/50-selected-meetup/meetupsService.ts b/02-basics-2/50-selected-meetup/meetupsService.ts new file mode 100644 index 0000000..a26efa0 --- /dev/null +++ b/02-basics-2/50-selected-meetup/meetupsService.ts @@ -0,0 +1,17 @@ +import type { MeetupDTO } from './meetups.types.ts' + +const API_URL = 'https://course-vue.javascript.ru/api' + +/** + * Получить данные митапа по его ID + * @param meetupId - ID митапа + * @returns Данные митапа + */ +export async function getMeetup(meetupId: number): Promise { + const response = await fetch(`${API_URL}/meetups/${meetupId}`) + const result = await response.json() + if (!response.ok) { + throw new Error(result.message) + } + return result +} diff --git a/02-basics-2/50-selected-meetup/tsconfig.json b/02-basics-2/50-selected-meetup/tsconfig.json new file mode 100644 index 0000000..8a8b469 --- /dev/null +++ b/02-basics-2/50-selected-meetup/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@shgk/vue-course-taskbook/configs/tsconfig.json", + "include": ["**/*", "**/*.vue"], + "files": [], + "compilerOptions": { + "outDir": "dist", + "paths": { + "@/*": ["./*"] + } + } +}