diff --git a/.claude/launch.json b/.claude/launch.json index 8b30f3bf..e8f8d419 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "dev", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], - "port": 5173 + "port": 5173, + "autoPort": true } ] } diff --git a/cypress/e2e/ocotillo/list-pages.cy.ts b/cypress/e2e/ocotillo/list-pages.cy.ts index 260974b4..b4f2919d 100644 --- a/cypress/e2e/ocotillo/list-pages.cy.ts +++ b/cypress/e2e/ocotillo/list-pages.cy.ts @@ -43,23 +43,25 @@ 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') - cy.get('input[aria-label="Filter rows on this page"]').should( + cy.get('input[aria-label="Search projects"]').should( 'have.attr', 'placeholder', - 'Filter this page...' + 'Search projects…' ) - cy.contains('[role="columnheader"]', 'Name').should('be.visible') - cy.contains('[role="columnheader"]', 'Description').should('be.visible') - cy.contains('[role="columnheader"]', 'Release Status').should('be.visible') - cy.contains('[role="columnheader"]', 'Type').should('be.visible') + // Native table semantics: the shadcn table renders th/tr rather than the + // DataGrid's explicit role attributes. + cy.contains('th', 'Name').should('be.visible') + cy.contains('th', 'Description').should('be.visible') + cy.contains('th', 'Release Status').should('be.visible') + cy.contains('th', 'Type').should('be.visible') - cy.contains('[role="row"]', projectAlpha.name).should('be.visible') - cy.contains('[role="row"]', projectBeta.name).should('be.visible') + cy.contains('tr', projectAlpha.name).should('be.visible') + cy.contains('tr', projectBeta.name).should('be.visible') cy.contains(projectAlpha.description).should('be.visible') }) @@ -68,7 +70,6 @@ describe('Ocotillo List Pages', () => { cy.wait('@getContacts') cy.contains('h3', /contacts & owners/i).should('be.visible') - cy.get('input[aria-label="Filter rows on this page"]').should('be.visible') cy.contains('[role="columnheader"]', 'Name').should('be.visible') cy.contains('[role="columnheader"]', 'Organization').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 showDrawControls?: { show: boolean position?: ControlPosition @@ -61,6 +63,7 @@ export const MapComponent = ({ popupContent, setPopupContent, onMouseMoveCallback, + onLoad, setSelectionPolygons, isLoading = false, initialViewState, @@ -279,7 +282,10 @@ export const MapComponent = ({ ref={mapRef} initialViewState={initialViewState} onClick={handleMouseClick} - onLoad={emitBoundsChange} + onLoad={() => { + emitBoundsChange() + onLoad?.() + }} onMove={(evt) => { setViewState(evt.viewState) emitBoundsChange() diff --git a/src/components/ProjectEdit/ProjectEditPanel.tsx b/src/components/ProjectEdit/ProjectEditPanel.tsx new file mode 100644 index 00000000..77b4dcd0 --- /dev/null +++ b/src/components/ProjectEdit/ProjectEditPanel.tsx @@ -0,0 +1,458 @@ +import { useNotification, useOne, useUpdate } from '@refinedev/core' +import { Loader2, MapIcon, UploadIcon } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link as RouterLink } from 'react-router' +import { captureEvent } from '@/analytics/posthog' +import { + EditPanel, + EditPanelField, + EditPanelSection, +} from '@/components/editing' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button, buttonVariants } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { useAccessCapabilities, useLexicon } from '@/hooks' +import type { IGroup } from '@/interfaces/ocotillo/IGroup' +import { parseProjectBoundaryGeoJson } from '@/utils' + +interface ProjectEditPanelProps { + projectId: string | number + projectName?: string | null + onClose: () => 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. +

+ )} +
+ + +