Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1779,11 +1779,13 @@ export class LaboratoryDataTaggingService extends DynamoDBService {
if (!t) throw new Error(`Unknown tag: ${tagId}`);
if (t.Kind === 'workflow') throw new Error('Workflow tags are auto-managed');
if (t.Kind === 'batch') throw new Error('Batch tags are not supported on samples');
if (t.Kind === 'permanent') throw new Error('Permanent tags are system-managed');
}
for (const tagId of remove) {
const t = await this.getTagRow(laboratoryId, tagId);
if (!t) throw new Error(`Unknown tag: ${tagId}`);
if (t.Kind === 'workflow') throw new Error('Workflow tags are auto-managed');
if (t.Kind === 'permanent') throw new Error('Permanent tags are system-managed');
}

for (const setId of sequenceSetIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,17 @@ describe('LaboratoryDataTaggingService.applyTagsToSamples', () => {
'Workflow tags are auto-managed',
);
});

it('rejects system-managed permanent tags', async () => {
(svc as unknown as { getTagRow: jest.Mock }).getTagRow = jest.fn().mockResolvedValue({
TagId: 'perm-1',
Kind: 'permanent',
Name: 'Permanent',
});
await expect(svc.applyTagsToSamples(labFixture(), 'user-1', ['set-1'], ['perm-1'], [])).rejects.toThrow(
'Permanent tags are system-managed',
);
});
});

describe('LaboratoryDataTaggingService.listSamplesByTag', () => {
Expand Down
135 changes: 126 additions & 9 deletions packages/front-end/src/app/components/EGImportDataWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
REGEX_GROUPING_PRESETS,
type RegexGroupingPresetKey,
} from '@easy-genomics/shared-lib/src/app/utils/sample-regex-grouping';
import { parseDelimitedText } from '@easy-genomics/shared-lib/src/app/utils/delimited-text';
import { TAG_PRESET_COLORS } from '@easy-genomics/shared-lib/src/app/constants/data-collections';
import { useToastStore, useUiStore } from '@FE/stores';
import { basenameFromS3Key } from '@FE/utils/data-collections-file-type';
import { exceedsBatchNameMaxLength } from '@FE/utils/data-collections-name-validation';
import { matchSheetToSamples, type SheetTagMatchResult } from '@FE/utils/sheet-tag-matching';

type ImportSourceKind = 's3' | 'upload';
type BatchMode = 'new' | 'existing';
Expand All @@ -40,7 +43,7 @@
const toast = useToastStore();
const uiStore = useUiStore();

const IMPORT_STEPS = ['Source', 'Group files', 'Review samples', 'Confirm'] as const;
const IMPORT_STEPS = ['Source', 'Group files', 'Tags', 'Review samples', 'Confirm'] as const;

const step = ref(1);
const importSource = ref<ImportSourceKind>('s3');
Expand All @@ -53,7 +56,10 @@
regexPattern,
);
const excludedSamples = ref<Set<string>>(new Set());
const setTagIds = ref<Record<string, string[]>>({});
const tagSheetRows = ref<string[][]>([]);
const tagSheetError = ref<string>('');
const nameColumnIndex = ref<number>(-1);
const tagColumnIndex = ref<number>(-1);
const submitting = ref(false);
const batchMode = ref<BatchMode>('new');
const newBatchName = ref('');
Expand Down Expand Up @@ -83,7 +89,7 @@
});

watch(step, (n) => {
if (n === 4 && !newBatchName.value.trim()) {
if (n === 5 && !newBatchName.value.trim()) {
newBatchName.value = importLabel.value;
}
});
Expand Down Expand Up @@ -115,6 +121,21 @@
return { paired, single, review, total: activeSets.value.length };
});

const sheetHeaders = computed<string[]>(() => tagSheetRows.value[0] ?? []);
const columnOptions = computed(() =>
sheetHeaders.value.map((header, index) => ({ label: header || `Column ${index + 1}`, value: index })),
);
const tagMatch = computed<SheetTagMatchResult | null>(() => {
if (!tagSheetRows.value.length || nameColumnIndex.value < 0 || tagColumnIndex.value < 0) return null;
return matchSheetToSamples({
rows: tagSheetRows.value,
nameColumnIndex: nameColumnIndex.value,
tagColumnIndex: tagColumnIndex.value,
sampleNames: proposedSets.value.map((s) => s.sampleId),
existingTags: props.tags,
});
});

const importLabel = computed(() => {
if (importSource.value === 'upload') {
return `upload-${new Date().toISOString().slice(0, 10)}`;
Expand Down Expand Up @@ -278,6 +299,54 @@
return `${destPrefix}${base}`;
}

async function handleTagSheetFile(event: Event): Promise<void> {
const inputEl = event.target as HTMLInputElement;
const file = inputEl.files?.[0];
inputEl.value = '';
nameColumnIndex.value = -1;
tagColumnIndex.value = -1;
if (!file) return;
try {
const rows = parseDelimitedText(await file.text());
if (rows.length < 2 || (rows[0] ?? []).length === 0) {
tagSheetRows.value = [];
tagSheetError.value = 'The sheet needs a header row and at least one data row.';
return;
}
tagSheetRows.value = rows;
tagSheetError.value = '';
} catch {
tagSheetRows.value = [];
tagSheetError.value = 'Could not read that file. Please upload a CSV.';
}
}

/** Create any missing tags, then resolve each sample's tag names to tag IDs. */
async function resolveSampleTagIds(): Promise<Record<string, string[]>> {
const match = tagMatch.value;
if (!match) return {};
const idByName = new Map<string, string>();
for (const t of props.tags) idByName.set(t.Name.trim().toLowerCase(), t.TagId);

for (let i = 0; i < match.tagsToCreate.length; i += 1) {
const created = await $api.dataCollections.createTag({
LaboratoryId: props.labId,
Name: match.tagsToCreate[i].name,
ColorHex: TAG_PRESET_COLORS[i % TAG_PRESET_COLORS.length],
});
idByName.set(created.Name.trim().toLowerCase(), created.TagId);
}

const resolved: Record<string, string[]> = {};
for (const [sampleName, tagNames] of Object.entries(match.perSample)) {
const ids = tagNames
.map((name) => idByName.get(name.trim().toLowerCase()))
.filter((id): id is string => Boolean(id));
if (ids.length) resolved[sampleName] = ids;
}
return resolved;
}

async function confirmImport(): Promise<void> {
if (!props.lab?.S3Bucket) return;
submitting.value = true;
Expand All @@ -286,11 +355,13 @@
const labRoot = `${props.lab.OrganizationId}/${props.lab.LaboratoryId}/`;
const destPrefix = `${labRoot}imports/${importLabel.value}/`;

const resolvedTagIds = await resolveSampleTagIds();

const sequenceSets = activeSets.value.map((s) => ({
Name: s.sampleId,
Layout: s.layout as SampleLayout,
Keys: s.files.map((f) => resolveDestKeyForFile(f.fileName, destPrefix)),
TagIds: setTagIds.value[s.sampleId],
TagIds: resolvedTagIds[s.sampleId],
FilenameRegex: regexPattern.value,
}));

Expand Down Expand Up @@ -474,8 +545,53 @@
/>
</div>

<!-- Step 3: Build -->
<div v-else-if="step === 3" class="flex min-h-0 flex-1 flex-col">
<!-- Step 3: Tags (optional) -->
<div v-else-if="step === 3" class="flex-1 overflow-y-auto p-6">
<h3 class="mb-2 font-medium">Tag samples from a sheet (optional)</h3>
<p class="mb-4 text-sm text-gray-500">
Upload a sheet listing your samples and a tag column. Rows are matched to the
{{ proposedSets.length }} samples above by name; missing tags are created on import.
</p>

<input type="file" accept=".csv,.tsv,.txt" class="mb-2 block text-sm" @change="handleTagSheetFile" />
<p v-if="tagSheetError" class="mb-4 text-sm text-red-600">{{ tagSheetError }}</p>

<div v-if="tagSheetRows.length" class="mt-4 grid grid-cols-2 gap-4">
<UFormGroup label="Sample-name column">
<USelect v-model="nameColumnIndex" :options="columnOptions" placeholder="Select column" />
</UFormGroup>
<UFormGroup label="Tag column">
<USelect v-model="tagColumnIndex" :options="columnOptions" placeholder="Select column" />
</UFormGroup>
</div>

<div v-if="tagMatch" class="mt-6 space-y-2 text-sm">
<p v-if="tagMatch.existingTagHits.length">
<strong>Existing tags applied:</strong>
{{ tagMatch.existingTagHits.map((t) => `${t.name} (${t.sampleCount})`).join(', ') }}
</p>
<p v-if="tagMatch.tagsToCreate.length" class="text-primary-600">
<strong>Tags to create:</strong>
{{ tagMatch.tagsToCreate.map((t) => `${t.name} (${t.sampleCount})`).join(', ') }}
</p>
<p v-for="w in tagMatch.typoWarnings" :key="w.name" class="text-amber-600">
⚠ "{{ w.name }}" is {{ w.distance }} edit(s) from existing tag "{{ w.nearest }}" — possible typo?
</p>
<p v-if="tagMatch.rejected.length" class="text-red-600">
<strong>Skipped:</strong>
{{ tagMatch.rejected.map((r) => `${r.name} — ${r.reason}`).join('; ') }}
</p>
<p v-if="tagMatch.unmatchedRows.length" class="text-gray-500">
{{ tagMatch.unmatchedRows.length }} sheet row(s) matched no sample and were ignored.
</p>
<p v-if="tagMatch.unmatchedSampleNames.length" class="text-gray-500">
{{ tagMatch.unmatchedSampleNames.length }} sample(s) had no sheet row and stay untagged.
</p>
</div>
</div>

<!-- Step 4: Build -->
<div v-else-if="step === 4" class="flex min-h-0 flex-1 flex-col">
<div class="flex gap-2 border-b p-4 text-xs">
<span class="rounded-full bg-green-100 px-2 py-1 text-green-800">{{ stats.paired }} paired</span>
<span class="rounded-full bg-blue-100 px-2 py-1 text-blue-800">{{ stats.single }} single-end</span>
Expand Down Expand Up @@ -516,7 +632,7 @@
</div>
</div>

<!-- Step 4: Confirm -->
<!-- Step 5: Confirm -->
<div v-else class="flex-1 overflow-y-auto p-6">
<h3 class="mb-4 font-medium">Confirm &amp; create</h3>
<dl class="mb-6 max-w-md space-y-2 text-sm">
Expand Down Expand Up @@ -582,8 +698,9 @@
<UButton v-if="step === 1" :disabled="!canContinueStep1" :loading="uploading" @click="loadSourceFiles">
{{ importSource === 'upload' ? 'Upload & continue to pattern' : 'Continue to pattern' }}
</UButton>
<UButton v-else-if="step === 2" @click="step = 3">Continue to build</UButton>
<UButton v-else-if="step === 3" @click="step = 4">Continue to confirm</UButton>
<UButton v-else-if="step === 2" @click="step = 3">Continue to tags</UButton>
<UButton v-else-if="step === 3" @click="step = 4">Continue to review</UButton>
<UButton v-else-if="step === 4" @click="step = 5">Continue to confirm</UButton>
<UButton v-else :loading="submitting" :disabled="!canConfirmImport" @click="confirmImport">
{{ importSource === 's3' ? `Copy & create ${stats.total} samples` : `Create ${stats.total} samples` }}
</UButton>
Expand Down
7 changes: 4 additions & 3 deletions packages/front-end/src/app/components/EGSampleTagSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { LaboratoryDataTag } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/data-collections';
import type { LaboratorySample } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/samples';
import { TAG_PRESET_COLORS, DEFAULT_TAG_COLOR } from '@easy-genomics/shared-lib/src/app/constants/data-collections';
import EGDialog from '@FE/components/EGDialog.vue';
import { useToastStore, useUiStore } from '@FE/stores';
import { ButtonVariantEnum } from '@FE/types/buttons';
Expand Down Expand Up @@ -32,11 +33,11 @@
const tagsSectionExpanded = ref(true);
const showLeftRailCreateTag = ref(false);
const leftRailNewTagName = ref('');
const leftRailNewTagColor = ref('#5B4FD4');
const leftRailNewTagColor = ref(DEFAULT_TAG_COLOR);
const tagToDelete = ref<LaboratoryDataTag | null>(null);
const isDeleteTagDialogOpen = ref(false);

const presetColors = ['#5B4FD4', '#85B7EB', '#F09595', '#97C459', '#ED93B1', '#EF9F27', '#B4B2A9'];
const presetColors = TAG_PRESET_COLORS;

const standardTags = computed(() => props.tags.filter((t) => (t.Kind ?? 'standard') === 'standard'));

Expand Down Expand Up @@ -131,7 +132,7 @@
function cancelLeftRailCreateTag(): void {
showLeftRailCreateTag.value = false;
leftRailNewTagName.value = '';
leftRailNewTagColor.value = '#5B4FD4';
leftRailNewTagColor.value = DEFAULT_TAG_COLOR;
}

function openDeleteTagDialog(tag: LaboratoryDataTag): void {
Expand Down
9 changes: 5 additions & 4 deletions packages/front-end/src/app/components/EGSamplesTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
LaboratoryRunUsageSummary,
} from '@easy-genomics/shared-lib/src/app/types/easy-genomics/data-collections';
import type { LaboratorySample } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/samples';
import { TAG_PRESET_COLORS, DEFAULT_TAG_COLOR } from '@easy-genomics/shared-lib/src/app/constants/data-collections';
import EGFileAnalysisHistoryTooltip from '@FE/components/EGFileAnalysisHistoryTooltip.vue';
import EGSampleTagSidebar from '@FE/components/EGSampleTagSidebar.vue';
import { useToastStore, useUiStore } from '@FE/stores';
Expand Down Expand Up @@ -53,7 +54,7 @@
const bulkRemoveTagIds = ref<string[]>([]);
const showInlineCreateTag = ref(false);
const inlineNewTagName = ref('');
const inlineNewTagColor = ref('#5B4FD4');
const inlineNewTagColor = ref(DEFAULT_TAG_COLOR);
const bulkPanelContentEl = ref<HTMLElement | null>(null);

/** Cards grid vs tabular layout (matches file explorer). */
Expand All @@ -77,7 +78,7 @@
const BATCH_HEADER_DOT_NOT_ANALYZED = '#EF9F27';
const BATCH_HEADER_DOT_ANALYZED = '#2DB48F';

const presetColors = ['#5B4FD4', '#85B7EB', '#F09595', '#97C459', '#ED93B1', '#EF9F27', '#B4B2A9'];
const presetColors = TAG_PRESET_COLORS;

const standardTags = computed(() => props.tags.filter((t) => (t.Kind ?? 'standard') === 'standard'));
const batchTags = computed(() => props.tags.filter((t) => t.Kind === 'batch'));
Expand Down Expand Up @@ -401,7 +402,7 @@
bulkRemoveTagIds.value = [];
showInlineCreateTag.value = false;
inlineNewTagName.value = '';
inlineNewTagColor.value = '#5B4FD4';
inlineNewTagColor.value = DEFAULT_TAG_COLOR;
}

function closeBulkPanel(): void {
Expand Down Expand Up @@ -470,7 +471,7 @@
bulkAddTagIds.value = [...new Set([...bulkAddTagIds.value, created.TagId])];
showInlineCreateTag.value = false;
inlineNewTagName.value = '';
inlineNewTagColor.value = '#5B4FD4';
inlineNewTagColor.value = DEFAULT_TAG_COLOR;
toast.success('Tag created');
emit('tag-created');
} catch (e: unknown) {
Expand Down
23 changes: 23 additions & 0 deletions packages/front-end/src/app/utils/levenshtein.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Levenshtein edit distance between two strings (insert/delete/substitute = 1).
* Used to flag likely typos when a new tag name is one or two edits from an
* existing tag name.
*/
export function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;

let prev = Array.from({ length: b.length + 1 }, (_v, j) => j);
let curr = new Array<number>(b.length + 1);

for (let i = 1; i <= a.length; i += 1) {
curr[0] = i;
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[b.length];
}
Loading
Loading