diff --git a/docs/technical-design/howto-update-state-programs.md b/docs/technical-design/howto-update-state-programs.md index be433e6530..3da740b4bb 100644 --- a/docs/technical-design/howto-update-state-programs.md +++ b/docs/technical-design/howto-update-state-programs.md @@ -12,5 +12,5 @@ The source of truth for that file comes from a CSV maintained by product and des 1. Download the latest version of csv from google docs when prompted by product/design. 2. Run the script following the command listed in the `import-programs.ts`. This will overwrite existing state programs JSON with the new output. Your usage of the script will likely look something like this: `cd scripts && yarn tsc && node import-programs.js path/to/data.csv > ../services/app-web/src/common-code/data/statePrograms.json` 3. Double check the diff. It's important not to delete any programs that have already been used for a submission because although programs are not in the database, we still store references to the program ID in postgres as if they are stable. Also, we want to be sure we are only changing programs expected to change. -4. Make a PR to update the statePrograms file in the codebase -5. If an ID wasn't present on the spreadsheet and was autogenerated by the script, be sure to add the id to the spreadsheet so that it's reflected there +4. For any newly created programs, manually populate the `id` field using a UUID generator +5. Make a PR to update the statePrograms file in the codebase diff --git a/scripts/import-programs.ts b/scripts/import-programs.ts index fc45baa109..36f93b77b8 100644 --- a/scripts/import-programs.ts +++ b/scripts/import-programs.ts @@ -122,6 +122,11 @@ fs.createReadStream(file) process.exit(1) } + if (!data.id) { + console.error('No ID set for program, make sure to set an ID in the spreadsheet', data) + process.exit(1) + } + if (!states[code]) { states[code] = { name: stateNames[code], diff --git a/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx b/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx index a83f2f7ded..1fbd8d5fee 100644 --- a/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx +++ b/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx @@ -1,5 +1,12 @@ import React from 'react' -import Select, { ActionMeta, AriaOnFocus, Props } from 'react-select' +import Select, { + ActionMeta, + AriaOnFocus, + Props, + FormatOptionLabelMeta, + SingleValue, + createFilter, +} from 'react-select' import styles from '../../components/Select/RateSelect/RateSelect.module.scss' import { StateUser, useIndexRatesQuery } from '../../gen/gqlClient' import { useAuth } from '../../contexts/AuthContext' @@ -15,9 +22,14 @@ import { useFormikContext } from 'formik' export interface LinkRateOptionType { readonly value: string - readonly label: React.ReactElement + readonly label: string readonly isFixed?: boolean readonly isDisabled?: boolean + readonly rateCertificationName: string + readonly rateProgramIDs: string + readonly rateDateStart: string + readonly rateDateEnd: string + readonly rateDateCertified: string } export type LinkRateSelectPropType = { @@ -31,7 +43,7 @@ export const LinkRateSelect = ({ initialValue, autofill, ...selectProps -}: LinkRateSelectPropType & Props) => { +}: LinkRateSelectPropType & Props) => { const { values }: { values: RateDetailFormConfig } = useFormikContext() const { data, loading, error } = useIndexRatesQuery() const { getKey } = useS3() @@ -52,32 +64,20 @@ export const LinkRateSelect = ({ const revision = rate.revisions[0] return { value: rate.id, - label: ( - <> - {revision.formData.rateCertificationName} -
-

- Programs:  - {programNames( - statePrograms, - revision.formData.rateProgramIDs - ).join(', ')} -

-

- Rating period:  - {formatCalendarDate( - revision.formData.rateDateStart - )} - -{formatCalendarDate(revision.formData.rateDateEnd)} -

-

- Certification date:  - {formatCalendarDate( - revision.formData.rateDateCertified - )} -

-
- + label: + revision.formData.rateCertificationName ?? + 'Unknown rate certification', + rateCertificationName: + revision.formData.rateCertificationName ?? + 'Unknown rate certification', + rateProgramIDs: programNames( + statePrograms, + revision.formData.rateProgramIDs + ).join(', '), + rateDateStart: formatCalendarDate(revision.formData.rateDateStart), + rateDateEnd: formatCalendarDate(revision.formData.rateDateEnd), + rateDateCertified: formatCalendarDate( + revision.formData.rateDateCertified ), } }) @@ -108,10 +108,10 @@ export const LinkRateSelect = ({ } const onInputChange = ( - newValue: LinkRateOptionType, + newValue: SingleValue, { action }: ActionMeta ) => { - if (action === 'select-option') { + if (action === 'select-option' && newValue) { const linkedRateID = newValue.value const linkedRate = rates.find((rate) => rate.id === linkedRateID) const linkedRateForm: FormikRateForm = convertGQLRateToRateForm( @@ -132,12 +132,27 @@ export const LinkRateSelect = ({ } } - //Need this to make the label searchable since the rate name is buried in a react element - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filterOptions = ({ label }: any, input: string) => - label.props.children[0].props.children - ?.toLowerCase() - .includes(input.toLowerCase()) + const formatOptionLabel = ( + data: LinkRateOptionType, + optionMeta: FormatOptionLabelMeta + ) => { + if (optionMeta.context === 'menu') { + return ( + <> + {data.rateCertificationName} +
+

Programs: {data.rateProgramIDs}

+

+ Rating period: {data.rateDateStart}- + {data.rateDateEnd} +

+

Certification date: {data.rateDateCertified}

+
+ + ) + } + return
{data.rateCertificationName}
+ } //We track rates that have already been selected to remove them from the dropdown const selectedRates = values.rateForms.map((rate) => rate.id && rate.id) @@ -153,6 +168,7 @@ export const LinkRateSelect = ({ (rate) => !selectedRates.includes(rate.value) ) } + formatOptionLabel={formatOptionLabel} isSearchable maxMenuHeight={400} aria-label="linked rate (required)" @@ -168,11 +184,14 @@ export const LinkRateSelect = ({ } loadingMessage={() => 'Loading rate certifications...'} name={name} - filterOption={filterOptions} + filterOption={createFilter({ + ignoreCase: true, + trim: true, + matchFrom: 'any' as const, + })} {...selectProps} inputId={name} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange={onInputChange as any} // TODO see why the types definitions are messed up for react-select "single" (not multi) onChange - may need to upgrade dep if this bug was fixed + onChange={onInputChange} /> ) } diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx index f120caccb3..1a4ec4e4c8 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx @@ -549,12 +549,9 @@ const RateDetailsV2 = ({ Additional rate certification - + )} diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateFormFields.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateFormFields.tsx index 34c828a759..0140dfed9b 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateFormFields.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateFormFields.tsx @@ -536,10 +536,9 @@ export const SingleRateFormFields = ({ ) )} - + )} diff --git a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.test.tsx b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.test.tsx index 5ebb565e0e..05eafaccc0 100644 --- a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.test.tsx +++ b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.test.tsx @@ -10,6 +10,7 @@ import { import { renderWithProviders } from '../../testHelpers/jestHelpers' import { SubmissionRevisionSummaryV2 } from './SubmissionRevisionSummaryV2' import { dayjs } from '../../common-code/dateHelpers' +import { mockContractPackageWithDifferentProgramsInRevisions } from '../../testHelpers/apolloMocks/contractPackageDataMock' describe('SubmissionRevisionSummary', () => { it('renders correctly without errors', async () => { @@ -119,10 +120,12 @@ describe('SubmissionRevisionSummary', () => { statusCode: 200, }), fetchContractMockSuccess({ - contract: mockContractPackageSubmittedWithRevisions({ - id: '15' - }) - }) + contract: mockContractPackageSubmittedWithRevisions( + { + id: '15', + } + ), + }), ], }, routerProvider: { @@ -131,17 +134,22 @@ describe('SubmissionRevisionSummary', () => { featureFlags: { 'link-rates': true, }, - } + } ) expect( await screen.findByRole('heading', { name: 'Contract details' }) ).toBeInTheDocument() - - expect(await screen.findByLabelText('Submission description')).toHaveTextContent('Submission 2') - expect(await screen.findByText('rate2 doc')).toBeInTheDocument() - expect(await screen.findByRole('heading', { name: 'MCR-MN-0005-SNBC'})).toBeInTheDocument() - expect(await screen.findByLabelText('Submitted')).toHaveTextContent('01/01/24') + expect( + await screen.findByLabelText('Submission description') + ).toHaveTextContent('Submission 2') + expect(await screen.findByText('rate2 doc')).toBeInTheDocument() + expect( + await screen.findByRole('heading', { name: 'MCR-MN-0005-SNBC' }) + ).toBeInTheDocument() + expect(await screen.findByLabelText('Submitted')).toHaveTextContent( + '01/01/24' + ) }) it('renders the right indexed version 1', async () => { @@ -160,10 +168,12 @@ describe('SubmissionRevisionSummary', () => { statusCode: 200, }), fetchContractMockSuccess({ - contract: mockContractPackageSubmittedWithRevisions({ - id: '15' - }) - }) + contract: mockContractPackageSubmittedWithRevisions( + { + id: '15', + } + ), + }), ], }, routerProvider: { @@ -172,16 +182,18 @@ describe('SubmissionRevisionSummary', () => { featureFlags: { 'link-rates': true, }, - } + } ) expect( await screen.findByRole('heading', { name: 'Contract details' }) ).toBeInTheDocument() - - expect(await screen.findByLabelText('Submission description')).toHaveTextContent('Submission 1') + + expect( + await screen.findByLabelText('Submission description') + ).toHaveTextContent('Submission 1') }) - it('renders the error indexed version 3', async () => { + it('renders the right indexed version 3', async () => { renderWithProviders( { statusCode: 200, }), fetchContractMockSuccess({ - contract: mockContractPackageSubmittedWithRevisions({ - id: '15' - }) - }) + contract: mockContractPackageSubmittedWithRevisions( + { + id: '15', + } + ), + }), ], }, routerProvider: { @@ -209,51 +223,103 @@ describe('SubmissionRevisionSummary', () => { featureFlags: { 'link-rates': true, }, - } + } + ) + expect( + await screen.findByRole('heading', { name: 'Contract details' }) + ).toBeInTheDocument() + + expect( + await screen.findByLabelText('Submission description') + ).toHaveTextContent('Submission 3') + }) + + it('renders the error indexed version 4', async () => { + renderWithProviders( + + } + /> + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchContractMockSuccess({ + contract: mockContractPackageSubmittedWithRevisions( + { + id: '15', + } + ), + }), + ], + }, + routerProvider: { + route: '/submissions/15/revisions/4', + }, + featureFlags: { + 'link-rates': true, + }, + } + ) + expect(await screen.findByRole('heading')).toHaveTextContent( + '404 / Page not found' ) - expect(await screen.findByRole('heading')).toHaveTextContent('404 / Page not found') - }) - // it('extracts the correct dates from the submission and displays them in tables', async () => { - // renderWithProviders( - // - // } - // /> - // , - // { - // apolloProvider: { - // mocks: [ - // fetchCurrentUserMock({ - // user: mockValidCMSUser(), - // statusCode: 200, - // }), - // fetchStateHealthPlanPackageMockSuccess({ - // stateSubmission: - // mockSubmittedHealthPlanPackageWithRevisions(), - // id: '15', - // }), - // ], - // }, - // routerProvider: { - // route: '/submissions/15/revisions/2', - // }, - // } - // ) - // await waitFor(() => { - // const rows = screen.getAllByRole('row') - // expect(rows).toHaveLength(2) - // expect(within(rows[0]).getByText('Date added')).toBeInTheDocument() - // expect( - // within(rows[1]).getByText( - // dayjs( - // mockSubmittedHealthPlanPackageWithRevisions() - // .revisions[2]?.node?.submitInfo?.updatedAt - // ).format('M/D/YY') - // ) - // ).toBeInTheDocument() - // }) - // }) + it('renders with correct submission name even when previous revisions have different programs', async () => { + // Test case written during MCR-4120 + const mockContract = + mockContractPackageWithDifferentProgramsInRevisions() + renderWithProviders( + + } + /> + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchContractMockSuccess({ + contract: mockContract, + }), + ], + }, + routerProvider: { + route: `/submissions/${mockContract.id}/revisions/1`, + }, + featureFlags: { + 'link-rates': true, + }, + } + ) + + expect( + await screen.findByRole('heading', { name: 'Contract details' }) + ).toBeInTheDocument() + + // grab information from the earliest submission and check its data displayed + const [earliestSubmission] = mockContract.packageSubmissions.slice(-1) + expect( + screen.getByRole('heading', { + level: 2, + name: earliestSubmission.contractRevision.contractName, + }) + ).toBeInTheDocument() + expect( + screen.queryByText( + earliestSubmission.contractRevision.formData + .submissionDescription + ) + ).toBeInTheDocument() + }) }) diff --git a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.tsx b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.tsx index 91ccd45d40..c8baf8095e 100644 --- a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.tsx +++ b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummaryV2.tsx @@ -49,18 +49,32 @@ export const SubmissionRevisionSummaryV2 = (): React.ReactElement => { fetchPolicy: 'network-only', }) const contract = fetchContractData?.fetchContract.contract - //We offset version by +1 of index, remove offset to find revision in revisions + + // Offset version by +1 of index, remove offset to find target previous submission in the history list const revisionIndex = Number(revisionVersion) - 1 - const name = - contract && - contract?.packageSubmissions.length > Number(revisionVersion) - ? contract.packageSubmissions.reverse()[revisionIndex] - .contractRevision.contractName - : '' + + // Reverse revisions to get correct submission order + const packageSubmissions = contract + ? [...contract.packageSubmissions] + .filter((submission) => { + return submission.cause === 'CONTRACT_SUBMISSION' + }) + .reverse() + : [] + const targetPreviousSubmission = + packageSubmissions[revisionIndex] && + packageSubmissions[revisionIndex].__typename + ? packageSubmissions[revisionIndex] + : undefined + const name = targetPreviousSubmission?.contractRevision.contractName + useEffect(() => { - updateHeading({ - customHeading: name, - }) + // make sure you do not update the page heading until we are sure the name for that previous submission exists + if (name) { + updateHeading({ + customHeading: name, + }) + } }, [name, updateHeading]) // Display any full page interim state resulting from the initial fetch API requests @@ -76,22 +90,12 @@ export const SubmissionRevisionSummaryV2 = (): React.ReactElement => { ) } - if ( - !contract || - contract.packageSubmissions.length <= Number(revisionVersion) - ) { + if (!contract || !targetPreviousSubmission || !name) { return } - // Reversing revisions to get correct submission order - // we offset the index by one so that our indices start at 1 - const packageSubmission = [...contract.packageSubmissions] - .filter((submission) => { - return submission.cause === 'CONTRACT_SUBMISSION' - }) - .reverse()[revisionIndex] - const revision = packageSubmission.contractRevision - const rateRevisions = packageSubmission.rateRevisions + const revision = targetPreviousSubmission.contractRevision + const rateRevisions = targetPreviousSubmission.rateRevisions const contractData = revision.formData const statePrograms = contract.state.programs const submitInfo = revision.submitInfo || undefined @@ -105,7 +109,6 @@ export const SubmissionRevisionSummaryV2 = (): React.ReactElement => { className={styles.container} > - ): Contract { @@ -1059,12 +1067,13 @@ function mockContractFormData( partial?: Partial): ContractFor } } -export { - mockContractPackageDraft, - mockContractPackageSubmitted, - mockContractWithLinkedRateDraft, - mockContractPackageUnlocked, - mockContractFormData, +export { + mockContractPackageDraft, + mockContractPackageSubmitted, + mockContractWithLinkedRateDraft, + mockContractPackageUnlocked, + mockContractFormData, mockContractPackageSubmittedWithRevisions, + mockContractPackageWithDifferentProgramsInRevisions }