diff --git a/.gitignore b/.gitignore index c987e98c1..79fc967a2 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,9 @@ build-iPhoneSimulator/ *.log *.xml +# allow the VZE Non-CR3 upload upload template +!app/public/files/non_cr3_template.csv + # Ignore any json files within these directories atd-events/**/*.json atd-events/**/*.zip diff --git a/api/README.md b/api/README.md index dd869e1b9..73c385e1a 100644 --- a/api/README.md +++ b/api/README.md @@ -12,13 +12,26 @@ Production: https://vision-zero-cr3-user-api.austinmobility.io/ These flask apps are deployed as long-running tasks in ECS and are reverse proxy'd out to the internet by an AWS API gateway via a namespace / VPC link configuration. Specific deployment instructions for the COA deployment are contained in our internal documentation repositories. +## Configuration + +The API requires certain environment variables to be set. Copy the `.env.example` file in the `api` directory to `.env` and fill in the values. + ## Local usage -You can start the API using either the project wide `docker compose` file with `docker compose up cr3-user-api` or if you prefer, you can use the `docker compose` stack that is concerned only with part of the stack as found in the `api` directory. Use whichever is best for your development needs. Additionally, you can use the `vision-zero` orchestration tool to `vision-zero api-up` and `vision-zero api-down` to start and stop the API. +Update your VZE environment (`/app/.env.local`) to use the local API: -## Configuration +``` +NEXT_PUBLIC_CR3_API_DOMAIN=http://localhost:8085 +``` -The API requires certain environment variables to be set. Copy the `.env.example` file in the `api` directory to `.env` and fill in the values. +You can start the API using either the project wide `docker compose` file with `docker compose up cr3-user-api` or if you prefer, you can use the `docker compose` stack that is concerned only with part of the stack as found in the `api` directory. Use whichever is best for your development needs. + +Both docker compose files enable local development by: + +- mounting your local `/api` directory into the container +- use the `--debug` command so that the web server restarts when it detects code changes + +Additionally, you can use the `vision-zero` orchestration tool to `vision-zero api-up` and `vision-zero api-down` to start and stop the API. ## Secrets diff --git a/api/docker-compose.yaml b/api/docker-compose.yaml index fd065a672..fd05e41f2 100644 --- a/api/docker-compose.yaml +++ b/api/docker-compose.yaml @@ -3,10 +3,10 @@ services: build: context: . ports: - - "5000:5000" + - "8085:5000" environment: - FLASK_ENV=production volumes: - .:/usr/src/app - command: flask run --host=0.0.0.0 --port=5000 + command: flask run --host=0.0.0.0 --port=5000 --debug restart: unless-stopped diff --git a/api/server.py b/api/server.py index 135c9f7a4..db5dffe11 100755 --- a/api/server.py +++ b/api/server.py @@ -3,11 +3,14 @@ # ATD - CR3 Download API # +import datetime import json +import os import re -import datetime +import secrets +import string + import boto3 -import os import requests from dotenv import load_dotenv, find_dotenv @@ -15,7 +18,7 @@ from functools import wraps from six.moves.urllib.request import urlopen -from flask import Flask, request, jsonify, abort, g +from flask import Flask, request, jsonify, g from flask_cors import cross_origin from werkzeug.local import LocalProxy from jose import jwt @@ -42,6 +45,32 @@ ADMIN_ROLE_NAME = "vz-admin" +CORS_URL = "*" +ALGORITHMS = ["RS256"] +APP = Flask(__name__) + + +def get_secure_password(num_chars=16): + """Generate a secure random password with at least one lowercase character, + uppercase character, special character, and digit: + https://docs.python.org/3/library/secrets.html#recipes-and-best-practices + + Args: + num_chars (int, optional): Defaults to 16. + """ + special_chars = "!@#$%^&*" + alphabet = string.ascii_letters + string.digits + special_chars + while True: + password = "".join(secrets.choice(alphabet) for i in range(num_chars)) + if ( + any(c.islower() for c in password) + and any(c.isupper() for c in password) + and any(c.isdigit() for c in password) + and any(c in special_chars for c in password) + ): + break + return password + def get_api_token(): """ @@ -63,12 +92,22 @@ def get_api_token(): return response.json().get("access_token", None) -CORS_URL = "*" -ALGORITHMS = ["RS256"] -APP = Flask(__name__) +def notAuthorizedError(): + """Return 403 error as application/json in shape of the Auth0 API""" + return ( + jsonify( + { + "error": "Forbidden", + "message": "You do not have permission to access this resource.", + "statusCode": 403, + } + ), + 403, + ) # Add the appropriate security headers to all responses +# These headers may be overwritten in prod and staging by the API gateway! @APP.after_request def add_custom_headers(response): # Cache-Control to manage caching behavior @@ -103,8 +142,9 @@ def add_custom_headers(response): response.headers["Expect-CT"] = "max-age=86400, enforce" # Access-Control-Allow-Origin for CORS + # These headers may be overwritten in prod and staging by the API gateway! response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.headers["Access-Control-Allow-Methods"] = "GET, DELETE, POST, PUT, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" response.headers["Access-Control-Allow-Credentials"] = "true" @@ -315,8 +355,14 @@ def healthcheck(): @APP.route("/cr3/download/") -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def download_crash_id(crash_id): """A valid access token is required to access this route""" @@ -366,7 +412,7 @@ def isValidUser(user_dict): return False # Check email for austintexas.gov - if str(user_email).endswith("@austintexas.gov") is False: + if not str(user_email).endswith("@austintexas.gov"): return False return True @@ -382,20 +428,32 @@ def hasUserRole(role, user_dict): @APP.route("/user/test") -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_test(): user_dict = current_user._get_current_object() if isValidUser(user_dict): return jsonify(message=current_user._get_current_object()) else: - abort(403) + return notAuthorizedError() @APP.route("/user/list_users") -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_list_users(): user_dict = current_user._get_current_object() @@ -407,28 +465,34 @@ def user_list_users(): + page + "&per_page=" + per_page - + "&include_totals=true" + + "&include_totals=true&sort=last_login:-1" ) headers = {"Authorization": f"Bearer {get_api_token()}"} - response = requests.get(endpoint, headers=headers).json() - return jsonify(response) + response = requests.get(endpoint, headers=headers) + return jsonify(response.json()), response.status_code else: - abort(403) + return notAuthorizedError() @APP.route("/user/get_user/") -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_get_user(id): user_dict = current_user._get_current_object() if isValidUser(user_dict) and hasUserRole(ADMIN_ROLE_NAME, user_dict): endpoint = f"https://{AUTH0_DOMAIN}/api/v2/users/" + id headers = {"Authorization": f"Bearer {get_api_token()}"} - response = requests.get(endpoint, headers=headers).json() - return jsonify(response) + response = requests.get(endpoint, headers=headers) + return jsonify(response.json()), response.status_code else: - abort(403) + return notAuthorizedError() @APP.route("/user/create_user", methods=["POST"]) @@ -439,17 +503,29 @@ def user_create_user(): user_dict = current_user._get_current_object() if isValidUser(user_dict) and hasUserRole(ADMIN_ROLE_NAME, user_dict): json_data = request.json + # set the user's password - user will have to reset it for access + json_data["password"] = get_secure_password() + # set additional user properties + json_data["connection"] = "Username-Password-Authentication" + json_data["verify_email"] = True + json_data["email_verified"] = False endpoint = f"https://{AUTH0_DOMAIN}/api/v2/users" headers = {"Authorization": f"Bearer {get_api_token()}"} - response = requests.post(endpoint, headers=headers, json=json_data).json() - return jsonify(response) + response = requests.post(endpoint, headers=headers, json=json_data) + return jsonify(response.json()), response.status_code else: - abort(403) + return notAuthorizedError() @APP.route("/user/update_user/", methods=["PUT"]) -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_update_user(id): user_dict = current_user._get_current_object() @@ -457,15 +533,21 @@ def user_update_user(id): json_data = request.json endpoint = f"https://{AUTH0_DOMAIN}/api/v2/users/" + id headers = {"Authorization": f"Bearer {get_api_token()}"} - response = requests.patch(endpoint, headers=headers, json=json_data).json() - return jsonify(response) + response = requests.patch(endpoint, headers=headers, json=json_data) + return jsonify(response.json()), response.status_code else: - abort(403) + return notAuthorizedError() @APP.route("/user/unblock_user/", methods=["DELETE"]) -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_unblock_user(id): user_dict = current_user._get_current_object() @@ -475,12 +557,18 @@ def user_unblock_user(id): response = requests.delete(endpoint, headers=headers) return f"{response.status_code}" else: - abort(403) + return notAuthorizedError() @APP.route("/user/delete_user/", methods=["DELETE"]) -@cross_origin(headers=["Content-Type", "Authorization"]) -@cross_origin(headers=["Access-Control-Allow-Origin", CORS_URL]) +@cross_origin( + headers=[ + "Content-Type", + "Authorization", + "Access-Control-Allow-Origin", + CORS_URL, + ], +) @requires_auth def user_delete_user(id): user_dict = current_user._get_current_object() @@ -488,9 +576,12 @@ def user_delete_user(id): endpoint = f"https://{AUTH0_DOMAIN}/api/v2/users/" + id headers = {"Authorization": f"Bearer {get_api_token()}"} response = requests.delete(endpoint, headers=headers) - return f"{response.status_code}" + if response.headers.get("Content-Type") == "application/json": + return jsonify(response.json()), response.status_code + else: + return response.text, response.status_code else: - abort(403) + return notAuthorizedError() if __name__ == "__main__": diff --git a/app/.eslintrc.json b/app/.eslintrc.json new file mode 100644 index 000000000..372241854 --- /dev/null +++ b/app/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/app/.nvmrc b/app/.nvmrc new file mode 100644 index 000000000..209e3ef4b --- /dev/null +++ b/app/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 000000000..f0eb61e0f --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": false +} diff --git a/app/README.md b/app/README.md new file mode 100644 index 000000000..876d61b23 --- /dev/null +++ b/app/README.md @@ -0,0 +1,137 @@ +**This** is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Quick start + +1. Start your database and graphql engine + +2. Use the `env_template` to configure the local environment variables. These can be copied from your existing VZE `.env` file, by replacing `REACT_APP_` with `NEXT_PUBLIC_` in the variable name: + +```shell +# run this cmd and then edit .env.local as needed +cp -n env_template .env.local +``` + +3. Activate your `node` environment (`v20` is required): + +```shell +nvm use +``` + +4. Install node packages + +``` +npm install +``` + +5. Start the development server + +```shell +npm run dev +``` + +6. Open [http://localhost:3002](http://localhost:3002) with your browser to see the result. + +## This app vs the old VZE + +- Create-React-App ➡️ NextJS +- Javascript ➡️ Typescript +- CoreUI + reactstrap ➡️ react-bootstrap +- Apollo Client ➡️ SWR + graphql-request + +## Working features + +- Crashes list + - filter by preset date range + - filter by custom date range + - 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 + - advanced search filter menu toggle shows a badge with how many fitlers are applied + - expandable filter toggle menu with working filter switch groups + - paginate through search results + - loading spinner apperas in pagination controls when fetching data + - sortable table columns with icon indicating sort direction +- Crash details page + - Crash map: crash map displays crash location with nearmap aerials + - Crash map: ability to edit crash location + - Crash diagram: diagrams render + - Crash diagram: info alert shows when no diagram is available and is temp record + - Crash diagram: danger alert shows when no diagram is available and is not temp record + - Crash narrative: loads normally and is scrollable for long narratives + - Crash data card: click field to edit + - Crash data card: cannot save unless field is edited + - Crash data card: use cancel button to exit edit mode and restore initial value + - Crash data card: use `enter` key to save edits + - Crash data card: edit a field with lookup values + - Crash data card: edit a text input + - Crash data card: edit a number input + - Crash data card: nullify a value (e.g. street name) by clearing its input and saving it + - FRB Recommendations + - Create recommendation + - Edit recommendation + - Related records - units: click field to edit it + - Related records - charges + - 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, crash counts and comp costs + - combined cr3 and noncr3 crashes list +- Navabar: + - Vision Zero logo displays on left side + - Avatar image displays on right side with dropdown items + - email address (disabled/not clickable) + - sighout link + - external links to report a bug, request enhancement, and TxDOT's CR3 codesheet +- Users + - user list view + - user details card + - add a new user + - edit a user + - delete a user +- create crash records + - view + search for temp records + - create crash record form + +## Todo + +- permissions-based features: + - side nav + - field editing + - etc. see component from VZE +- crash details + - crash injury widgets + - swap addresses + - Related records - people + - name edit component + - notes + - misc column editing + placement + - Crash map: show coordinates and enable editing by tying into input + - crash diagram: zoom/tilt control + - Change log: collapseable +- table + - advanced filters: test them and add "Other" unit type filter + - export + - record counts +- Top nav component + - navigation crash search +- locations + - export records +- location details + - crash charts and widgets +- create crash record + - delete crash record + - hide page from read-only users +- upload non-cr3 +- dashboard +- users + - copy user emails +- login page: make it look nice +- versioned localstorage +- error boundary (see Moped's for reference) +- map geocoder (technically an enhancement vis a vis current state) diff --git a/app/app/crashes/[record_locator]/page.tsx b/app/app/crashes/[record_locator]/page.tsx new file mode 100644 index 000000000..222e464b9 --- /dev/null +++ b/app/app/crashes/[record_locator]/page.tsx @@ -0,0 +1,204 @@ +"use client"; +import { useCallback } from "react"; +import { notFound } from "next/navigation"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import Card from "react-bootstrap/Card"; +import CrashMapCard from "@/components/CrashMapCard"; +import { GET_CRASH, UPDATE_CRASH } from "@/queries/crash"; +import { UPDATE_UNIT } from "@/queries/unit"; +import { UPDATE_PERSON } from "@/queries/person"; +import { useQuery } from "@/utils/graphql"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import CrashHeader from "@/components/CrashHeader"; +import CrashLocationBanner from "@/components/CrashLocationBanner"; +import CrashIsTemporaryBanner from "@/components/CrashIsTemporaryBanner"; +import CrashDiagramCard from "@/components/CrashDiagramCard"; +import DataCard from "@/components/DataCard"; +import RelatedRecordTable from "@/components/RelatedRecordTable"; +import ChangeLog from "@/components/ChangeLog"; +import { crashDataCards } from "@/configs/crashDataCard"; +import { unitRelatedRecordCols } from "@/configs/unitRelatedRecordTable"; +import { chargeRelatedRecordCols } from "@/configs/chargeRelatedRecordTable"; +import { peopleRelatedRecordCols } from "@/configs/peopleRelatedRecordTable"; +import { Crash } from "@/types/crashes"; +import CrashRecommendationCard from "@/components/CrashRecommendationCard"; +import CrashSwapAddressButton from "@/components/CrashSwapAddressButton"; + +const typename = "crashes"; + +export default function CrashDetailsPage({ + params, +}: { + params: { record_locator: string }; +}) { + const recordLocator = params.record_locator; + + const { data, error, refetch, isValidating } = useQuery({ + query: recordLocator ? GET_CRASH : null, + variables: { recordLocator }, + typename, + }); + + if (error) { + console.error(error); + } + + const onSaveCallback = useCallback(async () => { + await refetch(); + }, [refetch]); + + if (!data) { + // todo: loading spinner (would be nice to use a spinner inside cards) + return; + } + + if (data.length === 0) { + // 404 + notFound(); + } + + const crash = data[0]; + + return ( + <> + + + { + // show alert if crash on private drive or outside of Austin full purpose + (crash.private_dr_fl || !crash.in_austin_full_purpose) && ( + + ) + } + { + // show alert if crash is a temp record + crash.is_temp_record && + } + + + + + + + + + + Narrative + + {crash.investigator_narrative || ""} + + + + + + + + record={crash} + isValidating={isValidating} + title="Summary" + columns={crashDataCards.summary} + mutation={UPDATE_CRASH} + onSaveCallback={onSaveCallback} + /> + + + + record={crash} + isValidating={isValidating} + title="Flags" + columns={crashDataCards.flags} + mutation={UPDATE_CRASH} + onSaveCallback={onSaveCallback} + /> + + + + record={crash} + isValidating={isValidating} + title="Other" + columns={crashDataCards.other} + mutation={UPDATE_CRASH} + onSaveCallback={onSaveCallback} + /> + + + + + + record={crash} + isValidating={isValidating} + title="Primary address" + columns={crashDataCards.address} + mutation={UPDATE_CRASH} + onSaveCallback={onSaveCallback} + HeaderActionButton={CrashSwapAddressButton} + /> + + + + record={crash} + isValidating={isValidating} + title="Secondary address" + columns={crashDataCards.address_secondary} + mutation={UPDATE_CRASH} + onSaveCallback={onSaveCallback} + /> + + + + + + + + + + + + + + + + + + + + + + + + {crash && } + + + ); +} diff --git a/app/app/crashes/page.tsx b/app/app/crashes/page.tsx new file mode 100644 index 000000000..57ccb21ee --- /dev/null +++ b/app/app/crashes/page.tsx @@ -0,0 +1,25 @@ +"use client"; +import Card from "react-bootstrap/Card"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import { crashesListViewColumns } from "@/configs/crashesListViewColumns"; +import { crashesListViewQueryConfig } from "@/configs/crashesListViewTable"; +import TableWrapper from "@/components/TableWrapper"; +const localStorageKey = "crashesListViewQueryConfig"; + +export default function Crashes() { + return ( + <> + + + Crashes + + + + + + ); +} diff --git a/app/app/create-crash-record/page.tsx b/app/app/create-crash-record/page.tsx new file mode 100644 index 000000000..733a7eb4b --- /dev/null +++ b/app/app/create-crash-record/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import { useState, useCallback } from "react"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import AlignedLabel from "@/components/AlignedLabel"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import CreateCrashRecordModal from "@/components/CreateCrashRecordModal"; +import TableWrapper from "@/components/TableWrapper"; +import { FaCirclePlus } from "react-icons/fa6"; +import { tempCrashesListViewQueryConfig } from "@/configs/tempCrashesTable"; +import { tempCrashesListViewColumns } from "@/configs/tempCrashesListViewColumns"; + +const localStorageKey = "tempCrashesListViewQueryConfig"; + +export default function CreateCrashRecord() { + const [refetch, setRefetch] = useState(false); + const [showNewUserModal, setShowNewUserModal] = useState(false); + const onCloseModal = () => setShowNewUserModal(false); + + const onSaveCallback = useCallback(() => { + setRefetch((prev) => !prev); + setShowNewUserModal(false); + }, [setRefetch]); + + return ( + <> + + + Temporary records + +
+ +
+ +
+
+ + + ); +} diff --git a/app/app/dashboard/page.tsx b/app/app/dashboard/page.tsx new file mode 100644 index 000000000..28c49c759 --- /dev/null +++ b/app/app/dashboard/page.tsx @@ -0,0 +1,4 @@ +export default function Dashboard() { + return

This is the dashboard page

; + } + \ No newline at end of file diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 000000000..6c6514812 --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,25 @@ +import "../styles/global.scss"; +import AuthProvider from "@/contexts/Auth"; +import SidebarLayout from "@/components/SidebarLayout"; + +/** + * The root layout for the whole app + * + * todo: metadata + */ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + // + + + + {children} + + + + ); +} diff --git a/app/app/locations/[location_id]/page.tsx b/app/app/locations/[location_id]/page.tsx new file mode 100644 index 000000000..6c95066fa --- /dev/null +++ b/app/app/locations/[location_id]/page.tsx @@ -0,0 +1,111 @@ +"use client"; +import { useMemo } from "react"; +import { notFound } from "next/navigation"; +import Col from "react-bootstrap/Col"; +import Card from "react-bootstrap/Card"; +import Row from "react-bootstrap/Row"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import DataCard from "@/components/DataCard"; +import LocationMapCard from "@/components/LocationMapCard"; +import TableWrapper from "@/components/TableWrapper"; +import { useQuery } from "@/utils/graphql"; +import { GET_LOCATION } from "@/queries/location"; +import { Location } from "@/types/locations"; +import { Filter } from "@/utils/queryBuilder"; +import { locationCardColumns } from "@/configs/locationDataCard"; +import { locationCrashesColumns } from "@/configs/locationCrashesColumns"; +import { locationCrashesQueryConfig } from "@/configs/locationCrashesTable"; + +const typename = "atd_txdot_locations"; + +/** + * Hook which returns builds a Filter array with the `location_id` param. + * This can be passed as a `contextFilter` to the TableWrapper so that the + * location's crashes table is filtered by location + * @param {string} locationId - the location ID string from the page route + * @returns {Filter[]} the Filter array with the single location filter + */ +const useLocationIdFilter = (locationId: string): Filter[] => + useMemo( + () => [ + { + id: "location_id", + value: locationId, + column: "location_id", + operator: "_eq", + }, + ], + [locationId] + ); + +export default function LocationDetailsPage({ + params, +}: { + params: { location_id: string }; +}) { + const locationId = params.location_id; + + const locationIdFilter = useLocationIdFilter(locationId); + const { data, error } = useQuery({ + query: locationId ? GET_LOCATION : null, + variables: { locationId }, + typename, + }); + + if (error) { + console.error(error); + } + + if (!data) { + // todo: loading spinner (would be nice to use a spinner inside cards) + return; + } + + if (data.length === 0) { + // 404 + notFound(); + } + + const location = data[0]; + + return ( + <> + + {location.description} + + + + + + Promise.resolve()} + record={location} + /> + + + + + + Crashes + + + + + + + + ); +} diff --git a/app/app/locations/page.tsx b/app/app/locations/page.tsx new file mode 100644 index 000000000..c899bd506 --- /dev/null +++ b/app/app/locations/page.tsx @@ -0,0 +1,26 @@ +"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"; + +const localStorageKey = "locationsListViewQueryConfig"; + +export default function Locations() { + return ( + <> + + + Locations + + + + + + ); +} diff --git a/app/app/not-found.tsx b/app/app/not-found.tsx new file mode 100644 index 000000000..0ec1a5b5c --- /dev/null +++ b/app/app/not-found.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import Button from "react-bootstrap/Button"; +import Image from "react-bootstrap/Image"; +import Col from "react-bootstrap/Col"; +import Row from "react-bootstrap/Row"; + +export default function NotFound() { + return ( +
+ + + 404 + + + + +

Page Not Found

+

+ Sorry, we couldn't find the page you were looking for. +

+
+ + + +
+ +
+
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 000000000..df57cec70 --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,5 @@ +// this page redirects to /crashes +// todo: what should go here +export default function Page() { + return

hello

; +} diff --git a/app/app/upload-non-cr3/page.tsx b/app/app/upload-non-cr3/page.tsx new file mode 100644 index 000000000..ffedee11f --- /dev/null +++ b/app/app/upload-non-cr3/page.tsx @@ -0,0 +1,261 @@ +"use client"; +import { useState, useCallback } from "react"; +import Link from "next/link"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import Alert from "react-bootstrap/Alert"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Col from "react-bootstrap/Col"; +import Form from "react-bootstrap/Form"; +import Row from "react-bootstrap/Row"; +import Spinner from "react-bootstrap/Spinner"; +import Table from "react-bootstrap/Table"; +import Papa, { ParseResult } from "papaparse"; +import { ZodError } from "zod"; +import AlignedLabel from "@/components/AlignedLabel"; +import { useMutation } from "@/utils/graphql"; +import { INSERT_NON_CR3 } from "@/queries/nonCr3s"; +import { + NonCr3Upload, + NonCr3UploadDedupedSchema, + NonCr3ValidationError, +} from "@/types/nonCr3"; +import { + FaFileCsv, + FaCircleCheck, + FaCircleInfo, + FaTriangleExclamation, +} from "react-icons/fa6"; + +const MAX_ERRORS_TO_DISPLAY = 50; + +export default function UploadNonCr3() { + const [parsing, setParsing] = useState(false); + const [data, setData] = useState(null); + const [validationErrors, setValidationErrors] = useState< + NonCr3ValidationError[] | null + >(); + const [uploadError, setUploadError] = useState(false); + const [success, setSuccess] = useState(false); + const { mutate, loading: isMutating } = useMutation(INSERT_NON_CR3); + + const onSelectFile = useCallback( + (file: File) => { + Papa.parse(file, { + delimiter: ",", + header: true, + skipEmptyLines: true, + complete: (results: ParseResult) => { + if (results.errors.length > 0) { + // handle CSV parsing errors + const formattedErrors: NonCr3ValidationError[] = results.errors.map( + (issue) => ({ + rowNumber: (issue.row || 0) + 1, + fieldName: "", + message: issue.message, + }) + ); + setValidationErrors(formattedErrors); + } else { + // CSV has been parsed — run schema validations + console.log(results.data); + try { + const parsedData: NonCr3Upload[] = + NonCr3UploadDedupedSchema.parse(results.data); + setData(parsedData); + } catch (err) { + if (err instanceof ZodError) { + const formattedErrors = err.issues.map((issue) => { + const [rowNumber, fieldName] = issue.path; + const message = issue.message; + return { + rowNumber: Number(rowNumber) + 1, + fieldName: String(fieldName), + message, + }; + }); + setValidationErrors(formattedErrors); + } + } + } + setParsing(false); + }, + }); + }, + [setValidationErrors, setParsing, setData] + ); + + const onUpload = async () => { + try { + await mutate({ objects: data }); + setSuccess(true); + } catch (err) { + console.error(err); + setUploadError(true); + setSuccess(false); + } + }; + + return ( + <> + + + + Upload Non-CR3 records + + + {!data && ( + + +
e.preventDefault()}> + ) => { + setSuccess(false); + if (validationErrors) { + setValidationErrors(null); + } + if (e.target?.files && e.target?.files.length > 0) { + setParsing(true); + onSelectFile(e.target?.files[0]); + // reset file input + e.target.value = ""; + } else { + setParsing(false); + } + }} + /> + + + + + + Download CSV template + + +
+ )} + {data && + !validationErrors && + !uploadError && + !success && + !isMutating && ( + <> + + + + + + {`${data.length.toLocaleString()} records will be imported`} + + + + + + + + + + + )} + + + {parsing && ( + + + Processing... + + )} + {!validationErrors && isMutating && ( + + + Uploading records... + + )} + {success && ( + + + + Records successfully imported + + + )} + {validationErrors && ( + + + + + Your file is invalid — please correct the below errors and + try again. + + + + )} + {uploadError && ( + + + + + There was an error uploading your file - please try again. + + + + )} + {validationErrors && ( + + + + + + + + + + {validationErrors + .slice(0, MAX_ERRORS_TO_DISPLAY) + .map(({ fieldName, rowNumber, message }, i) => ( + + {/* if dupes are detected, rowNumber will be NaN and the fieldname will be the string literal `"undefined"` */} + + + + + ))} + {validationErrors.length > MAX_ERRORS_TO_DISPLAY && ( + + + + )} + +
Row #FieldError
{isNaN(rowNumber) ? "" : rowNumber}{fieldName === "undefined" ? "" : fieldName}{message}
{`${ + validationErrors.length - MAX_ERRORS_TO_DISPLAY + } more errors not shown`}
+ )} + +
+
+
+ + ); +} diff --git a/app/app/users/[user_id]/page.tsx b/app/app/users/[user_id]/page.tsx new file mode 100644 index 000000000..17a422bcf --- /dev/null +++ b/app/app/users/[user_id]/page.tsx @@ -0,0 +1,183 @@ +"use client"; +import { useState, useCallback } from "react"; +import { notFound, useRouter } from "next/navigation"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Col from "react-bootstrap/Col"; +import Row from "react-bootstrap/Row"; +import Spinner from "react-bootstrap/Spinner"; +import Table from "react-bootstrap/Table"; +import { FaUserEdit, FaUserAltSlash } from "react-icons/fa"; +import AlignedLabel from "@/components/AlignedLabel"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import UserModal from "@/components/UserModal"; +import { useToken, formatRoleName } from "@/utils/auth"; +import { useUser } from "@/utils/users"; +import { User } from "@/types/users"; +import { formatDateTime } from "@/utils/formatters"; + +type UserColumn = { + name: keyof User; + label: string; + renderer?: (user: User) => string; +}; + +// todo: this can be extended and used for the user list table column sorting, etc +const COLUMNS: UserColumn[] = [ + { + name: "app_metadata", + label: "Role", + renderer: (user) => formatRoleName(user.app_metadata.roles[0]) || "", + }, + { name: "name", label: "Name" }, + { name: "email", label: "Email" }, + { name: "last_ip", label: "Last IP address" }, + { name: "logins_count", label: "Login count" }, + { + name: "created_at", + label: "Created at", + renderer: (user) => formatDateTime(user.created_at), + }, + { + name: "last_login", + label: "Last login", + renderer: (user) => formatDateTime(user.last_login), + }, + { + name: "updated_at", + label: "Updated at", + renderer: (user) => formatDateTime(user.updated_at), + }, +]; + +export default function UserDetails({ + params, +}: { + params: { user_id: string }; +}) { + const router = useRouter(); + const token = useToken(); + const userId = params.user_id; + const { data: user, mutate } = useUser(userId, token); + const [showEditUserModal, setShowEditUserModal] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const onCloseModal = () => setShowEditUserModal(false); + + const onUpdateUserCallback = useCallback(async () => { + // refetch the user details and close + await mutate(); + setShowEditUserModal(false); + }, [mutate]); + + const onDeleteUser = useCallback(async () => { + setIsDeleting(true); + const url = `${ + process.env.NEXT_PUBLIC_CR3_API_DOMAIN + }/user/delete_user/${encodeURIComponent(userId)}`; + const method = "DELETE"; + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method, + }); + if (!response.ok) { + // the API may not return JSON for this endpoint — unclear from Auth0 docs + // auth0 api docs: https://auth0.com/docs/api/management/v2/users/delete-users-by-id + // very low priority todo: implement better error handling + const responseText = await response.text(); + console.error(responseText); + window.alert(`Failed to delete user: ${String(responseText)}`); + } else { + router.push(`/users`); + } + } catch (err) { + console.error(err); + window.alert(`Failed to delete user: An unknown error has occured`); + } + setIsDeleting(false); + }, [router, token, userId]); + + if (user && "error" in user) { + // 404 + notFound(); + } + + return ( + <> + + + + + User details + + {!user && } +
+ {user && ( + <> + + + + )} +
+ {user && ( + + + {COLUMNS.map((col) => ( + + + + + ))} + +
{col.label} + {col.renderer + ? col.renderer(user) + : String(user[col.name] || "")} +
+ )} +
+
+ +
+ {user && ( + + )} + + ); +} diff --git a/app/app/users/page.tsx b/app/app/users/page.tsx new file mode 100644 index 000000000..86a882c68 --- /dev/null +++ b/app/app/users/page.tsx @@ -0,0 +1,136 @@ +"use client"; +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Alert from "react-bootstrap/Alert"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Spinner from "react-bootstrap/Spinner"; +import Table from "react-bootstrap/Table"; +import { FaUserPlus, FaCopy } from "react-icons/fa6"; +import AlignedLabel from "@/components/AlignedLabel"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import UserModal from "@/components/UserModal"; +import { useUsersInfinite } from "@/utils/users"; +import { User } from "@/types/users"; +import { useToken, formatRoleName } from "@/utils/auth"; + +export default function Users() { + const token = useToken(); + const router = useRouter(); + const { users, isLoading, isValidating, error, mutate } = + useUsersInfinite(token); + const [showNewUserModal, setShowNewUserModal] = useState(false); + const onCloseModal = () => setShowNewUserModal(false); + + if (error) { + console.error(error); + } + + const onSaveUserCallback = useCallback( + async (user: User) => { + // refetch the entire user list in the background (do not await) + mutate(); + // navigate to the user details pagee + router.push(`/users/${user.user_id}`); + }, + [router, mutate] + ); + + return ( + <> + + + Users + +
+ {!isLoading && ( + <> + + + {/* show the spinner when revalidating - this is important user feedback after a + user has been deleted and the user list is being refetched */} + {isValidating && ( + + + + )} + + )} +
+ {/* todo: standardize the way we show error messages and use error boundary */} + {Boolean(error) && ( + +

Something went wrong

+

+

+ Error + {String(error)} +
+

+
+ )} + + + + + + + + + + + + + {isLoading && ( + + + + )} + {users.map((user) => { + return ( + router.push(`/users/${user.user_id}`)} + > + + + + + + + + ); + })} + +
NameEmailCreated atLast loginLogin countRole
+ +
{user.name}{user.email}{new Date(user.created_at).toLocaleDateString()} + {user.last_login + ? new Date(user.last_login).toLocaleDateString() + : ""} + {user.logins_count || "0"}{formatRoleName(user.app_metadata.roles[0])}
+
+
+ + + ); +} diff --git a/app/components/AlignedLabel.tsx b/app/components/AlignedLabel.tsx new file mode 100644 index 000000000..688245f13 --- /dev/null +++ b/app/components/AlignedLabel.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; + +/** + * Component that renders vertically-aligned children and prevents + * text wrapping. Use this to label a button with an icon and text. + */ +export default function AlignedLabel({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/app/components/AppBreadCrumb.tsx b/app/components/AppBreadCrumb.tsx new file mode 100644 index 000000000..4d2b92a43 --- /dev/null +++ b/app/components/AppBreadCrumb.tsx @@ -0,0 +1,74 @@ +import { useMemo, Fragment } from "react"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; + +interface Crumb { + label: string; + type: "page" | "id"; +} + +/** + * Hook that parses the current path into an array of usable crumbs + */ +const useCrumbs = (path: string): Crumb[] => + useMemo(() => { + const [, ...parts] = path.split("/"); + const crumbs: Crumb[] = []; + if (parts.length === 0) { + // we are at root + return crumbs; + } + crumbs.push({ + // if we don't remove the query string, nextjs can hit a server/client mismatch on login + // todo: this can't be the right way to fix this + // todo: test if still an issue with app router + label: parts[0].split("?")[0], + type: "page", + }); + if (parts.length > 1) { + crumbs.push({ + label: decodeURI(parts[1].split("?")[0]), + type: "id", + }); + } + // only two levels deep supported + return crumbs; + }, [path]); + +/** + * Bread crumb component - this is a proof of concept + * and needs more attention + */ +export default function AppBreadCrumb() { + const pathName = usePathname(); + const crumbs = useCrumbs(pathName); + const isDetailsPage = crumbs?.length > 1; + + return ( + + + {isDetailsPage && + crumbs?.map((crumb, i) => { + if (i < crumbs.length - 1) { + return ( + + + {crumb.label} + + {"/"} + + ); + } else { + return ( + + {crumb.label} + + ); + } + })} + + + ); +} diff --git a/app/components/AppNavBar.tsx b/app/components/AppNavBar.tsx new file mode 100644 index 000000000..47654a23f --- /dev/null +++ b/app/components/AppNavBar.tsx @@ -0,0 +1,100 @@ +import Container from "react-bootstrap/Container"; +import Navbar from "react-bootstrap/Navbar"; +import Image from "react-bootstrap/Image"; +import Dropdown from "react-bootstrap/Dropdown"; +import { User } from "@auth0/auth0-react"; +import { + FaRightFromBracket, + FaUserLarge, + FaBug, + FaLightbulb, + FaToolbox, + FaSquareArrowUpRight, +} from "react-icons/fa6"; +import DropdownAnchorToggle from "./DropdownAnchorToggle"; +import AlignedLabel from "./AlignedLabel"; + +type NavBarProps = { + user: User; + logout: () => void; +}; + +/** + * App navbar with branding and a right-aligned dropdown menu + */ +export default function AppNavBar({ user, logout }: NavBarProps) { + return ( + + + + Vision Zero Logo + + + + Vision Zero Logo + + + {user.email} + + + + + Account + + + logout()}> + + + Sign out + + + + + + + Report a bug + + + + + + + Request an enhancement + + + + + + + CR3 code sheet + + + + + + + + ); +} diff --git a/app/components/ChangeDetailHeader.tsx b/app/components/ChangeDetailHeader.tsx new file mode 100644 index 000000000..df190b364 --- /dev/null +++ b/app/components/ChangeDetailHeader.tsx @@ -0,0 +1,23 @@ +import { ChangeLogEntryEnriched } from "@/types/changeLog"; +import { formatDateTime } from "@/utils/formatters"; + +/** + * Header component of the change details table + */ +const ChangeDetailHeader = ({ + selectedChange, +}: { + selectedChange: ChangeLogEntryEnriched; +}) => { + return ( +
+ {`${selectedChange.record_type}`}{" "} + {selectedChange.operation_type} + {" | "} {selectedChange.created_by} + {" | "} + {formatDateTime(selectedChange.created_at)} +
+ ); +}; + +export default ChangeDetailHeader; diff --git a/app/components/ChangeLog.tsx b/app/components/ChangeLog.tsx new file mode 100644 index 000000000..57e14a21b --- /dev/null +++ b/app/components/ChangeLog.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from "react"; +import Card from "react-bootstrap/Card"; +import Table from "react-bootstrap/Table"; +import { formatDateTime } from "@/utils/formatters"; +import ChangeLogDetails from "./ChangeLogDetails"; +import { + ChangeLogEntry, + ChangeLogDiff, + ChangeLogEntryEnriched, +} from "@/types/changeLog"; + +const KEYS_TO_IGNORE = ["updated_at", "updated_by", "position"]; + +/** + * Return an array of values that are different between the `old` object and `new` object + * Each object in the array takes the format + * { field: , old: , new: } + */ +const getDiffArray = >( + new_: T, + old: T | null +): ChangeLogDiff[] => { + const diffArray = Object.keys(new_).reduce((diffs, key) => { + if (new_[key] !== old?.[key] && !KEYS_TO_IGNORE.includes(key)) { + diffs.push({ + field: key, + old: old?.[key] || null, + new: new_[key], + }); + } + return diffs; + }, []); + + // Sort array of entries by field name + diffArray.sort((a, b) => + a.field < b.field ? -1 : a.field > b.field ? 1 : 0 + ); + + return diffArray; +}; +/** + * Prettify the user name if it is `cris` + */ +const formatUserName = (userName: string): string => + userName === "cris" ? "TxDOT CRIS" : userName; + +/** + * Hook that adds `diffs` and `affected_fields` to the each ChangeLogEntry + */ +const useChangeLogData = (logs: ChangeLogEntry[]): ChangeLogEntryEnriched[] => + useMemo(() => { + if (!logs) { + return []; + } + return logs.map((change) => { + const newChange: ChangeLogEntryEnriched = { + ...change, + diffs: [], + affected_fields: [], + }; + newChange.diffs = getDiffArray( + change.record_json.new, + change.record_json.old + ); + newChange.affected_fields = newChange.diffs.map((diff) => diff.field); + change.created_by = formatUserName(change.created_by); + return newChange; + }); + }, [logs]); + +const isNewRecordEvent = (change: ChangeLogEntryEnriched) => + change.operation_type === "create"; + +/** + * Primary UI component which renders the change log with clickable rows + */ +export default function ChangeLog({ logs }: { logs: ChangeLogEntry[] }) { + const [selectedChange, setSelectedChange] = + useState(null); + + const changes = useChangeLogData(logs); + + return ( + + Record history + + + + + + + + + + + + + {changes.length === 0 && ( + // this should only happen in local dev where change log is not downloaded from replica + + + + )} + {changes.map((change) => ( + setSelectedChange(change)} + style={{ cursor: "pointer" }} + > + + + + + + + ))} + +
Record typeEventAffected fieldsEdited byDate
+ No changes found +
{change.record_type}{change.operation_type} + {isNewRecordEvent(change) + ? "" + : change.affected_fields.join(", ")} + {change.created_by}{formatDateTime(change.created_at)}
+ {/* Modal with change details table */} + {selectedChange && ( + + )} +
+
+ ); +} diff --git a/app/components/ChangeLogDetails.tsx b/app/components/ChangeLogDetails.tsx new file mode 100644 index 000000000..703dd989e --- /dev/null +++ b/app/components/ChangeLogDetails.tsx @@ -0,0 +1,61 @@ +import { Dispatch, SetStateAction } from "react"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; +import Table from "react-bootstrap/Table"; +import ChangeDetailHeader from "./ChangeDetailHeader"; +import { ChangeLogEntryEnriched } from "@/types/changeLog"; + +const isNewRecordEvent = (change: ChangeLogEntryEnriched): boolean => + change.operation_type === "create"; + +/** + * Modal which renders change log details when a change log entry is clicked + */ +const ChangeLogDetails = ({ + selectedChange, + setSelectedChange, +}: { + selectedChange: ChangeLogEntryEnriched; + setSelectedChange: Dispatch>; +}) => { + return ( + setSelectedChange(null)} + size="xl" + > + + + + + + + + + {!isNewRecordEvent(selectedChange) && } + + + + + {selectedChange.diffs.map((diff) => ( + + + {!isNewRecordEvent(selectedChange) && ( + + )} + + + ))} + +
FieldPrevious valueNew value
{diff.field}{String(diff.old)}{String(diff.new)}
+
+ + + +
+ ); +}; + +export default ChangeLogDetails; diff --git a/app/components/CrashDiagramCard.tsx b/app/components/CrashDiagramCard.tsx new file mode 100644 index 000000000..a44741257 --- /dev/null +++ b/app/components/CrashDiagramCard.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import Card from "react-bootstrap/Card"; +import Image from "react-bootstrap/Image"; +import { Crash } from "@/types/crashes"; + +// import axios from "axios"; +// import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; + +const CR3_DIAGRAM_BASE_URL = process.env.NEXT_PUBLIC_CR3_DIAGRAM_BASE_URL!; + +export default function CrashDiagramCard({ crash }: { crash: Crash }) { + const [diagramError, setDiagramError] = useState(false); + + return ( + + Diagram + + {!diagramError && ( + crash diagram { + console.error("Error loading CR3 diagram image"); + setDiagramError(true); + }} + /> + )} + {diagramError && crash.is_temp_record && ( + + Crash diagrams are not available + for user-created crash records + + )} + {diagramError && !crash.is_temp_record && ( + +

+ The crash diagram is not available. Typically, this indicates + there was an error when processing this crash&aposs CR3 PDF. +

+

+ For additional assistance, you can  + + report a bug + + + . +

+
+ )} +
+ Something else here +
+ ); +} diff --git a/app/components/CrashHeader.tsx b/app/components/CrashHeader.tsx new file mode 100644 index 000000000..fadd24d18 --- /dev/null +++ b/app/components/CrashHeader.tsx @@ -0,0 +1,22 @@ +import { Crash } from "@/types/crashes"; +import CrashInjuryIndicators from "./CrashInjuryIndicators"; + +interface CrashHeaderProps { + crash: Crash; +} + +/** + * Crash details page header + */ +export default function CrashHeader({ crash }: CrashHeaderProps) { + return ( +
+ + {crash.address_primary} + + {crash.crash_injury_metrics_view && ( + + )} +
+ ); +} diff --git a/app/components/CrashInjuryIndicators.tsx b/app/components/CrashInjuryIndicators.tsx new file mode 100644 index 000000000..b0f4930bb --- /dev/null +++ b/app/components/CrashInjuryIndicators.tsx @@ -0,0 +1,64 @@ +import { CrashInjuryMetric } from "@/types/crashInjuryMetrics"; +import Badge from "react-bootstrap/Badge"; + +const InjuryBadge = ({ + label, + value, + className, +}: { + label: string; + value: number | null; + className?: string; +}) => { + return ( + + {label} + {/* todo: decided if/how we want to assign colors to these badges */} + + {value || 0} + + + ); +}; + +/** + * Component which renders crash injuries (fatal, serious, etc) in a row + * with labeled pills + */ +const CrashInjuryIndicators = ({ + injuries, +}: { + injuries: CrashInjuryMetric; +}) => { + return ( +
+ + + + + +
+ ); +}; + +export default CrashInjuryIndicators; diff --git a/app/components/CrashIsTemporaryBanner.tsx b/app/components/CrashIsTemporaryBanner.tsx new file mode 100644 index 000000000..aec70ab36 --- /dev/null +++ b/app/components/CrashIsTemporaryBanner.tsx @@ -0,0 +1,68 @@ +import { useRouter } from "next/navigation"; +import { useAuth0 } from "@auth0/auth0-react"; +import Alert from "react-bootstrap/Alert"; +import Button from "react-bootstrap/Button"; +import Spinner from "react-bootstrap/Spinner"; +import { FaTrash, FaTriangleExclamation } from "react-icons/fa6"; +import AlignedLabel from "./AlignedLabel"; +import { useMutation } from "@/utils/graphql"; +import { DELETE_CRIS_CRASH } from "@/queries/crash"; + +interface CrashIsTemporaryBannerProps { + crashId: number; +} + +/** + * Banner that alerts the user when viewing a "temporary" crash record, aka + * a crash record that was manually created through the UI + */ +export default function CrashIsTemporaryBanner({ + crashId, +}: CrashIsTemporaryBannerProps) { + const { user } = useAuth0(); + const { mutate, loading: isMutating } = useMutation(DELETE_CRIS_CRASH); + const router = useRouter(); + + return ( + + + + + This crash record was created by the Vision Zero team and serves as a + placeholder until the CR3 report is received from TxDOT. It may be + deleted at any time. + + + + + + + ); +} diff --git a/app/components/CrashLocationBanner.tsx b/app/components/CrashLocationBanner.tsx new file mode 100644 index 000000000..899d87049 --- /dev/null +++ b/app/components/CrashLocationBanner.tsx @@ -0,0 +1,32 @@ +import Alert from "react-bootstrap/Alert"; +import { FaTriangleExclamation } from "react-icons/fa6"; +import AlignedLabel from "./AlignedLabel"; + +interface CrashLocationBannerProps { + /** + * If the crash occurred on a private drive + */ + privateDriveFlag: boolean | null; +} + +/** + * Crash Location Banner is rendered if private flag drive is true or austin full purpose flag is false. + */ +export default function CrashLocationBanner({ + privateDriveFlag, +}: CrashLocationBannerProps) { + return ( + + + + + This crash is not included in Vision Zero statistical reporting + because{" "} + {privateDriveFlag + ? "it occurred on a private drive" + : "it is located outside of the Austin full purpose jurisdiction"} + + + + ); +} diff --git a/app/components/CrashMap.tsx b/app/components/CrashMap.tsx new file mode 100644 index 000000000..5b665f29e --- /dev/null +++ b/app/components/CrashMap.tsx @@ -0,0 +1,105 @@ +import { + useCallback, + useRef, + useEffect, + Dispatch, + SetStateAction, +} from "react"; +import MapGL, { + FullscreenControl, + NavigationControl, + Marker, + ViewStateChangeEvent, + MapRef, +} from "react-map-gl"; +import { DEFAULT_MAP_PAN_ZOOM, DEFAULT_MAP_PARAMS } from "@/configs/map"; +import "mapbox-gl/dist/mapbox-gl.css"; +import { MapAerialSourceAndLayer } from "./MapAerialSourceAndLayer"; + +export interface LatLon { + latitude: number | null; + longitude: number | null; +} + +interface CrashMapProps { + /** + * The initial latitude - used when not editing + */ + savedLatitude: number | null; + /** + * The initial longitude - used when not editing + */ + savedLongitude: number | null; + /** + * If the map is in edit mode + */ + isEditing: boolean; + /** + * The lat/lon coordinates that are saved while editing + */ + editCoordinates: LatLon; + setEditCoordinates: Dispatch>; +} + +/** + * Map component which renders an editable point marker + */ +export const CrashMap = ({ + savedLatitude, + savedLongitude, + isEditing, + editCoordinates, + setEditCoordinates, +}: CrashMapProps) => { + const mapRef = useRef(null); + + const onDrag = useCallback( + (e: ViewStateChangeEvent) => { + const latitude = e.viewState.latitude; + const longitude = e.viewState.longitude; + setEditCoordinates({ latitude, longitude }); + }, + [setEditCoordinates] + ); + + useEffect(() => { + if (!isEditing) { + // reset marker coords + setEditCoordinates({ + latitude: savedLatitude, + longitude: savedLongitude, + }); + } + }, [isEditing, setEditCoordinates, savedLatitude, savedLongitude]); + return ( + e.target.resize()} + onDrag={isEditing ? onDrag : undefined} + maxZoom={21} + > + + + {savedLatitude && savedLongitude && !isEditing && ( + + )} + {isEditing && ( + + )} + {/* add nearmap raster source and style */} + + + ); +}; diff --git a/app/components/CrashMapCard.tsx b/app/components/CrashMapCard.tsx new file mode 100644 index 000000000..d121d0df4 --- /dev/null +++ b/app/components/CrashMapCard.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import { CrashMap } from "@/components/CrashMap"; +import { useMutation } from "@/utils/graphql"; +import { LatLon } from "@/components/CrashMap"; + +interface CrashMapCardProps { + savedLatitude: number | null; + savedLongitude: number | null; + crashId: number; + onSaveCallback: () => Promise; + mutation: string; +} + +/** + * Card component that renders the crash map and edit controls + */ +export default function CrashMapCard({ + crashId, + savedLatitude, + savedLongitude, + onSaveCallback, + mutation, +}: CrashMapCardProps) { + const [isEditingCoordinates, setIsEditingCoordinates] = useState(false); + const [editCoordinates, setEditCoordinates] = useState({ + latitude: 0, + longitude: 0, + }); + const { mutate, loading: isMutating } = useMutation(mutation); + + return ( + + Location + + + + +
+
+ Geocode provider +
+
+ + {isEditingCoordinates && ( + + )} +
+
+
+
+ ); +} diff --git a/app/components/CrashRecommendationCard.tsx b/app/components/CrashRecommendationCard.tsx new file mode 100644 index 000000000..6fc56941e --- /dev/null +++ b/app/components/CrashRecommendationCard.tsx @@ -0,0 +1,299 @@ +import { useState, useCallback } from "react"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Form from "react-bootstrap/Form"; +import Spinner from "react-bootstrap/Spinner"; +import { useForm } from "react-hook-form"; +import { useQuery, useMutation } from "@/utils/graphql"; +import CrashRecommendationPartners from "./CrashRecommendationPartners"; +import { + RECOMMENDATION_STATUS_QUERY, + RECOMMENDATION_PARTNERS_QUERY, + INSERT_RECOMMENDATION_MUTATION, + UPDATE_RECOMMENDATION_MUTATION, +} from "@/queries/recommendations"; +import { + Recommendation, + RecommendationStatus, + RecommendationPartner, + CoordinationPartner, + RecommendationFormInputs, +} from "@/types/recommendation"; +import { useAuth0 } from "@auth0/auth0-react"; + +/** + * Compares the old vs new RecommendationPartner arrays and returns + * new arrays of partner data that can be used in graphql mutation + * @param recommendationId - the pk of the current recommendation, if we are editing + * @param partnersOld - array of RecommendationPartner[] objs + * @param partnersNew - array of RecommendationPartner's from the form data. it is + * expected that these objects may only have a `partner_id` property + * @returns [partnersToAdd, partnerPksToDelete] + */ +const getPartnerChanges = ( + recommendationId: number | null, + partnersOld: Partial[], + partnersNew: Partial[] +) => { + const partnersToAdd: Partial[] = []; + const partnerPksToDelete: number[] = []; + + if (partnersOld.length > 0) { + // check if any partners have been removed + partnersOld.forEach((partnerOld) => { + if ( + !partnersNew.find( + (partnerNew) => partnerNew.partner_id === partnerOld.partner_id + ) + ) { + // delete this partner - so grab it's PK + partnerPksToDelete.push(partnerOld.id!); + } + }); + } + + if (partnersNew.length > 0) { + // check if any partners need to be added + partnersNew.forEach((partnerNew) => { + if ( + !partnersOld.find( + (partnerOld) => partnerNew.partner_id === partnerOld.partner_id + ) + ) { + // add this partner + partnersToAdd.push({ + partner_id: partnerNew.partner_id, + ...(recommendationId && { + recommendation_id: recommendationId, + }), + }); + } + }); + } + return [partnersToAdd, partnerPksToDelete]; +}; + +interface CrashRecommendationCardProps { + recommendation: Recommendation | null; + crash_pk: number; + onSaveCallback: () => Promise; +} + +/** + * UI component for viewing and editing fatalitiy review + * board (FRB) recommendations + */ +export default function CrashRecommendationCard({ + recommendation, + crash_pk, + onSaveCallback, +}: CrashRecommendationCardProps) { + const { user } = useAuth0(); + const [isEditing, setIsEditing] = useState(false); + const [isMutating, setIsMutating] = useState(false); + const { data: statuses, isLoading: isLoadingStatuses } = + useQuery({ + query: isEditing ? RECOMMENDATION_STATUS_QUERY : null, + typename: "statuses", + }); + + const { data: partners, isLoading: isLoadingPartners } = + useQuery({ + query: isEditing ? RECOMMENDATION_PARTNERS_QUERY : null, + typename: "partners", + }); + + const { + register, + reset, + handleSubmit, + formState: { isDirty }, + setValue, + watch, + } = useForm({ + defaultValues: recommendation + ? { + rec_text: recommendation.rec_text, + rec_update: recommendation.rec_update, + recommendation_status_id: recommendation.recommendation_status_id, + crash_pk: recommendation.crash_pk, + recommendations_partners: + recommendation.recommendations_partners || [], + } + : { + rec_text: null, + rec_update: null, + recommendation_status_id: null, + crash_pk, + created_by: user?.email, + recommendations_partners: [], + }, + }); + + const { mutate } = useMutation( + recommendation + ? UPDATE_RECOMMENDATION_MUTATION + : INSERT_RECOMMENDATION_MUTATION + ); + + const onSave = useCallback( + async (data: RecommendationFormInputs) => { + setIsMutating(true); + // just use a generic object to make the data structuring easier + const payload: Record = data; + + const [partnersToAdd, partnerPksToDelete] = getPartnerChanges( + recommendation?.id || null, + recommendation?.recommendations_partners || [], + data.recommendations_partners || [] + ); + + let variables; + if (recommendation) { + delete payload.recommendations_partners; + variables = { + record: payload, + id: recommendation?.id, + partnersToAdd, + partnerPksToDelete, + }; + } else { + payload.recommendations_partners = { data: partnersToAdd }; + variables = { record: payload }; + } + await mutate(variables, { skip_updated_by_setter: true }); + await onSaveCallback(); + setIsMutating(false); + setIsEditing(false); + }, + [recommendation, mutate, onSaveCallback, setIsMutating, setIsEditing] + ); + + return ( + + Fatality Review Board recommendations + +
+ + Partners + {!isEditing && ( +

+ {recommendation?.recommendations_partners + ?.map( + (rec_partner) => + rec_partner.atd__coordination_partners_lkp + ?.coord_partner_desc + ) + .join(", ") || ""} +

+ )} + {isEditing && isLoadingPartners && } + {isEditing && partners && ( +
+ +
+ )} +
+ + Status + {isEditing && !isLoadingStatuses && statuses && ( + Number(v) || null, + })} + > + + {statuses.map((option) => ( + + ))} + + )} + {isEditing && isLoadingStatuses && } + {!isEditing && ( +

+ {recommendation?.atd__recommendation_status_lkp + ?.rec_status_desc || ""} +

+ )} +
+ + Recommendation + {isEditing && ( + v?.trim() || null, + })} + as="textarea" + rows={6} + autoFocus={true} + /> + )} + {!isEditing &&

{recommendation?.rec_text || ""}

} +
+ + Updates + {isEditing && ( + v?.trim() || null, + })} + as="textarea" + rows={6} + /> + )} + {!isEditing &&

{recommendation?.rec_update || ""}

} +
+
+
+ +
+ {!isEditing && ( + + )} + {isEditing && ( + + )} + {isEditing && ( + + )} +
+
+
+ ); +} diff --git a/app/components/CrashRecommendationPartners.tsx b/app/components/CrashRecommendationPartners.tsx new file mode 100644 index 000000000..ec1cb92f5 --- /dev/null +++ b/app/components/CrashRecommendationPartners.tsx @@ -0,0 +1,63 @@ +import Form from "react-bootstrap/Form"; +import ListGroup from "react-bootstrap/ListGroup"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; +import { + CoordinationPartner, + RecommendationPartner, +} from "@/types/recommendation"; +import { RecommendationFormInputs } from "@/types/recommendation"; + +interface CrashRecommendationPartnersProps { + partners: CoordinationPartner[]; + setValue: UseFormSetValue; + watch: UseFormWatch; +} + +/** + * Multiselect form component for editing crash recommendation + * partners + */ +export default function CrashRecommendationPartners({ + setValue, + watch, + partners, +}: CrashRecommendationPartnersProps) { + const selectedPartners = watch("recommendations_partners"); + + const togglePartner = (id: number, add: boolean) => { + let updated: Partial[] = selectedPartners || []; + if (add) { + updated.push({ partner_id: id }); + } else { + updated = updated.filter((partner) => partner.partner_id !== id) || null; + } + setValue("recommendations_partners", updated, { shouldDirty: true }); + }; + + return ( + + {partners.map((partner) => { + const checked = Boolean( + selectedPartners?.find((val) => val.partner_id === partner.id) + ); + return ( + { + e.preventDefault(); + togglePartner(partner.id, !checked); + }} + > + null} + style={{ pointerEvents: "none" }} + /> + + ); + })} + + ); +} diff --git a/app/components/CrashSwapAddressButton.tsx b/app/components/CrashSwapAddressButton.tsx new file mode 100644 index 000000000..496abb14a --- /dev/null +++ b/app/components/CrashSwapAddressButton.tsx @@ -0,0 +1,63 @@ +import Button from "react-bootstrap/Button"; +import { useMutation } from "@/utils/graphql"; +import AlignedLabel from "@/components/AlignedLabel"; +import { FaArrowRightArrowLeft } from "react-icons/fa6"; +import { HeaderActionButtonProps } from "@/components/DataCard"; + +/** + * Button on the primary address data card that allows users to swap + * the primary and secondary addresses of a crash + */ +export default function CrashSwapAddressButton< + T extends Record +>({ record, mutation, onSaveCallback }: HeaderActionButtonProps) { + // switching all primary and secondary address values here + const mutationVariables = { + rpt_block_num: record.rpt_sec_block_num, + rpt_street_name: record.rpt_sec_street_name, + rpt_street_desc: record.rpt_sec_street_desc, + rpt_road_part_id: record.rpt_sec_road_part_id, + rpt_rdwy_sys_id: record.rpt_sec_rdwy_sys_id, + rpt_hwy_num: record.rpt_sec_hwy_num, + rpt_street_pfx: record.rpt_sec_street_pfx, + rpt_street_sfx: record.rpt_sec_street_sfx, + rpt_sec_block_num: record.rpt_block_num, + rpt_sec_street_name: record.rpt_street_name, + rpt_sec_street_desc: record.rpt_street_desc, + rpt_sec_road_part_id: record.rpt_road_part_id, + rpt_sec_rdwy_sys_id: record.rpt_rdwy_sys_id, + rpt_sec_hwy_num: record.rpt_hwy_num, + rpt_sec_street_pfx: record.rpt_street_pfx, + rpt_sec_street_sfx: record.rpt_street_sfx, + }; + + const { mutate, loading: isMutating } = useMutation(mutation); + + return ( +
+ +
+ ); +} diff --git a/app/components/CreateCrashRecordModal.tsx b/app/components/CreateCrashRecordModal.tsx new file mode 100644 index 000000000..2d1c10dc3 --- /dev/null +++ b/app/components/CreateCrashRecordModal.tsx @@ -0,0 +1,387 @@ +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Form from "react-bootstrap/Form"; +import Modal from "react-bootstrap/Modal"; +import Spinner from "react-bootstrap/Spinner"; +import AlignedLabel from "@/components/AlignedLabel"; +import FormControlDatePicker from "@/components/FormControlDatePicker"; +import { FaCirclePlus, FaCircleMinus } from "react-icons/fa6"; +import { useForm, SubmitHandler, useFieldArray } from "react-hook-form"; +import { useQuery, useMutation } from "@/utils/graphql"; +import { UNIT_TYPES_QUERY } from "@/queries/unit"; +import { CREATE_CRIS_CRASH } from "@/queries/crash"; +import { LookupTableOption } from "@/types/relationships"; +import { Crash } from "@/types/crashes"; +import { useAuth0 } from "@auth0/auth0-react"; + +interface CreateCrashModalProps { + /** + * A callback fired when either the modal backdrop is clicked, or the + * escape key is pressed + */ + onClose: () => void; + /** + * Callback fired after the user form is successfully submitted + */ + onSubmitCallback: () => void; + /** + * If the modal should be visible or hidden + */ + show: boolean; +} + +type UnitInputs = { + unit_desc_id: string; + fatality_count: number; + injury_count: number; +}; + +type CrashInputs = { + case_id: string; + crash_timestamp: string; + created_by: string; + cris_schema_version: "2023"; + is_temp_record: boolean; + private_dr_fl: boolean; + rpt_city_id: number; + rpt_sec_street_name: string; + rpt_street_name: string; + units_cris: UnitInputs[]; + updated_by: string; +}; + +const DEFAULT_UNIT: UnitInputs = { + unit_desc_id: "", + fatality_count: 0, + injury_count: 0, +}; + +const DEFAULT_FORM_VALUES: CrashInputs = { + case_id: "", + crash_timestamp: "", + created_by: "", + cris_schema_version: "2023", + is_temp_record: true, + private_dr_fl: false, + rpt_city_id: 22, + rpt_sec_street_name: "", + rpt_street_name: "", + units_cris: [{ ...DEFAULT_UNIT }], + updated_by: "", +}; + +/** + * Construct a unit record with people records such + * that it can be included in a crash insert mutation + */ +const makeUnitRecord = (unit: UnitInputs, index: number, userEmail: string) => { + const unitNumber = index + 1; + + const unitRecord: Record = { + unit_nbr: unitNumber, + unit_desc_id: unit.unit_desc_id, + cris_schema_version: "2023", + updated_by: userEmail, + created_by: userEmail, + }; + + const people = []; + + // create fatal injuries + for (let i = 0; i < Number(unit.fatality_count); i++) { + const personRecord = { + prsn_nbr: i + 1, + unit_nbr: unitNumber, + prsn_injry_sev_id: 4, + cris_schema_version: "2023", + is_primary_person: true, + updated_by: userEmail, + created_by: userEmail, + }; + + people.push(personRecord); + } + + // create other injuries + for (let i = 0; i < Number(unit.injury_count); i++) { + people.push({ + prsn_nbr: unit.fatality_count + i + 1, + unit_nbr: unitNumber, + prsn_injry_sev_id: 1, + cris_schema_version: "2023", + is_primary_person: true, + updated_by: userEmail, + created_by: userEmail, + }); + } + + return { ...unitRecord, people_cris: { data: people } }; +}; + +/** + * Modal form component used for creating a temporary crash record + */ +export default function CreateCrashRecordModal({ + onClose, + onSubmitCallback, + show, +}: CreateCrashModalProps) { + const { user } = useAuth0(); + const { data: unitTypes, isLoading: isLoadingUnitTypes } = + useQuery({ + query: UNIT_TYPES_QUERY, + typename: "lookups_unit_desc", + }); + const { mutate, loading: isSubmitting } = useMutation(CREATE_CRIS_CRASH); + + const { + register, + handleSubmit, + control, + formState: { errors }, + reset, + } = useForm({ + defaultValues: { + ...DEFAULT_FORM_VALUES, + created_by: user?.email || "", + updated_by: user?.email || "", + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "units_cris", + }); + + const onSubmit: SubmitHandler = async (data) => { + const crash: Record = data; + crash.units_cris = { + data: data.units_cris.map((unit, i) => + makeUnitRecord(unit, i, user?.email || "") + ), + }; + + const responseData = await mutate<{ + insert_crashes_cris: { returning: Crash[] }; + }>({ crash }); + + if (responseData && responseData.insert_crashes_cris) { + onSubmitCallback(); + } + reset(); + }; + + return ( + { + reset(); + onClose(); + }} + animation={false} + backdrop="static" + > + + Create crash record + + +
+ {/* Case Id */} + + Case ID + + Required + + Case ID is required + + + {/* Crash date */} + + Crash date + + name="crash_timestamp" + control={control} + isInvalid={Boolean(errors?.crash_timestamp)} + {...{ rules: { required: true } }} + /> + + Crash date is required + + + {/* Primary address */} + + Primary address + v?.trim().toUpperCase() || null, + })} + autoComplete="off" + data-1p-ignore + placeholder="ex: W 4TH ST" + isInvalid={Boolean(errors.rpt_street_name)} + /> + + Primary address is required + + + {/* Secondary address */} + + Secondary address + v?.trim().toUpperCase() || null, + })} + autoComplete="off" + data-1p-ignore + placeholder="ex: 100 S CONGRESS AVE" + isInvalid={Boolean(errors.rpt_sec_street_name)} + /> + + {/* Repeatable unit inputs */} + {fields.map((field, index) => { + return ( + + + Unit + {fields.length > 1 && ( + + )} + + + + Unit type + {unitTypes && ( + Number(v) || null, + })} + isInvalid={Boolean( + errors.units_cris?.[index]?.unit_desc_id + )} + > + + {unitTypes.map((option) => ( + + ))} + + )} + + Unit type is required + + {isLoadingUnitTypes && } + + + Fatality count + {unitTypes && ( + + )} + + {errors?.units_cris?.[index]?.fatality_count?.message} + + + + Injury count + {unitTypes && ( + + )} + + {errors?.units_cris?.[index]?.injury_count?.message} + + + + + ); + })} +
+ +
+
+
+ + {!isSubmitting && ( + + )} + + +
+ ); +} diff --git a/app/components/DataCard.tsx b/app/components/DataCard.tsx new file mode 100644 index 000000000..0aa0de420 --- /dev/null +++ b/app/components/DataCard.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import Card from "react-bootstrap/Card"; +import Spinner from "react-bootstrap/Spinner"; +import Table from "react-bootstrap/Table"; +import DataCardInput from "@/components/DataCardInput"; +import { useMutation, useQuery, useLookupQuery } from "@/utils/graphql"; +import { + getRecordValue, + renderColumnValue, + valueToString, + handleFormValueOutput, +} from "@/utils/formHelpers"; +import { ColDataCardDef } from "@/types/types"; +import { LookupTableOption } from "@/types/relationships"; + +export interface HeaderActionButtonProps> { + record: T; + mutation: string; + onSaveCallback: () => Promise; +} + +interface DataCardProps> { + record: T; + columns: ColDataCardDef[]; + mutation: string; + isValidating: boolean; + title: string; + onSaveCallback: () => Promise; + HeaderActionButton?: React.ComponentType>; +} + +/** + * Generic component which renders editable fields in a Card + */ +export default function DataCard>({ + record, + columns, + mutation, + isValidating, + title, + onSaveCallback, + HeaderActionButton, +}: DataCardProps) { + // todo: loading state, error state + // todo: handling of null/undefined values in select input + const [editColumn, setEditColumn] = useState | null>(null); + const { mutate, loading: isMutating } = useMutation(mutation); + const [query, typename] = useLookupQuery( + editColumn?.editable && editColumn?.relationship + ? editColumn.relationship + : undefined + ); + + const { data: selectOptions, isLoading: isLoadingLookups } = + useQuery({ + query, + // we don't need to refetch lookup table options + options: { revalidateIfStale: false }, + typename, + }); + + const onSave = async (value: unknown) => { + if (!editColumn) { + // not possible + return; + } + // Save the value to the foreign key column, if exists + const saveColumnName = editColumn.relationship?.foreignKey + ? editColumn.relationship?.foreignKey + : editColumn.path; + await mutate({ + id: record.id, + updates: { + [saveColumnName]: value, + }, + }); + await onSaveCallback(); + setEditColumn(null); + }; + + const onCancel = () => setEditColumn(null); + + return ( + + + {title} + {HeaderActionButton && ( + + )} + + + + + {columns.map((col) => { + const isEditingThisColumn = col.path === editColumn?.path; + return ( + { + if (!col.editable) { + return; + } + if (!isEditingThisColumn) { + setEditColumn(col); + } + }} + > + + {!isEditingThisColumn && ( + + )} + {isEditingThisColumn && ( + + )} + + ); + })} + +
+ {col.label} + {renderColumnValue(record, col)} + {isLoadingLookups && } + {!isLoadingLookups && ( + + onSave( + handleFormValueOutput( + value, + !!col.relationship, + col.inputType + ) + ) + } + onCancel={onCancel} + inputType={col.inputType} + selectOptions={selectOptions} + isMutating={isMutating || isValidating} + /> + )} +
+
+
+ ); +} diff --git a/app/components/DataCardInput.tsx b/app/components/DataCardInput.tsx new file mode 100644 index 000000000..579f6996a --- /dev/null +++ b/app/components/DataCardInput.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import { InputType } from "@/types/types"; +import { LookupTableOption } from "@/types/relationships"; + +interface DataCardInputProps { + /** + * The initial value to populate the input + */ + initialValue: string; + /** + * If the input is in the process of mutating via API call + */ + isMutating: boolean; + /** + * Controls the type of input to be rendered + */ + inputType?: InputType; + /** + * Array of lookup table options that will populate a