Skip to content

Commit

Permalink
feat(front): improve bin deletion UX
Browse files Browse the repository at this point in the history
The bin deletion via the interface was completly impratical.

From now on, the token storage is done automatically.
Indeed, a Service Worker is registered to intercept in particular
the request of the form and the request of the final page (redirection).
This allows to associate the token with the bin ID, which is not known
in advance.

In the background, the IndexedDB database is used via the `localforage`
abstraction, which allows to easily store key-value in the same way as
the `localStorage` (which is not available in a Service Worker).

This feature is only available for the clients with JavaScript enabled.

Closes: #147.
  • Loading branch information
Mesteery committed Sep 11, 2022
1 parent 9f39fef commit 8381df4
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 10 deletions.
37 changes: 37 additions & 0 deletions bin/assets/scripts/delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const deleteButton = document.getElementById('delete-button');
const id = location.pathname.slice(1, location.pathname.lastIndexOf('.'));

const token = await localforage.getItem(id);

if (token) {
deleteButton.classList.remove('hidden');
deleteButton.addEventListener('click', async () => {
try {
const response = await fetch(window.location.pathname, {
method: 'DELETE',
headers: {
Authorization: `Token ${token}`,
},
});

if (!response.ok) {
const error = new Error(`${response.status} ${response.statusText}\n${await response.text()}`);
error.response = response;
throw error;
}

location.href = '/';
} catch (error) {
console.error(error);
if (error.response.status === 401) {
alert('Le token stocké est très probablement erroné.');
localforage.removeItem(id);
} else {
console.error("Nous vous recommandons d'ouvrir une issue sur https://github.com/readthedocs-fr/bin-server si le problème persiste.");
alert(`Une erreur est survenue (${error.response?.statusText || error.message}), votre snippet n'a donc pas pu être supprimé. Regardez la console pour plus d'informations.`);
}
}
});
} else {
deleteButton.remove();
}
13 changes: 5 additions & 8 deletions bin/assets/scripts/newform.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ const form = document.forms['post-snippet'];
const lang = form.lang;
const langs = [...lang.options].slice(1).flatMap((option) => [option.value, option.textContent.toLowerCase()]);
const code = form.code;
const token = form.token;

token.addEventListener('click', () => {
navigator.clipboard.writeText(token.value).catch(() => {
// fallback to execCommand copy method
token.value.select();
document.execCommand('copy');
});
})
// NOTE: increment this number on Service Worker update
const SW_VERSION = 0;
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js?v=' + SW_VERSION);
}

const langAliases = {
txt: langs.indexOf('txt'),
Expand Down
41 changes: 41 additions & 0 deletions bin/assets/scripts/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
importScripts('https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js');

const NEW_URL = location.origin + '/new';

self.addEventListener('install', () => {
console.log('Installed Service Worker.');
self.skipWaiting();
});
self.addEventListener('activate', () => self.clients.claim());

// As the redirection mode of the form POST request
// is 'manual', we cannot get the the redirection URL
// from the request (opaqueredirect). A trick, is to store
// the event.request.resultingClientId, which seems to be
// the same only for the form POST request and the fetch
// of the redirection URL. This allows us to intercept
// the correct redirection URL request and thus obtain
// the ID.
const tokens = new Map();
self.addEventListener('fetch', (event) => {
let token;
// Intercept the request when the form is submitted
if (event.request.url === NEW_URL && event.request.method === 'POST') {
event.respondWith(fetch(event.request.clone()).then(async (response) => {
// Store the resultingClientId only if the request is successful.
if (response.type === 'opaqueredirect') {
tokens.set(event.request.resultingClientId,
new URLSearchParams(await event.request.text()).get('token'));
}
return response;
}));

// If the request's resultingClientId is the same as
// the submitted form request's resultingClientId,
// then this request is the fetch of the redirection URL.
} else if (token = tokens.get(event.request.resultingClientId)) {
tokens.delete(event.request.resultingClientId);
localforage.setItem(event.request.url.slice(event.request.url.lastIndexOf('/') + 1,
event.request.url.lastIndexOf('.')), token);
}
});
4 changes: 4 additions & 0 deletions bin/assets/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,7 @@ input[type="number"] {
#post-snippet:invalid .controls button svg {
fill: #707070;
}

button.hidden {
display: none;
}
9 changes: 7 additions & 2 deletions bin/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def get_new_form():

return bt.template(
'newform.html',
token=secrets.token_urlsafe(16),
languages=languages,
default_language=lang,
code=code,
Expand All @@ -65,6 +66,12 @@ def assets(filepath):
"""
return bt.static_file(filepath, root=root.joinpath('assets'))

@bt.route('/sw.js')
def sw():
"""
Get the Service Worker with a allowed scope to '/'.
"""
return bt.static_file('/sw.js', root=root.joinpath('assets/scripts'))

@bt.route('/new', method='POST')
def post_new():
Expand Down Expand Up @@ -159,7 +166,6 @@ def get_html(snippetid, ext=None):
:param snippetid: (path) required snippet id
:param ext: (path) optional language file extension, used to determine the highlight backend
:param token: (query) optional the "admin" token
:raises HTTPError: code 404 when the snippet is not found
"""
Expand All @@ -183,7 +189,6 @@ def get_html(snippetid, ext=None):
ext=ext,
snippetid=snippetid,
parentid=snippet.parentid,
token=bt.request.query.token,
)

@bt.route('/<snippetid>', method='DELETE')
Expand Down
8 changes: 8 additions & 0 deletions bin/views/highlight.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<link rel=stylesheet href="/assets/styles/monokai.css">
<script src="/assets/scripts/loc.js" defer></script>
<script src="/assets/scripts/report.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js" defer></script>
<script src="/assets/scripts/delete.js" defer type=module></script>
<script src="/assets/scripts/change-lang.js" defer></script>
<noscript>
<style>
Expand Down Expand Up @@ -39,6 +41,12 @@
% end
</select>

<button class="control hidden" id=delete-button title="Supprimer le snippet">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm1 8a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd" />
</svg>
</button>

% if parentid:
<a class="control" title="Voir la révision précédente" href="/{{parentid}}.{{ext}}">
% include('svg.html', name='history')
Expand Down
2 changes: 2 additions & 0 deletions bin/views/newform.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<textarea spellcheck=false placeholder="\\
% include('howto.txt')
" name=code autofocus required>{{code}}</textarea>

<input type=hidden name=token value="{{token}}">
<input type=hidden name=parentid value="{{parentid}}">

<div class="controls">
Expand Down

0 comments on commit 8381df4

Please sign in to comment.