diff --git a/src/locales/de.ts b/src/locales/de.ts index d38004774b..025b79329e 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -800,6 +800,10 @@ export default { "components.roomForm.labels.timePeriod": "Zeitraum", "components.roomForm.labels.timePeriod.from": "Zeitraum von", "components.roomForm.labels.timePeriod.to": "Zeitraum bis", + "components.roomForm.validation.generalSaveError": + "Beim Speichern ist ein Fehler aufgetreten. Bitte überprüfe deine Eingaben und versuche es erneut.", + "components.roomForm.validation.timePeriod.startBeforeEnd": + "Das Startdatum muss vor dem Enddatum liegen.", "components.timePicker.validation.format": "Bitte Format HH:MM verwenden.", "components.timePicker.validation.required": "Bitte Uhrzeit angeben.", "error.400": "400 – Fehlerhafte Anfrage", diff --git a/src/locales/en.ts b/src/locales/en.ts index acb5109fa7..4b5890ec25 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -792,6 +792,10 @@ export default { "components.roomForm.labels.timePeriod": "Time period", "components.roomForm.labels.timePeriod.from": "Time period from", "components.roomForm.labels.timePeriod.to": "Time period to", + "components.roomForm.validation.generalSaveError": + "An error occurred while saving. Please check your inputs and try again.", + "components.roomForm.validation.timePeriod.startBeforeEnd": + "The start date must be before the end date.", "components.timePicker.validation.format": "Please use format HH:MM", "components.timePicker.validation.required": "Please enter a time.", "error.400": "401 – Bad Request", diff --git a/src/locales/es.ts b/src/locales/es.ts index 5b13192e3d..85039cc95f 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -456,7 +456,7 @@ export default { "components.cardElement.deletedElement.warning.externalToolElement": "La herramienta {toolName} no está disponible. Por favor comuníquese con el administrador de la escuela.", "components.datePicker.validation.format": - "Por favor utilice el formato DD.MM.YYYY", + "Por favor utilice el formato DD.MM.AAAA", "components.datePicker.validation.required": "Por favor ingrese una fecha.", "components.dateTimePicker.messages.dateInPast": "La fecha y la hora están en el pasado.", @@ -812,6 +812,10 @@ export default { "components.roomForm.labels.timePeriod": "Periodo de tiempo", "components.roomForm.labels.timePeriod.from": "Periodo de tiempo desde", "components.roomForm.labels.timePeriod.to": "Periodo de tiempo hasta", + "components.roomForm.validation.generalSaveError": + "Se ha producido un error al guardar. Por favor, compruebe sus entradas e inténtelo de nuevo.", + "components.roomForm.validation.timePeriod.startBeforeEnd": + "La fecha de inicio debe ser anterior a la fecha de finalización.", "components.timePicker.validation.format": "Por favor utilice el formato HH:MM", "components.timePicker.validation.required": "Por favor ingrese un tiempo.", diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 767de5f144..4d91e9cf74 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -806,6 +806,10 @@ export default { "components.roomForm.labels.timePeriod": "Період часу", "components.roomForm.labels.timePeriod.from": "Період від", "components.roomForm.labels.timePeriod.to": "Період до", + "components.roomForm.validation.generalSaveError": + "Виникла помилка при збереженні. Будь ласка, перевірте свої записи та спробуйте ще раз.", + "components.roomForm.validation.timePeriod.startBeforeEnd": + "Дата початку повинна передувати даті закінчення.", "components.timePicker.validation.format": "Використовуйте формат ГГ:ХХ", "components.timePicker.validation.required": "Будь ласка, введіть час.", "error.400": "400 – Неприпустимий запит", diff --git a/src/modules/data/room/RoomCreate.state.ts b/src/modules/data/room/RoomCreate.state.ts index b90203f21e..12d6e390e8 100644 --- a/src/modules/data/room/RoomCreate.state.ts +++ b/src/modules/data/room/RoomCreate.state.ts @@ -1,7 +1,6 @@ import { RoomApiFactory, RoomColor } from "@/serverApi/v3"; import { RoomCreateParams, RoomItem } from "@/types/room/Room"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; -import { createApplicationError } from "@/utils/create-application-error.factory"; import { ref } from "vue"; export const useRoomCreateState = () => { @@ -15,15 +14,17 @@ export const useRoomCreateState = () => { endDate: undefined, }); + /** + * @throws ApiResponseError | ApiValidationError + */ const createRoom = async (params: RoomCreateParams): Promise => { isLoading.value = true; try { const room = (await roomApi.roomControllerCreateRoom(params)).data; + return room; } catch (error) { - const responseError = mapAxiosErrorToResponseError(error); - - throw createApplicationError(responseError.code); + throw mapAxiosErrorToResponseError(error); } finally { isLoading.value = false; } diff --git a/src/modules/data/room/RoomEdit.state.ts b/src/modules/data/room/RoomEdit.state.ts index 065c150f71..6ba2853c07 100644 --- a/src/modules/data/room/RoomEdit.state.ts +++ b/src/modules/data/room/RoomEdit.state.ts @@ -37,6 +37,9 @@ export const useRoomEditState = () => { } }; + /** + * @throws ApiResponseError | ApiValidationError + */ const updateRoom = async ( id: string, params: RoomUpdateParams @@ -45,9 +48,7 @@ export const useRoomEditState = () => { try { await roomApi.roomControllerUpdateRoom(id, params); } catch (error) { - const responseError = mapAxiosErrorToResponseError(error); - - throw createApplicationError(responseError.code); + throw mapAxiosErrorToResponseError(error); } finally { isLoading.value = false; } diff --git a/src/modules/feature/room/BoardGrid.unit.ts b/src/modules/feature/room/BoardGrid.unit.ts new file mode 100644 index 0000000000..ab73cc4ee0 --- /dev/null +++ b/src/modules/feature/room/BoardGrid.unit.ts @@ -0,0 +1,31 @@ +import { + createTestingVuetify, + createTestingI18n, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import { ComponentProps } from "vue-component-type-helpers"; +import BoardGrid from "./BoardGrid.vue"; +import { roomBoardTileListFactory } from "@@/tests/test-utils"; + +const mockBoards = roomBoardTileListFactory.buildList(3); + +describe("@feature-room/BoardGrid", () => { + const setup = (props: ComponentProps) => { + const wrapper = mount(BoardGrid, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + props, + }); + + return { wrapper }; + }; + + it("should render list of BoardTiles", async () => { + const { wrapper } = setup({ boards: mockBoards }); + + const boardTiles = wrapper.findAllComponents({ name: "BoardTile" }); + + expect(boardTiles.length).toStrictEqual(3); + }); +}); diff --git a/src/modules/feature/room/BoardTile.unit.ts b/src/modules/feature/room/BoardTile.unit.ts new file mode 100644 index 0000000000..6229d21a97 --- /dev/null +++ b/src/modules/feature/room/BoardTile.unit.ts @@ -0,0 +1,48 @@ +import { + createTestingVuetify, + createTestingI18n, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import { ComponentProps } from "vue-component-type-helpers"; +import BoardTile from "./BoardTile.vue"; +import { BoardLayout } from "@/serverApi/v3"; +import { RoomBoardItem } from "@/types/room/Room"; + +const mockBoard: RoomBoardItem = { + id: "59cce2c61113d1132c98dc06", + title: "A11Y for Beginners", + layout: BoardLayout.Columns, + isVisible: false, + createdAt: "2017-09-28T11:49:39.924Z", + updatedAt: "2017-09-28T11:49:39.924Z", +}; + +describe("@feature-room/BoardTile", () => { + const setup = (props: ComponentProps) => { + const wrapper = mount(BoardTile, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + props, + }); + + return { wrapper }; + }; + + describe("when board is column board in draft state", () => { + it("should compute correct subtitle", () => { + const { wrapper } = setup({ board: mockBoard, index: 0 }); + + const subtitle = wrapper.get("[data-testid='board-tile-subtitle-0']"); + expect(subtitle.text()).toStrictEqual( + "pages.room.boardCard.label.columnBoard - common.words.draft" + ); + }); + + it("should display tile in draft style", () => { + const { wrapper } = setup({ board: mockBoard, index: 0 }); + + expect(wrapper.classes()).toContain("board-is-draft"); + }); + }); +}); diff --git a/src/modules/feature/room/RoomDetails.unit.ts b/src/modules/feature/room/RoomDetails.unit.ts new file mode 100644 index 0000000000..b8de87e3ed --- /dev/null +++ b/src/modules/feature/room/RoomDetails.unit.ts @@ -0,0 +1,39 @@ +import { + createTestingVuetify, + createTestingI18n, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import { ComponentProps } from "vue-component-type-helpers"; +import RoomDetails from "./RoomDetails.vue"; +import { RoomDetails as RoomDetailsType } from "@/types/room/Room"; +import { RoomColor } from "@/serverApi/v3"; + +const mockRoom: RoomDetailsType = { + id: "59cce2c61113d1132c98dc06", + name: "A11Y for Beginners", + color: RoomColor.Magenta, + startDate: "", + endDate: "", + createdAt: "2017-09-28T11:49:39.924Z", + updatedAt: "2017-09-28T11:49:39.924Z", +}; + +describe("@feature-room/RoomDetails", () => { + const setup = (props: ComponentProps) => { + const wrapper = mount(RoomDetails, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + props, + }); + + return { wrapper }; + }; + + it("should render BoardGrid", async () => { + const { wrapper } = setup({ room: mockRoom }); + + const boardGrid = wrapper.findComponent({ name: "BoardGrid" }); + expect(boardGrid.exists()).toStrictEqual(true); + }); +}); diff --git a/src/modules/feature/room/RoomForm.unit.ts b/src/modules/feature/room/RoomForm.unit.ts index a64e6a02d8..7130e978af 100644 --- a/src/modules/feature/room/RoomForm.unit.ts +++ b/src/modules/feature/room/RoomForm.unit.ts @@ -2,12 +2,11 @@ import { createTestingVuetify, createTestingI18n, } from "@@/tests/test-utils/setup"; -import { mount } from "@vue/test-utils"; +import { flushPromises, mount } from "@vue/test-utils"; import { ComponentProps } from "vue-component-type-helpers"; import RoomForm from "./RoomForm.vue"; import { RoomCreateParams } from "@/types/room/Room"; import { RoomColor } from "@/serverApi/v3"; -import { nextTick } from "vue"; const mockRoom: RoomCreateParams = { name: "A11Y for Beginners", @@ -16,9 +15,11 @@ const mockRoom: RoomCreateParams = { endDate: "", }; -const invalidMockRoom: RoomCreateParams = { +const emptyMockRoom: RoomCreateParams = { name: "", color: RoomColor.Magenta, + startDate: undefined, + endDate: undefined, }; describe("@feature-room/RoomForm", () => { @@ -34,103 +35,71 @@ describe("@feature-room/RoomForm", () => { return { wrapper }; }; - it("should not save invalid room", async () => { - const { wrapper } = setup({ room: invalidMockRoom }); + describe("when save button is clicked", () => { + describe("when room data is invalid", () => { + it("should not emit 'save' event", async () => { + const { wrapper } = setup({ room: emptyMockRoom }); - // validation needs to be triggered manually due to code structure - await wrapper.vm.v$.$validate(); + const saveBtn = wrapper.findComponent( + "[data-testid='room-form-save-btn']" + ); + await saveBtn.trigger("click"); + await flushPromises(); - await wrapper.find("[type='submit']").trigger("click"); - await nextTick(); - - expect(wrapper.vm.v$.$invalid).toEqual(true); - expect(wrapper.emitted("save")).toBeUndefined(); - }); - - it("should emit save if room is valid", async () => { - const { wrapper } = setup({ room: mockRoom }); - - // validation needs to be triggered manually due to code structure - await wrapper.vm.v$.$validate(); - - await wrapper.find("[type='submit']").trigger("click"); - await nextTick(); - - expect(wrapper.vm.v$.$invalid).toEqual(false); - expect(wrapper.emitted("save")).toHaveLength(1); - }); - - it("should not directly emit cancel when room values were changed", async () => { - const { wrapper } = setup({ room: mockRoom }); - - const textField = wrapper.findComponent({ name: "VTextField" }); - const input = textField.find("input"); - - input.setValue("New Name"); - await nextTick(); - - expect(wrapper.vm.room.name).toEqual("New Name"); - - const cancelButton = wrapper.get('[data-testId="room-form-cancel-btn"]'); - await cancelButton.trigger("click"); + expect(wrapper.emitted("save")).toBeUndefined(); + }); + }); - expect(wrapper.vm.v$.$anyDirty).toEqual(true); - expect(wrapper.emitted("cancel")).toBeUndefined(); + describe("when room data is valid", () => { + it("should emit 'save' event", async () => { + const { wrapper } = setup({ room: mockRoom }); + + const saveBtn = wrapper.findComponent( + "[data-testid='room-form-save-btn']" + ); + await saveBtn.trigger("click"); + await flushPromises(); + + expect(wrapper.emitted("save")).toHaveLength(1); + expect(wrapper.emitted("save")?.[0][0]).toStrictEqual({ + room: mockRoom, + }); + }); + }); }); - it("should emit cancel when room values were not touched", () => { - const { wrapper } = setup({ room: mockRoom }); + describe("when cancel button is clicked", () => { + describe("when room values were not changed", () => { + it("should emit cancel", async () => { + const { wrapper } = setup({ room: mockRoom }); - const cancelButton = wrapper.get('[data-testId="room-form-cancel-btn"]'); - cancelButton.trigger("click"); + const cancelButton = wrapper.get( + '[data-testid="room-form-cancel-btn"]' + ); + await cancelButton.trigger("click"); - expect(wrapper.vm.v$.$anyDirty).toEqual(false); - expect(wrapper.emitted("cancel")).toHaveLength(1); - }); - - it("should change room state when new start date is given", () => { - const { wrapper } = setup({ room: mockRoom }); - - const datePickers = wrapper.findAllComponents({ - name: "DatePicker", + expect(wrapper.emitted("cancel")).toHaveLength(1); + }); }); - const today = new Date().toISOString(); + describe("when room values were changed", () => { + it("should not directly emit cancel", async () => { + const { wrapper } = setup({ room: mockRoom }); - datePickers[0].vm.$emit("update:date", today); + const textField = wrapper.findComponent({ name: "VTextField" }); + const input = textField.find("input"); - expect(wrapper.vm.v$.$anyDirty).toEqual(true); - expect(wrapper.vm.room.startDate).toEqual(today); - }); - - it("should change room state when new end date is given", () => { - const { wrapper } = setup({ room: mockRoom }); - - const datePickers = wrapper.findAllComponents({ - name: "DatePicker", - }); + await input.setValue("New Name"); - const today = new Date(); - const tomorrow = new Date(today.getDate() + 1).toISOString(); + expect(wrapper.vm.room.name).toEqual("New Name"); - datePickers[1].vm.$emit("update:date", tomorrow); + const cancelButton = wrapper.get( + '[data-testId="room-form-cancel-btn"]' + ); + await cancelButton.trigger("click"); - expect(wrapper.vm.v$.$anyDirty).toEqual(true); - expect(wrapper.vm.room.endDate).toEqual(tomorrow); - }); - - it("should change room state when new color is given", () => { - const { wrapper } = setup({ room: mockRoom }); - - const colorPicker = wrapper.findComponent({ - name: "RoomColorPicker", + expect(wrapper.emitted("cancel")).toBeUndefined(); + }); }); - - const newColor = RoomColor.Red; - - colorPicker.vm.$emit("update:color", newColor); - - expect(wrapper.vm.v$.$anyDirty).toEqual(true); - expect(wrapper.vm.room.color).toEqual(newColor); }); }); diff --git a/src/modules/feature/room/RoomForm.vue b/src/modules/feature/room/RoomForm.vue index 29b5284321..718507d18f 100644 --- a/src/modules/feature/room/RoomForm.vue +++ b/src/modules/feature/room/RoomForm.vue @@ -23,6 +23,8 @@
props.room); +const todayISO = computed(() => + dayjs.tz(new Date(), "DD.MM.YYYY", "UTC").format(DATETIME_FORMAT.inputDate) +); + +const isStartBeforeEndDate = ( + startDate: string | undefined, + endDate: string | undefined +) => { + if (!startDate || !endDate) return true; + return new Date(startDate) <= new Date(endDate); +}; + +const areDatesSameDay = ( + startDate: string | undefined, + endDate: string | undefined +) => { + if (!startDate || !endDate) return true; + + const start = new Date(startDate); + const end = new Date(endDate); + return ( + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate() + ); +}; + +const isStartBeforeOrEqualToEndDate = ( + startDate: string | undefined, + endDate: string | undefined +) => { + return ( + isStartBeforeEndDate(startDate, endDate) || + areDatesSameDay(startDate, endDate) + ); +}; + +const startBeforeEndDateValidator = (endDate: string | undefined) => { + return helpers.withParams( + { type: "startBeforeEndDate", value: endDate }, + helpers.withMessage( + t("components.roomForm.validation.timePeriod.startBeforeEnd"), + (startDate: string) => isStartBeforeOrEqualToEndDate(startDate, endDate) + ) + ); +}; -// Validation const validationRules = computed(() => ({ roomData: { name: { + maxLength: helpers.withMessage( + t("common.validation.tooLong"), + maxLength(100) + ), required: helpers.withMessage(t("common.validation.required2"), required), }, + startDate: { + startBeforeEndDate: startBeforeEndDateValidator(roomData.value.endDate), + }, }, })); @@ -102,27 +159,25 @@ const v$ = useVuelidate( { $lazy: true, $autoDirty: true } ); +const startDateErrors = computed(() => v$.value.roomData.startDate.$errors); + const onUpdateColor = () => { v$.value.$touch(); }; const onUpdateStartDate = (newDate: string) => { roomData.value.startDate = newDate; - v$.value.$touch(); }; const onUpdateEndDate = (newDate: string) => { roomData.value.endDate = newDate; - v$.value.$touch(); }; const onSave = async () => { const valid = await v$.value.$validate(); - if (!valid) { - // TODO notify user form is invalid - return; + if (valid) { + emit("save", { room: roomData.value }); } - emit("save", roomData.value); }; const onCancel = async () => { diff --git a/src/modules/page/room/RoomCreate.page.vue b/src/modules/page/room/RoomCreate.page.vue index 641a682c18..06329ea830 100644 --- a/src/modules/page/room/RoomCreate.page.vue +++ b/src/modules/page/room/RoomCreate.page.vue @@ -20,7 +20,11 @@ import { useTitle } from "@vueuse/core"; import { computed } from "vue"; import { useI18n } from "vue-i18n"; import { useRouter } from "vue-router"; +import { createApplicationError } from "@/utils/create-application-error.factory"; +import { injectStrict, NOTIFIER_MODULE_KEY } from "@/utils/inject"; +import { ApiResponseError } from "@/store/types/commons"; +const notifierModule = injectStrict(NOTIFIER_MODULE_KEY); const { t } = useI18n(); const router = useRouter(); @@ -42,9 +46,26 @@ const breadcrumbs: Breadcrumb[] = [ }, ]; -const onSave = async (roomParams: RoomCreateParams) => { - const { id } = await createRoom(roomParams); - router.push({ name: "room-details", params: { id } }); +const onSave = async (payload: { room: RoomCreateParams }) => { + try { + const room = await createRoom(payload.room); + + router.push({ name: "room-details", params: { id: room.id } }); + } catch (error: unknown) { + if (isInvalidRequestError(error)) { + notifierModule.show({ + text: t("components.roomForm.validation.generalSaveError"), + status: "error", + }); + } else { + throw createApplicationError((error as ApiResponseError).code); + } + } +}; + +const isInvalidRequestError = (error: unknown): boolean => { + const apiError = error as ApiResponseError; + return apiError.code === 400; }; const onCancel = () => { diff --git a/src/modules/page/room/RoomEdit.page.vue b/src/modules/page/room/RoomEdit.page.vue index 28fd0cc8cb..899cc5eed1 100644 --- a/src/modules/page/room/RoomEdit.page.vue +++ b/src/modules/page/room/RoomEdit.page.vue @@ -23,7 +23,11 @@ import { useTitle } from "@vueuse/core"; import { computed, ComputedRef, watch, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useRoute, useRouter } from "vue-router"; +import { injectStrict, NOTIFIER_MODULE_KEY } from "@/utils/inject"; +import { ApiResponseError } from "@/store/types/commons"; +import { createApplicationError } from "@/utils/create-application-error.factory"; +const notifierModule = injectStrict(NOTIFIER_MODULE_KEY); const { t } = useI18n(); const route = useRoute(); @@ -47,12 +51,29 @@ watch( { immediate: true } ); -const onSave = async (roomParams: RoomUpdateParams) => { - await updateRoom(route.params.id as string, roomParams); - router.push({ - name: "room-details", - params: { id: route.params.id as string }, - }); +const onSave = async (payload: { room: RoomUpdateParams }) => { + try { + await updateRoom(route.params.id as string, payload.room); + + router.push({ + name: "room-details", + params: { id: route.params.id as string }, + }); + } catch (error: unknown) { + if (isInvalidRequestError(error)) { + notifierModule.show({ + text: t("components.roomForm.validation.generalSaveError"), + status: "error", + }); + } else { + throw createApplicationError((error as ApiResponseError).code); + } + } +}; + +const isInvalidRequestError = (error: unknown): boolean => { + const apiError = error as ApiResponseError; + return apiError.code === 400; }; const onCancel = () => { diff --git a/src/modules/ui/date-time-picker/DatePicker.unit.ts b/src/modules/ui/date-time-picker/DatePicker.unit.ts index c2d7c94674..588f424924 100644 --- a/src/modules/ui/date-time-picker/DatePicker.unit.ts +++ b/src/modules/ui/date-time-picker/DatePicker.unit.ts @@ -12,6 +12,9 @@ describe("DatePicker", () => { return mount(DatePicker, { global: { plugins: [createTestingVuetify(), createTestingI18n()], + stubs: { + "transition-group": false, + }, }, ...options, attachTo: document.body, @@ -38,12 +41,12 @@ describe("DatePicker", () => { const dateSelector = wrapper.findComponent({ name: "v-date-picker" }); expect(dateSelector.exists()).toBe(true); - dateSelector.vm.$emit("update:model-value", new Date().toISOString()); + dateSelector.vm.$emit("update:modelValue", new Date().toISOString()); expect(wrapper.emitted("update:date")).toHaveLength(1); }); - describe("when date is invalid", () => { + describe("when required prop is set & value is left empty", () => { it("should emit error event", async () => { const wrapper = mountComponent({ props: { @@ -60,7 +63,9 @@ describe("DatePicker", () => { expect(wrapper.emitted("update:date")).toBeUndefined(); expect(wrapper.emitted("error")).toHaveLength(1); }); + }); + describe("when date is invalid", () => { it("should emit error event", async () => { const wrapper = mountComponent({ props: { date: new Date().toISOString() }, @@ -74,5 +79,9 @@ describe("DatePicker", () => { expect(wrapper.emitted("update:date")).toBeUndefined(); expect(wrapper.emitted("error")).toHaveLength(1); }); + + // TODO: Can't properly be tested with debounced validation, needs a new approach + it.todo("should display error message"); + it.todo("should display external error message"); }); }); diff --git a/src/modules/ui/date-time-picker/DatePicker.vue b/src/modules/ui/date-time-picker/DatePicker.vue index 5074e6388a..c06781586f 100644 --- a/src/modules/ui/date-time-picker/DatePicker.vue +++ b/src/modules/ui/date-time-picker/DatePicker.vue @@ -23,7 +23,6 @@ @keydown.tab="showDateDialog = false" /> -