diff --git a/docs/assets/diagram.svg b/docs/assets/diagram.svg index 6f66f48fea..ba240422ab 100644 --- a/docs/assets/diagram.svg +++ b/docs/assets/diagram.svg @@ -1,4 +1,4 @@ - + - OktaKibana \ No newline at end of file + OktaKibana \ No newline at end of file diff --git a/docs/docs/services/api.md b/docs/docs/services/api.md index dc048d0676..5e78460668 100644 --- a/docs/docs/services/api.md +++ b/docs/docs/services/api.md @@ -16,10 +16,11 @@ The api service deploys a lambda-backed API Gateway that is used by the frontend The largest component of the api service is the API Gateway itself. This is a standard deployment of a regional, REST API Gateway. We do not apply custom certificates or DNS names to the api gateway endpoint (yet); instead, our application uses the amazon generated SSL endpoint. -There are three endpoints on the api. Each is guarded by AWS IAM, meaning that while the API Gateway is publicly available, the API will not forward your request to the backing lambda unless you provide valid credentials obtained through AWS Cognito. This way, only users with an account that we can authenticate may successfully call endpoints. The three endpoints are: +There are four endpoints on the api. Each is guarded by AWS IAM, meaning that while the API Gateway is publicly available, the API will not forward your request to the backing lambda unless you provide valid credentials obtained through AWS Cognito. This way, only users with an account that we can authenticate may successfully call endpoints. The four endpoints are: - /search (POST): This endpoint accepts search queries from clients in the form of OpenSearch Query DSL queries. Once the query is received, the lambda adds extra query filters to ensure fine grain auth. This works by looking up the user making the call in Cognito, determining what type of user (cms or state) is making the call, determining what states that user has access to (if appropriate), and modifying the query in a way that will only return results for those states. By design, the only thing the search endpoint adds is related to authentication; the rest of the query building is left to the frontend for faster and more flexible development. - /item (POST): The item endpoint is used to fetch details for exactly one record. While you can form a query to do this and use the search endpoint, the item endpoint is for convenience. Simply make a post call containing the ID of the desired record to the item endpoint, and the record will be returned. Note that fine grain auth is still enforced in an identical way to search, whereby you will only obtain results for that ID if you should have access to that ID. - /getAttachmentUrl (POST): This endpoint is used to generate a presigned url for direct client downloading of S3 data, enforcing fine grain auth along the way. This is how we securely allow download of submission attachment data. From the details page, a user may click a file to download. Once clicked, their client makes a post to /getAttachmentUrl with the attachment metadata. The lambda function determines if the caller should or should not have access based on identical logic as the other endpoints (the UI would not display something they cannot download, but this guards against bad actors). If access is allowed, the lambda function generates a presigned url good for 60 seconds and returns it to the client browser, at which point files are downloaded automatically. +- /forms (GET): This endpoint function serves as the backend for handling forms and their associated data. This function provides various features, including retrieving form data, validating access, and serving the requested form content. The request to this endpoint must include a formId in the request body. Optionally, you can include a formVersion parameter. If you access this endpoint with formId without specifying formVersion, it will return the latest version. Form schemas are stored in a Lambda layer. Each form is organized in its directory, and each version is stored within that directory. The Lambda layer is located in the "opt" directory when deployed to aws. To access a specific version of a form with a formId, use the following URL structure: /opt/${formId}/v${formVersion}.json. The JSON form schemas are versioned and stored in Git under the "api/layers" directory. All endpoints and backing functions interact with the OpenSearch data layer. As such, and because OpenSearch is deployed within a VPC, all lambda functions of the api service are VPC based. The functions share a security group that allows outbound traffic. diff --git a/src/packages/shared-types/forms.ts b/src/packages/shared-types/forms.ts new file mode 100644 index 0000000000..b9d3de993f --- /dev/null +++ b/src/packages/shared-types/forms.ts @@ -0,0 +1,141 @@ +import { + Control, + FieldArrayPath, + FieldValues, + RegisterOptions, +} from "react-hook-form"; +import { + CalendarProps, + InputProps, + RadioProps, + SelectProps, + SwitchProps, + TextareaProps, +} from "shared-types"; + +export interface FormSchema { + header: string; + sections: Section[]; +} + +export type RHFSlotProps = { + name: string; + label?: string; + labelStyling?: string; + groupNamePrefix?: string; + description?: string; + dependency?: DependencyRule; + rules?: RegisterOptions; +} & { + [K in keyof RHFComponentMap]: { + rhf: K; + props?: RHFComponentMap[K]; + fields?: K extends "FieldArray" + ? RHFSlotProps[] + : K extends "FieldGroup" + ? RHFSlotProps[] + : never; + }; +}[keyof RHFComponentMap]; + +export type RHFOption = { + label: string; + value: string; + form?: FormGroup[]; + slots?: RHFSlotProps[]; +}; + +export type RHFComponentMap = { + Input: InputProps & { + label?: string; + description?: string; + }; + Textarea: TextareaProps; + Switch: SwitchProps; + Select: SelectProps & { sort?: "ascending" | "descending" }; + Radio: RadioProps & { + options: RHFOption[]; + }; + DatePicker: CalendarProps; + Checkbox: { + options: RHFOption[]; + }; + FieldArray: { + appendText?: string; + }; + FieldGroup: { + appendText?: string; + removeText?: string; + }; +}; + +export type FormGroup = { + description?: string; + slots: RHFSlotProps[]; + wrapperStyling?: string; + dependency?: DependencyRule; +}; + +export interface Section { + title: string; + form: FormGroup[]; + dependency?: DependencyRule; +} + +export interface Document { + header: string; + sections: Section[]; +} + +export type FieldArrayProps< + T extends FieldValues, + TFieldArrayName extends FieldArrayPath = FieldArrayPath +> = { + control: Control; + name: TFieldArrayName; + fields: RHFSlotProps[]; + groupNamePrefix?: string; + appendText?: string; +}; + +export type FieldGroupProps< + T extends FieldValues, + TFieldArrayName extends FieldArrayPath = FieldArrayPath +> = { + control: Control; + name: TFieldArrayName; + fields: RHFSlotProps[]; + appendText?: string; + removeText?: string; + groupNamePrefix?: string; +}; + +type ConditionRules = + | { + type: "valueExists" | "valueNotExist"; + } + | { + type: "expectedValue"; + expectedValue: unknown; + }; + +type Condition = { name: string } & ConditionRules; + +type Effects = + | { + type: "show" | "hide"; + } + | { + type: "setValue"; + newValue: unknown; + }; + +export interface DependencyRule { + conditions: Condition[]; + effect: Effects; +} + +export interface DependencyWrapperProps { + name?: string; + dependency?: DependencyRule; +} diff --git a/src/packages/shared-types/index.ts b/src/packages/shared-types/index.ts index e7e882693e..675bb55661 100644 --- a/src/packages/shared-types/index.ts +++ b/src/packages/shared-types/index.ts @@ -9,3 +9,5 @@ export * from "./actions"; export * from "./attachments"; export * from "./authority"; export * from "./action-types/withdraw-record"; +export * from "./forms"; +export * from "./inputs"; diff --git a/src/packages/shared-types/inputs.ts b/src/packages/shared-types/inputs.ts new file mode 100644 index 0000000000..022a9da035 --- /dev/null +++ b/src/packages/shared-types/inputs.ts @@ -0,0 +1,45 @@ +import { DayPicker } from "react-day-picker"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +export type CalendarProps = React.ComponentProps & { + className?: string; + classNames?: any; + showOutsideDays?: boolean; +}; + +export type DatePickerProps = { + date: Date | undefined; + onChange: (date: Date | undefined) => void; +}; + +export interface InputProps + extends React.InputHTMLAttributes { + icon?: string; +} + +export type RadioProps = React.ComponentPropsWithoutRef< + typeof RadioGroupPrimitive.Root +> & { + className?: string; +}; + +export type SelectProps = React.ComponentPropsWithoutRef< + typeof SelectPrimitive.Root +> & { + options: { label: string; value: any }[]; + className?: string; +}; + +export type SwitchProps = React.ComponentPropsWithoutRef< + typeof SwitchPrimitives.Root +> & { + className?: string; +}; + +export interface TextareaProps + extends React.TextareaHTMLAttributes { + charcount?: "simple" | "limited"; + charcountstyling?: string; +} diff --git a/src/packages/shared-types/user.ts b/src/packages/shared-types/user.ts index ae39bac554..48922698a8 100644 --- a/src/packages/shared-types/user.ts +++ b/src/packages/shared-types/user.ts @@ -31,3 +31,10 @@ export const CMS_READ_ONLY_ROLES = [ ]; export const STATE_ROLES = [UserRoles.STATE_SUBMITTER]; + +export const RoleDescriptionStrings: { [key: string]: string } = { + [UserRoles.CMS_READ_ONLY]: "Read Only", + [UserRoles.CMS_REVIEWER]: "Reviewer", + [UserRoles.HELPDESK]: "Helpdesk", + [UserRoles.STATE_SUBMITTER]: "State Submitter", +}; diff --git a/src/packages/shared-utils/index.ts b/src/packages/shared-utils/index.ts index 9e2cecc21c..431d5bf46b 100644 --- a/src/packages/shared-utils/index.ts +++ b/src/packages/shared-utils/index.ts @@ -1,3 +1,4 @@ export * from "./user-helper"; export * from "./s3-url-parser"; export * from "./rai-helper" +export * from "./regex" diff --git a/src/packages/shared-utils/regex.test.ts b/src/packages/shared-utils/regex.test.ts new file mode 100644 index 0000000000..c0866e0ec2 --- /dev/null +++ b/src/packages/shared-utils/regex.test.ts @@ -0,0 +1,43 @@ +import { it, describe, expect } from "vitest"; +import { convertRegexToString, reInsertRegex } from "./regex"; + +const testRegex = /^-?\d*\.?\d+$/g + +const testForm = { + label: "SSI federal benefit amount", + value: "ssi_federal_benefit_amount", + slots: [ + { + rhf: "Input", + name: "ssi_federal_benefit_percentage", + label: "Enter the SSI Federal Benefit Rate percentage", + props: { + icon: "%", + }, + rules: { + pattern: { + value: testRegex, + message: "Must be a percentage", + }, + required: "* Required", + }, + }, + ], + }; + +describe("form regex", () => { + it("conversion logic should work", () => { + const result = convertRegexToString(testForm) + const val = result.slots[0].rules.pattern.value + + const jsonVal = JSON.stringify(val) + expect(jsonVal).toBeTypeOf('string') + + const parsedVal = JSON.parse(jsonVal) + + const restoredRegex = new RegExp(parsedVal[1], parsedVal[2]); + expect(restoredRegex).toEqual(testRegex) + expect(reInsertRegex(result)).toEqual(testForm) + + }); +}) \ No newline at end of file diff --git a/src/packages/shared-utils/regex.ts b/src/packages/shared-utils/regex.ts new file mode 100644 index 0000000000..22a08336f4 --- /dev/null +++ b/src/packages/shared-utils/regex.ts @@ -0,0 +1,44 @@ +export const convertRegexToString = (obj: any) => { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const value = obj[key]; + + if (value instanceof RegExp) { + // Convert RegExp to string + const str = value.toString(); + // save it in this weird array thing + obj[key] = /\/(.*)\/(.*)/.exec(str); + } else if (typeof value === "object" && value !== null) { + // Recursively process nested objects + obj[key] = convertRegexToString(value); + } + } + } + + return obj; +}; + +export const reInsertRegex = (obj: any) => { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === "object" && obj[key] !== null) { + // If the current property is an object, recursively call the function + obj[key] = reInsertRegex(obj[key]); + + // Check if the current object has a property "pattern" with a "value" key + if ( + obj[key].hasOwnProperty("pattern") && + typeof obj[key].pattern === "object" && + obj[key].pattern.hasOwnProperty("value") + ) { + // if its a pattern.value replace the value's value with a regex from the weird array thing + obj[key].pattern.value = new RegExp( + obj[key].pattern.value[1], + obj[key].pattern.value[2] + ); + } + } + } + } + return obj; +}; diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts new file mode 100644 index 0000000000..9d8fe21f89 --- /dev/null +++ b/src/services/api/handlers/forms.ts @@ -0,0 +1,101 @@ +import { response } from "../libs/handler"; +import * as fs from "fs"; +import { APIGatewayEvent } from "aws-lambda"; +import { convertRegexToString } from "shared-utils"; + +export const forms = async (event: APIGatewayEvent) => { + try { + const formId = event.queryStringParameters?.formId?.toLocaleUpperCase(); + let formVersion = event.queryStringParameters?.formVersion; + + if (!formId) { + return response({ + statusCode: 400, + body: JSON.stringify({ error: "File ID was not provided" }), + }); + } + + const filePath = getFilepathForIdAndVersion(formId, formVersion); + + if (!filePath) { + return response({ + statusCode: 404, + body: JSON.stringify({ + error: "No file was found with provided formId and formVersion", + }), + }); + } + + const jsonData = await fs.promises.readFile(filePath, "utf-8"); + + if (!jsonData) { + return response({ + statusCode: 404, + body: JSON.stringify({ + error: `File found for ${formId}, but it's empty`, + }), + }); + } + + if (!formVersion) formVersion = getMaxVersion(formId); + + try { + const formObj = await import(`/opt/${formId}/v${formVersion}.js`); + + if (formObj?.form) { + const cleanedForm = convertRegexToString(formObj.form); + return response({ + statusCode: 200, + body: cleanedForm, + }); + } + } catch (importError) { + console.error("Error importing module:", importError); + return response({ + statusCode: 500, + body: JSON.stringify({ + error: importError.message + ? importError.message + : "Internal server error", + }), + }); + } + } catch (error) { + console.error("Error:", error); + return response({ + statusCode: 502, + body: JSON.stringify({ + error: error.message ? error.message : "Internal server error", + }), + }); + } +}; + +export function getMaxVersion(formId: string) { + const files = fs.readdirSync(`/opt/${formId}`); + if (!files) return undefined; + const versionNumbers = files?.map((fileName: string) => { + const match = fileName.match(/^v(\d+)\./); + if (match) { + return parseInt(match[1], 10); + } + return 1; + }); + return Math.max(...versionNumbers).toString(); +} + +export function getFilepathForIdAndVersion( + formId: string, + formVersion: string | undefined +): string | undefined { + if (formId && formVersion) { + return `/opt/${formId}/v${formVersion}.js`; + } + + const maxVersion = getMaxVersion(formId); + + if (!maxVersion) return undefined; + return `/opt/${formId}/v${maxVersion}.js`; +} + +export const handler = forms; diff --git a/src/services/api/handlers/tests/forms.test.ts b/src/services/api/handlers/tests/forms.test.ts new file mode 100644 index 0000000000..d4ea9ab2f3 --- /dev/null +++ b/src/services/api/handlers/tests/forms.test.ts @@ -0,0 +1,94 @@ +import * as fs from "fs"; +import { it, describe, expect, vi } from "vitest"; +import { forms } from "../forms"; +import { APIGatewayProxyEvent } from "aws-lambda/trigger/api-gateway-proxy"; + +describe("Forms Lambda Tests", () => { + it("should return 400 with error message if formId is not provided", async () => { + const event = { + body: JSON.stringify({}), + } as APIGatewayProxyEvent; + const result = await forms(event); + + expect(result?.statusCode).toBe(400); + expect(JSON.parse(result?.body as string)).toEqual({ + error: "File ID was not provided", + }); + }); + + it("should return 500 with error message if filePath is not found", async () => { + const event = { + body: JSON.stringify({ formId: "test", formVersion: "1" }), + } as APIGatewayProxyEvent; + const result = await forms(event); + + expect(result?.statusCode).toBe(500); + expect(JSON.parse(result?.body as string)).toEqual({ + error: "ENOENT: no such file or directory, open '/opt/test/v1.js'", + }); + }); + + it("should return 200 with JSON data if everything is valid", async () => { + vi.spyOn(fs.promises, "readFile").mockResolvedValue( + JSON.stringify({ key: "value" }) + ); + + const event = { + body: JSON.stringify({ formId: "testform", formVersion: "1" }), + } as APIGatewayProxyEvent; + const result = await forms(event); + + expect(result?.statusCode).toBe(200); + expect(result?.headers["Content-Type"]).toBe("application/json"); + }); + + it("should return 500 with a custom error message for other internal errors", async () => { + vi.spyOn(fs.promises, "readFile").mockRejectedValue( + new Error("Internal Server Error Message") + ); + + const event = { + body: JSON.stringify({ formId: "testform", formVersion: "1" }), + } as APIGatewayProxyEvent; + + const result = await forms(event); + + expect(result?.statusCode).toBe(500); + expect(JSON.parse(result?.body as string)).toEqual({ + error: "Internal Server Error Message", + }); + }); + + it("should return the correct JSON data for different file versions", async () => { + vi.spyOn(fs.promises, "readFile").mockImplementation(async (filePath) => { + const filePathString = filePath.toString(); + if (filePathString.includes("/opt/testform/v1.js")) { + return Buffer.from(JSON.stringify({ version: "1", data: "v1 data" })); + } else { + return Buffer.from(JSON.stringify({ version: "2", data: "v2 data" })); + } + }); + + const eventV1 = { + body: JSON.stringify({ formId: "testform", formVersion: "1" }), + } as APIGatewayProxyEvent; + const eventV2 = { + body: JSON.stringify({ formId: "testform", formVersion: "2" }), + } as APIGatewayProxyEvent; + + const resultV1 = await forms(eventV1); + const resultV2 = await forms(eventV2); + + expect(resultV1?.statusCode).toBe(200); + expect(resultV2?.statusCode).toBe(200); + + expect(JSON.parse(resultV1?.body as string)).toEqual({ + version: "1", + data: "v1 data", + }); + expect(JSON.parse(resultV2?.body as string)).toEqual({ + version: "2", + data: "v2 data", + }); + }); +}); diff --git a/src/services/api/layers/ABP1/v1.ts b/src/services/api/layers/ABP1/v1.ts new file mode 100644 index 0000000000..59637fd088 --- /dev/null +++ b/src/services/api/layers/ABP1/v1.ts @@ -0,0 +1,1323 @@ +import { FormSchema } from "shared-types"; + +const ABP1: FormSchema = { + header: "ABP 1: Alternative Benefit Plan populations", + sections: [ + { + title: "Population identification", + form: [ + { + description: + "Identify and define the population that will participate in the Alternative Benefit Plan.", + slots: [ + { + rhf: "Input", + name: "alt_benefit_plan_population_name", + label: "Alternative Benefit Plan population name", + rules: { + required: "* Required", + }, + props: { placeholder: "enter name" }, + }, + ], + }, + { + description: + "Identify eligibility groups that are included in the Alternative Benefit Plan's population and that may contain individuals that meet any targeting criteria used to further define the population.", + slots: [ + { + rhf: "FieldArray", + name: "eligibility_groups", + props: { + appendText: "Add group", + }, + fields: [ + { + rhf: "Select", + name: "eligibility_group", + rules: { + required: "* Required", + }, + label: "Eligibility group", + props: { + sort: "ascending", + className: "min-w-[300px]", + options: [ + { + label: "Parents and Other Caretaker Relatives", + value: "parents_caretaker_relatives", + }, + { + label: "Transitional Medical Assistance", + value: "transitional_medical_assist", + }, + { + label: "Extended Medicaid Due to Earnings", + value: "extend_medicaid_earnings", + }, + { + label: + "Extended Medicaid Due to Spousal Support Collections", + value: "extend_medicaid_spousal_support_collect", + }, + { + label: "Pregnant Women", + value: "pregnant_women", + }, + { + label: "Deemed Newborns", + value: "deemed_newborns", + }, + { + label: "Infants and Children under Age 19", + value: "infants_children_under_19", + }, + { + label: + "Children with Title IV-E Adoption Assistance, Foster Care or Guardianship Care", + value: + "children_title_IV-E_adoption_assist_foster_guardianship_care", + }, + { + label: "Former Foster Care Children", + value: "former_foster_children", + }, + { + label: "Adult Group", + value: "adult_group", + }, + { + label: "SSI Beneficiaries", + value: "ssi_beneficiaries", + }, + { + label: + "Aged, Blind and Disabled Individuals in 209(b) States", + value: "aged_blind_disabled_individuals_209b_states", + }, + { + label: + "Individuals Receiving Mandatory State Supplements", + value: + "individuals_receiving_mandatory_state_supplements", + }, + { + label: "Individuals Who Are Essential Spouses", + value: "essential_spouses", + }, + { + label: "Institutionalized Individuals Eligible in 1973", + value: "institutionalized_eligible_1973", + }, + { + label: "Blind or Disabled Individuals Eligible in 1937", + value: "blind_disabled_eligible_1937", + }, + { + label: + "Individuals Who Lost Eligibility for SSI/SSP Due to an Increase in OASDI Benefits in 1972", + value: + "lost_eligibility_SSI_SSP_increase_in_OASDI_benefits_1972", + }, + { + label: + "Individuals Eligible for SSI/SSP but for OASDI COLA increases since April, 1977", + value: + "eligible_SSI_SSP_but_for_OASDI_COLA_increases_April_1977", + }, + { + label: + "Disabled Widows and Widowers Ineligible for SSI due to Increase in OASDI", + value: + "disabled_widows_ineligible_SSI_due_to_increase_OASDI", + }, + { + label: + "Disabled Widows and Widowers Ineligible for SSI due to Early Receipt of Social Security", + value: + "disabled_widows_ineligible_SSI_due_to_early_receipt_social_security", + }, + { + label: "Working Disabled under 1619(b)", + value: "working_disabled_under_1619b", + }, + { + label: "Disabled Adult Children", + value: "disabled_adult_children", + }, + { + label: "Qualified Medicare Beneficiaries", + value: "qualified_medicare_beneficiaries", + }, + { + label: "Qualified Disabled and Working Individuals", + value: "qualified_disabled_working_individuals", + }, + { + label: "Specified Low Income Medicare Beneficiaries", + value: "spec_low_income_medicare_beneficiaries", + }, + { + label: "Qualifying Individuals", + value: "qualifying_individuals", + }, + { + label: + "Optional Coverage of Parents and Other Caretaker Relatives", + value: "opt_coverage_parents_other_caretaker_relatives", + }, + { + label: + "Reasonable Classifications of Individuals under Age 21", + value: "reasonable_class_under_21", + }, + { + label: "Children with Non-IV-E Adoption Assistance", + value: "children_Non-IV-E_adoption_assistance", + }, + { + label: "Independent Foster Care Adolescents", + value: "independent_foster_care_adolescents", + }, + { + label: "Optional Targeted Low Income Children", + value: "opt_targeted_low_income_children", + }, + { + label: + "Individuals Electing COBRA Continuation Coverage", + value: "individuals_electing_COBRA_cont_converage", + }, + { + label: + "Certain Individuals Needing Treatment for Breast or Cervical Cancer", + value: + "individuals_need_treatment_for_breasts_cervical_cancer", + }, + { + label: "Individuals with Tuberculosis", + value: "tuberculosis", + }, + { + label: + "Aged, Blind or Disabled Individuals Eligible for but Not Receiving Cash", + value: + "aged_blind_disabled_eligible_but_not_receiving_cash", + }, + { + label: + "Individuals Eligible for Cash except for Institutionalization", + value: "eligible_cash_except_for_institutionalization", + }, + { + label: + "Individuals Receiving Home and Community Based Services under Institutional Rules", + value: + "receiving_home_community_services_under_inst_rule", + }, + { + label: + "Optional State Supplement - 1634 States and SSI Criteria States with 1616 Agreements", + value: + "opt_state_supp_1634_states_SSI_criteria_states_1616_agreements", + }, + { + label: + "Optional State Supplement - 209(b) States and SSI Criteria States without 1616 Agreements", + value: + "opt_state_supp_209b_states_SSI_criteria_states_without_1616_agreements", + }, + { + label: + "Institutionalized Individuals Eligible under a Special Income Level", + value: "inst_eligible_under_special_income_level", + }, + { + label: "Individuals Receiving Hospice Care", + value: "hospice_care", + }, + { + label: "Qualified Disabled Children under Age 19 ", + value: "qualified_disabled_children_under_19", + }, + { + label: "Poverty Level Aged or Disabled", + value: "poverty_level_aged_disabled", + }, + { + label: "Work Incentives Eligibility Group", + value: "work_incentives_eligibility_group", + }, + { + label: "Ticket to Work Basic Group", + value: "ticket_work_basic_group", + }, + { + label: "Ticket to Work Medical Improvements Group", + value: "ticket_work_medical_imp_group", + }, + { + label: + "Family Opportunity Act Children with Disabilities", + value: "family_opportunity_act_children_disabilities", + }, + { + label: "Medically Needy Pregnant Women", + value: "med_needy_pregnant_women", + }, + { + label: "Medically Needy Children under Age 18", + value: "med_needy_children_under_18", + }, + { + label: "Medically Needy Children Age 18 through 20", + value: "med_needy_age_18_through_20", + }, + { + label: "Medically Needy Parents and Other Caretakers", + value: "med_needy_parents_caretakers", + }, + { + label: "Medically Needy Aged, Blind or Disabled", + value: "med_needy_aged_blind_disabled", + }, + { + label: + "Medically Needy Blind or Disabled Individuals Eligible in 1973", + value: "med_needy_blind_disabled_eligible_1973", + }, + ], + }, + }, + { + rhf: "Select", + name: "mandatory_voluntary", + label: "Mandatory or Voluntary", + rules: { + required: "* Required", + }, + props: { + className: "w-[200px]", + options: [ + { + label: "Mandatory", + value: "mandatory", + }, + { + label: "Voluntary", + value: "voluntary", + }, + ], + }, + }, + ], + }, + ], + }, + { + description: + "Is enrollment available for all individuals in these eligibility groups?", + slots: [ + { + rhf: "Select", + name: "is_enrollment_available", + rules: { + required: "* Required", + }, + props: { + className: "w-[150px]", + options: [ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ], + }, + }, + ], + }, + ], + }, + { + title: "Targeting criteria", + dependency: { + // example of conditionally hidden section + conditions: [ + { + name: "is_enrollment_available", + type: "expectedValue", + expectedValue: "no", + }, + ], + effect: { type: "show" }, + }, + form: [ + { + description: "Targeting criteria (check all that apply)", + slots: [ + { + rhf: "Checkbox", + name: "target_criteria", + rules: { + required: "* Required", + }, + props: { + options: [ + { + value: "income_standard", + label: "Income standard", + form: [ + { + description: "Income standard target", + slots: [ + { + rhf: "Radio", + name: "income_target", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: + "Households with income at or below the standard", + value: "income_target_below", + }, + { + label: + "Households with income above the standard", + value: "income_target_above", + }, + ], + }, + }, + ], + }, + { + description: "Income standard definition", + slots: [ + { + rhf: "Radio", + name: "income_definition", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "A percentage", + value: "income_definition_percentage", + slots: [ + { + rhf: "Radio", + name: "income_definition_percentage", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "Federal poverty level", + value: "federal_poverty_level", + slots: [ + { + rhf: "Input", + props: { + icon: "%", + }, + rules: { + pattern: { + value: /^-?\d*\.?\d+$/, + message: + "Must be a percentage", + }, + required: "* Required", + }, + name: "federal_poverty_level_percentage", + label: + "Enter the federal poverty level percentage", + }, + ], + }, + { + label: "SSI federal benefit amount", + value: "ssi_federal_benefit_amount", + slots: [ + { + rhf: "Input", + name: "ssi_federal_benefit_percentage", + label: + "Enter the SSI Federal Benefit Rate percentage", + props: { + icon: "%", + }, + rules: { + pattern: { + value: /^-?\d*\.?\d+$/, + message: + "Must be a percentage", + }, + required: "* Required", + }, + }, + ], + }, + { + label: "Other", + value: "other", + slots: [ + { + rhf: "Input", + name: "other_percentage", + label: + "Enter the other percentage", + props: { + icon: "%", + }, + rules: { + pattern: { + value: /^-?\d*\.?\d+$/, + message: + "Must be a percentage", + }, + required: "* Required", + }, + }, + { + rhf: "Textarea", + name: "other_describe", + label: "Describe:", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + }, + ], + }, + { + label: "A specific amount", + value: "income_definition_specific", + slots: [ + { + rhf: "Radio", + name: "income_definition_specific", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "Statewide standard", + value: "statewide_standard", + form: [ + { + slots: [ + { + rhf: "FieldArray", + name: "income_definition_specific_statewide", + fields: [ + { + rhf: "Input", + label: "Household Size", + name: "household_size", + props: { + placeholder: + "enter size", + className: + "w-[300px]", + }, + rules: { + pattern: { + value: + /^\d*\.?\d+$/, + message: + "Must be a positive numerical value", + }, + required: + "* Required", + }, + }, + { + rhf: "Input", + name: "standard", + label: "Standard ($)", + props: { + className: + "w-[200px]", + placeholder: + "enter amount", + icon: "$", + }, + rules: { + pattern: { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be a positive number, maximum of two decimals, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + ], + }, + { + slots: [ + { + rhf: "Checkbox", + name: "is_incremental_amount_statewide_std", + props: { + options: [ + { + label: + "There is an additional incremental amount.", + value: "yes", + form: [ + { + slots: [ + { + rhf: "Input", + label: + "Incremental amount ($)", + name: "dollar_incremental_amount_statewide_std", + props: { + icon: "$", + }, + rules: { + pattern: { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + { + label: "Standard varies by region", + value: "region_standard", + form: [ + { + slots: [ + { + rhf: "FieldGroup", + name: "income_definition_specific_statewide_group_region", + props: { + appendText: "Add Region", + removeText: + "Remove Region", + }, + fields: [ + { + rhf: "Input", + name: "name_of_region", + label: "Region Name", + rules: { + required: + "* Required", + }, + }, + { + rhf: "Textarea", + name: "region_description", + label: "Description", + rules: { + required: + "* Required", + }, + }, + { + rhf: "FieldArray", + name: "income_definition_region_statewide_arr", + props: { + appendText: + "Add household size", + }, + fields: [ + { + rhf: "Input", + label: + "Household Size", + name: "household_size", + props: { + placeholder: + "enter size", + className: + "w-[300px]", + }, + rules: { + pattern: { + value: + /^\d*\.?\d+$/, + message: + "Must be a positive numerical value", + }, + required: + "* Required", + }, + }, + { + rhf: "Input", + name: "standard", + label: + "Standard ($)", + props: { + className: + "w-[200px]", + placeholder: + "enter amount", + icon: "$", + }, + rules: { + pattern: { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + { + rhf: "Checkbox", + name: "is_incremental_amount", + props: { + options: [ + { + label: + "There is an additional incremental amount.", + value: "yes", + form: [ + { + slots: [ + { + rhf: "Input", + label: + "Incremental amount ($)", + name: "dollar_incremental_amount", + props: { + icon: "$", + }, + rules: { + pattern: + { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + { + label: + "Standard varies by living arrangement", + value: "living_standard", + + form: [ + { + slots: [ + { + rhf: "FieldGroup", + name: "income_definition_specific_statewide_group_liv_arrange", + props: { + appendText: + "Add Living Arrangement", + removeText: + "Remove living arrangement", + }, + fields: [ + { + rhf: "Input", + name: "name_of_living_arrangement", + label: + "Name of living arrangement", + rules: { + required: + "* Required", + }, + }, + { + rhf: "Textarea", + name: "living_arrangement_description", + label: "Description", + rules: { + required: + "* Required", + }, + }, + { + rhf: "FieldArray", + name: "income_definition_specific_statewide_arr", + props: { + appendText: + "Add household size", + }, + fields: [ + { + rhf: "Input", + label: + "Household Size", + name: "household_size", + props: { + placeholder: + "enter size", + className: + "w-[300px]", + }, + rules: { + pattern: { + value: + /^\d*\.?\d+$/, + message: + "Must be a positive numerical value", + }, + required: + "* Required", + }, + }, + { + rhf: "Input", + name: "standard", + label: + "Standard ($)", + props: { + className: + "w-[200px]", + placeholder: + "enter amount", + icon: "$", + }, + rules: { + pattern: { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + { + rhf: "Checkbox", + name: "is_incremental_amount", + props: { + options: [ + { + label: + "There is an additional incremental amount.", + value: "yes", + form: [ + { + slots: [ + { + rhf: "Input", + label: + "Incremental amount ($)", + name: "dollar_incremental_amount", + props: { + icon: "$", + }, + rules: { + pattern: + { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + { + label: + "Standard varies in some other way", + value: "other_standard", + + form: [ + { + slots: [ + { + rhf: "FieldGroup", + name: "income_definition_specific_statewide_group_other", + props: { + appendText: + "Add some other way", + removeText: + "Remove some other way", + }, + fields: [ + { + rhf: "Input", + name: "name_of_group", + label: "Name", + rules: { + required: + "* Required", + }, + }, + { + rhf: "Textarea", + name: "group_description", + label: "Description", + rules: { + required: + "* Required", + }, + }, + { + rhf: "FieldArray", + name: "income_definition_specific_statewide_arr", + props: { + appendText: + "Add household size", + }, + fields: [ + { + rhf: "Input", + label: + "Household Size", + name: "household_size", + props: { + placeholder: + "enter size", + className: + "w-[300px]", + }, + rules: { + pattern: { + value: + /^\d*\.?\d+$/, + message: + "Must be a positive numerical value", + }, + required: + "* Required", + }, + }, + { + rhf: "Input", + name: "standard", + label: + "Standard ($)", + props: { + className: + "w-[200px]", + placeholder: + "enter amount", + icon: "$", + }, + rules: { + pattern: { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + { + rhf: "Checkbox", + name: "is_incremental_amount", + props: { + options: [ + { + label: + "There is an additional incremental amount.", + value: "yes", + form: [ + { + slots: [ + { + rhf: "Input", + label: + "Incremental amount ($)", + name: "dollar_incremental_amount", + props: { + icon: "$", + }, + rules: { + pattern: + { + value: + /^\d*(?:\.\d{1,2})?$/, + message: + "Must be all numbers, no commas. e.g. 1234.56", + }, + required: + "* Required", + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + { + value: "health", + label: + "Disease, condition, diagnosis, or disorder (check all that apply)", + slots: [ + { + rhf: "Checkbox", + name: "health_conditions", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "Physical disability", + value: "physical_disability", + }, + { + label: "Brain Injury", + value: "brain_injury", + }, + { + label: "HIV/AIDS", + value: "hiv_aids", + }, + { + label: "Medically frail", + value: "med_frail", + }, + { + label: "Technology dependent", + value: "technology_dependent", + }, + { label: "Autism", value: "autism" }, + { + label: "Developmental disability", + value: "dev_disability", + }, + { + label: "Intellectual disability", + value: "int_disability", + }, + { + label: "Mental illness", + value: "mental_illness", + }, + { + label: "Substance use disorder", + value: "substance_use_dis", + }, + { label: "Diabetes", value: "diabetes" }, + { label: "Heart disease", value: "heart_dis" }, + { label: "Asthma", value: "asthma" }, + { label: "Obesity", value: "obesity" }, + { + label: + "Other disease, condition, diagnosis, or disorder", + value: "other", + slots: [ + { + rhf: "Textarea", + name: "other_description", + label: "Describe", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + }, + ], + }, + { + label: "Other targeting criteria", + value: "other_targeting_criteria", + slots: [ + { + rhf: "Textarea", + name: "other_targeting_criteria_description", + label: "Describe", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + { + title: "Geographic Area", + form: [ + { + description: + "Will the Alternative Benefit Plan population include individuals from the entire state/territory?", + slots: [ + { + rhf: "Select", + name: "is_geographic_area", + props: { + className: "w-[150px]", + options: [ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ], + }, + rules: { + required: "* Required", + }, + }, + ], + }, + { + description: "Method of geographic variation", + dependency: { + conditions: [ + { + name: "is_geographic_area", + type: "expectedValue", + expectedValue: "no", + }, + ], + effect: { type: "show" }, + }, + slots: [ + { + rhf: "Radio", + name: "geographic_variation", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "By county", + value: "by_county", + form: [ + { + description: "Specify counties", + slots: [ + { + name: "specify_counties", + rhf: "Textarea", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + { + label: "By region", + value: "by_region", + form: [ + { + description: "Specify regions", + slots: [ + { + name: "specify_regions", + rhf: "Textarea", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + { + label: "By city or town", + value: "by_city_town", + form: [ + { + description: "Specify cities or towns", + slots: [ + { + name: "specify_cities_towns", + rhf: "Textarea", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + { + label: "Other geographic area", + value: "other", + form: [ + { + description: "Specify other geographic area", + slots: [ + { + name: "specify_other", + rhf: "Textarea", + rules: { + required: "* Required", + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + { + title: "Additional information", + form: [ + { + description: + "Any other information the state/territory wishes to provide about the population (optional)", + // "Other information related to selection of the Section 1937 coverage option and the base benchmark plan (optional)", + slots: [ + { + name: "additional_information", + rhf: "Textarea", + }, + ], + }, + ], + }, + // { + // title: "Testing Alt Layouts", + // form: [ + // { + // description: "A test of horizontal layouts with no slot styles", + // wrapperStyling: "flex flex-wrap gap-6", + // slots: [ + // { + // name: "example1_1", + // label: "Example 1.1", + // rhf: "Input", + // }, + // { + // name: "example1_2", + // label: "Example 1.2", + // rhf: "Input", + // }, + // { + // name: "example1_3", + // label: "Example 1.3", + // rhf: "Input", + // }, + // ], + // }, + // { + // description: "A test of horizontal layouts with slot styles", + // wrapperStyling: "flex flex-wrap gap-6", + // slots: [ + // { + // name: "example2_1", + // label: "Example 2.1", + // rhf: "Input", + // props: { + // className: "w-80", + // }, + // }, + // { + // name: "example2_2", + // label: "Example 2.2", + // rhf: "Input", + // props: { + // className: "w-30", + // }, + // }, + // { + // name: "example2_3", + // label: "Example 2.3", + // rhf: "Input", + // props: { + // className: "w-120", + // }, + // }, + // ], + // }, + // ], + // }, + ], +}; + +export const form = ABP1; diff --git a/src/services/api/package.json b/src/services/api/package.json index 94a6529cda..61b7c2770b 100644 --- a/src/services/api/package.json +++ b/src/services/api/package.json @@ -28,6 +28,7 @@ }, "version": "0.0.0", "scripts": { + "build": "tsc", "lint": "eslint '**/*.{ts,js}'" } } diff --git a/src/services/api/serverless.yml b/src/services/api/serverless.yml index 680b735c0c..aee04474bf 100644 --- a/src/services/api/serverless.yml +++ b/src/services/api/serverless.yml @@ -6,6 +6,8 @@ plugins: - "@stratiformdigital/serverless-iam-helper" - "@stratiformdigital/serverless-s3-security-helper" - serverless-scriptable-plugin + - serverless-plugin-scripts + provider: name: aws runtime: nodejs18.x @@ -64,23 +66,31 @@ custom: scriptable: hooks: package:compileEvents: ./handlers/repack.js + scripts: + hooks: + package:initialize: | + yarn build params: master: + formsProvisionedConcurrency: 2 searchProvisionedConcurrency: 4 itemProvisionedConcurrency: 2 getAttachmentUrlProvisionedConcurrency: 2 submitProvisionedConcurrency: 2 val: + formsProvisionedConcurrency: 2 searchProvisionedConcurrency: 4 itemProvisionedConcurrency: 2 getAttachmentUrlProvisionedConcurrency: 2 submitProvisionedConcurrency: 2 production: + formsProvisionedConcurrency: 5 searchProvisionedConcurrency: 10 itemProvisionedConcurrency: 5 getAttachmentUrlProvisionedConcurrency: 5 submitProvisionedConcurrency: 5 default: + formsProvisionedConcurrency: 0 searchProvisionedConcurrency: 0 itemProvisionedConcurrency: 0 getAttachmentUrlProvisionedConcurrency: 0 @@ -218,7 +228,31 @@ functions: - Ref: SecurityGroup subnetIds: >- ${self:custom.vpc.privateSubnets} - provisionedConcurrency: ${param:submitProvisionedConcurrency} # reuse submit's concurrency + provisionedConcurrency: ${param:submitProvisionedConcurrency} + forms: + handler: handlers/forms.handler + layers: + - !Ref FormsLambdaLayer + maximumRetryAttempts: 0 + environment: + region: ${self:provider.region} + osDomain: ${param:osDomain} + events: + - http: + path: /forms + method: get + cors: true + authorizer: aws_iam + vpc: + securityGroupIds: + - Ref: SecurityGroup + subnetIds: >- + ${self:custom.vpc.privateSubnets} + provisionedConcurrency: ${param:searchProvisionedConcurrency} +layers: + forms: + path: layers + description: Lambda Layer for forms function resources: Resources: ApiGateway400ErrorCount: diff --git a/src/services/api/tsconfig.json b/src/services/api/tsconfig.json index 3884eb2003..3e4f76053c 100644 --- a/src/services/api/tsconfig.json +++ b/src/services/api/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { "target": "ES2016", - "moduleResolution": "node" - } + "moduleResolution": "node", + "module": "commonjs", + "allowSyntheticDefaultImports": true + }, + "include": ["./layers/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/src/services/ui/index.html b/src/services/ui/index.html index 7455ba647e..c0cb95a847 100644 --- a/src/services/ui/index.html +++ b/src/services/ui/index.html @@ -9,6 +9,10 @@ href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> + CMS MAKO diff --git a/src/services/ui/package.json b/src/services/ui/package.json index 5de0f4a9b5..885a3ac4ac 100644 --- a/src/services/ui/package.json +++ b/src/services/ui/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.6", @@ -43,6 +44,7 @@ "@tanstack/react-query": "^4.29.1", "@tanstack/react-query-devtools": "^4.29.5", "@types/file-saver": "^2.0.5", + "ajv-errors": "^3.0.0", "aws-amplify": "^5.2.5", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", diff --git a/src/services/ui/src/api/index.ts b/src/services/ui/src/api/index.ts index 7778f91a6c..9db0383485 100644 --- a/src/services/ui/src/api/index.ts +++ b/src/services/ui/src/api/index.ts @@ -1,4 +1,5 @@ export * from "./useSearch"; +export * from "./useGetForm"; export * from "./useGetItem"; export * from "./getAttachmentUrl"; export * from "./useGetPackageActions"; diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts new file mode 100644 index 0000000000..fb09f80b18 --- /dev/null +++ b/src/services/ui/src/api/useGetForm.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { API } from "aws-amplify"; +import { ReactQueryApiError } from "shared-types"; +import { FormSchema } from "shared-types"; +import { reInsertRegex } from "shared-utils"; + +export const getForm = async ( + formId: string, + formVersion?: string +): Promise => { + const form = await API.get("os", "/forms", { + queryStringParameters: { formId, formVersion }, + }); + + return reInsertRegex(form); +}; + +export const useGetForm = (formId: string, formVersion?: string) => { + return useQuery([formId, formVersion], () => + getForm(formId, formVersion) + ); +}; diff --git a/src/services/ui/src/components/BreadCrumb/BreadCrumb.tsx b/src/services/ui/src/components/BreadCrumb/BreadCrumb.tsx index bbd3787738..ef3cb8bda4 100644 --- a/src/services/ui/src/components/BreadCrumb/BreadCrumb.tsx +++ b/src/services/ui/src/components/BreadCrumb/BreadCrumb.tsx @@ -61,7 +61,7 @@ export const BreadCrumb = ({ {showSeperator && {seperator}} {active && ( - + {children} )} diff --git a/src/services/ui/src/components/Cards/OptionCard.test.tsx b/src/services/ui/src/components/Cards/OptionCard.test.tsx index 78af99e2f4..62ae3f023a 100644 --- a/src/services/ui/src/components/Cards/OptionCard.test.tsx +++ b/src/services/ui/src/components/Cards/OptionCard.test.tsx @@ -49,11 +49,11 @@ describe("OptionCard Component System", () => { expect(innerWrapper.className.includes("bg-slate-100")).toBeTruthy(); expect(innerWrapper.className.includes("bg-white")).toBeFalsy(); }); - test("title is rendered as an h3 and styled", () => { + test("title is rendered as an h2 and styled", () => { renderOptionCard(false); - const header = screen.getByRole("heading", { level: 3 }); + const header = screen.getByRole("heading", { level: 2 }); expect(header).toHaveTextContent("Test Card Title"); - expect(header).toHaveClass("text-lg text-sky-600 font-bold my-2"); + expect(header).toHaveClass("text-lg text-sky-700 font-bold my-2"); }); test("description is rendered", () => { renderOptionCard(false); diff --git a/src/services/ui/src/components/Cards/OptionCard.tsx b/src/services/ui/src/components/Cards/OptionCard.tsx index 6c4963d1f1..61d9fc23ab 100644 --- a/src/services/ui/src/components/Cards/OptionCard.tsx +++ b/src/services/ui/src/components/Cards/OptionCard.tsx @@ -45,10 +45,10 @@ export const OptionCard = ({ } hover:bg-sky-100`} > - {title} + {title} {description} - + diff --git a/src/services/ui/src/components/Inputs/button.tsx b/src/services/ui/src/components/Inputs/button.tsx index 51677f4531..e69a796fff 100644 --- a/src/services/ui/src/components/Inputs/button.tsx +++ b/src/services/ui/src/components/Inputs/button.tsx @@ -14,7 +14,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border-2 border-primary text-primary font-bold hover:text-accent-foreground", + "border border-primary text-primary font-bold hover:bg-primary/10", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", diff --git a/src/services/ui/src/components/Inputs/calendar.tsx b/src/services/ui/src/components/Inputs/calendar.tsx index 0a626733a8..080244a827 100644 --- a/src/services/ui/src/components/Inputs/calendar.tsx +++ b/src/services/ui/src/components/Inputs/calendar.tsx @@ -1,15 +1,9 @@ import * as React from "react"; +import { CalendarProps } from "shared-types"; import { DayPicker } from "react-day-picker"; - import { cn } from "@/lib/utils"; import { buttonVariants } from "./button"; -export type CalendarProps = React.ComponentProps & { - className?: string; - classNames?: any; - showOutsideDays?: boolean; -}; - function Calendar({ className, classNames, diff --git a/src/services/ui/src/components/Inputs/checkbox.tsx b/src/services/ui/src/components/Inputs/checkbox.tsx index 5e20915a6b..9fc81980c7 100644 --- a/src/services/ui/src/components/Inputs/checkbox.tsx +++ b/src/services/ui/src/components/Inputs/checkbox.tsx @@ -17,7 +17,7 @@ const Checkbox = React.forwardRef< ref={ref} id={props.label} className={cn( - "peer h-5 w-5 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + "peer h-7 w-7 my-2 shrink-0 border-black border-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground", className )} {...props} @@ -25,7 +25,7 @@ const Checkbox = React.forwardRef< - + diff --git a/src/services/ui/src/components/Inputs/date-picker.tsx b/src/services/ui/src/components/Inputs/date-picker.tsx index f85c207a99..0f4661cf39 100644 --- a/src/services/ui/src/components/Inputs/date-picker.tsx +++ b/src/services/ui/src/components/Inputs/date-picker.tsx @@ -3,17 +3,12 @@ import * as React from "react"; import { format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; - +import { DatePickerProps } from "shared-types"; import { cn } from "@/lib/utils"; import { Button } from "@/components/Inputs/button"; import { Calendar } from "@/components/Inputs/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; -type DatePickerProps = { - date: Date | undefined; - onChange: (date: Date | undefined) => void; -}; - export const DatePicker = ({ date, onChange }: DatePickerProps) => { const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); diff --git a/src/services/ui/src/components/Inputs/form.tsx b/src/services/ui/src/components/Inputs/form.tsx index f3ae142c85..9fb1c6aea4 100644 --- a/src/services/ui/src/components/Inputs/form.tsx +++ b/src/services/ui/src/components/Inputs/form.tsx @@ -95,7 +95,7 @@ const FormLabel = React.forwardRef< return ( @@ -148,7 +148,8 @@ const FormMessage = React.forwardRef< React.HTMLAttributes & { className?: string } >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField(); - const body = error ? String(error?.message) : children; + const body = + error && !Array.isArray(error) ? String(error?.message) : children; if (!body) { return null; diff --git a/src/services/ui/src/components/Inputs/input.tsx b/src/services/ui/src/components/Inputs/input.tsx index cf613eda2e..c7e1cd6696 100644 --- a/src/services/ui/src/components/Inputs/input.tsx +++ b/src/services/ui/src/components/Inputs/input.tsx @@ -1,26 +1,31 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; - -export interface InputProps - extends React.InputHTMLAttributes {} +import { InputProps } from "shared-types"; const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, icon, ...props }, ref) => { return ( - + {icon && ( + + {icon} + )} - ref={ref} - id={props.name} - {...props} - /> + + ); } ); + Input.displayName = "Input"; export { Input }; diff --git a/src/services/ui/src/components/Inputs/label.tsx b/src/services/ui/src/components/Inputs/label.tsx index 7c9606448a..a851368006 100644 --- a/src/services/ui/src/components/Inputs/label.tsx +++ b/src/services/ui/src/components/Inputs/label.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + "text-base leading-normal peer-disabled:cursor-not-allowed peer-disabled:opacity-70" ); const Label = React.forwardRef< diff --git a/src/services/ui/src/components/Inputs/radio-group.tsx b/src/services/ui/src/components/Inputs/radio-group.tsx index 891de3f1a5..d456a6598d 100644 --- a/src/services/ui/src/components/Inputs/radio-group.tsx +++ b/src/services/ui/src/components/Inputs/radio-group.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { CheckIcon } from "@radix-ui/react-icons"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { cn } from "@/lib/utils"; @@ -12,7 +11,7 @@ const RadioGroup = React.forwardRef< >(({ className, ...props }, ref) => { return ( @@ -25,18 +24,18 @@ const RadioGroupItem = React.forwardRef< React.ComponentPropsWithoutRef & { className?: string; } ->(({ className, children, ...props }, ref) => { +>(({ className, ...props }, ref) => { return ( - + ); diff --git a/src/services/ui/src/components/Inputs/select.tsx b/src/services/ui/src/components/Inputs/select.tsx index 39de1d001d..853cc0fa40 100644 --- a/src/services/ui/src/components/Inputs/select.tsx +++ b/src/services/ui/src/components/Inputs/select.tsx @@ -5,9 +5,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; - const SelectGroup = SelectPrimitive.Group; - const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< @@ -19,7 +17,7 @@ const SelectTrigger = React.forwardRef< & { className?: string }, - React.ComponentPropsWithoutRef & { - className?: string; - } + SwitchProps >(({ className, ...props }, ref) => ( { + (props.onChange as any)?.(value); + props.onCheckedChange?.(value); + }} ref={ref} > {} +import { TextareaProps } from "shared-types"; const Textarea = React.forwardRef( ({ className, ...props }, ref) => { + const strLn = (typeof props?.value === "string" && props.value.length) || 0; return ( - + + {props.charcount && ( + {`${strLn}${ + props.maxLength && props.charcount === "limited" + ? `/${props.maxLength}` + : "" + }`} )} - ref={ref} - {...props} - /> + > ); } ); diff --git a/src/services/ui/src/components/Layout/index.tsx b/src/services/ui/src/components/Layout/index.tsx index dd9dd10e73..d9c66f5d4b 100644 --- a/src/services/ui/src/components/Layout/index.tsx +++ b/src/services/ui/src/components/Layout/index.tsx @@ -1,4 +1,10 @@ -import { Link, NavLink, NavLinkProps, Outlet } from "react-router-dom"; +import { + Link, + NavLink, + NavLinkProps, + Outlet, + useNavigate, +} from "react-router-dom"; import oneMacLogo from "@/assets/onemac_logo.svg"; import { useMediaQuery } from "@/hooks"; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; @@ -10,35 +16,95 @@ import { Footer } from "../Footer"; import { UsaBanner } from "../UsaBanner"; import { FAQ_TARGET } from "@/routes"; import { useUserContext } from "../Context/userContext"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import config from "@/config"; const getLinks = (isAuthenticated: boolean, role?: boolean) => { - if (isAuthenticated && role) { - return [ - { - name: "Home", - link: "/", - }, - { - name: "Dashboard", - link: "/dashboard", - }, - { - name: "FAQ", - link: "/faq", - }, - ]; - } else { - return [ - { - name: "Home", - link: "/", - }, - { - name: "FAQ", - link: "/faq", - }, - ]; - } + const isProd = window && window.location.hostname === "mako.cms.gov"; + return [ + { + name: "Home", + link: "/", + condition: true, + }, + { + name: "Dashboard", + link: "/dashboard", + condition: isAuthenticated && role, + }, + { + name: "FAQ", + link: "/faq", + condition: true, + }, + { + name: "Webforms", + link: "/webforms", + condition: isAuthenticated && !isProd, + }, + ].filter((l) => l.condition); +}; + +const UserDropdownMenu = () => { + const navigate = useNavigate(); + + const handleViewProfile = () => { + navigate("/profile"); + }; + + const handleLogout = async () => { + await Auth.signOut(); + }; + + return ( + + + + My Account + + + + + + + + + + View Profile + + + + + Sign Out + + + + + + ); }; export const Layout = () => { @@ -80,6 +146,7 @@ export const Layout = () => { type ResponsiveNavProps = { isDesktop: boolean; }; + const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { const [prevMediaQuery, setPrevMediaQuery] = useState(isDesktop); const [isOpen, setIsOpen] = useState(false); @@ -98,8 +165,9 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { window.location.assign(url); }; - const handleLogout = async () => { - await Auth.signOut(); + const handleRegister = () => { + const url = `${config.idm.home_url}/signin/login.html`; + window.location.assign(url); }; if (isLoading || isError) return <>>; @@ -112,6 +180,7 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { setPrevMediaQuery(isDesktop); setIsOpen(false); } + if (isDesktop) { return ( <> @@ -128,19 +197,24 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { <> {data.user ? ( - - Sign Out - + // When the user is signed in + ) : ( - - Sign In - + // When the user is not signed in + <> + + Sign In + + + Register + + > )} > > @@ -166,19 +240,24 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { ))} <> {data.user ? ( - - Sign Out - + // When the user is signed in + ) : ( - - Sign In - + // When the user is not signed in + <> + + Sign In + + + Register + + > )} > @@ -195,3 +274,13 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { > ); }; + +export const SubNavHeader = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + +); diff --git a/src/services/ui/src/components/Opensearch/Filtering/FilterableDateRange.tsx b/src/services/ui/src/components/Opensearch/Filtering/FilterableDateRange.tsx index b33fb248a8..8123029ab9 100644 --- a/src/services/ui/src/components/Opensearch/Filtering/FilterableDateRange.tsx +++ b/src/services/ui/src/components/Opensearch/Filtering/FilterableDateRange.tsx @@ -1,10 +1,22 @@ -import { useState, useMemo, useEffect } from "react"; -import { format } from "date-fns"; +import { useState, useMemo } from "react"; +import { + format, + isAfter, + isBefore, + isValid, + parse, + startOfQuarter, + startOfMonth, + sub, + getYear, + endOfDay, + startOfDay, +} from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; import { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; -import { Button, Calendar } from "@/components/Inputs"; +import { Button, Calendar, Input } from "@/components/Inputs"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; import { OsRangeValue } from "shared-types"; @@ -19,23 +31,123 @@ type Props = Omit< export function FilterableDateRange({ value, onChange, ...props }: Props) { const [open, setOpen] = useState(false); - const [date, setDate] = useState({ + const [selectedDate, setSelectedDate] = useState({ from: value?.gte ? new Date(value?.gte) : undefined, to: value?.lte ? new Date(value?.lte) : undefined, }); + const [fromValue, setFromValue] = useState( + value?.gte ? format(new Date(value?.gte), "MM/dd/yyyy") : "" + ); + const [toValue, setToValue] = useState( + value?.lte ? format(new Date(value?.lte), "MM/dd/yyyy") : "" + ); const handleClose = (updateOpen: boolean) => { setOpen(updateOpen); }; + const checkSingleDateSelection = ( + from: Date | undefined, + to: Date | undefined + ) => { + if (from && !to) { + const rangeObject = getDateRange(from, endOfDay(from)); + onChange(rangeObject); + setFromValue(format(from, "MM/dd/yyyy")); + } + }; + + const onFromInput = (e: React.ChangeEvent) => { + const minValidYear = 1960; + const input = e.target.value; + + if (/^[0-9/]*$/.test(input)) { + setFromValue(e.target.value); + const date = parse(e.target.value, "MM/dd/yyyy", new Date()); + if ( + !isValid(date) || + getYear(date) < minValidYear || + isAfter(date, new Date()) + ) { + return setSelectedDate({ from: undefined, to: selectedDate?.to }); + } + if (selectedDate?.to && isAfter(date, selectedDate.to)) { + setSelectedDate({ from: date, to: undefined }); + setToValue(""); + } else { + setSelectedDate({ from: date, to: selectedDate?.to }); + onChange({ + gte: date.toISOString(), + lte: selectedDate?.to?.toISOString() || "", + }); + } + } + }; + + const onToInput = (e: React.ChangeEvent) => { + const minValidYear = 1960; + const inputValue = e.target.value; + + if (/^[0-9/]*$/.test(inputValue)) { + setToValue(e.target.value); + const date = parse(inputValue, "MM/dd/yyyy", new Date()); + + if ( + !isValid(date) || + getYear(date) < minValidYear || + isAfter(date, new Date()) + ) { + return setSelectedDate({ from: selectedDate?.from, to: undefined }); + } + + if (selectedDate?.from && isBefore(date, selectedDate.from)) { + setSelectedDate({ from: undefined, to: selectedDate.from }); + setFromValue(""); + } else { + setSelectedDate({ from: selectedDate?.from, to: date }); + onChange({ + gte: selectedDate?.from?.toISOString() || "", + lte: endOfDay(date).toISOString(), + }); + } + } + }; + + const getDateRange = (startDate: Date, endDate: Date): OsRangeValue => { + return { + gte: startDate.toISOString(), + lte: endDate.toISOString(), + }; + }; + + const setPresetRange = (range: string) => { + const today = startOfDay(new Date()); + let startDate = today; + if (range === "quarter") { + startDate = startOfQuarter(today); + } else if (range === "month") { + startDate = startOfMonth(today); + } else if (range === "week") { + startDate = sub(today, { days: 6 }); + } + + const rangeObject = getDateRange(startDate, endOfDay(today)); + onChange(rangeObject); + setSelectedDate({ from: startDate, to: today }); + setFromValue(format(startDate, "MM/dd/yyyy")); + setToValue(format(today, "MM/dd/yyyy")); + }; + const label = useMemo(() => { - const from = date?.from ? format(date.from, "LLL dd, y") : ""; - const to = date?.to ? format(date.to, "LLL dd, y") : ""; + const from = selectedDate?.from + ? format(selectedDate.from, "LLL dd, y") + : ""; + const to = selectedDate?.to ? format(selectedDate.to, "LLL dd, y") : ""; if (from && to) return `${from} - ${to}`; if (from) return `${from}`; return "Pick a date"; - }, [date]); + }, [selectedDate]); return ( @@ -57,28 +169,66 @@ export function FilterableDateRange({ value, onChange, ...props }: Props) { disabled={[{ after: new Date() }]} initialFocus mode="range" - defaultMonth={date?.from} - selected={date} + defaultMonth={selectedDate?.from} + selected={selectedDate} numberOfMonths={2} className="bg-white" onSelect={(d) => { - setDate(d); + setSelectedDate(d); if (!!d?.from && !!d.to) { onChange({ gte: d.from.toISOString(), - lte: d.to.toISOString(), + lte: endOfDay(d.to).toISOString(), + }); + setFromValue(format(d.from, "MM/dd/yyyy")); + setToValue(format(d.to, "MM/dd/yyyy")); + } else if (!d?.from && !d?.to) { + onChange({ + gte: "", + lte: "", }); + setFromValue(""); + setToValue(""); + } else { + checkSingleDateSelection(d.from, d.to); } }} {...props} /> + + + - + + + + setPresetRange("today")}>Today + setPresetRange("week")}>Last 7 Days + setPresetRange("month")}> + Month To Date + + setPresetRange("quarter")}> + Quarter To Date + + { - setDate({ from: undefined, to: undefined }); + setSelectedDate({ from: undefined, to: undefined }); onChange({ gte: undefined, lte: undefined }); + setToValue(""); + setFromValue(""); }} > Clear diff --git a/src/services/ui/src/components/Opensearch/Filtering/consts.ts b/src/services/ui/src/components/Opensearch/Filtering/consts.ts index c6da029d07..3ca849bc8b 100644 --- a/src/services/ui/src/components/Opensearch/Filtering/consts.ts +++ b/src/services/ui/src/components/Opensearch/Filtering/consts.ts @@ -29,7 +29,7 @@ export const FILTER_GROUPS = ( value: [], }, "planType.keyword": { - label: "Plan Type", + label: "Type", field: "planType.keyword", component: "multiCheck", prefix: "must", @@ -61,7 +61,7 @@ export const FILTER_GROUPS = ( value: { gte: undefined, lte: undefined }, }, raiReceivedDate: { - label: "RAI Response Date", + label: "Formal RAI Response", field: "raiReceivedDate", component: "dateRange", prefix: "must", diff --git a/src/services/ui/src/components/Opensearch/Filtering/useFilterDrawer.ts b/src/services/ui/src/components/Opensearch/Filtering/useFilterDrawer.ts index 2ff772e4c9..e6313c66e6 100644 --- a/src/services/ui/src/components/Opensearch/Filtering/useFilterDrawer.ts +++ b/src/services/ui/src/components/Opensearch/Filtering/useFilterDrawer.ts @@ -51,7 +51,7 @@ export const useFilterDrawer = () => { // update initial filter state + accordion default open items useEffect(() => { - if (!drawerOpen) return; + if (drawerOpen) return; const updateAccordions = [] as any[]; setFilters((s) => { diff --git a/src/services/ui/src/components/Opensearch/Settings/Visibility.tsx b/src/services/ui/src/components/Opensearch/Settings/Visibility.tsx index 4a88fb3e63..389fda4af7 100644 --- a/src/services/ui/src/components/Opensearch/Settings/Visibility.tsx +++ b/src/services/ui/src/components/Opensearch/Settings/Visibility.tsx @@ -14,6 +14,7 @@ export const VisibilityPopover = (props: Props) => { + Visibility Popover Icon diff --git a/src/services/ui/src/components/Opensearch/Table/index.tsx b/src/services/ui/src/components/Opensearch/Table/index.tsx index 2ff0fec041..a71f2b0586 100644 --- a/src/services/ui/src/components/Opensearch/Table/index.tsx +++ b/src/services/ui/src/components/Opensearch/Table/index.tsx @@ -32,7 +32,7 @@ export const OsTable: FC<{ }; return ( - + )} - + {context.data && !context.data.hits.length && ( + + + + + No Results Found + + + + )} {context.data?.hits.map((DAT) => ( diff --git a/src/services/ui/src/components/Opensearch/useOpensearch.ts b/src/services/ui/src/components/Opensearch/useOpensearch.ts index 728fc6de06..db038b55d9 100644 --- a/src/services/ui/src/components/Opensearch/useOpensearch.ts +++ b/src/services/ui/src/components/Opensearch/useOpensearch.ts @@ -100,7 +100,7 @@ export const useOsAggregate = () => { field: "leadAnalystName.keyword", name: "leadAnalystName.keyword", type: "terms", - size: 200, + size: 1000, }, ], filters: DEFAULT_FILTERS[props.queryKey[0]].filters || [], diff --git a/src/services/ui/src/components/Pagination/index.tsx b/src/services/ui/src/components/Pagination/index.tsx index 6bc3cb5ae3..f326bf4dd8 100644 --- a/src/services/ui/src/components/Pagination/index.tsx +++ b/src/services/ui/src/components/Pagination/index.tsx @@ -26,9 +26,10 @@ export const Pagination: FC = (props) => { - + Records per page: props.onSizeChange?.(Number(e.target.value))} @@ -76,7 +77,10 @@ export const Pagination: FC = (props) => { return ( ... = (props) => { props.onPageChange(Number(v.currentTarget.value) - 1) } className="absolute w-auto h-auto opacity-0 cursor-pointer" + aria-labelledby="morePagesButton" + id="pagesDropdown" > {PAGE.map((P) => ( @@ -102,11 +108,11 @@ export const Pagination: FC = (props) => { className={cn( "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0", { - "bg-blue-500": isActive, + "bg-blue-700": isActive, "focus-visible:outline-indigo-600": isActive, "text-white": isActive, "hover:text-black": isActive, - "hover:bg-blue-500": isActive, + "hover:bg-blue-700": isActive, } )} > diff --git a/src/services/ui/src/components/RHF/Document.tsx b/src/services/ui/src/components/RHF/Document.tsx new file mode 100644 index 0000000000..8f9d05460a --- /dev/null +++ b/src/services/ui/src/components/RHF/Document.tsx @@ -0,0 +1,30 @@ +import { Control, FieldValues } from "react-hook-form"; + +import { FormLabel } from "../Inputs"; +import { RHFSection } from "./Section"; +import { FormSchema } from "shared-types"; + +export const RHFDocument = (props: { + document: FormSchema; + control: Control; +}) => { + return ( + + + + + + {props.document.header} + + + {props.document.sections.map((SEC, index) => ( + + ))} + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FieldArray.tsx b/src/services/ui/src/components/RHF/FieldArray.tsx new file mode 100644 index 0000000000..61a594d7d8 --- /dev/null +++ b/src/services/ui/src/components/RHF/FieldArray.tsx @@ -0,0 +1,69 @@ +import { FieldValues, useFieldArray } from "react-hook-form"; +import { Plus, Trash2 } from "lucide-react"; + +import { RHFSlot } from "./Slot"; +import { Button, FormField } from "../Inputs"; +import { FieldArrayProps } from "shared-types"; +import { slotInitializer } from "./utils"; +import { useEffect } from "react"; + +export const RHFFieldArray = ( + props: FieldArrayProps +) => { + const fieldArr = useFieldArray({ + control: props.control, + name: props.name, + shouldUnregister: true, + }); + + const onAppend = () => { + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }; + + useEffect(() => { + if (fieldArr.fields.length) return; + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }, []); + + return ( + + {fieldArr.fields.map((FLD, index) => { + return ( + + {props.fields.map((SLOT) => { + const prefix = `${props.name}.${index}.`; + const adjustedPrefix = (props.groupNamePrefix ?? "") + prefix; + const adjustedSlotName = prefix + SLOT.name; + return ( + + ); + })} + {index >= 1 && ( + fieldArr.remove(index)} + /> + )} + + ); + })} + + + + {props.appendText ?? "New Row"} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FieldGroup.tsx b/src/services/ui/src/components/RHF/FieldGroup.tsx new file mode 100644 index 0000000000..fcebaea3dd --- /dev/null +++ b/src/services/ui/src/components/RHF/FieldGroup.tsx @@ -0,0 +1,77 @@ +import { FieldValues, useFieldArray } from "react-hook-form"; +import { Plus } from "lucide-react"; + +import { RHFSlot } from "./Slot"; +import { Button, FormField } from "../Inputs"; +import { FieldGroupProps } from "shared-types"; +import { slotInitializer } from "./utils"; +import { useEffect } from "react"; + +export const FieldGroup = ( + props: FieldGroupProps +) => { + const fieldArr = useFieldArray({ + control: props.control, + name: props.name, + shouldUnregister: true, + }); + + const onAppend = () => { + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }; + + useEffect(() => { + if (fieldArr.fields.length) return; + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }, []); + + return ( + + {fieldArr.fields.map((FLD, index) => { + return ( + + {props.fields.map((SLOT) => { + const prefix = `${props.name}.${index}.`; + const adjustedPrefix = (props.groupNamePrefix ?? "") + prefix; + const adjustedSlotName = prefix + SLOT.name; + return ( + + ); + })} + {index >= 1 && ( + { + fieldArr.remove(index); + }} + > + {props.removeText ?? "Remove Group"} + + )} + {fieldArr.fields.length > 1 && ( + + )} + + ); + })} + + + + {props.appendText ?? "New Group"} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FormGroup.tsx b/src/services/ui/src/components/RHF/FormGroup.tsx new file mode 100644 index 0000000000..10efe5ea9b --- /dev/null +++ b/src/services/ui/src/components/RHF/FormGroup.tsx @@ -0,0 +1,41 @@ +import { Control, FieldValues } from "react-hook-form"; +import { FormLabel, FormField } from "../Inputs"; +import { DependencyWrapper } from "./dependencyWrapper"; +import { RHFSlot } from "./Slot"; +import * as TRhf from "shared-types"; + +export const RHFFormGroup = (props: { + form: TRhf.FormGroup; + control: Control; + groupNamePrefix?: string; +}) => { + return ( + + + {props.form.description && ( + + + {props.form?.description} + + + )} + + {props.form.slots.map((SLOT) => ( + + + + ))} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/Section.tsx b/src/services/ui/src/components/RHF/Section.tsx new file mode 100644 index 0000000000..4adf2be47a --- /dev/null +++ b/src/services/ui/src/components/RHF/Section.tsx @@ -0,0 +1,34 @@ +/* eslint-disable react/prop-types */ +import { Control, FieldValues } from "react-hook-form"; +import { FormLabel } from "../Inputs"; +import { DependencyWrapper } from "./dependencyWrapper"; +import { Section } from "shared-types"; +import { RHFFormGroup } from "./FormGroup"; + +export const RHFSection = (props: { + section: Section; + control: Control; +}) => { + return ( + + + {props.section.title && ( + + + {props.section.title} + + + )} + + {props.section.form.map((FORM, index) => ( + + ))} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/Slot.tsx b/src/services/ui/src/components/RHF/Slot.tsx new file mode 100644 index 0000000000..d69d2c7655 --- /dev/null +++ b/src/services/ui/src/components/RHF/Slot.tsx @@ -0,0 +1,298 @@ +/* eslint-disable react/prop-types */ +import { ControllerProps, FieldPath, FieldValues } from "react-hook-form"; +import { + Button, + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, + Input, + Switch, + Textarea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + RadioGroup, + RadioGroupItem, + Calendar, + FormField, + Checkbox, +} from "../Inputs"; +import { RHFFormGroup } from "./FormGroup"; +import { CalendarIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; +import { cn } from "@/lib"; +import { format } from "date-fns"; +import { RHFFieldArray } from "./FieldArray"; +import { FieldGroup } from "./FieldGroup"; +import type { RHFSlotProps, RHFComponentMap, FormGroup } from "shared-types"; +import { useEffect, useMemo } from "react"; + +export const RHFSlot = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + control, + rhf, + label, + description, + name, + props, + labelStyling, + groupNamePrefix, + ...rest +}: RHFSlotProps & { control: any }): ControllerProps< + TFieldValues, + TName +>["render"] => + function Slot({ field }) { + // added to unregister/reset inputs when removed from dom + useEffect(() => { + return () => { + control.unregister(field.name); + }; + }, []); + + return ( + + {label && {label}} + + <> + {/* ----------------------------------------------------------------------------- */} + {rhf === "Input" && + (() => { + const hops = props as RHFComponentMap["Input"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Textarea" && + (() => { + const hops = props as RHFComponentMap["Textarea"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Switch" && + (() => { + const hops = props as RHFComponentMap["Switch"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Select" && + (() => { + const hops = props as RHFComponentMap["Select"]; + const opts = useMemo(() => { + if (hops.sort) { + const sorted = hops.options.sort((a, b) => + a.label.localeCompare(b.label) + ); + hops.sort === "descending" && sorted.reverse(); + return sorted; + } + return hops.options; + }, [hops.options, hops.sort]); + + return ( + + + + + + {opts.map((OPT) => ( + + {OPT.label} + + ))} + + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Radio" && + (() => { + const hops = props as RHFComponentMap["Radio"]; + return ( + + {hops.options.map((OPT) => { + return ( + + + + + {OPT.label} + + + {field.value === OPT.value && + OPT.form && + OPT.form.map((FORM, index) => { + return ( + + + + ); + })} + {field.value === OPT.value && + OPT.slots && + OPT.slots.map((SLOT, index) => ( + + + + ))} + + ); + })} + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Checkbox" && + (() => { + const hops = props as RHFComponentMap["Checkbox"]; + return ( + + {hops.options.map((OPT) => ( + + { + const filtered = + field.value?.filter( + (f: unknown) => f !== OPT.value + ) || []; + if (!c) return field.onChange(filtered); + field.onChange([...filtered, OPT.value]); + }} + /> + {field.value?.includes(OPT.value) && + !!OPT.slots && + OPT.slots && + OPT.slots.map((SLOT, index) => ( + + + + ))} + + {field.value?.includes(OPT.value) && + !!OPT.form && + OPT.form.map((FORM: FormGroup) => ( + + + + ))} + + ))} + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "DatePicker" && + (() => { + const hops = props as RHFComponentMap["DatePicker"]; + return ( + + + + + {field.value ? ( + format(field.value, "PPP") + ) : ( + Pick a date + )} + + + + + + + + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "FieldArray" && ( + + )} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "FieldGroup" && ( + + )} + > + + {description && {description}} + + + ); + }; diff --git a/src/services/ui/src/components/RHF/dependencyWrapper.tsx b/src/services/ui/src/components/RHF/dependencyWrapper.tsx new file mode 100644 index 0000000000..2c134adfa2 --- /dev/null +++ b/src/services/ui/src/components/RHF/dependencyWrapper.tsx @@ -0,0 +1,68 @@ +import { PropsWithChildren, useEffect, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { DependencyRule, DependencyWrapperProps } from "shared-types"; + +const checkTriggeringValue = ( + dependentValue: unknown[], + dependency?: DependencyRule +) => { + return !!dependency?.conditions?.every((d, i) => { + switch (d.type) { + case "expectedValue": + return dependentValue[i] === d?.expectedValue; + case "valueExists": + return ( + (Array.isArray(dependentValue[i]) && + (dependentValue[i] as unknown[]).length > 0) || + !!dependentValue[i] + ); + case "valueNotExist": + return ( + (Array.isArray(dependentValue[i]) && + (dependentValue[i] as unknown[]).length === 0) || + !dependentValue[i] + ); + } + }); +}; + +export const DependencyWrapper = ({ + name, + dependency, + children, +}: PropsWithChildren) => { + const { watch, setValue } = useFormContext(); + const [wasSetLast, setWasSetLast] = useState(false); + const dependentValues = watch( + dependency?.conditions?.map((c) => c.name) ?? [] + ); + const isTriggered = + dependency && checkTriggeringValue(dependentValues, dependency); + + useEffect(() => { + if ( + !wasSetLast && + dependency?.effect.type === "setValue" && + isTriggered && + !!name + ) { + setValue(name, dependency.effect.newValue); + setWasSetLast(true); + } else if (!isTriggered && wasSetLast) { + setWasSetLast(false); + } + }, [dependentValues]); + + switch (dependency?.effect.type) { + case "hide": + if (isTriggered) { + return null; + } + break; + case "show": + if (isTriggered) return <>{children}>; + else return null; + } + + return <>{children}>; +}; diff --git a/src/services/ui/src/components/RHF/index.ts b/src/services/ui/src/components/RHF/index.ts new file mode 100644 index 0000000000..917f9268e8 --- /dev/null +++ b/src/services/ui/src/components/RHF/index.ts @@ -0,0 +1,5 @@ +export * from "./Document"; +export * from "./FormGroup"; +export * from "./Section"; +export * from "./Slot"; +export * from "./utils"; diff --git a/src/services/ui/src/components/RHF/utils/index.ts b/src/services/ui/src/components/RHF/utils/index.ts new file mode 100644 index 0000000000..a029df0a8d --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./initializer"; +export * from "./validator"; diff --git a/src/services/ui/src/components/RHF/utils/initializer.ts b/src/services/ui/src/components/RHF/utils/initializer.ts new file mode 100644 index 0000000000..444bfcee3e --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/initializer.ts @@ -0,0 +1,58 @@ +import * as T from "shared-types"; +import { FormSchema } from "shared-types"; + +type GL = Record; + +export const formGroupInitializer = (ACC: GL, FORM: T.FormGroup) => { + FORM.slots.reduce(slotInitializer, ACC); + return ACC; +}; + +export const slotInitializer = (ACC: GL, SLOT: T.RHFSlotProps): GL => { + const optionReducer = (OPT: T.RHFOption) => { + if (OPT.form) OPT.form.reduce(formGroupInitializer, ACC); + if (OPT.slots) OPT.slots.reduce(slotInitializer, ACC); + return ACC; + }; + + const fieldInitializer = (ACC1: GL, SLOT: T.RHFSlotProps): GL => { + if (SLOT.rhf === "FieldArray" || SLOT.rhf === "FieldGroup") { + return { + ...ACC1, + [SLOT.name]: [SLOT.fields?.reduce(fieldInitializer, {})], + }; + } + + return { ...ACC1, ...slotInitializer(ACC1, SLOT) }; + }; + + switch (SLOT.rhf) { + case "Switch": + ACC[SLOT.name] = false; + break; + case "Radio": + case "Checkbox": + SLOT.props?.options.forEach(optionReducer); + ACC[SLOT.name] = []; + break; + case "FieldArray": + case "FieldGroup": + ACC[SLOT.name] = [SLOT.fields?.reduce(fieldInitializer, {})]; + break; + case "Input": + case "Select": + case "Textarea": + default: + ACC[SLOT.name] = ""; + break; + } + + return ACC; +}; + +export const documentInitializer = (document: FormSchema) => { + return document.sections.reduce((ACC, SEC) => { + SEC.form.reduce(formGroupInitializer, ACC); + return ACC; + }, {}); +}; diff --git a/src/services/ui/src/components/RHF/utils/is.ts b/src/services/ui/src/components/RHF/utils/is.ts new file mode 100644 index 0000000000..98c4b89b77 --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/is.ts @@ -0,0 +1,55 @@ +import { ValidationRule } from "react-hook-form"; + +export const INPUT_VALIDATION_RULES = { + max: "max", + min: "min", + maxLength: "maxLength", + minLength: "minLength", + pattern: "pattern", + required: "required", + validate: "validate", +} as const; +export type InputValidationRules = typeof INPUT_VALIDATION_RULES; +export type ERROR = Record; +export type MaxType = + | InputValidationRules["max"] + | InputValidationRules["maxLength"]; + +export type MinType = + | InputValidationRules["min"] + | InputValidationRules["minLength"]; + +// eslint-disable-next-line @typescript-eslint/ban-types +export const isFunction = (value: unknown): value is Function => + typeof value === "function"; + +export const isNullOrUndefined = (value: unknown): value is null | undefined => + value == null; + +export const isUndefined = (val: unknown): val is undefined => + val === undefined; + +export const isDateObject = (value: unknown): value is Date => + value instanceof Date; + +export const isObjectType = (value: unknown) => typeof value === "object"; + +export const isString = (value: unknown): value is string => + typeof value === "string"; + +export const isObject = (value: unknown): value is T => + !isNullOrUndefined(value) && + !Array.isArray(value) && + isObjectType(value) && + !isDateObject(value); + +export const isRegex = (value: unknown): value is RegExp => + value instanceof RegExp; + +export const getValueAndMessage = (validationData?: ValidationRule) => + isObject(validationData) && !isRegex(validationData) + ? validationData + : { + value: validationData, + message: "", + }; diff --git a/src/services/ui/src/components/RHF/utils/validator.ts b/src/services/ui/src/components/RHF/utils/validator.ts new file mode 100644 index 0000000000..3d80b3f20e --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/validator.ts @@ -0,0 +1,256 @@ +import * as T from "shared-types"; +import { RegisterOptions } from "react-hook-form"; +import { FormSchema } from "shared-types"; + +import { + isNullOrUndefined, + isUndefined, + isRegex, + getValueAndMessage, + isString, + ERROR, + // INPUT_VALIDATION_RULES, + // isFunction, + // MaxType, + // MinType, +} from "./is"; + +export const validateInput = (inputValue: any, rules?: RegisterOptions) => { + const isEmpty = + isUndefined(inputValue) || + inputValue === "" || + (Array.isArray(inputValue) && !inputValue.length); + + if (isEmpty && rules?.required) { + return isString(rules.required) ? rules.required : "*Required"; + } + + if ( + !isEmpty && + (!isNullOrUndefined(rules?.min) || !isNullOrUndefined(rules?.max)) + ) { + let exceedMax; + let exceedMin; + const maxOutput = getValueAndMessage(rules?.max); + const minOutput = getValueAndMessage(rules?.min); + + if (!isNullOrUndefined(inputValue) && !isNaN(inputValue as number)) { + const valueNumber = inputValue ? +inputValue : inputValue; + if (!isNullOrUndefined(maxOutput.value)) { + exceedMax = valueNumber > maxOutput.value; + } + if (!isNullOrUndefined(minOutput.value)) { + exceedMin = valueNumber < minOutput.value; + } + } else { + const valueDate = new Date(inputValue as string); + // const convertTimeToDate = (time: unknown) => + // new Date(new Date().toDateString() + " " + time); + // // const isTime = ref.type == "time"; + // // const isWeek = ref.type == "week"; + + if (isString(maxOutput.value) && inputValue) { + exceedMax = valueDate > new Date(maxOutput.value); + } + + if (isString(minOutput.value) && inputValue) { + exceedMin = valueDate < new Date(minOutput.value); + } + } + + if (exceedMax) return maxOutput.message; + if (exceedMin) return minOutput.message; + } + + if ( + (rules?.maxLength || rules?.minLength) && + !isEmpty && + isString(inputValue) + ) { + const maxLengthOutput = getValueAndMessage(rules?.maxLength); + const minLengthOutput = getValueAndMessage(rules?.minLength); + const exceedMax = + !isNullOrUndefined(maxLengthOutput.value) && + inputValue.length > +maxLengthOutput.value; + const exceedMin = + !isNullOrUndefined(minLengthOutput.value) && + inputValue.length < +minLengthOutput.value; + + if (exceedMax) return maxLengthOutput.message; + if (exceedMin) return minLengthOutput.message; + } + + if (rules?.pattern && !isEmpty && isString(inputValue)) { + const { value: patternValue, message } = getValueAndMessage(rules?.pattern); + + if (isRegex(patternValue) && !inputValue.match(patternValue)) { + return message; + } + } + // TODO: Add validate condition + // if (rules?.validate) { + // if (isFunction(rules?.validate)) { + // const result = await rules?.validate(inputValue, formValues); + // const validateError = getValidateError(result, inputRef); + + // if (validateError) { + // error[name] = { + // ...validateError, + // ...appendErrorsCurry( + // INPUT_VALIDATION_RULES.validate, + // validateError.message + // ), + // }; + // if (!validateAllFieldCriteria) { + // setCustomValidity(validateError.message); + // return error; + // } + // } + // } else if (isObject(validate)) { + // let validationResult = {} as FieldError; + + // for (const key in validate) { + // if (!isEmptyObject(validationResult) && !validateAllFieldCriteria) { + // break; + // } + + // const validateError = getValidateError( + // await validate[key](inputValue, formValues), + // inputRef, + // key + // ); + + // if (validateError) { + // validationResult = { + // ...validateError, + // ...appendErrorsCurry(key, validateError.message), + // }; + + // setCustomValidity(validateError.message); + + // if (validateAllFieldCriteria) { + // error[name] = validationResult; + // } + // } + // } + + // if (!isEmptyObject(validationResult)) { + // error[name] = { + // ref: inputRef, + // ...validationResult, + // }; + // if (!validateAllFieldCriteria) { + // return error; + // } + // } + // } + // } + + // If all checks pass, the input value is valid + return ""; +}; + +export const validateOption = (optionValue: string, options: any[]) => { + return options.find((OPT: any) => OPT.value === optionValue); +}; + +export const formGroupValidator = + (data: any) => (ACC: ERROR, FORM: T.FormGroup) => { + FORM.slots.reduce(slotValidator(data), ACC); + return ACC; + }; + +export const slotValidator = + (data: any) => + (ACC: ERROR, SLOT: T.RHFSlotProps): ERROR => { + const optionValidator = (OPT: T.RHFOption) => { + if (OPT.form) OPT.form.reduce(formGroupValidator(data), ACC); + if (OPT.slots) { + OPT.slots.reduce(slotValidator(data), ACC); + } + return ACC; + }; + + const fieldValidator = (FLD: any) => (SLOT1: T.RHFSlotProps) => { + if (SLOT1.rhf === "FieldArray") { + FLD[SLOT1.name].forEach((DAT: any) => { + SLOT1.fields?.forEach(fieldValidator(DAT)); + }); + } else if (SLOT1.rhf === "FieldGroup") { + FLD[SLOT1.name].forEach((DAT: any) => { + SLOT1.fields?.forEach(fieldValidator(DAT)); + }); + } else { + slotValidator(FLD)(ACC, SLOT1); + } + }; + + if (SLOT.rhf === "Input") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + if (SLOT.rhf === "Textarea") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + + if (SLOT.rhf === "Switch") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + + if (SLOT.rhf === "Radio") { + const validOption = SLOT.props?.options.find( + (OPT) => OPT.value === data[SLOT.name] + ); + if (!validOption) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } else { + optionValidator(validOption); + } + } + + if (SLOT.rhf === "Select") { + const validOption = SLOT.props?.options.find( + (OPT) => OPT.value === data[SLOT.name] + ); + if (!validOption) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } else { + optionValidator(validOption); + } + } + + if (SLOT.rhf === "Checkbox") { + if (data[SLOT.name]?.length) { + const validList = data[SLOT.name].every((VAL: any) => + SLOT.props?.options.some((OPT) => OPT.value === VAL) + ); + if (!validList) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } + + const selectedOptions = SLOT.props?.options.filter((OPT) => + data[SLOT.name].includes(OPT.value) + ); + selectedOptions?.forEach(optionValidator); + } + } + + if (SLOT.rhf === "FieldArray") { + data[SLOT.name].forEach((DAT: any) => { + SLOT.fields?.forEach(fieldValidator(DAT)); + }); + } + if (SLOT.rhf === "FieldGroup") { + data[SLOT.name].forEach((DAT: any) => { + SLOT.fields?.forEach(fieldValidator(DAT)); + }); + } + + return ACC; + }; + +export const documentValidator = (document: FormSchema) => (data: any) => { + return document.sections.reduce((ACC, SEC) => { + SEC.form.reduce(formGroupValidator(data), ACC); + return ACC; + }, {} as ERROR); +}; diff --git a/src/services/ui/src/components/SearchForm/index.tsx b/src/services/ui/src/components/SearchForm/index.tsx index bf25f0a36a..6e5fccce67 100644 --- a/src/services/ui/src/components/SearchForm/index.tsx +++ b/src/services/ui/src/components/SearchForm/index.tsx @@ -44,7 +44,11 @@ export const SearchForm: FC<{ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + + Search by Package ID, CPOC Name, or Submitter Name + (({ className, ...props }, ref) => ( - + { + return ( + + {"PRA Disclosure Statement"} + + { + "All State Medicaid agencies administering or supervising the \ + administration of 1915(c) home and community-based services (HCBS) \ + waivers are required to submit an annual Form CMS-372(S) Report for each \ + approved waiver. Section 1915(c)(2)(E) of the SocialSecurity Act \ + requires states to annually provide CMS with information on the waiver's \ + impact on the type, amount and cost of services provided under the state \ + plan in addition to the health and welgare of recipients. Under the \ + Privacy Act of 1974 any personally identifying information obatined will \ + be kept private to the extent of the law." + } + + + { + "Accordint to the Paperwork Reduction Act of 1995, no persons are required to respond \ + to a collection of information unless it displays a valid OMB control number. The valid OMB \ + control number for this information colleciton is 0938-0272. The time required to complete \ + this information collection is estimated to average 44 hours per response, including the time to \ + review instructions, search existing data resources, gather the data needed, and complete and \ + review the information collection. If you have comments concerning the accuracy of the time \ + estimate(s) or suggestions for improving this form, please write to:" + } + + + { + "CMS, 7500 Security Boulevard, Attn: PRA Reports Clearance Officer, Mail Stop C4-26-05, Baltimore, Maryland 21244-1850." + } + + + ); +}; diff --git a/src/services/ui/src/components/Webform/index.tsx b/src/services/ui/src/components/Webform/index.tsx new file mode 100644 index 0000000000..c214c4f913 --- /dev/null +++ b/src/services/ui/src/components/Webform/index.tsx @@ -0,0 +1,81 @@ +import { useForm } from "react-hook-form"; +import { Button, Form } from "@/components/Inputs"; +import { RHFDocument } from "@/components/RHF"; +import { Link, useParams } from "react-router-dom"; +import { SubNavHeader } from "@/components"; +import { documentInitializer } from "@/components/RHF/utils"; +import { useGetForm } from "@/api"; +import { LoadingSpinner } from "@/components"; +import { Footer } from "./footer"; +export const Webforms = () => { + return ( + <> + + Webforms + + + + ABP1 + + + > + ); +}; + +export function Webform() { + const { id, version } = useParams<{ + id: string; + version: string; + }>(); + const { data, isLoading, error } = useGetForm(id as string, version); + + const defaultValues = data ? documentInitializer(data) : {}; + const savedData = localStorage.getItem(`${id}v${version}`); + const form = useForm({ + defaultValues: savedData ? JSON.parse(savedData) : defaultValues, + }); + + const onSave = () => { + const values = form.getValues(); + localStorage.setItem(`${id}v${version}`, JSON.stringify(values)); + alert("Saved"); + }; + + const onSubmit = form.handleSubmit( + (data) => { + console.log({ data }); + // const validate = documentValidator(ABP1); + // const isValid = validate(data); + // console.log({ isValid }); + }, + (err) => { + console.log({ err }); + } + ); + + if (isLoading) return ; + if (error || !data) { + return ( + + {`There was an error loading ${id}`} + + ); + } + + return ( + + + + + + + Save draft + + Submit + + + + + + ); +} diff --git a/src/services/ui/src/components/index.tsx b/src/services/ui/src/components/index.tsx index e1e79751d3..edad3ab571 100644 --- a/src/services/ui/src/components/index.tsx +++ b/src/services/ui/src/components/index.tsx @@ -17,3 +17,4 @@ export * from "./SearchForm"; export * from "./SubmissionInfo"; export * from "./Modal"; export * from "./Dialog"; +export * from "./Webform"; diff --git a/src/services/ui/src/index.css b/src/services/ui/src/index.css index 1aed288ae5..e0ed753f78 100644 --- a/src/services/ui/src/index.css +++ b/src/services/ui/src/index.css @@ -72,6 +72,7 @@ } body { @apply bg-background text-foreground; + font-family: "Open Sans", system-ui, sans-serif; font-feature-settings: "rlig" 1, "calt" 1; } } diff --git a/src/services/ui/src/pages/create/create-form.tsx b/src/services/ui/src/pages/create/create-form.tsx index 979b28b84a..bcb5f9e19f 100644 --- a/src/services/ui/src/pages/create/create-form.tsx +++ b/src/services/ui/src/pages/create/create-form.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { getUserStateCodes } from "@/utils"; import { useGetUser } from "@/api/useGetUser"; import { useParams } from "react-router-dom"; +import { SubNavHeader } from "@/components"; type FormData = { id: string; @@ -55,15 +56,9 @@ export const Create = () => { return ( <> - - - - - Initial Submission - - - - + + Initial Submission + diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx index 56be766d13..b0085e5c2a 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx @@ -19,7 +19,7 @@ export const SpasList = () => { const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return ( - + { const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return ( - + { Dashboard {!userContext?.isCms && ( - - New Submission - + + New Submission + )} @@ -80,10 +80,10 @@ export const Dashboard = () => { > - SPAs + SPAs - Waivers + Waivers diff --git a/src/services/ui/src/pages/detail/detailNav.tsx b/src/services/ui/src/pages/detail/detailNav.tsx deleted file mode 100644 index 2da0de1eba..0000000000 --- a/src/services/ui/src/pages/detail/detailNav.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { removeUnderscoresAndCapitalize } from "@/utils"; -import { ChevronLeftIcon } from "@heroicons/react/24/outline"; -import { useNavigate } from "react-router-dom"; - -export const DetailNav = ({ id, type }: { id: string; type?: string }) => { - const navigate = useNavigate(); - const planType = removeUnderscoresAndCapitalize(type); - return ( - - - - - navigate(-1)}> - - - - {planType} Submission Details - {id} - - - - - - ); -}; diff --git a/src/services/ui/src/pages/detail/index.tsx b/src/services/ui/src/pages/detail/index.tsx index 7178d08705..afa77eeb63 100644 --- a/src/services/ui/src/pages/detail/index.tsx +++ b/src/services/ui/src/pages/detail/index.tsx @@ -160,7 +160,6 @@ export const Details = () => { return ( <> - {/* */} diff --git a/src/services/ui/src/pages/faq/index.tsx b/src/services/ui/src/pages/faq/index.tsx index 80c7de8e3d..0c542ad721 100644 --- a/src/services/ui/src/pages/faq/index.tsx +++ b/src/services/ui/src/pages/faq/index.tsx @@ -5,22 +5,15 @@ import { AccordionItem, AccordionContent, AccordionTrigger, + SubNavHeader, } from "@/components"; export const Faq = () => { return ( <> - - - - - - Frequently Asked Questions - - - - - + + Frequently Asked Questions + {oneMACFAQContent.map(({ sectionTitle, qanda }) => ( @@ -46,13 +39,19 @@ export const Faq = () => { Phone Number - + {helpDeskContact.phone} Email - + {helpDeskContact.email} diff --git a/src/services/ui/src/pages/form/index.tsx b/src/services/ui/src/pages/form/index.tsx index 1c979e6f68..139fe75f36 100644 --- a/src/services/ui/src/pages/form/index.tsx +++ b/src/services/ui/src/pages/form/index.tsx @@ -1,358 +1 @@ -export { MedicaidForm } from "./medicaid-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { - Button, - Calendar, - Checkbox, - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - Input, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Switch, - Textarea, -} from "@/components/Inputs"; -import { Link } from "react-router-dom"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; -import { cn } from "@/lib"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; -const items = [ - { - id: "recents", - label: "Recents", - }, - { - id: "home", - label: "Home", - }, - { - id: "applications", - label: "Applications", - }, - { - id: "desktop", - label: "Desktop", - }, - { - id: "downloads", - label: "Downloads", - }, - { - id: "documents", - label: "Documents", - }, -] as const; - -const FormSchema = z.object({ - username: z.string().min(2, { - message: "Username must be at least 2 characters.", - }), - email: z - .string({ - required_error: "Please select an email to display.", - }) - .email(), - bio: z - .string() - .min(10, { - message: "Bio must be at least 10 characters.", - }) - .max(160, { - message: "Bio must not be longer than 30 characters.", - }), - marketing_emails: z.boolean().default(false).optional(), - security_emails: z.boolean(), - type: z.enum(["all", "mentions", "none"], { - required_error: "You need to select a notification type.", - }), - dob: z.date({ - required_error: "A date of birth is required.", - }), - items: z.array(z.string()).refine((value) => value.some((item) => item), { - message: "You have to select at least one item.", - }), -}); - -export function ExampleForm() { - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - items: ["recents", "home"], - }, - }); - - function onSubmit(data: z.infer) { - console.log({ data }); - } - - return ( - - - - ( - - Username - - - - - This is your public display name. - - - - )} - /> - ( - - Bio - - - - - You can @mention other users and organizations. - - - - )} - /> - - Email Notifications - - ( - - - Marketing emails - - Receive emails about new products, features, and more. - - - - - - - )} - /> - ( - - - Security emails - - Receive emails about your account security. - - - - - - - )} - /> - - - ( - - Email - - - - - - - - m@example.com - m@google.com - m@support.com - - - - You can manage email addresses in your{" "} - email settings. - - - - )} - /> - ( - - Notify me about... - - - - - - - - All new messages - - - - - - - - Direct messages and mentions - - - - - - - Nothing - - - - - - )} - /> - ( - - Date of birth - - - - - {field.value ? ( - format(field.value, "PPP") - ) : ( - Pick a date - )} - - - - - - - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - Your date of birth is used to calculate your age. - - - - )} - /> - ( - - - Sidebar - - Select the items you want to display in the sidebar. - - - {items.map((item) => ( - { - return ( - - - { - return checked - ? field.onChange([...field.value, item.id]) - : field.onChange( - field.value?.filter( - (value) => value !== item.id - ) - ); - }} - label="" - /> - - - {item.label} - - - ); - }} - /> - ))} - - - )} - /> - Submit - - - - ); -} +export * from "./medicaid-form"; diff --git a/src/services/ui/src/pages/index.ts b/src/services/ui/src/pages/index.ts index e0cb29759d..9c876133c9 100644 --- a/src/services/ui/src/pages/index.ts +++ b/src/services/ui/src/pages/index.ts @@ -1,7 +1,8 @@ +export * from "./actions"; export * from "./create"; export * from "./dashboard"; -export * from "./welcome"; export * from "./detail"; export * from "./faq"; export * from "./form"; -export * from "./actions"; +export * from "./profile"; +export * from "./welcome"; diff --git a/src/services/ui/src/pages/profile/index.tsx b/src/services/ui/src/pages/profile/index.tsx new file mode 100644 index 0000000000..2b26a867c8 --- /dev/null +++ b/src/services/ui/src/pages/profile/index.tsx @@ -0,0 +1,77 @@ +import { useGetUser } from "@/api/useGetUser"; +import { Alert, SubNavHeader } from "@/components"; +import { Button } from "@/components/Inputs"; +import { RoleDescriptionStrings } from "shared-types"; +import config from "@/config"; + +export const Profile = () => { + const { data } = useGetUser(); + + // Returns comma-separated string of user role descriptions: + function rolesDescriptions(roles: string | undefined) { + const rolesArray: string[] | undefined = roles?.split(","); + + const descriptiveRolesArray = rolesArray?.map((role) => { + return RoleDescriptionStrings[role]; + }); + + if (descriptiveRolesArray) { + return descriptiveRolesArray.join(", "); + } + } + + return ( + <> + + My Profile + + + + + + + + + + + + All changes to your access or profile must be made in IDM. + + + Go to IDM + + + + + My Information + + + Full Name + + {data?.user?.given_name} {data?.user?.family_name} + + + + + Role + {rolesDescriptions(data?.user?.["custom:cms-roles"])} + + + + Email + {data?.user?.email} + + + + > + ); +}; diff --git a/src/services/ui/src/pages/welcome/index.tsx b/src/services/ui/src/pages/welcome/index.tsx index 87e4b25a40..a73f1d6f3d 100644 --- a/src/services/ui/src/pages/welcome/index.tsx +++ b/src/services/ui/src/pages/welcome/index.tsx @@ -39,7 +39,7 @@ export const Welcome = () => { alt="One Mac Logo" className="p-4 min-w-[400px]" /> - + Welcome to the official submission system for paper-based state plan amendments (SPAs) and section 1915 waivers. diff --git a/src/services/ui/src/router.tsx b/src/services/ui/src/router.tsx index 2080c26073..ab391c946d 100644 --- a/src/services/ui/src/router.tsx +++ b/src/services/ui/src/router.tsx @@ -6,7 +6,6 @@ import "@/api/amplifyConfig"; import * as C from "@/components"; import { QueryClient } from "@tanstack/react-query"; import { ROUTES } from "@/routes"; -import { MedicaidForm } from "./pages/form/medicaid-form"; export const queryClient = new QueryClient(); export const router = createBrowserRouter([ @@ -68,6 +67,9 @@ export const router = createBrowserRouter([ }, { path: ROUTES.MEDICAID_NEW, element: }, { path: ROUTES.ACTION, element: }, + { path: ROUTES.WEBFORMS, element: }, + { path: ROUTES.WEBFORM, element: }, + { path: ROUTES.PROFILE, element: }, ], loader: rootLoader(queryClient), }, diff --git a/src/services/ui/src/routes.ts b/src/services/ui/src/routes.ts index fde4d96966..e5a030d724 100644 --- a/src/services/ui/src/routes.ts +++ b/src/services/ui/src/routes.ts @@ -4,6 +4,7 @@ export enum ROUTES { DASHBOARD = "/dashboard", DETAILS = "/details", FAQ = "/faq", + PROFILE = "/profile", // New Submission Routes // Can stand to be reduced with dynamic segments (KH) NEW_SUBMISSION_OPTIONS = "/new-submission", @@ -20,6 +21,8 @@ export enum ROUTES { ACTION = "/action/:id/:type", CREATE = "/create", MEDICAID_NEW = "/new-submission/spa/medicaid/create", + WEBFORMS = "/webforms", + WEBFORM = "/webform/:id/:version", } export enum FAQ_SECTION { diff --git a/src/services/ui/tailwind.config.js b/src/services/ui/tailwind.config.js index 3f68c5d80b..d68234f0c1 100644 --- a/src/services/ui/tailwind.config.js +++ b/src/services/ui/tailwind.config.js @@ -3,6 +3,9 @@ export default { darkMode: ["class"], content: ["./src/**/*.{ts,tsx}", "./index.html"], theme: { + fontFamily: { + serif: ["Merriweather", "serif"], + }, container: { center: true, padding: "2rem", diff --git a/yarn.lock b/yarn.lock index ff037e4a42..96a5d9bbdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4483,6 +4483,20 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" +"@radix-ui/react-dropdown-menu@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63" + integrity sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-focus-guards@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" @@ -4531,6 +4545,31 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-menu@2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e" + integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-callback-ref" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + "@radix-ui/react-popover@^1.0.6": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" @@ -6468,6 +6507,11 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-errors@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-3.0.0.tgz#e54f299f3a3d30fe144161e5f0d8d51196c527bc" + integrity sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ== + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
{description}
{`${strLn}${ + props.maxLength && props.charcount === "limited" + ? `/${props.maxLength}` + : "" + }`}
My Account
-
Visibility Popover Icon
+ No Results Found +
+
Records per page: props.onSizeChange?.(Number(e.target.value))} @@ -76,7 +77,10 @@ export const Pagination: FC = (props) => { return ( ... = (props) => { props.onPageChange(Number(v.currentTarget.value) - 1) } className="absolute w-auto h-auto opacity-0 cursor-pointer" + aria-labelledby="morePagesButton" + id="pagesDropdown" > {PAGE.map((P) => ( @@ -102,11 +108,11 @@ export const Pagination: FC = (props) => { className={cn( "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0", { - "bg-blue-500": isActive, + "bg-blue-700": isActive, "focus-visible:outline-indigo-600": isActive, "text-white": isActive, "hover:text-black": isActive, - "hover:bg-blue-500": isActive, + "hover:bg-blue-700": isActive, } )} > diff --git a/src/services/ui/src/components/RHF/Document.tsx b/src/services/ui/src/components/RHF/Document.tsx new file mode 100644 index 0000000000..8f9d05460a --- /dev/null +++ b/src/services/ui/src/components/RHF/Document.tsx @@ -0,0 +1,30 @@ +import { Control, FieldValues } from "react-hook-form"; + +import { FormLabel } from "../Inputs"; +import { RHFSection } from "./Section"; +import { FormSchema } from "shared-types"; + +export const RHFDocument = (props: { + document: FormSchema; + control: Control; +}) => { + return ( + + + + + + {props.document.header} + + + {props.document.sections.map((SEC, index) => ( + + ))} + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FieldArray.tsx b/src/services/ui/src/components/RHF/FieldArray.tsx new file mode 100644 index 0000000000..61a594d7d8 --- /dev/null +++ b/src/services/ui/src/components/RHF/FieldArray.tsx @@ -0,0 +1,69 @@ +import { FieldValues, useFieldArray } from "react-hook-form"; +import { Plus, Trash2 } from "lucide-react"; + +import { RHFSlot } from "./Slot"; +import { Button, FormField } from "../Inputs"; +import { FieldArrayProps } from "shared-types"; +import { slotInitializer } from "./utils"; +import { useEffect } from "react"; + +export const RHFFieldArray = ( + props: FieldArrayProps +) => { + const fieldArr = useFieldArray({ + control: props.control, + name: props.name, + shouldUnregister: true, + }); + + const onAppend = () => { + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }; + + useEffect(() => { + if (fieldArr.fields.length) return; + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }, []); + + return ( + + {fieldArr.fields.map((FLD, index) => { + return ( + + {props.fields.map((SLOT) => { + const prefix = `${props.name}.${index}.`; + const adjustedPrefix = (props.groupNamePrefix ?? "") + prefix; + const adjustedSlotName = prefix + SLOT.name; + return ( + + ); + })} + {index >= 1 && ( + fieldArr.remove(index)} + /> + )} + + ); + })} + + + + {props.appendText ?? "New Row"} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FieldGroup.tsx b/src/services/ui/src/components/RHF/FieldGroup.tsx new file mode 100644 index 0000000000..fcebaea3dd --- /dev/null +++ b/src/services/ui/src/components/RHF/FieldGroup.tsx @@ -0,0 +1,77 @@ +import { FieldValues, useFieldArray } from "react-hook-form"; +import { Plus } from "lucide-react"; + +import { RHFSlot } from "./Slot"; +import { Button, FormField } from "../Inputs"; +import { FieldGroupProps } from "shared-types"; +import { slotInitializer } from "./utils"; +import { useEffect } from "react"; + +export const FieldGroup = ( + props: FieldGroupProps +) => { + const fieldArr = useFieldArray({ + control: props.control, + name: props.name, + shouldUnregister: true, + }); + + const onAppend = () => { + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }; + + useEffect(() => { + if (fieldArr.fields.length) return; + fieldArr.append(props.fields.reduce(slotInitializer, {}) as never); + }, []); + + return ( + + {fieldArr.fields.map((FLD, index) => { + return ( + + {props.fields.map((SLOT) => { + const prefix = `${props.name}.${index}.`; + const adjustedPrefix = (props.groupNamePrefix ?? "") + prefix; + const adjustedSlotName = prefix + SLOT.name; + return ( + + ); + })} + {index >= 1 && ( + { + fieldArr.remove(index); + }} + > + {props.removeText ?? "Remove Group"} + + )} + {fieldArr.fields.length > 1 && ( + + )} + + ); + })} + + + + {props.appendText ?? "New Group"} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/FormGroup.tsx b/src/services/ui/src/components/RHF/FormGroup.tsx new file mode 100644 index 0000000000..10efe5ea9b --- /dev/null +++ b/src/services/ui/src/components/RHF/FormGroup.tsx @@ -0,0 +1,41 @@ +import { Control, FieldValues } from "react-hook-form"; +import { FormLabel, FormField } from "../Inputs"; +import { DependencyWrapper } from "./dependencyWrapper"; +import { RHFSlot } from "./Slot"; +import * as TRhf from "shared-types"; + +export const RHFFormGroup = (props: { + form: TRhf.FormGroup; + control: Control; + groupNamePrefix?: string; +}) => { + return ( + + + {props.form.description && ( + + + {props.form?.description} + + + )} + + {props.form.slots.map((SLOT) => ( + + + + ))} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/Section.tsx b/src/services/ui/src/components/RHF/Section.tsx new file mode 100644 index 0000000000..4adf2be47a --- /dev/null +++ b/src/services/ui/src/components/RHF/Section.tsx @@ -0,0 +1,34 @@ +/* eslint-disable react/prop-types */ +import { Control, FieldValues } from "react-hook-form"; +import { FormLabel } from "../Inputs"; +import { DependencyWrapper } from "./dependencyWrapper"; +import { Section } from "shared-types"; +import { RHFFormGroup } from "./FormGroup"; + +export const RHFSection = (props: { + section: Section; + control: Control; +}) => { + return ( + + + {props.section.title && ( + + + {props.section.title} + + + )} + + {props.section.form.map((FORM, index) => ( + + ))} + + + + ); +}; diff --git a/src/services/ui/src/components/RHF/Slot.tsx b/src/services/ui/src/components/RHF/Slot.tsx new file mode 100644 index 0000000000..d69d2c7655 --- /dev/null +++ b/src/services/ui/src/components/RHF/Slot.tsx @@ -0,0 +1,298 @@ +/* eslint-disable react/prop-types */ +import { ControllerProps, FieldPath, FieldValues } from "react-hook-form"; +import { + Button, + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, + Input, + Switch, + Textarea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + RadioGroup, + RadioGroupItem, + Calendar, + FormField, + Checkbox, +} from "../Inputs"; +import { RHFFormGroup } from "./FormGroup"; +import { CalendarIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; +import { cn } from "@/lib"; +import { format } from "date-fns"; +import { RHFFieldArray } from "./FieldArray"; +import { FieldGroup } from "./FieldGroup"; +import type { RHFSlotProps, RHFComponentMap, FormGroup } from "shared-types"; +import { useEffect, useMemo } from "react"; + +export const RHFSlot = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + control, + rhf, + label, + description, + name, + props, + labelStyling, + groupNamePrefix, + ...rest +}: RHFSlotProps & { control: any }): ControllerProps< + TFieldValues, + TName +>["render"] => + function Slot({ field }) { + // added to unregister/reset inputs when removed from dom + useEffect(() => { + return () => { + control.unregister(field.name); + }; + }, []); + + return ( + + {label && {label}} + + <> + {/* ----------------------------------------------------------------------------- */} + {rhf === "Input" && + (() => { + const hops = props as RHFComponentMap["Input"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Textarea" && + (() => { + const hops = props as RHFComponentMap["Textarea"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Switch" && + (() => { + const hops = props as RHFComponentMap["Switch"]; + return ; + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Select" && + (() => { + const hops = props as RHFComponentMap["Select"]; + const opts = useMemo(() => { + if (hops.sort) { + const sorted = hops.options.sort((a, b) => + a.label.localeCompare(b.label) + ); + hops.sort === "descending" && sorted.reverse(); + return sorted; + } + return hops.options; + }, [hops.options, hops.sort]); + + return ( + + + + + + {opts.map((OPT) => ( + + {OPT.label} + + ))} + + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Radio" && + (() => { + const hops = props as RHFComponentMap["Radio"]; + return ( + + {hops.options.map((OPT) => { + return ( + + + + + {OPT.label} + + + {field.value === OPT.value && + OPT.form && + OPT.form.map((FORM, index) => { + return ( + + + + ); + })} + {field.value === OPT.value && + OPT.slots && + OPT.slots.map((SLOT, index) => ( + + + + ))} + + ); + })} + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "Checkbox" && + (() => { + const hops = props as RHFComponentMap["Checkbox"]; + return ( + + {hops.options.map((OPT) => ( + + { + const filtered = + field.value?.filter( + (f: unknown) => f !== OPT.value + ) || []; + if (!c) return field.onChange(filtered); + field.onChange([...filtered, OPT.value]); + }} + /> + {field.value?.includes(OPT.value) && + !!OPT.slots && + OPT.slots && + OPT.slots.map((SLOT, index) => ( + + + + ))} + + {field.value?.includes(OPT.value) && + !!OPT.form && + OPT.form.map((FORM: FormGroup) => ( + + + + ))} + + ))} + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "DatePicker" && + (() => { + const hops = props as RHFComponentMap["DatePicker"]; + return ( + + + + + {field.value ? ( + format(field.value, "PPP") + ) : ( + Pick a date + )} + + + + + + + + + ); + })()} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "FieldArray" && ( + + )} + + {/* ----------------------------------------------------------------------------- */} + {rhf === "FieldGroup" && ( + + )} + > + + {description && {description}} + + + ); + }; diff --git a/src/services/ui/src/components/RHF/dependencyWrapper.tsx b/src/services/ui/src/components/RHF/dependencyWrapper.tsx new file mode 100644 index 0000000000..2c134adfa2 --- /dev/null +++ b/src/services/ui/src/components/RHF/dependencyWrapper.tsx @@ -0,0 +1,68 @@ +import { PropsWithChildren, useEffect, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { DependencyRule, DependencyWrapperProps } from "shared-types"; + +const checkTriggeringValue = ( + dependentValue: unknown[], + dependency?: DependencyRule +) => { + return !!dependency?.conditions?.every((d, i) => { + switch (d.type) { + case "expectedValue": + return dependentValue[i] === d?.expectedValue; + case "valueExists": + return ( + (Array.isArray(dependentValue[i]) && + (dependentValue[i] as unknown[]).length > 0) || + !!dependentValue[i] + ); + case "valueNotExist": + return ( + (Array.isArray(dependentValue[i]) && + (dependentValue[i] as unknown[]).length === 0) || + !dependentValue[i] + ); + } + }); +}; + +export const DependencyWrapper = ({ + name, + dependency, + children, +}: PropsWithChildren) => { + const { watch, setValue } = useFormContext(); + const [wasSetLast, setWasSetLast] = useState(false); + const dependentValues = watch( + dependency?.conditions?.map((c) => c.name) ?? [] + ); + const isTriggered = + dependency && checkTriggeringValue(dependentValues, dependency); + + useEffect(() => { + if ( + !wasSetLast && + dependency?.effect.type === "setValue" && + isTriggered && + !!name + ) { + setValue(name, dependency.effect.newValue); + setWasSetLast(true); + } else if (!isTriggered && wasSetLast) { + setWasSetLast(false); + } + }, [dependentValues]); + + switch (dependency?.effect.type) { + case "hide": + if (isTriggered) { + return null; + } + break; + case "show": + if (isTriggered) return <>{children}>; + else return null; + } + + return <>{children}>; +}; diff --git a/src/services/ui/src/components/RHF/index.ts b/src/services/ui/src/components/RHF/index.ts new file mode 100644 index 0000000000..917f9268e8 --- /dev/null +++ b/src/services/ui/src/components/RHF/index.ts @@ -0,0 +1,5 @@ +export * from "./Document"; +export * from "./FormGroup"; +export * from "./Section"; +export * from "./Slot"; +export * from "./utils"; diff --git a/src/services/ui/src/components/RHF/utils/index.ts b/src/services/ui/src/components/RHF/utils/index.ts new file mode 100644 index 0000000000..a029df0a8d --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./initializer"; +export * from "./validator"; diff --git a/src/services/ui/src/components/RHF/utils/initializer.ts b/src/services/ui/src/components/RHF/utils/initializer.ts new file mode 100644 index 0000000000..444bfcee3e --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/initializer.ts @@ -0,0 +1,58 @@ +import * as T from "shared-types"; +import { FormSchema } from "shared-types"; + +type GL = Record; + +export const formGroupInitializer = (ACC: GL, FORM: T.FormGroup) => { + FORM.slots.reduce(slotInitializer, ACC); + return ACC; +}; + +export const slotInitializer = (ACC: GL, SLOT: T.RHFSlotProps): GL => { + const optionReducer = (OPT: T.RHFOption) => { + if (OPT.form) OPT.form.reduce(formGroupInitializer, ACC); + if (OPT.slots) OPT.slots.reduce(slotInitializer, ACC); + return ACC; + }; + + const fieldInitializer = (ACC1: GL, SLOT: T.RHFSlotProps): GL => { + if (SLOT.rhf === "FieldArray" || SLOT.rhf === "FieldGroup") { + return { + ...ACC1, + [SLOT.name]: [SLOT.fields?.reduce(fieldInitializer, {})], + }; + } + + return { ...ACC1, ...slotInitializer(ACC1, SLOT) }; + }; + + switch (SLOT.rhf) { + case "Switch": + ACC[SLOT.name] = false; + break; + case "Radio": + case "Checkbox": + SLOT.props?.options.forEach(optionReducer); + ACC[SLOT.name] = []; + break; + case "FieldArray": + case "FieldGroup": + ACC[SLOT.name] = [SLOT.fields?.reduce(fieldInitializer, {})]; + break; + case "Input": + case "Select": + case "Textarea": + default: + ACC[SLOT.name] = ""; + break; + } + + return ACC; +}; + +export const documentInitializer = (document: FormSchema) => { + return document.sections.reduce((ACC, SEC) => { + SEC.form.reduce(formGroupInitializer, ACC); + return ACC; + }, {}); +}; diff --git a/src/services/ui/src/components/RHF/utils/is.ts b/src/services/ui/src/components/RHF/utils/is.ts new file mode 100644 index 0000000000..98c4b89b77 --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/is.ts @@ -0,0 +1,55 @@ +import { ValidationRule } from "react-hook-form"; + +export const INPUT_VALIDATION_RULES = { + max: "max", + min: "min", + maxLength: "maxLength", + minLength: "minLength", + pattern: "pattern", + required: "required", + validate: "validate", +} as const; +export type InputValidationRules = typeof INPUT_VALIDATION_RULES; +export type ERROR = Record; +export type MaxType = + | InputValidationRules["max"] + | InputValidationRules["maxLength"]; + +export type MinType = + | InputValidationRules["min"] + | InputValidationRules["minLength"]; + +// eslint-disable-next-line @typescript-eslint/ban-types +export const isFunction = (value: unknown): value is Function => + typeof value === "function"; + +export const isNullOrUndefined = (value: unknown): value is null | undefined => + value == null; + +export const isUndefined = (val: unknown): val is undefined => + val === undefined; + +export const isDateObject = (value: unknown): value is Date => + value instanceof Date; + +export const isObjectType = (value: unknown) => typeof value === "object"; + +export const isString = (value: unknown): value is string => + typeof value === "string"; + +export const isObject = (value: unknown): value is T => + !isNullOrUndefined(value) && + !Array.isArray(value) && + isObjectType(value) && + !isDateObject(value); + +export const isRegex = (value: unknown): value is RegExp => + value instanceof RegExp; + +export const getValueAndMessage = (validationData?: ValidationRule) => + isObject(validationData) && !isRegex(validationData) + ? validationData + : { + value: validationData, + message: "", + }; diff --git a/src/services/ui/src/components/RHF/utils/validator.ts b/src/services/ui/src/components/RHF/utils/validator.ts new file mode 100644 index 0000000000..3d80b3f20e --- /dev/null +++ b/src/services/ui/src/components/RHF/utils/validator.ts @@ -0,0 +1,256 @@ +import * as T from "shared-types"; +import { RegisterOptions } from "react-hook-form"; +import { FormSchema } from "shared-types"; + +import { + isNullOrUndefined, + isUndefined, + isRegex, + getValueAndMessage, + isString, + ERROR, + // INPUT_VALIDATION_RULES, + // isFunction, + // MaxType, + // MinType, +} from "./is"; + +export const validateInput = (inputValue: any, rules?: RegisterOptions) => { + const isEmpty = + isUndefined(inputValue) || + inputValue === "" || + (Array.isArray(inputValue) && !inputValue.length); + + if (isEmpty && rules?.required) { + return isString(rules.required) ? rules.required : "*Required"; + } + + if ( + !isEmpty && + (!isNullOrUndefined(rules?.min) || !isNullOrUndefined(rules?.max)) + ) { + let exceedMax; + let exceedMin; + const maxOutput = getValueAndMessage(rules?.max); + const minOutput = getValueAndMessage(rules?.min); + + if (!isNullOrUndefined(inputValue) && !isNaN(inputValue as number)) { + const valueNumber = inputValue ? +inputValue : inputValue; + if (!isNullOrUndefined(maxOutput.value)) { + exceedMax = valueNumber > maxOutput.value; + } + if (!isNullOrUndefined(minOutput.value)) { + exceedMin = valueNumber < minOutput.value; + } + } else { + const valueDate = new Date(inputValue as string); + // const convertTimeToDate = (time: unknown) => + // new Date(new Date().toDateString() + " " + time); + // // const isTime = ref.type == "time"; + // // const isWeek = ref.type == "week"; + + if (isString(maxOutput.value) && inputValue) { + exceedMax = valueDate > new Date(maxOutput.value); + } + + if (isString(minOutput.value) && inputValue) { + exceedMin = valueDate < new Date(minOutput.value); + } + } + + if (exceedMax) return maxOutput.message; + if (exceedMin) return minOutput.message; + } + + if ( + (rules?.maxLength || rules?.minLength) && + !isEmpty && + isString(inputValue) + ) { + const maxLengthOutput = getValueAndMessage(rules?.maxLength); + const minLengthOutput = getValueAndMessage(rules?.minLength); + const exceedMax = + !isNullOrUndefined(maxLengthOutput.value) && + inputValue.length > +maxLengthOutput.value; + const exceedMin = + !isNullOrUndefined(minLengthOutput.value) && + inputValue.length < +minLengthOutput.value; + + if (exceedMax) return maxLengthOutput.message; + if (exceedMin) return minLengthOutput.message; + } + + if (rules?.pattern && !isEmpty && isString(inputValue)) { + const { value: patternValue, message } = getValueAndMessage(rules?.pattern); + + if (isRegex(patternValue) && !inputValue.match(patternValue)) { + return message; + } + } + // TODO: Add validate condition + // if (rules?.validate) { + // if (isFunction(rules?.validate)) { + // const result = await rules?.validate(inputValue, formValues); + // const validateError = getValidateError(result, inputRef); + + // if (validateError) { + // error[name] = { + // ...validateError, + // ...appendErrorsCurry( + // INPUT_VALIDATION_RULES.validate, + // validateError.message + // ), + // }; + // if (!validateAllFieldCriteria) { + // setCustomValidity(validateError.message); + // return error; + // } + // } + // } else if (isObject(validate)) { + // let validationResult = {} as FieldError; + + // for (const key in validate) { + // if (!isEmptyObject(validationResult) && !validateAllFieldCriteria) { + // break; + // } + + // const validateError = getValidateError( + // await validate[key](inputValue, formValues), + // inputRef, + // key + // ); + + // if (validateError) { + // validationResult = { + // ...validateError, + // ...appendErrorsCurry(key, validateError.message), + // }; + + // setCustomValidity(validateError.message); + + // if (validateAllFieldCriteria) { + // error[name] = validationResult; + // } + // } + // } + + // if (!isEmptyObject(validationResult)) { + // error[name] = { + // ref: inputRef, + // ...validationResult, + // }; + // if (!validateAllFieldCriteria) { + // return error; + // } + // } + // } + // } + + // If all checks pass, the input value is valid + return ""; +}; + +export const validateOption = (optionValue: string, options: any[]) => { + return options.find((OPT: any) => OPT.value === optionValue); +}; + +export const formGroupValidator = + (data: any) => (ACC: ERROR, FORM: T.FormGroup) => { + FORM.slots.reduce(slotValidator(data), ACC); + return ACC; + }; + +export const slotValidator = + (data: any) => + (ACC: ERROR, SLOT: T.RHFSlotProps): ERROR => { + const optionValidator = (OPT: T.RHFOption) => { + if (OPT.form) OPT.form.reduce(formGroupValidator(data), ACC); + if (OPT.slots) { + OPT.slots.reduce(slotValidator(data), ACC); + } + return ACC; + }; + + const fieldValidator = (FLD: any) => (SLOT1: T.RHFSlotProps) => { + if (SLOT1.rhf === "FieldArray") { + FLD[SLOT1.name].forEach((DAT: any) => { + SLOT1.fields?.forEach(fieldValidator(DAT)); + }); + } else if (SLOT1.rhf === "FieldGroup") { + FLD[SLOT1.name].forEach((DAT: any) => { + SLOT1.fields?.forEach(fieldValidator(DAT)); + }); + } else { + slotValidator(FLD)(ACC, SLOT1); + } + }; + + if (SLOT.rhf === "Input") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + if (SLOT.rhf === "Textarea") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + + if (SLOT.rhf === "Switch") { + ACC[SLOT.name] = validateInput(data[SLOT.name], SLOT.rules); + } + + if (SLOT.rhf === "Radio") { + const validOption = SLOT.props?.options.find( + (OPT) => OPT.value === data[SLOT.name] + ); + if (!validOption) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } else { + optionValidator(validOption); + } + } + + if (SLOT.rhf === "Select") { + const validOption = SLOT.props?.options.find( + (OPT) => OPT.value === data[SLOT.name] + ); + if (!validOption) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } else { + optionValidator(validOption); + } + } + + if (SLOT.rhf === "Checkbox") { + if (data[SLOT.name]?.length) { + const validList = data[SLOT.name].every((VAL: any) => + SLOT.props?.options.some((OPT) => OPT.value === VAL) + ); + if (!validList) { + ACC[SLOT.name] = `invalid option - '${data[SLOT.name]}'`; + } + + const selectedOptions = SLOT.props?.options.filter((OPT) => + data[SLOT.name].includes(OPT.value) + ); + selectedOptions?.forEach(optionValidator); + } + } + + if (SLOT.rhf === "FieldArray") { + data[SLOT.name].forEach((DAT: any) => { + SLOT.fields?.forEach(fieldValidator(DAT)); + }); + } + if (SLOT.rhf === "FieldGroup") { + data[SLOT.name].forEach((DAT: any) => { + SLOT.fields?.forEach(fieldValidator(DAT)); + }); + } + + return ACC; + }; + +export const documentValidator = (document: FormSchema) => (data: any) => { + return document.sections.reduce((ACC, SEC) => { + SEC.form.reduce(formGroupValidator(data), ACC); + return ACC; + }, {} as ERROR); +}; diff --git a/src/services/ui/src/components/SearchForm/index.tsx b/src/services/ui/src/components/SearchForm/index.tsx index bf25f0a36a..6e5fccce67 100644 --- a/src/services/ui/src/components/SearchForm/index.tsx +++ b/src/services/ui/src/components/SearchForm/index.tsx @@ -44,7 +44,11 @@ export const SearchForm: FC<{ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + + Search by Package ID, CPOC Name, or Submitter Name + (({ className, ...props }, ref) => ( - + { + return ( + + {"PRA Disclosure Statement"} + + { + "All State Medicaid agencies administering or supervising the \ + administration of 1915(c) home and community-based services (HCBS) \ + waivers are required to submit an annual Form CMS-372(S) Report for each \ + approved waiver. Section 1915(c)(2)(E) of the SocialSecurity Act \ + requires states to annually provide CMS with information on the waiver's \ + impact on the type, amount and cost of services provided under the state \ + plan in addition to the health and welgare of recipients. Under the \ + Privacy Act of 1974 any personally identifying information obatined will \ + be kept private to the extent of the law." + } + + + { + "Accordint to the Paperwork Reduction Act of 1995, no persons are required to respond \ + to a collection of information unless it displays a valid OMB control number. The valid OMB \ + control number for this information colleciton is 0938-0272. The time required to complete \ + this information collection is estimated to average 44 hours per response, including the time to \ + review instructions, search existing data resources, gather the data needed, and complete and \ + review the information collection. If you have comments concerning the accuracy of the time \ + estimate(s) or suggestions for improving this form, please write to:" + } + + + { + "CMS, 7500 Security Boulevard, Attn: PRA Reports Clearance Officer, Mail Stop C4-26-05, Baltimore, Maryland 21244-1850." + } + + + ); +}; diff --git a/src/services/ui/src/components/Webform/index.tsx b/src/services/ui/src/components/Webform/index.tsx new file mode 100644 index 0000000000..c214c4f913 --- /dev/null +++ b/src/services/ui/src/components/Webform/index.tsx @@ -0,0 +1,81 @@ +import { useForm } from "react-hook-form"; +import { Button, Form } from "@/components/Inputs"; +import { RHFDocument } from "@/components/RHF"; +import { Link, useParams } from "react-router-dom"; +import { SubNavHeader } from "@/components"; +import { documentInitializer } from "@/components/RHF/utils"; +import { useGetForm } from "@/api"; +import { LoadingSpinner } from "@/components"; +import { Footer } from "./footer"; +export const Webforms = () => { + return ( + <> + + Webforms + + + + ABP1 + + + > + ); +}; + +export function Webform() { + const { id, version } = useParams<{ + id: string; + version: string; + }>(); + const { data, isLoading, error } = useGetForm(id as string, version); + + const defaultValues = data ? documentInitializer(data) : {}; + const savedData = localStorage.getItem(`${id}v${version}`); + const form = useForm({ + defaultValues: savedData ? JSON.parse(savedData) : defaultValues, + }); + + const onSave = () => { + const values = form.getValues(); + localStorage.setItem(`${id}v${version}`, JSON.stringify(values)); + alert("Saved"); + }; + + const onSubmit = form.handleSubmit( + (data) => { + console.log({ data }); + // const validate = documentValidator(ABP1); + // const isValid = validate(data); + // console.log({ isValid }); + }, + (err) => { + console.log({ err }); + } + ); + + if (isLoading) return ; + if (error || !data) { + return ( + + {`There was an error loading ${id}`} + + ); + } + + return ( + + + + + + + Save draft + + Submit + + + + + + ); +} diff --git a/src/services/ui/src/components/index.tsx b/src/services/ui/src/components/index.tsx index e1e79751d3..edad3ab571 100644 --- a/src/services/ui/src/components/index.tsx +++ b/src/services/ui/src/components/index.tsx @@ -17,3 +17,4 @@ export * from "./SearchForm"; export * from "./SubmissionInfo"; export * from "./Modal"; export * from "./Dialog"; +export * from "./Webform"; diff --git a/src/services/ui/src/index.css b/src/services/ui/src/index.css index 1aed288ae5..e0ed753f78 100644 --- a/src/services/ui/src/index.css +++ b/src/services/ui/src/index.css @@ -72,6 +72,7 @@ } body { @apply bg-background text-foreground; + font-family: "Open Sans", system-ui, sans-serif; font-feature-settings: "rlig" 1, "calt" 1; } } diff --git a/src/services/ui/src/pages/create/create-form.tsx b/src/services/ui/src/pages/create/create-form.tsx index 979b28b84a..bcb5f9e19f 100644 --- a/src/services/ui/src/pages/create/create-form.tsx +++ b/src/services/ui/src/pages/create/create-form.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { getUserStateCodes } from "@/utils"; import { useGetUser } from "@/api/useGetUser"; import { useParams } from "react-router-dom"; +import { SubNavHeader } from "@/components"; type FormData = { id: string; @@ -55,15 +56,9 @@ export const Create = () => { return ( <> - - - - - Initial Submission - - - - + + Initial Submission + diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx index 56be766d13..b0085e5c2a 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx @@ -19,7 +19,7 @@ export const SpasList = () => { const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return ( - + { const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return ( - + { Dashboard {!userContext?.isCms && ( - - New Submission - + + New Submission + )} @@ -80,10 +80,10 @@ export const Dashboard = () => { > - SPAs + SPAs - Waivers + Waivers diff --git a/src/services/ui/src/pages/detail/detailNav.tsx b/src/services/ui/src/pages/detail/detailNav.tsx deleted file mode 100644 index 2da0de1eba..0000000000 --- a/src/services/ui/src/pages/detail/detailNav.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { removeUnderscoresAndCapitalize } from "@/utils"; -import { ChevronLeftIcon } from "@heroicons/react/24/outline"; -import { useNavigate } from "react-router-dom"; - -export const DetailNav = ({ id, type }: { id: string; type?: string }) => { - const navigate = useNavigate(); - const planType = removeUnderscoresAndCapitalize(type); - return ( - - - - - navigate(-1)}> - - - - {planType} Submission Details - {id} - - - - - - ); -}; diff --git a/src/services/ui/src/pages/detail/index.tsx b/src/services/ui/src/pages/detail/index.tsx index 7178d08705..afa77eeb63 100644 --- a/src/services/ui/src/pages/detail/index.tsx +++ b/src/services/ui/src/pages/detail/index.tsx @@ -160,7 +160,6 @@ export const Details = () => { return ( <> - {/* */} diff --git a/src/services/ui/src/pages/faq/index.tsx b/src/services/ui/src/pages/faq/index.tsx index 80c7de8e3d..0c542ad721 100644 --- a/src/services/ui/src/pages/faq/index.tsx +++ b/src/services/ui/src/pages/faq/index.tsx @@ -5,22 +5,15 @@ import { AccordionItem, AccordionContent, AccordionTrigger, + SubNavHeader, } from "@/components"; export const Faq = () => { return ( <> - - - - - - Frequently Asked Questions - - - - - + + Frequently Asked Questions + {oneMACFAQContent.map(({ sectionTitle, qanda }) => ( @@ -46,13 +39,19 @@ export const Faq = () => { Phone Number - + {helpDeskContact.phone} Email - + {helpDeskContact.email} diff --git a/src/services/ui/src/pages/form/index.tsx b/src/services/ui/src/pages/form/index.tsx index 1c979e6f68..139fe75f36 100644 --- a/src/services/ui/src/pages/form/index.tsx +++ b/src/services/ui/src/pages/form/index.tsx @@ -1,358 +1 @@ -export { MedicaidForm } from "./medicaid-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { - Button, - Calendar, - Checkbox, - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - Input, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Switch, - Textarea, -} from "@/components/Inputs"; -import { Link } from "react-router-dom"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; -import { cn } from "@/lib"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; -const items = [ - { - id: "recents", - label: "Recents", - }, - { - id: "home", - label: "Home", - }, - { - id: "applications", - label: "Applications", - }, - { - id: "desktop", - label: "Desktop", - }, - { - id: "downloads", - label: "Downloads", - }, - { - id: "documents", - label: "Documents", - }, -] as const; - -const FormSchema = z.object({ - username: z.string().min(2, { - message: "Username must be at least 2 characters.", - }), - email: z - .string({ - required_error: "Please select an email to display.", - }) - .email(), - bio: z - .string() - .min(10, { - message: "Bio must be at least 10 characters.", - }) - .max(160, { - message: "Bio must not be longer than 30 characters.", - }), - marketing_emails: z.boolean().default(false).optional(), - security_emails: z.boolean(), - type: z.enum(["all", "mentions", "none"], { - required_error: "You need to select a notification type.", - }), - dob: z.date({ - required_error: "A date of birth is required.", - }), - items: z.array(z.string()).refine((value) => value.some((item) => item), { - message: "You have to select at least one item.", - }), -}); - -export function ExampleForm() { - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - items: ["recents", "home"], - }, - }); - - function onSubmit(data: z.infer) { - console.log({ data }); - } - - return ( - - - - ( - - Username - - - - - This is your public display name. - - - - )} - /> - ( - - Bio - - - - - You can @mention other users and organizations. - - - - )} - /> - - Email Notifications - - ( - - - Marketing emails - - Receive emails about new products, features, and more. - - - - - - - )} - /> - ( - - - Security emails - - Receive emails about your account security. - - - - - - - )} - /> - - - ( - - Email - - - - - - - - m@example.com - m@google.com - m@support.com - - - - You can manage email addresses in your{" "} - email settings. - - - - )} - /> - ( - - Notify me about... - - - - - - - - All new messages - - - - - - - - Direct messages and mentions - - - - - - - Nothing - - - - - - )} - /> - ( - - Date of birth - - - - - {field.value ? ( - format(field.value, "PPP") - ) : ( - Pick a date - )} - - - - - - - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - Your date of birth is used to calculate your age. - - - - )} - /> - ( - - - Sidebar - - Select the items you want to display in the sidebar. - - - {items.map((item) => ( - { - return ( - - - { - return checked - ? field.onChange([...field.value, item.id]) - : field.onChange( - field.value?.filter( - (value) => value !== item.id - ) - ); - }} - label="" - /> - - - {item.label} - - - ); - }} - /> - ))} - - - )} - /> - Submit - - - - ); -} +export * from "./medicaid-form"; diff --git a/src/services/ui/src/pages/index.ts b/src/services/ui/src/pages/index.ts index e0cb29759d..9c876133c9 100644 --- a/src/services/ui/src/pages/index.ts +++ b/src/services/ui/src/pages/index.ts @@ -1,7 +1,8 @@ +export * from "./actions"; export * from "./create"; export * from "./dashboard"; -export * from "./welcome"; export * from "./detail"; export * from "./faq"; export * from "./form"; -export * from "./actions"; +export * from "./profile"; +export * from "./welcome"; diff --git a/src/services/ui/src/pages/profile/index.tsx b/src/services/ui/src/pages/profile/index.tsx new file mode 100644 index 0000000000..2b26a867c8 --- /dev/null +++ b/src/services/ui/src/pages/profile/index.tsx @@ -0,0 +1,77 @@ +import { useGetUser } from "@/api/useGetUser"; +import { Alert, SubNavHeader } from "@/components"; +import { Button } from "@/components/Inputs"; +import { RoleDescriptionStrings } from "shared-types"; +import config from "@/config"; + +export const Profile = () => { + const { data } = useGetUser(); + + // Returns comma-separated string of user role descriptions: + function rolesDescriptions(roles: string | undefined) { + const rolesArray: string[] | undefined = roles?.split(","); + + const descriptiveRolesArray = rolesArray?.map((role) => { + return RoleDescriptionStrings[role]; + }); + + if (descriptiveRolesArray) { + return descriptiveRolesArray.join(", "); + } + } + + return ( + <> + + My Profile + + + + + + + + + + + + All changes to your access or profile must be made in IDM. + + + Go to IDM + + + + + My Information + + + Full Name + + {data?.user?.given_name} {data?.user?.family_name} + + + + + Role + {rolesDescriptions(data?.user?.["custom:cms-roles"])} + + + + Email + {data?.user?.email} + + + + > + ); +}; diff --git a/src/services/ui/src/pages/welcome/index.tsx b/src/services/ui/src/pages/welcome/index.tsx index 87e4b25a40..a73f1d6f3d 100644 --- a/src/services/ui/src/pages/welcome/index.tsx +++ b/src/services/ui/src/pages/welcome/index.tsx @@ -39,7 +39,7 @@ export const Welcome = () => { alt="One Mac Logo" className="p-4 min-w-[400px]" /> - + Welcome to the official submission system for paper-based state plan amendments (SPAs) and section 1915 waivers. diff --git a/src/services/ui/src/router.tsx b/src/services/ui/src/router.tsx index 2080c26073..ab391c946d 100644 --- a/src/services/ui/src/router.tsx +++ b/src/services/ui/src/router.tsx @@ -6,7 +6,6 @@ import "@/api/amplifyConfig"; import * as C from "@/components"; import { QueryClient } from "@tanstack/react-query"; import { ROUTES } from "@/routes"; -import { MedicaidForm } from "./pages/form/medicaid-form"; export const queryClient = new QueryClient(); export const router = createBrowserRouter([ @@ -68,6 +67,9 @@ export const router = createBrowserRouter([ }, { path: ROUTES.MEDICAID_NEW, element: }, { path: ROUTES.ACTION, element: }, + { path: ROUTES.WEBFORMS, element: }, + { path: ROUTES.WEBFORM, element: }, + { path: ROUTES.PROFILE, element: }, ], loader: rootLoader(queryClient), }, diff --git a/src/services/ui/src/routes.ts b/src/services/ui/src/routes.ts index fde4d96966..e5a030d724 100644 --- a/src/services/ui/src/routes.ts +++ b/src/services/ui/src/routes.ts @@ -4,6 +4,7 @@ export enum ROUTES { DASHBOARD = "/dashboard", DETAILS = "/details", FAQ = "/faq", + PROFILE = "/profile", // New Submission Routes // Can stand to be reduced with dynamic segments (KH) NEW_SUBMISSION_OPTIONS = "/new-submission", @@ -20,6 +21,8 @@ export enum ROUTES { ACTION = "/action/:id/:type", CREATE = "/create", MEDICAID_NEW = "/new-submission/spa/medicaid/create", + WEBFORMS = "/webforms", + WEBFORM = "/webform/:id/:version", } export enum FAQ_SECTION { diff --git a/src/services/ui/tailwind.config.js b/src/services/ui/tailwind.config.js index 3f68c5d80b..d68234f0c1 100644 --- a/src/services/ui/tailwind.config.js +++ b/src/services/ui/tailwind.config.js @@ -3,6 +3,9 @@ export default { darkMode: ["class"], content: ["./src/**/*.{ts,tsx}", "./index.html"], theme: { + fontFamily: { + serif: ["Merriweather", "serif"], + }, container: { center: true, padding: "2rem", diff --git a/yarn.lock b/yarn.lock index ff037e4a42..96a5d9bbdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4483,6 +4483,20 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" +"@radix-ui/react-dropdown-menu@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63" + integrity sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-focus-guards@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" @@ -4531,6 +4545,31 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-menu@2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e" + integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-callback-ref" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + "@radix-ui/react-popover@^1.0.6": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" @@ -6468,6 +6507,11 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-errors@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-3.0.0.tgz#e54f299f3a3d30fe144161e5f0d8d51196c527bc" + integrity sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ== + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
+ { + "All State Medicaid agencies administering or supervising the \ + administration of 1915(c) home and community-based services (HCBS) \ + waivers are required to submit an annual Form CMS-372(S) Report for each \ + approved waiver. Section 1915(c)(2)(E) of the SocialSecurity Act \ + requires states to annually provide CMS with information on the waiver's \ + impact on the type, amount and cost of services provided under the state \ + plan in addition to the health and welgare of recipients. Under the \ + Privacy Act of 1974 any personally identifying information obatined will \ + be kept private to the extent of the law." + } +
+ { + "Accordint to the Paperwork Reduction Act of 1995, no persons are required to respond \ + to a collection of information unless it displays a valid OMB control number. The valid OMB \ + control number for this information colleciton is 0938-0272. The time required to complete \ + this information collection is estimated to average 44 hours per response, including the time to \ + review instructions, search existing data resources, gather the data needed, and complete and \ + review the information collection. If you have comments concerning the accuracy of the time \ + estimate(s) or suggestions for improving this form, please write to:" + } +
+ { + "CMS, 7500 Security Boulevard, Attn: PRA Reports Clearance Officer, Mail Stop C4-26-05, Baltimore, Maryland 21244-1850." + } +
- + {helpDeskContact.phone}
- + {helpDeskContact.email}
+ {data?.user?.given_name} {data?.user?.family_name} +
{rolesDescriptions(data?.user?.["custom:cms-roles"])}
{data?.user?.email}
Welcome to the official submission system for paper-based state plan amendments (SPAs) and section 1915 waivers.