@datagrok/proteomics 1.2.0 → 1.3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +18 -5
  3. package/dist/package-test.js +1 -1
  4. package/dist/package-test.js.map +1 -1
  5. package/dist/package.js +1 -1
  6. package/dist/package.js.map +1 -1
  7. package/docs/personas-and-capabilities.md +16 -0
  8. package/docs/publishing-design-rationale.md +140 -0
  9. package/package.json +20 -5
  10. package/package.png +0 -0
  11. package/src/analysis/imputation.ts +3 -1
  12. package/src/analysis/normalization.ts +9 -4
  13. package/src/analysis/spc-storage.ts +1 -1
  14. package/src/menu.ts +5 -2
  15. package/src/package-api.ts +10 -2
  16. package/src/package-test.ts +3 -1
  17. package/src/package.g.ts +17 -3
  18. package/src/package.ts +31 -15
  19. package/src/panels/published-analysis-panel.ts +2 -2
  20. package/src/publishing/assert-published-shape.ts +2 -2
  21. package/src/publishing/package-settings-editor.ts +125 -0
  22. package/src/publishing/publish-project.ts +8 -8
  23. package/src/publishing/publish-settings.ts +57 -3
  24. package/src/publishing/publish-state.ts +45 -16
  25. package/src/publishing/reviewer-groups.ts +24 -0
  26. package/src/publishing/share-dialog.ts +42 -27
  27. package/src/publishing/trim-dataframe.ts +8 -1
  28. package/src/tests/abundance-correlation.ts +135 -0
  29. package/src/tests/analysis.ts +63 -0
  30. package/src/tests/enrichment-visualization.ts +95 -9
  31. package/src/tests/enrichment.ts +2 -1
  32. package/src/tests/project-vocabulary.ts +39 -0
  33. package/src/tests/publish-roundtrip.ts +40 -40
  34. package/src/tests/publish-spike.ts +2 -2
  35. package/src/tests/rank-abundance.ts +85 -0
  36. package/src/utils/abundance-detection.ts +145 -0
  37. package/src/viewers/abundance-correlation.ts +208 -0
  38. package/src/viewers/enrichment-viewers.ts +58 -24
  39. package/src/viewers/rank-abundance.ts +153 -0
  40. package/src/viewers/volcano.ts +3 -0
  41. package/test-console-output-1.log +1145 -1090
  42. package/test-record-1.mp4 +0 -0
  43. package/src/tests/group-mean-correlation.ts +0 -139
  44. package/src/viewers/group-mean-correlation.ts +0 -218
@@ -0,0 +1,125 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+
5
+ import {parseProjectVocabulary} from './publish-settings';
6
+ import {loadReviewerGroups, reviewerGroupName} from './reviewer-groups';
7
+
8
+ /**
9
+ * Custom editor for the Proteomics package settings (Plugins → Proteomics →
10
+ * Settings). Replaces the auto-generated form so `projectVocabulary` gets a
11
+ * proper add / edit / remove list instead of a single comma-separated text box.
12
+ *
13
+ * Follows the platform contract used by HitTriage / SequenceTranslator: read the
14
+ * live value with `prop.get(null)` and stage edits with `prop.set(null, value)`;
15
+ * the Settings pane's SAVE button commits the staged values. (Direct
16
+ * `Package.setSettings()` is NOT the write path here — the property staging is.)
17
+ *
18
+ * Because a custom editor replaces the ENTIRE settings form, every Proteomics
19
+ * setting is rendered here, not just the vocabulary — otherwise the others would
20
+ * vanish from the pane.
21
+ */
22
+ export async function proteomicsSettingsEditorWidget(propList: DG.Property[]): Promise<DG.Widget> {
23
+ const findProp = (name: string): DG.Property | undefined => propList.find((p) => p.name === name);
24
+
25
+ const inputs: DG.InputBase[] = [];
26
+
27
+ const addStringInput = (name: string, caption: string): void => {
28
+ const prop = findProp(name);
29
+ if (!prop) return;
30
+ const cur = (prop.get(null) as string | null) ?? '';
31
+ const input = ui.input.string(caption, {value: cur});
32
+ input.onChanged.subscribe(() => {
33
+ try { prop.set(null, input.value ?? ''); } catch (e) { console.error(e); }
34
+ });
35
+ inputs.push(input);
36
+ };
37
+
38
+ const addBoolInput = (name: string, caption: string): void => {
39
+ const prop = findProp(name);
40
+ if (!prop) return;
41
+ const cur = prop.get(null);
42
+ const input = ui.input.bool(caption, {value: cur === true || cur === 'true'});
43
+ input.onChanged.subscribe(() => {
44
+ try { prop.set(null, !!input.value); } catch (e) { console.error(e); }
45
+ });
46
+ inputs.push(input);
47
+ };
48
+
49
+ // Default reviewer team: a dropdown of the same teams the Share dialog offers,
50
+ // so admins pick from a controlled list rather than free-typing a name. Falls
51
+ // back to a free-text input if the group list can't be loaded (e.g. no ACL).
52
+ const addDefaultGroupInput = async (): Promise<void> => {
53
+ const prop = findProp('defaultReviewerGroup');
54
+ if (!prop) return;
55
+ const cur = (prop.get(null) as string | null) ?? '';
56
+ let groupNames: string[] = [];
57
+ try {
58
+ groupNames = [...new Set((await loadReviewerGroups()).map(reviewerGroupName).filter(Boolean))];
59
+ } catch (e) { console.error(e); }
60
+
61
+ if (groupNames.length > 0) {
62
+ const input = ui.input.choice('Default Reviewer Team', {
63
+ value: groupNames.includes(cur) ? cur : null,
64
+ items: groupNames,
65
+ nullable: true,
66
+ });
67
+ input.setTooltip('Team pre-selected in the Share for Review dialog. Leave blank to default to the first team.');
68
+ input.onChanged.subscribe(() => {
69
+ try { prop.set(null, input.value ?? ''); } catch (e) { console.error(e); }
70
+ });
71
+ inputs.push(input);
72
+ } else {
73
+ const input = ui.input.string('Default Reviewer Team', {value: cur});
74
+ input.setTooltip('Team name pre-selected in the Share for Review dialog. Leave blank to default to the first team.');
75
+ input.onChanged.subscribe(() => {
76
+ try { prop.set(null, input.value ?? ''); } catch (e) { console.error(e); }
77
+ });
78
+ inputs.push(input);
79
+ }
80
+ };
81
+
82
+ addStringInput('reviewSpaceName', 'Review Space Name');
83
+ addStringInput('reviewNamePrefix', 'Review Name Prefix');
84
+ await addDefaultGroupInput();
85
+ addBoolInput('verifyPublishedDashboard', 'Verify Published Dashboard');
86
+
87
+ const root = ui.divV([ui.form(inputs)]);
88
+
89
+ // ── Project vocabulary — add / edit / remove list ───────────────────────────
90
+ const vocabProp = findProp('projectVocabulary');
91
+ if (vocabProp) {
92
+ // Serialize on every edit; parseProjectVocabulary trims + dedupes on read.
93
+ let items = parseProjectVocabulary(vocabProp.get(null));
94
+ const commit = (): void => {
95
+ try { vocabProp.set(null, items.join(', ')); } catch (e) { console.error(e); }
96
+ };
97
+
98
+ const rowsHost = ui.divV([]);
99
+ const renderRows = (): void => {
100
+ ui.empty(rowsHost);
101
+ if (items.length === 0) {
102
+ rowsHost.appendChild(ui.divText('No projects yet — add one below.',
103
+ {style: {color: 'var(--grey-4)', fontStyle: 'italic', margin: '4px 0'}}));
104
+ }
105
+ items.forEach((val, i) => {
106
+ const input = ui.input.string('', {value: val});
107
+ input.input.style.width = '260px';
108
+ input.onChanged.subscribe(() => { items[i] = (input.value ?? '').trim(); commit(); });
109
+ const del = ui.icons.delete(() => { items.splice(i, 1); renderRows(); commit(); }, 'Remove project');
110
+ rowsHost.appendChild(ui.divH([input.input, del], {style: {alignItems: 'center', gap: '8px', margin: '2px 0'}}));
111
+ });
112
+ };
113
+
114
+ const addBtn = ui.button('+ Add project', () => { items.push(''); renderRows(); });
115
+ renderRows();
116
+
117
+ root.appendChild(ui.h1('Projects', {style: {marginTop: '12px', marginBottom: '4px'}}));
118
+ root.appendChild(ui.divText('Analysts share each analysis under one of these. Add, rename, or remove names here.',
119
+ {style: {color: 'var(--grey-5)', marginBottom: '6px'}}));
120
+ root.appendChild(rowsHost);
121
+ root.appendChild(addBtn);
122
+ }
123
+
124
+ return DG.Widget.fromRoot(root);
125
+ }
@@ -4,7 +4,7 @@ import {awaitCheck, delay} from '@datagrok-libraries/test/src/test';
4
4
 
5
5
  import {
6
6
  META_COLUMNS, PUBLISHED_TAGS, PublishOptions, PublishedMetadata,
7
- slugifyTarget,
7
+ slugifyProject,
8
8
  } from './publish-state';
9
9
  import {trimEnrichmentForPublish, trimForPublish} from './trim-dataframe';
10
10
  import {
@@ -135,7 +135,7 @@ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): P
135
135
  pThreshold = optsAny.pThreshold;
136
136
  } catch { /* defaults */ }
137
137
 
138
- const slug = slugifyTarget(opts.target);
138
+ const slug = slugifyProject(opts.project);
139
139
  const priorVersionN = parsePriorVersion((opts.priorVersion as any)?.name);
140
140
  const version = priorVersionN > 0 ? priorVersionN + 1 : 1;
141
141
  const publishId = generateUuid();
@@ -149,7 +149,7 @@ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): P
149
149
  })();
150
150
 
151
151
  const meta: PublishedMetadata = {
152
- target: opts.target,
152
+ project: opts.project,
153
153
  publishedAt,
154
154
  publishedBy,
155
155
  publishedByEmail,
@@ -203,8 +203,8 @@ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): P
203
203
  }
204
204
  const umbrellaClient = dapiAny.spaces.id(umbrella.id);
205
205
 
206
- // ─── Step 5 — ensure per-target child Space ─────────────────────────────────
207
- pi.description = 'Ensuring per-target Space...';
206
+ // ─── Step 5 — ensure per-project child Space ────────────────────────────────
207
+ pi.description = 'Ensuring per-project Space...';
208
208
  const childName = `${reviewNamePrefix()}-${slug}`;
209
209
  let childSpace: any = null;
210
210
 
@@ -231,7 +231,7 @@ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): P
231
231
  childSpace = await findChildByName();
232
232
  if (!childSpace) {
233
233
  throw new Error(
234
- `Per-target child Space '${childName}' exists but could not be resolved via ` +
234
+ `Per-project child Space '${childName}' exists but could not be resolved via ` +
235
235
  `umbrella children.list() (msg: ${msg}).`);
236
236
  }
237
237
  }
@@ -244,10 +244,10 @@ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): P
244
244
  const projectName = `${reviewNamePrefix()}-${slug}-v${version}-${dateStr}`;
245
245
  (project as any).name = projectName;
246
246
  // Set friendlyName explicitly so the platform doesn't "humanize" the slug
247
- // (e.g. DMD → "DM D"). Use the original target verbatim for a clean label,
247
+ // (e.g. DMD → "DM D"). Use the original project name verbatim for a clean label,
248
248
  // and derive the label's leading words from the (configurable) name prefix.
249
249
  (project as any).friendlyName =
250
- `${reviewNamePrefix().replace(/-/g, ' ')} ${opts.target} v${version} ${dateStr}`;
250
+ `${reviewNamePrefix().replace(/-/g, ' ')} ${opts.project} v${version} ${dateStr}`;
251
251
 
252
252
  try { (project as any).options[PUBLISHED_TAGS.PUBLISHED_ID] = publishId; } catch { /* swallow */ }
253
253
  // Step 9 writes the supersede pointer on the prior project; the NEW
@@ -8,7 +8,7 @@
8
8
  * What is and isn't configurable, and why:
9
9
  * - The **umbrella Space** ({@link reviewSpaceName}) and the **name prefix**
10
10
  * ({@link reviewNamePrefix}) are free text.
11
- * - The structural suffix `-<target-slug>-v<version>-<date>` is NOT configurable: the
11
+ * - The structural suffix `-<project-slug>-v<version>-<date>` is NOT configurable: the
12
12
  * republish flow finds the prior version by matching `"<prefix>-<slug>-v"` and parsing
13
13
  * the `-v<n>-` segment (see `findPriorShare` in `publish-state.ts`). Letting the suffix
14
14
  * vary would break version detection. So we expose the prefix, keep the skeleton fixed.
@@ -32,17 +32,27 @@ function readSetting(key: string, fallback: string): string {
32
32
  return fallback;
33
33
  }
34
34
 
35
- /** Umbrella Space the per-target review Spaces live under. Setting: `reviewSpaceName`. */
35
+ /** Umbrella Space the per-project review Spaces live under. Setting: `reviewSpaceName`. */
36
36
  export function reviewSpaceName(): string {
37
37
  return readSetting('reviewSpaceName', DEFAULT_REVIEW_SPACE);
38
38
  }
39
39
 
40
- /** Prefix for the per-target child Space and the published project name.
40
+ /** Prefix for the per-project child Space and the published project name.
41
41
  * Setting: `reviewNamePrefix`. The `-<slug>-v<version>-<date>` suffix stays fixed. */
42
42
  export function reviewNamePrefix(): string {
43
43
  return readSetting('reviewNamePrefix', DEFAULT_REVIEW_NAME_PREFIX);
44
44
  }
45
45
 
46
+ /**
47
+ * Name of the team pre-selected in the Share for Review dialog's "Share with
48
+ * team" picker. Setting: `defaultReviewerGroup`. Empty (the default) means "no
49
+ * preference" — the dialog falls back to the first available team. A stale value
50
+ * (team renamed/removed) is ignored the same way.
51
+ */
52
+ export function defaultReviewerGroup(): string {
53
+ return readSetting('defaultReviewerGroup', '');
54
+ }
55
+
46
56
  /**
47
57
  * Whether to re-open each published dashboard and assert it survives a reload
48
58
  * (the heavy round-trip check). Default ON — keep it for client deliverables;
@@ -57,3 +67,47 @@ export function verifyPublishedDashboard(): boolean {
57
67
  } catch { /* settings not ready — use default */ }
58
68
  return true;
59
69
  }
70
+
71
+ /**
72
+ * The controlled list of project names an analyst may share under. Maintained by
73
+ * package administrators via the `projectVocabulary` package setting — a
74
+ * comma-separated string they edit in the package Settings panel (add / modify /
75
+ * remove names); the Share for Review dialog offers exactly these (no free text).
76
+ *
77
+ * Parses the setting robustly: a comma / newline-separated string (the string
78
+ * property), or a JS array (future-proofing if the property is ever promoted to
79
+ * a `string_list`). Returns a de-duplicated, trimmed, order-preserving list;
80
+ * `[]` when unset or not-yet-loaded, which the dialog treats as "ask an admin".
81
+ */
82
+ export function getProjectVocabulary(): string[] {
83
+ let raw: unknown;
84
+ try {
85
+ raw = (_package?.settings as Record<string, unknown> | undefined)?.['projectVocabulary'];
86
+ } catch { /* settings not ready */ return []; }
87
+ return parseProjectVocabulary(raw);
88
+ }
89
+
90
+ /**
91
+ * Pure parser for the `projectVocabulary` setting value — extracted so it can be
92
+ * unit-tested without a live package. Accepts a comma / newline-separated string
93
+ * (the string property) or a JS array (future `string_list`). Returns a
94
+ * de-duplicated, trimmed, order-preserving list.
95
+ */
96
+ export function parseProjectVocabulary(raw: unknown): string[] {
97
+ let items: string[] = [];
98
+ if (Array.isArray(raw))
99
+ items = raw.map((v) => String(v));
100
+ else if (typeof raw === 'string')
101
+ items = raw.split(/[\r\n,]+/);
102
+
103
+ const seen = new Set<string>();
104
+ const out: string[] = [];
105
+ for (const it of items) {
106
+ const t = it.trim();
107
+ if (t.length > 0 && !seen.has(t)) {
108
+ seen.add(t);
109
+ out.push(t);
110
+ }
111
+ }
112
+ return out;
113
+ }
@@ -26,7 +26,7 @@ export const PUBLISHED_TAGS = {
26
26
  PUBLISHED_AT: 'proteomics.published_at',
27
27
  PUBLISHED_BY: 'proteomics.published_by',
28
28
  PUBLISHED_BY_EMAIL: 'proteomics.published_by_email',
29
- PUBLISHED_TARGET: 'proteomics.published_target',
29
+ PUBLISHED_PROJECT: 'proteomics.published_project',
30
30
  PUBLISHED_DE_METHOD: 'proteomics.published_de_method',
31
31
  PUBLISHED_FC_THRESHOLD: 'proteomics.published_fc_threshold',
32
32
  PUBLISHED_P_THRESHOLD: 'proteomics.published_p_threshold',
@@ -48,7 +48,7 @@ export const META_COLUMNS = {
48
48
  PUBLISHED_AT: '_meta_published_at',
49
49
  PUBLISHED_BY: '_meta_published_by',
50
50
  PUBLISHED_BY_EMAIL: '_meta_published_by_email',
51
- PUBLISHED_TARGET: '_meta_published_target',
51
+ PUBLISHED_PROJECT: '_meta_published_project',
52
52
  PUBLISHED_DE_METHOD: '_meta_published_de_method',
53
53
  PUBLISHED_FC_THRESHOLD: '_meta_published_fc_threshold',
54
54
  PUBLISHED_P_THRESHOLD: '_meta_published_p_threshold',
@@ -59,10 +59,20 @@ export const META_COLUMNS = {
59
59
  SUPERSEDES: '_meta_supersedes',
60
60
  } as const;
61
61
 
62
+ /**
63
+ * Legacy persisted keys from before the Target→Project rename (the project label
64
+ * used to be called "target"). Read-only fallbacks so analyses published under
65
+ * the old naming still open. Never written — {@link setPublishedTags} only emits
66
+ * the current `PUBLISHED_PROJECT` key.
67
+ */
68
+ export const LEGACY_PUBLISHED_TARGET_TAG = 'proteomics.published_target' as const;
69
+ export const LEGACY_PUBLISHED_TARGET_COL = '_meta_published_target' as const;
70
+
62
71
  /** Typed view of one published-analysis DataFrame's metadata. */
63
72
  export interface PublishedMetadata {
64
- /** Raw user input — never use directly for paths/URLs (use {@link slugifyTarget}). */
65
- target: string;
73
+ /** Raw user input — the project name. Never use directly for paths/URLs
74
+ * (use {@link slugifyProject}). */
75
+ project: string;
66
76
  /** Normalized to Date when read; ISO string accepted on write. */
67
77
  publishedAt: Date;
68
78
  publishedBy: string;
@@ -70,7 +80,7 @@ export interface PublishedMetadata {
70
80
  deMethod: 'limma' | 'deqms' | 't-test' | 'spectronaut' | string;
71
81
  fcThreshold: number;
72
82
  pThreshold: number;
73
- /** Monotonically increasing per (target, group); first share is version 1. */
83
+ /** Monotonically increasing per (project, group); first share is version 1. */
74
84
  version: number;
75
85
  /** The published Project's id — set after `projects.save` returns. */
76
86
  publishId: string;
@@ -83,7 +93,7 @@ export interface PublishedMetadata {
83
93
 
84
94
  /** Input shape consumed by `share-dialog.ts` → `publishAnalysis(df, opts)`. */
85
95
  export interface PublishOptions {
86
- target: string;
96
+ project: string;
87
97
  reviewerGroup: DG.Group;
88
98
  note: string;
89
99
  /** Set by {@link findPriorShare} when this is a republish; null on first share. */
@@ -121,7 +131,7 @@ export function isPublished(df: DG.DataFrame): boolean {
121
131
  * Reads the published metadata from a DataFrame, column-FIRST and tag-SECOND
122
132
  * (Pitfall 3 belt-and-braces). Returns `null` when the DataFrame is not a
123
133
  * published analysis. Returns a populated object when at least the required
124
- * load-bearing fields (target, publishId) are recoverable; per-field corruption
134
+ * load-bearing fields (project, publishId) are recoverable; per-field corruption
125
135
  * falls back gracefully without crashing the whole read.
126
136
  */
127
137
  export function getPublishedMetadata(df: DG.DataFrame): PublishedMetadata | null {
@@ -171,16 +181,35 @@ export function getPublishedMetadata(df: DG.DataFrame): PublishedMetadata | null
171
181
  return s === 'true';
172
182
  };
173
183
 
174
- const target = readString('PUBLISHED_TARGET', 'PUBLISHED_TARGET');
184
+ // Project label — read the current key, then fall back to the pre-rename
185
+ // `published_target` tag/column so already-published analyses still open.
186
+ const readProject = (): string | null => {
187
+ const current = readString('PUBLISHED_PROJECT', 'PUBLISHED_PROJECT');
188
+ if (current != null) return current;
189
+ try {
190
+ const col = df.col(LEGACY_PUBLISHED_TARGET_COL);
191
+ if (col != null) {
192
+ const v = col.get(0);
193
+ if (v != null && v !== '') return String(v);
194
+ }
195
+ } catch { /* fall through to legacy tag */ }
196
+ try {
197
+ return df.getTag(LEGACY_PUBLISHED_TARGET_TAG) ?? null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ };
202
+
203
+ const project = readProject();
175
204
  const publishId = readString('PUBLISHED_ID', 'PUBLISHED_ID');
176
- if (target == null || publishId == null) return null;
205
+ if (project == null || publishId == null) return null;
177
206
 
178
207
  const publishedAt = readDate();
179
208
  const versionRaw = readString('PUBLISHED_VERSION', 'PUBLISHED_VERSION');
180
209
  const versionNum = versionRaw != null ? parseInt(versionRaw, 10) : NaN;
181
210
 
182
211
  return {
183
- target,
212
+ project,
184
213
  publishedAt: publishedAt ?? new Date(NaN),
185
214
  publishedBy: readString('PUBLISHED_BY', 'PUBLISHED_BY') ?? '',
186
215
  publishedByEmail: readString('PUBLISHED_BY_EMAIL', 'PUBLISHED_BY_EMAIL'),
@@ -212,7 +241,7 @@ export function setPublishedTags(df: DG.DataFrame, meta: PublishedMetadata): voi
212
241
  df.setTag(PUBLISHED_TAGS.PUBLISHED_BY, meta.publishedBy);
213
242
  if (meta.publishedByEmail != null)
214
243
  df.setTag(PUBLISHED_TAGS.PUBLISHED_BY_EMAIL, meta.publishedByEmail);
215
- df.setTag(PUBLISHED_TAGS.PUBLISHED_TARGET, meta.target);
244
+ df.setTag(PUBLISHED_TAGS.PUBLISHED_PROJECT, meta.project);
216
245
  df.setTag(PUBLISHED_TAGS.PUBLISHED_DE_METHOD, meta.deMethod);
217
246
  df.setTag(PUBLISHED_TAGS.PUBLISHED_FC_THRESHOLD, String(meta.fcThreshold));
218
247
  df.setTag(PUBLISHED_TAGS.PUBLISHED_P_THRESHOLD, String(meta.pThreshold));
@@ -226,7 +255,7 @@ export function setPublishedTags(df: DG.DataFrame, meta: PublishedMetadata): voi
226
255
  }
227
256
 
228
257
  /**
229
- * Sanitizes a freeform target string per D-01 into a safe slug.
258
+ * Sanitizes a freeform project string per D-01 into a safe slug.
230
259
  *
231
260
  * Rules:
232
261
  * - Charset `[A-Za-z0-9._-]` (case PRESERVED — do NOT lowercase)
@@ -235,7 +264,7 @@ export function setPublishedTags(df: DG.DataFrame, meta: PublishedMetadata): voi
235
264
  * - Cap at 64 chars
236
265
  * - Empty result → `'unnamed'` (never produce empty slug — would break Project.name)
237
266
  */
238
- export function slugifyTarget(raw: string): string {
267
+ export function slugifyProject(raw: string): string {
239
268
  if (raw == null) return 'unnamed';
240
269
  let s = String(raw)
241
270
  .replace(/[^A-Za-z0-9._-]+/g, '-')
@@ -249,7 +278,7 @@ export function slugifyTarget(raw: string): string {
249
278
 
250
279
  /**
251
280
  * Looks up the most recent prior published Project matching the slugified
252
- * target. Used by Plan 05 (`share-dialog.ts`) for republish-detection banner
281
+ * project. Used by Plan 05 (`share-dialog.ts`) for republish-detection banner
253
282
  * and Plan 04 (`publish-project.ts`) for the supersede chain.
254
283
  *
255
284
  * Smart-filter `like` confirmed working by spike 15-00 (assumption A8); the
@@ -260,8 +289,8 @@ export function slugifyTarget(raw: string): string {
260
289
  * the platform ACL implicitly scoping `dapi.projects.filter` to projects the
261
290
  * current user can administer.
262
291
  */
263
- export async function findPriorShare(target: string, _group: DG.Group | null): Promise<DG.Project | null> {
264
- const slug = slugifyTarget(target);
292
+ export async function findPriorShare(project: string, _group: DG.Group | null): Promise<DG.Project | null> {
293
+ const slug = slugifyProject(project);
265
294
  const namePrefix = `${reviewNamePrefix()}-${slug}-v`;
266
295
  const versionRe = /-v(\d+)-/;
267
296
 
@@ -0,0 +1,24 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+
4
+ /**
5
+ * Loads the teams eligible as reviewers — excludes hidden and personal groups
6
+ * and the built-in 'All users' group. Shared by the Share for Review dialog and
7
+ * the package settings editor so both offer exactly the same set.
8
+ */
9
+ export async function loadReviewerGroups(): Promise<DG.Group[]> {
10
+ const allGroups = await grok.dapi.groups.list();
11
+ const allUsersId = (DG.Group as any).defaultGroupsIds?.['All users'];
12
+ return (allGroups ?? []).filter((g: any) => {
13
+ if (g == null) return false;
14
+ if (g.hidden) return false;
15
+ if (g.personal) return false;
16
+ if (allUsersId && g.id === allUsersId) return false;
17
+ return true;
18
+ });
19
+ }
20
+
21
+ /** Display name used as the choice key — friendlyName, falling back to name. */
22
+ export function reviewerGroupName(g: DG.Group): string {
23
+ return (g as any).friendlyName ?? (g as any).name ?? '';
24
+ }
@@ -3,10 +3,11 @@ import * as ui from 'datagrok-api/ui';
3
3
  import * as DG from 'datagrok-api/dg';
4
4
 
5
5
  import {
6
- DE_COMPLETE_TAG, PublishOptions, findPriorShare, slugifyTarget,
6
+ DE_COMPLETE_TAG, PublishOptions, findPriorShare, slugifyProject,
7
7
  } from './publish-state';
8
8
  import {publishAnalysis} from './publish-project';
9
- import {reviewNamePrefix, verifyPublishedDashboard} from './publish-settings';
9
+ import {defaultReviewerGroup, getProjectVocabulary, reviewNamePrefix, verifyPublishedDashboard} from './publish-settings';
10
+ import {loadReviewerGroups, reviewerGroupName} from './reviewer-groups';
10
11
 
11
12
  /**
12
13
  * Analyst-facing share dialog. Single entry point into the publish flow —
@@ -32,15 +33,7 @@ export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void>
32
33
  return;
33
34
  }
34
35
 
35
- const allGroups = await grok.dapi.groups.list();
36
- const allUsersId = (DG.Group as any).defaultGroupsIds?.['All users'];
37
- const filteredGroups = (allGroups ?? []).filter((g: any) => {
38
- if (g == null) return false;
39
- if (g.hidden) return false;
40
- if (g.personal) return false;
41
- if (allUsersId && g.id === allUsersId) return false;
42
- return true;
43
- });
36
+ const filteredGroups = await loadReviewerGroups();
44
37
 
45
38
  if (filteredGroups.length === 0) {
46
39
  grok.shell.warning('No teams available to share with. Ask an admin to create a reviewer team.');
@@ -49,16 +42,38 @@ export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void>
49
42
 
50
43
  const groupByName = new Map<string, DG.Group>();
51
44
  for (const g of filteredGroups) {
52
- const name = (g as any).friendlyName ?? (g as any).name ?? '';
45
+ const name = reviewerGroupName(g);
53
46
  if (name && !groupByName.has(name)) groupByName.set(name, g);
54
47
  }
55
48
  const groupItems = Array.from(groupByName.keys());
56
49
 
57
- const targetInput = ui.input.string('Target', {value: ''});
58
- targetInput.setTooltip('Free text your team\'s name for this target (e.g., MYH7-DMD, muscle-atrophy panel)');
50
+ // Pre-select the admin-configured default team when it's still available;
51
+ // otherwise fall back to the first team in the list.
52
+ const configuredDefault = defaultReviewerGroup();
53
+ const defaultGroupName = configuredDefault && groupItems.includes(configuredDefault)
54
+ ? configuredDefault
55
+ : groupItems[0];
56
+
57
+ // Project is a controlled vocabulary — admins maintain the list in the
58
+ // `projectVocabulary` package setting; the analyst picks, never free-types.
59
+ const projectVocabulary = getProjectVocabulary();
60
+ if (projectVocabulary.length === 0) {
61
+ grok.shell.warning(
62
+ 'No projects are configured to share under. Ask a package administrator to add project ' +
63
+ 'names in the Proteomics package settings (projectVocabulary).');
64
+ return;
65
+ }
66
+
67
+ const projectInput = ui.input.choice('Project', {
68
+ value: projectVocabulary[0],
69
+ items: projectVocabulary,
70
+ nullable: false,
71
+ });
72
+ projectInput.setTooltip('Pick the project to share this analysis under. ' +
73
+ 'Maintained by package administrators in the Proteomics package settings.');
59
74
 
60
75
  const groupInput = ui.input.choice('Share with team', {
61
- value: groupItems[0],
76
+ value: defaultGroupName,
62
77
  items: groupItems,
63
78
  nullable: false,
64
79
  });
@@ -92,14 +107,14 @@ export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void>
92
107
  };
93
108
 
94
109
  const updateBannerAndSummary = async (): Promise<void> => {
95
- const target = (targetInput.value ?? '').trim();
110
+ const project = (projectInput.value ?? '').trim();
96
111
  const groupName = groupInput.value;
97
112
  const group = groupName ? groupByName.get(groupName) ?? null : null;
98
- const slug = target ? slugifyTarget(target) : '<no-target>';
113
+ const slug = project ? slugifyProject(project) : '<no-project>';
99
114
  const dateStr = new Date().toISOString().slice(0, 10);
100
115
 
101
- if (target && group) {
102
- try { priorVersion = await findPriorShare(target, group); }
116
+ if (project && group) {
117
+ try { priorVersion = await findPriorShare(project, group); }
103
118
  catch { priorVersion = null; }
104
119
  } else {
105
120
  priorVersion = null;
@@ -124,32 +139,32 @@ export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void>
124
139
  : 'New share';
125
140
 
126
141
  summary.innerHTML = '';
127
- summary.appendChild(ui.divText(`Project: ${projectName}`));
142
+ summary.appendChild(ui.divText(`Published as: ${projectName}`));
128
143
  summary.appendChild(ui.divText(`Will be visible to: ${groupLabel}`));
129
144
  summary.appendChild(ui.divText(`Status: ${statusLabel}`));
130
145
  summary.appendChild(ui.divText(`Date: ${dateStr}`));
131
146
  };
132
147
 
133
- targetInput.onChanged.subscribe(() => { void updateBannerAndSummary(); });
148
+ projectInput.onChanged.subscribe(() => { void updateBannerAndSummary(); });
134
149
  groupInput.onChanged.subscribe(() => { void updateBannerAndSummary(); });
135
150
 
136
151
  await updateBannerAndSummary();
137
152
 
138
153
  ui.dialog('Share Analysis for Review')
139
- .add(targetInput)
154
+ .add(projectInput)
140
155
  .add(groupInput)
141
156
  .add(noteInput)
142
157
  .add(verifyInput)
143
158
  .add(banner)
144
159
  .add(summary)
145
160
  .onOK(async () => {
146
- const target = (targetInput.value ?? '').trim();
147
- if (!target) { grok.shell.warning('Target is required.'); return; }
161
+ const project = (projectInput.value ?? '').trim();
162
+ if (!project) { grok.shell.warning('Project is required.'); return; }
148
163
  const group = groupInput.value ? groupByName.get(groupInput.value) : null;
149
164
  if (!group) { grok.shell.warning('Pick a team to share with.'); return; }
150
165
 
151
166
  const opts: PublishOptions = {
152
- target,
167
+ project,
153
168
  reviewerGroup: group,
154
169
  note: (noteInput.value ?? '') as string,
155
170
  priorVersion,
@@ -157,12 +172,12 @@ export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void>
157
172
  };
158
173
 
159
174
  try {
160
- const project = await publishAnalysis(df, opts);
175
+ const publishedProject = await publishAnalysis(df, opts);
161
176
  const groupName = (group as any).friendlyName ?? (group as any).name ?? '(unnamed team)';
162
177
  // Single non-modal confirmation — the published project is browsable in the
163
178
  // Spaces tree, so no modal or action links are needed. publishAnalysis no
164
179
  // longer emits its own toast, so this is the one and only confirmation.
165
- grok.shell.info(`Shared as ${(project as any).name} with team ${groupName}.`);
180
+ grok.shell.info(`Shared as ${(publishedProject as any).name} with team ${groupName}.`);
166
181
  } catch (e: any) {
167
182
  grok.shell.error(`Share failed: ${e?.message ?? String(e)}`);
168
183
  }
@@ -87,6 +87,11 @@ export function trimForPublish(source: DG.DataFrame, meta: PublishedMetadata): D
87
87
  }) ?? null;
88
88
  const significant = findColumn(source, '', ['significant', 'sig']);
89
89
  const direction = findColumn(source, '', ['direction', 'regulation', 'up_down']);
90
+ // Per-condition mean abundance (Candidates). Kept so the shared snapshot can
91
+ // still render the Rank–Abundance / dynamic-range plot; soft (omitted when
92
+ // absent, e.g. a Report snapshot or an older Candidates export).
93
+ const qtyNum = findColumn(source, '', ['avg group quantity numerator']);
94
+ const qtyDen = findColumn(source, '', ['avg group quantity denominator']);
90
95
 
91
96
  const required: Array<[string, DG.Column | null]> = [
92
97
  ['Protein ID', proteinId],
@@ -108,6 +113,8 @@ export function trimForPublish(source: DG.DataFrame, meta: PublishedMetadata): D
108
113
  adjPValue!.name,
109
114
  significant!.name,
110
115
  direction?.name,
116
+ qtyNum?.name,
117
+ qtyDen?.name,
111
118
  ].filter((n): n is string => typeof n === 'string' && n.length > 0)));
112
119
 
113
120
  const frozen = source.clone(null, allowlist);
@@ -153,7 +160,7 @@ export function trimForPublish(source: DG.DataFrame, meta: PublishedMetadata): D
153
160
  {name: META_COLUMNS.PUBLISHED_AT, value: meta.publishedAt, type: 'datetime'},
154
161
  {name: META_COLUMNS.PUBLISHED_BY, value: meta.publishedBy, type: 'string'},
155
162
  {name: META_COLUMNS.PUBLISHED_BY_EMAIL, value: meta.publishedByEmail ?? '', type: 'string', emptyForNull: true},
156
- {name: META_COLUMNS.PUBLISHED_TARGET, value: meta.target, type: 'string'},
163
+ {name: META_COLUMNS.PUBLISHED_PROJECT, value: meta.project, type: 'string'},
157
164
  {name: META_COLUMNS.PUBLISHED_DE_METHOD, value: meta.deMethod, type: 'string'},
158
165
  {name: META_COLUMNS.PUBLISHED_FC_THRESHOLD, value: String(meta.fcThreshold), type: 'string'},
159
166
  {name: META_COLUMNS.PUBLISHED_P_THRESHOLD, value: String(meta.pThreshold), type: 'string'},