Prune files not in the deploy set (stale build context)
Browse files
web/src/customer-grid/FieldBatchAccessDialog.tsx
DELETED
|
@@ -1,233 +0,0 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / FieldBatchAccessDialog.tsx
|
| 3 |
-
//
|
| 4 |
-
// One small access editor for the Field manager's plural Share and Reassign
|
| 5 |
-
// actions. It deliberately uses the existing field-share routes rather than
|
| 6 |
-
// inventing a second permissions model: every selected field keeps its own
|
| 7 |
-
// grants, and the server remains the authority for each individual refusal.
|
| 8 |
-
// ---------------------------------------------------------------------------
|
| 9 |
-
|
| 10 |
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 11 |
-
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 12 |
-
|
| 13 |
-
export type FieldBatchAccessAction = "share" | "reassign";
|
| 14 |
-
|
| 15 |
-
interface Person {
|
| 16 |
-
user: string;
|
| 17 |
-
name: string;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
interface Entry {
|
| 21 |
-
user: string;
|
| 22 |
-
role: "view" | "edit";
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
function asPeople(raw: unknown): Person[] {
|
| 26 |
-
if (!Array.isArray(raw)) return [];
|
| 27 |
-
const seen = new Set<string>();
|
| 28 |
-
const people: Person[] = [];
|
| 29 |
-
for (const item of raw) {
|
| 30 |
-
if (!item || typeof item !== "object") continue;
|
| 31 |
-
const candidate = item as Record<string, unknown>;
|
| 32 |
-
const user = typeof candidate.username === "string" ? candidate.username.trim().toLowerCase() : "";
|
| 33 |
-
const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
|
| 34 |
-
if (!user || seen.has(user)) continue;
|
| 35 |
-
seen.add(user);
|
| 36 |
-
people.push({ user, name: name || user });
|
| 37 |
-
}
|
| 38 |
-
return people;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
function asEntries(raw: unknown): Entry[] {
|
| 42 |
-
if (!Array.isArray(raw)) return [];
|
| 43 |
-
const seen = new Set<string>();
|
| 44 |
-
const entries: Entry[] = [];
|
| 45 |
-
for (const item of raw) {
|
| 46 |
-
if (!item || typeof item !== "object") continue;
|
| 47 |
-
const candidate = item as Record<string, unknown>;
|
| 48 |
-
const user = typeof candidate.user === "string" ? candidate.user.trim().toLowerCase() : "";
|
| 49 |
-
const role = candidate.role === "view" || candidate.role === "edit" ? candidate.role : null;
|
| 50 |
-
if (!user || !role || seen.has(user)) continue;
|
| 51 |
-
seen.add(user);
|
| 52 |
-
entries.push({ user, role });
|
| 53 |
-
}
|
| 54 |
-
return entries;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
async function replyMessage(res: Response): Promise<string> {
|
| 58 |
-
const body = (await res.json().catch(() => null)) as { error?: { message?: unknown } } | null;
|
| 59 |
-
const message = body?.error?.message;
|
| 60 |
-
return typeof message === "string" && message.trim() ? message.trim() : `The server answered ${res.status}.`;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
export default function FieldBatchAccessDialog({
|
| 64 |
-
action,
|
| 65 |
-
tableKey,
|
| 66 |
-
fields,
|
| 67 |
-
onClose,
|
| 68 |
-
onComplete,
|
| 69 |
-
}: {
|
| 70 |
-
action: FieldBatchAccessAction;
|
| 71 |
-
tableKey: string;
|
| 72 |
-
fields: readonly { key: string; label: string }[];
|
| 73 |
-
onClose: () => void;
|
| 74 |
-
onComplete: (message: string) => void;
|
| 75 |
-
}) {
|
| 76 |
-
const [people, setPeople] = useState<Person[]>([]);
|
| 77 |
-
const [person, setPerson] = useState("");
|
| 78 |
-
const [role, setRole] = useState<Entry["role"]>("view");
|
| 79 |
-
const [busy, setBusy] = useState(false);
|
| 80 |
-
const [error, setError] = useState("");
|
| 81 |
-
const [result, setResult] = useState("");
|
| 82 |
-
const first = fields[0];
|
| 83 |
-
const title = action === "share" ? "Share fields" : "Reassign fields";
|
| 84 |
-
const subject = fields.length === 1 ? `“${first?.label ?? "field"}”` : `${fields.length} fields`;
|
| 85 |
-
|
| 86 |
-
const pathFor = useCallback(
|
| 87 |
-
(key: string) => `${API_V1}/share/field/${encodeURIComponent(`${tableKey}:${key}`)}`,
|
| 88 |
-
[tableKey]
|
| 89 |
-
);
|
| 90 |
-
|
| 91 |
-
useEffect(() => {
|
| 92 |
-
let alive = true;
|
| 93 |
-
if (!first) return () => { alive = false; };
|
| 94 |
-
void (async () => {
|
| 95 |
-
const res = await fetch(pathFor(first.key), { credentials: CREDENTIALS }).catch(() => null);
|
| 96 |
-
if (!alive) return;
|
| 97 |
-
if (!res) {
|
| 98 |
-
setError("Cannot reach the server.");
|
| 99 |
-
return;
|
| 100 |
-
}
|
| 101 |
-
if (!res.ok) {
|
| 102 |
-
setError(await replyMessage(res));
|
| 103 |
-
return;
|
| 104 |
-
}
|
| 105 |
-
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
| 106 |
-
const offered = asPeople(body?.people);
|
| 107 |
-
setPeople(offered);
|
| 108 |
-
if (offered.length === 0) setError("No eligible people were returned for this workspace.");
|
| 109 |
-
})();
|
| 110 |
-
return () => { alive = false; };
|
| 111 |
-
}, [first, pathFor]);
|
| 112 |
-
|
| 113 |
-
useEffect(() => {
|
| 114 |
-
const onKey = (event: KeyboardEvent) => {
|
| 115 |
-
if (event.key === "Escape" && !busy) onClose();
|
| 116 |
-
};
|
| 117 |
-
window.addEventListener("keydown", onKey);
|
| 118 |
-
return () => window.removeEventListener("keydown", onKey);
|
| 119 |
-
}, [busy, onClose]);
|
| 120 |
-
|
| 121 |
-
const canApply = !!person && !busy && !result;
|
| 122 |
-
const buttonText = action === "share" ? "Share" : "Reassign";
|
| 123 |
-
const personLabel = useMemo(
|
| 124 |
-
() => people.find((item) => item.user === person)?.name ?? person,
|
| 125 |
-
[people, person]
|
| 126 |
-
);
|
| 127 |
-
|
| 128 |
-
const apply = useCallback(() => {
|
| 129 |
-
if (!canApply) return;
|
| 130 |
-
void (async () => {
|
| 131 |
-
setBusy(true);
|
| 132 |
-
setError("");
|
| 133 |
-
let changed = 0;
|
| 134 |
-
const refused: string[] = [];
|
| 135 |
-
for (const field of fields) {
|
| 136 |
-
const path = pathFor(field.key);
|
| 137 |
-
const read = await fetch(path, { credentials: CREDENTIALS }).catch(() => null);
|
| 138 |
-
if (!read) {
|
| 139 |
-
refused.push(`${field.label}: Cannot reach the server.`);
|
| 140 |
-
continue;
|
| 141 |
-
}
|
| 142 |
-
if (!read.ok) {
|
| 143 |
-
refused.push(`${field.label}: ${await replyMessage(read)}`);
|
| 144 |
-
continue;
|
| 145 |
-
}
|
| 146 |
-
const current = (await read.json().catch(() => null)) as Record<string, unknown> | null;
|
| 147 |
-
const response = action === "share"
|
| 148 |
-
? await fetch(path, {
|
| 149 |
-
method: "PUT",
|
| 150 |
-
credentials: CREDENTIALS,
|
| 151 |
-
headers: { "Content-Type": "application/json" },
|
| 152 |
-
body: JSON.stringify({
|
| 153 |
-
entries: [
|
| 154 |
-
...asEntries(current?.entries).filter((entry) => entry.user !== person),
|
| 155 |
-
{ user: person, role },
|
| 156 |
-
],
|
| 157 |
-
}),
|
| 158 |
-
}).catch(() => null)
|
| 159 |
-
: await fetch(`${path}/owner`, {
|
| 160 |
-
method: "PUT",
|
| 161 |
-
credentials: CREDENTIALS,
|
| 162 |
-
headers: { "Content-Type": "application/json" },
|
| 163 |
-
body: JSON.stringify({ owner: person }),
|
| 164 |
-
}).catch(() => null);
|
| 165 |
-
if (!response) {
|
| 166 |
-
refused.push(`${field.label}: Cannot reach the server.`);
|
| 167 |
-
} else if (!response.ok) {
|
| 168 |
-
refused.push(`${field.label}: ${await replyMessage(response)}`);
|
| 169 |
-
} else {
|
| 170 |
-
changed += 1;
|
| 171 |
-
}
|
| 172 |
-
}
|
| 173 |
-
const noun = changed === 1 ? "field" : "fields";
|
| 174 |
-
const verb = action === "share" ? "shared with" : "reassigned to";
|
| 175 |
-
const message = changed > 0
|
| 176 |
-
? `${changed} ${noun} ${verb} ${personLabel}.`
|
| 177 |
-
: "No selected fields were changed.";
|
| 178 |
-
setResult(message);
|
| 179 |
-
if (refused.length > 0) setError(refused.join(" "));
|
| 180 |
-
if (changed > 0) onComplete(message);
|
| 181 |
-
setBusy(false);
|
| 182 |
-
})();
|
| 183 |
-
}, [action, canApply, fields, onComplete, pathFor, person, personLabel, role]);
|
| 184 |
-
|
| 185 |
-
return (
|
| 186 |
-
<div className="shell-newdb-scrim cg-field-access-scrim" onClick={() => (busy ? null : onClose())}>
|
| 187 |
-
<section
|
| 188 |
-
className="shell-newdb shell-share"
|
| 189 |
-
role="dialog"
|
| 190 |
-
aria-modal="true"
|
| 191 |
-
aria-label={`${title} ${subject}`}
|
| 192 |
-
onClick={(event) => event.stopPropagation()}
|
| 193 |
-
>
|
| 194 |
-
<h2>{title}</h2>
|
| 195 |
-
<p className="shell-newdb-sub">
|
| 196 |
-
{action === "share"
|
| 197 |
-
? `Give ${personLabel || "someone"} access to ${subject}. Existing access on each field stays in place.`
|
| 198 |
-
: `Make ${personLabel || "someone"} the owner of ${subject}. A field must already be shared before it can be reassigned.`}
|
| 199 |
-
</p>
|
| 200 |
-
<div className="shell-share-add">
|
| 201 |
-
<select
|
| 202 |
-
className="shell-share-select"
|
| 203 |
-
aria-label="Person"
|
| 204 |
-
value={person}
|
| 205 |
-
disabled={busy || !!result}
|
| 206 |
-
onChange={(event) => setPerson(event.target.value)}
|
| 207 |
-
>
|
| 208 |
-
<option value="">Choose a person</option>
|
| 209 |
-
{people.map((item) => <option key={item.user} value={item.user}>{item.name}</option>)}
|
| 210 |
-
</select>
|
| 211 |
-
{action === "share" ? (
|
| 212 |
-
<select
|
| 213 |
-
className="shell-share-select"
|
| 214 |
-
aria-label="Access level"
|
| 215 |
-
value={role}
|
| 216 |
-
disabled={busy || !!result}
|
| 217 |
-
onChange={(event) => setRole(event.target.value === "edit" ? "edit" : "view")}
|
| 218 |
-
>
|
| 219 |
-
<option value="view">Can view</option>
|
| 220 |
-
<option value="edit">Can edit</option>
|
| 221 |
-
</select>
|
| 222 |
-
) : null}
|
| 223 |
-
</div>
|
| 224 |
-
{result ? <p className="shell-share-summary">{result}</p> : null}
|
| 225 |
-
{error ? <p className="shell-newdb-err">{error}</p> : null}
|
| 226 |
-
<div className="shell-newdb-actions">
|
| 227 |
-
<button type="button" disabled={busy} onClick={onClose}>{result ? "Done" : "Cancel"}</button>
|
| 228 |
-
{!result ? <button type="button" className="login-submit" disabled={!canApply} onClick={apply}>{busy ? "Applying..." : buttonText}</button> : null}
|
| 229 |
-
</div>
|
| 230 |
-
</section>
|
| 231 |
-
</div>
|
| 232 |
-
);
|
| 233 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/customer-grid/RouteNav.tsx
DELETED
|
@@ -1,773 +0,0 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / RouteNav.tsx
|
| 3 |
-
// W42-T24 (owner instruction 10, contract C6) - THE ROUTE NAVIGATION.
|
| 4 |
-
//
|
| 5 |
-
// Before this file, the Route button opened a bare planner whose only listing was a plain
|
| 6 |
-
// `<select>`. A route could not be renamed, duplicated, deleted, shared or filed from the place
|
| 7 |
-
// it was listed, and a route somebody shared with you wore no mark at all.
|
| 8 |
-
//
|
| 9 |
-
// THIS IS `ViewSidebar.tsx`'s VOCABULARY, NOT A SECOND IMPLEMENTATION. Same row shape, the same
|
| 10 |
-
// `cg-view-row` / `cg-view-main` / `cg-view-more` class names, the same trigger in the same
|
| 11 |
-
// position, the same `AnchoredOverlay` menu surface, the same folder drag behaviour and the same
|
| 12 |
-
// empty-state voice. Interaction parity is the point of the ticket; resemblance is not enough.
|
| 13 |
-
//
|
| 14 |
-
// HOST NEUTRAL. Nothing under `customer-grid/**` may import `shell/**`, so the share dialog is
|
| 15 |
-
// reached the way `ColumnMenu.tsx` reaches it: a `window` CustomEvent named "aios:share-open",
|
| 16 |
-
// whose literal is deliberately re-spelled at each call site rather than imported across the
|
| 17 |
-
// boundary. This file raises no such event itself; it hands the decision up through
|
| 18 |
-
// `onShareRoute`, so the one dispatch lives beside the rest of the route doors in `MapView.tsx`.
|
| 19 |
-
//
|
| 20 |
-
// NO EXPAND / COLLAPSE MACHINERY. The views rail's disclosure chevron is being deleted (R12), so
|
| 21 |
-
// copying it here would ship a control on its way out. A folder shows its routes.
|
| 22 |
-
// ---------------------------------------------------------------------------
|
| 23 |
-
|
| 24 |
-
import { useCallback, useMemo, useState } from "react";
|
| 25 |
-
import type { DragEvent, ReactNode } from "react";
|
| 26 |
-
import { AnchoredOverlay } from "./OverlaySurface";
|
| 27 |
-
import type { AnchorRect } from "./OverlaySurface";
|
| 28 |
-
import { FolderMark, MenuLabel } from "./icons";
|
| 29 |
-
import { SHARE_SHAPE } from "./iconShapes";
|
| 30 |
-
import type { GridFolder } from "./types";
|
| 31 |
-
import {
|
| 32 |
-
ROOT_FOLDER_ID, folderNesting, groupByFolder, isSyntheticFolderId, mayNestFolder,
|
| 33 |
-
reorderFolderIds,
|
| 34 |
-
} from "./folders";
|
| 35 |
-
|
| 36 |
-
/**
|
| 37 |
-
* One route as `GET /customers/route-order` serves it (W42-T22 and W42-T23 grew every key below
|
| 38 |
-
* the first three).
|
| 39 |
-
*
|
| 40 |
-
* `shared` IS THE CLIENT'S MEANING ALREADY, AND IT MUST NOT BE RE-DERIVED.
|
| 41 |
-
* `_merge_shared_fields` stamps `shared: True` on every tenant-wide column, and every route is
|
| 42 |
-
* one of those, always. The route-order door answers the question `viewShareMark` asks instead,
|
| 43 |
-
* which is *granted to me*; re-computing it here from the field definition would put the mark on
|
| 44 |
-
* every route in the list.
|
| 45 |
-
*/
|
| 46 |
-
export interface RouteNavRoute {
|
| 47 |
-
key: string;
|
| 48 |
-
label: string;
|
| 49 |
-
inputsHash?: string;
|
| 50 |
-
stops?: number;
|
| 51 |
-
solvedBy?: string | null;
|
| 52 |
-
mine: boolean;
|
| 53 |
-
owner?: string | null;
|
| 54 |
-
shared?: boolean;
|
| 55 |
-
sharedOut?: boolean;
|
| 56 |
-
hasShares?: boolean;
|
| 57 |
-
sharedRole?: string | null;
|
| 58 |
-
folderId?: string | null;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
export interface RouteShareMark {
|
| 62 |
-
title: string;
|
| 63 |
-
ariaLabel: string;
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
/**
|
| 67 |
-
* CONTRACT C6 - DOES THIS ROUTE WEAR THE SHARE MARK, AND WHAT DOES IT SAY?
|
| 68 |
-
*
|
| 69 |
-
* A LINE-FOR-LINE MIRROR of `ViewSidebar.tsx::viewShareMark`, reading the same five keys in the
|
| 70 |
-
* same order: `hasShares`/`shared` (its line 198), `shared` + `owner` (199), `sharedRole` (203)
|
| 71 |
-
* and `sharedOut` (209). The route-order door spells them that way on purpose; a key spelled one
|
| 72 |
-
* letter differently is a navigation that lists routes, opens the share dialog, passes every gate
|
| 73 |
-
* and never draws the icon.
|
| 74 |
-
*
|
| 75 |
-
* THE COPY IS REWRITTEN, THE READING IS NOT. `viewShareMark` is exported and could have been
|
| 76 |
-
* called directly, but every sentence it returns says "view", so a route would have told its
|
| 77 |
-
* reader it was a view. The rule is shared; the noun is not.
|
| 78 |
-
*/
|
| 79 |
-
export function routeShareMark(
|
| 80 |
-
route: Pick<
|
| 81 |
-
RouteNavRoute,
|
| 82 |
-
"shared" | "owner" | "sharedRole" | "label" | "hasShares" | "sharedOut"
|
| 83 |
-
>
|
| 84 |
-
): RouteShareMark | null {
|
| 85 |
-
if (!route.hasShares && !route.shared) return null;
|
| 86 |
-
if (route.shared && route.owner) {
|
| 87 |
-
return {
|
| 88 |
-
title:
|
| 89 |
-
`Shared with you by ${route.owner}.` +
|
| 90 |
-
(route.sharedRole === "edit"
|
| 91 |
-
? " You can edit it."
|
| 92 |
-
: " You can open it, not change it."),
|
| 93 |
-
ariaLabel: `${route.label}, shared with you by ${route.owner}`,
|
| 94 |
-
};
|
| 95 |
-
}
|
| 96 |
-
if (route.sharedOut) {
|
| 97 |
-
return {
|
| 98 |
-
title: "You shared this route. Anyone you gave access to can open it.",
|
| 99 |
-
ariaLabel: `${route.label}, shared by you`,
|
| 100 |
-
};
|
| 101 |
-
}
|
| 102 |
-
// `hasShares` and nothing beside it: this route has grants and the payload does not say whose
|
| 103 |
-
// or on what terms. Say only what is known.
|
| 104 |
-
return { title: "This route is shared.", ariaLabel: `${route.label}, shared` };
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
/**
|
| 108 |
-
* The share glyph, drawn from `SHARE_SHAPE` so a shared route, a shared view and a shared field
|
| 109 |
-
* are ONE mark. `ViewSidebar.tsx`'s own `ShareMark` is not exported and that file is not this
|
| 110 |
-
* ticket's to edit, so the geometry is taken from the module both of them already read rather
|
| 111 |
-
* than hand-copied.
|
| 112 |
-
*/
|
| 113 |
-
function ShareMark({ size = 13 }: { size?: number }) {
|
| 114 |
-
return (
|
| 115 |
-
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden>
|
| 116 |
-
{SHARE_SHAPE.map((s) =>
|
| 117 |
-
s.fill ? (
|
| 118 |
-
<path key={s.d} d={s.d} fill="currentColor" />
|
| 119 |
-
) : (
|
| 120 |
-
<path
|
| 121 |
-
key={s.d}
|
| 122 |
-
d={s.d}
|
| 123 |
-
fill="none"
|
| 124 |
-
stroke="currentColor"
|
| 125 |
-
strokeWidth={1.35}
|
| 126 |
-
strokeLinecap="round"
|
| 127 |
-
strokeLinejoin="round"
|
| 128 |
-
/>
|
| 129 |
-
)
|
| 130 |
-
)}
|
| 131 |
-
</svg>
|
| 132 |
-
);
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
const ROUTE_DRAG_TYPE = "application/x-loopable-route";
|
| 136 |
-
const RFOLD_DRAG_TYPE = "application/x-loopable-routefolder";
|
| 137 |
-
const DOTS = "···";
|
| 138 |
-
|
| 139 |
-
export interface RouteNavProps {
|
| 140 |
-
/** Every route this account may see, `mine` or not, exactly as the door served them. */
|
| 141 |
-
routes: RouteNavRoute[];
|
| 142 |
-
/** This reader's own route folders, as the same listing carries them. */
|
| 143 |
-
folders: GridFolder[];
|
| 144 |
-
/** The route currently drawn on the map, so the rail can mark it active. */
|
| 145 |
-
openKey: string;
|
| 146 |
-
/** Which route the rail is waiting on a server answer for, or "" for none. */
|
| 147 |
-
busyKey: string;
|
| 148 |
-
/**
|
| 149 |
-
* A sentence the rail owes the reader, or "" for none.
|
| 150 |
-
*
|
| 151 |
-
* ⛔ IT RENDERS UNCONDITIONALLY, AND THAT IS THE WHOLE REASON IT EXISTS RATHER THAN REUSING THE
|
| 152 |
-
* PLANNER'S `saveErr`. Every message slot inside the planner below sits behind `plan &&`, so a
|
| 153 |
-
* refusal raised while there is no plan yet, which is exactly when Create is refused, would be
|
| 154 |
-
* written to a slot that is not on screen. A control whose refusal is invisible is a dead menu
|
| 155 |
-
* row (standing rule 1's second sentence: reported, never silent).
|
| 156 |
-
*/
|
| 157 |
-
notice: string;
|
| 158 |
-
/** A failed listing is distinct from the honest empty state and offers an immediate retry. */
|
| 159 |
-
listError: string | null;
|
| 160 |
-
onListRetry: () => void;
|
| 161 |
-
/** ONE CLICK OPENS A ROUTE. There is no second gesture and no confirm step. */
|
| 162 |
-
onOpenRoute: (key: string) => void;
|
| 163 |
-
/** CREATING IS A SEPARATE AFFORDANCE, never a row in the list of what exists. */
|
| 164 |
-
onCreateRoute: () => void;
|
| 165 |
-
onRenameRoute: (key: string, label: string) => void;
|
| 166 |
-
onDuplicateRoute: (key: string) => void;
|
| 167 |
-
onDeleteRoute: (key: string) => void;
|
| 168 |
-
/** Opens the SAME dialog a view or a field opens. The host owns the dispatch. */
|
| 169 |
-
onShareRoute: (key: string, label: string) => void;
|
| 170 |
-
/**
|
| 171 |
-
* REQUIRED, NOT OPTIONAL, AND THAT IS THE TICKET'S OWN INSTRUCTION. `ViewSidebar` learned this
|
| 172 |
-
* the expensive way: an optional reorder prop lets a caller mount the rail with dragging
|
| 173 |
-
* silently switched off, and every gate stays green because the component is correct and the
|
| 174 |
-
* handler simply never arrives. `onViewReorder` was made required in wave 27 for exactly this;
|
| 175 |
-
* its sibling `onFolderReorder?` is still optional there and is the shape being avoided here.
|
| 176 |
-
* Both reorder props below are required, so a mount that forgets one does not compile.
|
| 177 |
-
*/
|
| 178 |
-
onRouteReorder: (order: string[]) => void;
|
| 179 |
-
onFolderReorder: (order: string[]) => void;
|
| 180 |
-
/** Drag a route into a folder, or out to the top level. */
|
| 181 |
-
onRouteMove: (key: string, folderId: string | null) => void;
|
| 182 |
-
onFolderCreate: (name: string, parent: string | null) => void;
|
| 183 |
-
onFolderRename: (folderId: string, name: string) => void;
|
| 184 |
-
onFolderDelete: (folderId: string) => void;
|
| 185 |
-
/**
|
| 186 |
-
* THE ROUTE THAT WAS JUST CREATED, so the rail can ASK whether to share it.
|
| 187 |
-
*
|
| 188 |
-
* A PROMPT, NOT A DEFAULT. The done-when is "prompts to share it or keep it private", and a
|
| 189 |
-
* route that opened the share dialog unasked would have made the choice for the person. Null
|
| 190 |
-
* whenever nothing was just made, which is almost always.
|
| 191 |
-
*/
|
| 192 |
-
created: { key: string; label: string } | null;
|
| 193 |
-
onCreatedDismiss: () => void;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
export function RouteNav({
|
| 197 |
-
routes,
|
| 198 |
-
folders,
|
| 199 |
-
openKey,
|
| 200 |
-
busyKey,
|
| 201 |
-
notice,
|
| 202 |
-
listError,
|
| 203 |
-
onListRetry,
|
| 204 |
-
onOpenRoute,
|
| 205 |
-
onCreateRoute,
|
| 206 |
-
onRenameRoute,
|
| 207 |
-
onDuplicateRoute,
|
| 208 |
-
onDeleteRoute,
|
| 209 |
-
onShareRoute,
|
| 210 |
-
onRouteReorder,
|
| 211 |
-
onFolderReorder,
|
| 212 |
-
onRouteMove,
|
| 213 |
-
onFolderCreate,
|
| 214 |
-
onFolderRename,
|
| 215 |
-
onFolderDelete,
|
| 216 |
-
created,
|
| 217 |
-
onCreatedDismiss,
|
| 218 |
-
}: RouteNavProps) {
|
| 219 |
-
const [menu, setMenu] = useState<{ key: string; anchor: AnchorRect } | null>(null);
|
| 220 |
-
const [folderMenu, setFolderMenu] = useState<{ id: string; anchor: AnchorRect } | null>(null);
|
| 221 |
-
const [createMenu, setCreateMenu] = useState<AnchorRect | null>(null);
|
| 222 |
-
const [renamingKey, setRenamingKey] = useState("");
|
| 223 |
-
const [renamingFolder, setRenamingFolder] = useState("");
|
| 224 |
-
const [draft, setDraft] = useState("");
|
| 225 |
-
const [armedDelete, setArmedDelete] = useState("");
|
| 226 |
-
const [routeDrag, setRouteDrag] = useState("");
|
| 227 |
-
const [routeOver, setRouteOver] = useState("");
|
| 228 |
-
const [foldDrag, setFoldDrag] = useState("");
|
| 229 |
-
const [foldOver, setFoldOver] = useState("");
|
| 230 |
-
|
| 231 |
-
const nesting = useMemo(() => folderNesting(folders), [folders]);
|
| 232 |
-
const nestedFolders = nesting.ordered;
|
| 233 |
-
|
| 234 |
-
/**
|
| 235 |
-
* The rail's groups, depth first.
|
| 236 |
-
*
|
| 237 |
-
* `nesting.ordered` rather than `folders`, because `groupByFolder` emits one group per folder
|
| 238 |
-
* IN THE ORDER IT IS HANDED THEM. Passing the raw list would draw a child folder above its
|
| 239 |
-
* parent whenever the store happened to hold it that way.
|
| 240 |
-
* `folderIdOf` returns the payload value UNTOUCHED, including the difference between absent
|
| 241 |
-
* (never filed) and the reserved root id (deliberately dragged to the top level). Collapsing
|
| 242 |
-
* the two would send a route somebody shared with you out of the Shared group the first time
|
| 243 |
-
* you dragged it anywhere.
|
| 244 |
-
*/
|
| 245 |
-
const groups = useMemo(
|
| 246 |
-
() =>
|
| 247 |
-
groupByFolder<RouteNavRoute>(
|
| 248 |
-
routes,
|
| 249 |
-
nestedFolders,
|
| 250 |
-
(r) => r.folderId ?? null,
|
| 251 |
-
(r) => !!r.shared && !!r.owner
|
| 252 |
-
),
|
| 253 |
-
[routes, nestedFolders]
|
| 254 |
-
);
|
| 255 |
-
|
| 256 |
-
const closeMenus = useCallback(() => {
|
| 257 |
-
setMenu(null);
|
| 258 |
-
setFolderMenu(null);
|
| 259 |
-
setArmedDelete("");
|
| 260 |
-
}, []);
|
| 261 |
-
|
| 262 |
-
const reorderRoutes = useCallback(
|
| 263 |
-
(draggedKey: string, beforeKey: string | null) => {
|
| 264 |
-
const next = reorderFolderIds(routes.map((r) => r.key), draggedKey, beforeKey);
|
| 265 |
-
if (next) onRouteReorder(next);
|
| 266 |
-
},
|
| 267 |
-
[routes, onRouteReorder]
|
| 268 |
-
);
|
| 269 |
-
|
| 270 |
-
const reorderFolders = useCallback(
|
| 271 |
-
(draggedId: string, beforeId: string | null) => {
|
| 272 |
-
const next = reorderFolderIds(folders.map((f) => f.id), draggedId, beforeId);
|
| 273 |
-
if (next) onFolderReorder(next);
|
| 274 |
-
},
|
| 275 |
-
[folders, onFolderReorder]
|
| 276 |
-
);
|
| 277 |
-
|
| 278 |
-
const commitRename = useCallback(() => {
|
| 279 |
-
const name = draft.trim();
|
| 280 |
-
if (renamingKey && name) onRenameRoute(renamingKey, name);
|
| 281 |
-
setRenamingKey("");
|
| 282 |
-
setDraft("");
|
| 283 |
-
}, [draft, renamingKey, onRenameRoute]);
|
| 284 |
-
|
| 285 |
-
const commitFolderRename = useCallback(() => {
|
| 286 |
-
const name = draft.trim();
|
| 287 |
-
if (renamingFolder && name) onFolderRename(renamingFolder, name);
|
| 288 |
-
setRenamingFolder("");
|
| 289 |
-
setDraft("");
|
| 290 |
-
}, [draft, renamingFolder, onFolderRename]);
|
| 291 |
-
|
| 292 |
-
/**
|
| 293 |
-
* Drop handlers for one route row.
|
| 294 |
-
*
|
| 295 |
-
* `stopPropagation` IS LOAD-BEARING, and it is `ViewSidebar`'s reason verbatim: a route drag
|
| 296 |
-
* carries TWO payloads at once, the reorder channel and the plain-text filing channel, so a
|
| 297 |
-
* drop on a row inside a folder would otherwise reorder AND re-file in one gesture.
|
| 298 |
-
*/
|
| 299 |
-
const rowDrop = (key: string) => ({
|
| 300 |
-
onDragOver: (e: DragEvent) => {
|
| 301 |
-
if (!e.dataTransfer.types.includes(ROUTE_DRAG_TYPE)) return;
|
| 302 |
-
e.preventDefault();
|
| 303 |
-
e.stopPropagation();
|
| 304 |
-
setRouteOver(key);
|
| 305 |
-
},
|
| 306 |
-
onDragLeave: () => setRouteOver(""),
|
| 307 |
-
onDrop: (e: DragEvent) => {
|
| 308 |
-
if (!e.dataTransfer.types.includes(ROUTE_DRAG_TYPE)) return;
|
| 309 |
-
e.preventDefault();
|
| 310 |
-
e.stopPropagation();
|
| 311 |
-
const dragged = e.dataTransfer.getData(ROUTE_DRAG_TYPE);
|
| 312 |
-
setRouteOver("");
|
| 313 |
-
setRouteDrag("");
|
| 314 |
-
if (dragged && dragged !== key) reorderRoutes(dragged, key);
|
| 315 |
-
},
|
| 316 |
-
});
|
| 317 |
-
|
| 318 |
-
const sectionDrop = (target: string | null) => ({
|
| 319 |
-
onDragOver: (e: DragEvent) => {
|
| 320 |
-
if (!e.dataTransfer.types.includes("text/plain")) return;
|
| 321 |
-
e.preventDefault();
|
| 322 |
-
setFoldOver(target ?? ROOT_FOLDER_ID);
|
| 323 |
-
},
|
| 324 |
-
onDragLeave: () => setFoldOver(""),
|
| 325 |
-
onDrop: (e: DragEvent) => {
|
| 326 |
-
e.preventDefault();
|
| 327 |
-
setFoldOver("");
|
| 328 |
-
const folded = e.dataTransfer.getData(RFOLD_DRAG_TYPE);
|
| 329 |
-
if (folded) {
|
| 330 |
-
setFoldDrag("");
|
| 331 |
-
if (target && folded !== target) reorderFolders(folded, target);
|
| 332 |
-
return;
|
| 333 |
-
}
|
| 334 |
-
const dragged = e.dataTransfer.getData("text/plain");
|
| 335 |
-
setRouteDrag("");
|
| 336 |
-
if (dragged) onRouteMove(dragged, target);
|
| 337 |
-
},
|
| 338 |
-
});
|
| 339 |
-
|
| 340 |
-
const menuRoute = menu ? routes.find((r) => r.key === menu.key) || null : null;
|
| 341 |
-
const folderRow = folderMenu ? folders.find((f) => f.id === folderMenu.id) || null : null;
|
| 342 |
-
const nestHere = folderRow
|
| 343 |
-
? mayNestFolder(nesting, folderRow.id)
|
| 344 |
-
: { ok: true, reason: undefined as string | undefined };
|
| 345 |
-
|
| 346 |
-
const renderRoute = (route: RouteNavRoute): ReactNode => {
|
| 347 |
-
const mark = routeShareMark(route);
|
| 348 |
-
const active = route.key === openKey;
|
| 349 |
-
const renaming = renamingKey === route.key;
|
| 350 |
-
const stops = typeof route.stops === "number" ? route.stops : null;
|
| 351 |
-
const desc = [
|
| 352 |
-
stops === null ? "" : `${stops.toLocaleString()} ${stops === 1 ? "stop" : "stops"}`,
|
| 353 |
-
route.mine ? "" : route.solvedBy ? `planned by ${route.solvedBy}` : "planned by a colleague",
|
| 354 |
-
]
|
| 355 |
-
.filter(Boolean)
|
| 356 |
-
.join(" · ");
|
| 357 |
-
return (
|
| 358 |
-
<div
|
| 359 |
-
key={route.key}
|
| 360 |
-
className={
|
| 361 |
-
"cg-view-row cg-route-row" +
|
| 362 |
-
(active ? " is-active" : "") +
|
| 363 |
-
(routeDrag === route.key ? " is-viewdrag" : "") +
|
| 364 |
-
(routeOver === route.key && routeDrag ? " is-drop-above" : "")
|
| 365 |
-
}
|
| 366 |
-
draggable={!renaming}
|
| 367 |
-
onDragStart={(e) => {
|
| 368 |
-
e.dataTransfer.setData(ROUTE_DRAG_TYPE, route.key);
|
| 369 |
-
e.dataTransfer.setData("text/plain", route.key);
|
| 370 |
-
e.dataTransfer.effectAllowed = "move";
|
| 371 |
-
setRouteDrag(route.key);
|
| 372 |
-
}}
|
| 373 |
-
onDragEnd={() => {
|
| 374 |
-
setRouteDrag("");
|
| 375 |
-
setRouteOver("");
|
| 376 |
-
}}
|
| 377 |
-
{...rowDrop(route.key)}
|
| 378 |
-
>
|
| 379 |
-
{renaming ? (
|
| 380 |
-
<input
|
| 381 |
-
className="cg-input cg-route-rename"
|
| 382 |
-
value={draft}
|
| 383 |
-
autoFocus
|
| 384 |
-
aria-label={`Rename ${route.label}`}
|
| 385 |
-
onChange={(e) => setDraft(e.target.value)}
|
| 386 |
-
onBlur={commitRename}
|
| 387 |
-
onKeyDown={(e) => {
|
| 388 |
-
if (e.key === "Enter") {
|
| 389 |
-
e.preventDefault();
|
| 390 |
-
commitRename();
|
| 391 |
-
}
|
| 392 |
-
if (e.key === "Escape") {
|
| 393 |
-
e.preventDefault();
|
| 394 |
-
setRenamingKey("");
|
| 395 |
-
}
|
| 396 |
-
}}
|
| 397 |
-
/>
|
| 398 |
-
) : (
|
| 399 |
-
<button
|
| 400 |
-
type="button"
|
| 401 |
-
className="cg-view-main"
|
| 402 |
-
title={route.label}
|
| 403 |
-
disabled={busyKey === route.key}
|
| 404 |
-
onClick={() => onOpenRoute(route.key)}
|
| 405 |
-
>
|
| 406 |
-
<span className="cg-view-text">
|
| 407 |
-
<span className="cg-view-name">{route.label}</span>
|
| 408 |
-
{desc ? <span className="cg-view-desc">{desc}</span> : null}
|
| 409 |
-
</span>
|
| 410 |
-
{mark ? (
|
| 411 |
-
<span
|
| 412 |
-
className="cg-view-shared"
|
| 413 |
-
role="img"
|
| 414 |
-
aria-label={mark.ariaLabel}
|
| 415 |
-
title={mark.title}
|
| 416 |
-
>
|
| 417 |
-
<ShareMark />
|
| 418 |
-
</span>
|
| 419 |
-
) : null}
|
| 420 |
-
</button>
|
| 421 |
-
)}
|
| 422 |
-
<button
|
| 423 |
-
type="button"
|
| 424 |
-
className="cg-view-more"
|
| 425 |
-
aria-label={`Actions for ${route.label}`}
|
| 426 |
-
aria-haspopup="menu"
|
| 427 |
-
aria-expanded={menu?.key === route.key}
|
| 428 |
-
onClick={(e) => {
|
| 429 |
-
// THE ANCHOR IS HOISTED BEFORE THE UPDATER. `currentTarget` is null by the time a
|
| 430 |
-
// state updater runs, and reading it inside one is the white screen this rail's
|
| 431 |
-
// sibling already shipped once.
|
| 432 |
-
const anchor = e.currentTarget.getBoundingClientRect();
|
| 433 |
-
setFolderMenu(null);
|
| 434 |
-
setArmedDelete("");
|
| 435 |
-
setMenu((cur) => (cur && cur.key === route.key ? null : { key: route.key, anchor }));
|
| 436 |
-
}}
|
| 437 |
-
>
|
| 438 |
-
{DOTS}
|
| 439 |
-
</button>
|
| 440 |
-
</div>
|
| 441 |
-
);
|
| 442 |
-
};
|
| 443 |
-
|
| 444 |
-
return (
|
| 445 |
-
<nav className="cg-route-nav" aria-label="Route navigation">
|
| 446 |
-
<div className="cg-route-nav-head">
|
| 447 |
-
<span className="cg-views-title">Routes</span>
|
| 448 |
-
<button
|
| 449 |
-
type="button"
|
| 450 |
-
className="cg-link-btn cg-create-btn"
|
| 451 |
-
aria-haspopup="menu"
|
| 452 |
-
aria-expanded={!!createMenu}
|
| 453 |
-
onClick={(e) => {
|
| 454 |
-
const anchor = e.currentTarget.getBoundingClientRect();
|
| 455 |
-
closeMenus();
|
| 456 |
-
setCreateMenu((cur) => (cur ? null : anchor));
|
| 457 |
-
}}
|
| 458 |
-
>
|
| 459 |
-
<span className="cg-create-icon">+</span>
|
| 460 |
-
<span className="cg-create-label">{"Create new…"}</span>
|
| 461 |
-
</button>
|
| 462 |
-
</div>
|
| 463 |
-
|
| 464 |
-
{notice ? <span className="cg-route-nav-notice">{notice}</span> : null}
|
| 465 |
-
|
| 466 |
-
{/* CREATING PROMPTS TO SHARE, and the prompt is the whole of the done-when's third clause.
|
| 467 |
-
It offers the same two answers a new view is offered and routes the first one into the
|
| 468 |
-
SAME dialog a view or a field opens. */}
|
| 469 |
-
{created ? (
|
| 470 |
-
<div className="cg-route-share-prompt" role="status">
|
| 471 |
-
<span className="cg-route-share-line">
|
| 472 |
-
{`Saved ${created.label}. Only you can see it for now.`}
|
| 473 |
-
</span>
|
| 474 |
-
<span className="cg-form-actions">
|
| 475 |
-
<button
|
| 476 |
-
type="button"
|
| 477 |
-
className="cg-btn cg-btn-primary"
|
| 478 |
-
onClick={() => onShareRoute(created.key, created.label)}
|
| 479 |
-
>
|
| 480 |
-
Share route
|
| 481 |
-
</button>
|
| 482 |
-
<button type="button" className="cg-btn" onClick={() => onCreatedDismiss()}>
|
| 483 |
-
Keep private
|
| 484 |
-
</button>
|
| 485 |
-
</span>
|
| 486 |
-
</div>
|
| 487 |
-
) : null}
|
| 488 |
-
|
| 489 |
-
<div className="cg-route-nav-list" {...sectionDrop(ROOT_FOLDER_ID)}>
|
| 490 |
-
{listError ? (
|
| 491 |
-
<div className="cg-route-nav-notice" role="alert">
|
| 492 |
-
<span>{listError}</span>
|
| 493 |
-
<button type="button" className="cg-link-btn" onClick={onListRetry}>Retry</button>
|
| 494 |
-
</div>
|
| 495 |
-
) : routes.length === 0 ? (
|
| 496 |
-
<div className="cg-fold-empty">
|
| 497 |
-
No routes yet. Plan one on the map, then save it to keep it here.
|
| 498 |
-
</div>
|
| 499 |
-
) : null}
|
| 500 |
-
{groups.map((group) => {
|
| 501 |
-
if (!group.folder) {
|
| 502 |
-
return (
|
| 503 |
-
<div key={group.pinned ? "pinned" : "root"} className="cg-route-nav-root">
|
| 504 |
-
{group.items.map(renderRoute)}
|
| 505 |
-
</div>
|
| 506 |
-
);
|
| 507 |
-
}
|
| 508 |
-
const f = group.folder;
|
| 509 |
-
const synthetic = isSyntheticFolderId(f.id);
|
| 510 |
-
const depth = nesting.depth[f.id] || 1;
|
| 511 |
-
return (
|
| 512 |
-
<div key={f.id} className="cg-route-nav-group">
|
| 513 |
-
<div
|
| 514 |
-
className={
|
| 515 |
-
"cg-fold-head" +
|
| 516 |
-
(foldDrag === f.id ? " is-viewdrag" : "") +
|
| 517 |
-
(foldOver === f.id ? " is-drop-above" : "")
|
| 518 |
-
}
|
| 519 |
-
style={{ paddingLeft: 6 + (depth - 1) * 12 }}
|
| 520 |
-
draggable={!synthetic && renamingFolder !== f.id}
|
| 521 |
-
onDragStart={(e) => {
|
| 522 |
-
e.dataTransfer.setData(RFOLD_DRAG_TYPE, f.id);
|
| 523 |
-
e.dataTransfer.effectAllowed = "move";
|
| 524 |
-
setFoldDrag(f.id);
|
| 525 |
-
}}
|
| 526 |
-
onDragEnd={() => {
|
| 527 |
-
setFoldDrag("");
|
| 528 |
-
setFoldOver("");
|
| 529 |
-
}}
|
| 530 |
-
{...(synthetic ? {} : sectionDrop(f.id))}
|
| 531 |
-
>
|
| 532 |
-
<FolderMark icon={f.icon} size={16} />
|
| 533 |
-
{renamingFolder === f.id ? (
|
| 534 |
-
<input
|
| 535 |
-
className="cg-input cg-fold-rename"
|
| 536 |
-
value={draft}
|
| 537 |
-
autoFocus
|
| 538 |
-
aria-label={`Rename folder ${f.name}`}
|
| 539 |
-
onChange={(e) => setDraft(e.target.value)}
|
| 540 |
-
onBlur={commitFolderRename}
|
| 541 |
-
onKeyDown={(e) => {
|
| 542 |
-
if (e.key === "Enter") {
|
| 543 |
-
e.preventDefault();
|
| 544 |
-
commitFolderRename();
|
| 545 |
-
}
|
| 546 |
-
if (e.key === "Escape") {
|
| 547 |
-
e.preventDefault();
|
| 548 |
-
setRenamingFolder("");
|
| 549 |
-
}
|
| 550 |
-
}}
|
| 551 |
-
/>
|
| 552 |
-
) : (
|
| 553 |
-
<span className="cg-fold-name" title={f.name}>
|
| 554 |
-
{f.name}
|
| 555 |
-
</span>
|
| 556 |
-
)}
|
| 557 |
-
{synthetic ? null : (
|
| 558 |
-
<button
|
| 559 |
-
type="button"
|
| 560 |
-
className="cg-view-more"
|
| 561 |
-
aria-label={`Actions for folder ${f.name}`}
|
| 562 |
-
aria-haspopup="menu"
|
| 563 |
-
aria-expanded={folderMenu?.id === f.id}
|
| 564 |
-
onClick={(e) => {
|
| 565 |
-
const anchor = e.currentTarget.getBoundingClientRect();
|
| 566 |
-
setMenu(null);
|
| 567 |
-
setArmedDelete("");
|
| 568 |
-
setFolderMenu((cur) =>
|
| 569 |
-
cur && cur.id === f.id ? null : { id: f.id, anchor }
|
| 570 |
-
);
|
| 571 |
-
}}
|
| 572 |
-
>
|
| 573 |
-
{DOTS}
|
| 574 |
-
</button>
|
| 575 |
-
)}
|
| 576 |
-
</div>
|
| 577 |
-
{group.items.length === 0 ? (
|
| 578 |
-
<div className="cg-fold-empty">Empty. Drag a route here.</div>
|
| 579 |
-
) : (
|
| 580 |
-
group.items.map(renderRoute)
|
| 581 |
-
)}
|
| 582 |
-
</div>
|
| 583 |
-
);
|
| 584 |
-
})}
|
| 585 |
-
</div>
|
| 586 |
-
|
| 587 |
-
{menu && menuRoute ? (
|
| 588 |
-
<AnchoredOverlay
|
| 589 |
-
anchor={menu.anchor}
|
| 590 |
-
className="cg-view-menu cg-route-menu"
|
| 591 |
-
placement="bottom-end"
|
| 592 |
-
role="menu"
|
| 593 |
-
ariaLabel={`Actions for ${menuRoute.label}`}
|
| 594 |
-
onDismiss={closeMenus}
|
| 595 |
-
dataKind="route-menu"
|
| 596 |
-
>
|
| 597 |
-
<button
|
| 598 |
-
type="button"
|
| 599 |
-
role="menuitem"
|
| 600 |
-
className="cg-menu-item"
|
| 601 |
-
disabled={!menuRoute.mine}
|
| 602 |
-
title={
|
| 603 |
-
menuRoute.mine ? undefined : "Only the person who planned this route can rename it."
|
| 604 |
-
}
|
| 605 |
-
onClick={() => {
|
| 606 |
-
setDraft(menuRoute.label);
|
| 607 |
-
setRenamingKey(menuRoute.key);
|
| 608 |
-
closeMenus();
|
| 609 |
-
}}
|
| 610 |
-
>
|
| 611 |
-
<MenuLabel icon="rename" text="Rename" />
|
| 612 |
-
</button>
|
| 613 |
-
<button
|
| 614 |
-
type="button"
|
| 615 |
-
role="menuitem"
|
| 616 |
-
className="cg-menu-item"
|
| 617 |
-
onClick={() => {
|
| 618 |
-
onDuplicateRoute(menuRoute.key);
|
| 619 |
-
closeMenus();
|
| 620 |
-
}}
|
| 621 |
-
>
|
| 622 |
-
<MenuLabel icon="duplicate" text="Duplicate" />
|
| 623 |
-
</button>
|
| 624 |
-
<button
|
| 625 |
-
type="button"
|
| 626 |
-
role="menuitem"
|
| 627 |
-
className="cg-menu-item"
|
| 628 |
-
onClick={() => {
|
| 629 |
-
onShareRoute(menuRoute.key, menuRoute.label);
|
| 630 |
-
closeMenus();
|
| 631 |
-
}}
|
| 632 |
-
>
|
| 633 |
-
<MenuLabel icon={<ShareMark size={16} />} text="Share route" />
|
| 634 |
-
</button>
|
| 635 |
-
<div className="cg-menu-sep" />
|
| 636 |
-
<button
|
| 637 |
-
type="button"
|
| 638 |
-
role="menuitem"
|
| 639 |
-
className={
|
| 640 |
-
"cg-menu-item cg-menu-item--danger is-danger" +
|
| 641 |
-
(armedDelete === menuRoute.key ? " is-armed" : "")
|
| 642 |
-
}
|
| 643 |
-
disabled={!menuRoute.mine}
|
| 644 |
-
title={
|
| 645 |
-
menuRoute.mine ? undefined : "Only the person who planned this route can delete it."
|
| 646 |
-
}
|
| 647 |
-
onClick={() => {
|
| 648 |
-
if (armedDelete !== menuRoute.key) {
|
| 649 |
-
setArmedDelete(menuRoute.key);
|
| 650 |
-
return;
|
| 651 |
-
}
|
| 652 |
-
onDeleteRoute(menuRoute.key);
|
| 653 |
-
closeMenus();
|
| 654 |
-
}}
|
| 655 |
-
>
|
| 656 |
-
<MenuLabel
|
| 657 |
-
icon="trash"
|
| 658 |
-
text={
|
| 659 |
-
armedDelete === menuRoute.key
|
| 660 |
-
? "Delete route? The visit numbers go with it."
|
| 661 |
-
: "Delete route"
|
| 662 |
-
}
|
| 663 |
-
/>
|
| 664 |
-
</button>
|
| 665 |
-
</AnchoredOverlay>
|
| 666 |
-
) : null}
|
| 667 |
-
|
| 668 |
-
{folderMenu && folderRow ? (
|
| 669 |
-
<AnchoredOverlay
|
| 670 |
-
anchor={folderMenu.anchor}
|
| 671 |
-
className="cg-view-menu cg-route-menu"
|
| 672 |
-
placement="bottom-end"
|
| 673 |
-
role="menu"
|
| 674 |
-
ariaLabel={`Actions for folder ${folderRow.name}`}
|
| 675 |
-
onDismiss={closeMenus}
|
| 676 |
-
dataKind="route-folder-menu"
|
| 677 |
-
>
|
| 678 |
-
<button
|
| 679 |
-
type="button"
|
| 680 |
-
role="menuitem"
|
| 681 |
-
className="cg-menu-item"
|
| 682 |
-
disabled={!nestHere.ok}
|
| 683 |
-
title={nestHere.ok ? undefined : nestHere.reason}
|
| 684 |
-
onClick={() => {
|
| 685 |
-
onFolderCreate("New folder", folderRow.id);
|
| 686 |
-
closeMenus();
|
| 687 |
-
}}
|
| 688 |
-
>
|
| 689 |
-
<MenuLabel icon={<FolderMark size={16} />} text="New folder inside" />
|
| 690 |
-
</button>
|
| 691 |
-
<button
|
| 692 |
-
type="button"
|
| 693 |
-
role="menuitem"
|
| 694 |
-
className="cg-menu-item"
|
| 695 |
-
onClick={() => {
|
| 696 |
-
setDraft(folderRow.name);
|
| 697 |
-
setRenamingFolder(folderRow.id);
|
| 698 |
-
closeMenus();
|
| 699 |
-
}}
|
| 700 |
-
>
|
| 701 |
-
<MenuLabel icon="rename" text="Rename" />
|
| 702 |
-
</button>
|
| 703 |
-
<div className="cg-menu-sep" />
|
| 704 |
-
<button
|
| 705 |
-
type="button"
|
| 706 |
-
role="menuitem"
|
| 707 |
-
className={
|
| 708 |
-
"cg-menu-item cg-menu-item--danger is-danger" +
|
| 709 |
-
(armedDelete === folderRow.id ? " is-armed" : "")
|
| 710 |
-
}
|
| 711 |
-
onClick={() => {
|
| 712 |
-
if (armedDelete !== folderRow.id) {
|
| 713 |
-
setArmedDelete(folderRow.id);
|
| 714 |
-
return;
|
| 715 |
-
}
|
| 716 |
-
onFolderDelete(folderRow.id);
|
| 717 |
-
closeMenus();
|
| 718 |
-
}}
|
| 719 |
-
>
|
| 720 |
-
<MenuLabel
|
| 721 |
-
icon="trash"
|
| 722 |
-
text={
|
| 723 |
-
armedDelete === folderRow.id
|
| 724 |
-
? "Delete folder? Its routes move to the top level."
|
| 725 |
-
: "Delete folder"
|
| 726 |
-
}
|
| 727 |
-
/>
|
| 728 |
-
</button>
|
| 729 |
-
</AnchoredOverlay>
|
| 730 |
-
) : null}
|
| 731 |
-
|
| 732 |
-
{createMenu ? (
|
| 733 |
-
<AnchoredOverlay
|
| 734 |
-
anchor={createMenu}
|
| 735 |
-
className="cg-view-menu cg-create-flyout cg-route-create"
|
| 736 |
-
placement="bottom-start"
|
| 737 |
-
role="menu"
|
| 738 |
-
ariaLabel="Create"
|
| 739 |
-
onDismiss={() => setCreateMenu(null)}
|
| 740 |
-
dataKind="route-create-new"
|
| 741 |
-
>
|
| 742 |
-
<button
|
| 743 |
-
type="button"
|
| 744 |
-
role="menuitem"
|
| 745 |
-
className="cg-menu-item cg-create-row"
|
| 746 |
-
onClick={() => {
|
| 747 |
-
onFolderCreate("New folder", null);
|
| 748 |
-
setCreateMenu(null);
|
| 749 |
-
}}
|
| 750 |
-
>
|
| 751 |
-
<MenuLabel icon={<FolderMark size={16} />} text="Folder" />
|
| 752 |
-
</button>
|
| 753 |
-
<div className="cg-menu-sep" />
|
| 754 |
-
<button
|
| 755 |
-
type="button"
|
| 756 |
-
role="menuitem"
|
| 757 |
-
className="cg-menu-item cg-create-row"
|
| 758 |
-
onClick={() => {
|
| 759 |
-
onCreateRoute();
|
| 760 |
-
setCreateMenu(null);
|
| 761 |
-
}}
|
| 762 |
-
>
|
| 763 |
-
<MenuLabel icon="addEnd" text="Route" />
|
| 764 |
-
</button>
|
| 765 |
-
<p className="cg-create-note">
|
| 766 |
-
A route starts private. After you save it you are asked whether to share it, and you
|
| 767 |
-
can change that later from Share route.
|
| 768 |
-
</p>
|
| 769 |
-
</AnchoredOverlay>
|
| 770 |
-
) : null}
|
| 771 |
-
</nav>
|
| 772 |
-
);
|
| 773 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|