From e5d713a84f46a8fdaa2bb256123f365677c128e7 Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Tue, 28 Jan 2025 16:52:16 +1100 Subject: [PATCH 1/6] Photo field redesign - visual and structure Signed-off-by: Peter Baker --- app/src/gui/fields/TakePhoto.tsx | 464 ++++++++++++++++++------------- 1 file changed, 276 insertions(+), 188 deletions(-) diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index b08259f14..b92bf2019 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -15,207 +15,282 @@ * * Filename: TakePhoto.tsx * Description: - * TODO : to add function check if photo be downloaded */ import {Camera, CameraResultType, Photo} from '@capacitor/camera'; +import {Capacitor} from '@capacitor/core'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; import CameraAltIcon from '@mui/icons-material/CameraAlt'; -import Button, {ButtonProps} from '@mui/material/Button'; -import {FieldProps} from 'formik'; -import React from 'react'; - -// import ImageList from '@mui/material/ImageList'; import DeleteIcon from '@mui/icons-material/Delete'; import ImageIcon from '@mui/icons-material/Image'; -import {Alert, List, ListItem, Typography, useMediaQuery} from '@mui/material'; +import {Alert, Box, Paper, Typography, useTheme} from '@mui/material'; +import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import ImageListItem from '@mui/material/ImageListItem'; import ImageListItemBar from '@mui/material/ImageListItemBar'; -import {createTheme, styled} from '@mui/material/styles'; +import {FieldProps} from 'formik'; +import React from 'react'; +import {APP_NAME} from '../../buildconfig'; import {logError} from '../../logging'; import FaimsAttachmentManagerDialog from '../components/ui/Faims_Attachment_Manager_Dialog'; -import {Capacitor} from '@capacitor/core'; -import {APP_NAME} from '../../buildconfig'; -function base64image_to_blob(image: Photo): Blob { +/** + * Converts a base64 encoded image to a Blob object using fetch API instead of + * deprecated atob + * @param image - Photo object containing base64 string and format information + * @returns Promise resolving to a Blob object representing the image + * @throws Error if base64String is undefined + */ +async function base64ImageToBlob(image: Photo): Promise { if (image.base64String === undefined) { throw Error('No photo data found'); } - // from https://stackoverflow.com/a/62144916/1306020 - const rawData = atob(image.base64String); - const bytes = new Array(rawData.length); - for (let x = 0; x < rawData.length; x++) { - bytes[x] = rawData.charCodeAt(x); - } - const arr = new Uint8Array(bytes); - const blob = new Blob([arr], {type: 'image/' + image.format}); - return blob; + + // Convert base64 to binary using fetch API + const response = await fetch( + `data:image/${image.format};base64,${image.base64String}` + ); + return await response.blob(); } interface Props { - helpertext?: string; // this should be removed but will appear in older notebooks helperText?: string; label?: string; issyncing?: string; isconflict?: boolean; } -type ImageListProps = { +interface ImageListProps { images: Array; - setopen: any; - setimage: any; + // if null, this indicates the image is not downloaded + setOpen: (path: string | null) => void; + setImages: (newFiles: Array) => void; disabled: boolean; fieldName: string; -}; -const theme = createTheme(); -/******** create own Image List for dynamic loading images TODO: need to test if it's working on browsers and phone *** Kate */ -const ImageGalleryList = styled('ul')(() => ({ - display: 'grid', - padding: 0, - margin: theme.spacing(0, 4), - gap: 8, - [theme.breakpoints.up('sm')]: { - gridTemplateColumns: 'repeat(2, 1fr)', - }, - [theme.breakpoints.up('md')]: { - gridTemplateColumns: 'repeat(4, 1fr)', - }, - [theme.breakpoints.up('lg')]: { - gridTemplateColumns: 'repeat(5, 1fr)', - }, -})); + onAddPhoto: () => void; +} -const FAIMSViewImageList = (props: { - images: Array; - fieldName: string; - setopen: Function; -}) => { +/** + * Displays a placeholder component when no images are present + */ +const EmptyState = ({onAddPhoto}: {onAddPhoto: () => void}) => { + const theme = useTheme(); return ( - - {props.images.map((image, index) => - image['attachment_id'] === undefined ? ( - - - - ) : ( - // ?? not allow user to delete image if the image is not download yet - - ) - )} - + + + + No Photos Yet + + + ); }; -const FAIMSImageIconList = (props: { - index: number; - setopen: Function; - fieldName: string; -}) => { - const {index, setopen} = props; +/** + * Displays a placeholder when an image is unavailable or cannot be loaded + */ +const UnavailableImage = () => { + const theme = useTheme(); return ( - - setopen(null)}> - - - + + + ); }; -const FAIMSImageList = (props: ImageListProps) => { - const {images, setopen, setimage, fieldName} = props; - const disabled = props.disabled ?? false; - const handelonClick = (index: number) => { +/** + * Displays a grid of images with add and delete functionality + */ +const ImageGallery = ({ + images, + setOpen: setopen, + setImages, + disabled, + fieldName, + onAddPhoto, +}: ImageListProps) => { + const theme = useTheme(); + // Handler for deleting images from the gallery + const handleDelete = (index: number) => { if (images.length > index) { - const newimages = images.filter((image: any, i: number) => i !== index); - setimage(newimages); + // need to reverse the index here to account for reverse display + const originalIndex = images.length - 1 - index; + const newImages = images.filter( + (_: any, i: number) => i !== originalIndex + ); + setImages(newImages); } }; - if (images === null || images === undefined) - return No photo taken.; - if (disabled === true) - return ( - - ); return ( - - {props.images.map((image: any, index: number) => - image['attachment_id'] === undefined ? ( - - + + {/* Add Photo Button */} + {!disabled && ( + + setopen(URL.createObjectURL(image))} - /> + onClick={onAddPhoto} + > + + + + )} - + image['attachment_id'] === undefined ? ( + handelonClick(index)} - > - - - } - actionPosition="left" - /> - - ) : ( - // ?? not allow user to delete image if the image is not download yet - - ) - )}{' '} - + > + + setopen(URL.createObjectURL(image))} + alt={`Photo ${index + 1}`} + sx={{ + width: '100%', + height: '100%', + objectFit: 'contain', + bgcolor: theme.palette.background.lightBackground, + }} + /> + {!disabled && ( + handleDelete(index)} + size="large" + > + + + } + actionPosition="right" + /> + )} + + + ) : ( + setopen(null)} + sx={{ + cursor: 'pointer', + borderRadius: theme.spacing(1), + overflow: 'hidden', + boxShadow: theme.shadows[2], + aspectRatio: '4/3', + '&:hover': { + boxShadow: theme.shadows[4], + }, + }} + > + + + ) + )} + + ); }; +/** + * A cphoto capture and management component. Supports taking photos via device + * camera, displaying them in a grid, and instructing re: permissions across + * different platforms + */ export const TakePhoto: React.FC< FieldProps & Props & - ButtonProps & { - ValueTextProps: React.HTMLAttributes; - ErrorTextProps: React.HTMLAttributes; - NoErrorTextProps: React.HTMLAttributes; + React.ButtonHTMLAttributes & { + ValueTextProps?: React.HTMLAttributes; + ErrorTextProps?: React.HTMLAttributes; + NoErrorTextProps?: React.HTMLAttributes; } > = props => { const [open, setOpen] = React.useState(false); const [photoPath, setPhotoPath] = React.useState(null); const [noPermission, setNoPermission] = React.useState(false); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - + // Handles photo capture with permission checks const takePhoto = async () => { if (Capacitor.getPlatform() === 'web') { const permission = await navigator.permissions.query({ @@ -237,15 +312,15 @@ export const TakePhoto: React.FC< } try { - const image = base64image_to_blob( - await Camera.getPhoto({ - quality: 90, - allowEditing: false, - resultType: CameraResultType.Base64, - correctOrientation: true, - promptLabelHeader: 'Take/Select a photo (drag to view more)', - }) - ); + const photoResult = await Camera.getPhoto({ + quality: 90, + allowEditing: false, + resultType: CameraResultType.Base64, + correctOrientation: true, + promptLabelHeader: 'Take or select a photo', + }); + + const image = await base64ImageToBlob(photoResult); const newImages = props.field.value !== null ? props.field.value.concat(image) : [image]; props.form.setFieldValue(props.field.name, newImages, true); @@ -262,30 +337,49 @@ export const TakePhoto: React.FC< ); - const title = props.label; - const helperText = props.helpertext ?? props.helperText ?? undefined; + const images = props.form.values[props.field.name] ?? []; + const disabled = props.disabled ?? false; return ( -
- {title &&

{title}

} - {helperText &&

{helperText}

} - + + {/* Title and helper text section */} + + {props.label && ( + + {props.label} + + )} + {props.helperText && ( + + {props.helperText} + + )} + + + {images.length === 0 ? ( + + ) : ( + { + setOpen(true); + setPhotoPath(path); + }} + setImages={(newfiles: Array) => { + props.form.setFieldValue(props.field.name, newfiles, true); + }} + disabled={disabled} + fieldName={props.field.name} + onAddPhoto={takePhoto} + /> + )} + {noPermission && ( - + {Capacitor.getPlatform() === 'web' && ( <> - Please enable camera permissions this page. In your browser, look - to the left of the web address bar for a button that gives access - to browser settings for this page. + Please enable camera permissions for this page. Look for the + camera permissions button in your browser's address bar. )} {Capacitor.getPlatform() === 'android' && ( @@ -304,21 +398,13 @@ export const TakePhoto: React.FC< )} )} - { - setOpen(true); - setPhotoPath(path); - }} - setimage={(newfiles: Array) => { - props.form.setFieldValue(props.field.name, newfiles, true); - }} - disabled={props.disabled ?? false} - fieldName={props.field.name} - /> - - {errorText} - + + {error && ( + + {errorText} + + )} + -
+ ); }; +export default TakePhoto; + // const uiSpec = { // 'component-namespace': 'faims-custom', // this says what web component to use to render/acquire value from // 'component-name': 'TakePhoto', From 379d1cce245bb63353aa1708583ef4b5b650753c Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Tue, 28 Jan 2025 17:02:11 +1100 Subject: [PATCH 2/6] Reverting change which removed the helpertext legacy Signed-off-by: Peter Baker --- app/src/gui/fields/TakePhoto.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index b92bf2019..ec31ff57a 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -54,6 +54,8 @@ async function base64ImageToBlob(image: Photo): Promise { } interface Props { + // this should be removed but will appear in older notebooks + helpertext?: string; helperText?: string; label?: string; issyncing?: string; @@ -349,9 +351,9 @@ export const TakePhoto: React.FC< {props.label} )} - {props.helperText && ( + {(props.helperText || props.helpertext) && ( - {props.helperText} + {props.helperText || props.helpertext} )} From f0d08e4e97f108248d4af8e982afe59c106507b5 Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Tue, 28 Jan 2025 17:11:30 +1100 Subject: [PATCH 3/6] Making close button more prominent Signed-off-by: Peter Baker --- .../ui/Faims_Attachment_Manager_Dialog.tsx | 24 ++++++++++++++++--- app/src/gui/fields/TakePhoto.tsx | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx b/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx index 8b6cbbde6..fb05289cb 100644 --- a/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx +++ b/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx @@ -18,7 +18,6 @@ * TODO: download single file */ -import React from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; @@ -59,16 +58,35 @@ export default function FaimsAttachmentManagerDialog(props: DiagProps) { position: 'absolute', right: 8, top: 8, + bgcolor: 'rgba(0, 0, 0, 0.5)', + '&:hover': { + bgcolor: 'rgba(0, 0, 0, 0.7)', + transform: 'scale(1.1)', + }, + transition: 'all 0.2s ease-in-out', }} > - + {path !== null ? ( ) : isSyncing === 'true' ? ( diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index ec31ff57a..b94ec7546 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -275,7 +275,7 @@ const ImageGallery = ({ }; /** - * A cphoto capture and management component. Supports taking photos via device + * A photo capture and management component. Supports taking photos via device * camera, displaying them in a grid, and instructing re: permissions across * different platforms */ From e741ce7fc07a9963ce6bebfb411b2400c3149f5a Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Tue, 28 Jan 2025 17:20:53 +1100 Subject: [PATCH 4/6] Adds an undownloaded image flag Signed-off-by: Peter Baker --- app/src/gui/fields/TakePhoto.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index b94ec7546..33388b7cf 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -30,7 +30,7 @@ import ImageListItem from '@mui/material/ImageListItem'; import ImageListItemBar from '@mui/material/ImageListItemBar'; import {FieldProps} from 'formik'; import React from 'react'; -import {APP_NAME} from '../../buildconfig'; +import {APP_NAME, NOTEBOOK_NAME} from '../../buildconfig'; import {logError} from '../../logging'; import FaimsAttachmentManagerDialog from '../components/ui/Faims_Attachment_Manager_Dialog'; @@ -53,6 +53,11 @@ async function base64ImageToBlob(image: Photo): Promise { return await response.blob(); } +// Helper function to check if any images are undownloaded +const hasUndownloadedImages = (images: Array): boolean => { + return images.some(image => image['attachment_id'] !== undefined); +}; + interface Props { // this should be removed but will appear in older notebooks helpertext?: string; @@ -341,6 +346,7 @@ export const TakePhoto: React.FC< const images = props.form.values[props.field.name] ?? []; const disabled = props.disabled ?? false; + const hasUndownloaded = hasUndownloadedImages(images); return ( @@ -358,6 +364,14 @@ export const TakePhoto: React.FC< )} + {/* Download Banner */} + {hasUndownloaded && ( + + To download attachments and photos, please go to the {NOTEBOOK_NAME}{' '} + Settings Tab and enable it. + + )} + {images.length === 0 ? ( ) : ( From 2f8642779be7892d109e0d7c7fe38b8470fadc0a Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Wed, 29 Jan 2025 12:55:31 +1100 Subject: [PATCH 5/6] Improving wording, adding query string param manager custom hook with typing, using for tab index on notebook page, deeplinking Signed-off-by: Peter Baker --- app/src/constants/routes.tsx | 1 + .../components/notebook/datagrid_toolbar.tsx | 2 +- app/src/gui/components/notebook/index.tsx | 73 ++++--- .../ui/Faims_Attachment_Manager_Dialog.tsx | 13 +- .../components/workspace/CreateNewSurvey.tsx | 2 +- app/src/gui/fields/TakePhoto.tsx | 28 ++- app/src/gui/pages/notebook.tsx | 34 +-- app/src/utils/customHooks.tsx | 205 ++++++++++++++++++ app/src/utils/custom_hooks.tsx | 40 ---- 9 files changed, 306 insertions(+), 92 deletions(-) delete mode 100644 app/src/utils/custom_hooks.tsx diff --git a/app/src/constants/routes.tsx b/app/src/constants/routes.tsx index 1254226fa..df75245af 100644 --- a/app/src/constants/routes.tsx +++ b/app/src/constants/routes.tsx @@ -28,6 +28,7 @@ export const AUTH_RETURN = '/auth-return/'; export const NOT_FOUND = '/not-found'; export const INDIVIDUAL_NOTEBOOK_ROUTE = `/${NOTEBOOK_NAME}s/`; +export const INDIVIDUAL_NOTEBOOK_ROUTE_TAB_Q = 'tab'; export const NOTEBOOK_LIST_ROUTE = '/'; export const RECORD_LIST = '/records'; diff --git a/app/src/gui/components/notebook/datagrid_toolbar.tsx b/app/src/gui/components/notebook/datagrid_toolbar.tsx index 7fae0a097..c6d089f40 100644 --- a/app/src/gui/components/notebook/datagrid_toolbar.tsx +++ b/app/src/gui/components/notebook/datagrid_toolbar.tsx @@ -31,7 +31,7 @@ import {GridToolbarContainer, GridToolbarFilterButton} from '@mui/x-data-grid'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import TuneIcon from '@mui/icons-material/Tune'; -import {usePrevious} from '../../../utils/custom_hooks'; +import {usePrevious} from '../../../utils/customHooks'; import {theme} from '../../themes'; interface ToolbarProps { diff --git a/app/src/gui/components/notebook/index.tsx b/app/src/gui/components/notebook/index.tsx index 1ba4e7e86..025a39976 100644 --- a/app/src/gui/components/notebook/index.tsx +++ b/app/src/gui/components/notebook/index.tsx @@ -1,46 +1,58 @@ -import React, {useEffect, useState} from 'react'; +import {ProjectUIModel, ProjectUIViewsets} from '@faims3/data-model'; +import DashboardIcon from '@mui/icons-material/Dashboard'; import { - Tabs, - Tab, - Typography, - Box, - Paper, - AppBar, Alert, AlertTitle, + AppBar, + Box, Button, Grid, - TableContainer, + Paper, + Tab, Table, TableBody, - TableRow, TableCell, + TableContainer, + TableRow, + Tabs, + Typography, } from '@mui/material'; -import {useNavigate} from 'react-router-dom'; -import {ProjectUIViewsets} from '@faims3/data-model'; -import {getUiSpecForProject} from '../../../uiSpecification'; -import {ProjectUIModel} from '@faims3/data-model'; -import DraftsTable from './draft_table'; -import {RecordsBrowseTable} from './record_table'; -import MetadataRenderer from '../metadataRenderer'; -import AddRecordButtons from './add_record_by_type'; -import NotebookSettings from './settings'; import {useTheme} from '@mui/material/styles'; -import DraftTabBadge from './draft_tab_badge'; import useMediaQuery from '@mui/material/useMediaQuery'; -import CircularLoading from '../ui/circular_loading'; -import * as ROUTES from '../../../constants/routes'; -import DashboardIcon from '@mui/icons-material/Dashboard'; +import {useQuery} from '@tanstack/react-query'; +import React, {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; import { NOTEBOOK_NAME, NOTEBOOK_NAME_CAPITALIZED, SHOW_RECORD_SUMMARY_COUNTS, } from '../../../buildconfig'; -import {useQuery} from '@tanstack/react-query'; +import * as ROUTES from '../../../constants/routes'; import {getMetadataValue} from '../../../sync/metadata'; import {ProjectExtended} from '../../../types/project'; -import RangeHeader from './range_header'; +import {getUiSpecForProject} from '../../../uiSpecification'; +import {useQueryParams} from '../../../utils/customHooks'; +import MetadataRenderer from '../metadataRenderer'; +import CircularLoading from '../ui/circular_loading'; +import AddRecordButtons from './add_record_by_type'; +import DraftTabBadge from './draft_tab_badge'; +import DraftsTable from './draft_table'; import {OverviewMap} from './overview_map'; +import RangeHeader from './range_header'; +import {RecordsBrowseTable} from './record_table'; +import NotebookSettings from './settings'; + +// Define how tabs appear in the query string arguments, providing a two way map +type TabIndex = 'records' | 'details' | 'settings' | 'map'; +const TAB_TO_INDEX = new Map([ + ['records', 0], + ['details', 1], + ['settings', 2], + ['map', 3], +]); +const INDEX_TO_TAB = new Map( + Array.from(TAB_TO_INDEX.entries()).map(([k, v]) => [v, k]) +); /** * TabPanelProps defines the properties for the TabPanel component. @@ -107,7 +119,18 @@ type NotebookComponentProps = { * @returns {JSX.Element} - The JSX element for the NotebookComponent. */ export default function NotebookComponent({project}: NotebookComponentProps) { - const [notebookTabValue, setNotebookTabValue] = React.useState(0); + // This manages the tab using a query string arg + const {params, setParam} = useQueryParams<{tab: TabIndex}>({ + tab: { + key: ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE_TAB_Q, + defaultValue: 'records', + }, + }); + const notebookTabValue = TAB_TO_INDEX.get(params.tab ?? 'details') ?? 0; + const setNotebookTabValue = (val: number) => { + setParam('tab', INDEX_TO_TAB.get(val) ?? 'records'); + }; + const [recordDraftTabValue, setRecordDraftTabValue] = React.useState(0); const [totalRecords, setTotalRecords] = useState(0); const [myRecords, setMyRecords] = useState(0); diff --git a/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx b/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx index fb05289cb..7a3a235cd 100644 --- a/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx +++ b/app/src/gui/components/ui/Faims_Attachment_Manager_Dialog.tsx @@ -106,8 +106,9 @@ export default function FaimsAttachmentManagerDialog(props: DiagProps) { ) : ( - To download attachments and photos, please go to{' '} - {NOTEBOOK_NAME_CAPITALIZED} / Settings Tab and enable it. + To download existing photos, please go to the{' '} + {NOTEBOOK_NAME_CAPITALIZED} Settings Tab and enable attachment + download. )} @@ -122,9 +123,13 @@ export default function FaimsAttachmentManagerDialog(props: DiagProps) { color="primary" size="large" component={RouterLink} - to={ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE + project_id} + to={ + ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE + + project_id + + `?${ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE_TAB_Q}=settings` + } > - CHANGE SETTINGS + Go to settings )} diff --git a/app/src/gui/components/workspace/CreateNewSurvey.tsx b/app/src/gui/components/workspace/CreateNewSurvey.tsx index 7a04aa29c..01e3ae301 100644 --- a/app/src/gui/components/workspace/CreateNewSurvey.tsx +++ b/app/src/gui/components/workspace/CreateNewSurvey.tsx @@ -13,7 +13,7 @@ import { CREATE_NOTEBOOK_ROLES, userHasRoleInSpecificListing, } from '../../../users'; -import useGetListings from '../../../utils/custom_hooks'; +import {useGetListings} from '../../../utils/customHooks'; import {useGetAllUserInfo} from '../../../utils/useGetCurrentUser'; import NewNotebookForListing from '../notebook/NewNotebookForListing'; import CircularLoading from '../ui/circular_loading'; diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index 33388b7cf..abe00e9f4 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -23,14 +23,16 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'; import CameraAltIcon from '@mui/icons-material/CameraAlt'; import DeleteIcon from '@mui/icons-material/Delete'; import ImageIcon from '@mui/icons-material/Image'; -import {Alert, Box, Paper, Typography, useTheme} from '@mui/material'; +import {Alert, Box, Link, Paper, Typography, useTheme} from '@mui/material'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import ImageListItem from '@mui/material/ImageListItem'; import ImageListItemBar from '@mui/material/ImageListItemBar'; import {FieldProps} from 'formik'; import React from 'react'; -import {APP_NAME, NOTEBOOK_NAME} from '../../buildconfig'; +import {useNavigate} from 'react-router'; +import {APP_NAME, NOTEBOOK_NAME_CAPITALIZED} from '../../buildconfig'; +import * as ROUTES from '../../constants/routes'; import {logError} from '../../logging'; import FaimsAttachmentManagerDialog from '../components/ui/Faims_Attachment_Manager_Dialog'; @@ -296,6 +298,7 @@ export const TakePhoto: React.FC< const [open, setOpen] = React.useState(false); const [photoPath, setPhotoPath] = React.useState(null); const [noPermission, setNoPermission] = React.useState(false); + const navigate = useNavigate(); // Handles photo capture with permission checks const takePhoto = async () => { @@ -347,6 +350,7 @@ export const TakePhoto: React.FC< const images = props.form.values[props.field.name] ?? []; const disabled = props.disabled ?? false; const hasUndownloaded = hasUndownloadedImages(images); + const projectId = props.form.values['_project_id']; return ( @@ -367,8 +371,22 @@ export const TakePhoto: React.FC< {/* Download Banner */} {hasUndownloaded && ( - To download attachments and photos, please go to the {NOTEBOOK_NAME}{' '} - Settings Tab and enable it. + To download existing photos, please go to the{' '} + { + // Deeplink directly to settings tab + } + { + navigate( + ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE + + projectId + + `?${ROUTES.INDIVIDUAL_NOTEBOOK_ROUTE_TAB_Q}=settings` + ); + }} + > + {NOTEBOOK_NAME_CAPITALIZED} Settings Tab + {' '} + and enable attachment download. )} @@ -422,7 +440,7 @@ export const TakePhoto: React.FC< )} setOpen(false)} filedId={props.id} diff --git a/app/src/gui/pages/notebook.tsx b/app/src/gui/pages/notebook.tsx index d789fe046..67ab1e514 100644 --- a/app/src/gui/pages/notebook.tsx +++ b/app/src/gui/pages/notebook.tsx @@ -27,38 +27,40 @@ * - React Router: useParams, useNavigate * - Material UI: Box, Typography, Chip, IconButton, CircularProgress */ -import {useContext} from 'react'; -import {useNavigate, useParams} from 'react-router-dom'; -import {Box, Chip, IconButton, Typography} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; import MenuBookIcon from '@mui/icons-material/MenuBook'; -import {CircularProgress} from '@mui/material'; +import { + Box, + Chip, + CircularProgress, + IconButton, + Typography, +} from '@mui/material'; import {useTheme} from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import {useContext} from 'react'; +import {useNavigate, useParams} from 'react-router-dom'; +import {NOTEBOOK_NAME_CAPITALIZED} from '../../buildconfig'; +import * as ROUTES from '../../constants/routes'; import {ProjectsContext} from '../../context/projects-context'; import NotebookComponent from '../components/notebook'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ErrorIcon from '@mui/icons-material/Error'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import * as ROUTES from '../../constants/routes'; -import {NOTEBOOK_NAME_CAPITALIZED} from '../../buildconfig'; export default function Notebook() { + const theme = useTheme(); const {project_id} = useParams<{ project_id: string; }>(); - + const history = useNavigate(); const project = useContext(ProjectsContext).projects.find( project => project_id === project.project_id ); - - if (!project) return ; - - const theme = useTheme(); const mq_above_md = useMediaQuery(theme.breakpoints.up('md')); - - const history = useNavigate(); const isActive = project?.activated; + if (!project) return ; + return ( {/* Back Button Section */} diff --git a/app/src/utils/customHooks.tsx b/app/src/utils/customHooks.tsx index 080fd18aa..17187a072 100644 --- a/app/src/utils/customHooks.tsx +++ b/app/src/utils/customHooks.tsx @@ -1,7 +1,12 @@ +import {ListingsObject} from '@faims3/data-model/src/types'; +import {useQuery, UseQueryResult} from '@tanstack/react-query'; import React, {useEffect, useRef, useState} from 'react'; import {useNavigate} from 'react-router'; import * as ROUTES from '../constants/routes'; import {OfflineFallbackComponent} from '../gui/components/ui/OfflineFallback'; +import {directory_db} from '../sync/databases'; +import {useCallback} from 'react'; +import {useSearchParams} from 'react-router-dom'; export const usePrevious = (value: T): T | undefined => { /** @@ -81,3 +86,203 @@ export function useIsOnline(): UseIsOnlineResponse { ), }; } + +/** + * Fetches listings from the directory database. + * @returns Promise + */ +const fetchListings = async (): Promise => { + const {rows} = await directory_db.local.allDocs({ + include_docs: true, + }); + + return rows.map(row => row.doc).filter(d => d !== undefined); +}; + +/** + * Custom hook to fetch and manage listings from a directory database using React Query. + */ +export const useGetListings = (): UseQueryResult => { + return useQuery({ + queryKey: ['listings'], + queryFn: fetchListings, + }); +}; + +/* +QUERY PARAMS MANAGER +==================== +*/ + +/** + * Configuration for a single query parameter + * @template T The type of the parameter value + */ +type QueryParamConfig = { + /** The key used in the URL query string */ + key: string; + /** Default value if the parameter is not present */ + defaultValue?: T; + /** Function to convert the string from the URL to the parameter type */ + parser?: (value: string) => T; + /** Function to convert the parameter value to a string for the URL */ + serializer?: (value: T) => string; +}; + +type QueryParamValue = T | undefined; + +/** + * Hook result containing the current parameters and methods to update them + */ +type UseQueryParamsResult> = { + /** Current parameter values */ + params: {[K in keyof T]: QueryParamValue}; + /** Set a single parameter value */ + setParam: (key: K, value: T[K] | undefined) => void; + /** Set multiple parameter values at once */ + setParams: (values: Partial<{[K in keyof T]: T[K] | undefined}>) => void; + /** Remove a single parameter */ + removeParam: (key: keyof T) => void; + /** Remove all parameters */ + removeAllParams: () => void; +}; + +// Default converters if none provided in config +const defaultParser = (value: string) => value; +const defaultSerializer = (value: any) => String(value); + +/** + * Hook to manage URL query parameters with type safety + * + * @example + * // Track the active tab index in the URL + * const { params, setParam } = useQueryParams<{tabIndex: number}>({ + * tabIndex: { + * key: 'tab', + * defaultValue: 0, + * parser: value => parseInt(value) + * } + * }); + * + * // URL will show ?tab=0 by default + * // params.tabIndex will be the current tab number + * // setParam('tabIndex', 2) will update URL to ?tab=2 + */ +export function useQueryParams>(config: { + [K in keyof T]: QueryParamConfig; +}): UseQueryParamsResult { + // Use React Router's search params hook for URL management + const [searchParams, setSearchParams] = useSearchParams(); + // Track initial mount to only set defaults once + const isInitialMount = useRef(true); + + useEffect(() => { + // Only run this effect on the first mount + if (isInitialMount.current) { + const updates = new URLSearchParams(searchParams); + let hasUpdates = false; + + // Check each configured parameter + Object.entries(config).forEach(([_, paramConfig]) => { + const value = searchParams.get(paramConfig.key); + // If param is missing from URL but has a default value, add it + if (value === null && paramConfig.defaultValue !== undefined) { + // Use serialiser to add value to URL + const serializer = paramConfig.serializer || defaultSerializer; + updates.set(paramConfig.key, serializer(paramConfig.defaultValue)); + hasUpdates = true; + } + }); + + // Only update URL if we added any default values + if (hasUpdates) { + setSearchParams(updates); + } + isInitialMount.current = false; + } + }, [config, searchParams, setSearchParams]); + + // Convert URL string values to typed parameters + const params = Object.entries(config).reduce( + (acc, [key, paramConfig]) => { + const value = searchParams.get(paramConfig.key); + const parser = paramConfig.parser || defaultParser; + + // Use parsed URL value if present, otherwise use default + acc[key as keyof T] = + value !== null ? parser(value) : paramConfig.defaultValue; + + return acc; + }, + {} as {[K in keyof T]: QueryParamValue} + ); + + // Update a single parameter in the URL + const setParam = useCallback( + (key: K, value: T[K] | undefined) => { + const paramConfig = config[key]; + const serializer = paramConfig.serializer || defaultSerializer; + + setSearchParams(prev => { + const next = new URLSearchParams(prev); + // Remove param if value is undefined, otherwise set it + if (value === undefined) { + next.delete(paramConfig.key); + } else { + next.set(paramConfig.key, serializer(value)); + } + return next; + }); + }, + [config, setSearchParams] + ); + + // Update multiple parameters at once + const setMultipleParams = useCallback( + (values: Partial<{[K in keyof T]: T[K] | undefined}>) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + + // Process each parameter update + Object.entries(values).forEach(([key, value]) => { + const paramConfig = config[key as keyof T]; + const serializer = paramConfig.serializer || defaultSerializer; + + if (value === undefined) { + next.delete(paramConfig.key); + } else { + next.set(paramConfig.key, serializer(value)); + } + }); + + return next; + }); + }, + [config, setSearchParams] + ); + + // Remove a specific parameter from the URL + const removeParam = useCallback( + (key: keyof T) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + next.delete(config[key].key); + return next; + }); + }, + [config, setSearchParams] + ); + + // Clear all parameters from the URL + const removeAllParams = useCallback(() => { + setSearchParams(new URLSearchParams()); + }, [setSearchParams]); + + return { + params, + setParam, + setParams: setMultipleParams, + removeParam, + removeAllParams, + }; +} diff --git a/app/src/utils/custom_hooks.tsx b/app/src/utils/custom_hooks.tsx deleted file mode 100644 index 1c4330798..000000000 --- a/app/src/utils/custom_hooks.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import {useEffect, useRef} from 'react'; -import {useQuery, UseQueryResult} from '@tanstack/react-query'; -import {directory_db} from '../sync/databases'; -import {ListingsObject} from '@faims3/data-model/src/types'; - -export const usePrevious = (value: T): T | undefined => { - /** - * Capture the previous value of a state variable (useful for functional components - * in place of class-based lifecycle method componentWillUpdate) - */ - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; -}; - -/** - * Fetches listings from the directory database. - * @returns Promise - */ -const fetchListings = async (): Promise => { - const {rows} = await directory_db.local.allDocs({ - include_docs: true, - }); - - return rows.map(row => row.doc).filter(d => d !== undefined); -}; - -/** - * Custom hook to fetch and manage listings from a directory database using React Query. - */ -const useGetListings = (): UseQueryResult => { - return useQuery({ - queryKey: ['listings'], - queryFn: fetchListings, - }); -}; - -export default useGetListings; From 2807c297e3dc9430fa1c6634d53be07a85058ea9 Mon Sep 17 00:00:00 2001 From: Peter Baker Date: Thu, 30 Jan 2025 12:53:39 +1100 Subject: [PATCH 6/6] Fixing up some weird merge stuff and implementing reduced thumbnail size Signed-off-by: Peter Baker --- .../notebook/NewNotebookForListing.tsx | 2 +- app/src/gui/fields/TakePhoto.tsx | 16 ++++++--- app/src/gui/pages/workspace.tsx | 2 +- app/src/utils/customHooks.tsx | 34 ++++++++++++------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/app/src/gui/components/notebook/NewNotebookForListing.tsx b/app/src/gui/components/notebook/NewNotebookForListing.tsx index 0412658fd..2097f4647 100644 --- a/app/src/gui/components/notebook/NewNotebookForListing.tsx +++ b/app/src/gui/components/notebook/NewNotebookForListing.tsx @@ -24,7 +24,7 @@ import {NOTEBOOK_NAME, NOTEBOOK_NAME_CAPITALIZED} from '../../../buildconfig'; import {useNotification} from '../../../context/popup'; import {useCreateNotebookFromTemplate} from '../../../utils/apiHooks/notebooks'; import {useGetTemplates} from '../../../utils/apiHooks/templates'; -import {useGetListing} from '../../../utils/custom_hooks'; +import {useGetListing} from '../../../utils/customHooks'; import CircularLoading from '../ui/circular_loading'; import {refreshToken} from '../../../context/slices/authSlice'; diff --git a/app/src/gui/fields/TakePhoto.tsx b/app/src/gui/fields/TakePhoto.tsx index abe00e9f4..3988cdd8b 100644 --- a/app/src/gui/fields/TakePhoto.tsx +++ b/app/src/gui/fields/TakePhoto.tsx @@ -160,11 +160,17 @@ const ImageGallery = ({ diff --git a/app/src/gui/pages/workspace.tsx b/app/src/gui/pages/workspace.tsx index 82b554729..66f723120 100644 --- a/app/src/gui/pages/workspace.tsx +++ b/app/src/gui/pages/workspace.tsx @@ -24,7 +24,7 @@ import React from 'react'; import {NOTEBOOK_NAME_CAPITALIZED} from '../../buildconfig'; import {selectActiveUser} from '../../context/slices/authSlice'; import {useAppSelector} from '../../context/store'; -import {useGetListing} from '../../utils/custom_hooks'; +import {useGetListing} from '../../utils/customHooks'; import Notebooks from '../components/workspace/notebooks'; export default function Workspace() { diff --git a/app/src/utils/customHooks.tsx b/app/src/utils/customHooks.tsx index 17187a072..0db044103 100644 --- a/app/src/utils/customHooks.tsx +++ b/app/src/utils/customHooks.tsx @@ -88,24 +88,32 @@ export function useIsOnline(): UseIsOnlineResponse { } /** - * Fetches listings from the directory database. - * @returns Promise + * Fetches a specific listing from the directory database. + * @returns Promise */ -const fetchListings = async (): Promise => { - const {rows} = await directory_db.local.allDocs({ - include_docs: true, - }); - - return rows.map(row => row.doc).filter(d => d !== undefined); +const fetchListing = async ( + serverId: string +): Promise => { + try { + return await directory_db.local.get(serverId); + } catch { + return undefined; + } }; /** - * Custom hook to fetch and manage listings from a directory database using React Query. + * Custom hook to fetch and manage listings from a directory database using + * React Query. */ -export const useGetListings = (): UseQueryResult => { - return useQuery({ - queryKey: ['listings'], - queryFn: fetchListings, +export const useGetListing = (input: {serverId?: string}) => { + return useQuery({ + queryKey: ['listings', input.serverId], + queryFn: async () => { + if (!input.serverId) { + return null; + } + return (await fetchListing(input.serverId)) || null; + }, }); };