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.
+ */}
+
+
+ >
+ );
+}
+
+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};
}
`;