mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-29 10:18:29 -04:00
535 lines
19 KiB
JavaScript
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();
|
|
});
|
|
});
|