From 98d8c0d644fd457e8a3b6a7498638246e664ac78 Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 17 Dec 2025 16:50:06 +0100 Subject: [PATCH] This commit will enable the users to edit their device details.. --- app/components/mydevices/dt/columns.tsx | 240 ++-- app/routes/device.$deviceId.edit.general.tsx | 532 ++++----- app/routes/device.$deviceId.edit.sensors.tsx | 1039 +++++++++--------- 3 files changed, 928 insertions(+), 883 deletions(-) diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index b61314e4..894baaca 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -1,59 +1,59 @@ -"use client"; +'use client' -import { type ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, ClipboardCopy, Ellipsis } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { type ColumnDef } from '@tanstack/react-table' +import { ArrowUpDown, ClipboardCopy, Ellipsis } from 'lucide-react' +import { Button } from '@/components/ui/button' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { type Device } from "~/schema"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu' +import { type Device } from '~/schema' export type SenseBox = { - id: string; - name: string; - exposure: Device["exposure"]; - // model: string; -}; + id: string + name: string + exposure: Device['exposure'] + // model: string; +} -const colStyle = "pl-0 dark:text-white"; +const colStyle = 'pl-0 dark:text-white' export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "exposure", - header: ({ column }) => { - return ( - - ); - }, - }, - /* { + { + accessorKey: 'name', + header: ({ column }) => { + return ( + + ) + }, + }, + { + accessorKey: 'exposure', + header: ({ column }) => { + return ( + + ) + }, + }, + /* { accessorKey: "model", header: ({ column }) => { return ( @@ -68,76 +68,76 @@ export const columns: ColumnDef[] = [ ); }, }, */ - { - accessorKey: "id", - header: () =>
Sensebox ID
, - cell: ({ row }) => { - const senseBox = row.original; + { + accessorKey: 'id', + header: () =>
Sensebox ID
, + cell: ({ row }) => { + const senseBox = row.original - return ( - //
-
- - {senseBox?.id} - - navigator.clipboard.writeText(senseBox?.id)} - className="ml-[6px] mr-1 inline-block h-4 w-4 align-text-bottom text-[#818a91] dark:text-white cursor-pointer" - /> -
- ); - }, - }, - { - id: "actions", - header: () =>
Actions
, - cell: ({ row }) => { - const senseBox = row.original; + return ( + //
+
+ + {senseBox?.id} + + navigator.clipboard.writeText(senseBox?.id)} + className="ml-[6px] mr-1 inline-block h-4 w-4 cursor-pointer align-text-bottom text-[#818a91] dark:text-white" + /> +
+ ) + }, + }, + { + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const senseBox = row.original - return ( - - - - - - Actions - - - Overview - - - Show on map - - - Edit - - - Data upload - - - - Support - - - navigator.clipboard.writeText(senseBox?.id)} - className="cursor-pointer" - > - Copy ID - - - - ); - }, - }, -]; + return ( + + + + + + Actions + + + Overview + + + Show on map + + + Edit + + + Data upload + + + + Support + + + navigator.clipboard.writeText(senseBox?.id)} + className="cursor-pointer" + > + Copy ID + + + + ) + }, + }, +] diff --git a/app/routes/device.$deviceId.edit.general.tsx b/app/routes/device.$deviceId.edit.general.tsx index af37f226..a58e162e 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -1,300 +1,300 @@ -import { Save } from "lucide-react"; -import React, { useState } from "react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, - data, - redirect, - Form, - useActionData, - useLoaderData, - useOutletContext } from "react-router"; -import invariant from "tiny-invariant"; -import ErrorMessage from "~/components/error-message"; +import { Save } from 'lucide-react' +import React, { useState } from 'react' import { - deleteDevice, - getDeviceWithoutSensors, - updateDeviceInfo, -} from "~/models/device.server"; -import { verifyLogin } from "~/models/user.server"; -import { getUserEmail, getUserId } from "~/utils/session.server"; + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, + redirect, + Form, + useActionData, + useLoaderData, + useOutletContext, +} from 'react-router' +import invariant from 'tiny-invariant' +import ErrorMessage from '~/components/error-message' +import { + deleteDevice, + getDeviceWithoutSensors, + updateDeviceInfo, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' +import { getUserEmail, getUserId } from '~/utils/session.server' //***************************************************** export async function loader({ request, params }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + //* if user is not logged in, redirect to home + const userId = await getUserId(request) + if (!userId) return redirect('/') - const deviceID = params.deviceId; + const deviceID = params.deviceId - if (typeof deviceID !== "string") { - return redirect("/profile/me"); - } + if (typeof deviceID !== 'string') { + return redirect('/profile/me') + } - const deviceData = await getDeviceWithoutSensors({ id: deviceID }); + const deviceData = await getDeviceWithoutSensors({ id: deviceID }) - return { device: deviceData }; + return { device: deviceData } } //***************************************************** export async function action({ request, params }: ActionFunctionArgs) { - const formData = await request.formData(); - const { intent, name, exposure, passwordDelete } = - Object.fromEntries(formData); - - const errors = { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: passwordDelete ? null : "Password is required.", - }; + const formData = await request.formData() + const { intent, name, exposure, passwordDelete } = + Object.fromEntries(formData) + const errors = { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: passwordDelete ? null : 'Password is required.', + } - const deviceID = params.deviceId; - invariant(typeof deviceID === "string", " Device id not found."); - invariant(typeof name === "string", "Device name is required."); - invariant(typeof exposure === "string", "Device name is required."); + const deviceID = params.deviceId + invariant(typeof deviceID === 'string', ' Device id not found.') + invariant(typeof name === 'string', 'Device name is required.') + invariant(typeof exposure === 'string', 'Device name is required.') - if ( - exposure !== "indoor" && - exposure !== "outdoor" && - exposure !== "mobile" && - exposure !== "unknown" - ) { - return data({ - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: errors.passwordDelete, - }, - status: 400, - }); - } + if ( + exposure !== 'indoor' && + exposure !== 'outdoor' && + exposure !== 'mobile' && + exposure !== 'unknown' + ) { + return data({ + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: errors.passwordDelete, + }, + status: 400, + }) + } - switch (intent) { - case "save": { - await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }); - return data({ - errors: { - exposure: null, - passwordDelete: null, - }, - status: 200, - }); - } - case "delete": { - //* check password validaty - if (errors.passwordDelete) { - return data({ - errors, - status: 400, - }); - } - //* 1. get user email - const userEmail = await getUserEmail(request); - invariant(typeof userEmail === "string", "email not found"); - invariant( - typeof passwordDelete === "string", - "password must be a string", - ); - //* 2. check entered password - const user = await verifyLogin(userEmail, passwordDelete); - //* 3. retrun error if password is not correct - if (!user) { - return data( - { - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: "Invalid password", - }, - }, - { status: 400 }, - ); - } - //* 4. delete device - await deleteDevice({ id: deviceID }); + switch (intent) { + case 'save': { + await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }) + return data({ + errors: { + exposure: null, + passwordDelete: null, + }, + status: 200, + }) + } + case 'delete': { + //* check password validaty + if (errors.passwordDelete) { + return data({ + errors, + status: 400, + }) + } + //* 1. get user email + const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') + invariant(typeof passwordDelete === 'string', 'password must be a string') + //* 2. check entered password + const user = await verifyLogin(userEmail, passwordDelete) + //* 3. retrun error if password is not correct + if (!user) { + return data( + { + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: 'Invalid password', + }, + }, + { status: 400 }, + ) + } + //* 4. delete device + await deleteDevice({ id: deviceID }) - return redirect("/profile/me"); - } - } + return redirect('/profile/me') + } + } - return redirect(""); + return redirect('') } //********************************** export default function () { - const { device } = useLoaderData(); - const actionData = useActionData(); - const [passwordDelVal, setPasswordVal] = useState(""); //* to enable delete account button - //* focus when an error occured - const nameRef = React.useRef(null); - const passwordDelRef = React.useRef(null); - const [name, setName] = useState(device?.name); - const [exposure, setExposure] = useState(device?.exposure); - //* to view toast on edit page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>(); - - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ); + const { device } = useLoaderData() + const actionData = useActionData() + const [passwordDelVal, setPasswordVal] = useState('') //* to enable delete account button + //* focus when an error occured + const nameRef = React.useRef(null) + const passwordDelRef = React.useRef(null) + const [name, setName] = useState(device?.name) + const [exposure, setExposure] = useState(device?.exposure) + //* to view toast on edit page + const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() - //* when device data updated successfully - if (!hasErrors) { - setToastOpen(true); - // setToastOpenTest(true); - } - //* when password is null - else if (hasErrors && actionData?.errors?.passwordDelete) { - passwordDelRef.current?.focus(); - } - } - }, [actionData, setToastOpen]); + React.useEffect(() => { + if (actionData) { + const hasErrors = Object.values(actionData?.errors).some( + (errorMessage) => errorMessage, + ) - return ( -
- {/* general form */} -
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

General

-
-
- -
-
-
+ //* when device data updated successfully + if (!hasErrors) { + setToastOpen(true) + // setToastOpenTest(true); + } + //* when password is null + else if (hasErrors && actionData?.errors?.passwordDelete) { + passwordDelRef.current?.focus() + } + } + }, [actionData, setToastOpen]) - {/* divider */} -
+ return ( +
+ {/* general form */} +
+
+ {/* Form */} + + {/* Heading */} +
+ {/* Title */} +
+
+

General

+
+
+ +
+
+
-
- {/* */} - {/* Name */} -
- + {/* divider */} +
-
- setName(e.target.value)} - ref={nameRef} - aria-describedby="name-error" - className="w-full rounded border border-gray-200 px-2 py-1 text-base" - /> -
-
+
+ {/* */} + {/* Name */} +
+ - {/* Exposure */} -
- +
+ setName(e.target.value)} + ref={nameRef} + aria-describedby="name-error" + className="w-full rounded border border-gray-200 px-2 py-1 text-base" + /> +
+
-
- -
-
+ {/* Exposure */} +
+ + {/* changed the case of the option values to lowercase as the + server expects lowercase */} +
+ +
+
- {/* Delete device */} -
-

- Delete senseBox -

-
+ {/* Delete device */} +
+

+ Delete senseBox +

+
-
-

- If you really want to delete your station, please type your - current password - all measurements will be deleted as well. -

-
-
- setPasswordVal(e.target.value)} - /> - {actionData?.errors?.passwordDelete && ( -
- {actionData.errors.passwordDelete} -
- )} -
- {/* Delete button */} -
- -
- {/* */} -
- -
-
-
- ); +
+

+ If you really want to delete your station, please type your + current password - all measurements will be deleted as well. +

+
+
+ setPasswordVal(e.target.value)} + /> + {actionData?.errors?.passwordDelete && ( +
+ {actionData.errors.passwordDelete} +
+ )} +
+ {/* Delete button */} +
+ +
+ {/* */} +
+ +
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/routes/device.$deviceId.edit.sensors.tsx b/app/routes/device.$deviceId.edit.sensors.tsx index b7f84414..acd530e4 100644 --- a/app/routes/device.$deviceId.edit.sensors.tsx +++ b/app/routes/device.$deviceId.edit.sensors.tsx @@ -1,517 +1,562 @@ import { - ChevronDownIcon, - Trash2, - ClipboardCopy, - Edit, - Plus, - Save, - Undo2, - X, -} from "lucide-react"; -import React, { useState } from "react"; -import { redirect , Form, useActionData, useLoaderData, useOutletContext, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; -import invariant from "tiny-invariant"; + ChevronDownIcon, + Trash2, + ClipboardCopy, + Edit, + Plus, + Save, + Undo2, + X, +} from 'lucide-react' +import React, { useState, useCallback } from 'react' import { - DropdownMenu, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import ErrorMessage from "~/components/error-message"; + redirect, + Form, + useActionData, + useLoaderData, + useOutletContext, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router' +import invariant from 'tiny-invariant' import { - addNewSensor, - deleteSensor, - getSensorsFromDevice, - updateSensor, -} from "~/models/sensor.server"; -import { assignIcon, getIcon, iconsList } from "~/utils/sensoricons"; -import { getUserId } from "~/utils/session.server"; + DropdownMenu, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import ErrorMessage from '~/components/error-message' +import { + addNewSensor, + deleteSensor, + getSensorsFromDevice, + updateSensor, +} from '~/models/sensor.server' +import { assignIcon, getIcon, iconsList } from '~/utils/sensoricons' +import { getUserId } from '~/utils/session.server' + +// Type for sensor data with editing state +interface SensorData { + id?: string + title?: string + unit?: string + sensorType?: string + icon?: string + editing?: boolean + edited?: boolean + new?: boolean + deleted?: boolean + deleting?: boolean + notValidInput?: boolean +} //***************************************************** export async function loader({ request, params }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + const userId = await getUserId(request) + if (!userId) return redirect('/') - const deviceID = params.deviceId; - if (typeof deviceID !== "string") { - return "deviceID not found"; - } - const rawSensorsData = await getSensorsFromDevice(deviceID); + const deviceID = params.deviceId + if (typeof deviceID !== 'string') { + return 'deviceID not found' + } + const rawSensorsData = await getSensorsFromDevice(deviceID) - return rawSensorsData as any; + return rawSensorsData as SensorData[] } //***************************************************** export async function action({ request, params }: ActionFunctionArgs) { - //* ToDo: upadte it to include button clicks inside form - const formData = await request.formData(); - const { updatedSensorsData } = Object.fromEntries(formData); - - if (typeof updatedSensorsData !== "string") { - return { isUpdated: false }; - } - const updatedSensorsDataJson = JSON.parse(updatedSensorsData); - - for (const sensor of updatedSensorsDataJson) { - if (sensor?.new === true && sensor?.edited === true) { - const deviceID = params.deviceId; - invariant(deviceID, `deviceID not found!`); - - await addNewSensor({ - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - deviceId: deviceID, - }); - } else if (sensor?.edited === true) { - await updateSensor({ - id: sensor.id, - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - // icon: sensor.icon, - }); - } else if (sensor?.deleted === true) { - await deleteSensor(sensor.id); - } - } - - return { isUpdated: true }; + const formData = await request.formData() + const { updatedSensorsData } = Object.fromEntries(formData) + + if (typeof updatedSensorsData !== 'string') { + return { isUpdated: false } + } + const updatedSensorsDataJson = JSON.parse(updatedSensorsData) as SensorData[] + + for (const sensor of updatedSensorsDataJson) { + if (sensor?.new === true && sensor?.edited === true) { + const deviceID = params.deviceId + invariant(deviceID, `deviceID not found!`) + + await addNewSensor({ + title: sensor.title!, + unit: sensor.unit!, + sensorType: sensor.sensorType!, + deviceId: deviceID, + }) + } else if (sensor?.edited === true) { + await updateSensor({ + id: sensor.id!, + title: sensor.title!, + unit: sensor.unit!, + sensorType: sensor.sensorType!, + }) + } else if (sensor?.deleted === true) { + await deleteSensor(sensor.id!) + } + } + + return { isUpdated: true } } //********************************** export default function EditBoxSensors() { - const data = useLoaderData(); - const actionData = useActionData(); - - const [sensorsData, setSensorsData] = useState(data); - - /* temp impl. until figuring out how to updating state of nested objects */ - const [tepmState, setTepmState] = useState(false); - //* to view toast on edit-page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>(); - - React.useEffect(() => { - //* if sensors data were updated successfully - if (actionData && actionData?.isUpdated) { - //* show notification when data is successfully updated - setToastOpen(true); - // window.location.reload(); - //* reset sensor data elements - for (let index = 0; index < sensorsData.length; index++) { - const sensor = sensorsData[index]; - if (sensor.new == true && sensor.notValidInput == true) { - sensorsData.splice(index, 1); - } else if (sensor.deleted) { - sensorsData.splice(index, 1); - } else if (sensor.new == true && sensor.notValidInput == true) { - sensorsData.splice(index, 1); - } else if (sensor.editing == true) { - delete sensor.editing; - } - } - } - }, [actionData, sensorsData, setToastOpen]); - - return ( -
- {/* sensor form */} -
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

Sensor

-
-
- {/* Add button */} - - {/* Save button */} - -
-
-
- - {/* divider */} -
- -
-

- Data measured by sensors that you are going to delete will be - deleted as well. If you add new sensors, don't forget to - retrieve your new script (see tab 'Script'). -

-
- -
    - {sensorsData?.map((sensor: any, index: number) => { - return ( -
  • -
    - {/* left side -> sensor icons list */} -
    - {sensor?.editing ? ( - -
    - {/* view icon */} - - - {/* down arrow icon */} - - - - - - - {iconsList?.map((icon: any) => { - const Icon = icon.name; - return ( - { - setTepmState(!tepmState); - sensor.icon = icon.id; - }} - > - - - ); - })} - - - -
    -
    - ) : ( - - {sensor.icon - ? getIcon(sensor.icon) - : assignIcon(sensor.sensorType, sensor.title)} - - )} -
    - {/* middle -> sensor attributes */} -
    - {/* shown by default */} - {!sensor?.editing && ( - - - Phenomenon: - - {sensor?.title} - - - ID: - - {sensor?.id} - - - - Unit: - - {sensor?.unit} - - - - Type: - - {sensor?.sensorType} - - - - )} - - {/* shown when edit button clicked */} - {sensor?.editing && ( -
    -
    - - { - setTepmState(!tepmState); - sensor.title = e.target.value; - if (sensor.title.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - - { - setTepmState(!tepmState); - sensor.sensorType = e.target.value; - if (sensor.sensorType.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - - { - setTepmState(!tepmState); - sensor.unit = e.target.value; - if (sensor.unit.length === 0) { - sensor.notValidInput = true; - } else { - sensor.notValidInput = false; - } - }} - /> -
    -
    - )} -
    - - {/* right side -> Save, delete, cancel buttons */} -
    - {/* buttons shown by default */} - - {/* warning text - delete */} - {sensor?.deleting && ( - - This sensor will be deleted. - - )} - - {/* undo button */} - {sensor?.deleting && ( - - )} - - {!sensor?.editing && !sensor?.deleting && ( - - {/* edit button */} - {/* ToDo: why onClick not updating the state unless dummy unrelated state is updated */} - - - {/* delete button */} - - - )} - - - {sensor?.editing && ( - - {/* invalid input text */} - {sensor?.notValidInput && ( - - Please fill out all required fields. - - )} - - {/* save button */} - - - {/* cancel button */} - - - )} -
    -
    -
  • - ); - })} -
- - {/* As there's no way to send data wiht form on submit to action (see: https://github.com/remix-run/react-router/discussions/10264) */} - -
-
-
-
- ); + const data = useLoaderData() + const actionData = useActionData() + const [sensorsData, setSensorsData] = useState( + data as SensorData[], + ) + const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() + + // Helper to update a sensor immutably + const updateSensorState = useCallback( + (index: number, updates: Partial) => { + setSensorsData((prev) => + prev.map((sensor, i) => + i === index ? { ...sensor, ...updates } : sensor, + ), + ) + }, + [], + ) + + // Helper to remove a sensor from state + const removeSensorFromState = useCallback((index: number) => { + setSensorsData((prev) => prev.filter((_, i) => i !== index)) + }, []) + + // Helper to add a new sensor + const addNewSensorToState = useCallback(() => { + setSensorsData((prev) => [ + ...prev, + { + title: '', + unit: '', + sensorType: '', + editing: true, + new: true, + notValidInput: true, + }, + ]) + }, []) + + // Helper to validate sensor fields + const validateSensor = (sensor: SensorData): boolean => { + return Boolean(sensor.title && sensor.unit && sensor.sensorType) + } + + // Helper to reset sensor to original data + const resetSensor = useCallback( + (index: number) => { + const originalData = (data as SensorData[])[index] + updateSensorState(index, { + editing: false, + title: originalData.title, + unit: originalData.unit, + sensorType: originalData.sensorType, + notValidInput: false, + }) + }, + [data, updateSensorState], + ) + + React.useEffect(() => { + if (actionData?.isUpdated) { + setToastOpen(true) + + // Clean up state after successful update + setSensorsData((prev) => + prev + .filter((sensor) => !sensor.deleted) // Remove deleted sensors + .map((sensor) => ({ + ...sensor, + editing: false, + edited: false, + new: false, + notValidInput: false, + })), + ) + } + }, [actionData, setToastOpen]) + + return ( +
+
+
+
+ {/* Heading */} +
+
+
+

Sensor

+
+
+ {/* Add button */} + + {/* Save button */} + +
+
+
+ + {/* divider */} +
+ +
+

+ Data measured by sensors that you are going to delete will be + deleted as well. If you add new sensors, don't forget to + retrieve your new script (see tab 'Script'). +

+
+ +
    + {sensorsData?.map((sensor, index) => ( +
  • +
    + {/* Left side -> sensor icons */} +
    + {sensor?.editing ? ( + +
    + {/* View icon */} + + + {/* Icon dropdown */} + + + + + + + {iconsList?.map((icon: any) => { + const Icon = icon.name + return ( + + updateSensorState(index, { + icon: icon.id, + }) + } + > + + + ) + })} + + + +
    +
    + ) : ( + + {sensor.icon + ? getIcon(sensor.icon) + : assignIcon( + sensor.sensorType ?? '', + sensor.title ?? '', + )} + + )} +
    + + {/* Middle -> sensor attributes */} +
    + {/* Display mode */} + {!sensor?.editing && ( + + + Phenomenon: + + {sensor?.title} + + + ID: + + {sensor?.id} + + + + Unit: + + {sensor?.unit} + + + + Type: + + {sensor?.sensorType} + + + + )} + + {/* Edit mode */} + {sensor?.editing && ( +
    + {/* Phenomenon */} +
    + + { + const value = e.target.value + updateSensorState(index, { + title: value, + notValidInput: !validateSensor({ + ...sensor, + title: value, + }), + }) + }} + /> +
    + + {/* Unit */} +
    + + { + const value = e.target.value + updateSensorState(index, { + unit: value, + notValidInput: !validateSensor({ + ...sensor, + unit: value, + }), + }) + }} + /> +
    + + {/* Type */} +
    + + { + const value = e.target.value + updateSensorState(index, { + sensorType: value, + notValidInput: !validateSensor({ + ...sensor, + sensorType: value, + }), + }) + }} + /> +
    +
    + )} +
    + + {/* Right side -> action buttons */} +
    + + {/* Delete warning */} + {sensor?.deleting && ( + <> + + This sensor will be deleted. + + + + )} + + {/* Default buttons (not editing, not deleting) */} + {!sensor?.editing && !sensor?.deleting && ( + + + + + + )} + + + {/* Editing buttons */} + {sensor?.editing && ( + + {sensor?.notValidInput && ( + + Please fill out all required fields. + + )} + + {/* Save button */} + + + {/* Cancel button */} + + + )} +
    +
    +
  • + ))} +
+ + {/* Hidden input for form submission */} + +
+
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) }