Files

535 lines
19 KiB
JavaScript

// The jobs notepad: adding a job, moving it between days, and linking it to a
// crop, planting or seeds, all without reloading the page. The server sends back
// the notepad's groups after each change. If adding a job goes wrong, the job
// stays in the box and a message says so: it is not sent again, since the first
// may have worked and a second would add the job twice. A move or a tick that
// goes wrong reloads the page, which shows how things stand. Choosing Edit in a
// job's menu turns its name into a box to type in; Enter saves, Esc cancels.
import {csrfToken} from '../api';
import {celebrate, doneText, hide} from './job_celebration';
import {nextStreak, undoStreak} from './job_streak';
document.addEventListener('DOMContentLoaded', function () {
const form = document.querySelector('.notepad-add');
const groups = document.getElementById('notepad-groups');
if (!form || !groups) return;
const input = form.querySelector('input[type="text"]');
const failure = document.getElementById('notepad-add-failed');
const saving = document.getElementById('notepad-add-saving');
const addButton = form.querySelector('input[type="submit"]');
const addLabel = addButton.value;
const kinds = JSON.parse(form.dataset.kinds);
// A suggestion's kind (planting, seed or crop) is its type, lower case.
const kindOf = function (item) { return item.jobbable_type.toLowerCase(); };
const words = JSON.parse(groups.dataset.celebrations);
const reload = function () { window.location.reload(); };
// The line beside the snail says how many jobs are done this week.
const caption = document.getElementById('notepad-snail-caption');
const showDone = function (done) {
if (caption) caption.textContent = done > 0 ? words.slow + ' ' + doneText(words, done) : words.slow;
};
// The jobs crossed off since the page loaded. Each request says so, so the
// server leaves them where they are and a tick never makes a row jump. They
// move to Done when the page is loaded again.
const kept = new Set();
// Hide done moves the jobs crossed off since the page loaded down to Done,
// as loading the page again would. It is only there when there are some.
const hideDoneButton = document.getElementById('notepad-hide-done');
// The tick box or menu button with focus in the list, as the job it belongs to
// and which control it is, so its replacement can be focused when the list is
// drawn again.
const focusedControl = function () {
const el = document.activeElement;
const row = el && groups.contains(el) ? el.closest('.notepad-job') : null;
const selector = row && ['.notepad-box', '.record-actions-toggle'].find(function (candidate) { return el.matches(candidate); });
return selector ? {id: row.dataset.id, selector: selector} : null;
};
// Sends a change to the server and shows the groups it sends back. Resolves
// with how many jobs are done this week.
const transmit = function (url, body) {
if (body) {
kept.forEach(function (id) { body.append('keep[]', id); });
if (groups.dataset.gardenId) body.append('garden_id', groups.dataset.gardenId);
}
return fetch(url, {
method: body ? 'POST' : 'GET',
body: body,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken(),
},
credentials: 'same-origin',
})
.then(function (response) {
if (!response.ok) throw new Error(response.status);
const done = response.headers.get('X-Jobs-Done-This-Week');
return response.text().then(function (html) {
const focused = focusedControl();
groups.innerHTML = html;
if (hideDoneButton) hideDoneButton.hidden = kept.size === 0;
showDone(Number(done));
const again = focused && groups.querySelector('.notepad-job[data-id="' + focused.id + '"] ' + focused.selector);
if (again) again.focus();
return Number(done);
});
});
};
// Changes are sent one at a time, in the order they were made. Sent together,
// an older answer could arrive last and put an older list, and count, on show.
let queue = Promise.resolve();
const send = function (url, body) {
const sent = queue.then(function () { return transmit(url, body); });
queue = sent.catch(function () {});
return sent;
};
// Changes one or more of a job's settings, such as {finished: '1'}.
const patchJob = function (url, attributes) {
const body = new FormData();
body.append('_method', 'patch');
Object.keys(attributes).forEach(function (key) { body.append('job[' + key + ']', attributes[key]); });
return send(url, body);
};
if (hideDoneButton) hideDoneButton.addEventListener('click', function () {
kept.clear();
const url = new URL(hideDoneButton.dataset.url, window.location.origin);
if (groups.dataset.gardenId) url.searchParams.set('garden_id', groups.dataset.gardenId);
send(url.toString(), null).catch(reload);
});
// ---- Crossing a job off, or putting it back
let streak = 0;
let lastAt = null;
groups.addEventListener('submit', function (event) {
const tick = event.target.closest('.notepad-tick');
if (!tick) return;
event.preventDefault();
const finishing = tick.querySelector('input[name="job[finished]"]').value === '1';
const row = tick.closest('.notepad-job');
const name = row.querySelector('.notepad-name').textContent;
const id = row.dataset.id;
if (finishing) kept.add(id); else kept.delete(id);
markSaving(row);
const box = tick.getBoundingClientRect();
const url = tick.action;
send(url, new FormData(tick))
.then(function (done) {
if (!finishing) {
streak = undoStreak(streak);
return hide();
}
streak = nextStreak(streak, lastAt, Date.now());
lastAt = Date.now();
celebrate({
words: words,
name: name,
done: done,
streak: streak,
origin: {x: box.left + box.width / 2, y: box.top + box.height / 2},
onUndo: function () {
streak = undoStreak(streak);
kept.delete(id);
patchJob(url, {finished: '0'}).catch(reload);
},
});
})
.catch(function () {
tick.submit();
});
});
// A row waiting for the server: dimmed, with a spinner, and not to be touched.
const markSaving = function (row) {
row.classList.add('saving');
row.setAttribute('aria-busy', 'true');
row.draggable = false;
};
// ---- Editing a job's name in place
// The row can't be dragged while it is being edited, or the text couldn't be
// selected. If saving fails the box stays, so what was typed is not lost.
const startEditing = function (row) {
if (row.classList.contains('editing')) return;
const name = row.querySelector('.notepad-name');
const original = name.textContent;
const box = document.createElement('input');
box.type = 'text';
box.className = 'notepad-name-input';
box.value = original;
box.setAttribute('aria-label', name.dataset.editLabel);
let finished = false;
let chosen = null;
// Typing @ and a crop name here offers links too. The list drops down under
// the row, and the link picked is shown beside the box until it is saved.
const options = document.createElement('ul');
options.id = 'notepad-edit-suggestions';
options.className = 'notepad-suggestions';
options.setAttribute('role', 'listbox');
options.hidden = true;
const chip = document.createElement('span');
chip.className = 'notepad-edit-link';
chip.hidden = true;
const stop = function () {
finished = true;
box.remove();
options.remove();
chip.remove();
name.hidden = false;
row.classList.remove('editing', 'error');
row.querySelector('.notepad-edit-hint').hidden = true;
row.draggable = true;
};
const save = function () {
const typed = box.value.trim();
if (typed === '' || (typed === original && !chosen)) return stop();
const changes = {name: typed};
if (chosen) Object.assign(changes, {jobbable_type: chosen.jobbable_type, jobbable_id: chosen.jobbable_id});
row.classList.add('saving');
box.readOnly = true;
patchJob(row.dataset.url, changes).catch(function () {
finished = false;
box.readOnly = false;
row.classList.remove('saving');
row.classList.add('error');
box.focus();
});
};
suggestions(box, options, function (item) {
chosen = item;
chip.textContent = kinds[kindOf(item)] + ': ' + item.label;
chip.hidden = false;
});
box.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
box.blur();
} else if (event.key === 'Escape') {
event.preventDefault();
stop();
}
});
box.addEventListener('blur', function () {
if (!finished) save();
});
row.classList.add('editing');
row.draggable = false;
row.querySelector('.notepad-edit-hint').hidden = false;
name.hidden = true;
name.after(box);
box.after(chip);
row.appendChild(options);
box.focus();
box.select();
};
groups.addEventListener('click', function (event) {
const edit = event.target.closest('[data-notepad-edit]');
if (!edit) return;
event.preventDefault();
startEditing(edit.closest('.notepad-job'));
});
// ---- Picking a day from the browser's calendar
// "Change date" opens the browser's own calendar, which marks today, beside the
// job's day. Picking a day moves the job there.
groups.addEventListener('click', function (event) {
const change = event.target.closest('[data-notepad-change-date]');
if (!change) return;
event.preventDefault();
const input = change.closest('.notepad-day').querySelector('.notepad-date-input');
if (input.showPicker) input.showPicker();
else input.focus();
});
groups.addEventListener('change', function (event) {
const input = event.target.closest('.notepad-date-input');
if (!input || !input.value) return;
patchJob(input.closest('.notepad-job').dataset.url, {due_date: input.value}).catch(reload);
});
// ---- Adding a job
const link = document.getElementById('notepad-link');
const typeField = document.getElementById('notepad-jobbable-type');
const idField = document.getElementById('notepad-jobbable-id');
let adding = false;
const clearLink = function () {
typeField.value = '';
idField.value = '';
link.hidden = true;
};
// While a job is on its way, the form says so, so Enter isn't pressed again. The
// box stays open to type the next job in, but it can't be sent until this is.
const showSaving = function (name) {
form.classList.add('saving');
form.setAttribute('aria-busy', 'true');
addButton.disabled = true;
addButton.value = addButton.dataset.savingLabel;
saving.querySelector('.notepad-add-saving-text').textContent = saving.dataset.template.replace('%{name}', name);
saving.hidden = false;
};
const hideSaving = function () {
form.classList.remove('saving');
form.removeAttribute('aria-busy');
addButton.disabled = false;
addButton.value = addLabel;
saving.hidden = true;
};
form.addEventListener('submit', function (event) {
event.preventDefault();
if (adding || !input.value.trim()) return;
adding = true;
failure.hidden = true;
const submitted = input.value;
showSaving(submitted);
send(form.action, new FormData(form))
.then(function () {
// Typing the next job while this one was sent is not to be lost.
if (input.value === submitted) {
input.value = '';
clearLink();
}
input.focus();
adding = false;
hideSaving();
})
.catch(function () {
failure.hidden = false;
adding = false;
hideSaving();
});
});
// ---- Moving a job to a day
// Each group says which day a job dropped in it gets, in data-drop ("none" for
// no day). The row menu does the same for keyboard and touch.
let dragged = null;
const dropTarget = function (event) {
const group = event.target.closest('.notepad-group');
return group && group.dataset.drop ? group : null;
};
const clearDropTargets = function () {
groups.querySelectorAll('.drop-over').forEach(function (el) { el.classList.remove('drop-over'); });
};
groups.addEventListener('dragstart', function (event) {
const row = event.target.closest('.notepad-job');
if (!row) return;
dragged = row;
row.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', row.dataset.url);
});
groups.addEventListener('dragend', function () {
if (dragged) dragged.classList.remove('dragging');
dragged = null;
clearDropTargets();
});
groups.addEventListener('dragover', function (event) {
const group = dropTarget(event);
if (!dragged || !group) return;
event.preventDefault();
clearDropTargets();
group.classList.add('drop-over');
});
groups.addEventListener('drop', function (event) {
const group = dropTarget(event);
if (!dragged || !group) return;
event.preventDefault();
// Show the job in its new place at once, marked as saving, so the drop is seen
// to have worked while the server catches up. The list then replaces it.
const row = dragged;
const hint = group.querySelector('.notepad-drop-hint');
if (hint) hint.remove();
group.appendChild(row);
markSaving(row);
patchJob(row.dataset.url, {due_date: group.dataset.drop === 'none' ? '' : group.dataset.drop}).catch(reload);
});
// ---- Linking a job: typing @ and a crop name offers your plantings and seeds
// of it, then the crop. Picking one swaps the @ text for the crop's name. This
// works in the add box and in the box a job's name is edited in.
// Offers suggestions under a text box, listed in the given element. What to do
// with the one picked is up to onPick.
const suggestions = function (box, list, onPick) {
let items = [];
let active = -1;
let timer = null;
let latest = 0;
box.setAttribute('role', 'combobox');
box.setAttribute('aria-autocomplete', 'list');
box.setAttribute('aria-controls', list.id);
box.setAttribute('aria-expanded', 'false');
const close = function () {
items = [];
active = -1;
list.hidden = true;
list.innerHTML = '';
box.setAttribute('aria-expanded', 'false');
box.removeAttribute('aria-activedescendant');
};
const setActive = function (index) {
active = index;
Array.prototype.forEach.call(list.children, function (option, i) {
option.setAttribute('aria-selected', i === index ? 'true' : 'false');
option.classList.toggle('active', i === index);
});
if (index >= 0) box.setAttribute('aria-activedescendant', list.children[index].id);
};
// The @ and the letters after it, up to the cursor.
const mention = function () {
const before = box.value.slice(0, box.selectionStart);
const match = /(^|\s)@([^@]*)$/.exec(before);
if (!match) return null;
return {start: match.index + match[1].length, query: match[2].trim()};
};
const pick = function (item) {
const found = mention();
if (found) {
const rest = box.value.slice(box.selectionStart).replace(/^\s+/, '');
const text = box.value.slice(0, found.start) + item.crop_name + ' ';
box.value = text + rest;
box.setSelectionRange(text.length, text.length);
}
close();
onPick(item);
box.focus();
};
const render = function () {
list.innerHTML = '';
items.forEach(function (item, i) {
const option = document.createElement('li');
option.id = list.id + '-' + i;
option.setAttribute('role', 'option');
option.className = 'notepad-suggestion';
const icon = document.createElement('img');
icon.className = 'notepad-suggestion-icon';
icon.src = item.icon_url;
icon.alt = '';
option.appendChild(icon);
[['kind', 'span', kinds[kindOf(item)]], ['', 'span', item.label], ['', 'small', item.detail]].forEach(function (part) {
if (!part[2]) return;
const el = document.createElement(part[1]);
el.className = part[0];
el.textContent = part[2];
option.appendChild(el);
});
// mousedown, so the pick happens before the box loses focus.
option.addEventListener('mousedown', function (event) {
event.preventDefault();
pick(item);
});
list.appendChild(option);
});
list.hidden = items.length === 0;
box.setAttribute('aria-expanded', items.length ? 'true' : 'false');
setActive(items.length ? 0 : -1);
};
const search = function () {
const found = mention();
if (!found || !found.query) return close();
const id = ++latest;
const garden = groups.dataset.gardenId ? '&garden_id=' + encodeURIComponent(groups.dataset.gardenId) : '';
fetch(form.dataset.suggestionsUrl + '?q=' + encodeURIComponent(found.query) + garden, {
headers: {'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json'},
credentials: 'same-origin',
})
.then(function (response) { return response.ok ? response.json() : []; })
.then(function (results) {
if (id !== latest) return;
items = results;
render();
})
.catch(close);
};
box.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(search, 150);
});
// Capturing, and before the box's own keys, so Enter picks a suggestion
// instead of submitting or saving, and Escape closes the list first.
box.addEventListener('keydown', function (event) {
if (list.hidden) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive((active + 1) % items.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive((active - 1 + items.length) % items.length);
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
event.stopImmediatePropagation();
if (items[active]) pick(items[active]);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
close();
}
}, true);
box.addEventListener('blur', close);
};
// Linking a job being added: the link is shown in a chip, until it is added.
suggestions(input, document.getElementById('notepad-suggestions'), function (item) {
typeField.value = item.jobbable_type;
idField.value = item.jobbable_id;
link.querySelector('.notepad-link-text').textContent = kinds[kindOf(item)] + ': ' + item.label;
link.hidden = false;
});
link.querySelector('button').addEventListener('click', function () {
clearLink();
input.focus();
});
});