< Summary

Information
Class: item-edit-panel.tsx
Assembly: app.features.items
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/features/items/item-edit-panel.tsx
Tag: 58_25416222083
Line coverage
0%
Covered lines: 0
Uncovered lines: 42
Coverable lines: 42
Total lines: 215
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 38
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/features/items/item-edit-panel.tsx

#LineLine coverage
 1import { useEffect, useRef, useState } from "react";
 2import { useFetcher } from "react-router";
 3import type { ItemResponse, LocationResponse, RoomResponse } from "~/api/client";
 4import { FormField, PanelHeader } from "~/components/panel-ui";
 5import { inputStyle } from "~/lib/styles";
 6
 7type ActionData =
 8  | { ok: true; intent: "create-item" | "update-item"; item: ItemResponse }
 9  | { ok: true; intent: "delete-item" }
 10  | { ok: false; error: string };
 11
 012export function ItemEditPanel({ item, rooms, locations, defaultRoomId, onClose, onSaved, onDeleted, onNewRoom }: {
 13  item?: ItemResponse;
 14  rooms: RoomResponse[];
 15  locations: LocationResponse[];
 16  defaultRoomId?: number;
 17  onClose: () => void;
 18  onSaved: (saved: ItemResponse) => void;
 19  onDeleted: () => void;
 20  onNewRoom?: (locationId?: number) => void;
 21}) {
 022  const fetcher = useFetcher<ActionData>();
 023  const [validationError, setValidationError] = useState<string | null>(null);
 024  const isNew = !item;
 025  const submitting = fetcher.state !== "idle";
 026  const locationById = Object.fromEntries(locations.map(l => [l.id!, l]));
 27
 28  // Derive server-side error from fetcher result during render (no setState needed)
 029  const actionError = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok
 30    ? fetcher.data.error : null;
 031  const error = validationError ?? actionError;
 32
 033  const onSavedRef = useRef(onSaved);
 034  const onDeletedRef = useRef(onDeleted);
 035  useEffect(() => { onSavedRef.current = onSaved; onDeletedRef.current = onDeleted; });
 36
 37  // Only call success callbacks — no setState here (error is derived in render)
 038  const fetchedRef = useRef(false);
 039  useEffect(() => {
 040    if (fetcher.state === "submitting") { fetchedRef.current = true; return; }
 041    if (!fetchedRef.current || fetcher.state !== "idle" || !fetcher.data) return;
 042    fetchedRef.current = false;
 043    const data = fetcher.data;
 044    if (!data.ok) return;
 045    if (data.intent === "create-item" || data.intent === "update-item") onSavedRef.current(data.item);
 046    else if (data.intent === "delete-item") onDeletedRef.current();
 47  }, [fetcher.state, fetcher.data]);
 48
 049  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
 050    e.preventDefault();
 051    const fd = new FormData(e.currentTarget);
 052    const name = (fd.get("name") as string ?? "").trim();
 053    if (!name) { setValidationError("Name is required."); return; }
 054    setValidationError(null);
 055    fetcher.submit(fd, { method: "post" });
 56  }
 57
 058  function handleDelete() {
 059    if (!item?.id || !confirm("Delete this item?")) return;
 060    fetcher.submit(
 61      { _intent: "delete-item", itemId: String(item.id) },
 62      { method: "post" },
 63    );
 64  }
 65
 066  return (
 67    <aside className="tui-panel" style={{
 68      width: 340, borderLeft: "1px solid var(--c-border)",
 69      background: "var(--c-bg-2)", flexShrink: 0,
 70      display: "flex", flexDirection: "column", overflowY: "auto",
 71    }}>
 72      <span className="tui-panel-title">{isNew ? "─[ new item ]─" : `─[ edit · #${item.id} ]─`}</span>
 73      <PanelHeader
 74        label={isNew ? "NEW ITEM" : `EDIT ITEM #${item.id}`}
 75        onClose={onClose}
 76      />
 77
 78      <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14, padding: "16px 16px 0", 
 79        <input type="hidden" name="_intent" value={isNew ? "create-item" : "update-item"} />
 80        {!isNew && <input type="hidden" name="itemId" value={item.id} />}
 81
 82        {error && (
 83          <div style={{ fontSize: 12, color: "#ef4444", padding: "6px 10px", background: "rgba(239,68,68,0.08)", borderR
 84            {error}
 85          </div>
 86        )}
 87
 88        {isNew && (
 89          <FormField label="Room">
 90            <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
 91              <select name="roomId" defaultValue={defaultRoomId ?? rooms[0]?.id ?? ""} style={{ ...inputStyle, flex: 1 }
 092                {locations.map(loc => {
 093                  const locRooms = rooms.filter(r => r.locationId === loc.id);
 094                  if (locRooms.length === 0) return null;
 095                  return (
 96                    <optgroup key={loc.id} label={loc.name ?? "Location"}>
 097                      {locRooms.map(r => (
 098                        <option key={r.id} value={r.id}>{r.name}</option>
 99                      ))}
 100                    </optgroup>
 101                  );
 102                })}
 103              </select>
 104              {onNewRoom && (
 105                <button
 106                  type="button"
 0107                  onClick={() => onNewRoom()}
 108                  title="New room"
 109                  style={{
 110                    flexShrink: 0, padding: "7px 10px", borderRadius: 6, fontSize: 13,
 111                    border: "1px solid var(--c-border)", background: "var(--c-bg-3)",
 112                    color: "var(--c-fg-2)", cursor: "pointer", fontFamily: "inherit",
 113                  }}
 114                >+</button>
 115              )}
 116            </div>
 117          </FormField>
 118        )}
 119
 120        {!isNew && item.roomId != null && (
 121          <>
 122            {/* Backend's UpdateItemRequest requires RoomId>=1; the read-only display
 123                wouldn't post a value, so carry it through with a hidden input. */}
 124            <input type="hidden" name="roomId" value={item.roomId} />
 125            <FormField label="Room">
 126              <div style={{ ...inputStyle, color: "var(--c-fg-2)", cursor: "default" }}>
 0127                {(() => {
 0128                  const room = rooms.find(r => r.id === item.roomId);
 0129                  const loc = room?.locationId != null ? locationById[room.locationId] : null;
 0130                  return loc ? `${loc.name} / ${room?.name}` : (room?.name ?? "—");
 131                })()}
 132              </div>
 133            </FormField>
 134          </>
 135        )}
 136
 137        <FormField label="Name *">
 138          <input
 139            name="name" type="text" required autoFocus
 140            defaultValue={item?.name ?? ""}
 141            placeholder="e.g. Vintage Lamp"
 142            style={inputStyle}
 143          />
 144        </FormField>
 145
 146        <FormField label="Description">
 147          <textarea
 148            name="description" rows={2}
 149            defaultValue={item?.description ?? ""}
 150            placeholder="e.g. Brass table lamp, 1980s"
 151            style={{ ...inputStyle, resize: "vertical" }}
 152          />
 153        </FormField>
 154
 155        <FormField label="Category">
 156          <input
 157            name="category" type="text"
 158            defaultValue={item?.category ?? ""}
 159            placeholder="e.g. Electronics, Furniture"
 160            style={inputStyle}
 161          />
 162        </FormField>
 163
 164        <FormField label="Notes">
 165          <textarea
 166            name="notes" rows={3}
 167            defaultValue={item?.notes ?? ""}
 168            placeholder="e.g. Needs new shade"
 169            style={{ ...inputStyle, resize: "vertical" }}
 170          />
 171        </FormField>
 172
 173        <div style={{ display: "flex", gap: 8, paddingBottom: 4 }}>
 174          <button
 175            type="submit" disabled={submitting}
 176            style={{
 177              flex: 1, padding: "8px 14px", borderRadius: 6,
 178              border: "none", background: "var(--c-accent)", color: "#fff",
 179              fontSize: 13, fontWeight: 500, cursor: submitting ? "not-allowed" : "pointer",
 180              opacity: submitting ? 0.7 : 1, fontFamily: "inherit",
 181            }}
 182          >
 183            {submitting ? "Saving…" : (isNew ? "Create item" : "Save changes")}
 184          </button>
 185          <button
 186            type="button" onClick={onClose} disabled={submitting}
 187            style={{
 188              padding: "8px 14px", borderRadius: 6,
 189              border: "1px solid var(--c-border)", background: "transparent",
 190              color: "var(--c-fg-2)", fontSize: 13, cursor: "pointer", fontFamily: "inherit",
 191            }}
 192          >
 193            Cancel
 194          </button>
 195        </div>
 196
 197        {!isNew && (
 198          <div style={{ marginTop: "auto", paddingTop: 16, paddingBottom: 20, borderTop: "1px solid var(--c-border)" }}>
 199            <button
 200              type="button" onClick={handleDelete} disabled={submitting}
 201              style={{
 202                width: "100%", padding: "7px 14px", borderRadius: 6,
 203                border: "1px solid rgba(239,68,68,0.4)", background: "rgba(239,68,68,0.07)",
 204                color: "#ef4444", fontSize: 13, cursor: submitting ? "not-allowed" : "pointer",
 205                fontFamily: "inherit",
 206              }}
 207            >
 208              Delete item
 209            </button>
 210          </div>
 211        )}
 212      </form>
 213    </aside>
 214  );
 215}