diff --git a/.github/workflows/CD_preview.yml b/.github/workflows/CD_preview.yml index 69c1d1ca..6b64570d 100644 --- a/.github/workflows/CD_preview.yml +++ b/.github/workflows/CD_preview.yml @@ -93,13 +93,12 @@ jobs: - name: Comment PR with preview URL if: github.event_name == 'pull_request' env: - PREVIEW_URL: ${{ steps.preview-url.outputs.url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr comment ${{ github.event.pull_request.number }} --body "$(cat <<'EOF' + gh pr comment ${{ github.event.pull_request.number }} --body "$(cat <({ } control={control as unknown as Control} - render={({ field, fieldState }) => ( - - - {label} - {showAsterisk ? ( - <> - {' '} - - * - - - ) : null} - - - {fieldState?.error && ( - {fieldState?.error?.message} - )} - - )} + render={({ field, fieldState }) => { + const resolvedId = selectProps.id ?? String(name) + const resolvedLabelId = labelId ?? `${resolvedId}-label` + + return ( + + + {label} + {showAsterisk ? ( + <> + {' '} + + * + + + ) : null} + + + {fieldState?.error && ( + {fieldState?.error?.message} + )} + + ) + }} /> -) \ No newline at end of file +) diff --git a/src/components/Controlled/ControlledSelectWithChipsField.tsx b/src/components/Controlled/ControlledSelectWithChipsField.tsx index cdc13b5e..671f1ff4 100644 --- a/src/components/Controlled/ControlledSelectWithChipsField.tsx +++ b/src/components/Controlled/ControlledSelectWithChipsField.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import { Select, MenuItem, @@ -10,7 +10,7 @@ import { Box, SelectChangeEvent, } from '@mui/material' -import { Controller, Control, Path } from 'react-hook-form' +import { Control, Path, useController } from 'react-hook-form' export const ControlledSelectWithChipsField = ({ control, @@ -19,7 +19,7 @@ export const ControlledSelectWithChipsField = ({ options, required, multiple, - chipLimit = 3, + chipLimit = Number.POSITIVE_INFINITY, clearChipsSignal, resetClearChipsSignal, ...selectProps @@ -31,78 +31,70 @@ export const ControlledSelectWithChipsField = ({ required?: boolean multiple?: boolean chipLimit?: number - clearChipsSignal: boolean - resetClearChipsSignal: () => void + clearChipsSignal?: boolean + resetClearChipsSignal?: () => void } & SelectProps) => { - const [selectedChips, setSelectedChips] = useState([]) + const { + field, + fieldState, + } = useController({ + name: name as Path, + control: control as unknown as Control, + }) useEffect(() => { if (clearChipsSignal) { - setSelectedChips([]) - resetClearChipsSignal() + field.onChange([]) + resetClearChipsSignal?.() } - }, [clearChipsSignal, resetClearChipsSignal]) + }, [clearChipsSignal, field, resetClearChipsSignal]) const handleSelectChange = (event: SelectChangeEvent) => { const selectedValues = event.target.value as string[] - - if (selectedValues.length <= chipLimit) { - setSelectedChips(selectedValues) - } else { - // Enforce chip limit by replacing the oldest chip with the new selection - const updatedChips = [...selectedValues] - updatedChips.shift() // Remove the oldest chip - setSelectedChips(updatedChips) - } + const nextValue = + selectedValues.length <= chipLimit + ? selectedValues + : selectedValues.slice(-chipLimit) + field.onChange(nextValue) } + const selectedValues = Array.isArray(field.value) ? field.value : [] + const resolvedId = selectProps.id ?? String(name) + const resolvedLabelId = `${resolvedId}-label` + return ( - } - control={control as unknown as Control} - render={({ field, fieldState }) => ( - - {label} - ( + + {selected?.map((value: string) => ( + option.value === value)?.label} + color="primary" + /> ))} - - {fieldState?.error && ( - {fieldState?.error?.message} - )} - + + )} + > + {options.map((option) => ( + + {option.label} + + ))} + + {fieldState?.error && ( + {fieldState?.error?.message} )} - /> + ) } diff --git a/src/components/Controlled/ControlledTextField.tsx b/src/components/Controlled/ControlledTextField.tsx index 19e93957..239aca0e 100644 --- a/src/components/Controlled/ControlledTextField.tsx +++ b/src/components/Controlled/ControlledTextField.tsx @@ -21,6 +21,14 @@ export const ControlledTextField = ({ showAsterisk?: boolean warning?: boolean } & TextFieldProps) => { + const inputLabelProps = + type === 'date' + ? { + ...(textFieldProps.InputLabelProps ?? {}), + shrink: textFieldProps.InputLabelProps?.shrink ?? true, + } + : textFieldProps.InputLabelProps + return ( } @@ -46,6 +54,7 @@ export const ControlledTextField = ({ error={!!fieldState?.error} helperText={fieldState?.error?.message || ''} type={type} + InputLabelProps={inputLabelProps} fullWidth multiline={multiline} minRows={multiline ? minRows : undefined} diff --git a/src/components/form/contact/CreateEditContact.tsx b/src/components/form/contact/CreateEditContact.tsx index f5f49b72..07ac17a1 100644 --- a/src/components/form/contact/CreateEditContact.tsx +++ b/src/components/form/contact/CreateEditContact.tsx @@ -1,28 +1,20 @@ -import { - Control, - FieldErrors, - UseFormWatch, - UseFormSetValue, - useFieldArray +import { + Control, + FieldErrors, + UseFormWatch, + UseFormSetValue, + useFieldArray, } from 'react-hook-form' import Grid from '@mui/material/Grid2' -import { - Button, - Typography, - Box, - Divider -} from '@mui/material' +import { Button, Typography, Box, Divider } from '@mui/material' import { Add, Delete } from '@mui/icons-material' -import { - ControlledTextField, - ControlledSelectField, -} from '@/components' +import { ControlledTextField, ControlledSelectField } from '@/components' import { useLexicon } from '@/hooks' /** * CreateEditContact Component * A reusable form component for creating and editing contact information. - * + * * @param control - The control object from useForm * @param watch - The watch object from useForm * @param setValue - The setValue function from useForm @@ -65,63 +57,91 @@ export const CreateEditContact: React.FC = ({ onRemoveContact, onAddContact, canRemoveContact = true, - totalContacts = 1 + totalContacts = 1, }) => { const getFieldName = (fieldName: string) => { return mode === 'step' ? `${fieldPrefix}${fieldName}` : fieldName } - const { fields: emailFields, append: appendEmail, remove: removeEmail } = useFieldArray({ + const { + fields: emailFields, + append: appendEmail, + remove: removeEmail, + } = useFieldArray({ control, name: getFieldName('emails'), }) - const { fields: phoneFields, append: appendPhone, remove: removePhone } = useFieldArray({ + const { + fields: phoneFields, + append: appendPhone, + remove: removePhone, + } = useFieldArray({ control, name: getFieldName('phones'), }) - const { fields: addressFields, append: appendAddress, remove: removeAddress } = useFieldArray({ + const { + fields: addressFields, + append: appendAddress, + remove: removeAddress, + } = useFieldArray({ control, name: getFieldName('addresses'), }) //get contact role options - const { options: contactRoleOptions, isLoading: contactRoleLoading } = useLexicon({ - category: 'role' - }) + const { options: contactRoleOptions, isLoading: contactRoleLoading } = + useLexicon({ + category: 'role', + }) //get email type options - const { options: emailTypeOptions, isLoading: emailTypeLoading } = useLexicon({ - category: 'email_type' - }) + const { options: emailTypeOptions, isLoading: emailTypeLoading } = useLexicon( + { + category: 'email_type', + } + ) //get phone type options - const { options: phoneTypeOptions, isLoading: phoneTypeLoading } = useLexicon({ - category: 'phone_type' - }) + const { options: phoneTypeOptions, isLoading: phoneTypeLoading } = useLexicon( + { + category: 'phone_type', + } + ) //get address type options - const { options: addressTypeOptions, isLoading: addressTypeLoading } = useLexicon({ - category: 'address_type' - }) + const { options: addressTypeOptions, isLoading: addressTypeLoading } = + useLexicon({ + category: 'address_type', + }) //get release status options - const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = useLexicon({ - category: 'release_status' - }) + const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = + useLexicon({ + category: 'release_status', + }) //get contact type options - const { options: contactTypeOptions, isLoading: contactTypeLoading } = useLexicon({ - category: 'contact_type' - }) + const { options: contactTypeOptions, isLoading: contactTypeLoading } = + useLexicon({ + category: 'contact_type', + }) return ( {/* Contact Header with canRemoveContact button */} {contactIndex !== undefined && ( - + Contact {contactIndex + 1} {onRemoveContact && canRemoveContact && ( @@ -155,7 +176,7 @@ export const CreateEditContact: React.FC = ({ /> - + = ({ required /> - + = ({ required /> - + = ({ <> {/* Emails Section */} - + Emails @@ -248,6 +279,7 @@ export const CreateEditContact: React.FC = ({ variant="outlined" color="error" fullWidth + size="medium" > Remove @@ -258,18 +290,28 @@ export const CreateEditContact: React.FC = ({ {/* Phones Section */} - + Phone Numbers @@ -282,9 +324,7 @@ export const CreateEditContact: React.FC = ({ fullWidth control={control} name={`${getFieldName('phones')}.${phoneIndex}.country_code`} - options={[ - { value: '+1', label: 'US (+1)' }, - ]} + options={[{ value: '+1', label: 'US (+1)' }]} defaultValue="+1" /> @@ -322,6 +362,7 @@ export const CreateEditContact: React.FC = ({ variant="outlined" color="error" fullWidth + size="medium" > Remove @@ -332,22 +373,32 @@ export const CreateEditContact: React.FC = ({ {/* Addresses Section */} - + Addresses @@ -423,6 +474,7 @@ export const CreateEditContact: React.FC = ({ variant="outlined" color="error" fullWidth + size="medium" > Remove @@ -433,16 +485,11 @@ export const CreateEditContact: React.FC = ({ )} - {/* Add Contact Button */} {onAddContact && ( - diff --git a/src/components/form/thing/CreateEditWell.tsx b/src/components/form/thing/CreateEditWell.tsx index 676b585c..49973310 100644 --- a/src/components/form/thing/CreateEditWell.tsx +++ b/src/components/form/thing/CreateEditWell.tsx @@ -1,15 +1,12 @@ import { Control, FieldErrors } from 'react-hook-form' import Grid from '@mui/material/Grid2' -import { - ControlledTextField, - ControlledSelectField, -} from '@/components' +import { ControlledTextField, ControlledSelectField } from '@/components' import { useLexicon } from '@/hooks' /** * CreateEditWell Component * A reusable form component for creating and editing well information. - * + * * @param control - The control object from useForm * @param errors - The errors object from useForm * @param mode - The mode of the component ('standalone' or 'step') @@ -21,27 +18,36 @@ interface CreateEditWellProps { errors?: FieldErrors mode?: 'standalone' | 'step' fieldPrefix?: string + showWellType?: boolean + showNotes?: boolean + notesFieldName?: string + notesLabel?: string } export const CreateEditWell: React.FC = ({ control, errors, mode = 'standalone', - fieldPrefix = '' + fieldPrefix = '', + showWellType = true, + showNotes = true, + notesFieldName = 'notes', + notesLabel = 'Notes', }) => { const getFieldName = (fieldName: string) => { return mode === 'step' ? `${fieldPrefix}${fieldName}` : fieldName } //get well type options - const { options: wellTypeOptions, isLoading: wellTypeLoading } = useLexicon({ - category: 'well_type' + const { options: wellTypeOptions, isLoading: wellTypeLoading } = useLexicon({ + category: 'well_type', }) //get release status options - const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = useLexicon({ - category: 'release_status' - }) + const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = + useLexicon({ + category: 'release_status', + }) return ( @@ -66,16 +72,18 @@ export const CreateEditWell: React.FC = ({ /> - - - + {showWellType && ( + + + + )} = ({ /> - - - + {showNotes && ( + + + + )} ) } diff --git a/src/components/form/thing/CreateEditWellScreen.tsx b/src/components/form/thing/CreateEditWellScreen.tsx index f2bd9866..9090c9df 100644 --- a/src/components/form/thing/CreateEditWellScreen.tsx +++ b/src/components/form/thing/CreateEditWellScreen.tsx @@ -38,9 +38,10 @@ export const CreateEditWellScreen: React.FC = ({ } //get release status options - const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = useLexicon({ - category: 'release_status' - }) + const { options: releaseStatusOptions, isLoading: releaseStatusLoading } = + useLexicon({ + category: 'release_status', + }) return ( @@ -49,7 +50,7 @@ export const CreateEditWellScreen: React.FC = ({ Screen {screenIndex !== undefined ? screenIndex + 1 : ''} - + = ({ fullWidth /> - + = ({ fullWidth /> - + = ({ /> - - - + {/*disable release status for now*/} + {/*reenable when a proper publication process is in place*/} + {/**/} + {/* */} + {/**/} {/* Add/Remove buttons for step mode */} {mode === 'step' && ( - + {onAddScreen && ( + + + ) + + if (!canManageAmp && !loadedQuery.isLoading) { + return ( + + + You do not have access to edit this well. + + + ) + } return ( - - - + + + + + + + + Edit Well + + {topActions} + + + + + {loadedQuery.error ? ( + Failed to load the well editor. + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tap the map to update coordinates. + + + + {latitude != null && + longitude != null && + Number.isFinite(Number(latitude)) && + Number.isFinite(Number(longitude)) ? ( + + + + ) : null} + + + + + + + } + variant="outlined" + onClick={applyBlankContact} + > + Add Contact + + } + > + + {contactFields.length === 0 ? ( + No contacts yet. Add one to begin. + ) : null} + {contactFields.map((field, index) => ( + + 0} + /> + + ))} + + + + } + variant="outlined" + onClick={applyBlankScreen} + > + Add Screen + + } + > + + {wellScreenFields.length === 0 ? ( + + No well screens yet. Add one to begin. + + ) : null} + {wellScreenFields.map((field, index) => ( + + 0} + /> + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/pages/ocotillo/thing/well-edit.service.ts b/src/pages/ocotillo/thing/well-edit.service.ts new file mode 100644 index 00000000..2758acde --- /dev/null +++ b/src/pages/ocotillo/thing/well-edit.service.ts @@ -0,0 +1,448 @@ +import { ocotilloDataProvider } from '@/providers/ocotillo-data-provider' +import type { IContact, IWell, IWellScreen } from '@/interfaces/ocotillo' +import { normalizeElevationFeet } from '@/utils/waterElevation' + +export interface IWellEditForm { + well: { + id: number + name: string + release_status: string + first_visit_date?: string | null + well_depth?: number | null + hole_depth?: number | null + well_casing_depth?: number | null + well_casing_diameter?: number | null + well_casing_materials?: string[] | null + well_purposes?: string[] | null + well_construction_notes?: string | null + well_completion_date?: string | null + well_completion_date_source?: string | null + well_driller_name?: string | null + well_construction_method?: string | null + well_construction_method_source?: string | null + well_pump_type?: string | null + well_pump_depth?: number | null + formation_completion_code?: string | null + is_suitable_for_datalogger?: boolean | null + well_status?: string | null + measuring_point_height?: number | null + measuring_point_description?: string | null + } + location: { + id?: number + name?: string | null + point?: string | null + latitude?: number | null + longitude?: number | null + notes?: string | null + release_status?: string | null + elevation?: number | null + elevation_unit?: string | null + elevation_accuracy?: number | null + elevation_method?: string | null + coordinate_accuracy?: number | null + coordinate_method?: string | null + } + contacts: Array<{ + id?: number + name?: string | null + organization?: string | null + role?: string | null + contact_type?: string | null + release_status?: string | null + emails: Array<{ + id?: number + email: string + email_type?: string | null + release_status?: string | null + }> + phones: Array<{ + id?: number + phone_number: string + phone_type?: string | null + release_status?: string | null + country_code?: string | null + }> + addresses: Array<{ + id?: number + address_line_1: string + address_line_2?: string | null + city: string + state: string + postal_code: string + country?: string | null + address_type?: string | null + release_status?: string | null + }> + }> + wellScreens: Array<{ + id?: number + screen_depth_top?: number | null + screen_depth_bottom?: number | null + screen_type?: string | null + screen_description?: string | null + release_status?: string | null + }> + notes?: { + general_notes?: string | null + construction_notes?: string | null + measuring_notes?: string | null + site_notes?: string | null + water_notes?: string | null + sampling_procedure_notes?: string | null + } +} + +export interface IWellEditPayload extends IWellEditForm {} + +type WellAggregateResponse = { + well: IWell + contacts: IContact[] + wellScreens: IWellScreen[] +} + +export type AggregateFieldErrors = Record + +export interface AggregateEditError extends Error { + status?: number + fieldErrors?: AggregateFieldErrors + errors?: AggregateFieldErrors +} + +const emptyString = (value: unknown) => + typeof value === 'string' ? value : null + +const normalizeArray = (value: unknown) => { + if (!Array.isArray(value)) return [] as string[] + return value.filter((item): item is string => typeof item === 'string') +} + +const firstNoteByType = ( + notes: Array<{ note_type: string; content: string }> | undefined, + noteType: string +) => notes?.find((note) => note.note_type === noteType)?.content ?? null + +const joinNotes = (notes: Array<{ content: string }> | undefined) => + notes + ?.map((note) => note.content) + .filter(Boolean) + .join('\n\n') ?? null + +export const createEmptyWellEditForm = (id = 0): IWellEditForm => ({ + well: { + id, + name: '', + release_status: 'draft', + first_visit_date: null, + well_depth: null, + hole_depth: null, + well_casing_depth: null, + well_casing_diameter: null, + well_casing_materials: [], + well_purposes: [], + well_construction_notes: null, + well_completion_date: null, + well_completion_date_source: null, + well_driller_name: null, + well_construction_method: null, + well_construction_method_source: null, + well_pump_type: null, + well_pump_depth: null, + formation_completion_code: null, + is_suitable_for_datalogger: null, + well_status: null, + measuring_point_height: null, + measuring_point_description: null, + }, + location: { + name: null, + point: null, + latitude: null, + longitude: null, + notes: null, + release_status: 'draft', + elevation: null, + elevation_accuracy: null, + elevation_method: null, + coordinate_accuracy: null, + coordinate_method: null, + }, + contacts: [], + wellScreens: [], + notes: { + general_notes: null, + construction_notes: null, + measuring_notes: null, + site_notes: null, + water_notes: null, + sampling_procedure_notes: null, + }, +}) + +const getNestedNotes = ( + notes: Array<{ note_type: string; content: string }> | undefined +) => ({ + general_notes: firstNoteByType(notes, 'General'), + construction_notes: firstNoteByType(notes, 'Construction'), + measuring_notes: firstNoteByType(notes, 'Coordinate'), + site_notes: firstNoteByType(notes, 'Directions'), + water_notes: firstNoteByType(notes, 'Water'), + sampling_procedure_notes: firstNoteByType(notes, 'Sampling Procedure'), +}) + +export const mapWellEditAggregateToForm = ( + aggregate: WellAggregateResponse +): IWellEditForm => { + const currentLocation = aggregate.well.current_location as any + const coordinates = Array.isArray(currentLocation?.geometry?.coordinates) + ? currentLocation.geometry.coordinates + : [] + const longitude = Number(coordinates[0]) + const latitude = Number(coordinates[1]) + const hasCoordinates = Number.isFinite(longitude) && Number.isFinite(latitude) + const elevation = currentLocation?.properties?.elevation + + return { + well: { + id: aggregate.well.id, + name: aggregate.well.name ?? '', + release_status: aggregate.well.release_status, + first_visit_date: aggregate.well.first_visit_date ?? null, + well_depth: aggregate.well.well_depth ?? null, + hole_depth: aggregate.well.hole_depth ?? null, + well_casing_depth: aggregate.well.well_casing_depth ?? null, + well_casing_diameter: aggregate.well.well_casing_diameter ?? null, + well_casing_materials: normalizeArray( + aggregate.well.well_casing_materials + ), + well_purposes: normalizeArray(aggregate.well.well_purposes as unknown[]), + well_construction_notes: joinNotes( + aggregate.well.construction_notes as any + ), + well_completion_date: aggregate.well.well_completion_date ?? null, + well_completion_date_source: emptyString( + aggregate.well.well_completion_date_source + ), + well_driller_name: emptyString(aggregate.well.well_driller_name), + well_construction_method: emptyString( + aggregate.well.well_construction_method + ), + well_construction_method_source: emptyString( + aggregate.well.well_construction_method_source + ), + well_pump_type: emptyString(aggregate.well.well_pump_type), + well_pump_depth: aggregate.well.well_pump_depth ?? null, + formation_completion_code: emptyString( + aggregate.well.formation_completion_code + ), + is_suitable_for_datalogger: + aggregate.well.is_suitable_for_datalogger ?? null, + well_status: emptyString(aggregate.well.well_status), + measuring_point_height: aggregate.well.measuring_point_height ?? null, + measuring_point_description: emptyString( + aggregate.well.measuring_point_description + ), + }, + location: { + id: currentLocation?.properties?.id, + name: currentLocation?.properties?.name ?? null, + point: hasCoordinates ? `POINT(${longitude} ${latitude})` : null, + latitude: hasCoordinates ? latitude : null, + longitude: hasCoordinates ? longitude : null, + notes: joinNotes(currentLocation?.properties?.notes), + release_status: aggregate.well.release_status, + elevation: normalizeElevationFeet(currentLocation) ?? elevation ?? null, + elevation_unit: 'ft', + elevation_accuracy: + currentLocation?.properties?.elevation_accuracy ?? null, + elevation_method: currentLocation?.properties?.elevation_method ?? null, + coordinate_accuracy: + currentLocation?.properties?.coordinate_accuracy ?? null, + coordinate_method: currentLocation?.properties?.coordinate_method ?? null, + }, + contacts: aggregate.contacts.map((contact) => ({ + id: contact.id, + name: contact.name ?? null, + organization: contact.organization ?? null, + role: contact.role ?? null, + contact_type: contact.contact_type ?? null, + release_status: contact.release_status ?? null, + emails: (contact.emails ?? []).map((email) => ({ + id: email.id, + email: email.email ?? '', + email_type: email.email_type ?? null, + release_status: email.release_status ?? null, + })), + phones: (contact.phones ?? []).map((phone) => ({ + id: phone.id, + phone_number: phone.phone_number ?? '', + phone_type: phone.phone_type ?? null, + release_status: phone.release_status ?? null, + })), + addresses: (contact.addresses ?? []).map((address) => ({ + id: address.id, + address_line_1: address.address_line_1 ?? '', + address_line_2: address.address_line_2 ?? null, + city: address.city ?? '', + state: address.state ?? '', + postal_code: address.postal_code ?? '', + country: address.country ?? 'United States', + address_type: address.address_type ?? null, + release_status: address.release_status ?? null, + })), + })), + wellScreens: aggregate.wellScreens.map((screen) => ({ + id: screen.id, + screen_depth_top: screen.screen_depth_top ?? null, + screen_depth_bottom: screen.screen_depth_bottom ?? null, + screen_type: screen.screen_type ?? null, + screen_description: screen.screen_description ?? null, + release_status: screen.release_status ?? null, + })), + notes: { + ...getNestedNotes(aggregate.well.general_notes as any), + construction_notes: + joinNotes(aggregate.well.construction_notes as any) ?? null, + site_notes: joinNotes(aggregate.well.site_notes as any) ?? null, + sampling_procedure_notes: + joinNotes(aggregate.well.sampling_procedure_notes as any) ?? null, + water_notes: joinNotes(aggregate.well.water_notes as any) ?? null, + measuring_notes: joinNotes(currentLocation?.properties?.notes) ?? null, + }, + } +} + +export const mapWellEditFormToPayload = ( + form: IWellEditForm +): IWellEditPayload => ({ + ...form, + well: { + ...form.well, + well_casing_materials: form.well.well_casing_materials?.length + ? form.well.well_casing_materials + : null, + well_purposes: form.well.well_purposes?.length + ? form.well.well_purposes + : null, + }, + location: { + ...form.location, + release_status: form.well.release_status, + elevation_unit: 'ft', + point: + form.location.point ?? + (form.location.longitude != null && form.location.latitude != null + ? `POINT(${form.location.longitude} ${form.location.latitude})` + : null), + }, +}) + +const toFieldErrors = ( + detail: Array<{ loc: Array; msg: string }> +) => { + const fieldErrors: AggregateFieldErrors = {} + + detail.forEach((issue) => { + const fieldPath = issue.loc.join('.') + const cleanFieldPath = fieldPath.startsWith('body.') + ? fieldPath.substring(5) + : fieldPath + + if (!cleanFieldPath) return + + if (!fieldErrors[cleanFieldPath]) { + fieldErrors[cleanFieldPath] = [] + } + + fieldErrors[cleanFieldPath].push(issue.msg) + }) + + return fieldErrors +} + +const transformValidationError = (error: any) => { + if ( + (error?.response?.status === 422 || error?.response?.status === 409) && + error?.response?.data?.detail + ) { + const fieldErrors = toFieldErrors(error.response.data.detail) + const transformedError: AggregateEditError = new Error('Validation Error') + transformedError.status = error.response.status + transformedError.fieldErrors = fieldErrors + transformedError.errors = fieldErrors + return transformedError + } + + return error +} + +const fetchAllPages = async ( + resource: string, + params: Record +) => { + const firstPage = await ocotilloDataProvider.getList({ + resource, + pagination: { currentPage: 1, pageSize: 1000 }, + meta: { params }, + }) + + const totalPages = Math.max(1, Math.ceil(firstPage.total / 1000)) + + if (totalPages === 1) { + return firstPage.data as T[] + } + + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + ocotilloDataProvider.getList({ + resource, + pagination: { currentPage: index + 2, pageSize: 1000 }, + meta: { params }, + }) + ) + ) + + return [ + ...(firstPage.data as T[]), + ...remainingPages.flatMap((page) => page.data as T[]), + ] +} + +export const loadWellEditForm = async (thingId: number) => { + const [wellResult, contacts, wellScreens] = await Promise.all([ + ocotilloDataProvider.getOne({ + resource: 'thing/water-well', + id: thingId, + }), + fetchAllPages('contact', { thing_id: thingId }), + fetchAllPages('thing/well-screen', { thing_id: thingId }), + ]) + + return mapWellEditAggregateToForm({ + well: wellResult.data as IWell, + contacts, + wellScreens, + }) +} + +export const submitWellEditForm = async ( + thingId: number, + form: IWellEditForm +) => { + const payload = mapWellEditFormToPayload(form) + + try { + const response = await ocotilloDataProvider.custom({ + url: `thing/water-well/${thingId}/edit`, + method: 'post', + payload, + headers: { + 'Content-Type': 'application/json', + }, + }) + + return response.data + } catch (error) { + throw transformValidationError(error) + } +} diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index f6b449d9..e3847d63 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -4,6 +4,7 @@ import { useDataProvider, useList, useOne, + useGo, useResourceParams, useShow, } from '@refinedev/core' @@ -20,7 +21,8 @@ import { IWell, IWellScreen, } from '@/interfaces/ocotillo' -import { Box, Stack } from '@mui/material' +import { Box, Button, Stack } from '@mui/material' +import EditIcon from '@mui/icons-material/Edit' import { IHydrographDatasource } from '@/interfaces/st2' import { useAccessCapabilities, useSensorDeploymentRows } from '@/hooks' import Grid from '@mui/material/Grid2' @@ -53,6 +55,7 @@ export const WellShow = () => { () => dataProvider('ocotillo'), [dataProvider] ) + const go = useGo() const { id } = useResourceParams() const { query, result: well } = useShow({ @@ -326,6 +329,14 @@ export const WellShow = () => { headerButtons={() => canManageAmp ? ( + +}) => { + const TestForm = () => { + const { control } = useForm({ + defaultValues: { + dateValue: null, + textValue: null, + [name]: value, + }, + }) + + return ( + + ) + } + + return render() +} + +describe('ControlledTextField', () => { + it('shrinks label by default for date inputs with empty values', () => { + renderField({ + type: 'date', + label: 'Well Completion Date', + name: 'dateValue', + value: null, + }) + + const input = screen.getByLabelText('Well Completion Date') + const label = document.querySelector(`label[for="${input.id}"]`) + expect(label).toBeTruthy() + expect(label?.getAttribute('data-shrink')).toBe('true') + }) + + it('allows callers to override date label shrink behavior', () => { + renderField({ + type: 'date', + label: 'First Visit Date', + name: 'dateValue', + value: null, + inputLabelProps: { shrink: false }, + }) + + const input = screen.getByLabelText('First Visit Date') + const label = document.querySelector(`label[for="${input.id}"]`) + expect(label).toBeTruthy() + expect(label?.getAttribute('data-shrink')).toBe('false') + }) + + it('keeps default non-date label behavior unchanged', () => { + renderField({ + type: 'text', + label: 'Driller Name', + name: 'textValue', + value: null, + }) + + const input = screen.getByLabelText('Driller Name') + const label = document.querySelector(`label[for="${input.id}"]`) + expect(label).toBeTruthy() + expect(label?.getAttribute('data-shrink')).toBe('false') + }) +}) diff --git a/src/test/pages/well-edit.service.test.ts b/src/test/pages/well-edit.service.test.ts new file mode 100644 index 00000000..714801f5 --- /dev/null +++ b/src/test/pages/well-edit.service.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/providers/ocotillo-data-provider', () => ({ + ocotilloDataProvider: { + getOne: vi.fn(), + getList: vi.fn(), + custom: vi.fn(), + }, +})) + +import { ocotilloDataProvider } from '@/providers/ocotillo-data-provider' +import { + loadWellEditForm, + mapWellEditAggregateToForm, + mapWellEditFormToPayload, + submitWellEditForm, +} from '@/pages/ocotillo/thing/well-edit.service' + +describe('well edit service', () => { + it('maps aggregate data into grouped form defaults', () => { + const form = mapWellEditAggregateToForm({ + well: { + id: 7, + name: 'Test Well', + release_status: 'public', + first_visit_date: '2024-01-02', + well_depth: 123, + hole_depth: 130, + well_casing_depth: 80, + well_casing_diameter: 6, + well_casing_materials: ['steel'], + well_purposes: ['monitoring'], + well_construction_notes: null, + well_completion_date: '2024-02-03', + well_completion_date_source: 'field', + well_driller_name: 'Driller', + well_construction_method: 'drilled', + well_construction_method_source: 'record', + well_pump_type: 'submersible', + well_pump_depth: 44, + formation_completion_code: 'ABC', + is_suitable_for_datalogger: true, + well_status: 'active', + measuring_point_height: 1.2, + measuring_point_description: 'cap', + current_location: { + geometry: { coordinates: [-106.9, 34.1] }, + properties: { + name: 'Site 1', + release_status: 'public', + notes: [{ content: 'Location note' }], + elevation: 5000, + elevation_accuracy: 1.5, + elevation_method: 'DEM', + coordinate_accuracy: 20, + coordinate_method: 'GPS', + }, + }, + construction_notes: [{ content: 'Construction note' }], + water_notes: [], + site_notes: [], + sampling_procedure_notes: [], + general_notes: [], + permissions: [], + } as any, + contacts: [ + { + id: 1, + name: 'Contact', + organization: 'Org', + role: 'owner', + contact_type: 'person', + release_status: 'private', + emails: [ + { + id: 11, + email: 'a@example.com', + email_type: 'Primary', + release_status: 'private', + }, + ], + phones: [ + { + id: 12, + phone_number: '555', + phone_type: 'Mobile', + release_status: 'private', + }, + ], + addresses: [ + { + id: 13, + address_line_1: '1 Main', + city: 'City', + state: 'NM', + postal_code: '12345', + country: 'US', + address_type: 'Mailing', + release_status: 'private', + }, + ], + } as any, + ], + wellScreens: [ + { + id: 2, + screen_depth_top: 10, + screen_depth_bottom: 20, + screen_type: 'PVC', + screen_description: 'Screen', + release_status: 'public', + } as any, + ], + }) + + expect(form.well.id).toBe(7) + expect(form.location.longitude).toBe(-106.9) + expect(form.location.latitude).toBe(34.1) + expect(form.location.release_status).toBe(form.well.release_status) + expect(form.contacts[0].emails[0].id).toBe(11) + expect(form.wellScreens[0].id).toBe(2) + expect(form.notes?.construction_notes).toBe('Construction note') + }) + + it('serializes grouped form data into the edit payload', () => { + const payload = mapWellEditFormToPayload({ + well: { + id: 7, + name: 'Test Well', + release_status: 'public', + well_casing_materials: [], + well_purposes: ['monitoring'], + }, + location: { + release_status: 'private', + point: null, + latitude: 34.1, + longitude: -106.9, + }, + contacts: [], + wellScreens: [], + } as any) + + expect(payload.well.well_casing_materials).toBeNull() + expect(payload.well.well_purposes).toEqual(['monitoring']) + expect(payload.location.release_status).toBe('public') + expect(payload.location.point).toBe('POINT(-106.9 34.1)') + }) + + it('loads the aggregate using the dedicated service calls', async () => { + ;(ocotilloDataProvider.getOne as any).mockResolvedValue({ + data: { + id: 7, + name: 'Test Well', + release_status: 'public', + current_location: { + geometry: { coordinates: [-106.9, 34.1] }, + properties: {}, + }, + }, + }) + ;(ocotilloDataProvider.getList as any).mockResolvedValue({ + data: [], + total: 0, + }) + + await loadWellEditForm(7) + + expect(ocotilloDataProvider.getOne).toHaveBeenCalledWith({ + resource: 'thing/water-well', + id: 7, + }) + expect(ocotilloDataProvider.getList).toHaveBeenCalled() + }) + + it('translates validation errors from the aggregate endpoint', async () => { + ;(ocotilloDataProvider.custom as any).mockRejectedValue({ + response: { + status: 422, + data: { + detail: [ + { loc: ['body', 'well', 'name'], msg: 'required' }, + { + loc: ['body', 'contacts', 0, 'addresses', 1, 'city'], + msg: 'required', + }, + ], + }, + }, + }) + + await expect( + submitWellEditForm(7, { + well: { id: 7, name: '', release_status: 'public' }, + location: {}, + contacts: [], + wellScreens: [], + } as any) + ).rejects.toMatchObject({ + fieldErrors: { + 'well.name': ['required'], + 'contacts.0.addresses.1.city': ['required'], + }, + }) + }) +}) diff --git a/src/test/pages/well-edit.test.tsx b/src/test/pages/well-edit.test.tsx new file mode 100644 index 00000000..d757c172 --- /dev/null +++ b/src/test/pages/well-edit.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockedUseGo = vi.fn() +const mockedUseResourceParams = vi.fn() +const mockedUseAccessCapabilities = vi.fn() +const mockedUseLexicon = vi.fn() +const mockedUseQuery = vi.fn() +const mockedUseMutation = vi.fn() + +vi.mock('@refinedev/core', async () => { + const actual = + await vi.importActual('@refinedev/core') + + return { + ...actual, + useGo: () => mockedUseGo, + useResourceParams: () => mockedUseResourceParams(), + } +}) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (args?: unknown) => mockedUseQuery(args), + useMutation: (args?: unknown) => mockedUseMutation(args), +})) + +vi.mock('@/hooks', () => ({ + useAccessCapabilities: () => mockedUseAccessCapabilities(), + useLexicon: (args: { category: string }) => mockedUseLexicon(args), +})) + +vi.mock('@/components', () => ({ + MapComponent: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('react-map-gl', () => ({ + Source: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + Layer: () =>
, +})) + +vi.mock('@/components/AppBreadcrumb', () => ({ + AppBreadcrumb: () =>
, +})) + +vi.mock('@/components/form/contact/CreateEditContact', () => ({ + CreateEditContact: () =>
, +})) + +vi.mock('@/components/form/thing/CreateEditWell', () => ({ + CreateEditWell: () =>
, +})) + +vi.mock('@/components/form/thing/CreateEditWellScreen', () => ({ + CreateEditWellScreen: () =>
, +})) + +vi.mock('@/pages/ocotillo/thing/well-edit.service', () => ({ + createEmptyWellEditForm: vi.fn(() => ({ + well: { + id: 7, + name: '', + release_status: 'public', + well_casing_materials: [], + well_purposes: [], + }, + location: {}, + contacts: [], + wellScreens: [], + notes: {}, + })), + loadWellEditForm: vi.fn(), + submitWellEditForm: vi.fn(), +})) + +import { WellEdit } from '@/pages/ocotillo/thing/edit' + +describe('WellEdit lexicon-backed fields', () => { + beforeEach(() => { + mockedUseGo.mockReset() + mockedUseResourceParams.mockReset() + mockedUseAccessCapabilities.mockReset() + mockedUseLexicon.mockReset() + mockedUseQuery.mockReset() + mockedUseMutation.mockReset() + + mockedUseGo.mockReturnValue(vi.fn()) + mockedUseResourceParams.mockReturnValue({ id: '7' }) + mockedUseAccessCapabilities.mockReturnValue({ canManageAmp: true }) + mockedUseMutation.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }) + mockedUseQuery.mockReturnValue({ + data: { + well: { + id: 7, + name: 'Test Well', + release_status: 'public', + first_visit_date: null, + well_completion_date: null, + well_construction_method: 'drilled', + well_pump_type: 'submersible', + well_status: 'active', + well_casing_materials: ['steel'], + well_purposes: ['monitoring'], + }, + location: { + coordinate_method: 'GPS', + elevation_method: 'DEM', + latitude: 34.1, + longitude: -106.9, + }, + contacts: [], + wellScreens: [], + notes: {}, + }, + isLoading: false, + error: null, + }) + mockedUseLexicon.mockImplementation( + ({ category }: { category: string }) => ({ + options: + { + well_pump_type: [{ value: 'submersible', label: 'submersible' }], + well_construction_method: [{ value: 'drilled', label: 'drilled' }], + well_purpose: [{ value: 'monitoring', label: 'monitoring' }], + coordinate_method: [{ value: 'GPS', label: 'GPS' }], + elevation_method: [{ value: 'DEM', label: 'DEM' }], + status: [{ value: 'active', label: 'active' }], + casing_material: [{ value: 'steel', label: 'steel' }], + }[category] ?? [], + }) + ) + }) + + it('renders the targeted fields as lexicon-backed selects and hydrates multi-select values', async () => { + render() + + expect(screen.getByRole('combobox', { name: /pump type/i })).toBeTruthy() + expect( + screen.getByRole('combobox', { name: /construction method/i }) + ).toBeTruthy() + expect(screen.getByRole('combobox', { name: /well status/i })).toBeTruthy() + expect( + screen.getByRole('combobox', { name: /coordinate method/i }) + ).toBeTruthy() + expect( + screen.getByRole('combobox', { name: /elevation method/i }) + ).toBeTruthy() + + await waitFor(() => { + expect(screen.getAllByText('monitoring').length).toBeGreaterThan(0) + expect(screen.getAllByText('steel').length).toBeGreaterThan(0) + }) + + expect(screen.queryByRole('textbox', { name: /pump type/i })).toBeNull() + expect( + screen.queryByRole('textbox', { name: /^construction method$/i }) + ).toBeNull() + expect(screen.queryByRole('textbox', { name: /^well status$/i })).toBeNull() + expect( + screen.queryByRole('textbox', { name: /^coordinate method$/i }) + ).toBeNull() + expect( + screen.queryByRole('textbox', { name: /^elevation method$/i }) + ).toBeNull() + expect( + screen.queryByRole('textbox', { name: /release status/i }) + ).toBeNull() + + const firstVisitInput = screen.getByLabelText('First Visit Date') + const wellCompletionInput = screen.getByLabelText('Well Completion Date') + const firstVisitLabel = document.querySelector( + `label[for="${firstVisitInput.id}"]` + ) + const wellCompletionLabel = document.querySelector( + `label[for="${wellCompletionInput.id}"]` + ) + expect(firstVisitLabel?.getAttribute('data-shrink')).toBe('true') + expect(wellCompletionLabel?.getAttribute('data-shrink')).toBe('true') + }) +}) diff --git a/src/test/pages/well-show.test.tsx b/src/test/pages/well-show.test.tsx index a8c4a4ea..09de7a5b 100644 --- a/src/test/pages/well-show.test.tsx +++ b/src/test/pages/well-show.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import React from 'react' -import { render } from '@testing-library/react' +import { fireEvent, render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' const mockedUseShow = vi.fn() @@ -11,6 +11,7 @@ const mockedUseDataProvider = vi.fn() const mockedUseQuery = vi.fn() const mockedUseResourceParams = vi.fn() const mockedUseAccessCapabilities = vi.fn() +const mockedUseGo = vi.fn() vi.mock('@refinedev/core', async () => { const actual = @@ -23,14 +24,24 @@ vi.mock('@refinedev/core', async () => { useOne: (args?: unknown) => mockedUseOne(args), useDataProvider: (args?: unknown) => mockedUseDataProvider(args), useResourceParams: (args?: unknown) => mockedUseResourceParams(args), + useGo: () => mockedUseGo, } }) vi.mock('@refinedev/mui', async () => { return { useDataGrid: (args?: unknown) => mockedUseDataGrid(args), - Show: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ Show: ({ + children, + headerButtons, + }: { + children: React.ReactNode + headerButtons?: () => React.ReactNode + }) => ( +
+ {headerButtons?.()} + {children} +
), } }) @@ -44,6 +55,10 @@ vi.mock('@/hooks', () => ({ useSensorDeploymentRows: () => [], })) +vi.mock('@/components/AppBreadcrumb', () => ({ + AppBreadcrumb: () =>
, +})) + vi.mock('@/components', () => { const Stub = ({ name }: { name: string }) =>
{name}
return { @@ -82,6 +97,7 @@ describe('WellShow data loading', () => { mockedUseQuery.mockClear() mockedUseResourceParams.mockClear() mockedUseAccessCapabilities.mockClear() + mockedUseGo.mockClear() mockedUseShow.mockReturnValue({ query: { isLoading: false }, @@ -107,6 +123,7 @@ describe('WellShow data loading', () => { }) mockedUseResourceParams.mockReturnValue({ id: '42' }) mockedUseAccessCapabilities.mockReturnValue({ canManageAmp: false }) + mockedUseGo.mockReturnValue(vi.fn()) }) it('enables well-scoped queries only when id is present', () => { @@ -185,4 +202,29 @@ describe('WellShow data loading', () => { dataGridCalls.every((args) => args.queryOptions?.enabled === false) ).toBe(true) }) + + it('shows an edit button only to AMP admins', () => { + mockedUseAccessCapabilities.mockReturnValue({ canManageAmp: true }) + + const { getByRole, rerender, queryByRole } = render() + + expect(getByRole('button', { name: /edit/i })).toBeTruthy() + + mockedUseAccessCapabilities.mockReturnValue({ canManageAmp: false }) + rerender() + + expect(queryByRole('button', { name: /edit/i })).toBeNull() + }) + + it('navigates to the edit page when edit is clicked', () => { + mockedUseAccessCapabilities.mockReturnValue({ canManageAmp: true }) + + const { getByRole } = render() + fireEvent.click(getByRole('button', { name: /edit/i })) + + expect(mockedUseGo).toHaveBeenCalledWith({ + to: '/ocotillo/well/edit/42', + type: 'push', + }) + }) })