diff --git a/src/components/__tests__/FormFieldCompanyAddress.cy.js b/src/components/__tests__/FormFieldCompanyAddress.cy.js index 4f8373549..39fee83f8 100644 --- a/src/components/__tests__/FormFieldCompanyAddress.cy.js +++ b/src/components/__tests__/FormFieldCompanyAddress.cy.js @@ -96,6 +96,13 @@ describe('', () => { // set organization ID in store store.setOrganizationId(organizationId); cy.wrap(store.getOrganizationId).should('eq', organizationId); + /** + * Manually load subsidiaries. + * In the live application, this is handled by `onMounted` hook, + * or calling `loadSubsidiariesToStore` method in another + * component. + */ + store.loadSubsidiariesToStore(null); // wait for subsidiaries API cy.waitForSubsidiariesApi( subsidiariesResponse, diff --git a/src/components/__tests__/FormFieldSelectTable.cy.js b/src/components/__tests__/FormFieldSelectTable.cy.js index e4a1ec2ab..c581c1b11 100644 --- a/src/components/__tests__/FormFieldSelectTable.cy.js +++ b/src/components/__tests__/FormFieldSelectTable.cy.js @@ -1,18 +1,24 @@ +import { ref } from 'vue'; import FormFieldSelectTable from 'components/form/FormFieldSelectTable.vue'; import { rideToWorkByBikeConfig } from 'src/boot/global_vars'; import { i18n } from '../../boot/i18n'; import { useApiGetOrganizations } from 'src/composables/useApiGetOrganizations'; import { createPinia, setActivePinia } from 'pinia'; +import { vModelAdapter } from 'app/test/cypress/utils'; import { OrganizationLevel, OrganizationType, } from 'src/components/types/Organization'; +import { interceptOrganizationsApi } from '../../../test/cypress/support/commonTests'; import { useRegisterChallengeStore } from 'src/stores/registerChallenge'; +// variables const { contactEmail } = rideToWorkByBikeConfig; +const model = ref(null); describe('', () => { let options; + let organizationId; let subsidiaryId; before(() => { @@ -32,6 +38,12 @@ describe('', () => { }, ); }); + // set common organizationId from fixture + cy.fixture('formFieldCompanyCreate').then( + (formFieldCompanyCreateResponse) => { + organizationId = formFieldCompanyCreateResponse.id; + }, + ); // set common subsidiaryId from fixture cy.fixture('formOrganizationOptions').then((formOrganizationOptions) => { subsidiaryId = formOrganizationOptions[0].subsidiaries[0].id; @@ -95,8 +107,20 @@ describe('', () => { context('organization company', () => { beforeEach(() => { cy.interceptCitiesGetApi(rideToWorkByBikeConfig, i18n); + // intercept both POST and GET requests for organizations + interceptOrganizationsApi( + rideToWorkByBikeConfig, + i18n, + OrganizationType.company, + ); + cy.interceptSubsidiaryPostApi( + rideToWorkByBikeConfig, + i18n, + organizationId, + ); cy.mount(FormFieldSelectTable, { props: { + ...vModelAdapter(model), options: options, organizationLevel: OrganizationLevel.organization, organizationType: OrganizationType.company, @@ -219,6 +243,76 @@ describe('', () => { cy.dataCy('dialog-button-submit').click(); cy.dataCy('dialog-add-option').should('not.exist'); }); + + it('allows to add a new organization', () => { + cy.fixture('apiPostSubsidiaryRequest').then( + (apiPostSubsidiaryRequest) => { + cy.fixture('formFieldCompanyCreateRequest').then( + (formFieldCompanyCreateRequest) => { + // open add company dialog + cy.dataCy('button-add-option').click(); + // verify that dialog is visible + cy.dataCy('dialog-add-option').should('be.visible'); + cy.dataCy('dialog-add-option') + .find('h3') + .should('be.visible') + .and('contain', i18n.global.t('form.company.titleAddCompany')); + // fill in the form + cy.fillOrganizationSubsidiaryForm( + formFieldCompanyCreateRequest, + apiPostSubsidiaryRequest, + ); + // submit the form + cy.dataCy('dialog-button-submit').click(); + // wait for API call + cy.waitForOrganizationPostApi(); + // verify that dialog is closed + cy.dataCy('dialog-add-option').should('not.exist'); + // test emitted events + cy.fixture('formFieldCompanyCreate').then( + (formFieldCompanyCreateResponse) => { + // test that create:option event was emitted + cy.wrap(Cypress.vueWrapper.emitted('create:option')).should( + 'have.length', + 1, + ); + // test that event payload is correct + cy.wrap( + Cypress.vueWrapper.emitted('create:option')[0][0], + ).should('deep.equal', formFieldCompanyCreateResponse); + // test that model value was updated + cy.wrap(model) + .its('value') + .should('eq', formFieldCompanyCreateResponse.id); + }, + ); + // open dialog again + cy.dataCy('button-add-option').click(); + cy.dataCy('dialog-add-option').should('be.visible'); + // verify that dialog form was reset + cy.dataCy('form-add-company-name') + .find('input') + .should('have.value', ''); + cy.dataCy('form-add-company-vat-id') + .find('input') + .should('have.value', ''); + cy.dataCy('form-add-subsidiary-street') + .find('input') + .should('have.value', ''); + cy.dataCy('form-add-subsidiary-house-number') + .find('input') + .should('have.value', ''); + cy.dataCy('form-add-subsidiary-city') + .find('input') + .should('have.value', ''); + cy.dataCy('form-add-subsidiary-zip') + .find('input') + .should('have.value', ''); + }, + ); + }, + ); + }); }); context('organization company selected', () => { diff --git a/src/components/__tests__/FormSelectOrganization.cy.js b/src/components/__tests__/FormSelectOrganization.cy.js index 4be4adb57..82bb93a05 100644 --- a/src/components/__tests__/FormSelectOrganization.cy.js +++ b/src/components/__tests__/FormSelectOrganization.cy.js @@ -1,8 +1,11 @@ +import { computed } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; import FormSelectOrganization from 'components/form/FormSelectOrganization.vue'; import { i18n } from '../../boot/i18n'; import { OrganizationType } from 'src/components/types/Organization'; import { useRegisterChallengeStore } from 'src/stores/registerChallenge'; +import { interceptOrganizationsApi } from '../../../test/cypress/support/commonTests'; +import { rideToWorkByBikeConfig } from 'src/boot/global_vars'; // selectors const selectorFormFieldCompanyAddress = 'form-company-address'; @@ -10,13 +13,34 @@ const selectorFormFieldSelectTable = 'form-select-table-company'; const selectorFormSelectOrganization = 'form-select-organization'; describe('', () => { + let organizationId; + it('has translation for all strings', () => { cy.testLanguageStringsInContext([], 'index.component', i18n); }); + before(() => { + setActivePinia(createPinia()); + // set common organizationId from fixture + cy.fixture('formFieldCompanyCreate').then( + (formFieldCompanyCreateResponse) => { + organizationId = formFieldCompanyCreateResponse.id; + }, + ); + }); + context('desktop', () => { beforeEach(() => { - setActivePinia(createPinia()); + cy.interceptSubsidiariesGetApi( + rideToWorkByBikeConfig, + i18n, + organizationId, + ); + interceptOrganizationsApi( + rideToWorkByBikeConfig, + i18n, + OrganizationType.company, + ); cy.mount(FormSelectOrganization, { props: {}, }); @@ -28,7 +52,16 @@ describe('', () => { context('mobile', () => { beforeEach(() => { - setActivePinia(createPinia()); + cy.interceptSubsidiariesGetApi( + rideToWorkByBikeConfig, + i18n, + organizationId, + ); + interceptOrganizationsApi( + rideToWorkByBikeConfig, + i18n, + OrganizationType.company, + ); cy.mount(FormSelectOrganization, { props: {}, }); @@ -37,6 +70,102 @@ describe('', () => { coreTests(); }); + + context('create new organization and subsidiary', () => { + beforeEach(() => { + cy.interceptCitiesGetApi(rideToWorkByBikeConfig, i18n); + interceptOrganizationsApi( + rideToWorkByBikeConfig, + i18n, + OrganizationType.company, + ); + cy.interceptSubsidiaryPostApi( + rideToWorkByBikeConfig, + i18n, + organizationId, + ); + cy.mount(FormSelectOrganization, { + props: {}, + }); + cy.viewport('iphone-6'); + }); + + it('allows to create a new organization and subsidiary', () => { + cy.fixture('formFieldCompany').then((formFieldCompanyResponse) => { + cy.fixture('apiPostSubsidiaryRequest').then( + (apiPostSubsidiaryRequest) => { + cy.fixture('apiPostSubsidiaryResponse').then( + (apiPostSubsidiaryResponse) => { + cy.fixture('formFieldCompanyCreateRequest').then( + (formFieldCompanyCreateRequest) => { + cy.fixture('formFieldCompanyCreate').then( + (formFieldCompanyCreateResponse) => { + cy.wrap(useRegisterChallengeStore()).then((store) => { + store.setOrganizationType(OrganizationType.company); + const organizationId = computed( + () => store.getOrganizationId, + ); + const subsidiaryId = computed( + () => store.getSubsidiaryId, + ); + // open dialog form + cy.dataCy('button-add-option').click(); + // verify that dialog is visible + cy.dataCy('dialog-add-option').should('be.visible'); + cy.dataCy('dialog-add-option') + .find('h3') + .should('be.visible') + .and( + 'contain', + i18n.global.t('form.company.titleAddCompany'), + ); + // fill in the form + cy.fillOrganizationSubsidiaryForm( + formFieldCompanyCreateRequest, + apiPostSubsidiaryRequest, + ); + // submit the form + cy.dataCy('dialog-button-submit').click(); + // check that POST organization API was called + cy.waitForOrganizationPostApi(); + // check that POST subsidiary API was called + cy.waitForSubsidiaryPostApi(); + // check that a new organization was added to the list + cy.dataCy('form-select-table-options') + .find('.q-radio__label') + .should( + 'have.length', + formFieldCompanyResponse.count + 1, + ); + cy.dataCy('form-select-table-options') + .find('.q-radio__label') + .contains(formFieldCompanyCreateRequest.name) + .should('exist'); + // check that organizationId was saved in store + cy.wrap(organizationId) + .its('value') + .should('equal', formFieldCompanyCreateResponse.id); + // check that subsidiaryId was saved in store + cy.wrap(subsidiaryId) + .its('value') + .should('equal', apiPostSubsidiaryResponse.id); + // check that the subsidary (address) input has correct value + cy.dataCy('form-company-address').should( + 'contain', + apiPostSubsidiaryRequest.address.street, + ); + }); + }, + ); + }, + ); + }, + ); + }, + ); + }); + }); + }); }); function coreTests() { diff --git a/src/components/form/FormAddCompany.vue b/src/components/form/FormAddCompany.vue index 8006876a6..66e21879b 100644 --- a/src/components/form/FormAddCompany.vue +++ b/src/components/form/FormAddCompany.vue @@ -154,7 +154,7 @@ export default defineComponent({ {{ $t('form.company.textSubsidiaryAddress') }}

- + store.isLoadingSubsidiaries); + const subsidiaries = computed(() => store.subsidiaries); + const { isLoading: isLoadingCreateSubsidiary, createSubsidiary } = useApiPostSubsidiary(logger); const isLoading = computed( () => isLoadingSubsidiaries.value || isLoadingCreateSubsidiary.value, ); - - const store = useRegisterChallengeStore(); /** * If organization ID is set, load subsidiaries. * This ensures, that options are loaded on page refresh. @@ -105,7 +102,7 @@ export default defineComponent({ onMounted(async () => { if (store.getOrganizationId) { logger?.info('Loading subsidiaries.'); - await loadSubsidiaries(store.getOrganizationId); + await store.loadSubsidiariesToStore(logger); } else { logger?.debug( `Organization was not selected <${store.getOrganizationId}>,` + @@ -113,28 +110,9 @@ export default defineComponent({ ); } }); - /** - * Watch for organization ID changes. - * This clears the subsidiary options and state after organization change. - * Then loads subsidiaries for the new organization. - * Should not be triggered on mounted not to clear saved subsidiary ID. - */ - watch( - () => store.getOrganizationId, - (newValue) => { - logger?.debug( - `Register challenge store organization ID updated to <${newValue}>.`, - ); - subsidiaryId.value = null; - if (newValue) { - logger?.info('Loading subsidiaries.'); - loadSubsidiaries(newValue); - } - }, - ); const options = computed(() => - subsidiaries.value?.map((subsidiary) => ({ + store.getSubsidiaries?.map((subsidiary) => ({ label: getAddressString(subsidiary.address), value: subsidiary.id, })), diff --git a/src/components/form/FormFieldSelectTable.vue b/src/components/form/FormFieldSelectTable.vue index b53e45a1b..215707bcf 100644 --- a/src/components/form/FormFieldSelectTable.vue +++ b/src/components/form/FormFieldSelectTable.vue @@ -57,6 +57,8 @@ import { useOrganizations } from '../../composables/useOrganizations'; import { useSelectTable } from '../../composables/useSelectTable'; import { useValidation } from '../../composables/useValidation'; import { useApiPostTeam } from '../../composables/useApiPostTeam'; +import { useApiPostOrganization } from '../../composables/useApiPostOrganization'; +import { useApiPostSubsidiary } from '../../composables/useApiPostSubsidiary'; // enums import { OrganizationType, OrganizationLevel } from '../types/Organization'; @@ -72,6 +74,23 @@ import { FormSelectTableOption, FormTeamFields, } from '../types/Form'; +import type { OrganizationSubsidiary } from '../types/Organization'; + +// utils +import { deepObjectWithSimplePropsCopy } from '../../utils'; + +const emptyFormCompanyFields: FormCompanyFields = { + name: '', + vatId: '', + address: { + street: '', + houseNumber: '', + city: '', + zip: '', + cityChallenge: null, + department: '', + }, +}; export default defineComponent({ name: 'FormFieldSelectTable', @@ -110,21 +129,12 @@ export default defineComponent({ // user input for filtering const query = ref(''); const formRef = ref(null); - const organizationNew = ref({ - name: '', - vatId: '', - address: { - street: '', - houseNumber: '', - city: '', - zip: '', - cityChallenge: null, - department: '', - }, - }); - const teamNew = ref({ - name: '', - }); + const organizationNew = ref( + deepObjectWithSimplePropsCopy( + emptyFormCompanyFields, + ) as FormCompanyFields, + ); + const teamNew = ref({ name: '' }); const selectOrganizationRef = ref(null); /** @@ -159,14 +169,6 @@ export default defineComponent({ // controls dialog visibility const isDialogOpen = ref(false); - // close dialog - const onClose = (): void => { - if (formRef.value) { - formRef.value.reset(); - } - isDialogOpen.value = false; - }; - /** * Validates the form. * If form is valid it submits the data. @@ -187,17 +189,110 @@ export default defineComponent({ }; const { createTeam } = useApiPostTeam(logger); + const { isLoading: isLoadingCreateOrganization, createOrganization } = + useApiPostOrganization(logger); + const { isLoading: isLoadingCreateSubsidiary, createSubsidiary } = + useApiPostSubsidiary(logger); + const isLoading = computed( + () => + isLoadingCreateOrganization.value || isLoadingCreateSubsidiary.value, + ); + const registerChallengeStore = useRegisterChallengeStore(); + const subsidiaryId = computed({ + get: (): number | null => registerChallengeStore.getSubsidiaryId, + set: (value: number | null) => + registerChallengeStore.setSubsidiaryId(value), + }); + + /** + * Triggered by clicking on an option. + * If organization option was changed, resets subsidiary ID and reloads + * subsidiaries. + * @param {number | null} value - ID of the selected option. + */ + const onChangeOption = (value: number | null): void => { + if (props.organizationLevel === OrganizationLevel.organization) { + logger?.debug(`Organizations option changed to <${value}>.`); + logger?.info('Resetting subsidiary ID to null.'); + registerChallengeStore.setSubsidiaryId(null); + logger?.info('Reloading subsidiaries data from the API.'); + registerChallengeStore.loadSubsidiariesToStore(logger); + } + }; /** * Submit dialog form based on organization level - * If `company`, create a new company + * If `company`, create a new company (and subsidiary) * If `team`, create a new team * @returns {Promise} */ const submitDialogForm = async (): Promise => { if (props.organizationLevel === OrganizationLevel.organization) { - // TODO: Create a new company + // create organization + if (!props.organizationType) { + logger?.info('No organization type provided.'); + return; + } + logger?.info('Create new organization.'); + const organizationData = await createOrganization( + organizationNew.value.name, + organizationNew.value.vatId, + props.organizationType, + ); + + if (!organizationData?.id) { + logger?.error('New organization ID not found.'); + return; + } + logger?.debug( + `New organization was created with ID <${organizationData.id}> and name <${organizationData.name}>.`, + ); + + logger?.debug( + `Updating organization model ID from <${inputValue.value}> to <${organizationData.id}>.`, + ); + // set organization ID in store + inputValue.value = organizationData.id; + // emit event to append data to organization options + emit('create:option', organizationData); + + // create subsidiary + logger?.info('Create new subsidiary.'); + const subsidiaryData = await createSubsidiary( + organizationData.id, + organizationNew.value.address, + ); + if (subsidiaryData?.id) { + logger?.debug( + `New subsidiary was created with data <${JSON.stringify(subsidiaryData, null, 2)}>.`, + ); + // set subsidiary ID in store + logger?.debug( + `Updating subsidiary ID from <${subsidiaryId.value}> to <${subsidiaryData.id}>`, + ); + registerChallengeStore.setSubsidiaryId(subsidiaryData.id); + logger?.debug(`Subsidiary ID model set to <${subsidiaryId.value}>.`); + // create a new subsidiary array + const newSubsidiary: OrganizationSubsidiary = { + id: subsidiaryData.id, + address: { + street: subsidiaryData.street, + houseNumber: subsidiaryData.houseNumber, + city: subsidiaryData.city, + zip: subsidiaryData.zip, + cityChallenge: subsidiaryData.cityChallenge, + department: subsidiaryData.department, + }, + teams: [], + }; + // set subsidiary options to array with new subsidiary + registerChallengeStore.setSubsidiaries([newSubsidiary]); + } else { + logger?.error('New subsidiary data not found.'); + } + // close dialog + onClose(); } else if (props.organizationLevel === OrganizationLevel.team) { logger?.info('Create team.'); const subsidiaryId = registerChallengeStore.getSubsidiaryId; @@ -213,8 +308,7 @@ export default defineComponent({ // emit `create:option` event emit('create:option', data); // close dialog - isDialogOpen.value = false; - logger?.info('Close add team modal dialog.'); + onClose(); // store data in v-model (emits to parent component) inputValue.value = data.id; logger?.debug(`New team model ID set to <${inputValue.value}>.`); @@ -222,6 +316,25 @@ export default defineComponent({ } }; + // close dialog + const onClose = (): void => { + if (formRef.value) { + formRef.value.reset(); + } + // reset organizationNew and teamNew + organizationNew.value = deepObjectWithSimplePropsCopy( + emptyFormCompanyFields, + ) as FormCompanyFields; + teamNew.value = { name: '' }; + logger?.debug( + `Reset new organization model value <${organizationNew.value}>.`, + ); + logger?.debug(`Reset new team model value <${teamNew.value}>.`); + // close dialog + isDialogOpen.value = false; + logger?.info('Close add option modal dialog.'); + }; + const { getOrganizationLabels } = useOrganizations(); const { getSelectTableLabels } = useSelectTable(); @@ -278,8 +391,10 @@ export default defineComponent({ teamNew, titleDialog, isFilled, + isLoading, onClose, onSubmit, + onChangeOption, OrganizationType, OrganizationLevel, selectOrganizationRef, @@ -351,6 +466,7 @@ export default defineComponent({ :label="item.label" color="primary" data-cy="form-select-table-option" + @update:model-value="onChangeOption" />