fsanyoto commited on
Commit
5fe6f43
·
verified ·
1 Parent(s): 1904e77

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
- }