diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SaveSeedlotFormDtoClassA.java b/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SaveSeedlotFormDtoClassA.java index 77d095445..d526e6906 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SaveSeedlotFormDtoClassA.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/dto/SaveSeedlotFormDtoClassA.java @@ -15,4 +15,6 @@ public record SaveSeedlotFormDtoClassA( @Schema( description = "The JSON object that stores the progress on the front-end", example = "any json object") - JsonNode progressStatus) {} + JsonNode progressStatus, + @Schema(description = "The amount of time this data have been revised", example = "46") + Integer revisionCount) {} diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpoint.java b/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpoint.java index a0e240088..08bccbce6 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpoint.java @@ -473,6 +473,14 @@ public ResponseEntity submitSeedlotForm( @ApiResponse( responseCode = "401", description = "Access token is missing or invalid", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "403", + description = "Client id requested not present on user profile and roles.", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "409", + description = "Data conflict while saving", content = @Content(schema = @Schema(implementation = Void.class))) }) @RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"}) @@ -501,13 +509,17 @@ public ResponseEntity saveFormProgressClassA( @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Successfully retrieved."), - @ApiResponse( - responseCode = "404", - description = "Seedlot form progress not found", - content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "401", description = "Access token is missing or invalid", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "403", + description = "Client id requested not present on user profile and roles.", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "404", + description = "Seedlot form progress not found", content = @Content(schema = @Schema(implementation = Void.class))) }) @RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"}) @@ -534,13 +546,17 @@ public SaveSeedlotFormDtoClassA getFormProgressClassA( @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Successfully retrieved."), - @ApiResponse( - responseCode = "404", - description = "Seedlot form progress not found", - content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "401", description = "Access token is missing or invalid", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "403", + description = "Client id requested not present on user profile and roles.", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "404", + description = "Seedlot form progress not found", content = @Content(schema = @Schema(implementation = Void.class))) }) @RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"}) diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/exception/RevisionCountMismatchException.java b/backend/src/main/java/ca/bc/gov/backendstartapi/exception/RevisionCountMismatchException.java new file mode 100644 index 000000000..7c26463ee --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/exception/RevisionCountMismatchException.java @@ -0,0 +1,15 @@ +package ca.bc.gov.backendstartapi.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.server.ResponseStatusException; + +/** This class represents a revision count mismatch exception. */ +@ResponseStatus(value = HttpStatus.CONFLICT) +public class RevisionCountMismatchException extends ResponseStatusException { + + /** This class represents a revision count mismatch exception. */ + public RevisionCountMismatchException() { + super(HttpStatus.CONFLICT, String.format("Request rejected due to revision count mismatch")); + } +} diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/security/UserInfo.java b/backend/src/main/java/ca/bc/gov/backendstartapi/security/UserInfo.java index 3fc94c46f..19c136f63 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/security/UserInfo.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/security/UserInfo.java @@ -44,6 +44,8 @@ public record UserInfo( @NonNull List clientIds, @NonNull String jwtToken) { + private static final String devClientNumber = "00011223"; + /** Ensure immutability for the user's roles. */ public UserInfo { if (identityProvider.equals(IdentityProvider.IDIR)) { @@ -70,7 +72,16 @@ public static UserInfo createDevUser() { null, IdentityProvider.IDIR, Set.of(), - List.of("00011223"), + List.of(devClientNumber), "abcdef123456"); } + + /** + * Getter for devClientNumber. + * + * @return the mocked client number + */ + public static String getDevClientNumber() { + return devClientNumber; + } } diff --git a/backend/src/main/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormService.java b/backend/src/main/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormService.java index 128609915..9cd7d7124 100644 --- a/backend/src/main/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormService.java +++ b/backend/src/main/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormService.java @@ -4,12 +4,15 @@ import ca.bc.gov.backendstartapi.dto.SaveSeedlotFormDtoClassA; import ca.bc.gov.backendstartapi.entity.SaveSeedlotProgressEntityClassA; import ca.bc.gov.backendstartapi.entity.seedlot.Seedlot; +import ca.bc.gov.backendstartapi.exception.ClientIdForbiddenException; import ca.bc.gov.backendstartapi.exception.JsonParsingException; +import ca.bc.gov.backendstartapi.exception.RevisionCountMismatchException; import ca.bc.gov.backendstartapi.exception.SeedlotFormProgressNotFoundException; import ca.bc.gov.backendstartapi.exception.SeedlotNotFoundException; import ca.bc.gov.backendstartapi.repository.SaveSeedlotProgressRepositoryClassA; import ca.bc.gov.backendstartapi.repository.SeedlotRepository; import ca.bc.gov.backendstartapi.security.LoggedUserService; +import ca.bc.gov.backendstartapi.security.UserInfo; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,6 +40,10 @@ public void saveFormClassA(@NonNull String seedlotNumber, SaveSeedlotFormDtoClas Seedlot relatedSeedlot = seedlotRepository.findById(seedlotNumber).orElseThrow(SeedlotNotFoundException::new); + String seedlotApplicantClientNumber = relatedSeedlot.getApplicantClientNumber(); + + verifySeedlotAccessPrivilege(seedlotApplicantClientNumber); + Optional optionalEntityToSave = saveSeedlotProgressRepositoryClassA.findById(seedlotNumber); @@ -57,7 +64,21 @@ public void saveFormClassA(@NonNull String seedlotNumber, SaveSeedlotFormDtoClas parsedProgressStatus, loggedUserService.createAuditCurrentUser()); } else { - SparLog.warn( + // Revision Count verification + Integer prevRevCount = data.revisionCount(); + Integer currRevCount = optionalEntityToSave.get().getRevisionCount(); + + if (!prevRevCount.equals(currRevCount)) { + // Conflict detected + SparLog.info( + "Save progress failed due to revision count mismatch, prev revision count: {}, curr" + + " revision count: {}", + prevRevCount, + currRevCount); + throw new RevisionCountMismatchException(); + } + + SparLog.info( "A-class seedlot progress for seedlot number {} exists, replacing with new values", seedlotNumber); entityToSave = optionalEntityToSave.get(); @@ -84,20 +105,30 @@ public SaveSeedlotFormDtoClassA getFormClassA(@NonNull String seedlotNumber) { if (form.isPresent()) { SparLog.info("A-class seedlot progress found for seedlot number {}", seedlotNumber); + + String seedlotApplicantClientNumber = form.get().getSeedlot().getApplicantClientNumber(); + verifySeedlotAccessPrivilege(seedlotApplicantClientNumber); } return form.map( savedEntity -> new SaveSeedlotFormDtoClassA( mapper.convertValue(savedEntity.getAllStepData(), JsonNode.class), - mapper.convertValue(savedEntity.getProgressStatus(), JsonNode.class))) + mapper.convertValue(savedEntity.getProgressStatus(), JsonNode.class), + form.get().getRevisionCount())) .orElseThrow(SeedlotFormProgressNotFoundException::new); } /** Retrieves the progress_status column then return it as a json object. */ public JsonNode getFormStatusClassA(String seedlotNumber) { - SparLog.info( - "Retrieving A-class seedlot progress status for seedlot number {}", seedlotNumber); + SparLog.info("Retrieving A-class seedlot progress status for seedlot number {}", seedlotNumber); + + Seedlot relatedSeedlot = + seedlotRepository.findById(seedlotNumber).orElseThrow(SeedlotNotFoundException::new); + + String seedlotApplicantClientNumber = relatedSeedlot.getApplicantClientNumber(); + verifySeedlotAccessPrivilege(seedlotApplicantClientNumber); + ObjectMapper mapper = new ObjectMapper(); Optional form = saveSeedlotProgressRepositoryClassA.getStatusById(seedlotNumber); @@ -120,4 +151,20 @@ public JsonNode getFormStatusClassA(String seedlotNumber) { throw new JsonParsingException(); } } + + /** + * Verify if the service initiator has the correct access. + * + * @param seedlot to verify + * @throw an {@link ClientIdForbiddenException} + */ + private void verifySeedlotAccessPrivilege(String seedlotApplicantClientNumber) { + Optional userInfo = loggedUserService.getLoggedUserInfo(); + + if (userInfo.isEmpty() || !userInfo.get().clientIds().contains(seedlotApplicantClientNumber)) { + SparLog.info( + "Request denied due to user not having client id: {}", seedlotApplicantClientNumber); + throw new ClientIdForbiddenException(); + } + } } diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpointTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpointTest.java index 0349e8cdb..7a7251c6c 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpointTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/endpoint/SeedlotEndpointTest.java @@ -646,7 +646,7 @@ void getSeedlotFormProgress_notFound_shouldThrowException() throws Exception { @DisplayName("Get seedlot form progress should succeed") void getSeedlotFormProgress_shouldSucceed() throws Exception { when(saveSeedlotFormService.getFormClassA(any())) - .thenReturn(new SaveSeedlotFormDtoClassA(null, null)); + .thenReturn(new SaveSeedlotFormDtoClassA(null, null, 1)); mockMvc .perform( diff --git a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormServiceTest.java b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormServiceTest.java index e5dffc0c1..6bf3cde04 100644 --- a/backend/src/test/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/backendstartapi/service/SaveSeedlotFormServiceTest.java @@ -12,6 +12,7 @@ import ca.bc.gov.backendstartapi.repository.SaveSeedlotProgressRepositoryClassA; import ca.bc.gov.backendstartapi.repository.SeedlotRepository; import ca.bc.gov.backendstartapi.security.LoggedUserService; +import ca.bc.gov.backendstartapi.security.UserInfo; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Optional; @@ -35,11 +36,17 @@ class SaveSeedlotFormServiceTest { private static final String SEEDLOT_NUMBER = "678123"; + private Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER); + @BeforeEach void setup() { saveSeedlotFormService = new SaveSeedlotFormService( saveSeedlotProgressRepositoryClassA, seedlotRepository, loggedUserService); + + when(loggedUserService.getLoggedUserInfo()).thenReturn(Optional.of(UserInfo.createDevUser())); + + testSeedlot.setApplicantClientNumber(UserInfo.getDevClientNumber()); } @Test @@ -51,7 +58,7 @@ void saveSeedlotProgress_seedlotMissing_shouldFail() throws Exception { JsonNode progressStatus = new ObjectMapper().readTree("{ \"f2\" : \"v2\" } "); - SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus); + SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus, 1); ResponseStatusException expectedException = assertThrows( @@ -64,7 +71,6 @@ void saveSeedlotProgress_seedlotMissing_shouldFail() throws Exception { @Test @DisplayName("Save seedlot progress with missing seedlot should succeed.") void saveSeedlotProgress_shouldSucceed() throws Exception { - Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER); when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot)); @@ -75,7 +81,7 @@ void saveSeedlotProgress_shouldSucceed() throws Exception { JsonNode progressStatus = new ObjectMapper().readTree("{ \"f2\" : \"v2\" } "); - SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus); + SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus, 1); // Testing a void function, if there is no error then it means success. assertDoesNotThrow(() -> saveSeedlotFormService.saveFormClassA(SEEDLOT_NUMBER, saveDto)); @@ -97,7 +103,6 @@ void getSeedlotProgress_noSeedlotNumber_shouldFail() throws Exception { @Test @DisplayName("Get seedlot progress should succeed.") void getSeedlotProgress_shouldSucceed() throws Exception { - Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER); when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot)); @@ -126,7 +131,6 @@ void getSeedlotProgressStatus_noSeedlotNumber_shouldFail() throws Exception { @Test @DisplayName("Get seedlot progress status should succeed.") void getSeedlotProgressStatus_shouldSucceed() throws Exception { - Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER); when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot)); diff --git a/frontend/src/styles/custom.scss b/frontend/src/styles/custom.scss index 8a155fc19..6d45e1fdb 100644 --- a/frontend/src/styles/custom.scss +++ b/frontend/src/styles/custom.scss @@ -408,7 +408,11 @@ label.#{vars.$bcgov-prefix}--label--disabled { padding: 0; } -.danger-tertiary-btn{ +.danger-tertiary-btn { border-color: var(--#{vars.$bcgov-prefix}-button-danger-secondary, colors.$red-70); color: var(--#{vars.$bcgov-prefix}-button-danger-secondary, colors.$red-70); } + +.ul-disc { + list-style-type: disc; +} diff --git a/frontend/src/types/SeedlotType.ts b/frontend/src/types/SeedlotType.ts index 3ebc1d735..944234e44 100644 --- a/frontend/src/types/SeedlotType.ts +++ b/frontend/src/types/SeedlotType.ts @@ -304,7 +304,8 @@ export type TscSeedlotEditPayloadType = SeedlotAClassSubmitType & { export type SeedlotProgressPayloadType = { allStepData: AllStepData, - progressStatus: ProgressIndicatorConfig + progressStatus: ProgressIndicatorConfig, + revisionCount: number }; export type SeedlotCalculationsResultsType = { diff --git a/frontend/src/views/Seedlot/ContextContainerClassA/constants.ts b/frontend/src/views/Seedlot/ContextContainerClassA/constants.tsx similarity index 88% rename from frontend/src/views/Seedlot/ContextContainerClassA/constants.ts rename to frontend/src/views/Seedlot/ContextContainerClassA/constants.tsx index 721037f17..0aa7a788d 100644 --- a/frontend/src/views/Seedlot/ContextContainerClassA/constants.ts +++ b/frontend/src/views/Seedlot/ContextContainerClassA/constants.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { EmptyMultiOptObj } from '../../../shared-constants/shared-constants'; import MultiOptionsObj from '../../../types/MultiOptionsObject'; import { @@ -96,8 +98,21 @@ export const smartSaveText = { loading: 'Saving...', error: 'Save changes failed', idle: 'Save changes', + reload: 'Reload form', success: 'Changes saved!', - suggestion: 'Your recent changes could not be saved. Please try saving the form manually to keep all of your changes.' + suggestion: 'Your recent changes could not be saved. Please try saving the form manually to keep all of your changes.', + conflictTitle: 'Conflict detected', + conflictSuggestion: ( +
+ Another user has updated this form. Please reload the page to view the latest information +
+
    +
  • Saving and submitting are temporarily disabled to prevent overwriting
  • +
  • Reload the page to continue editing without losing further data
  • +
  • Any unsaved changes will be lost
  • +
+
+ ) }; export const emptyCollectionStep: CollectionFormSubmitType = { diff --git a/frontend/src/views/Seedlot/ContextContainerClassA/context.tsx b/frontend/src/views/Seedlot/ContextContainerClassA/context.tsx index 6d168074a..24700fd72 100644 --- a/frontend/src/views/Seedlot/ContextContainerClassA/context.tsx +++ b/frontend/src/views/Seedlot/ContextContainerClassA/context.tsx @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import React, { createContext } from 'react'; -import { UseMutationResult } from '@tanstack/react-query'; +import { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import { AxiosResponse } from 'axios'; import { EmptyMultiOptObj } from '../../../shared-constants/shared-constants'; import MultiOptionsObj from '../../../types/MultiOptionsObject'; import { RichSeedlotType, SeedlotAClassSubmitType, - SeedlotCalculationsResultsType, SeedlotType + SeedlotCalculationsResultsType, SeedlotProgressPayloadType, SeedlotType } from '../../../types/SeedlotType'; import { AllStepData, AreaOfUseDataType, ProgressIndicatorConfig } from './definitions'; @@ -73,7 +73,8 @@ export type ClassAContextType = { areaOfUseData: AreaOfUseDataType, setAreaOfUseData: React.Dispatch>, isCalculatingPt: boolean, - setIsCalculatingPt: Function + setIsCalculatingPt: Function, + getFormDraftQuery: UseQueryResult } const ClassAContext = createContext({ @@ -122,7 +123,8 @@ const ClassAContext = createContext({ areaOfUseData: {} as AreaOfUseDataType, setAreaOfUseData: () => { }, isCalculatingPt: false, - setIsCalculatingPt: () => { } + setIsCalculatingPt: () => { }, + getFormDraftQuery: {} as UseQueryResult }); export default ClassAContext; diff --git a/frontend/src/views/Seedlot/ContextContainerClassA/definitions.ts b/frontend/src/views/Seedlot/ContextContainerClassA/definitions.ts index 7fb3166b7..ad4573810 100644 --- a/frontend/src/views/Seedlot/ContextContainerClassA/definitions.ts +++ b/frontend/src/views/Seedlot/ContextContainerClassA/definitions.ts @@ -1,3 +1,4 @@ +import { QueryObserverResult } from '@tanstack/react-query'; import { CollectionForm } from '../../../components/SeedlotRegistrationSteps/CollectionStep/definitions'; import InterimForm from '../../../components/SeedlotRegistrationSteps/InterimStep/definitions'; import { SingleOwnerForm } from '../../../components/SeedlotRegistrationSteps/OwnershipStep/definitions'; @@ -7,6 +8,7 @@ import { RowDataDictType, NotifCtrlType, AllParentTreeMap } from '../../../compo import { MutationStatusType } from '../../../types/QueryStatusType'; import MultiOptionsObj from '../../../types/MultiOptionsObject'; import { OptionsInputType, StringInputType } from '../../../types/FormInputType'; +import { SeedlotProgressPayloadType } from '../../../types/SeedlotType'; export type ParentTreeStepDataObj = { tableRowData: RowDataDictType, // table row data used in Cone & Pollen and the SMP Success tabs @@ -49,6 +51,7 @@ export type SaveTooltipProps = { mutationStatus: MutationStatusType; lastSaveTimestamp: string; handleSaveBtn: Function; + reloadFormDraft: () => Promise> } export type RegFormProps = { diff --git a/frontend/src/views/Seedlot/ContextContainerClassA/index.tsx b/frontend/src/views/Seedlot/ContextContainerClassA/index.tsx index b2d85a668..e1c1a1c65 100644 --- a/frontend/src/views/Seedlot/ContextContainerClassA/index.tsx +++ b/frontend/src/views/Seedlot/ContextContainerClassA/index.tsx @@ -7,7 +7,7 @@ import { import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; +import { AxiosError, isAxiosError } from 'axios'; import { DateTime } from 'luxon'; import { @@ -38,6 +38,7 @@ import { GenWorthValType, GeoInfoValType } from '../SeedlotReview/definitions'; import { INITIAL_GEN_WORTH_VALS, INITIAL_GEO_INFO_VALS } from '../SeedlotReview/constants'; import { MeanGeomInfoSectionConfigType, RowItem } from '../../../components/SeedlotRegistrationSteps/ParentTreeStep/definitions'; import InfoDisplayObj from '../../../types/InfoDisplayObj'; +import { StringInputType } from '../../../types/FormInputType'; import ClassAContext, { ClassAContextType } from './context'; import { @@ -60,7 +61,6 @@ import { } from './constants'; import './styles.scss'; -import { StringInputType } from '../../../types/FormInputType'; type props = { children: React.ReactNode @@ -453,11 +453,6 @@ const ContextContainerClassA = ({ children }: props) => { clientNumbers ]); - const logState = () => { - // eslint-disable-next-line no-console - console.log(allStepData); - }; - /** * Update the progress indicator status */ @@ -507,7 +502,6 @@ const ContextContainerClassA = ({ children }: props) => { }; const setStep = (delta: number) => { - logState(); const prevStep = formStep; const newStep = prevStep + delta; updateProgressStatus(newStep, prevStep); @@ -568,6 +562,8 @@ const ContextContainerClassA = ({ children }: props) => { return clonedStatus; }; + const [formDraftRevCount, setFormDraftRevCount] = useState(0); + const saveProgress = useMutation({ mutationFn: () => { const updatedProgressStatus = structuredClone(updateAllStepStatus()); @@ -582,7 +578,8 @@ const ContextContainerClassA = ({ children }: props) => { seedlotNumber ?? '', { allStepData, - progressStatus: updatedProgressStatus + progressStatus: updatedProgressStatus, + revisionCount: formDraftRevCount } ); }, @@ -592,15 +589,26 @@ const ContextContainerClassA = ({ children }: props) => { setSaveStatus('finished'); setSaveDescription(smartSaveText.success); }, - onError: () => { - setSaveStatus('error'); - setSaveDescription(smartSaveText.error); - }, - onSettled: () => { - setTimeout(() => { - setSaveStatus(null); + onError: (error: any) => { + if (isAxiosError(error) && error.response?.data.status === 409) { + setSaveStatus('conflict'); setSaveDescription(smartSaveText.idle); - }, FIVE_SECONDS); + } else { + setSaveStatus('error'); + setSaveDescription(smartSaveText.error); + } + }, + onSettled: (res, error) => { + // Reset button status and description only if the error isn't a conflict + if ( + res + || (isAxiosError(error) && error.response?.data.status !== 409) + ) { + setTimeout(() => { + setSaveStatus(null); + setSaveDescription(smartSaveText.idle); + }, FIVE_SECONDS); + } }, retry: 0 }); @@ -630,20 +638,20 @@ const ContextContainerClassA = ({ children }: props) => { * For auto save on interval. */ useEffect(() => { - if (numOfEdit.current >= MAX_EDIT_BEFORE_SAVE && isFormIncomplete) { + if (numOfEdit.current >= MAX_EDIT_BEFORE_SAVE && isFormIncomplete && saveStatus !== 'conflict') { if (!saveProgress.isLoading) { saveProgress.mutate(); } } const interval = setInterval(() => { - if (numOfEdit.current > 0 && !saveProgress.isLoading && isFormIncomplete) { + if (numOfEdit.current > 0 && !saveProgress.isLoading && isFormIncomplete && saveStatus !== 'conflict') { saveProgress.mutate(); } }, TEN_SECONDS); return () => clearInterval(interval); - }, [numOfEdit.current]); + }, [numOfEdit.current, saveStatus]); /** * Fetch the seedlot form draft only if the status of the seedlot is pending or incomplete. @@ -664,7 +672,12 @@ const ContextContainerClassA = ({ children }: props) => { const currStepName = stepMap[formStep]; savedStatus[currStepName].isCurrent = true; + setFormDraftRevCount(getFormDraftQuery.data.revisionCount); setProgressStatus(getFormDraftQuery.data.progressStatus); + setSaveStatus(null); + setSaveDescription(smartSaveText.idle); + setLastSaveTimestamp(DateTime.now().toISO()); + numOfEdit.current = 0; } if (getFormDraftQuery.status === 'error') { const error = getFormDraftQuery.error as AxiosError; @@ -678,7 +691,12 @@ const ContextContainerClassA = ({ children }: props) => { setDefaultAgencyAndCode(getAgencyObj(), getDefaultLocationCode()); } } - }, [getFormDraftQuery.status, getFormDraftQuery.isFetchedAfterMount, forestClientQuery.status]); + }, [ + getFormDraftQuery.status, + getFormDraftQuery.isFetchedAfterMount, + forestClientQuery.status, + getFormDraftQuery.isRefetching + ]); const [genWorthVals, setGenWorthVals] = useState(() => INITIAL_GEN_WORTH_VALS); @@ -771,6 +789,7 @@ const ContextContainerClassA = ({ children }: props) => { || orchardQuery.isFetching || gameticMethodologyQuery.isFetching || fundingSourcesQuery.isFetching + || getFormDraftQuery.isFetching ), genWorthInfoItems, setGenWorthInfoItems, @@ -785,7 +804,8 @@ const ContextContainerClassA = ({ children }: props) => { areaOfUseData, setAreaOfUseData, isCalculatingPt, - setIsCalculatingPt + setIsCalculatingPt, + getFormDraftQuery }), [ seedlotNumber, calculatedValues, allStepData, seedlotQuery.status, diff --git a/frontend/src/views/Seedlot/ContextContainerClassA/styles.scss b/frontend/src/views/Seedlot/ContextContainerClassA/styles.scss index 967b5594c..8051d9103 100644 --- a/frontend/src/views/Seedlot/ContextContainerClassA/styles.scss +++ b/frontend/src/views/Seedlot/ContextContainerClassA/styles.scss @@ -109,14 +109,21 @@ } } - .save-error-actionable-notification { + .save-error-actionable-notification, + .save-conflict-actionable-notification { width: 100%; max-width: none; margin-bottom: 3rem; .#{vars.$bcgov-prefix}--actionable-notification__content { - display: flex; - flex-direction: row; + p { + font-size: 0.875rem; + + ul { + margin-left: 1.5rem; + margin-top: 1.125rem; + } + } } .#{vars.$bcgov-prefix}--actionable-notification__details { @@ -156,4 +163,18 @@ } } } + + .save-error-actionable-notification { + .#{vars.$bcgov-prefix}--actionable-notification__content { + display: flex; + flex-direction: row; + } + } + + .conflict-suggestion-div { + ul { + margin-left: 1.5rem; + margin-top: 1.125rem; + } + } } diff --git a/frontend/src/views/Seedlot/SeedlotRegFormClassA/RegPage.tsx b/frontend/src/views/Seedlot/SeedlotRegFormClassA/RegPage.tsx index a2d31e972..de9e4553b 100644 --- a/frontend/src/views/Seedlot/SeedlotRegFormClassA/RegPage.tsx +++ b/frontend/src/views/Seedlot/SeedlotRegFormClassA/RegPage.tsx @@ -23,7 +23,7 @@ import SubmitModal from '../../../components/SeedlotRegistrationSteps/SubmitModa import ClassAContext from '../ContextContainerClassA/context'; import { addParamToPath } from '../../../utils/PathUtils'; import ROUTES from '../../../routes/constants'; -import { smartSaveText } from '../ContextContainerClassA/constants'; +import { completeProgressConfig, smartSaveText } from '../ContextContainerClassA/constants'; const RegPage = () => { const navigate = useNavigate(); @@ -44,9 +44,13 @@ const RegPage = () => { getSeedlotPayload, updateProgressStatus, saveProgressStatus, - isFetchingData + isFetchingData, + seedlotData, + getFormDraftQuery } = useContext(ClassAContext); + const reloadFormDraft = () => getFormDraftQuery.refetch(); + return (
@@ -79,6 +83,7 @@ const RegPage = () => { saveDescription={saveDescription} mutationStatus={saveProgressStatus} lastSaveTimestamp={lastSaveTimestamp} + reloadFormDraft={reloadFormDraft} /> ) @@ -92,7 +97,11 @@ const RegPage = () => { { updateProgressStatus(e, formStep); setStep((e - formStep)); @@ -122,18 +131,18 @@ const RegPage = () => { : null } { - saveProgressStatus === 'error' + saveStatus === 'conflict' || saveStatus === 'error' ? ( handleSaveBtn()} + title={saveStatus === 'conflict' ? `${smartSaveText.conflictTitle}` : `${smartSaveText.error}:\u00A0`} + subtitle={saveStatus === 'conflict' ? smartSaveText.conflictSuggestion : smartSaveText.suggestion} + actionButtonLabel={saveStatus === 'conflict' ? smartSaveText.reload : smartSaveText.idle} + onActionButtonClick={saveStatus === 'conflict' ? reloadFormDraft : handleSaveBtn} /> @@ -189,9 +198,9 @@ const RegPage = () => { size="lg" className="form-action-btn" onClick={() => handleSaveBtn()} - disabled={saveProgressStatus === 'loading'} + disabled={saveProgressStatus === 'loading' || saveStatus === 'conflict'} > - + ) @@ -215,7 +224,7 @@ const RegPage = () => { { submitSeedlot.mutate(getSeedlotPayload(allStepData, seedlotNumber)); }} diff --git a/frontend/src/views/Seedlot/SeedlotRegFormClassA/SaveTooltip.tsx b/frontend/src/views/Seedlot/SeedlotRegFormClassA/SaveTooltip.tsx index 50058e842..4f91ccb4c 100644 --- a/frontend/src/views/Seedlot/SeedlotRegFormClassA/SaveTooltip.tsx +++ b/frontend/src/views/Seedlot/SeedlotRegFormClassA/SaveTooltip.tsx @@ -7,7 +7,7 @@ import { ToggletipContent, ToggletipActions } from '@carbon/react'; -import { Save } from '@carbon/icons-react'; +import { Save, Reset } from '@carbon/icons-react'; import { DateTime } from 'luxon'; import { ONE_SECOND } from '../../../config/TimeUnits'; @@ -17,7 +17,7 @@ import { smartSaveText } from '../ContextContainerClassA/constants'; const SaveTooltipLabel = ( { - handleSaveBtn, saveStatus, saveDescription, mutationStatus, lastSaveTimestamp + handleSaveBtn, saveStatus, saveDescription, mutationStatus, lastSaveTimestamp, reloadFormDraft }: SaveTooltipProps ) => { const getTimeDiffString = (timeStamp: string): string => DateTime.fromISO(timeStamp).diffNow('minutes').get('minutes').toFixed(0) @@ -49,38 +49,63 @@ const SaveTooltipLabel = ( if (mutationStatus === 'loading') { prompt = smartSaveText.loading; } - if (mutationStatus === 'error') { + if (saveStatus === 'error') { prompt = smartSaveText.error; } + if (saveStatus === 'conflict') { + prompt = `Smartsave failed: ${smartSaveText.conflictTitle}`; + } setAutosavePrompt(prompt); }, [lastSaveTimeDiff, lastSaveTimestamp, mutationStatus, saveStatus]); return ( -

{autsavePrompt}

+

+ {autsavePrompt} +

-

- Changes you make are saved periodically. -

-

- Last save was - {' '} - {lastSaveTimeDiff} - {' '} - min ago, but you can also manually save the form. -

+ { + saveStatus === 'conflict' + ? ( + smartSaveText.conflictSuggestion + ) + : ( + <> +

+ Changes you make are saved periodically. +

+

+ Last save was + {' '} + {lastSaveTimeDiff} + {' '} + min ago, but you can also manually save the form. +

+ + ) + } +