finished designing the list and mobile view of the field list

This commit is contained in:
csawatzky 2025-08-26 14:33:57 -06:00
parent 3567190c3a
commit 4db21a3d0c
9 changed files with 287 additions and 113 deletions

2
package-lock.json generated
View file

@ -10911,7 +10911,7 @@
},
"node_modules/protobuf-ts": {
"version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#1b6076608722fff5290457b077604bb0b38d5499",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#70351a87aa4a83bd6274adf8e3b7937090ab4cc2",
"dependencies": {
"protobufjs": "^6.8.8"
}

View file

@ -285,7 +285,6 @@ export default function ComponentCard(props: Props) {
// ) : (
<UnitMeasurementSummary
component={component}
excludedNodes={component.settings.excludedNodes}
reading={UnitMeasurement.convertLastMeasurement(measurements)}
//dense
/>

View file

@ -1,7 +1,13 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Grid2,
IconButton,
Paper,
Stack,
Tab,
Table,
TableBody,
@ -13,15 +19,20 @@ import {
Typography
} from "@mui/material";
import React, { useCallback, useEffect, useState } from "react";
import { useMobile, useSnackbar } from "hooks";
import { useGlobalState, useFieldAPI } from "providers";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { useGlobalState, useFieldAPI, useJohnDeereProxyAPI, useCNHiProxyAPI } from "providers";
//import HarvestTable from "harvestPlan/HarvestTable";
import { Field } from "models";
import { Field, fieldScope, teamScope } from "models";
import GrainDescriber from "grain/GrainDescriber";
import { pond } from "protobuf-ts/pond";
import HarvestSettings from "harvestPlan/HarvestSettings";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
import FieldMinimap from "./Fieldminimap";
import FieldActions from "./FieldActions";
import FieldSettings from "./FieldSettings";
import { ExpandMore, Settings } from "@mui/icons-material";
import { cloneDeep } from "lodash";
import ShareAllFields from "./ShareAllFields";
interface TabPanelProps {
children?: React.ReactNode;
@ -29,61 +40,73 @@ interface TabPanelProps {
value: any;
}
function TabPanelMine(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`simple-tab-${index}`}
style={{ height: "94%" }}
{...other}>
{value === index && <React.Fragment>{children}</React.Fragment>}
</div>
);
}
export default function FieldList() {
const [{ as }] = useGlobalState();
const isMobile = useMobile();
const fieldAPI = useFieldAPI();
const jdAPI = useJohnDeereProxyAPI();
const cnhAPI = useCNHiProxyAPI();
const { openSnack } = useSnackbar();
const [value, setValue] = React.useState(0);
const [tabValue, setTabValue] = React.useState(0);
const [openHarvestSettings, setOpenHarvestSettings] = useState(false);
const [fieldForPlan, setFieldForPlan] = useState(Field.create());
const [fields, setFields] = useState<Field[]>([]); //all fields
const [adaptiveFields, setAdaptiveFields] = useState<Field[]>([])
const [jdFields, setJDFields] = useState<Field[]>([])
const [cnhFields, setCNHFields] = useState<Field[]>([])
// const [fieldForPlan, setFieldForPlan] = useState(Field.create());
const [fields, setFields] = useState<Field[]>([]);
const [total, setTotal] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(5)
const [page, setPage] = useState(0)
const [selectedField, setSelectedField] = useState<Field>()
const [openFieldSettings, setOpenFieldSettings] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setValue(newValue);
setTabValue(newValue);
setPage(0)
};
const loadFields = useCallback(() => {
const loadAdaptiveFields = useCallback(() => {
fieldAPI
.listFields(500, 0, "asc", "fieldName", undefined, as)
.listFields(rowsPerPage, page*rowsPerPage, "asc", "fieldName", undefined, as)
.then(resp => {
setAdaptiveFields(resp.data.fields.map(f => Field.any(f)));
// resp.data.fields.forEach(f => {
// if (f.settings) {
// fieldAPI.updateField(f.settings.key, f.settings);
// }
// });
setFields(resp.data.fields.map(f => Field.create(f)));
setTotal(resp.data.total)
})
.catch(err => {
openSnack("Failed to load Field Mapping");
openSnack("Failed to load Fields");
});
}, [fieldAPI, as, openSnack]);
}, [fieldAPI, as, openSnack, page, rowsPerPage]);
const loadJDFields = useCallback(() => {
jdAPI.listFields(rowsPerPage, page*rowsPerPage, as)
.then(resp => {
resp.data.fields ? setFields(resp.data.fields.map(f => Field.create(f))) : setFields([])
}).catch(err => {
openSnack("Failed to load John Deere Fields");
})
},[jdAPI, as, openSnack, page, rowsPerPage])
const loadCNHFields = useCallback(() => {
cnhAPI.listFields(rowsPerPage, page*rowsPerPage, as)
.then(resp => {
resp.data.fields ? setFields(resp.data.fields.map(f => Field.create(f))) : setFields([])
}).catch(err => {
openSnack("Failed to load John Deere Fields");
})
},[cnhAPI, as, openSnack, page, rowsPerPage])
useEffect(() => {
loadFields();
}, [loadFields]);
useEffect(()=>{
setFields(adaptiveFields.concat(jdFields, cnhFields))
},[adaptiveFields,jdFields,cnhFields])
//based on the current tab load the correct fields
switch(tabValue){
case 0:
loadAdaptiveFields();
break;
case 1:
loadJDFields();
break;
case 2:
loadCNHFields();
break;
}
}, [loadAdaptiveFields, loadJDFields, loadCNHFields, tabValue]);
const calcTotalAcres = (fields: Field[]) => {
let totalAcres = 0;
@ -94,6 +117,19 @@ export default function FieldList() {
return totalAcres;
};
const fieldActions = (field: Field) => {
return (
<Box>
<IconButton
onClick={() => {
setSelectedField(field)
setOpenFieldSettings(true)
}}><Settings /></IconButton>
<FieldActions field={field} permissions={field.permissions} refreshCallback={()=>{}}/>
</Box>
)
}
const columns: Column<Field>[] = [
{
title: "View",
@ -108,26 +144,25 @@ const columns: Column<Field>[] = [
{
title: "Field Name",
render: row => {
return <Typography>{row.name()}</Typography>
return <Typography paddingLeft={2}>{row.name()}</Typography>
}
},
{
title: "Current Crop",
render: row => {
return <Typography>{row.crop()}</Typography>
return <Typography paddingLeft={2}>{GrainDescriber(row.crop()).name}</Typography>
}
},
{
title: "Acres",
render: row => {
return <Typography>{row.acres()}</Typography>
return <Typography paddingLeft={2}>{row.acres()}</Typography>
}
},
{
title: "Actions",
render: row => {
return <Typography>Field Actions Here</Typography>
}
hidden: tabValue > 0,
render: row => fieldActions(row)
}
]
@ -136,43 +171,126 @@ const columns: Column<Field>[] = [
<ResponsiveTable<Field>
columns={columns}
rows={fields}
setPage={()=>{}}
handleRowsPerPageChange={()=>{}}
page={0}
pageSize={1}
total={fields.length}
setPage={(newPage)=>{
setPage(newPage)
}}
handleRowsPerPageChange={(e)=>{setRowsPerPage(e.target.value)}}
page={page}
pageSize={rowsPerPage}
total={total}
resizeable
loadMore={() => {
let currentFields = cloneDeep(fields)
switch(tabValue){
case 0:
fieldAPI.listFields(rowsPerPage, currentFields.length, "asc", "fieldName", undefined, as).then(resp => {
setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f))))
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
case 1:
jdAPI.listFields(rowsPerPage, currentFields.length, as).then(resp => {
resp.data.fields ? setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f)))) : setFields([])
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
case 2:
cnhAPI.listFields(rowsPerPage, currentFields.length, as).then(resp => {
resp.data.fields ? setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f)))) : setFields([])
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
}
}}
renderMobile={(row, index) => {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Grid2 alignItems="center" width="100%" container direction={"row"} justifyContent="space-between" wrap="nowrap">
<Grid2>
<Typography textAlign="center">
{row.name()}
</Typography>
</Grid2>
<Grid2>
{fieldActions(row)}
</Grid2>
</Grid2>
</AccordionSummary>
<AccordionDetails>
<Grid2 alignItems="center" width="100%" container direction={"row"} justifyContent="space-between" wrap="nowrap">
<Grid2 size={6}>
<Box height={150} width="100%">
<FieldMinimap field={row} />
</Box>
</Grid2>
<Grid2 size={6}>
<Stack spacing={2}>
<Typography textAlign="center">{row.grainName()}</Typography>
<Typography textAlign="center">{row.acres()} Acres</Typography>
</Stack>
</Grid2>
</Grid2>
</AccordionDetails>
</Accordion>
)
}}
/>
)
}
return (
<Box>
{isMobile &&
<Button
onClick={()=>{setShareOpen(true)}}
variant="contained"
color="primary">
Share All
</Button>
}
<Tabs
value={value}
value={tabValue}
onChange={handleChange}
TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}>
<Tab label="All" />
<Tab label="Adaptive" />
<Tab label="John Deere" />
<Tab label="Case New Holland" />
{!isMobile &&
<Button
style={{
position: "absolute",
top: 0,
right: 0
}}
onClick={()=>{setShareOpen(true)}}
variant="contained"
color="primary">
Share All
</Button>
}
</Tabs>
<TabPanelMine index={0} value={value}>
{fieldTable(fields)}
</TabPanelMine>
<TabPanelMine index={1} value={value}>
{fieldTable(adaptiveFields)}
</TabPanelMine>
<TabPanelMine index={2} value={value}>
{fieldTable(jdFields)}
</TabPanelMine>
<TabPanelMine index={3} value={value}>
{fieldTable(cnhFields)}
</TabPanelMine>
<HarvestSettings
{fieldTable(fields)}
{/* <HarvestSettings
open={openHarvestSettings}
close={() => setOpenHarvestSettings(false)}
field={fieldForPlan}
/> */}
<FieldSettings
selectedField={selectedField}
removeField={() => {
//will need to re-load the fields after one is deleted
loadAdaptiveFields()
}}
open={openFieldSettings}
onClose={() => {
setOpenFieldSettings(false);
}}
/>
<ShareAllFields open={shareOpen} close={() => {setShareOpen(false)}}/>
</Box>
);
}

View file

@ -40,16 +40,17 @@ export default function FieldMinimap(props: Props) {
return geoCollection ?
(
<Map
initialViewState={{
bounds: field.fieldBounds(.001)
}}
zoom={1}
maxBounds={
field.fieldBounds(.001)
}
reuseMaps
style={{width: "100%", height: "100%"}}
mapboxAccessToken={MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/satellite-streets-v11"
>
<GeoMapLayer
objectCollection={geoCollection}
setInteractiveLayers={()=>{}}
/>
</Map>
)

View file

@ -0,0 +1,43 @@
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
interface Props {
open: boolean
close: () => void
}
export default function ShareAllFields(props: Props) {
const {open, close} = props
const share = () => {
//if sharing to team use shareAllByKey
//if sharing to user use shareAll
}
const target = () => {
// toggle for whether sharing to user or team
// if sharing to user have a text field
// if sharing to team have the team selector
}
const permissions = () => {
// the checkboxes of permissions
}
const actions = () => {
//the buttons to shaare or cancel
}
return (
<ResponsiveDialog open={open} onClose={close}>
<DialogTitle>Share All Fields</DialogTitle>
<DialogContent>
Content for selecting target and permissions
</DialogContent>
<DialogActions>
Buttons for cancel and confirm
</DialogActions>
</ResponsiveDialog>
)
}

View file

@ -6,7 +6,7 @@ interface Props {
objectCollection: FeatureCollection;
measurementCollection?: FeatureCollection;
showTitle?: boolean;
setInteractiveLayers: (layers: string[]) => void;
setInteractiveLayers?: (layers: string[]) => void;
}
export default function GeoMapLayer(props: Props) {
@ -18,7 +18,9 @@ export default function GeoMapLayer(props: Props) {
// }, [measurementCollection, objectCollection]);
useEffect(() => {
setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]);
if(setInteractiveLayers){
setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
@ -60,6 +62,7 @@ export default function GeoMapLayer(props: Props) {
/>
)}
</Source>
{measurementCollection &&
<Source type="geojson" data={measurementCollection} id={"measurements"}>
<Layer
id="measurementBorder"
@ -90,6 +93,7 @@ export default function GeoMapLayer(props: Props) {
}}
/>
</Source>
}
</React.Fragment>
);
}

View file

@ -10,14 +10,16 @@ import { LngLat, LngLatBounds } from "mapbox-gl";
export class Field {
public settings: pond.FieldSettings = pond.FieldSettings.create();
public status: pond.FieldStatus = pond.FieldStatus.create();
public permissions: pond.Permission[] = [];
public permissions: pond.Permission[] = [];
public static create(pf?: pond.Field): Field {
let my = new Field();
if (pf) {
my.settings = pond.FieldSettings.fromObject(cloneDeep(or(pf.settings, {})));
my.status = pond.FieldStatus.fromObject(cloneDeep(or(pf.status, {})));
my.permissions = pf.fieldPermissions;
//the from object is required here to deal with a protobuf quirk where enums would be seen as strings at runtime, but the code sees them as the actual value
let field = pond.Field.fromObject(cloneDeep(or(pf, pond.Field.create())))
my.settings = field.settings ? field.settings : pond.FieldSettings.create()
my.status = field.status ? field.status : pond.FieldStatus.create()
my.permissions = field.fieldPermissions
}
return my;
}

View file

@ -5,6 +5,7 @@ import { useGlobalState } from "providers";
import React, { createContext, PropsWithChildren, useContext } from "react";
import { or } from "utils";
import { pondURL } from "./pond";
import { permissionToString } from "pbHelpers/Permission";
export interface IFieldAPIContext {
addField: (field: pond.FieldSettings, otherTeam?: string) => Promise<any>;
@ -18,14 +19,6 @@ export interface IFieldAPIContext {
otherTeam?: string,
asRoot?: boolean
) => Promise<AxiosResponse<pond.ListFieldsResponse>>;
fieldsPageData: (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
otherTeam?: string,
) => Promise<AxiosResponse<pond.ListFieldsPageDataResponse>>
removeField: (key: string, otherTeam?: string) => Promise<AxiosResponse<pond.RemoveFieldResponse>>;
updateField: (
key: string,
@ -33,6 +26,14 @@ export interface IFieldAPIContext {
asRoot?: true,
otherTeam?: string
) => Promise<AxiosResponse<pond.UpdateFieldResponse>>;
shareAll: (
email: string,
permissions: pond.Permission[],
) => Promise<AxiosResponse<any>>
shareAllByKey: (
key: string,
permissions: pond.Permission[]
) => Promise<AxiosResponse<any>>
}
export const FieldAPIContext = createContext<IFieldAPIContext>({} as IFieldAPIContext);
@ -88,30 +89,6 @@ export default function FieldProvider(props: PropsWithChildren<Props>) {
);
};
const fieldsPageData = (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
otherTeam?: string,
) => {
const view = otherTeam ? otherTeam : as
return get<pond.ListFieldsPageDataResponse>(
pondURL(
"/fieldsPage" +
"?limit=" +
limit +
"&offset=" +
offset +
("&order=" + or(order, "asc")) +
("&by=" + or(orderBy, "key")) +
(view ? "&as=" + view : "") +
(search ? "&search=" + search : "")
)
);
}
const updateField = (key: string, field: pond.FieldSettings, asRoot?: boolean, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view)
@ -125,6 +102,36 @@ const view = otherTeam ? otherTeam : as
);
};
const shareAll = (email: string, permissions: pond.Permission[]) => {
let url = pondURL("/fields/shareAll")
if (as) url = url + "?as=" + as
return new Promise<AxiosResponse>((resolve, reject) => {
post(url, {
email: email,
permissions: permissions.map(permission => permissionToString(permission))
}).then(resp => {
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
}
const shareAllByKey = (key: string, permissions: pond.Permission[]) => {
let url = pondURL("/fields/shareAllByKey")
if (as) url = url + "?as=" + as
return new Promise<AxiosResponse>((resolve, reject) => {
post(url, {
key: key,
permissions: permissions.map(permission => permissionToString(permission))
}).then(resp => {
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
}
return (
<FieldAPIContext.Provider
value={{
@ -133,7 +140,8 @@ const view = otherTeam ? otherTeam : as
listFields,
removeField,
updateField,
fieldsPageData
shareAll,
shareAllByKey
}}>
{children}
</FieldAPIContext.Provider>

View file

@ -135,7 +135,6 @@ export default function PermissionProvider(props: PropsWithChildren<Props>) {
let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey")
if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`)
return new Promise<AxiosResponse>((resolve, reject) => {
post(url, {
key: key,
permissions: permissions.map(permission => permissionToString(permission))