From 8d31d0800698a73179a8253ec522d0302529432d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 13:06:59 -0500 Subject: [PATCH 1/4] feat(asset): add unassociated asset page --- src/components/ProtectedRoute.tsx | 2 +- src/components/WellShow/AssetActions.tsx | 342 +++++++++++++++++ .../WellShow/AssetPreviewWithOverlay.tsx | 349 ++---------------- src/components/WellShow/index.ts | 1 + src/config/navigation.ts | 16 +- src/pages/ocotillo/asset/index.tsx | 1 + src/pages/ocotillo/asset/unassociated.tsx | 233 ++++++++++++ src/resources/ocotillo.tsx | 11 +- src/routes/ocotillo.tsx | 17 +- .../AssetPreviewWithOverlay.test.tsx | 151 ++++++++ src/utils/accessControl.ts | 8 + 11 files changed, 796 insertions(+), 335 deletions(-) create mode 100644 src/components/WellShow/AssetActions.tsx create mode 100644 src/pages/ocotillo/asset/unassociated.tsx create mode 100644 src/test/components/AssetPreviewWithOverlay.test.tsx diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index bcd99713..189e846b 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -9,7 +9,7 @@ export const ProtectedRoute = ({ children, }: { resource: string - action?: 'list' | 'show' | 'create' | 'edit' | 'delete' + action?: 'list' | 'show' | 'create' | 'edit' | 'delete' | 'manage' children: ReactNode }) => { const { data, isLoading } = useCan({ resource, action }) diff --git a/src/components/WellShow/AssetActions.tsx b/src/components/WellShow/AssetActions.tsx new file mode 100644 index 00000000..b9623ed2 --- /dev/null +++ b/src/components/WellShow/AssetActions.tsx @@ -0,0 +1,342 @@ +import { useState } from 'react' +import { Autocomplete, Box, TextField, Typography } from '@mui/material' +import { useCustomMutation, useNotification } from '@refinedev/core' +import { useAutocomplete } from '@refinedev/mui' +import { Link2, MoreVertical, Trash2, Unlink } from 'lucide-react' +import type { IAsset, IWell } from '@/interfaces/ocotillo' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button as UiButton } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +type AssetAction = 'disassociate-asset' | 'delete-asset' + +const getMutationErrorMessage = (error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'data' in error.response + ) { + const responseData = error.response.data as { detail?: string } + + if (typeof responseData.detail === 'string') { + return responseData.detail + } + } + + if (error instanceof Error && error.message) { + return error.message + } + + return 'Request failed. Please try again.' +} + +export const AssetActions = ({ + asset, + refetchAssets, + includeDisassociate = true, + noun = 'attachment', +}: { + asset: IAsset + refetchAssets: () => Promise + includeDisassociate?: boolean + noun?: 'asset' | 'attachment' +}) => { + const { open: notify } = useNotification() + const { mutateAsync: mutateAsset, mutation: assetMutation } = + useCustomMutation() + const [confirmAction, setConfirmAction] = useState(null) + const [isReassociateDialogOpen, setIsReassociateDialogOpen] = useState(false) + const [selectedWell, setSelectedWell] = useState(null) + const capitalizedNoun = noun[0].toUpperCase() + noun.slice(1) + + const { autocompleteProps: wellAutocompleteProps } = useAutocomplete({ + resource: 'thing/water-well', + dataProviderName: 'ocotillo', + queryOptions: { + enabled: isReassociateDialogOpen, + }, + onSearch: (value) => [ + { + field: 'name', + operator: 'contains', + value, + }, + ], + }) + + const handleConfirmAssetAction = async () => { + if (!confirmAction) return + + const isDisassociate = confirmAction === 'disassociate-asset' + + try { + if (isDisassociate) { + await mutateAsset({ + url: `asset/${asset.id}/association`, + method: 'patch', + values: { thing_id: null }, + dataProviderName: 'ocotillo', + }) + } else { + await mutateAsset({ + url: `asset/${asset.id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + } + + await refetchAssets() + + notify?.({ + type: 'success', + message: isDisassociate + ? `${capitalizedNoun} disassociated` + : `${capitalizedNoun} deleted`, + }) + } catch (error) { + console.error(error) + notify?.({ + type: 'error', + message: isDisassociate + ? `Could not disassociate ${noun}` + : `Could not delete ${noun}`, + description: getMutationErrorMessage(error), + }) + } finally { + setConfirmAction(null) + } + } + + const handleReassociateAsset = async () => { + if (!selectedWell) return + + try { + await mutateAsset({ + url: `asset/${asset.id}/association`, + method: 'patch', + values: { thing_id: selectedWell.id }, + dataProviderName: 'ocotillo', + }) + + await refetchAssets() + notify?.({ + type: 'success', + message: `${capitalizedNoun} reassociated`, + description: selectedWell.name + ? `${capitalizedNoun} moved to ${selectedWell.name}.` + : undefined, + }) + setIsReassociateDialogOpen(false) + setSelectedWell(null) + } catch (error) { + console.error(error) + notify?.({ + type: 'error', + message: `Could not reassociate ${noun}`, + description: getMutationErrorMessage(error), + }) + } + } + + const currentThingId = asset.thing_id ?? null + const wellOptions = ((wellAutocompleteProps.options ?? []) as IWell[]).filter( + (well) => well.id !== currentThingId + ) + + return ( + <> + + + + + + + + {includeDisassociate && ( + { + event.stopPropagation() + setConfirmAction('disassociate-asset') + }} + > + + Disassociate {noun} + + )} + { + event.stopPropagation() + setSelectedWell(null) + setIsReassociateDialogOpen(true) + }} + > + + Reassociate {noun} + + + { + event.stopPropagation() + setConfirmAction('delete-asset') + }} + > + + Delete {noun} + + + + + { + if (!open && !assetMutation.isPending) { + setConfirmAction(null) + } + }} + > + + + + {confirmAction === 'disassociate-asset' + ? `Disassociate this ${noun}?` + : `Delete this ${noun}?`} + + + {confirmAction === 'disassociate-asset' + ? 'The asset will remain uploaded, but it will no longer be associated with any well.' + : 'This permanently deletes the uploaded asset record. Use this only when the file should not be kept.'} + + + + + Cancel + + { + event.preventDefault() + void handleConfirmAssetAction() + }} + disabled={assetMutation.isPending} + className={ + confirmAction === 'delete-asset' + ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' + : undefined + } + > + {assetMutation.isPending + ? 'Working...' + : confirmAction === 'disassociate-asset' + ? `Disassociate ${noun}` + : `Delete ${noun}`} + + + + + + { + if (!open && assetMutation.isPending) { + return + } + + setIsReassociateDialogOpen(open) + + if (!open) { + setSelectedWell(null) + } + }} + > + + + Reassociate {noun} + + Move this {noun} to one well. It will be removed from any current + well association. + + + + + options} + getOptionLabel={(well) => well?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => setSelectedWell(value)} + renderOption={({ key, ...props }, well) => ( + + {well.name} + + )} + renderInput={(params) => ( + + )} + /> + + + + setIsReassociateDialogOpen(false)} + disabled={assetMutation.isPending} + > + Cancel + + void handleReassociateAsset()} + disabled={!selectedWell || assetMutation.isPending} + > + {assetMutation.isPending ? 'Working...' : 'Reassociate'} + + + + + + ) +} diff --git a/src/components/WellShow/AssetPreviewWithOverlay.tsx b/src/components/WellShow/AssetPreviewWithOverlay.tsx index 0d283bd0..93be8c0f 100644 --- a/src/components/WellShow/AssetPreviewWithOverlay.tsx +++ b/src/components/WellShow/AssetPreviewWithOverlay.tsx @@ -1,43 +1,10 @@ -import { useState } from 'react' -import { Autocomplete, Box, Typography, Button, TextField } from '@mui/material' -import type { IAsset, IWell } from '@/interfaces/ocotillo' +import { Box, Typography, Button } from '@mui/material' +import type { IAsset } from '@/interfaces/ocotillo' import { QueryObserverResult } from '@tanstack/react-query' -import { - GetListResponse, - HttpError, - useCustomMutation, - useNotification, -} from '@refinedev/core' -import { useAutocomplete } from '@refinedev/mui' +import { GetListResponse, HttpError } from '@refinedev/core' import { HttpStatus } from '@/enums' import { isImage, isPdf, isText } from '@/utils' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Button as UiButton } from '@/components/ui/button' -import { Link2, MoreVertical, Trash2, Unlink } from 'lucide-react' +import { AssetActions } from '@/components/WellShow/AssetActions' const previewStyles = { grid: { @@ -70,6 +37,22 @@ const previewStyles = { pointerEvents: 'auto', }, }, + full: { + image: { + width: '100%', + maxWidth: '100%', + maxHeight: '72vh', + objectFit: 'contain', + display: 'block', + }, + frame: { + width: '100%', + height: '72vh', + border: 0, + bgcolor: 'background.paper', + pointerEvents: 'auto', + }, + }, } as const export const AssetPreview = ({ @@ -77,7 +60,7 @@ export const AssetPreview = ({ variant, }: { asset: IAsset - variant: 'grid' | 'slideshow' + variant: 'grid' | 'slideshow' | 'full' }) => { if (isImage(asset)) { return ( @@ -122,28 +105,6 @@ export const AssetPreviewWithOverlay = ({ canManageAsset?: boolean }) => { const isSlideshow = variant === 'slideshow' - const { open: notify } = useNotification() - const { mutateAsync: mutateAsset, mutation: assetMutation } = - useCustomMutation() - const [confirmAction, setConfirmAction] = useState< - 'disassociate-asset' | 'delete-asset' | null - >(null) - const [isReassociateDialogOpen, setIsReassociateDialogOpen] = useState(false) - const [selectedWell, setSelectedWell] = useState(null) - const { autocompleteProps: wellAutocompleteProps } = useAutocomplete({ - resource: 'thing/water-well', - dataProviderName: 'ocotillo', - queryOptions: { - enabled: canManageAsset && isReassociateDialogOpen, - }, - onSearch: (value) => [ - { - field: 'name', - operator: 'contains', - value, - }, - ], - }) const getRefreshedAsset = async ( assetId: IAsset['id'], @@ -209,109 +170,6 @@ export const AssetPreviewWithOverlay = ({ } } - const getMutationErrorMessage = (error: unknown) => { - if ( - typeof error === 'object' && - error !== null && - 'response' in error && - typeof error.response === 'object' && - error.response !== null && - 'data' in error.response - ) { - const responseData = error.response.data as { detail?: string } - - if (typeof responseData.detail === 'string') { - return responseData.detail - } - } - - if (error instanceof Error && error.message) { - return error.message - } - - return 'Request failed. Please try again.' - } - - const handleConfirmAssetAction = async () => { - if (!confirmAction) return - - const isDisassociate = confirmAction === 'disassociate-asset' - - try { - if (isDisassociate) { - await mutateAsset({ - url: `asset/${asset.id}/association`, - method: 'patch', - values: { thing_id: null }, - dataProviderName: 'ocotillo', - }) - } else { - await mutateAsset({ - url: `asset/${asset.id}`, - method: 'delete', - values: {}, - dataProviderName: 'ocotillo', - }) - } - - await refetchAssets() - - notify?.({ - type: 'success', - message: isDisassociate - ? 'Attachment disassociated' - : 'Attachment deleted', - }) - } catch (error) { - console.error(error) - notify?.({ - type: 'error', - message: isDisassociate - ? 'Could not disassociate attachment' - : 'Could not delete attachment', - description: getMutationErrorMessage(error), - }) - } finally { - setConfirmAction(null) - } - } - - const handleReassociateAsset = async () => { - if (!selectedWell) return - - try { - await mutateAsset({ - url: `asset/${asset.id}/association`, - method: 'patch', - values: { thing_id: selectedWell.id }, - dataProviderName: 'ocotillo', - }) - - await refetchAssets() - notify?.({ - type: 'success', - message: 'Attachment reassociated', - description: selectedWell.name - ? `Attachment moved to ${selectedWell.name}.` - : undefined, - }) - setIsReassociateDialogOpen(false) - setSelectedWell(null) - } catch (error) { - console.error(error) - notify?.({ - type: 'error', - message: 'Could not reassociate attachment', - description: getMutationErrorMessage(error), - }) - } - } - - const currentThingId = asset.thing_id ?? null - const wellOptions = ((wellAutocompleteProps.options ?? []) as IWell[]).filter( - (well) => well.id !== currentThingId - ) - return ( <> - - event.stopPropagation()} - disabled={assetMutation.isPending} - className="bg-background hover:bg-background" - > - - - - - { - event.stopPropagation() - setConfirmAction('disassociate-asset') - }} - > - - Disassociate attachment - - { - event.stopPropagation() - setSelectedWell(null) - setIsReassociateDialogOpen(true) - }} - > - - Reassociate attachment - - - { - event.stopPropagation() - setConfirmAction('delete-asset') - }} - > - - Delete attachment - - - + )} - - { - if (!open && !assetMutation.isPending) { - setConfirmAction(null) - } - }} - > - - - - {confirmAction === 'disassociate-asset' - ? 'Disassociate this attachment?' - : 'Delete this attachment?'} - - - {confirmAction === 'disassociate-asset' - ? 'The asset will remain uploaded, but it will no longer be associated with any well.' - : 'This permanently deletes the uploaded asset record. Use this only when the file should not be kept.'} - - - - - Cancel - - { - event.preventDefault() - void handleConfirmAssetAction() - }} - disabled={assetMutation.isPending} - className={ - confirmAction === 'delete-asset' - ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' - : undefined - } - > - {assetMutation.isPending - ? 'Working...' - : confirmAction === 'disassociate-asset' - ? 'Disassociate attachment' - : 'Delete attachment'} - - - - - - { - if (!open && assetMutation.isPending) { - return - } - - setIsReassociateDialogOpen(open) - - if (!open) { - setSelectedWell(null) - } - }} - > - - - Reassociate attachment - - Move this attachment to one other well. It will be removed from - any current well association. - - - - - options} - getOptionLabel={(well) => well?.name ?? ''} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={(_, value) => setSelectedWell(value)} - renderOption={({ key, ...props }, well) => ( - - {well.name} - - )} - renderInput={(params) => ( - - )} - /> - - - - setIsReassociateDialogOpen(false)} - disabled={assetMutation.isPending} - > - Cancel - - void handleReassociateAsset()} - disabled={!selectedWell || assetMutation.isPending} - > - {assetMutation.isPending ? 'Working...' : 'Reassociate'} - - - - ) } diff --git a/src/components/WellShow/index.ts b/src/components/WellShow/index.ts index 889a3e04..56f55b53 100644 --- a/src/components/WellShow/index.ts +++ b/src/components/WellShow/index.ts @@ -4,6 +4,7 @@ export * from './GeologyInformation' export * from './OwnerPermissions' export * from './Attachments' export * from './AttachmentsUploadDialog' +export * from './AssetActions' export * from './AssetPreviewWithOverlay' export * from './AlternateIds' export * from './Contacts' diff --git a/src/config/navigation.ts b/src/config/navigation.ts index c4283622..3aa6c797 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -5,6 +5,7 @@ import { FileText, FolderKanban, Home, + Image, LineChart, Map as MapIcon, MapPin, @@ -22,7 +23,7 @@ import type { PortalRole } from '@/utils/accessControl' export const AmpRole = { Viewer: 'AMP.Viewer', Editor: 'AMP.Editor', - Admin: 'AMP.Admin', + Admin: 'AMP.Admin', } as const satisfies Record export type NavItem = { @@ -81,7 +82,11 @@ export const PRIMARY_NAV: NavItem[] = [ }, ] -const viewerAndAbove: PortalRole[] = [AmpRole.Viewer, AmpRole.Editor, AmpRole.Admin] +const viewerAndAbove: PortalRole[] = [ + AmpRole.Viewer, + AmpRole.Editor, + AmpRole.Admin, +] const adminOnly: PortalRole[] = [AmpRole.Admin] /** @@ -125,6 +130,13 @@ export const RESOURCE_NAV: NavItem[] = [ resource: 'ocotillo.collections', roles: viewerAndAbove, }, + { + label: 'Unassociated Assets', + href: '/ocotillo/asset/unassociated', + icon: Image, + resource: 'ocotillo.asset-unassociated', + roles: adminOnly, + }, { label: 'Locations', href: '/ocotillo/location', diff --git a/src/pages/ocotillo/asset/index.tsx b/src/pages/ocotillo/asset/index.tsx index de83fe16..39a64a51 100644 --- a/src/pages/ocotillo/asset/index.tsx +++ b/src/pages/ocotillo/asset/index.tsx @@ -2,3 +2,4 @@ export * from './list' export * from './create' export * from './edit' export * from './show' +export * from './unassociated' diff --git a/src/pages/ocotillo/asset/unassociated.tsx b/src/pages/ocotillo/asset/unassociated.tsx new file mode 100644 index 00000000..9f52588c --- /dev/null +++ b/src/pages/ocotillo/asset/unassociated.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState } from 'react' +import { Box, Chip, Typography } from '@mui/material' +import type { GridColDef, GridRowParams } from '@mui/x-data-grid' +import { useList } from '@refinedev/core' +import type { IAsset } from '@/interfaces/ocotillo' +import { ListPage } from '@/components' +import { AssetActions, AssetPreview } from '@/components/WellShow' +import { formatAppDateTime, formatFileSize, isImage, isPdf } from '@/utils' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button as UiButton } from '@/components/ui/button' + +const AssetThumbnail = ({ asset }: { asset: IAsset }) => { + if (asset.signed_url && isImage(asset)) { + return ( + + ) + } + + return ( + + + {isPdf(asset) ? 'PDF' : asset.mime_type?.split('/')[1] || 'File'} + + + ) +} + +export const UnassociatedAssetList: React.FC = () => { + const [previewAsset, setPreviewAsset] = useState(null) + const { result, query } = useList({ + resource: 'asset/unassociated', + dataProviderName: 'ocotillo', + queryOptions: { + // Signed URLs expire after 15 minutes. Refresh periodically for preview + // and download flows that may stay open during cleanup work. + refetchInterval: 10 * 60 * 1000, + refetchIntervalInBackground: false, + staleTime: 9 * 60 * 1000, + }, + }) + + const unassociatedAssets = result?.data ?? [] + + const columns = useMemo[]>( + () => [ + { + field: 'preview', + headerName: 'Preview', + width: 110, + sortable: false, + filterable: false, + renderCell: ({ row }: { row: IAsset }) => ( + + + + ), + }, + { + field: 'name', + headerName: 'Name', + type: 'string', + minWidth: 220, + flex: 1, + }, + { + field: 'label', + headerName: 'Label', + type: 'string', + minWidth: 220, + flex: 2, + }, + { + field: 'mime_type', + headerName: 'Type', + type: 'string', + minWidth: 150, + }, + { + field: 'size', + headerName: 'Size', + width: 110, + valueGetter: (size: number) => + typeof size === 'number' ? formatFileSize(size) : '', + }, + { + field: 'release_status', + headerName: 'Status', + width: 130, + renderCell: ({ value }) => + value ? ( + + + + ) : null, + }, + { + field: 'created_at', + headerName: 'Created At', + minWidth: 180, + valueGetter: (isoDate: string) => formatAppDateTime(isoDate), + }, + { + field: 'actions', + headerName: 'Actions', + width: 110, + sortable: false, + filterable: false, + align: 'center', + headerAlign: 'center', + renderCell: ({ row }) => ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }} + > + + + ), + }, + ], + [query.refetch] + ) + + const dataGridProps = { + rows: unassociatedAssets, + rowCount: result?.total ?? unassociatedAssets.length, + loading: query.isLoading || query.isFetching, + paginationMode: 'client', + } + + const handleRowClick = (params: GridRowParams) => { + setPreviewAsset(params.row) + } + + return ( + <> + row.id} + disableRowClick + onRowClick={handleRowClick} + hideHeaderButtons + /> + + { + if (!open) { + setPreviewAsset(null) + } + }} + > + + + {previewAsset?.name ?? 'Asset preview'} + + + {previewAsset && ( + + + + )} + + + setPreviewAsset(null)} + > + Cancel + + + + + + ) +} diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 3ec1f361..34cc9580 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -4,7 +4,7 @@ import { DatasetLinked, Contacts, // DynamicFormOutlined, - // Image, + Image, LibraryBooksOutlined, // Link, Map, @@ -223,6 +223,15 @@ tables.push({ }, }) +tables.push({ + name: 'asset-unassociated', + list: '/ocotillo/asset/unassociated', + meta: { + label: 'Unassociated Assets', + icon: , + }, +}) + let forms: { name: string edit?: string diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 768c0dbf..3031e471 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -1,9 +1,6 @@ import { Route, Routes } from 'react-router' import { ErrorComponent } from '@refinedev/mui' -import { - ContactList, - ContactShow, -} from '@/pages/ocotillo/contact' +import { ContactList, ContactShow } from '@/pages/ocotillo/contact' import { SpringList, SpringCreate, @@ -51,6 +48,7 @@ import { AssetCreate, AssetEdit, AssetShow, + UnassociatedAssetList, } from '@/pages/ocotillo/asset' import { ThingIdLinkList, @@ -180,6 +178,17 @@ export const OcotilloRoutes = () => { } /> + + + + } + /> } /> } /> } /> diff --git a/src/test/components/AssetPreviewWithOverlay.test.tsx b/src/test/components/AssetPreviewWithOverlay.test.tsx new file mode 100644 index 00000000..9d6f7276 --- /dev/null +++ b/src/test/components/AssetPreviewWithOverlay.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +import { screen, waitFor, within } from '@testing-library/react' +import { render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { IAsset, IWell } from '@/interfaces/ocotillo' + +const mockedMutateAsset = vi.fn() +const mockedNotify = vi.fn() + +const wells: IWell[] = [ + { + id: 1, + name: 'Current Well', + created_at: '2025-01-01', + release_status: 'public', + thing_type: 'water-well', + location_id: 1, + } as IWell, + { + id: 2, + name: 'Replacement Well', + created_at: '2025-01-01', + release_status: 'public', + thing_type: 'water-well', + location_id: 2, + } as IWell, +] + +vi.mock('@refinedev/core', async () => { + const actual = + await vi.importActual('@refinedev/core') + + return { + ...actual, + useCustomMutation: () => ({ + mutateAsync: mockedMutateAsset, + mutation: { isPending: false }, + }), + useNotification: () => ({ open: mockedNotify }), + } +}) + +vi.mock('@refinedev/mui', () => ({ + useAutocomplete: () => ({ + autocompleteProps: { + options: wells, + loading: false, + }, + }), +})) + +import { AssetPreviewWithOverlay } from '@/components/WellShow/AssetPreviewWithOverlay' + +const asset: IAsset = { + id: 10, + label: 'asset', + name: 'field-photo.jpg', + storage_path: 'field-photo.jpg', + storage_service: 'test', + created_at: new Date('2025-01-01'), + release_status: 'public', + mime_type: 'image/jpeg', + size: 100, + file: null, + thing_id: 1, + uri: 'test://field-photo.jpg', + signed_url: 'https://example.com/field-photo.jpg', +} + +describe('AssetPreviewWithOverlay reassociation dialog', () => { + beforeEach(() => { + mockedMutateAsset.mockReset() + mockedNotify.mockReset() + mockedMutateAsset.mockResolvedValue({}) + }) + + it('selects a well from autocomplete without reassociating until the button is clicked', async () => { + const user = userEvent.setup() + const refetchAssets = vi.fn().mockResolvedValue({ data: { data: [asset] } }) + + render( + + ) + + await user.click(screen.getByLabelText('Attachment actions for field-photo.jpg')) + await user.click(screen.getByText('Reassociate attachment')) + + const dialog = screen.getByRole('dialog', { + name: 'Reassociate attachment', + }) + await user.click(within(dialog).getByLabelText('Well')) + await user.click(screen.getByText('Replacement Well')) + + expect( + screen.getByRole('dialog', { name: 'Reassociate attachment' }) + ).toBeTruthy() + expect(mockedMutateAsset).not.toHaveBeenCalled() + + await user.click(within(dialog).getByRole('button', { name: 'Reassociate' })) + + await waitFor(() => { + expect(mockedMutateAsset).toHaveBeenCalledWith({ + url: 'asset/10/association', + method: 'patch', + values: { thing_id: 2 }, + dataProviderName: 'ocotillo', + }) + }) + }) + + it('selects a highlighted well with Enter without closing the dialog', async () => { + const user = userEvent.setup() + const refetchAssets = vi.fn().mockResolvedValue({ data: { data: [asset] } }) + + render( + + ) + + await user.click(screen.getByLabelText('Attachment actions for field-photo.jpg')) + await user.click(screen.getByText('Reassociate attachment')) + + const dialog = screen.getByRole('dialog', { + name: 'Reassociate attachment', + }) + const wellInput = within(dialog).getByLabelText('Well') + + await user.click(wellInput) + await user.keyboard('{ArrowDown}{Enter}') + + expect( + screen.getByRole('dialog', { name: 'Reassociate attachment' }) + ).toBeTruthy() + expect( + within(dialog).getByRole('button', { + name: 'Reassociate', + }).disabled + ).toBe(false) + expect(mockedMutateAsset).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index 376a6ff5..fecc915e 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -93,6 +93,14 @@ const resourcePolicies: Record = { 'ocotillo.thing-well-pdf-preview': { list: adminRoles, show: adminRoles }, 'ocotillo.thing-well-batch-export': { list: viewerRoles, show: viewerRoles }, 'ocotillo.thing-well-projects': { list: viewerRoles, show: viewerRoles }, + 'ocotillo.asset-unassociated': { + list: adminRoles, + show: adminRoles, + edit: adminRoles, + create: adminRoles, + delete: adminRoles, + manage: adminRoles, + }, 'ocotillo.groundwater-level-observation': { list: viewerRoles, show: viewerRoles, From 559fda0415a4d92c77faa7f92e01e7d49d6c3f63 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 13:19:36 -0500 Subject: [PATCH 2/4] fix(pages/unassociated): swap from client side to server side pagination --- src/pages/ocotillo/asset/unassociated.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/pages/ocotillo/asset/unassociated.tsx b/src/pages/ocotillo/asset/unassociated.tsx index 9f52588c..46ab174d 100644 --- a/src/pages/ocotillo/asset/unassociated.tsx +++ b/src/pages/ocotillo/asset/unassociated.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import { Box, Chip, Typography } from '@mui/material' import type { GridColDef, GridRowParams } from '@mui/x-data-grid' -import { useList } from '@refinedev/core' +import { useDataGrid } from '@refinedev/mui' import type { IAsset } from '@/interfaces/ocotillo' import { ListPage } from '@/components' import { AssetActions, AssetPreview } from '@/components/WellShow' @@ -60,9 +60,10 @@ const AssetThumbnail = ({ asset }: { asset: IAsset }) => { export const UnassociatedAssetList: React.FC = () => { const [previewAsset, setPreviewAsset] = useState(null) - const { result, query } = useList({ + const { dataGridProps, tableQuery } = useDataGrid({ resource: 'asset/unassociated', dataProviderName: 'ocotillo', + pagination: { pageSize: 10, mode: 'server' }, queryOptions: { // Signed URLs expire after 15 minutes. Refresh periodically for preview // and download flows that may stay open during cleanup work. @@ -72,8 +73,6 @@ export const UnassociatedAssetList: React.FC = () => { }, }) - const unassociatedAssets = result?.data ?? [] - const columns = useMemo[]>( () => [ { @@ -154,7 +153,7 @@ export const UnassociatedAssetList: React.FC = () => { > @@ -162,16 +161,9 @@ export const UnassociatedAssetList: React.FC = () => { ), }, ], - [query.refetch] + [tableQuery.refetch] ) - const dataGridProps = { - rows: unassociatedAssets, - rowCount: result?.total ?? unassociatedAssets.length, - loading: query.isLoading || query.isFetching, - paginationMode: 'client', - } - const handleRowClick = (params: GridRowParams) => { setPreviewAsset(params.row) } From 903361b1baaccd0421b2cc923dc417d4210b14f2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 13:30:19 -0500 Subject: [PATCH 3/4] fix(asset): update refresh configs to always refresh on component mount --- src/pages/ocotillo/asset/unassociated.tsx | 1 + src/pages/ocotillo/thing/well-show.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/pages/ocotillo/asset/unassociated.tsx b/src/pages/ocotillo/asset/unassociated.tsx index 46ab174d..345fb352 100644 --- a/src/pages/ocotillo/asset/unassociated.tsx +++ b/src/pages/ocotillo/asset/unassociated.tsx @@ -70,6 +70,7 @@ export const UnassociatedAssetList: React.FC = () => { refetchInterval: 10 * 60 * 1000, refetchIntervalInBackground: false, staleTime: 9 * 60 * 1000, + refetchOnMount: 'always', }, }) diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 080a9212..56f9eb66 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -118,6 +118,10 @@ export const WellShow = () => { // Treat asset data as fresh for 9 minutes. The periodic refetch updates // the signed URLs before they expire. staleTime: 9 * 60 * 1000, + + // Refresh when navigating back to this page so asset associations and + // signed URLs reflect changes made elsewhere. + refetchOnMount: 'always', }, }) useEffect(() => { From 7cfb9d0852ae604296c36de6abd409ec6b312ae5 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 14:53:36 -0500 Subject: [PATCH 4/4] test(accessControl): update test with new route --- src/test/utils/accessControl.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/utils/accessControl.test.ts b/src/test/utils/accessControl.test.ts index 8d60d0bb..db9a04af 100644 --- a/src/test/utils/accessControl.test.ts +++ b/src/test/utils/accessControl.test.ts @@ -35,6 +35,7 @@ const routableResourceNames = routableResources .map((resource) => resource.name) .sort() const expectedRegisteredRoutableResources = [ + 'ocotillo.asset-unassociated', 'ocotillo.collections', 'ocotillo.contact', 'ocotillo.hydrograph-correction',