mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 20:52:48 -05:00
Merge branch 'main' into feat/photo-library-uploads
This commit is contained in:
104 files changed
+1737
-372
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user