Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create locations pages and map #1613

Open
wants to merge 12 commits into
base: john/20024-zod-poc
Choose a base branch
from
13 changes: 12 additions & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@ npm run dev
- Change log: change log works normally with details modal. But it is not collapseable (yet).
- Sidebar
- is exapandable and open/closed state is preserved in localstorage

- Locations list
- filter using search input and selecting a field to search on
- reset filters
- filters are preserved (in local storage) when refreshing the page or navigating back to it
- Location details page
- Location polygon map
- Location data card displays the location ID

## Todo

Expand Down Expand Up @@ -101,7 +107,12 @@ npm run dev
- navigration crash search
- profile menu with signout
- locations
- export records
- location details
- data card: crash counts and comp costs
- cr3 crashes list
- noncr3 crashes list
- crash charts and widgets
- create crash record
- upload non-cr3
- dashboard
Expand Down
66 changes: 66 additions & 0 deletions app/app/locations/[location_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";
import { notFound } from "next/navigation";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import AppBreadCrumb from "@/components/AppBreadCrumb";
import DataCard from "@/components/DataCard";
import LocationMapCard from "@/components/LocationMapCard";
import { useQuery } from "@/utils/graphql";
import { GET_LOCATION } from "@/queries/location";
import { locationSchema } from "@/schema/locationSchema";
import { locationCardColumns } from "@/configs/locationDataCard";

const typename = "atd_txdot_locations";

export default function LocationDetailsPage({
params,
}: {
params: { location_id: string };
}) {
const locationId = params.location_id;

const { data, error } = useQuery({
query: locationId ? GET_LOCATION : null,
variables: { locationId },
schema: locationSchema,
typename,
});

if (error) {
console.error(error);
}

if (!data) {
// todo: loading spinner (would be nice to use a spinner inside cards)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return;
}

if (data.length === 0) {
// 404
notFound();
}

const location = data[0];

return (
<>
<AppBreadCrumb />
<span className="fs-2">{location.description}</span>
<Row>
<Col sm={12} md={8} className="mb-3">
<LocationMapCard location={location} />
</Col>
<Col sm={12} md={4} className="mb-3">
<DataCard
columns={locationCardColumns}
isValidating={false}
mutation=""
title="Details"
onSaveCallback={() => Promise.resolve()}
record={location}
/>
</Col>
</Row>
</>
);
}
30 changes: 27 additions & 3 deletions app/app/locations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
"use client";
import Card from "react-bootstrap/Card";
import AppBreadCrumb from "@/components/AppBreadCrumb";
import { locationsListViewColumns } from "@/configs/locationsListViewColumns";
import { locationsListViewQueryConfig } from "@/configs/locationsListViewTable";
import TableWrapper from "@/components/TableWrapper";
import { locationsListSchema } from "@/schema/locationsList";

const localStorageKey = "locationsListViewQueryConfig";

export default function Locations() {
return <h1>This is the locations page</h1>;
}

return (
<>
<AppBreadCrumb />
<Card className="mx-3 mb-3">
<Card.Header className="fs-5 fw-bold">Locations</Card.Header>
<Card.Body>
<TableWrapper
columns={locationsListViewColumns}
initialQueryConfig={locationsListViewQueryConfig}
localStorageKey={localStorageKey}
schema={locationsListSchema}
/>
</Card.Body>
</Card>
</>
);
}
79 changes: 79 additions & 0 deletions app/components/LocationMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useRef, useMemo } from "react";
import MapGL, {
FullscreenControl,
NavigationControl,
MapRef,
Source,
Layer,
} from "react-map-gl";
import { center } from "@turf/center";
import { DEFAULT_MAP_PAN_ZOOM, DEFAULT_MAP_PARAMS } from "@/configs/map";
import "mapbox-gl/dist/mapbox-gl.css";
import { MapAerialSourceAndLayer } from "./MapAerialSourceAndLayer";
import { MultiPolygon } from "@/types/geojson";
import { LineLayerSpecification } from "mapbox-gl";

interface LocationMapProps {
polygon: MultiPolygon;
locationId: string;
}

const polygonLayer: LineLayerSpecification = {
id: "location-polygon",
source: "location-polygon",
type: "line",
paint: {
"line-color": "orange",
"line-width": 4,
},
};

const usePolygonFeature = (polygon: MultiPolygon, locationId: string) =>
useMemo(
() => [
{
type: "Feature",
properties: {
id: locationId,
},
geometry: polygon,
},
center(polygon),
],
[polygon, locationId]
);

/**
* Map component which renders an editable point marker
*/
export const LocationMap = ({ polygon, locationId }: LocationMapProps) => {
const mapRef = useRef<MapRef | null>(null);
const [polygonFeature, centerFeature] = usePolygonFeature(
polygon,
locationId
);

return (
<MapGL
ref={mapRef}
initialViewState={{
latitude: Number(centerFeature.geometry.coordinates[1]),
longitude: Number(centerFeature.geometry.coordinates[0]),
zoom: DEFAULT_MAP_PAN_ZOOM.zoom,
}}
{...DEFAULT_MAP_PARAMS}
cooperativeGestures={true}
// Resize the map canvas when parent row expands to fit crash
onLoad={(e) => e.target.resize()}
maxZoom={21}
>
<FullscreenControl position="top-left" />
<NavigationControl position="top-left" showCompass={false} />
<Source type="geojson" data={polygonFeature} id="location-polygon">
<Layer {...polygonLayer} />
</Source>
{/* add nearmap raster source and style */}
<MapAerialSourceAndLayer />
</MapGL>
);
};
21 changes: 21 additions & 0 deletions app/components/LocationMapCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Card from "react-bootstrap/Card";
import { LocationMap } from "./LocationMap";
import { Location } from "@/types/locations";
/**
* Card component that renders the crash map and edit controls
*/
export default function LocationMapCard({ location }: { location: Location }) {
return (
<Card>
<Card.Header>Location</Card.Header>
<Card.Body className="p-1 crash-header-card-body">
{location.geometry && (
<LocationMap
polygon={location.geometry}
locationId={location.location_id}
/>
)}
</Card.Body>
</Card>
);
}
4 changes: 2 additions & 2 deletions app/components/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ListGroup from "react-bootstrap/ListGroup";
import {
FaShieldHeart,
FaGaugeHigh,
FaMap,
FaLocationDot,
FaAngleRight,
FaRightFromBracket,
} from "react-icons/fa6";
Expand Down Expand Up @@ -110,7 +110,7 @@ export default function SidebarLayout({ children }: { children: ReactNode }) {
<SideBarListItem
isCollapsed={isCollapsed}
isCurrentPage={segments.includes("locations")}
Icon={FaMap}
Icon={FaLocationDot}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i changed the sidebar icon to the map marker, this feels a lot more intuitive to me than the folding map icon

label="Locations"
href="/locations"
/>
Expand Down
10 changes: 2 additions & 8 deletions app/components/TableSearchFieldSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import Form from "react-bootstrap/Form";
import { TableSearchProps } from "./TableSearch";

// todo: move to prop
const fields = [
{ label: "Crash ID", value: "record_locator" },
{ label: "Case ID", value: "case_id" },
{ label: "Address", value: "address_primary" },
];

export default function TableSearchFieldSelector({
searchSettings,
setSearchSettings,
queryConfig,
}: TableSearchProps) {
// todo: bug here where changing the search field clears the search input if it hasn't been searched
return (
<>
<Form.Label className="fw-bold me-2 mb-0">Search by </Form.Label>
{fields.map((field) => {
{queryConfig.searchFields.map((field) => {
return (
<Form.Check
key={field.value}
Expand Down
10 changes: 6 additions & 4 deletions app/components/TableWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,12 @@ export default function TableWrapper<T extends Record<string, unknown>>({
</Row>
<Row className="mb-3">
<Col xs={12} md={6} className="d-flex justify-content-between">
<TableAdvancedSearchFilterToggle
setIsFilterOpen={setIsFilterOpen}
activeFilterCount={activeFilterCount}
/>
{queryConfig.filterCards?.length > 0 && (
<TableAdvancedSearchFilterToggle
setIsFilterOpen={setIsFilterOpen}
activeFilterCount={activeFilterCount}
/>
)}
<TableSearch
queryConfig={queryConfig}
setQueryConfig={setQueryConfig}
Expand Down
5 changes: 5 additions & 0 deletions app/configs/crashesListViewTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export const crashesListViewQueryConfig: QueryConfig = {
operator: "_ilike",
wildcard: true,
},
searchFields: [
{ label: "Crash ID", value: "record_locator" },
{ label: "Case ID", value: "case_id" },
{ label: "Address", value: "address_primary" },
],
dateFilter: {
mode: "ytd",
column: "crash_timestamp",
Expand Down
16 changes: 16 additions & 0 deletions app/configs/locationColumns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ColDataCardDef } from "@/types/types";
import { Location } from "@/types/locations";

export const locationColumns: {
[name: string]: ColDataCardDef<Location>;
} = {
location_id: {
name: "location_id",
label: "Location ID",
},
// todo: need to support non-lookup table relationships
// cr3_crash_count: {
// name: "cr3_crash_count",
// relationshipName: "locations_list_view"
// }
};
Copy link
Member Author

@johnclary johnclary Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the current data cards can render relationships to lookup tables, but there no is mechanism to render card values through other relationships, like the locations <> locations_list_view one-to-one relationship. it felt like a big enough lift to leave out of scope. and probably not worth touching until we settle the question of if we want to stick with zod-generated types 🤔

3 changes: 3 additions & 0 deletions app/configs/locationDataCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { locationColumns } from "./locationColumns";

export const locationCardColumns = [locationColumns.location_id];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels a bit silly but i'm matching the pattern i used to set up the crash data card column arrays 🤷

35 changes: 35 additions & 0 deletions app/configs/locationsListViewColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Link from "next/link";
import { ColDataCardDef } from "@/types/types";
import { LocationsListLocation } from "@/types/locationsList";
import { renderNumber } from "@/utils/formHelpers";

export const locationsListViewColumns: ColDataCardDef<LocationsListLocation>[] =
[
{
name: "location_id",
label: "Location ID",
sortable: true,
valueRenderer: (record: LocationsListLocation) => (
<Link href={`/locations/${record.location_id}`}>
{record.location_id}
</Link>
),
},
{
name: "description",
label: "Location",
sortable: true,
},
{
name: "cr3_crash_count",
label: "CR3 crashes",
sortable: true,
valueFormatter: renderNumber,
},
{
name: "non_cr3_crash_count",
label: "Non-CR3 crashes",
sortable: true,
valueFormatter: renderNumber,
},
];
Loading