From 0095f6f4c9d5b4a241acb5c583770c46d57a6c2e Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 10 Aug 2026 23:39:29 -0700 Subject: [PATCH 1/5] feat(projects): promote Projects to a top-level resource with editing Projects were introduced as a sub-feature of Wells and later moved to top-level navigation, but the URL, resource name, route nesting, access key and source directory still described them as a wells sub-feature. Finish that move and build out editing on the list page. Routing: - /ocotillo/well/projects -> /ocotillo/projects, resource `projects`, access key `ocotillo.projects`, route lifted out of the `well` block - pages moved to src/pages/ocotillo/projects/, components renamed to ProjectList / ProjectShow List: - replace the MUI DataGrid with the shadcn table. The group endpoint ignores sort params and returns a few hundred rows, so the page loads the full set and sorts, filters and pages locally; filtering a single server page would only ever describe part of the data - sortable columns with aria-sort and blanks-last ordering, search over name/description/type/status, Type and Release status facets with a "none" option for untyped records, 20 rows per page Editing: - ProjectEditPanel opens from a row edit action or double click: name (admin only), description, release status, type, and boundary upload - GeoJSON upload accepts a single polygon and emits MULTIPOLYGON WKT, the only geometry type group.project_area accepts - 409 on the unique (name, group_type) constraint gets its own message Also drops the client-side quick filter from ListPage. It filtered only the current server page while sitting next to the Filters toolbar button, with no way to tell the two apart. Server-search lists (Wells) keep their search input. Project type editing requires the matching OcotilloAPI change adding group_type to UpdateGroup; without it PATCH returns 200 and silently discards the value. Co-Authored-By: Claude Opus 5 --- cypress/e2e/ocotillo/list-pages.cy.ts | 2 +- cypress/e2e/ocotillo/show-pages.cy.ts | 2 +- src/analytics/posthog.ts | 2 +- src/components/AppShell.tsx | 2 +- src/components/ListPage.tsx | 120 ++--- .../ProjectEdit/ProjectEditPanel.tsx | 458 ++++++++++++++++ .../ProjectsTable/ProjectsTable.tsx | 489 ++++++++++++++++++ src/components/ui/table.tsx | 114 ++++ src/config/navigation.ts | 4 +- src/hooks/useSearchModalState.ts | 2 +- src/pages/ocotillo/projects/index.ts | 2 + src/pages/ocotillo/projects/list.tsx | 108 ++++ .../well-projects => projects}/show.tsx | 15 +- src/pages/ocotillo/thing/index.tsx | 2 - .../ocotillo/thing/well-projects/list.tsx | 131 ----- src/resources/ocotillo.tsx | 8 +- src/routes/ocotillo.tsx | 39 +- src/test/components/ProjectListPanel.test.tsx | 148 ++++++ src/test/components/ProjectsTable.test.tsx | 264 ++++++++++ src/test/components/SearchModal.test.tsx | 2 +- src/test/utils/accessControl.test.ts | 8 +- src/test/utils/projectBoundary.test.ts | 108 ++++ src/utils/accessControl.ts | 11 +- src/utils/index.ts | 1 + src/utils/projectBoundary.ts | 161 ++++++ 25 files changed, 1944 insertions(+), 259 deletions(-) create mode 100644 src/components/ProjectEdit/ProjectEditPanel.tsx create mode 100644 src/components/ProjectsTable/ProjectsTable.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/pages/ocotillo/projects/index.ts create mode 100644 src/pages/ocotillo/projects/list.tsx rename src/pages/ocotillo/{thing/well-projects => projects}/show.tsx (96%) delete mode 100644 src/pages/ocotillo/thing/well-projects/list.tsx create mode 100644 src/test/components/ProjectListPanel.test.tsx create mode 100644 src/test/components/ProjectsTable.test.tsx create mode 100644 src/test/utils/projectBoundary.test.ts create mode 100644 src/utils/projectBoundary.ts diff --git a/cypress/e2e/ocotillo/list-pages.cy.ts b/cypress/e2e/ocotillo/list-pages.cy.ts index 260974b4..7187df21 100644 --- a/cypress/e2e/ocotillo/list-pages.cy.ts +++ b/cypress/e2e/ocotillo/list-pages.cy.ts @@ -43,7 +43,7 @@ describe('Ocotillo List Pages', () => { }) it('renders the project list with project rows and navigation targets', () => { - cy.visit('/ocotillo/well/projects') + cy.visit('/ocotillo/projects') cy.wait('@getProjects') cy.contains('h3', /^Projects$/).should('be.visible') diff --git a/cypress/e2e/ocotillo/show-pages.cy.ts b/cypress/e2e/ocotillo/show-pages.cy.ts index baa20c92..a340ac31 100644 --- a/cypress/e2e/ocotillo/show-pages.cy.ts +++ b/cypress/e2e/ocotillo/show-pages.cy.ts @@ -13,7 +13,7 @@ describe('Ocotillo Show Pages', () => { it('renders the project show page with details, map, and associated wells', () => { interceptProjectShowFixtures() cy.login() - cy.visit('/ocotillo/well/projects/show/10') + cy.visit('/ocotillo/projects/show/10') cy.wait('@getProject') cy.contains('h3', projectAlpha.name).should('be.visible') diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts index afec9e22..0785d5d3 100644 --- a/src/analytics/posthog.ts +++ b/src/analytics/posthog.ts @@ -145,7 +145,7 @@ export const listPageviewProps = ( pathname: string, search: string ): Record | undefined => { - if (pathname === '/ocotillo/well/projects') { + if (pathname === '/ocotillo/projects') { return { page_template: 'projects_list' } } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 0083504d..5adbd0ab 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1088,7 +1088,7 @@ const BREADCRUMB_RESOURCES: Record< sample: { label: 'Samples', listHref: '/ocotillo/sample', resource: 'sample' }, projects: { label: 'Projects', - listHref: '/ocotillo/well/projects', + listHref: '/ocotillo/projects', resource: 'group', }, } diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index 90a8705d..be52d1bf 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -18,7 +18,7 @@ import { MuiEvent, } from '@mui/x-data-grid' import { settings } from '@/settings' -import React, { useMemo, useState } from 'react' +import React from 'react' import { useNavigate } from 'react-router' import { CanAccess, @@ -35,10 +35,12 @@ import { } from '@/components/OcotilloPageHeader' /** - * Standard layout for Ocotillo list pages: title, optional description, search - * bar, record count, and MUI DataGrid with shared toolbar. Wells and Projects - * both use this component; page files supply columns, dataGridProps, and any - * page-specific header buttons or row navigation. + * Standard layout for Ocotillo list pages: title, optional description, record + * count, and MUI DataGrid with shared toolbar. Wells and Projects both use this + * component; page files supply columns, dataGridProps, and any page-specific + * header buttons or row navigation. Lists that opt into searchMode="server" + * also get a search input; everything else filters via the DataGrid's Filters + * toolbar button. */ // Shows a dismissible chip for each active column filter. @@ -238,9 +240,9 @@ export const ListPage: React.FC = ({ } const navigate = useNavigate() - const [localQuickFilter, setLocalQuickFilter] = useState('') - const quickFilter = - searchMode === 'server' ? (searchValue ?? '') : localQuickFilter + // Only server-search lists get a search input; client-side filtering is the + // DataGrid's Filters toolbar button. + const isServerSearch = searchMode === 'server' const { show } = useNavigation() const { resource } = useResourceParams() @@ -275,38 +277,7 @@ export const ListPage: React.FC = ({ const rowCount = dataGridProps.rowCount as number | undefined const { rows: allRows, ...restDataGridProps } = dataGridProps - const getSearchableCellValue = (row: any, col: GridColDef) => { - const raw = row[col.field] - - if (raw == null) return '' - if (Array.isArray(raw)) return raw.map((v) => String(v)).join(', ') - if (typeof raw === 'object') return JSON.stringify(raw) - return String(raw) - } - - const filteredRows = useMemo(() => { - if (searchMode === 'server') { - return allRows ?? [] - } - - if (!quickFilter || !allRows) return allRows ?? [] - - const needle = quickFilter.toLowerCase().trim() - - return allRows.filter((row: any) => - columns.some((col) => - getSearchableCellValue(row, col).toLowerCase().includes(needle) - ) - ) - }, [allRows, quickFilter, columns, searchMode]) - - const handleSearchChange = (value: string) => { - if (searchMode === 'server') { - onSearchChange?.(value) - } else { - setLocalQuickFilter(value) - } - } + const rows = allRows ?? [] const toolbarConfig = { hideExport: restDataGridProps.paginationMode === 'server', @@ -398,52 +369,45 @@ export const ListPage: React.FC = ({ > {children} - {/* Search bar and record count sit outside the DataGrid to preserve input focus */} + {/* Server search bar and record count sit outside the DataGrid to preserve input focus */} - - - handleSearchChange(e.target.value)} - placeholder={ - searchPlaceholder ?? - (searchMode === 'server' - ? 'Search all records...' - : 'Filter this page...') - } - sx={{ fontSize: 14, flex: 1 }} - inputProps={{ - 'aria-label': - searchAriaLabel ?? - (searchMode === 'server' - ? 'Search all records' - : 'Filter rows on this page'), + {isServerSearch && ( + - + > + + onSearchChange?.(e.target.value)} + placeholder={searchPlaceholder ?? 'Search all records...'} + sx={{ fontSize: 14, flex: 1 }} + inputProps={{ + 'aria-label': searchAriaLabel ?? 'Search all records', + }} + /> + + )} {rowCount !== undefined && rowCount > 0 && ( {rowCount.toLocaleString()} total records @@ -454,7 +418,7 @@ export const ListPage: React.FC = ({ {/* Refine sets filterDebounceMs to 0 for server-side grids; restore MUI debounce so toolbar column filters keep input focus while typing. */} void +} + +/** Fields this panel can send to PATCH /group/{id}. */ +type ProjectDraft = { + name: string + description: string + release_status: string + group_type: string + /** WKT, the shape the API stores in group.project_area. */ + project_area: string +} + +const EMPTY_DRAFT: ProjectDraft = { + name: '', + description: '', + release_status: '', + group_type: '', + project_area: '', +} + +const MAX_BOUNDARY_FILE_BYTES = 10 * 1024 * 1024 + +function draftFromProject(project: IGroup | undefined): ProjectDraft { + return { + name: project?.name ?? '', + description: project?.description ?? '', + release_status: project?.release_status ?? '', + group_type: project?.group_type ?? '', + project_area: + typeof project?.project_area === 'string' ? project.project_area : '', + } +} + +function FieldsSkeleton() { + return ( + <> + {[0, 1, 2].map((row) => ( +
+ + +
+ ))} + + ) +} + +export function ProjectEditPanel({ + projectId, + projectName, + onClose, +}: ProjectEditPanelProps) { + const { open: notify } = useNotification() + const { canManageAmp } = useAccessCapabilities() + const { mutateAsync: updateProject, mutation } = useUpdate() + + const isSaving = mutation.isPending + + const [draft, setDraft] = useState(EMPTY_DRAFT) + const [initial, setInitial] = useState(EMPTY_DRAFT) + const [discardDialogOpen, setDiscardDialogOpen] = useState(false) + const [boundaryError, setBoundaryError] = useState(null) + const [boundaryFileName, setBoundaryFileName] = useState(null) + const wasLoadingRef = useRef(true) + const fileInputRef = useRef(null) + + const { query: projectQuery, result: project } = useOne({ + resource: 'group', + dataProviderName: 'ocotillo', + id: projectId, + queryOptions: { enabled: Boolean(projectId) }, + }) + + const { options: releaseStatusOptions, isLoading: isReleaseStatusLoading } = + useLexicon({ category: 'release_status' }) + const { options: groupTypeOptions, isLoading: isGroupTypeLoading } = + useLexicon({ category: 'group_type' }) + + const isLoading = projectQuery.isLoading + + useEffect(() => { + captureEvent('edit_panel_opened', { + resource: 'project', + project_id: projectId, + }) + }, [projectId]) + + useEffect(() => { + wasLoadingRef.current = true + }, [projectId]) + + // Seed the draft once per project, so typing is not clobbered by refetches. + useEffect(() => { + if (isLoading) { + wasLoadingRef.current = true + return + } + + if (!wasLoadingRef.current) { + return + } + + const next = draftFromProject(project) + setDraft(next) + setInitial(next) + wasLoadingRef.current = false + }, [isLoading, project, projectId]) + + const changedFields = useMemo( + () => + (Object.keys(draft) as (keyof ProjectDraft)[]).filter( + (field) => draft[field] !== initial[field] + ), + [draft, initial] + ) + + const isDirty = changedFields.length > 0 + const isNameInvalid = draft.name.trim().length === 0 + + const panelTitle = projectName ? `Edit: ${projectName}` : 'Edit project' + + const setField = (field: keyof ProjectDraft, value: string) => { + setDraft((previous) => ({ ...previous, [field]: value })) + } + + const handleBoundaryFile = async (file: File | undefined) => { + // Reset first so re-picking the same file after an error still registers. + if (fileInputRef.current) fileInputRef.current.value = '' + if (!file) return + + setBoundaryError(null) + setBoundaryFileName(null) + + if (file.size > MAX_BOUNDARY_FILE_BYTES) { + setBoundaryError( + 'File is larger than 10 MB. Simplify it before uploading.' + ) + return + } + + const result = parseProjectBoundaryGeoJson(await file.text()) + + if ('error' in result) { + setBoundaryError(result.error) + return + } + + setField('project_area', result.wkt) + setBoundaryFileName(file.name) + captureEvent('project_boundary_uploaded', { + project_id: projectId, + file_name: file.name, + }) + } + + const handleRemoveBoundary = () => { + setBoundaryError(null) + setBoundaryFileName(null) + setField('project_area', '') + } + + const handleSave = async () => { + if (!isDirty || isSaving || isNameInvalid) { + return + } + + const values: Record = {} + for (const field of changedFields) { + const value = draft[field].trim() + // The API treats description and release_status as nullable; name is not. + values[field] = field === 'name' ? value : value === '' ? null : value + } + + try { + await updateProject({ + resource: 'group', + dataProviderName: 'ocotillo', + id: projectId, + values, + }) + + captureEvent('edit_saved', { + resource: 'project', + project_id: projectId, + fields_changed: changedFields, + }) + onClose() + } catch (error) { + // group has a unique (name, group_type) constraint, so a rename or a type + // change can collide with an existing project. + const status = (error as { statusCode?: number })?.statusCode + notify?.({ + type: 'error', + message: + status === 409 + ? 'Another project already uses this name and type.' + : 'Could not save project changes. Please try again.', + }) + } + } + + const handleRequestClose = () => { + if (isSaving) { + return + } + + if (isDirty) { + setDiscardDialogOpen(true) + return + } + + onClose() + } + + const handleDiscardChanges = () => { + captureEvent('edit_abandoned', { + resource: 'project', + project_id: projectId, + had_changes: isDirty, + }) + onClose() + } + + return ( + <> + + + + + } + > + + {isLoading ? ( + + ) : ( + <> + + setField('name', event.target.value)} + /> + {canManageAmp ? ( + isNameInvalid ? ( +

+ Name cannot be empty. +

+ ) : null + ) : ( +

+ Only administrators can rename a project. +

+ )} +
+ + +