@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.
- package/CHANGELOG.md +16 -0
- package/README.md +18 -5
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +16 -0
- package/docs/publishing-design-rationale.md +140 -0
- package/package.json +20 -5
- package/package.png +0 -0
- package/src/analysis/imputation.ts +3 -1
- package/src/analysis/normalization.ts +9 -4
- package/src/analysis/spc-storage.ts +1 -1
- package/src/menu.ts +5 -2
- package/src/package-api.ts +10 -2
- package/src/package-test.ts +3 -1
- package/src/package.g.ts +17 -3
- package/src/package.ts +31 -15
- package/src/panels/published-analysis-panel.ts +2 -2
- package/src/publishing/assert-published-shape.ts +2 -2
- package/src/publishing/package-settings-editor.ts +125 -0
- package/src/publishing/publish-project.ts +8 -8
- package/src/publishing/publish-settings.ts +57 -3
- package/src/publishing/publish-state.ts +45 -16
- package/src/publishing/reviewer-groups.ts +24 -0
- package/src/publishing/share-dialog.ts +42 -27
- package/src/publishing/trim-dataframe.ts +8 -1
- package/src/tests/abundance-correlation.ts +135 -0
- package/src/tests/analysis.ts +63 -0
- package/src/tests/enrichment-visualization.ts +95 -9
- package/src/tests/enrichment.ts +2 -1
- package/src/tests/project-vocabulary.ts +39 -0
- package/src/tests/publish-roundtrip.ts +40 -40
- package/src/tests/publish-spike.ts +2 -2
- package/src/tests/rank-abundance.ts +85 -0
- package/src/utils/abundance-detection.ts +145 -0
- package/src/viewers/abundance-correlation.ts +208 -0
- package/src/viewers/enrichment-viewers.ts +58 -24
- package/src/viewers/rank-abundance.ts +153 -0
- package/src/viewers/volcano.ts +3 -0
- package/test-console-output-1.log +1145 -1090
- package/test-record-1.mp4 +0 -0
- package/src/tests/group-mean-correlation.ts +0 -139
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
207
|
-
pi.description = 'Ensuring per-
|
|
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-
|
|
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
|
|
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.
|
|
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 `-<
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
65
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
264
|
-
const slug =
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
113
|
+
const slug = project ? slugifyProject(project) : '<no-project>';
|
|
99
114
|
const dateStr = new Date().toISOString().slice(0, 10);
|
|
100
115
|
|
|
101
|
-
if (
|
|
102
|
-
try { priorVersion = await findPriorShare(
|
|
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(`
|
|
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
|
-
|
|
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(
|
|
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
|
|
147
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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 ${(
|
|
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.
|
|
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'},
|