Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
baa1d48
Allow submitting sequences without the FASTA header
caduribas Sep 16, 2025
eede333
Fix unit tests
caduribas Sep 16, 2025
de4047e
Keep supporting the search attribute for compatibility
caduribas Sep 16, 2025
010adb6
Change link and use r2dt.bio page
caduribas Sep 16, 2025
25ab0c5
Highlight the matched part in yellow
caduribas Sep 16, 2025
dfa7fac
Script to update the templates.js file with the latest models.json fr…
caduribas Sep 17, 2025
44ab47b
Update template list
caduribas Sep 17, 2025
a456896
Build update
caduribas Sep 17, 2025
3ec61ac
Add line about how to update templates
caduribas Sep 18, 2025
62528e5
Only trigger the search after typing 2 characters. Show up to 200 res…
caduribas Sep 19, 2025
00695e5
Update placeholder
caduribas Sep 19, 2025
451b3e7
Build update
caduribas Sep 20, 2025
e57e7aa
Legend minimized by default
caduribas Sep 22, 2025
45a830c
Legend as a fixed section below the secondary structure
caduribas Sep 22, 2025
4af8019
Do not show dot-bracket notation
caduribas Sep 24, 2025
63eaeed
Place the legend on the left, right or centered
caduribas Sep 24, 2025
fd14836
Fix unit test
caduribas Sep 24, 2025
d0d30b9
Update legend info
caduribas Sep 24, 2025
f9d35fa
Validate sequence size
caduribas Sep 24, 2025
61edd2a
Show message when sequence does not match any of the templates
caduribas Sep 24, 2025
8003837
Return NO_MATCH on error 400
caduribas Sep 25, 2025
d3c5865
Add function to update URL
caduribas Sep 25, 2025
6ab2dc9
Update unit tests
caduribas Sep 25, 2025
67a7b13
Disable advanced options when dot-bracket is present
caduribas Sep 25, 2025
d144886
Build update
caduribas Sep 25, 2025
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
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,24 @@ If you prefer to install this package and perform the updates manually, see the

### Other methods of use

If you already have an external URL that returns an SVG generated by R2DT, you can pass it directly using the
`url` attribute.
If you already have an external URL that returns an SVG generated by R2DT, you can provide it through the
`search` attribute as a JSON object.

```
<r2dt-web url="https://example.com/svg"></r2dt-web>
<r2dt-web search='{"url": "https://example.com/svg"}'></r2dt-web>
```

If you have a URS from RNAcentral, you can pass it using the `urs` attribute. Click
[here](https://rnacentral.org/help#how-to-find-rnacentral-id) to see how you can find an RNAcentral identifier
for an RNA sequence.
To load an RNAcentral identifier (URS), pass it in the same way:

```
<r2dt-web urs="URS0000049E57"></r2dt-web>
<r2dt-web search='{"urs": "URS0000049E57"}'></r2dt-web>
```

![URS visualisation](img/urs.png)

Click [here](https://rnacentral.org/help#how-to-find-rnacentral-id) to see how you can find an RNAcentral identifier
for an RNA sequence.

If neither `url` nor `urs` is provided, the component will display a search field. You can optionally display example
sequences using the `examples` attribute.

Expand Down Expand Up @@ -70,22 +71,23 @@ directly or through an import with Webpack:
This component accepts a number of attributes. You pass them as html attributes and their values are strings
(this is a requirement of Web Components):

| parameter | description |
|-----------|-----------------------------------------------------------------------|
| url | URL that returns an SVG generated by R2DT |
| urs | Unique RNA Sequence identifier from RNAcentral |
| examples | Array of example sequences with `description` and `sequence` |
| legend | Legend position. Can be topRight, topLeft, bottomRight and bottomLeft |
| parameter | description |
|-----------|------------------------------------------------------------------------|
| search | JSON object with `url` or `urs` attributes |
| examples | Array of example sequences with `description` and `sequence` |
| legend | Legend position (left by default). Can be `left`, `right` and `center` |

### Local development

1. `npm install`

2. `npm start` to start a server on http://localhost:9000/
2. `npm run update-templates` to use the latest models from the R2DT repository

3. `npm start` to start a server on http://localhost:9000/

3. `npm run build` to generate a new distribution
4. `npm run build` to generate a new distribution

4. `npm test` to run unit tests
5. `npm test` to run unit tests

### Notes

Expand Down
19 changes: 16 additions & 3 deletions __tests__/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ describe('actions.js', () => {
expect(result).toBe('NOT_FOUND');
});

test('fetchStatus returns FAILURE', async () => {
fetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('FAILURE') });
const result = await fetchStatus('job123');
expect(result).toBe('FAILURE');
});

test('fetchStatus returns ERROR', async () => {
fetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('ERROR') });
const result = await fetchStatus('job123');
expect(result).toBe('ERROR');
});

test('fetchStatus returns error on bad response', async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(fetchStatus('job123')).rejects.toThrow('Error 500');
Expand All @@ -49,9 +61,10 @@ describe('actions.js', () => {
expect(res).toEqual({ template: 'template', source: 'source' });
});

test('getSvg throws on error response', async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(getSvg('job123')).rejects.toThrow('Error 500');
test('getSvg returns NO_MATCH', async () => {
fetch.mockResolvedValueOnce({ ok: true, status: 400 });
const result = await getSvg('job123');
expect(result).toBe('NO_MATCH');
});

test('getSvg returns empty string when text is missing', async () => {
Expand Down
10 changes: 5 additions & 5 deletions __tests__/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('R2DTWidget', () => {

test('should not render search interface if url parameter is provided', () => {
widget = document.createElement('r2dt-web');
widget.setAttribute('url', 'https://example.com');
widget.setAttribute('search', '{"url": "https://example.com"}');
container.appendChild(widget);

return Promise.resolve().then(() => {
Expand All @@ -92,7 +92,7 @@ describe('R2DTWidget', () => {
});

widget = document.createElement('r2dt-web');
widget.setAttribute('url', 'https://example.com/test');
widget.setAttribute('search', '{"url": "https://example.com/test"}');
container.appendChild(widget);

await Promise.resolve();
Expand All @@ -114,7 +114,7 @@ describe('R2DTWidget', () => {
});

widget = document.createElement('r2dt-web');
widget.setAttribute('urs', 'URS0000001');
widget.setAttribute('search', '{"urs": "URS0000001"}');
container.appendChild(widget);

await Promise.resolve();
Expand All @@ -131,7 +131,7 @@ describe('R2DTWidget', () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });

widget = document.createElement('r2dt-web');
widget.setAttribute('urs', 'URS_FAIL');
widget.setAttribute('search', '{"urs": "URS_FAIL"}');
container.appendChild(widget);

await Promise.resolve();
Expand All @@ -147,7 +147,7 @@ describe('R2DTWidget', () => {
jest.spyOn(actions, 'fetchSvgFromUrl').mockResolvedValue("NO_SVG");

widget = document.createElement('r2dt-web');
widget.setAttribute('url', 'https://bad.example.com');
widget.setAttribute('search', '{"url": "https://bad.example.com"}');
container.appendChild(widget);

await Promise.resolve();
Expand Down
3 changes: 1 addition & 2 deletions __tests__/legend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ describe('r2dtLegend', () => {

test('always includes legend structure', () => {
const html = r2dtLegend('TemplateX', 'rfam');
expect(html).toContain('class="r2dt-legend-toggle-btn"');
expect(html).toContain('class="r2dt-legend-content"');
expect(html).toContain('class="r2dt-card"');
expect(html).toContain('r2dt-traveler-key');
});
});
27 changes: 20 additions & 7 deletions __tests__/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ describe('validateFasta', () => {
expect(result.error).toBeUndefined();
});

test('should reject empty sequence', () => {
const fasta = '>empty\n';
test('should accept the sequence without header', () => {
const sequence = 'ACGUACGUACGU';
const result = validateFasta(sequence);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

test('should accept valid dot-bracket structure', () => {
const fasta = '>seq\nACGU\n.().';
const result = validateFasta(fasta);
expect(result.valid).toBe(false);
expect(result.error).toContain('FASTA format requires a header and at least one sequence line');
expect(result.valid).toBe(true);
});

test('should reject missing FASTA header', () => {
const fasta = 'ACGTACGT\n';
test('should reject empty sequence', () => {
const fasta = '>empty\n';
const result = validateFasta(fasta);
expect(result.valid).toBe(false);
expect(result.error).toContain('FASTA header must start with ">"');
expect(result.error).toContain('FASTA format requires a sequence after the header');
});

test('should reject invalid characters in sequence', () => {
Expand All @@ -36,6 +42,13 @@ describe('validateFasta', () => {
expect(result.error).toContain('Invalid nucleotide sequence');
});

test('should reject sequence shorter than 4 nucleotides', () => {
const fasta = '>short\nACG';
const result = validateFasta(fasta);
expect(result.valid).toBe(false);
expect(result.error).toContain('Please check your sequence, it cannot be shorter than 4 or longer than 8000 nucleotides');
});

test('should reject invalid dot-bracket notation size', () => {
const fasta = '>invalid\nACGT\n.().()';
const result = validateFasta(fasta);
Expand Down
18 changes: 17 additions & 1 deletion __tests__/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { showMessage, hideMessage, removeJobIdFromUrl } from '../src/utils';
import { showMessage, hideMessage, removeJobIdFromUrl, updateUrl } from '../src/utils';

describe('Utils', () => {
describe('showMessage', () => {
Expand Down Expand Up @@ -45,4 +45,20 @@ describe('Utils', () => {
);
});
});

describe('updateUrl', () => {
beforeEach(() => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
window.history.pushState.mockClear();
});

test('should update URL with job ID', () => {
updateUrl('job123');
expect(window.history.pushState).toHaveBeenCalledWith(
{ jobid: 'job123' },
'',
'http://example.com/?jobid=job123'
);
});
});
});
2 changes: 1 addition & 1 deletion dist/r2dt-web.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/r2dt-web.js.map

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"description": "Visualise RNA secondary structure using standard layouts.",
"scripts": {
"start": "webpack serve --mode development --open",
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"update-templates": "node scripts/update-templates.js",
"build": "npm run update-templates && webpack --mode production",
"build:dev": "npm run update-templates && webpack --mode development",
"test": "jest --coverage",
"test:watch": "jest --watch"
},
Expand Down
38 changes: 38 additions & 0 deletions scripts/update-templates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
This script updates the templates.js file with the latest models.json from the R2DT repository.
*/

import fs from 'fs';
import https from 'https';

const url = 'https://raw.githubusercontent.com/r2dt-bio/R2DT/main/data/models.json';
const outputFile = 'src/templates.js';

https.get(url, res => {
let data = '';
res.on('data', chunk => (data += chunk));
res.on('end', () => {
try {
const models = JSON.parse(data);

// Sort models by description
const templates = [...models]
.sort((a, b) => (a.description).trim().localeCompare((b.description).trim(), 'en', { sensitivity: 'base' }))
.map(m => ({
label: (m.description).trim(),
model_id: m.model_id,
source: m.source,
}));

// Write to file
const jsContent = `export const templates = ${JSON.stringify(templates)};\n`;
fs.writeFileSync(outputFile, jsContent);

console.log(`Updated ${outputFile} with ${templates.length} templates.`);
} catch (e) {
console.warn('Error parsing models.json. Keeping existing templates.js.');
}
});
}).on('error', err => {
console.warn('Network error fetching models.json:', err.message);
});
9 changes: 6 additions & 3 deletions src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export async function fetchStatus(jobId) {
getSvg(jobId),
getTsv(jobId),
]);

if (svgData === 'NO_MATCH') {
return { jobId: jobId, svg: 'NO_MATCH' };
}

return {
dotBracketNotation: fastaData.dotBracketNotation,
fastaHeader: fastaData.fastaHeader,
Expand Down Expand Up @@ -72,12 +77,12 @@ export async function getSvg(jobId) {
'Accept': 'text/plain',
},
});
if (response.status === 400) { return 'NO_MATCH' }
if (!response.ok) throw new Error(`Error ${response.status}`);
const data = await response.text();
return data;
} catch (error) {
console.error(error);
throw error;
}
}

Expand All @@ -98,7 +103,6 @@ export async function getFasta(jobId) {
return { fastaHeader: fastaHeader, sequence: sequence, dotBracketNotation: dotBracketNotation };
} catch (error) {
console.error(error);
throw error;
}
}

Expand All @@ -118,7 +122,6 @@ export async function getTsv(jobId) {
return { template: template, source: source };
} catch (error) {
console.error(error);
throw error;
}
}

Expand Down
25 changes: 21 additions & 4 deletions src/advanced.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { templates } from './templates.js';
import { disableAdvanced } from './utils.js';

export const setupAdvancedSearch = (shadowRoot, insertionPoint) => {
const advancedContainer = document.createElement('div');
Expand All @@ -25,7 +26,7 @@ export const setupAdvancedSearch = (shadowRoot, insertionPoint) => {
${templates.map(t => `<option value="${t.model_id}">${t.label}</option>`).join('')}
</select>

<input type="text" id="r2dt-template-autocomplete" class="r2dt-template-autocomplete r2dt-hidden" placeholder="Start typing..." list="r2dt-template-datalist" />
<input type="text" id="r2dt-template-autocomplete" class="r2dt-template-autocomplete r2dt-hidden" placeholder="Start typing..." />
<div id="r2dt-autocomplete-list" class="r2dt-autocomplete-list r2dt-hidden"></div>
</div>
</div>
Expand Down Expand Up @@ -64,12 +65,12 @@ export const setupAdvancedSearch = (shadowRoot, insertionPoint) => {
const value = autocompleteInput.value.toLowerCase().trim();
autocompleteList.innerHTML = '';

if (!value) {
if (!value || value.length < 2) {
autocompleteList.classList.add('r2dt-hidden');
return;
}

const matches = templates.filter(t => t.label.toLowerCase().includes(value)).slice(0, 10);
const matches = templates.filter(t => t.label.toLowerCase().includes(value)).slice(0, 200);
if (!matches.length) {
autocompleteList.classList.add('r2dt-hidden');
return;
Expand All @@ -78,7 +79,12 @@ export const setupAdvancedSearch = (shadowRoot, insertionPoint) => {
matches.forEach(t => {
const item = document.createElement('div');
item.className = 'r2dt-autocomplete-item';
item.textContent = t.label;

// Highlight the matched part
const regex = new RegExp(`(${value})`, 'i');
const highlighted = t.label.replace(regex, '<mark>$1</mark>');
item.innerHTML = highlighted;

item.addEventListener('click', () => {
autocompleteInput.value = t.label;
autocompleteList.classList.add('r2dt-hidden');
Expand Down Expand Up @@ -136,4 +142,15 @@ export const setupAdvancedSearch = (shadowRoot, insertionPoint) => {
const isHidden = advancedContainer.classList.toggle('r2dt-hidden');
toggleLink.textContent = isHidden ? 'Show advanced' : 'Hide advanced';
});

// Disable advanced options when dot-bracket is present
const searchTextarea = shadowRoot.querySelector('.r2dt-search-input');
if (searchTextarea) {
const onTextareaInput = () => {
const hasDotBracket = /[.()]/.test(searchTextarea.value || '');
disableAdvanced(shadowRoot, hasDotBracket);
};
onTextareaInput();
searchTextarea.addEventListener('input', onTextareaInput);
}
};
Loading