diff --git a/frontend/src/components/common.tsx b/frontend/src/components/common.tsx index a8937ab2..fcb200cf 100644 --- a/frontend/src/components/common.tsx +++ b/frontend/src/components/common.tsx @@ -1,5 +1,5 @@ import { useIsAuthenticated, useMsal } from "@azure/msal-react"; -import { Dialog } from "@equinor/eds-core-react"; +import { Dialog, List } from "@equinor/eds-core-react"; import { QueryErrorResetBoundary, useSuspenseQuery, @@ -17,7 +17,10 @@ import { userGetUserOptions } from "#client/@tanstack/react-query.gen"; import { GeneralButton } from "#components/form/button"; import type { HealthCheck } from "#services/smda"; import { + ActionButtonsContainer, GenericDialog, + OrphanWarningContainer, + OrphanWarningList, PageCode, PageHeader, PageText, @@ -39,6 +42,48 @@ export function Loading() { return Loading...; } +const ORPHAN_LIST_PREVIEW_LIMIT = 20; + +export function OrphanWarningBox({ + message, + listItems, + buttonLabel, + onRemove, +}: { + message: string; + listItems: string[]; + buttonLabel: string; + onRemove: () => void; +}) { + const visibleListItems = listItems.slice(0, ORPHAN_LIST_PREVIEW_LIMIT); + const hiddenItemCount = listItems.length - visibleListItems.length; + + return ( + + {message} + + + {visibleListItems.map((item) => ( + {item} + ))} + {hiddenItemCount > 0 && ( + + and {hiddenItemCount} more + + )} + + + + + + + ); +} + function ErrorFallback({ error, resetErrorBoundary, diff --git a/frontend/src/components/project/rms/Overview.tsx b/frontend/src/components/project/rms/Overview.tsx index 863bb5dc..0f57f91e 100644 --- a/frontend/src/components/project/rms/Overview.tsx +++ b/frontend/src/components/project/rms/Overview.tsx @@ -13,6 +13,7 @@ import { projectPatchRmsMutation, rmsDeleteRmsProjectMutation, rmsGetHorizonsQueryKey, + rmsGetWellsQueryKey, rmsGetZonesQueryKey, rmsPostRmsProjectMutation, sessionGetSessionQueryKey, @@ -258,6 +259,9 @@ function RmsProjectActions({ void queryClient.invalidateQueries({ queryKey: rmsGetZonesQueryKey(), }); + void queryClient.invalidateQueries({ + queryKey: rmsGetWellsQueryKey(), + }); }, onError: (error, variables) => { if (error.response?.status === HTTP_STATUS_422_UNPROCESSABLE_CONTENT) { diff --git a/frontend/src/components/project/rms/Stratigraphy.style.ts b/frontend/src/components/project/rms/Stratigraphy.style.ts index e0b2e6cf..fc5b0837 100644 --- a/frontend/src/components/project/rms/Stratigraphy.style.ts +++ b/frontend/src/components/project/rms/Stratigraphy.style.ts @@ -1,8 +1,6 @@ import { tokens } from "@equinor/eds-tokens"; import styled from "styled-components"; -import { WarningBox } from "#styles/common"; - export const StratigraphyEditorContainer = styled.div` display: grid; grid-template-columns: 1fr 1fr; @@ -16,7 +14,3 @@ export const StratigraphyEditorContainer = styled.div` text-decoration: underline; } `; - -export const OrphanTypesContainer = styled(WarningBox)` - margin-top: ${tokens.spacings.comfortable.medium}; -`; diff --git a/frontend/src/components/project/rms/Stratigraphy.tsx b/frontend/src/components/project/rms/Stratigraphy.tsx index 3b885868..fd59e8ca 100644 --- a/frontend/src/components/project/rms/Stratigraphy.tsx +++ b/frontend/src/components/project/rms/Stratigraphy.tsx @@ -1,4 +1,4 @@ -import { Dialog, List } from "@equinor/eds-core-react"; +import { Dialog } from "@equinor/eds-core-react"; import { type AnyFormApi, createFormHook } from "@tanstack/react-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; @@ -17,7 +17,7 @@ import { rmsGetHorizonsOptions, rmsGetZonesOptions, } from "#client/@tanstack/react-query.gen"; -import { ConfirmCloseDialog } from "#components/common"; +import { ConfirmCloseDialog, OrphanWarningBox } from "#components/common"; import { CancelButton, GeneralButton, @@ -44,10 +44,7 @@ import { fieldContext, formContext, useFormContext } from "#utils/form"; import { useConfirmClose } from "#utils/ui.ts"; import { StratigraphicFramework } from "../stratigraphicFramework/StratigraphicFramework.tsx"; import { Horizons, Zones } from "./StratigraphicFramework"; -import { - OrphanTypesContainer, - StratigraphyEditorContainer, -} from "./Stratigraphy.style"; +import { StratigraphyEditorContainer } from "./Stratigraphy.style"; import { namesNotInReference, useItemHandlers } from "./utils.ts"; type ConfirmAction = "add" | "remove" | ""; @@ -100,33 +97,6 @@ function ConfirmActionDialog({ ); } -function OrphanWarningBox({ - orphanZoneNames, - orphanHorizonNames, -}: { - orphanZoneNames: string[]; - orphanHorizonNames: string[]; -}) { - return ( - - - There are horizons or zones in the project stratigraphy that are no - longer available in RMS. They need to be removed before data can be - saved. - - - - {orphanHorizonNames.length > 0 && ( - {orphanHorizonNames.join(", ")} - )} - {orphanZoneNames.length > 0 && ( - {orphanZoneNames.join(", ")} - )} - - - ); -} - function StratigraphyEditor({ availableHorizons, availableZones, @@ -185,6 +155,28 @@ function StratigraphyEditor({ ); const hasOrphans = orphanHorizonNames.length > 0 || orphanZoneNames.length > 0; + const orphanCount = orphanHorizonNames.length + orphanZoneNames.length; + const orphanTypeCounts = [ + orphanHorizonNames.length > 0 + ? `${orphanHorizonNames.length} ${ + orphanHorizonNames.length === 1 ? "horizon" : "horizons" + }` + : "", + orphanZoneNames.length > 0 + ? `${orphanZoneNames.length} ${ + orphanZoneNames.length === 1 ? "zone" : "zones" + }` + : "", + ].filter(Boolean); + const orphanListItems = [ + ...orphanHorizonNames.map((name) => `Horizon: ${name}`), + ...orphanZoneNames.map((name) => `Zone: ${name}`), + ]; + + const removeOrphans = () => { + removeItems("horizons", orphanHorizonNames); + removeItems("zones", orphanZoneNames); + }; useEffect(() => { form.setErrorMap({ @@ -216,8 +208,19 @@ function StratigraphyEditor({ {hasOrphans && ( 0 ? "horizons" : "", + orphanZoneNames.length > 0 ? "zones" : "", + ] + .filter(Boolean) + .join(" and ")}`} + onRemove={removeOrphans} /> )} diff --git a/frontend/src/components/project/rms/Wellbores.style.ts b/frontend/src/components/project/rms/Wellbores.style.ts new file mode 100644 index 00000000..64237c92 --- /dev/null +++ b/frontend/src/components/project/rms/Wellbores.style.ts @@ -0,0 +1,31 @@ +import { tokens } from "@equinor/eds-tokens"; +import styled from "styled-components"; + +export const WellsContainer = styled.div` + width: fit-content; + max-width: 100%; + margin-bottom: ${tokens.spacings.comfortable.medium}; + + .table-wrapper { + /* EDS's Firefox table workaround makes interactive DataGrid headers taller + than the 48px row height used by the virtualizer. */ + thead th { + height: 48px !important; + vertical-align: middle !important; + } + + thead th [class*="CellInner"] { + height: 100% !important; + padding-block: 0 !important; + gap: ${tokens.spacings.comfortable.small}; + } + + thead th [class*="SortButton"] { + width: auto !important; + } + + thead th.persistent-filter [class*="FilterVisibility"] { + opacity: 1 !important; + } + } +`; diff --git a/frontend/src/components/project/rms/Wellbores.tsx b/frontend/src/components/project/rms/Wellbores.tsx new file mode 100644 index 00000000..b5cddff4 --- /dev/null +++ b/frontend/src/components/project/rms/Wellbores.tsx @@ -0,0 +1,588 @@ +import { Checkbox, Dialog, List } from "@equinor/eds-core-react"; +import { type ColumnDef, EdsDataGrid } from "@equinor/eds-data-grid-react"; +import { type AnyFormApi, createFormHook } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "react-toastify"; + +import type { RmsProject, RmsWell } from "#client"; +import { + projectGetChangelogQueryKey, + projectGetProjectQueryKey, + projectPatchRmsWellsMutation, + rmsGetWellsOptions, +} from "#client/@tanstack/react-query.gen"; +import { ConfirmCloseDialog, OrphanWarningBox } from "#components/common"; +import { + CancelButton, + GeneralButton, + SubmitButton, +} from "#components/form/button"; +import type { + FormSubmitCallbackProps, + MutationCallbackProps, +} from "#components/form/form.tsx"; +import { + ActionButtonsContainer, + EditDialog, + PageCode, + PageSectionWidthConstrained, + PageText, +} from "#styles/common"; +import { + HTTP_STATUS_422_UNPROCESSABLE_CONTENT, + httpValidationErrorToString, +} from "#utils/api.ts"; +import { fieldContext, formContext, useFormContext } from "#utils/form"; +import { useConfirmClose } from "#utils/ui.ts"; +import { WellsContainer } from "./Wellbores.style"; + +const { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: {}, + formComponents: { WellboresEditor, CancelButton, SubmitButton }, +}); + +function sortByAvailableOrder( + wells: RmsWell[], + availableWells: RmsWell[], +): RmsWell[] { + const order = new Map(availableWells.map((well, idx) => [well.name, idx])); + + return [...wells].sort( + (a, b) => + (order.get(a.name) ?? Number.MAX_SAFE_INTEGER) - + (order.get(b.name) ?? Number.MAX_SAFE_INTEGER), + ); +} + +// The grid header and each row are 48px high at the EDS comfortable density. +// Firefox also uses this fixed estimate because EDS disables dynamic row +// measurement there. +const GRID_ROW_HEIGHT = 48; + +// Keep short grids only as tall as their header and rows. Cap long grids at +// maxHeight so they scroll and virtualize instead of expanding the page. +function gridHeight(rowCount: number, maxHeight: number): number { + return Math.min((rowCount + 1) * GRID_ROW_HEIGHT, maxHeight); +} + +const storedWellColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Well", + size: 200, + }, + { + id: "planned", + header: "Planned", + accessorFn: (row) => (row.planned ? "Yes" : "No"), + size: 100, + }, +]; + +function WellboresEditor({ availableWells }: { availableWells: RmsWell[] }) { + const form: AnyFormApi = useFormContext(); + const projectWells = form.getFieldValue("wells") as RmsWell[]; + + const availableNames = useMemo( + () => new Set(availableWells.map((well) => well.name)), + [availableWells], + ); + const selectedNames = new Set(projectWells.map((well) => well.name)); + const plannedByName = new Map( + projectWells.map((well) => [well.name, well.planned ?? false]), + ); + + const orphanWellNames = projectWells + .filter((well) => !availableNames.has(well.name)) + .map((well) => well.name); + const hasOrphans = orphanWellNames.length > 0; + const includedCount = projectWells.filter((well) => + availableNames.has(well.name), + ).length; + + useEffect(() => { + form.setErrorMap({ + onChange: hasOrphans ? ["Outdated wells are present"] : undefined, + }); + }, [form, hasOrphans]); + + const setWells = (wells: RmsWell[]) => { + form.setFieldValue("wells", sortByAvailableOrder(wells, availableWells)); + }; + + const toggleSelected = (name: string) => { + if (selectedNames.has(name)) { + setWells(projectWells.filter((well) => well.name !== name)); + } else { + setWells([...projectWells, { name, planned: false }]); + } + }; + + const removeOrphans = () => { + setWells(projectWells.filter((well) => availableNames.has(well.name))); + }; + + const togglePlanned = (name: string) => { + setWells( + projectWells.map((well) => + well.name === name ? { ...well, planned: !well.planned } : well, + ), + ); + }; + + const selectAll = () => { + setWells( + availableWells.map((well) => ({ + name: well.name, + planned: plannedByName.get(well.name) ?? false, + })), + ); + }; + + const deselectAll = () => { + setWells([]); + }; + + const allSelected = + !hasOrphans && + projectWells.length === availableWells.length && + availableWells.every((well) => selectedNames.has(well.name)); + + const columns: ColumnDef[] = [ + { + id: "include", + header: "Include", + enableColumnFilter: false, + enableSorting: false, + size: 90, + cell: ({ row }) => { + const name = row.original.name; + + return ( + { + toggleSelected(name); + }} + /> + ); + }, + }, + { + accessorKey: "name", + header: "Well", + size: 200, + }, + { + id: "planned", + header: "Planned", + enableColumnFilter: false, + enableSorting: false, + size: 90, + cell: ({ row }) => { + const name = row.original.name; + const isSelected = selectedNames.has(name); + + return ( + { + togglePlanned(name); + }} + /> + ); + }, + }, + ]; + + return ( + <> + {hasOrphans && ( + + )} + + + {includedCount} of{" "} + {availableWells.length} RMS{" "} + {availableWells.length === 1 ? "well" : "wells"}{" "} + {includedCount === 1 ? "is" : "are"} included. + + + + row.name} + // Keep the Well column filter visible because the hint points users + // to it. Other filter icons appear on hover or focus as usual. + headerClass={(column) => + column.id === "name" ? "persistent-filter" : "" + } + enableSorting + enableColumnFiltering + emptyMessage="No RMS wells available." + /> + + + + + + + + 💡 Tips + + + When no wells are stored, all available RMS wells are selected when + you open the editor. + + + Use the filter in the Well column to find wells, then clear their + checkbox to exclude them from the project. + + + Mark a well as planned to store it without making it available for + wellbore mapping. + + + + ); +} + +function Edit({ + projectWells, + projectReadOnly, + isDialogOpen, + closeDialog, + isRmsProjectOpen, +}: { + projectWells: RmsWell[]; + projectReadOnly: boolean; + isDialogOpen: boolean; + closeDialog: () => void; + isRmsProjectOpen: boolean; +}) { + const availableWellsQuery = useQuery({ + ...rmsGetWellsOptions(), + enabled: isRmsProjectOpen, + }); + const isInitialized = useRef(false); + const availableWellsLoaded = availableWellsQuery.isSuccess; + + const queryClient = useQueryClient(); + + const rmsWellsMutation = useMutation({ + ...projectPatchRmsWellsMutation(), + onSuccess: () => { + void queryClient.refetchQueries({ + queryKey: projectGetProjectQueryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: projectGetChangelogQueryKey(), + }); + }, + onError: (error) => { + if (error.response?.status === HTTP_STATUS_422_UNPROCESSABLE_CONTENT) { + const message = httpValidationErrorToString(error); + console.error(message); + toast.error(message, { autoClose: false }); + } + }, + meta: { + errorPrefix: "Error updating project wells", + preventDefaultErrorHandling: [HTTP_STATUS_422_UNPROCESSABLE_CONTENT], + }, + }); + + const form = useAppForm({ + defaultValues: { + wells: projectWells, + }, + onSubmit: ({ value, formApi }) => { + if (!projectReadOnly) { + mutationCallback({ + formValue: value.wells, + formSubmitCallback, + formReset: formApi.reset, + }); + } + }, + }); + + const mutationCallback = ({ + formValue, + formSubmitCallback, + formReset, + }: MutationCallbackProps) => { + rmsWellsMutation.mutate( + { body: formValue }, + { + onSuccess: (data) => { + formSubmitCallback({ message: data.message, formReset }); + closeDialog(); + }, + }, + ); + }; + + const formSubmitCallback = ({ + message, + formReset, + }: FormSubmitCallbackProps) => { + toast.info(message); + formReset(); + }; + + // Auto-select all available wells when opening the dialog with no stored + // wells. The user must still save the selection explicitly. + useEffect(() => { + if (!isDialogOpen) { + isInitialized.current = false; + + return; + } + + if (isInitialized.current || !availableWellsQuery.isSuccess) { + return; + } + + if (projectWells.length === 0) { + form.setFieldValue( + "wells", + availableWellsQuery.data.map((well) => ({ + name: well.name, + planned: false, + })), + ); + } + + isInitialized.current = true; + }, [ + isDialogOpen, + availableWellsQuery.data, + availableWellsQuery.isSuccess, + projectWells.length, + form, + ]); + + const confirmClose = useConfirmClose({ + enable: isDialogOpen && !projectReadOnly, + determineRequiresConfirmation: () => + !projectReadOnly && !form.state.isDefaultValue, + onCloseConfirmed: () => { + form.reset(); + closeDialog(); + }, + }); + + return ( + <> + + + + Set project wells + + + {availableWellsQuery.isPending ? ( + Loading RMS wells... + ) : availableWellsQuery.isError ? ( + + Could not load wells from RMS. Reload the RMS project and try + again. + + ) : availableWellsQuery.data.length === 0 && + projectWells.length === 0 ? ( + + No wells are available in RMS. Add wells to the RMS project, then + reload the RMS project. + + ) : ( + + state.values}> + {() => ( + + )} + + + )} + + + {/* + The submit button is kept in its own form element, separate from the + editor grid above. EdsDataGrid renders its sort/filter controls as + native buttons/inputs without an explicit type, so having them inside + a form would cause accidental submits when sorting or filtering. + */} +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + + + [state.isDefaultValue, state.canSubmit] as const + } + > + {([isDefaultValue, canSubmit]) => ( + + )} + + { + e.preventDefault(); + confirmClose.handleCloseRequest(); + }} + /> + +
+
+ + ); +} + +export function Wellbores({ + rmsData, + projectReadOnly, + isRmsProjectOpen, +}: { + rmsData: RmsProject | undefined | null; + projectReadOnly: boolean; + isRmsProjectOpen: boolean; +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const projectWells = rmsData?.wells ?? []; + + const closeDialog = () => { + setIsDialogOpen(false); + }; + const openDialog = () => { + setIsDialogOpen(true); + }; + + return ( + <> + + + The following wells are stored in the project. Planned wells are + excluded from wellbore mapping. All other stored wells are available + for mapping. + + + + {projectWells.length ? ( + <> + + + {projectWells.length}{" "} + {projectWells.length === 1 ? "well is" : "wells are"} included in + the project. + + + + + + row.name} + enableSorting + enableColumnFiltering + /> + + + + ) : ( + + No wells currently stored in the project. + + )} + + + + + + + + ); +} diff --git a/frontend/src/routes/project/rms/wellbores.tsx b/frontend/src/routes/project/rms/wellbores.tsx index 6b9e5552..5513fcd6 100644 --- a/frontend/src/routes/project/rms/wellbores.tsx +++ b/frontend/src/routes/project/rms/wellbores.tsx @@ -1,5 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; +import { Suspense } from "react"; +import { Loading } from "#components/common"; +import { Wellbores } from "#components/project/rms/Wellbores"; import { useProject } from "#services/project"; import { PageHeader, @@ -11,17 +14,32 @@ export const Route = createFileRoute("/project/rms/wellbores")({ component: RouteComponent, }); -function RouteComponent() { +function Content() { const project = useProject(); - return ( + return project.status && project.data ? ( + + ) : ( - Wellbores - - {project.status - ? "Wellbore content is coming soon." - : "Project not set."} - + Project not set. ); } + +function RouteComponent() { + return ( + <> + + Wellbores + + + }> + + + + ); +} diff --git a/frontend/src/styles/common.ts b/frontend/src/styles/common.ts index 613ddc45..ab2bfe5b 100644 --- a/frontend/src/styles/common.ts +++ b/frontend/src/styles/common.ts @@ -114,6 +114,20 @@ export const WarningBox = styled(GenericBox)` background: ${tokens.colors.ui.background__warning.hex}; `; +export const OrphanWarningContainer = styled(WarningBox)` + > *:last-child { + margin-top: ${tokens.spacings.comfortable.medium}; + margin-bottom: 0; + } +`; + +export const OrphanWarningList = styled(List)` + max-height: 4.5rem; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: ${tokens.spacings.comfortable.small}; +`; + export const ChipsContainer = styled.div` display: flex; flex-wrap: wrap; @@ -155,6 +169,10 @@ export const GenericDialog = styled(Dialog).attrs<{ export const EditDialog = styled(GenericDialog)` #eds-dialog-customcontent { + max-height: calc(100vh - 12rem); + box-sizing: border-box; + overflow-y: auto; + overscroll-behavior: contain; padding-bottom: ${tokens.spacings.comfortable.x_large}; } `;