diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SeedlotSourceDto.java b/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SeedlotSourceDto.java new file mode 100644 index 000000000..9e822eb02 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SeedlotSourceDto.java @@ -0,0 +1,17 @@ +package ca.bc.gov.backendstartapi.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This general record is used for simple data object with only a code and description to be + * consumed by endpoints. + */ +@Schema(description = """ + A DTO for seedlot sources. + """) +public record SeedlotSourceDto( + @Schema(description = "The Code that represent a data object", example = "1") String code, + @Schema(description = "The description/value of the data object", example = "Squirrel cache") + String description, + @Schema(description = "Indicate whether this option is default", example = "True") + Boolean isDefault) {} diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpoint.java b/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpoint.java index 958388edd..9da305476 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpoint.java @@ -1,6 +1,6 @@ package ca.bc.gov.backendstartapi.endpoint; -import ca.bc.gov.backendstartapi.dto.CodeDescriptionDto; +import ca.bc.gov.backendstartapi.dto.SeedlotSourceDto; import ca.bc.gov.backendstartapi.entity.SeedlotSourceEntity; import ca.bc.gov.backendstartapi.service.SeedlotSourceService; import io.swagger.v3.oas.annotations.Operation; @@ -63,14 +63,21 @@ public class SeedlotSourceEndpoint { @Schema( type = "string", description = "Name of a seedlot source", - example = "Custom Lot")) + example = "Custom Lot")), + @SchemaProperty( + name = "isDefault", + schema = + @Schema( + type = "boolean", + description = "Indicates if this option is default", + example = "true")) })), @ApiResponse( responseCode = "401", description = "Access token is missing or invalid", content = @Content(schema = @Schema(implementation = Void.class))) }) - public List getAllSeedlotSource() { + public List getAllSeedlotSource() { return seedlotSourceService.getAllSeedlotSource(); } } diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/entity/SeedlotSourceEntity.java b/backend/src/main/java/ca/bc/gov/backendstartapi/entity/SeedlotSourceEntity.java index 2cb465f25..789324b81 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/entity/SeedlotSourceEntity.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/entity/SeedlotSourceEntity.java @@ -22,9 +22,19 @@ public class SeedlotSourceEntity extends CodeDescriptionEntity { @Column(name = "seedlot_source_code", length = 3) private String seedlotSourceCode; + @Column(name = "default_source_ind", nullable = true) + private Boolean isDefault; + + /** + * Constructor for SeedlotSourceEntity. + */ public SeedlotSourceEntity( - String seedlotSourceCode, String description, EffectiveDateRange effectiveDateRange) { + String seedlotSourceCode, + String description, + EffectiveDateRange effectiveDateRange, + Boolean isDefault) { super(description, effectiveDateRange); this.seedlotSourceCode = seedlotSourceCode; + this.isDefault = isDefault; } } diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/service/SeedlotSourceService.java b/backend/src/main/java/ca/bc/gov/backendstartapi/service/SeedlotSourceService.java index 15beca7f2..37a97f818 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/service/SeedlotSourceService.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/service/SeedlotSourceService.java @@ -1,6 +1,6 @@ package ca.bc.gov.backendstartapi.service; -import ca.bc.gov.backendstartapi.dto.CodeDescriptionDto; +import ca.bc.gov.backendstartapi.dto.SeedlotSourceDto; import ca.bc.gov.backendstartapi.repository.SeedlotSourceRepository; import java.util.ArrayList; import java.util.List; @@ -18,15 +18,18 @@ public SeedlotSourceService(SeedlotSourceRepository seedlotSourceRepository) { } /** Fetch all valid seedlot source from the repository. */ - public List getAllSeedlotSource() { + public List getAllSeedlotSource() { log.info("Fetching all seedlot source"); - List resultList = new ArrayList<>(); + List resultList = new ArrayList<>(); seedlotSourceRepository.findAll().stream() .filter(method -> method.isValid()) .forEach( method -> { - CodeDescriptionDto methodToAdd = - new CodeDescriptionDto(method.getSeedlotSourceCode(), method.getDescription()); + SeedlotSourceDto methodToAdd = + new SeedlotSourceDto( + method.getSeedlotSourceCode(), + method.getDescription(), + method.getIsDefault()); resultList.add(methodToAdd); }); diff --git a/backend/src/main/resources/db/migration/V20__alter_seedlot_source.sql b/backend/src/main/resources/db/migration/V20__alter_seedlot_source.sql new file mode 100644 index 000000000..ccafd9500 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__alter_seedlot_source.sql @@ -0,0 +1,23 @@ +alter table + spar.seedlot_source_list +add + default_source_ind boolean default null; + +alter table + spar.seedlot_source_list +add + constraint only_one_default_source unique (default_source_ind); + +update + spar.seedlot_source_list +set + description = 'Custom Seedlot' +where + seedlot_source_code = 'CUS'; + +update + spar.seedlot_source_list +set + default_source_ind = true +where + seedlot_source_code = 'TPT'; diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpointTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpointTest.java index 9433821b4..7fa41349e 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpointTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotSourceEndpointTest.java @@ -7,7 +7,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import ca.bc.gov.backendstartapi.dto.CodeDescriptionDto; +import ca.bc.gov.backendstartapi.dto.SeedlotSourceDto; import ca.bc.gov.backendstartapi.service.SeedlotSourceService; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -37,9 +37,9 @@ class SeedlotSourceEndpointTest { @WithMockUser(roles = "user_read") void getAllSeedlotSource() throws Exception { - CodeDescriptionDto firstMethod = new CodeDescriptionDto("CUS", "Custom Lot"); - CodeDescriptionDto secondMethod = new CodeDescriptionDto("TPT", "Tested Parent Trees"); - CodeDescriptionDto thirdMethod = new CodeDescriptionDto("UPT", "Untested Parent Trees"); + SeedlotSourceDto firstMethod = new SeedlotSourceDto("CUS", "Custom Lot", null); + SeedlotSourceDto secondMethod = new SeedlotSourceDto("TPT", "Tested Parent Trees", true); + SeedlotSourceDto thirdMethod = new SeedlotSourceDto("UPT", "Untested Parent Trees", null); when(seedlotSourceService.getAllSeedlotSource()) .thenReturn(List.of(firstMethod, secondMethod, thirdMethod)); diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/repository/seedlot/SeedlotEntityRelationalTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/repository/seedlot/SeedlotEntityRelationalTest.java index 1a9d15b5b..5b329b337 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/repository/seedlot/SeedlotEntityRelationalTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/repository/seedlot/SeedlotEntityRelationalTest.java @@ -45,8 +45,7 @@ protected Seedlot createSeedlot(String id) { new GeneticWorthEntity("AD", "Animal browse resistance (deer)", effectiveDateRange); geneticWorthRepository.saveAndFlush(geneticWorth); - - var seedlotSource = new SeedlotSourceEntity("CUS", "Custom Lot", effectiveDateRange); + var seedlotSource = new SeedlotSourceEntity("CUS", "Custom Lot", effectiveDateRange, null); seedlotSourceRepository.saveAndFlush(seedlotSource); var seedlot = new Seedlot(id); diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotServiceTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotServiceTest.java index 0c000c197..22edbe5c7 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotServiceTest.java @@ -75,7 +75,7 @@ void createSeedlotTest_happyPath_shouldSucceed() { when(seedlotStatusRepository.findById("PND")).thenReturn(Optional.of(statusEntity)); SeedlotSourceEntity sourceEntity = - new SeedlotSourceEntity("TPT", "Tested Parent Trees", DATE_RANGE); + new SeedlotSourceEntity("TPT", "Tested Parent Trees", DATE_RANGE, null); when(seedlotSourceRepository.findById("TPT")).thenReturn(Optional.of(sourceEntity)); Seedlot seedlot = new Seedlot("63000"); diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotSourceServiceTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotSourceServiceTest.java index 53279d73d..15ef5710f 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotSourceServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SeedlotSourceServiceTest.java @@ -3,6 +3,7 @@ import static org.mockito.Mockito.when; import ca.bc.gov.backendstartapi.dto.CodeDescriptionDto; +import ca.bc.gov.backendstartapi.dto.SeedlotSourceDto; import ca.bc.gov.backendstartapi.entity.SeedlotSourceEntity; import ca.bc.gov.backendstartapi.entity.embeddable.EffectiveDateRange; import ca.bc.gov.backendstartapi.repository.SeedlotSourceRepository; @@ -39,15 +40,15 @@ void getAllSeedlotSourceServiceTest() { var expiredDateRange = new EffectiveDateRange(effectiveDate, expiredDate); SeedlotSourceEntity firstEntity = - new SeedlotSourceEntity("CUS", "Custom Lot", effectiveDateRange); + new SeedlotSourceEntity("CUS", "Custom Lot", effectiveDateRange, null); seedlotSourceRepository.saveAndFlush(firstEntity); SeedlotSourceEntity secondEntity = - new SeedlotSourceEntity("TPT", "Tested Parent Trees", effectiveDateRange); + new SeedlotSourceEntity("TPT", "Tested Parent Trees", effectiveDateRange, true); seedlotSourceRepository.saveAndFlush(secondEntity); // This entity should not appear in the result list SeedlotSourceEntity expiredEntity = - new SeedlotSourceEntity("V", "V for Vendetta", expiredDateRange); + new SeedlotSourceEntity("V", "V for Vendetta", expiredDateRange, null); seedlotSourceRepository.saveAndFlush(expiredEntity); List testEntityList = @@ -74,7 +75,7 @@ void getAllSeedlotSourceServiceTest() { } }; - List resultList = seedlotSourceService.getAllSeedlotSource(); + List resultList = seedlotSourceService.getAllSeedlotSource(); Assertions.assertEquals(testEntityList.size() - 1, resultList.size()); Assertions.assertEquals(testDtoList.size(), resultList.size()); diff --git a/frontend/cypress/e2e/smoke-test/create-a-class-seedlot.cy.ts b/frontend/cypress/e2e/smoke-test/create-a-class-seedlot.cy.ts index 26adf2d8c..b027c9e97 100644 --- a/frontend/cypress/e2e/smoke-test/create-a-class-seedlot.cy.ts +++ b/frontend/cypress/e2e/smoke-test/create-a-class-seedlot.cy.ts @@ -16,6 +16,29 @@ describe('Create A Class Seedlot', () => { cy.login(); cy.visit('/seedlots'); cy.url().should('contains', '/seedlots'); + + cy.intercept( + { + method: 'GET', + url: '**/api/forest-clients/**' + }, + { + statusCode: 200 + } + ).as('verifyLocationCode'); + + cy.intercept( + { + method: 'POST', + url: '**/api/seedlots' + }, + { + statusCode: 201, + body: { + seedlotNumber: '654321' + } + } + ).as('submitSeedlot'); }); it('should register a Class A Seedlot', () => { @@ -55,33 +78,33 @@ describe('Create A Class Seedlot', () => { .scrollIntoView() .click(); // Check checkbox behavior when Tested parent tree selected - cy.get('#tested-radio') + cy.get('#seedlot-source-radio-btn-tpt') .should('be.checked'); - cy.get('#untested-radio') + cy.get('#seedlot-source-radio-btn-upt') .should('not.be.checked'); - cy.get('#custom-radio') + cy.get('#seedlot-source-radio-btn-cus') .should('not.be.checked'); // Check checkbox behavior when Custom seedlot selected - cy.get('#custom-radio') + cy.get('#seedlot-source-radio-btn-cus') .siblings('.bx--radio-button__label') .find('.bx--radio-button__appearance') .click(); - cy.get('#tested-radio') + cy.get('#seedlot-source-radio-btn-tpt') .should('not.be.checked'); - cy.get('#untested-radio') + cy.get('#seedlot-source-radio-btn-upt') .should('not.be.checked'); - cy.get('#custom-radio') + cy.get('#seedlot-source-radio-btn-cus') .should('be.checked'); // Check checkbox behavior when Untested parent tree selected - cy.get('#untested-radio') + cy.get('#seedlot-source-radio-btn-upt') .siblings('.bx--radio-button__label') .find('.bx--radio-button__appearance') .click(); - cy.get('#tested-radio') + cy.get('#seedlot-source-radio-btn-tpt') .should('not.be.checked'); - cy.get('#untested-radio') + cy.get('#seedlot-source-radio-btn-upt') .should('be.checked'); - cy.get('#custom-radio') + cy.get('#seedlot-source-radio-btn-cus') .should('not.be.checked'); // To be registered? should be checked by default cy.get('#registered-tree-seed-center') @@ -93,13 +116,8 @@ describe('Create A Class Seedlot', () => { cy.get('.save-button') .find('button') .click(); - // To-do Validate seedlot id - cy.get('.scf-info-container') - .find('h2') - .contains(/^Your A class seedlot [0-9]/); - cy.contains('button', "Go back to seedlot's main screen") - .click(); - cy.get('.bx--data-table-content').contains(data.seedlotInformation.species); + cy.url().should('contains', '/creation-success'); + cy.get('h1').contains('654321'); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index af0a3ea2a..daca49a35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "react-hash-string": "^1.0.0", "react-router-dom": "^6.3.0", "react-test-renderer": "^18.2.0", + "react-toastify": "^9.1.3", "sass": "1.64.2", "ts-jest": "^29.1.0", "typescript": "^5.1.3", @@ -6128,6 +6129,14 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -13845,6 +13854,18 @@ "react": "^18.2.0" } }, + "node_modules/react-toastify": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", + "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 15353c133..1538c59f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "react-hash-string": "^1.0.0", "react-router-dom": "^6.3.0", "react-test-renderer": "^18.2.0", + "react-toastify": "^9.1.3", "sass": "1.64.2", "ts-jest": "^29.1.0", "typescript": "^5.1.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27a483bf0..1b75ebaf4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,9 @@ import React from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; import './styles/custom.scss'; import ProtectedRoute from './routes/ProtectedRoute'; @@ -29,78 +31,82 @@ const App: React.FC = () => { const { signed } = useAuth(); return ( - - - } /> - }> - } /> - } /> + <> + + + + } /> + }> + } /> + } /> - - - - )} - /> + + + + )} + /> - - - - )} - /> + + + + )} + /> - - - - )} - /> + + + + )} + /> - - - - )} - /> + + + + )} + /> - - - - )} - /> + + + + )} + /> - - - - )} - /> + + + + )} + /> + + + + + )} + /> + + + + - - - - )} - /> - - - ); }; diff --git a/frontend/src/api-service/ApiConfig.ts b/frontend/src/api-service/ApiConfig.ts index 906486c7b..5c6c40300 100644 --- a/frontend/src/api-service/ApiConfig.ts +++ b/frontend/src/api-service/ApiConfig.ts @@ -53,6 +53,10 @@ const ApiConfig = { geneticWorth: `${serverHost}/api/genetic-worth/calculate-all`, + seedlotSources: `${serverHost}/api/seedlot-sources`, + + seedlots: `${serverHost}/api/seedlots`, + /** * ORACLE API */ diff --git a/frontend/src/api-service/SeedlotSourcesAPI.ts b/frontend/src/api-service/SeedlotSourcesAPI.ts new file mode 100644 index 000000000..04f5c2022 --- /dev/null +++ b/frontend/src/api-service/SeedlotSourcesAPI.ts @@ -0,0 +1,15 @@ +import SeedlotSourceType from '../types/SeedlotSourceType'; +import ApiConfig from './ApiConfig'; +import api from './api'; + +const getSeedlotSources = () => { + const url = ApiConfig.seedlotSources; + // The sorting is to put the tested parent tree at the top, custom at the bottom. + return api.get(url).then((res) => res.data.sort((a: SeedlotSourceType) => { + if (a.code.startsWith('C')) return 1; + if (a.code.startsWith('T')) return -1; + return 0; + })); +}; + +export default getSeedlotSources; diff --git a/frontend/src/api-service/applicantAgenciesAPI.ts b/frontend/src/api-service/applicantAgenciesAPI.ts index 514d4eb73..ca33331a9 100644 --- a/frontend/src/api-service/applicantAgenciesAPI.ts +++ b/frontend/src/api-service/applicantAgenciesAPI.ts @@ -1,24 +1,30 @@ import ApplicantAgencyType from '../types/ApplicantAgencyType'; import ApplicantAgenciesItems from '../mock-server/fixtures/ApplicantAgenciesItems'; +import MultiOptionsObj from '../types/MultiOptionsObject'; -const getApplicantAgenciesOptions = (): Array => { - const options: string[] = []; +const getApplicantAgenciesOptions = (): MultiOptionsObj[] => { + const options: MultiOptionsObj[] = []; ApplicantAgenciesItems.sort( (a: ApplicantAgencyType, b: ApplicantAgencyType) => (a.clientName < b.clientName ? -1 : 1) ); ApplicantAgenciesItems.forEach((agency: ApplicantAgencyType) => { - let correctName = agency.clientName + let clientName = agency.clientName .toLowerCase() .split(' ') .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); - if (correctName.indexOf('-') > -1) { - correctName = correctName + if (clientName.indexOf('-') > -1) { + clientName = clientName .split('-') .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join('-'); } - options.push(`${agency.clientNumber} - ${correctName} - ${agency.acronym}`); + const newAgency: MultiOptionsObj = { + code: agency.clientNumber, + label: `${agency.clientNumber} - ${clientName} - ${agency.acronym}`, + description: '' + }; + options.push(newAgency); }); return options; }; diff --git a/frontend/src/api-service/seedlotAPI.ts b/frontend/src/api-service/seedlotAPI.ts index abea1d29b..a2b44774e 100644 --- a/frontend/src/api-service/seedlotAPI.ts +++ b/frontend/src/api-service/seedlotAPI.ts @@ -1,3 +1,4 @@ +import { SeedlotRegPayloadType } from '../types/SeedlotRegistrationTypes'; import ApiConfig from './ApiConfig'; import api from './api'; @@ -6,6 +7,11 @@ export const getSeedlotInfo = (seedlotNumber: string) => { return api.get(url).then((res) => res.data); }; +export const postSeedlot = (payload: SeedlotRegPayloadType) => { + const url = ApiConfig.seedlots; + return api.post(url, payload); +}; + export const postFile = ( file: File, isMixFile: boolean diff --git a/frontend/src/components/ApplicantInformationForm/constants.ts b/frontend/src/components/ApplicantInformationForm/constants.ts index be82d6527..a1b100d54 100644 --- a/frontend/src/components/ApplicantInformationForm/constants.ts +++ b/frontend/src/components/ApplicantInformationForm/constants.ts @@ -1,3 +1,4 @@ +import { SeedlotRegFormType } from '../../types/SeedlotRegistrationTypes'; import ComboBoxPropsType from './definitions'; export const pageTexts = { @@ -5,24 +6,67 @@ export const pageTexts = { helperTextDisabled: 'Please select an Applicant Agency before setting the agency number', helperTextEnabled: '2-digit code that identifies the address of operated office or division', invalidLocationValue: 'Please enter a valid value between 0 and 99', - invalidLocationForSelectedAgency: 'This agency number is not valid for the selected agency, please enter a valid one or change the agency' + invalidLocationForSelectedAgency: 'This agency number is not valid for the selected agency, please enter a valid one or change the agency', + cannotVerify: 'Cannot verify the location code at the moment' } }; -export const applicantAgencyFieldProps: ComboBoxPropsType = { - id: 'applicant-info-combobox', - className: '', +export const applicantAgencyFieldConfig: ComboBoxPropsType = { placeholder: 'Select an agency...', titleText: 'Applicant agency name', - invalidText: '', + invalidText: 'Please select an agency', helperText: 'You can enter your agency number, name or acronym' }; -export const speciesFieldProps: ComboBoxPropsType = { - id: 'seedlot-species-combobox', - className: 'applicant-info-combobox-species', +export const speciesFieldConfig: ComboBoxPropsType = { placeholder: 'Enter or choose an species for the seedlot', titleText: 'Seedlot species', invalidText: 'Please select a species', helperText: '' }; + +export const InitialSeedlotFormData: SeedlotRegFormType = { + client: { + id: 'applicant-info-combobox', + isInvalid: false, + value: { + code: '', + label: '', + description: '' + } + }, + locationCode: { + id: 'agency-number-input', + isInvalid: false, + value: '' + }, + email: { + id: 'appliccant-email-input', + isInvalid: false, + value: '' + }, + species: { + id: 'seedlot-species-combobox', + isInvalid: false, + value: { + code: '', + label: '', + description: '' + } + }, + sourceCode: { + id: '', + isInvalid: false, + value: '' + }, + willBeRegistered: { + id: '', + isInvalid: false, + value: true + }, + isBcSource: { + id: '', + isInvalid: false, + value: true + } +}; diff --git a/frontend/src/components/ApplicantInformationForm/definitions.ts b/frontend/src/components/ApplicantInformationForm/definitions.ts index e66b248b9..d3d223c32 100644 --- a/frontend/src/components/ApplicantInformationForm/definitions.ts +++ b/frontend/src/components/ApplicantInformationForm/definitions.ts @@ -1,6 +1,4 @@ type ComboBoxPropsType = { - id: string; - className: string; placeholder: string; titleText: string; invalidText: string; diff --git a/frontend/src/components/ApplicantInformationForm/index.tsx b/frontend/src/components/ApplicantInformationForm/index.tsx index 5259125a1..5ffeefe47 100644 --- a/frontend/src/components/ApplicantInformationForm/index.tsx +++ b/frontend/src/components/ApplicantInformationForm/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, UseQueryResult, useMutation } from '@tanstack/react-query'; @@ -13,84 +13,81 @@ import { Button, ComboBox, TextInputSkeleton, - InlineLoading + InlineLoading, + RadioButtonSkeleton, + ActionableNotification } from '@carbon/react'; import { DocumentAdd } from '@carbon/icons-react'; import validator from 'validator'; +import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; import Subtitle from '../Subtitle'; import InputErrorText from '../InputErrorText'; -import getForestClientNumber from '../../utils/StringUtils'; +import { ErrToastOption } from '../../config/ToastifyConfig'; import { FilterObj, filterInput } from '../../utils/filterUtils'; +import focusById from '../../utils/FocusUtils'; +import { THREE_HALF_HOURS, THREE_HOURS } from '../../config/TimeUnits'; -import SeedlotRegistrationObj from '../../types/SeedlotRegistrationObj'; +import { SeedlotRegFormType, SeedlotRegPayloadType } from '../../types/SeedlotRegistrationTypes'; +import SeedlotSourceType from '../../types/SeedlotSourceType'; import ComboBoxEvent from '../../types/ComboBoxEvent'; -import api from '../../api-service/api'; -import ApiConfig from '../../api-service/ApiConfig'; import getVegCodes from '../../api-service/vegetationCodeAPI'; import getApplicantAgenciesOptions from '../../api-service/applicantAgenciesAPI'; import getForestClientLocation from '../../api-service/forestClientsAPI'; +import getSeedlotSources from '../../api-service/SeedlotSourcesAPI'; +import { postSeedlot } from '../../api-service/seedlotAPI'; import { LOCATION_CODE_LIMIT } from '../../shared-constants/shared-constants'; import ComboBoxPropsType from './definitions'; import { - applicantAgencyFieldProps, - speciesFieldProps, - pageTexts + applicantAgencyFieldConfig, + speciesFieldConfig, + pageTexts, + InitialSeedlotFormData } from './constants'; +import { convertToPayload } from './utils'; import './styles.scss'; +import ErrorToast from '../Toast/ErrorToast'; const ApplicantInformationForm = () => { const navigate = useNavigate(); - const seedlotData: SeedlotRegistrationObj = { - seedlotNumber: 0, - applicant: { - name: '', - number: '', - email: '' - }, - species: { - label: '', - code: '', - description: '' - }, - source: 'tested', - registered: true, - collectedBC: true - }; - - const agencyInputRef = useRef(null); - const numberInputRef = useRef(null); - const emailInputRef = useRef(null); - const speciesInputRef = useRef(null); - - const [responseBody, setResponseBody] = useState(seedlotData); - const [isLocationCodeInvalid, setIsLocationCodeInvalid] = useState(false); - const [isEmailInvalid, setIsEmailInvalid] = useState(false); - const [isSpeciesInvalid, setIsSpeciesInvalid] = useState(false); - const [forestClientNumber, setForestClientNumber] = useState(''); + const [formData, setFormData] = useState(InitialSeedlotFormData); const [invalidLocationMessage, setInvalidLocationMessage] = useState(''); const [locationCodeHelper, setLocationCodeHelper] = useState( pageTexts.locCodeInput.helperTextDisabled ); + const setInputValidation = (inputName: keyof SeedlotRegFormType, isInvalid: boolean) => ( + setFormData((prevData) => ({ + ...prevData, + [inputName]: { + ...prevData[inputName], + isInvalid + } + })) + ); + const updateAfterLocValidation = (isInvalid: boolean) => { - setIsLocationCodeInvalid(isInvalid); + setInputValidation('locationCode', isInvalid); setLocationCodeHelper(pageTexts.locCodeInput.helperTextEnabled); }; const validateLocationCodeMutation = useMutation({ - mutationFn: (queryParams:string[]) => getForestClientLocation( - queryParams[0], - queryParams[1] + mutationFn: (queryParams: string[]) => getForestClientLocation( + queryParams[0], // Client Number + queryParams[1] // Location Code ), - onError: () => { - setInvalidLocationMessage(pageTexts.locCodeInput.invalidLocationForSelectedAgency); + onError: (err: AxiosError) => { + const errMsg = err.code === 'ERR_BAD_REQUEST' + ? pageTexts.locCodeInput.invalidLocationForSelectedAgency + : pageTexts.locCodeInput.cannotVerify; + setInvalidLocationMessage(errMsg); updateAfterLocValidation(true); }, onSuccess: () => updateAfterLocValidation(false) @@ -103,188 +100,280 @@ const ApplicantInformationForm = () => { const vegCodeQuery = useQuery({ queryKey: ['vegetation-codes'], - queryFn: () => getVegCodes(true) + queryFn: () => getVegCodes(true), + staleTime: THREE_HOURS, // will not refetch for 3 hours + cacheTime: THREE_HALF_HOURS // data is cached 3.5 hours then deleted }); - const locationCodeChangeHandler = ( - event: React.ChangeEvent - ) => { - const { value } = event.target; - setResponseBody({ - ...responseBody, - applicant: { - ...responseBody.applicant, - number: (value.slice(0, LOCATION_CODE_LIMIT)) + const setDefaultSource = (sources: SeedlotSourceType[]) => { + sources.forEach((source) => { + if (source.isDefault) { + setFormData((prevData) => ({ + ...prevData, + sourceCode: { + ...prevData.sourceCode, + value: source.code + } + })); } }); }; - const validateLocationCode = () => { - let applicantNumber = responseBody.applicant.number; - const isInRange = validator.isInt(applicantNumber, { min: 0, max: 99 }); - - // Adding this check to add an extra 0 on the left, for cases where - // the user types values between 0 and 9 - if (isInRange && applicantNumber.length === 1) { - applicantNumber = applicantNumber.padStart(2, '0'); - setResponseBody({ - ...responseBody, - applicant: { - ...responseBody.applicant, - number: applicantNumber + const seedlotSourcesQuery = useQuery({ + queryKey: ['seedlot-sources'], + queryFn: () => getSeedlotSources(), + onSuccess: (sources) => setDefaultSource(sources), + staleTime: THREE_HOURS, + cacheTime: THREE_HALF_HOURS + }); + + /** + * Default value is only set once upon query success, when cache data is used + * we will need to set the default again here. + */ + useEffect(() => { + if (seedlotSourcesQuery.isSuccess && !formData.sourceCode.value) { + setDefaultSource(seedlotSourcesQuery.data); + } + }, [seedlotSourcesQuery.isFetched]); + + const handleLocationCodeBlur = (clientNumber: string, locationCode: string) => { + const isInRange = validator.isInt(locationCode, { min: 0, max: 99 }); + // Padding 0 in front of single digit code + const formattedCode = (isInRange && locationCode.length === 1) + ? locationCode.padStart(2, '0') + : locationCode; + + if (isInRange) { + setFormData((prevResBody) => ({ + ...prevResBody, + locationCode: { + ...prevResBody.locationCode, + value: formattedCode, + isInvalid: false } - }); + })); } if (!isInRange) { setInvalidLocationMessage(pageTexts.locCodeInput.invalidLocationValue); - setIsLocationCodeInvalid(true); return; } - if (forestClientNumber) { - validateLocationCodeMutation.mutate([forestClientNumber, applicantNumber]); - setIsLocationCodeInvalid(false); - setLocationCodeHelper(''); + if (clientNumber && locationCode) { + validateLocationCodeMutation.mutate([clientNumber, formattedCode]); } }; - const inputChangeHandlerApplicant = ( - event: React.ChangeEvent + /** + * Handle changes for location code. + */ + const handleLocationCode = ( + value: string ) => { - const { name, value } = event.target; - setResponseBody({ - ...responseBody, - applicant: { - ...responseBody.applicant, - [name]: value + setFormData((prevResBody) => ({ + ...prevResBody, + locationCode: { + ...prevResBody.locationCode, + value: value.slice(0, LOCATION_CODE_LIMIT) } - }); + })); }; - const comboBoxChangeHandler = (event: ComboBoxEvent, isApplicantAgency: boolean) => { + /** + * Handle combobox changes for agency and species. + */ + const handleComboBox = (event: ComboBoxEvent, isApplicantAgency: boolean) => { const { selectedItem } = event; - if (isApplicantAgency) { - setResponseBody({ - ...responseBody, - applicant: { - ...responseBody.applicant, - name: selectedItem, - number: selectedItem ? responseBody.applicant.number : '' - } - }); - if (!selectedItem) { - setIsLocationCodeInvalid(false); + const inputName: keyof SeedlotRegFormType = isApplicantAgency ? 'client' : 'species'; + const isInvalid = selectedItem === null; + setFormData((prevData) => ({ + ...prevData, + [inputName]: { + ...prevData[inputName], + value: selectedItem?.code ? selectedItem : { + code: '', + label: '', + description: '' + }, + isInvalid } - setForestClientNumber(selectedItem ? getForestClientNumber(selectedItem) : ''); - setLocationCodeHelper( - selectedItem - ? pageTexts.locCodeInput.helperTextEnabled - : pageTexts.locCodeInput.helperTextDisabled - ); - } else { - setResponseBody({ - ...responseBody, - species: selectedItem - }); + })); + + if (isApplicantAgency && selectedItem?.code && formData.locationCode.value) { + validateLocationCodeMutation.mutate([selectedItem.code, formData.locationCode.value]); } }; - const inputChangeHandlerRadio = (event: string) => { - const value = event; - setResponseBody({ - ...responseBody, - source: value - }); + const handleSource = (value: string) => { + setFormData((prevData) => ({ + ...prevData, + sourceCode: { + ...prevData.sourceCode, + value + } + })); }; - const inputChangeHandlerCheckboxes = (event: React.ChangeEvent) => { - const { name, checked } = event.target; - setResponseBody({ - ...responseBody, - [name]: checked - }); + const handleEmail = (value: string) => { + const isEmailInvalid = !validator.isEmail(value); + setFormData((prevData) => ({ + ...prevData, + email: { + ...prevData.email, + value, + isInvalid: isEmailInvalid + } + })); }; - const validateApplicantEmail = () => { - if (validator.isEmail(responseBody.applicant.email)) { - setIsEmailInvalid(false); - } else { - setIsEmailInvalid(true); - } + const handleCheckBox = (inputName: keyof SeedlotRegFormType, checked: boolean) => { + setFormData((prevData) => ({ + ...prevData, + [inputName]: { + ...prevData[inputName], + value: checked + } + })); }; - const validateAndSubmit = (event: React.FormEvent) => { - event.preventDefault(); + const seedlotMutation = useMutation({ + mutationFn: (payload: SeedlotRegPayloadType) => postSeedlot(payload), + onError: (err: AxiosError) => { + toast.error( + , + ErrToastOption + ); + }, + onSuccess: (res) => navigate({ + pathname: '/seedlots/creation-success', + search: `?seedlotNumber=${res.data.seedlotNumber}&seedlotClass=A` + }) + }); - if (isLocationCodeInvalid) { - numberInputRef.current?.focus(); - } else if (isEmailInvalid) { - emailInputRef.current?.focus(); - } else if (!responseBody.species.label) { - setIsSpeciesInvalid(true); - speciesInputRef.current?.focus(); - } else { - const url = ApiConfig.aClassSeedlot; - api.post(url, responseBody) - .then((response) => { - navigate(`/seedlots/successfully-created/${response.data.seedlotNumber}`); - }) - .catch((error) => { - // eslint-disable-next-line - console.error(`Error: ${error}`); - }); + const renderSources = () => { + if (seedlotSourcesQuery.isSuccess) { + return seedlotSourcesQuery.data.map((source: SeedlotSourceType) => ( + + )); } + return ; }; const displayCombobox = ( query: UseQueryResult, propsValues: ComboBoxPropsType, isApplicantComboBox = false - ) => { - const { status, fetchStatus, isSuccess } = query; - const fetchError = status === 'error'; - - if (fetchStatus === 'fetching') { - return ( + ) => ( + query.isFetching + ? ( - ); - } - return ( - - {/* For now the default selected item will not be set, + ) + : ( + + {/* For now the default selected item will not be set, we need the information from each user to set the correct one */} - filterInput({ item, inputValue }) + filterInput({ item, inputValue }) + } + placeholder={propsValues.placeholder} + titleText={propsValues.titleText} + onChange={(e: ComboBoxEvent) => handleComboBox(e, isApplicantComboBox)} + invalid={isApplicantComboBox ? formData.client.isInvalid : formData.species.isInvalid} + invalidText={propsValues.invalidText} + helperText={query.isError ? '' : propsValues.helperText} + disabled={query.isError} + /> + { + query.isError + ? + : null } - placeholder={propsValues.placeholder} - titleText={propsValues.titleText} - onChange={(e: ComboBoxEvent) => comboBoxChangeHandler(e, isApplicantComboBox)} - invalid={!isApplicantComboBox && isSpeciesInvalid} - invalidText={propsValues.invalidText} - helperText={fetchError ? '' : propsValues.helperText} - disabled={fetchError} - /> - { - fetchError - ? - : null - } - - ); + + ) + ); + + const validateAndSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + // Validate client + if (formData.client.isInvalid || !formData.client.value.code) { + setInputValidation('client', true); + focusById(formData.client.id); + return; + } + // Vaidate location code + if ( + formData.locationCode.isInvalid + || !formData.locationCode.value + || !validateLocationCodeMutation.isSuccess + ) { + setInputValidation('locationCode', true); + setInvalidLocationMessage(pageTexts.locCodeInput.invalidLocationValue); + focusById(formData.locationCode.id); + return; + } + // Validate email + if (formData.email.isInvalid || !formData.email.value) { + setInputValidation('email', true); + focusById(formData.email.id); + return; + } + // Validate species + if (formData.species.isInvalid || !formData.species.value.code) { + setInputValidation('species', true); + focusById(formData.species.id); + return; + } + // Source code, and the two booleans always have a default value so there's no need to check. + + // Submit Seedlot. + const payload = convertToPayload(formData); + seedlotMutation.mutate(payload); }; return (
+ { + seedlotMutation.isError + ? ( + + + false} + > + An error has occurred when trying to create your seedlot number. + Please try submiting it again later. + {' '} + {`${seedlotMutation.error.code}: ${seedlotMutation.error.message}`} + + + + ) + : null + }

Applicant agency

@@ -293,23 +382,30 @@ const ApplicantInformationForm = () => {
{ - displayCombobox(applicantAgencyQuery, applicantAgencyFieldProps, true) + displayCombobox(applicantAgencyQuery, applicantAgencyFieldConfig, true) } ) => locationCodeChangeHandler(e)} - onBlur={() => validateLocationCode()} + disabled={!formData.client.value?.code} + onChange={ + ( + e: React.ChangeEvent + ) => handleLocationCode(e.target.value) + } + onBlur={ + ( + e: React.ChangeEvent + ) => handleLocationCodeBlur(formData.client.value?.code, e.target.value) + } onWheel={(e: React.ChangeEvent) => e.target.blur()} helperText={locationCodeHelper} /> @@ -325,14 +421,12 @@ const ApplicantInformationForm = () => { ) => inputChangeHandlerApplicant(e)} - onBlur={() => validateApplicantEmail()} + onBlur={(e: React.ChangeEvent) => handleEmail(e.target.value)} /> @@ -342,9 +436,9 @@ const ApplicantInformationForm = () => { - + { - displayCombobox(vegCodeQuery, speciesFieldProps) + displayCombobox(vegCodeQuery, speciesFieldConfig) } @@ -353,24 +447,15 @@ const ApplicantInformationForm = () => { legendText="Class A source" name="class-source-radiogroup" orientation="vertical" - defaultSelected="tested" - onChange={(e: string) => inputChangeHandlerRadio(e)} + onChange={(e: string) => handleSource(e)} > - - - + { + seedlotSourcesQuery.isFetching + ? ( + + ) + : renderSources() + } @@ -381,9 +466,9 @@ const ApplicantInformationForm = () => { id="registered-tree-seed-center" name="registered" labelText="Yes, to be registered with the Tree Seed Centre" - defaultChecked + checked={formData.willBeRegistered.value} onChange={ - (e: React.ChangeEvent) => inputChangeHandlerCheckboxes(e) + (e: React.ChangeEvent) => handleCheckBox('willBeRegistered', e.target.checked) } /> @@ -396,9 +481,9 @@ const ApplicantInformationForm = () => { id="collected-bc" name="collectedBC" labelText="Yes, collected from a location within B.C." - defaultChecked + checked={formData.isBcSource.value} onChange={ - (e: React.ChangeEvent) => inputChangeHandlerCheckboxes(e) + (e: React.ChangeEvent) => handleCheckBox('isBcSource', e.target.checked) } /> diff --git a/frontend/src/components/ApplicantInformationForm/styles.scss b/frontend/src/components/ApplicantInformationForm/styles.scss index 2278a9fc4..9d71d911b 100644 --- a/frontend/src/components/ApplicantInformationForm/styles.scss +++ b/frontend/src/components/ApplicantInformationForm/styles.scss @@ -2,7 +2,24 @@ @use '@bcgov-nr/nr-theme/design-tokens/variables.scss' as vars; .applicant-information-form { - margin-top: 1rem; + margin-top: 2.75rem; + + .error-row { + margin-bottom: 2rem; + + button { + display: none; + } + + #create-seedlot-error-banner { + max-width: none; + border-radius: 0.25rem; + } + + .#{vars.$bcgov-prefix}--actionable-notification__content { + flex-direction: column; + } + } } .applicant-information-form h2 { @@ -16,9 +33,9 @@ } .agency-information, -.seedlot-species-combobox, .class-source-radio, -.registered-checkbox { +.registered-checkbox, +.seedlot-species-row { margin-bottom: 2rem; } diff --git a/frontend/src/components/ApplicantInformationForm/utils.ts b/frontend/src/components/ApplicantInformationForm/utils.ts new file mode 100644 index 000000000..4e2c74062 --- /dev/null +++ b/frontend/src/components/ApplicantInformationForm/utils.ts @@ -0,0 +1,13 @@ +import { SeedlotRegFormType, SeedlotRegPayloadType } from '../../types/SeedlotRegistrationTypes'; + +// eslint-disable-next-line import/prefer-default-export +export const convertToPayload = (formData: SeedlotRegFormType): SeedlotRegPayloadType => ({ + applicantClientNumber: formData.client.value.code, + applicantLocationCode: formData.locationCode.value, + applicantEmailAddress: formData.email.value, + vegetationCode: formData.species.value.code, + seedlotSourceCode: formData.sourceCode.value, + toBeRegistrdInd: formData.willBeRegistered.value, + bcSourceInd: formData.isBcSource.value, + geneticClassCode: 'A' +}); diff --git a/frontend/src/components/ApplicantSeedlotInformation/index.tsx b/frontend/src/components/ApplicantSeedlotInformation/index.tsx index ca0ba088d..34a5d78b1 100644 --- a/frontend/src/components/ApplicantSeedlotInformation/index.tsx +++ b/frontend/src/components/ApplicantSeedlotInformation/index.tsx @@ -5,12 +5,12 @@ import { Edit } from '@carbon/icons-react'; import Subtitle from '../Subtitle'; -import SeedlotRegistrationObj from '../../types/SeedlotRegistrationObj'; +import { OldSeedlotRegistrationObj } from '../../types/SeedlotRegistrationTypes'; import './styles.scss'; interface ApplicantSeedlotInformationProps { - seedlotApplicantData: SeedlotRegistrationObj; + seedlotApplicantData: OldSeedlotRegistrationObj; } const ApplicantSeedlotInformation = ( diff --git a/frontend/src/components/SeedlotRegistrationSteps/CollectionStep/definitions.ts b/frontend/src/components/SeedlotRegistrationSteps/CollectionStep/definitions.ts index 0975f2f70..22084f3c5 100644 --- a/frontend/src/components/SeedlotRegistrationSteps/CollectionStep/definitions.ts +++ b/frontend/src/components/SeedlotRegistrationSteps/CollectionStep/definitions.ts @@ -1,5 +1,6 @@ +import { FormInputType } from '../../../types/FormInputType'; import MultiOptionsObj from '../../../types/MultiOptionsObject'; -import { FormInputType, FormInvalidationObj } from '../../../views/Seedlot/SeedlotRegistrationForm/definitions'; +import { FormInvalidationObj } from '../../../views/Seedlot/SeedlotRegistrationForm/definitions'; export type CollectionForm = { useDefaultAgencyInfo: FormInputType & { diff --git a/frontend/src/components/SeedlotRegistrationSteps/ParentTreeStep/index.tsx b/frontend/src/components/SeedlotRegistrationSteps/ParentTreeStep/index.tsx index 955ae09c8..98de258ba 100644 --- a/frontend/src/components/SeedlotRegistrationSteps/ParentTreeStep/index.tsx +++ b/frontend/src/components/SeedlotRegistrationSteps/ParentTreeStep/index.tsx @@ -26,6 +26,7 @@ import EmptySection from '../../EmptySection'; import { sortAndSliceRows, sliceTableRowData } from '../../../utils/PaginationUtils'; import { recordValues } from '../../../utils/RecordUtils'; import { GenWorthCalcPayload } from '../../../types/GeneticWorthTypes'; +import { THREE_HALF_HOURS, THREE_HOURS } from '../../../config/TimeUnits'; import { renderColOptions, renderTableBody, renderNotification, renderDefaultInputs, renderPagination @@ -173,8 +174,8 @@ const ParentTreeStep = ( setSlicedRows, setStepData ), - staleTime: 3 * (60 * 60 * 1000), // will not refetch for 3 hours - cacheTime: 3.5 * (60 * 60 * 1000) // data is cached 3.5 hours then deleted + staleTime: THREE_HOURS, // will not refetch for 3 hours + cacheTime: THREE_HALF_HOURS // data is cached 3.5 hours then deleted }); // Re-populate table if it is emptied by users and data is cached diff --git a/frontend/src/components/Toast/ErrorToast/index.tsx b/frontend/src/components/Toast/ErrorToast/index.tsx new file mode 100644 index 000000000..8a29de601 --- /dev/null +++ b/frontend/src/components/Toast/ErrorToast/index.tsx @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React from 'react'; +import { ToastNotification } from '@carbon/react'; +import CustomToastProps from '../definitions'; + +const ErrorToast = ({ + closeToast, toastProps, title, subtitle +}: CustomToastProps) => ( + +); + +export default ErrorToast; diff --git a/frontend/src/components/Toast/definitions.ts b/frontend/src/components/Toast/definitions.ts new file mode 100644 index 000000000..9b05b7b24 --- /dev/null +++ b/frontend/src/components/Toast/definitions.ts @@ -0,0 +1,15 @@ +/** + * From react-toastify: + * When you render a component, a closeToast prop and the toastProps + * are injected into your component. + * Therefor the closeToast and toastProps are needed + * but we don't do anything with it. + */ +type CustomToastProps = { + closeToast?: any, + toastProps?: any, + title: string, + subtitle: string +}; + +export default CustomToastProps; diff --git a/frontend/src/config/TimeUnits.ts b/frontend/src/config/TimeUnits.ts new file mode 100644 index 000000000..9504b0fcc --- /dev/null +++ b/frontend/src/config/TimeUnits.ts @@ -0,0 +1,12 @@ +// All units are in milliseconds. +export const ONE_SECOND = 1000; + +export const SEVEN_SECONDS = 7 * ONE_SECOND; + +export const ONE_MINUTE = 60 * ONE_SECOND; + +export const ONE_HOUR = 60 * ONE_MINUTE; + +export const THREE_HOURS = 3 * ONE_HOUR; + +export const THREE_HALF_HOURS = 3.5 * ONE_HOUR; diff --git a/frontend/src/config/ToastifyConfig.ts b/frontend/src/config/ToastifyConfig.ts new file mode 100644 index 000000000..a3e879e2f --- /dev/null +++ b/frontend/src/config/ToastifyConfig.ts @@ -0,0 +1,18 @@ +/* + * These configs are intended to use along with carbon toast, + * see usage in src/components/ApplicantInformationForm/index.tsx. + */ + +import { ToastOptions, toast } from 'react-toastify'; +import { SEVEN_SECONDS } from './TimeUnits'; + +// eslint-disable-next-line import/prefer-default-export +export const ErrToastOption: ToastOptions = { + position: toast.POSITION.TOP_RIGHT, + icon: false, + closeButton: false, + theme: 'dark', + hideProgressBar: true, + style: { padding: 0, background: 'none', boxShadow: 'none' }, + autoClose: SEVEN_SECONDS +}; diff --git a/frontend/src/mock-server/models/index.ts b/frontend/src/mock-server/models/index.ts index 6413ffe32..640227214 100644 --- a/frontend/src/mock-server/models/index.ts +++ b/frontend/src/mock-server/models/index.ts @@ -5,7 +5,7 @@ import { ModelDefinition } from 'miragejs/-types'; import { FavActivityType } from '../../types/FavActivityTypes'; import GeneticClassesType from '../../types/GeneticClasses'; import ApplicantInfo from '../../types/ApplicantInfo'; -import SeedlotRegistration from '../../types/SeedlotRegistrationObj'; +import { OldSeedlotRegistrationObj } from '../../types/SeedlotRegistrationTypes'; import Seedlot from '../../types/Seedlot'; import CollectionInformation from '../../types/CollectionInformation'; import CollectorAgency from '../../types/CollectorAgency'; @@ -18,8 +18,8 @@ import RegisterOwnerArray from '../../types/SeedlotTypes/OwnershipTypes'; const FavouriteModel: ModelDefinition = Model.extend({}); const GeneticClassesModel: ModelDefinition = Model.extend({}); const ApplicantInfoModel: ModelDefinition = Model.extend({}); -const SeedlotRegistrationModel: ModelDefinition = Model.extend({}); -const SeedlotInfoModel: ModelDefinition = Model.extend({}); +const SeedlotRegistrationModel: ModelDefinition = Model.extend({}); +const SeedlotInfoModel: ModelDefinition = Model.extend({}); const SeedlotModel: ModelDefinition = Model.extend({}); const CollectionInformationModel: ModelDefinition = Model.extend({}); const CollectorAgencyModel: ModelDefinition = Model.extend({}); diff --git a/frontend/src/styles/custom.scss b/frontend/src/styles/custom.scss index 05fcb7679..d870e3b7d 100644 --- a/frontend/src/styles/custom.scss +++ b/frontend/src/styles/custom.scss @@ -7,17 +7,13 @@ @use '@bcgov-nr/nr-theme/style-sheets/carbon-components-overrides.scss'; -@use '@carbon/type/scss/_font-family.scss' with ( - $font-families: types.$type-family, - $font-weights: types.$font-weights -); +@use '@carbon/type/scss/_font-family.scss' with ($font-families: types.$type-family, + $font-weights: types.$font-weights); // Set the correct light theme @use '@carbon/react/scss/themes'; -@use '@carbon/react/scss/theme' with ( - $fallback: themes.$white, - $theme: (light.$light-theme) -); +@use '@carbon/react/scss/theme' with ($fallback: themes.$white, + $theme: (light.$light-theme)); // Buttons, tags and notifications components tokens doesn't work properly // when setting directly on the theme, so we override the tokens directly @@ -346,3 +342,11 @@ @include theme.add-component-tokens($notification-tokens); @include react.theme(light.$light-theme); } + +.toastception { + width: 100%; +} + +.Toastify__toast-body { + padding: 0; +} diff --git a/frontend/src/types/FormInputType.ts b/frontend/src/types/FormInputType.ts new file mode 100644 index 000000000..d20d202d1 --- /dev/null +++ b/frontend/src/types/FormInputType.ts @@ -0,0 +1,4 @@ +export type FormInputType = { + id: string; + isInvalid: boolean; +} diff --git a/frontend/src/types/SeedlotRegistrationObj.ts b/frontend/src/types/SeedlotRegistrationObj.ts deleted file mode 100644 index 4ebceee63..000000000 --- a/frontend/src/types/SeedlotRegistrationObj.ts +++ /dev/null @@ -1,13 +0,0 @@ -import ApplicantInfo from './ApplicantInfo'; -import MultiOptionsObj from './MultiOptionsObject'; - -type SeedlotRegistrationObj = { - seedlotNumber: number; - applicant: ApplicantInfo; - species: MultiOptionsObj; - source: string; - registered: boolean; - collectedBC: boolean; -} - -export default SeedlotRegistrationObj; diff --git a/frontend/src/types/SeedlotRegistrationTypes.ts b/frontend/src/types/SeedlotRegistrationTypes.ts new file mode 100644 index 000000000..76994acd8 --- /dev/null +++ b/frontend/src/types/SeedlotRegistrationTypes.ts @@ -0,0 +1,39 @@ +import ApplicantInfo from './ApplicantInfo'; +import { FormInputType } from './FormInputType'; +import MultiOptionsObj from './MultiOptionsObject'; + +/** + * The form data obj used in seedlot creation. + */ +export type SeedlotRegFormType = { + client: FormInputType & { value: MultiOptionsObj }; + locationCode: FormInputType & { value: string }; + email: FormInputType & { value: string }; + species: FormInputType & { value: MultiOptionsObj }; + sourceCode: FormInputType & { value: string }; + willBeRegistered: FormInputType & { value: boolean }; + isBcSource: FormInputType & { value: boolean }; +}; + +/** + * The object that will be sent in a POST call. + */ +export type SeedlotRegPayloadType = { + applicantClientNumber: string; + applicantLocationCode: string; + applicantEmailAddress: string; + vegetationCode: string; + seedlotSourceCode: string; + toBeRegistrdInd: boolean; + bcSourceInd: boolean; + geneticClassCode: 'A' | 'B'; +} + +export type OldSeedlotRegistrationObj = { + seedlotNumber: number; + applicant: ApplicantInfo; + species: MultiOptionsObj; + source: string; + registered: boolean; + collectedBC: boolean; +} diff --git a/frontend/src/types/SeedlotSourceType.ts b/frontend/src/types/SeedlotSourceType.ts new file mode 100644 index 000000000..74f2c121d --- /dev/null +++ b/frontend/src/types/SeedlotSourceType.ts @@ -0,0 +1,7 @@ +type SeedlotSourceType = { + code: string; + description: string; + isDefault: boolean | null; +} + +export default SeedlotSourceType; diff --git a/frontend/src/utils/FocusUtils.ts b/frontend/src/utils/FocusUtils.ts new file mode 100644 index 000000000..0ec0cbb29 --- /dev/null +++ b/frontend/src/utils/FocusUtils.ts @@ -0,0 +1,5 @@ +const focusById = (id: string): void => { + document.getElementById(id)?.focus(); +}; + +export default focusById; diff --git a/frontend/src/views/Seedlot/SeedlotCreatedFeedback/index.tsx b/frontend/src/views/Seedlot/SeedlotCreatedFeedback/index.tsx index 44d0e0a38..1bffe66f3 100644 --- a/frontend/src/views/Seedlot/SeedlotCreatedFeedback/index.tsx +++ b/frontend/src/views/Seedlot/SeedlotCreatedFeedback/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { FlexGrid, Column, @@ -11,7 +11,10 @@ import './styles.scss'; const SeedlotCreatedFeedback = () => { const navigate = useNavigate(); - const seedlotNumber = useParams().seedlot; + const [searchParams] = useSearchParams(); + + const seedlotNumber = searchParams.get('seedlotNumber'); + const seedlotClass = searchParams.get('seedlotClass'); return ( @@ -23,30 +26,40 @@ const SeedlotCreatedFeedback = () => { -

A class seedlot created!

-
-
- - -

- Your A class seedlot +

+ {seedlotClass} + -class {' '} {seedlotNumber} {' '} - has been created with success! - Now you can access the seedlot's detail screen, - create another one or go back to the seedlot's main screen. -

+ seedlot created +
+ + + + + @@ -54,11 +67,18 @@ const SeedlotCreatedFeedback = () => {
@@ -70,7 +90,7 @@ const SeedlotCreatedFeedback = () => { size="lg" className="btn-scf" > - Go back to seedlot's main screen + Seedlot's main screen diff --git a/frontend/src/views/Seedlot/SeedlotCreatedFeedback/styles.scss b/frontend/src/views/Seedlot/SeedlotCreatedFeedback/styles.scss index 0a513165f..b6126931d 100644 --- a/frontend/src/views/Seedlot/SeedlotCreatedFeedback/styles.scss +++ b/frontend/src/views/Seedlot/SeedlotCreatedFeedback/styles.scss @@ -2,7 +2,9 @@ @use '@carbon/type'; .seedlot-created-feedback-page { - height:max-content; + width: 80%; + margin: 0 auto; + padding-top: 10%; .scf-pictogram { width: 100%; @@ -26,7 +28,7 @@ color: var(--#{vars.$bcgov-prefix}-text-secondary) } - .navigate-btn{ + .navigate-btn { padding-top: 2rem; } } diff --git a/frontend/src/views/Seedlot/SeedlotDetails/index.tsx b/frontend/src/views/Seedlot/SeedlotDetails/index.tsx index 26b44f4b3..f9b51ad46 100644 --- a/frontend/src/views/Seedlot/SeedlotDetails/index.tsx +++ b/frontend/src/views/Seedlot/SeedlotDetails/index.tsx @@ -15,7 +15,7 @@ import { } from '@carbon/react'; import Seedlot from '../../../types/Seedlot'; -import SeedlotRegistration from '../../../types/SeedlotRegistrationObj'; +import { OldSeedlotRegistrationObj } from '../../../types/SeedlotRegistrationTypes'; import api from '../../../api-service/api'; import ApiConfig from '../../../api-service/ApiConfig'; @@ -51,7 +51,7 @@ const manageOptions = [ const SeedlotDetails = () => { const { seedlot } = useParams(); const [seedlotData, setSeedlotData] = useState(); - const [seedlotApplicantData, setSeedlotApplicantData] = useState(); + const [seedlotApplicantData, setSeedlotApplicantData] = useState(); const getSeedlotData = () => { if (seedlot) { diff --git a/frontend/src/views/Seedlot/SeedlotRegistrationForm/definitions.ts b/frontend/src/views/Seedlot/SeedlotRegistrationForm/definitions.ts index d3cd73443..2f81174be 100644 --- a/frontend/src/views/Seedlot/SeedlotRegistrationForm/definitions.ts +++ b/frontend/src/views/Seedlot/SeedlotRegistrationForm/definitions.ts @@ -27,11 +27,6 @@ type SingleInvalidObj = { optInvalidText?: string } -export type FormInputType = { - id: string; - isInvalid: boolean; -} - export type FormInvalidationObj = { [key: string]: SingleInvalidObj; } diff --git a/frontend/src/views/Seedlot/SeedlotRegistrationForm/index.tsx b/frontend/src/views/Seedlot/SeedlotRegistrationForm/index.tsx index ffd1ae418..fb0310781 100644 --- a/frontend/src/views/Seedlot/SeedlotRegistrationForm/index.tsx +++ b/frontend/src/views/Seedlot/SeedlotRegistrationForm/index.tsx @@ -18,7 +18,7 @@ import getMethodsOfPayment from '../../../api-service/methodsOfPaymentAPI'; import getConeCollectionMethod from '../../../api-service/coneCollectionMethodAPI'; import { getSeedlotInfo } from '../../../api-service/seedlotAPI'; import getGameticMethodology from '../../../api-service/gameticMethodologyAPI'; -import getApplicantAgenciesOptions from '../../../api-service/applicantAgenciesAPI'; +// import getApplicantAgenciesOptions from '../../../api-service/applicantAgenciesAPI'; import PageTitle from '../../../components/PageTitle'; import SeedlotRegistrationProgress from '../../../components/SeedlotRegistrationProgress'; @@ -52,7 +52,8 @@ import { initParentTreeState, generateDefaultRows, validateCollectionStep, - verifyCollectionStepCompleteness + verifyCollectionStepCompleteness, + getAgencies } from './utils'; import { initialProgressConfig, stepMap } from './constants'; @@ -63,7 +64,7 @@ const defaultExtStorAgency = '12797 - Tree Seed Centre - MOF'; const SeedlotRegistrationForm = () => { const navigate = useNavigate(); - const seedlotNumber = useParams().seedlot ?? ''; + const seedlotNumber = useParams().seedlotNumber ?? ''; const [formStep, setFormStep] = useState(0); const [ @@ -104,7 +105,7 @@ const SeedlotRegistrationForm = () => { const applicantAgencyQuery = useQuery({ queryKey: ['applicant-agencies'], - queryFn: () => getApplicantAgenciesOptions() + queryFn: () => getAgencies() }); const [allInvalidationObj, setAllInvalidationObj] = useState({ diff --git a/frontend/src/views/Seedlot/SeedlotRegistrationForm/utils.ts b/frontend/src/views/Seedlot/SeedlotRegistrationForm/utils.ts index d5e7b0438..8eea582d1 100644 --- a/frontend/src/views/Seedlot/SeedlotRegistrationForm/utils.ts +++ b/frontend/src/views/Seedlot/SeedlotRegistrationForm/utils.ts @@ -7,6 +7,8 @@ import { import { notificationCtrlObj } from '../../../components/SeedlotRegistrationSteps/ParentTreeStep/constants'; import { RowDataDictType } from '../../../components/SeedlotRegistrationSteps/ParentTreeStep/definitions'; import { getMixRowTemplate } from '../../../components/SeedlotRegistrationSteps/ParentTreeStep/utils'; +import ApplicantAgenciesItems from '../../../mock-server/fixtures/ApplicantAgenciesItems'; +import ApplicantAgencyType from '../../../types/ApplicantAgencyType'; import { FormInvalidationObj, OwnershipInvalidObj, ParentTreeStepDataObj } from './definitions'; @@ -207,3 +209,27 @@ export const verifyCollectionStepCompleteness = (collectionData: CollectionForm) } return true; }; + +// TODO: delete this temp data generator + +export const getAgencies = (): string[] => { + const options: string[] = []; + ApplicantAgenciesItems.sort( + (a: ApplicantAgencyType, b: ApplicantAgencyType) => (a.clientName < b.clientName ? -1 : 1) + ); + ApplicantAgenciesItems.forEach((agency: ApplicantAgencyType) => { + let clientName = agency.clientName + .toLowerCase() + .split(' ') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + if (clientName.indexOf('-') > -1) { + clientName = clientName + .split('-') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('-'); + } + options.push(`${agency.clientNumber} - ${clientName} - ${agency.acronym}`); + }); + return options; +};