Merge branch 'main' into feat/photo-library-uploads

This commit is contained in:
Daniel O'Connor authored and GitHub committed 2026-10-26 12:46:39 +10:30
commit 7b5ea4b782
104 files changed
+1737 -372

No files matched your search

+2
View File
@@ -22,12 +22,14 @@ import './site/navbar_search';
import './site/places';
import './site/planting_alternate_name';
import './site/tab_navigation';
import { initFlashMessages } from './site/flash_messages';
// Photos uploaded to the photo library go from the browser straight to
// storage, not through the app: file fields marked direct_upload.
ActiveStorage.start();
document.addEventListener('DOMContentLoaded', function() {
initFlashMessages();
var popoverTrigger = Array.prototype.slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTrigger.map(function(popoverTrigger2) {
return new bootstrap.Popover(popoverTrigger2);
+1 -2
View File
@@ -13,7 +13,7 @@ const FAILED = {byField: {}, other: ['Something went wrong joining. Please try a
// JSON, shows the server's errors against their fields, and on success goes to
// wherever the server would have sent the new member.
export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose, onSwitch}) {
const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false, newsletter: true});
const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false});
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState({byField: {}, other: []});
const captcha = useRecaptcha(recaptchaSiteKey);
@@ -64,7 +64,6 @@ export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose,
<div className="row align-items-center">
<div className="col-md-6">
<CheckField id="sign-up-tos" label={<>I agree to the <a href="/policy/tos" target="_blank" rel="noreferrer">Terms of Service</a></>} checked={values.tos_agreement} onChange={set('tos_agreement')} errors={on.tos_agreement} />
<CheckField id="sign-up-newsletter" label={`Subscribe to the ${siteName} newsletter`} checked={values.newsletter} onChange={set('newsletter')} />
</div>
{recaptchaSiteKey ? <div className="col-md-6 mt-3 mt-md-0"><div ref={captcha.ref} /></div> : null}
</div>
@@ -30,11 +30,6 @@ describe('SignUpModal', () => {
expect(screen.getByRole('dialog', {name: 'Join Growstuff'})).toBeInTheDocument();
});
it('offers the newsletter, ticked', () => {
setup();
expect(screen.getByLabelText('Subscribe to the Growstuff newsletter')).toBeChecked();
});
it('does not tick the terms for me', () => {
setup();
expect(screen.getByLabelText(/Terms of Service/)).not.toBeChecked();
+115
View File
@@ -0,0 +1,115 @@
export function initFlashMessages(delay = 2000) {
const flashContainer = document.querySelector('.flash-messages');
if (!flashContainer) return;
const alerts = Array.from(flashContainer.querySelectorAll('.alert'));
if (alerts.length === 0) return;
alerts.forEach((alertEl) => setupAutoDismiss(alertEl, delay));
}
function setupAutoDismiss(alertEl, delay) {
let timer = null;
let interactionStarted = false;
let isHoveredOrFocused = false;
const startTimer = () => {
if (timer) clearTimeout(timer);
if (isHoveredOrFocused) return;
timer = setTimeout(() => {
dismissAlert(alertEl);
}, delay);
};
const pauseTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
const handleInteraction = (event) => {
// Ignore interaction events originating inside the alert itself
if (event.target && (event.target instanceof Node) && alertEl.contains(event.target)) return;
if (!interactionStarted) {
interactionStarted = true;
detachInteractionListeners();
}
startTimer();
};
const interactionEvents = ['scroll', 'touchstart', 'mousedown', 'keydown', 'focusin'];
const attachInteractionListeners = () => {
interactionEvents.forEach((type) => {
window.addEventListener(type, handleInteraction, { capture: true, passive: true });
});
};
const detachInteractionListeners = () => {
interactionEvents.forEach((type) => {
window.removeEventListener(type, handleInteraction, { capture: true });
});
};
// Pause on hover or focus within the alert
const onMouseEnter = () => {
isHoveredOrFocused = true;
pauseTimer();
};
const onMouseLeave = () => {
isHoveredOrFocused = false;
if (interactionStarted) {
startTimer();
}
};
const onFocusIn = (e) => {
if (alertEl.contains(e.target)) {
isHoveredOrFocused = true;
pauseTimer();
}
};
const onFocusOut = (e) => {
if (!alertEl.contains(e.relatedTarget)) {
isHoveredOrFocused = false;
if (interactionStarted) {
startTimer();
}
}
};
alertEl.addEventListener('mouseenter', onMouseEnter);
alertEl.addEventListener('mouseleave', onMouseLeave);
alertEl.addEventListener('focusin', onFocusIn);
alertEl.addEventListener('focusout', onFocusOut);
// Clean up if alert is dismissed manually
alertEl.addEventListener('closed.bs.alert', () => {
pauseTimer();
detachInteractionListeners();
});
attachInteractionListeners();
}
function dismissAlert(alertEl) {
if (window.bootstrap && window.bootstrap.Alert) {
const bsAlert = window.bootstrap.Alert.getOrCreateInstance(alertEl);
if (bsAlert) {
bsAlert.close();
return;
}
}
// Fallback if Bootstrap JS object is not loaded
alertEl.classList.remove('show');
setTimeout(() => {
alertEl.remove();
}, 150);
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initFlashMessages } from './flash_messages';
describe('initFlashMessages', () => {
let flashContainer;
let alertEl;
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = `
<div class="flash-messages">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<button class="btn-close" type="button" aria-label="Close"></button>
<span>Settings saved!</span>
<input type="text" id="alert-input" />
</div>
</div>
<main>
<button id="outside-btn">Outside</button>
<input type="text" id="outside-input" />
</main>
`;
flashContainer = document.querySelector('.flash-messages');
alertEl = document.querySelector('.alert');
});
afterEach(() => {
vi.useRealTimers();
});
it('does not dismiss flash messages automatically before user interaction', () => {
initFlashMessages(3000);
vi.advanceTimersByTime(5000);
expect(document.querySelector('.alert')).not.toBeNull();
});
it('starts 3-second auto-dismiss on window scroll user interaction', () => {
initFlashMessages(3000);
window.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(2900);
expect(document.querySelector('.alert')).not.toBeNull();
vi.advanceTimersByTime(300);
// After fallback animation timeout (150ms), element is removed if bootstrap JS object is absent
vi.advanceTimersByTime(200);
expect(document.querySelector('.alert')).toBeNull();
});
it('starts 3-second auto-dismiss on touchstart interaction', () => {
initFlashMessages(3000);
window.dispatchEvent(new Event('touchstart'));
vi.advanceTimersByTime(3500);
expect(document.querySelector('.alert')).toBeNull();
});
it('starts 3-second auto-dismiss on focusin outside the alert', () => {
initFlashMessages(3000);
const outsideInput = document.getElementById('outside-input');
outsideInput.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
vi.advanceTimersByTime(3500);
expect(document.querySelector('.alert')).toBeNull();
});
it('pauses auto-dismiss timer while mouse hovers over the alert and resumes when unhovered', () => {
initFlashMessages(3000);
// Trigger user interaction
window.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(2000);
// Hover over alert
alertEl.dispatchEvent(new MouseEvent('mouseenter'));
// Advance past original 3 second mark
vi.advanceTimersByTime(2000);
expect(document.querySelector('.alert')).not.toBeNull();
// Mouse leaves alert
alertEl.dispatchEvent(new MouseEvent('mouseleave'));
// Advance 2.9 seconds: still there
vi.advanceTimersByTime(2900);
expect(document.querySelector('.alert')).not.toBeNull();
// Advance another 300ms + fallback
vi.advanceTimersByTime(300 + 200);
expect(document.querySelector('.alert')).toBeNull();
});
it('pauses auto-dismiss timer while focus is inside the alert', () => {
initFlashMessages(3000);
// Trigger user interaction
window.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(2000);
// Focus on input inside alert
const alertInput = document.getElementById('alert-input');
alertEl.dispatchEvent(new FocusEvent('focusin', { bubbles: true, target: alertInput }));
vi.advanceTimersByTime(3000);
expect(document.querySelector('.alert')).not.toBeNull();
// Focus moves outside alert
const outsideBtn = document.getElementById('outside-btn');
alertEl.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideBtn }));
vi.advanceTimersByTime(3500);
expect(document.querySelector('.alert')).toBeNull();
});
it('uses window.bootstrap.Alert when available', () => {
const closeSpy = vi.fn(() => {
alertEl.remove();
});
window.bootstrap = {
Alert: {
getOrCreateInstance: vi.fn(() => ({
close: closeSpy,
})),
},
};
initFlashMessages(3000);
window.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(3100);
expect(window.bootstrap.Alert.getOrCreateInstance).toHaveBeenCalledWith(alertEl);
expect(closeSpy).toHaveBeenCalled();
delete window.bootstrap;
});
});
+3 -2
View File
@@ -4,6 +4,8 @@
// behind. Then the idea makes way and the next takes its place. If anything goes
// wrong the choice is sent the ordinary way, which shows the ideas on their page.
import {csrfToken} from '../api';
const reduced = function () {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
};
@@ -38,11 +40,10 @@ document.addEventListener('DOMContentLoaded', function () {
const body = document.getElementById('job-ideas-body');
if (!modal || !body) return;
const token = document.querySelector('meta[name="csrf-token"]');
const request = function (url, options) {
const settings = options || {};
return fetch(url, Object.assign({credentials: 'same-origin'}, settings, {
headers: Object.assign({'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': token ? token.content : ''}, settings.headers),
headers: Object.assign({'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken()}, settings.headers),
}));
};
const ok = function (response) {
+38 -5
View File
@@ -6,6 +6,7 @@
// 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';
@@ -19,7 +20,6 @@ document.addEventListener('DOMContentLoaded', function () {
const saving = document.getElementById('notepad-add-saving');
const addButton = form.querySelector('input[type="submit"]');
const addLabel = addButton.value;
const token = document.querySelector('meta[name="csrf-token"]');
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(); };
@@ -37,6 +37,10 @@ document.addEventListener('DOMContentLoaded', function () {
// 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.
@@ -50,14 +54,16 @@ document.addEventListener('DOMContentLoaded', function () {
// 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) {
kept.forEach(function (id) { body.append('keep[]', id); });
if (groups.dataset.gardenId) body.append('garden_id', groups.dataset.gardenId);
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: 'POST',
method: body ? 'POST' : 'GET',
body: body,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': token ? token.content : '',
'X-CSRF-Token': csrfToken(),
},
credentials: 'same-origin',
})
@@ -67,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function () {
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();
@@ -92,6 +99,13 @@ document.addEventListener('DOMContentLoaded', function () {
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;
@@ -239,6 +253,25 @@ document.addEventListener('DOMContentLoaded', function () {
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');