gramene-search 2.18.2 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13176,7 +13176,540 @@ var $261baeb81c4d4d8a$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.conn
13176
13176
 
13177
13177
 
13178
13178
 
13179
- const $0f50f369018a42ef$var$MAX_GENE_IDS = 1000; // Define the maximum number of gene IDs allowed
13179
+
13180
+
13181
+
13182
+
13183
+
13184
+ // Conflict rows rendered before the "show N more" cap, mirroring the Refine
13185
+ // sidebar's INITIAL_VISIBLE. A large paste can produce hundreds of conflicts.
13186
+ const $3b525b9a0ec89bcb$var$INITIAL_VISIBLE = 25;
13187
+ // Candidates shown per row before collapsing. Nothing guarantees 15 is the ceiling.
13188
+ const $3b525b9a0ec89bcb$var$MAX_CANDIDATES = 50;
13189
+ // Genome bulk buttons offered. More than this and the toolbar is worse than the rows.
13190
+ const $3b525b9a0ec89bcb$var$MAX_GENOME_ACTIONS = 6;
13191
+ // A collapsible bucket with a count, following facets/FacetCounts.js FacetGroup.
13192
+ // An empty bucket renders nothing rather than an empty heading.
13193
+ const $3b525b9a0ec89bcb$var$Bucket = ({ title: title, count: count, tone: tone, defaultOpen: defaultOpen, actions: actions, children: children })=>{
13194
+ const [open, setOpen] = (0, $gXNCa$react.useState)(!!defaultOpen);
13195
+ if (!count) return null;
13196
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13197
+ className: `glr-bucket glr-bucket-${tone}`,
13198
+ children: [
13199
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13200
+ className: "glr-bucket-header",
13201
+ onClick: ()=>setOpen(!open),
13202
+ children: [
13203
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13204
+ className: "glr-caret",
13205
+ children: open ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsChevronDown), {}) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsChevronRight), {})
13206
+ }),
13207
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13208
+ className: "glr-bucket-title",
13209
+ children: title
13210
+ }),
13211
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13212
+ className: "glr-bucket-count",
13213
+ children: count.toLocaleString()
13214
+ }),
13215
+ actions && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13216
+ className: "glr-bucket-actions",
13217
+ children: actions
13218
+ })
13219
+ ]
13220
+ }),
13221
+ open && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
13222
+ className: "glr-bucket-body",
13223
+ children: children
13224
+ })
13225
+ ]
13226
+ });
13227
+ };
13228
+ /** "sorghum_bicolort2tcas" -> "Sorghum bicolort2tcas" */ function $3b525b9a0ec89bcb$var$speciesLabel(systemName) {
13229
+ if (!systemName) return null;
13230
+ const s = systemName.replace(/_/g, ' ');
13231
+ return s.charAt(0).toUpperCase() + s.slice(1);
13232
+ }
13233
+ /**
13234
+ * Pure resolver for a validation response.
13235
+ *
13236
+ * `choices` is keyed by INDEX into `ambiguous`, not by the input string: a pasted
13237
+ * token can be anything, and `obj['__proto__'] = [...]` on a plain object sets the
13238
+ * prototype instead of a key.
13239
+ *
13240
+ * An index with NO entry keeps *all* of that input's matches — the "nothing is
13241
+ * silently dropped" default. An index mapped to `[]` means the user explicitly
13242
+ * dropped the row, which is why every read tests `undefined` rather than falsiness.
13243
+ */ const $3b525b9a0ec89bcb$var$GeneListResolver = ({ validation: validation, choices: choices, meta: meta, onChange: onChange })=>{
13244
+ const [query, setQuery] = (0, $gXNCa$react.useState)('');
13245
+ const [showAll, setShowAll] = (0, $gXNCa$react.useState)(false);
13246
+ const [expanded, setExpanded] = (0, $gXNCa$react.useState)({});
13247
+ const { resolved: resolved, ambiguous: ambiguous, unknown: unknown } = validation;
13248
+ const keptFor = (i, a)=>choices[i] === undefined ? a.matches : choices[i];
13249
+ const setKept = (i, a, next)=>{
13250
+ const c = {
13251
+ ...choices
13252
+ };
13253
+ // Drop back to the default when everything is kept, so "untouched" and
13254
+ // "explicitly kept all" stay indistinguishable and the state stays small.
13255
+ if (next.length === a.matches.length) delete c[i];
13256
+ else c[i] = next;
13257
+ onChange(c);
13258
+ };
13259
+ const toggle = (i, a, id)=>{
13260
+ const cur = keptFor(i, a);
13261
+ setKept(i, a, cur.indexOf(id) === -1 ? [
13262
+ ...cur,
13263
+ id
13264
+ ] : cur.filter((x)=>x !== id));
13265
+ };
13266
+ /**
13267
+ * Which genomes would actually narrow something.
13268
+ *
13269
+ * A plain "prefer sorghum" button is useless on this index: 94 of its ~100
13270
+ * genomes are sorghum accessions, so a conflict like Sobic.001G000200 — whose
13271
+ * candidates are sorghum_bicolor and sorghum_bicolort2tcas — is entirely
13272
+ * sorghum on both sides. The useful axis is the individual genome, and only
13273
+ * those that would change a row are worth offering.
13274
+ */ const genomeActions = (0, $gXNCa$react.useMemo)(()=>{
13275
+ const tally = new Map();
13276
+ ambiguous.forEach((a)=>{
13277
+ const names = new Set();
13278
+ a.matches.forEach((id)=>{
13279
+ const d = meta[id];
13280
+ if (d && d.system_name) names.add(d.system_name);
13281
+ });
13282
+ names.forEach((sn)=>{
13283
+ const kept = a.matches.filter((id)=>meta[id] && meta[id].system_name === sn);
13284
+ // Only counts if it narrows: keeping every candidate is a no-op.
13285
+ if (kept.length && kept.length < a.matches.length) tally.set(sn, (tally.get(sn) || 0) + 1);
13286
+ });
13287
+ });
13288
+ return [
13289
+ ...tally.entries()
13290
+ ].sort((x, y)=>y[1] - x[1]).slice(0, $3b525b9a0ec89bcb$var$MAX_GENOME_ACTIONS);
13291
+ }, [
13292
+ ambiguous,
13293
+ meta
13294
+ ]);
13295
+ const applyGenome = (systemName)=>(e)=>{
13296
+ e.stopPropagation();
13297
+ const c = {
13298
+ ...choices
13299
+ };
13300
+ ambiguous.forEach((a, i)=>{
13301
+ const kept = a.matches.filter((id)=>meta[id] && meta[id].system_name === systemName);
13302
+ // Rows with no candidate in this genome are left exactly as they are,
13303
+ // rather than emptied.
13304
+ if (kept.length && kept.length < a.matches.length) c[i] = kept;
13305
+ });
13306
+ onChange(c);
13307
+ };
13308
+ const bulk = (mode)=>(e)=>{
13309
+ // These buttons sit inside the clickable header; don't collapse the section.
13310
+ e.stopPropagation();
13311
+ if (mode === 'all') return onChange({});
13312
+ const c = {};
13313
+ ambiguous.forEach((a, i)=>{
13314
+ if (mode === 'first' && a.matches.length > 1) c[i] = [
13315
+ a.matches[0]
13316
+ ];
13317
+ if (mode === 'none') c[i] = [];
13318
+ });
13319
+ onChange(c);
13320
+ };
13321
+ const filtered = (0, $gXNCa$react.useMemo)(()=>{
13322
+ const q = query.trim().toLowerCase();
13323
+ const withIndex = ambiguous.map((a, i)=>({
13324
+ a: a,
13325
+ i: i
13326
+ }));
13327
+ if (!q) return withIndex;
13328
+ return withIndex.filter(({ a: a })=>a.input.toLowerCase().indexOf(q) !== -1 || a.matches.some((id)=>id.toLowerCase().indexOf(q) !== -1));
13329
+ }, [
13330
+ ambiguous,
13331
+ query
13332
+ ]);
13333
+ const shown = showAll ? filtered : filtered.slice(0, $3b525b9a0ec89bcb$var$INITIAL_VISIBLE);
13334
+ const candidate = (i, a, id)=>{
13335
+ const doc = meta[id];
13336
+ const kept = keptFor(i, a).indexOf(id) !== -1;
13337
+ const species = $3b525b9a0ec89bcb$var$speciesLabel(doc && doc.system_name);
13338
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("label", {
13339
+ className: `glr-candidate${kept ? ' glr-candidate-kept' : ''}`,
13340
+ children: [
13341
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("input", {
13342
+ type: "checkbox",
13343
+ checked: kept,
13344
+ onChange: ()=>toggle(i, a, id)
13345
+ }),
13346
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13347
+ className: "glr-candidate-id",
13348
+ children: id
13349
+ }),
13350
+ species ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13351
+ className: "glr-candidate-species",
13352
+ children: species
13353
+ }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13354
+ className: "glr-candidate-species glr-muted",
13355
+ children: "\u2026"
13356
+ }),
13357
+ doc && doc.description && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13358
+ className: "glr-candidate-desc",
13359
+ title: doc.description,
13360
+ children: doc.description
13361
+ }),
13362
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
13363
+ type: "button",
13364
+ className: "glr-only",
13365
+ title: "Keep only this one",
13366
+ onClick: ()=>setKept(i, a, [
13367
+ id
13368
+ ]),
13369
+ children: "only"
13370
+ })
13371
+ ]
13372
+ }, id);
13373
+ };
13374
+ const row = ({ a: a, i: i })=>{
13375
+ const kept = keptFor(i, a);
13376
+ // Long candidate lists start collapsed so a 15-match row doesn't bury the rest.
13377
+ const isOpen = expanded[i] !== undefined ? expanded[i] : a.matches.length <= 3;
13378
+ const visible = isOpen ? a.matches.slice(0, $3b525b9a0ec89bcb$var$MAX_CANDIDATES) : [];
13379
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13380
+ className: "glr-row",
13381
+ children: [
13382
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13383
+ className: "glr-input",
13384
+ children: [
13385
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
13386
+ type: "button",
13387
+ className: "glr-row-toggle",
13388
+ onClick: ()=>setExpanded((s)=>({
13389
+ ...s,
13390
+ [i]: !isOpen
13391
+ })),
13392
+ children: isOpen ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsChevronDown), {}) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsChevronRight), {})
13393
+ }),
13394
+ a.input,
13395
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Badge), {
13396
+ bg: kept.length === 0 ? 'danger' : 'secondary',
13397
+ className: "glr-input-count",
13398
+ children: [
13399
+ kept.length,
13400
+ "/",
13401
+ a.matches.length
13402
+ ]
13403
+ })
13404
+ ]
13405
+ }),
13406
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
13407
+ className: "glr-candidates",
13408
+ children: isOpen ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
13409
+ children: [
13410
+ visible.map((id)=>candidate(i, a, id)),
13411
+ a.matches.length > $3b525b9a0ec89bcb$var$MAX_CANDIDATES && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
13412
+ className: "glr-hint",
13413
+ children: [
13414
+ "\u2026 ",
13415
+ a.matches.length - $3b525b9a0ec89bcb$var$MAX_CANDIDATES,
13416
+ " more. Search to narrow."
13417
+ ]
13418
+ }),
13419
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
13420
+ className: "glr-row-bulk",
13421
+ children: [
13422
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
13423
+ type: "button",
13424
+ className: "glr-only",
13425
+ onClick: ()=>setKept(i, a, a.matches),
13426
+ children: "all"
13427
+ }),
13428
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
13429
+ type: "button",
13430
+ className: "glr-only",
13431
+ onClick: ()=>setKept(i, a, []),
13432
+ children: "none"
13433
+ })
13434
+ ]
13435
+ })
13436
+ ]
13437
+ }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
13438
+ className: "glr-collapsed",
13439
+ children: [
13440
+ a.matches.length,
13441
+ " candidates \u2014 ",
13442
+ kept.length === a.matches.length ? 'all kept' : `${kept.length} kept`
13443
+ ]
13444
+ })
13445
+ })
13446
+ ]
13447
+ }, i);
13448
+ };
13449
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13450
+ className: "glr",
13451
+ children: [
13452
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)($3b525b9a0ec89bcb$var$Bucket, {
13453
+ title: "Conflicts \u2014 one identifier, several genes",
13454
+ count: ambiguous.length,
13455
+ tone: "warn",
13456
+ defaultOpen: true,
13457
+ actions: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
13458
+ children: [
13459
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13460
+ size: "sm",
13461
+ variant: "outline-secondary",
13462
+ onClick: bulk('first'),
13463
+ children: "Keep first"
13464
+ }),
13465
+ ' ',
13466
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13467
+ size: "sm",
13468
+ variant: "outline-secondary",
13469
+ onClick: bulk('none'),
13470
+ children: "Drop all"
13471
+ }),
13472
+ ' ',
13473
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13474
+ size: "sm",
13475
+ variant: "outline-secondary",
13476
+ onClick: bulk('all'),
13477
+ children: "Reset"
13478
+ })
13479
+ ]
13480
+ }),
13481
+ children: [
13482
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("p", {
13483
+ className: "glr-hint",
13484
+ children: [
13485
+ "These identifiers match more than one gene. ",
13486
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("strong", {
13487
+ children: "All matches are kept unless you narrow them"
13488
+ }),
13489
+ " \u2014 untick what you don't want, or use",
13490
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("em", {
13491
+ children: " only"
13492
+ }),
13493
+ " to keep a single gene."
13494
+ ]
13495
+ }),
13496
+ genomeActions.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13497
+ className: "glr-genomes",
13498
+ children: [
13499
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13500
+ className: "glr-hint glr-genomes-label",
13501
+ children: "Keep only matches from:"
13502
+ }),
13503
+ genomeActions.map(([sn, n])=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Button), {
13504
+ size: "sm",
13505
+ variant: "outline-primary",
13506
+ className: "glr-genome-btn",
13507
+ onClick: applyGenome(sn),
13508
+ children: [
13509
+ $3b525b9a0ec89bcb$var$speciesLabel(sn),
13510
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Badge), {
13511
+ bg: "light",
13512
+ text: "dark",
13513
+ className: "glr-genome-count",
13514
+ children: [
13515
+ "narrows ",
13516
+ n.toLocaleString()
13517
+ ]
13518
+ })
13519
+ ]
13520
+ }, sn))
13521
+ ]
13522
+ }),
13523
+ ambiguous.length > $3b525b9a0ec89bcb$var$INITIAL_VISIBLE && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Control, {
13524
+ type: "search",
13525
+ size: "sm",
13526
+ className: "glr-search",
13527
+ placeholder: "Search conflicts\u2026",
13528
+ value: query,
13529
+ onChange: (e)=>{
13530
+ setQuery(e.target.value);
13531
+ setShowAll(false);
13532
+ }
13533
+ }),
13534
+ shown.map(row),
13535
+ filtered.length > shown.length && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("button", {
13536
+ type: "button",
13537
+ className: "glr-more",
13538
+ onClick: ()=>setShowAll(true),
13539
+ children: [
13540
+ "show ",
13541
+ (filtered.length - shown.length).toLocaleString(),
13542
+ " more"
13543
+ ]
13544
+ }),
13545
+ query && filtered.length === 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("p", {
13546
+ className: "glr-hint",
13547
+ children: [
13548
+ "No conflicts match \u201C",
13549
+ query,
13550
+ "\u201D."
13551
+ ]
13552
+ })
13553
+ ]
13554
+ }),
13555
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($3b525b9a0ec89bcb$var$Bucket, {
13556
+ title: "Resolved",
13557
+ count: resolved.length,
13558
+ tone: "ok",
13559
+ defaultOpen: false,
13560
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
13561
+ className: "glr-resolved",
13562
+ children: resolved.map((r, i)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13563
+ className: "glr-resolved-row",
13564
+ children: [
13565
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13566
+ className: "glr-resolved-input",
13567
+ children: r.input
13568
+ }),
13569
+ r.input !== r.id && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
13570
+ children: [
13571
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13572
+ className: "glr-arrow",
13573
+ children: "\u2192"
13574
+ }),
13575
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
13576
+ className: "glr-resolved-id",
13577
+ children: r.id
13578
+ })
13579
+ ]
13580
+ })
13581
+ ]
13582
+ }, i))
13583
+ })
13584
+ }),
13585
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)($3b525b9a0ec89bcb$var$Bucket, {
13586
+ title: "Not recognised",
13587
+ count: unknown.length,
13588
+ tone: "bad",
13589
+ defaultOpen: false,
13590
+ actions: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13591
+ size: "sm",
13592
+ variant: "outline-secondary",
13593
+ onClick: (e)=>{
13594
+ e.stopPropagation();
13595
+ navigator.clipboard && navigator.clipboard.writeText(unknown.join('\n'));
13596
+ },
13597
+ children: "Copy"
13598
+ }),
13599
+ children: [
13600
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("p", {
13601
+ className: "glr-hint",
13602
+ children: "No gene matches these. Matching is case-sensitive for stable IDs, so a lowercased ID may not be found even when the gene exists."
13603
+ }),
13604
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
13605
+ className: "glr-unknown",
13606
+ children: unknown.join('\n')
13607
+ })
13608
+ ]
13609
+ })
13610
+ ]
13611
+ });
13612
+ };
13613
+ var $3b525b9a0ec89bcb$export$2e2bcd8739ae039 = $3b525b9a0ec89bcb$var$GeneListResolver;
13614
+
13615
+
13616
+ // Client for the gene-lists API ({api}/gene_lists*).
13617
+ //
13618
+ // Mirrors the shape of bundles/savedViews.js: Firebase Bearer token, one
13619
+ // status -> message mapper so every call site reports failures the same way, and
13620
+ // JSON request bodies. Deliberately a plain module rather than a redux bundle —
13621
+ // the feature is self-contained in UserGeneLists and shares no state.
13622
+ // Server-side limits, mirrored so we can say something useful instead of
13623
+ // surfacing a bare 400.
13624
+ const $4abf453405c18681$export$7fadd81f3363fc57 = 6000;
13625
+ const $4abf453405c18681$export$7f2835aa6d834dde = 255;
13626
+ // Candidate hydration passes ids through the URL. ~500 ids is a 414 (the URL
13627
+ // crosses ~8.5KB), so batch well under that.
13628
+ const $4abf453405c18681$var$HYDRATE_BATCH = 150;
13629
+ function $4abf453405c18681$export$cbb0be6dabd4295(action, status) {
13630
+ if (status === 401) return new Error(`${action} failed \u{2014} please sign in again.`);
13631
+ if (status === 404) return new Error(`${action} failed \u{2014} that list no longer exists.`);
13632
+ if (status === 405) return new Error('Gene lists are not available on this server yet.');
13633
+ if (status === 502 || status === 503) return new Error(`${action} failed \u{2014} the gene index is unreachable. Try again shortly.`);
13634
+ return new Error(`${action} failed (${status})`);
13635
+ }
13636
+ // The API returns {message} on most 4xx; prefer it, since it names the offending
13637
+ // field (e.g. which limit was exceeded).
13638
+ async function $4abf453405c18681$var$readError(res, action) {
13639
+ try {
13640
+ const body = await res.json();
13641
+ if (body && body.message) return new Error(body.message);
13642
+ } catch (e) {
13643
+ // not JSON — fall through to the status mapper
13644
+ }
13645
+ return $4abf453405c18681$export$cbb0be6dabd4295(action, res.status);
13646
+ }
13647
+ async function $4abf453405c18681$export$6c06d9a9d837c44e(api, ids) {
13648
+ const res = await fetch(`${api}/gene_lists/validate`, {
13649
+ method: 'POST',
13650
+ headers: {
13651
+ 'Content-Type': 'application/json'
13652
+ },
13653
+ body: JSON.stringify(ids)
13654
+ });
13655
+ if (!res.ok) throw await $4abf453405c18681$var$readError(res, 'Validation');
13656
+ const body = await res.json();
13657
+ // Be strict about the contract. A server still returning the old
13658
+ // {ids, missing, hash} shape must fail here and say so, rather than quietly
13659
+ // validating nothing — which is exactly how the previous client broke.
13660
+ if (!body || !Array.isArray(body.resolved)) throw new Error('Unexpected response from the validation service (is the server up to date?).');
13661
+ return {
13662
+ resolved: body.resolved || [],
13663
+ ambiguous: body.ambiguous || [],
13664
+ unknown: body.unknown || []
13665
+ };
13666
+ }
13667
+ async function $4abf453405c18681$export$41928ff990e251ce(api, ids) {
13668
+ const unique = [
13669
+ ...new Set(ids)
13670
+ ].filter(Boolean);
13671
+ const out = {};
13672
+ for(let i = 0; i < unique.length; i += $4abf453405c18681$var$HYDRATE_BATCH){
13673
+ const batch = unique.slice(i, i + $4abf453405c18681$var$HYDRATE_BATCH);
13674
+ // {!terms} takes its values literally, so no id needs escaping, and it sits in
13675
+ // `fq`, where Solr local params are reliable.
13676
+ const fq = encodeURIComponent(`{!terms f=id}${batch.join(',')}`);
13677
+ // `rows` is mandatory. Without it the endpoint answers with its default of 20
13678
+ // and the rest of the batch silently disappears.
13679
+ const url = `${api}/search?q=*:*&fq=${fq}&rows=${batch.length}` + `&fl=id,name,system_name,description`;
13680
+ try {
13681
+ const res = await fetch(url);
13682
+ if (!res.ok) continue;
13683
+ const body = await res.json();
13684
+ const docs = body && body.response && body.response.docs || [];
13685
+ docs.forEach((d)=>{
13686
+ if (d && d.id) out[d.id] = d;
13687
+ });
13688
+ } catch (e) {
13689
+ // leave this batch un-hydrated
13690
+ }
13691
+ }
13692
+ return out;
13693
+ }
13694
+ async function $4abf453405c18681$export$87f42b3480ee6c49(api, token, { label: label, site: site, isPublic: isPublic, ids: ids }) {
13695
+ const res = await fetch(`${api}/gene_lists`, {
13696
+ method: 'POST',
13697
+ headers: {
13698
+ 'Content-Type': 'application/json',
13699
+ Authorization: `Bearer ${token}`
13700
+ },
13701
+ body: JSON.stringify({
13702
+ label: label,
13703
+ site: site,
13704
+ isPublic: !!isPublic,
13705
+ ids: ids
13706
+ })
13707
+ });
13708
+ if (!res.ok) throw await $4abf453405c18681$var$readError(res, 'Save');
13709
+ return res.json();
13710
+ }
13711
+
13712
+
13180
13713
  const $0f50f369018a42ef$var$formatDate = (value)=>{
13181
13714
  if (!value) return '';
13182
13715
  const d = new Date(value);
@@ -13727,14 +14260,20 @@ const $0f50f369018a42ef$var$GeneListDisplayComponent = (props)=>{
13727
14260
  };
13728
14261
  const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13729
14262
  const [geneList, setGeneList] = (0, $gXNCa$react.useState)('');
13730
- const [listHash, setListHash] = (0, $gXNCa$react.useState)(null);
13731
- const [validationError, setValidationError] = (0, $gXNCa$react.useState)([]);
13732
14263
  const [listName, setListName] = (0, $gXNCa$react.useState)('');
13733
14264
  const [listIsPublic, setListIsPublic] = (0, $gXNCa$react.useState)(false);
13734
- const [validatedList, setValidatedList] = (0, $gXNCa$react.useState)([]);
14265
+ // The three-bucket validate response, the user's narrowing decisions, and the
14266
+ // hydrated gene docs used to label candidates.
14267
+ const [validation, setValidation] = (0, $gXNCa$react.useState)(null);
14268
+ const [choices, setChoices] = (0, $gXNCa$react.useState)({});
14269
+ const [meta, setMeta] = (0, $gXNCa$react.useState)({});
13735
14270
  const [errorMessage, setErrorMessage] = (0, $gXNCa$react.useState)('');
13736
- const [loading, setLoading] = (0, $gXNCa$react.useState)(false); // New loading state
13737
- const [user, setUser] = (0, $gXNCa$react.useState)({});
14271
+ const [notice, setNotice] = (0, $gXNCa$react.useState)('');
14272
+ const [loading, setLoading] = (0, $gXNCa$react.useState)(false);
14273
+ const [saving, setSaving] = (0, $gXNCa$react.useState)(false);
14274
+ // null, not {}: the initial value is read to decide whether Save is enabled, and
14275
+ // an empty object is truthy, which used to offer Save to a logged-out visitor.
14276
+ const [user, setUser] = (0, $gXNCa$react.useState)(null);
13738
14277
  const auth = props.auth;
13739
14278
  (0, $gXNCa$react.useEffect)(()=>{
13740
14279
  if (!auth) return;
@@ -13743,6 +14282,38 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13743
14282
  }, [
13744
14283
  auth
13745
14284
  ]);
14285
+ // The single source of truth for what gets saved. An ambiguous input the user
14286
+ // never touched contributes ALL of its matches, so nothing is silently dropped;
14287
+ // narrowing is a choice, not a requirement.
14288
+ // `choices[i] === undefined` means untouched -> keep all. `choices[i] === []`
14289
+ // means the user explicitly dropped the row, so this must NOT be `|| a.matches`:
14290
+ // an empty array is falsy, and that would silently re-add everything they just
14291
+ // removed.
14292
+ const keptFor = (i, a)=>choices[i] === undefined ? a.matches : choices[i];
14293
+ const idsToSave = (0, $gXNCa$react.useMemo)(()=>{
14294
+ if (!validation) return [];
14295
+ const out = validation.resolved.map((r)=>r.id);
14296
+ validation.ambiguous.forEach((a, i)=>out.push(...keptFor(i, a)));
14297
+ // Mandatory, not tidiness: the same gene reaches this list twice routinely —
14298
+ // two inputs can resolve to one id, and a resolved id can also appear among
14299
+ // another input's candidates. Duplicates would inflate n_genes and change the
14300
+ // server's content hash, breaking the save endpoint's idempotency.
14301
+ return [
14302
+ ...new Set(out)
14303
+ ];
14304
+ }, [
14305
+ validation,
14306
+ choices
14307
+ ]);
14308
+ // How much of the total came from conflicts, for the summary line — with
14309
+ // keep-all-by-default a single identifier can contribute 15 genes.
14310
+ const fromConflicts = (0, $gXNCa$react.useMemo)(()=>{
14311
+ if (!validation) return 0;
14312
+ return validation.ambiguous.reduce((n, a, i)=>n + keptFor(i, a).length, 0);
14313
+ }, [
14314
+ validation,
14315
+ choices
14316
+ ]);
13746
14317
  // Function to handle gene list input
13747
14318
  const handleGeneListChange = (event)=>{
13748
14319
  setGeneList(event.target.value);
@@ -13759,65 +14330,66 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13759
14330
  };
13760
14331
  // Function to submit gene list for validation
13761
14332
  const handleSubmit = async ()=>{
13762
- const geneArray = geneList.split('\n').filter(Boolean); // Convert the gene list into an array and filter out empty values
13763
- if (geneArray.length > $0f50f369018a42ef$var$MAX_GENE_IDS) {
13764
- setErrorMessage(`You have exceeded the maximum limit of ${$0f50f369018a42ef$var$MAX_GENE_IDS} gene IDs.`);
14333
+ // The server only drops blanks and duplicates; splitting and trimming is ours
14334
+ // to do. Accept commas, tabs and semicolons as well as newlines.
14335
+ const geneArray = [
14336
+ ...new Set(geneList.split(/[\s,;]+/).map((s)=>s.trim()).filter(Boolean))
14337
+ ];
14338
+ if (geneArray.length === 0) {
14339
+ setErrorMessage('Paste at least one gene identifier.');
14340
+ return;
14341
+ }
14342
+ if (geneArray.length > (0, $4abf453405c18681$export$7fadd81f3363fc57)) {
14343
+ setErrorMessage(`That is ${geneArray.length.toLocaleString()} identifiers; the limit is ${(0, $4abf453405c18681$export$7fadd81f3363fc57).toLocaleString()}.`);
13765
14344
  return;
13766
14345
  }
13767
- setErrorMessage(''); // Reset error message if validation passes
13768
- setLoading(true); // Set loading state to true to show progress
14346
+ setErrorMessage('');
14347
+ setNotice('');
14348
+ setLoading(true);
13769
14349
  try {
13770
- const response = await fetch(`${props.api}/gene_lists/validate`, {
13771
- method: 'POST',
13772
- headers: {
13773
- 'Content-Type': 'application/json'
13774
- },
13775
- body: JSON.stringify(geneArray)
13776
- });
13777
- const result = await response.json();
13778
- if (result.hash) {
13779
- setListHash(result.hash);
13780
- setValidatedList(result.ids);
13781
- setValidationError(result.missing);
13782
- } else // Handle errors from validation
13783
- alert('Error during validation.');
14350
+ const result = await (0, $4abf453405c18681$export$6c06d9a9d837c44e)(props.api, geneArray);
14351
+ setValidation(result);
14352
+ setChoices({});
14353
+ // Candidates are only choosable if the user can see what they are, so pull
14354
+ // species/description for every candidate id. Best-effort and non-blocking:
14355
+ // the resolver renders bare ids until this lands.
14356
+ const candidateIds = result.ambiguous.reduce((acc, a)=>acc.concat(a.matches), []);
14357
+ if (candidateIds.length) (0, $4abf453405c18681$export$41928ff990e251ce)(props.api, candidateIds).then(setMeta).catch(()=>{});
14358
+ else setMeta({});
13784
14359
  } catch (error) {
13785
- alert('There was an error with the validation service.', error);
14360
+ setValidation(null);
14361
+ setErrorMessage(error.message || 'There was an error with the validation service.');
13786
14362
  } finally{
13787
- setLoading(false); // Set loading to false when the request is complete
14363
+ setLoading(false);
13788
14364
  }
13789
14365
  };
13790
14366
  // Function to save the validated gene list
13791
14367
  const handleSaveList = async ()=>{
13792
- const queryParams = {
13793
- label: listName,
13794
- hash: listHash,
13795
- site: props.site,
13796
- n_genes: validatedList.length,
13797
- isPublic: listIsPublic
13798
- };
13799
- const queryString = new URLSearchParams(queryParams).toString();
13800
- const token = await user.getIdToken();
14368
+ setErrorMessage('');
14369
+ setNotice('');
14370
+ setSaving(true);
13801
14371
  try {
13802
- const response = await fetch(`${props.api}/gene_lists?${queryString}`, {
13803
- method: 'POST',
13804
- headers: {
13805
- "Content-Type": "application/json",
13806
- "Authorization": `Bearer ${token}`
13807
- }
14372
+ // Inside the try: getIdToken() rejects when the session has expired, and
14373
+ // that used to escape as an unhandled rejection with no UI feedback.
14374
+ const token = await user.getIdToken();
14375
+ const result = await (0, $4abf453405c18681$export$87f42b3480ee6c49)(props.api, token, {
14376
+ label: listName,
14377
+ site: props.site,
14378
+ isPublic: listIsPublic,
14379
+ ids: idsToSave
13808
14380
  });
13809
- if (!response.ok) throw new Error('Failed to save list');
13810
- const result = await response.json();
13811
- console.log(result);
13812
14381
  setGeneList('');
13813
- setListHash(null);
13814
- setValidatedList([]);
13815
- setValidationError([]);
14382
+ setValidation(null);
14383
+ setChoices({});
14384
+ setMeta({});
13816
14385
  setListName('');
13817
14386
  setListIsPublic(false);
14387
+ setNotice(`Saved \u{201C}${listName}\u{201D} \u{2014} ${(result.n_genes || idsToSave.length).toLocaleString()} genes.`);
13818
14388
  if (props.onListSaved) props.onListSaved();
13819
14389
  } catch (error) {
13820
- console.error("There was an problem with fetch", error);
14390
+ setErrorMessage(error.message || 'Failed to save the gene list.');
14391
+ } finally{
14392
+ setSaving(false);
13821
14393
  }
13822
14394
  };
13823
14395
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
@@ -13836,7 +14408,7 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13836
14408
  rows: 10,
13837
14409
  value: geneList,
13838
14410
  onChange: handleGeneListChange,
13839
- placeholder: `Paste or drop your gene list here (Maximum: ${$0f50f369018a42ef$var$MAX_GENE_IDS} IDs)`,
14411
+ placeholder: `Paste or drop your gene list here (Maximum: ${(0, $4abf453405c18681$export$7fadd81f3363fc57)} IDs)`,
13840
14412
  onDrop: handleDrop,
13841
14413
  onDragOver: handleDragOver
13842
14414
  })
@@ -13844,8 +14416,16 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13844
14416
  }),
13845
14417
  errorMessage && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Alert), {
13846
14418
  variant: "danger",
14419
+ dismissible: true,
14420
+ onClose: ()=>setErrorMessage(''),
13847
14421
  children: errorMessage
13848
14422
  }),
14423
+ notice && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Alert), {
14424
+ variant: "success",
14425
+ dismissible: true,
14426
+ onClose: ()=>setNotice(''),
14427
+ children: notice
14428
+ }),
13849
14429
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13850
14430
  variant: "primary",
13851
14431
  onClick: handleSubmit,
@@ -13869,53 +14449,55 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13869
14449
  })
13870
14450
  ]
13871
14451
  }),
13872
- !loading && listHash && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13873
- className: "validation-summary mt-3 p-3 border rounded bg-light",
14452
+ !loading && validation && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
13874
14453
  children: [
14454
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $3b525b9a0ec89bcb$export$2e2bcd8739ae039), {
14455
+ validation: validation,
14456
+ choices: choices,
14457
+ meta: meta,
14458
+ onChange: setChoices
14459
+ }),
13875
14460
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
14461
+ className: "glr-summary",
13876
14462
  children: [
13877
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
13878
- className: "text-success",
13879
- children: [
13880
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("strong", {
13881
- children: validatedList.length
13882
- }),
13883
- " valid"
13884
- ]
14463
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
14464
+ className: "glr-summary-total",
14465
+ children: idsToSave.length.toLocaleString()
13885
14466
  }),
13886
- validationError.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13887
- className: "text-danger mt-1",
14467
+ ' ',
14468
+ "gene",
14469
+ idsToSave.length === 1 ? '' : 's',
14470
+ " will be saved",
14471
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
14472
+ className: "glr-summary-detail",
13888
14473
  children: [
13889
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("strong", {
13890
- children: validationError.length
14474
+ validation.resolved.length.toLocaleString(),
14475
+ " identifier",
14476
+ validation.resolved.length === 1 ? '' : 's',
14477
+ " matched exactly one gene",
14478
+ validation.ambiguous.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
14479
+ children: [
14480
+ '; ',
14481
+ validation.ambiguous.length.toLocaleString(),
14482
+ " conflicting identifier",
14483
+ validation.ambiguous.length === 1 ? '' : 's',
14484
+ " contributing",
14485
+ ' ',
14486
+ fromConflicts.toLocaleString(),
14487
+ " candidate",
14488
+ fromConflicts === 1 ? '' : 's',
14489
+ Object.keys(choices).length > 0 && ` (${Object.keys(choices).length.toLocaleString()} narrowed)`
14490
+ ]
13891
14491
  }),
13892
- " not found"
14492
+ '. ',
14493
+ "Genes matched more than once are counted once."
13893
14494
  ]
13894
14495
  })
13895
14496
  ]
13896
- }),
13897
- validationError.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("details", {
13898
- className: "mt-2",
13899
- children: [
13900
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("summary", {
13901
- style: {
13902
- cursor: 'pointer'
13903
- },
13904
- children: "Show unrecognized IDs"
13905
- }),
13906
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
13907
- className: "small mt-2 mb-0",
13908
- style: {
13909
- maxHeight: 150,
13910
- overflow: 'auto'
13911
- },
13912
- children: validationError.join('\n')
13913
- })
13914
- ]
13915
14497
  })
13916
14498
  ]
13917
14499
  }),
13918
- !loading && validatedList.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
14500
+ !loading && idsToSave.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
13919
14501
  className: "save-list mt-4",
13920
14502
  children: [
13921
14503
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Form).Group, {
@@ -13942,7 +14524,8 @@ const $0f50f369018a42ef$var$GeneListComponent = (props)=>{
13942
14524
  user ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13943
14525
  variant: "primary",
13944
14526
  onClick: handleSaveList,
13945
- children: "Save Gene List"
14527
+ disabled: saving || !listName.trim(),
14528
+ children: saving ? "Saving\u2026" : 'Save Gene List'
13946
14529
  }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
13947
14530
  variant: "secondary",
13948
14531
  disabled: true,