Files
growstuff/app/javascript/site/job_ideas.test.js
T
Brenda Wallace bbb8a142c7 widening the range for suggsting jobs (#5034)
* Add crop_job_step association to Job model and migration

- Introduced a has_many association in CropJobStep for jobs with dependent nullify.
- Added crop_job_step reference to Job model with optional association.
- Created migration to add crop_job_step reference to jobs table with foreign key.
- Updated schema to reflect the new association.
- Added tests for crop_job_step association in Job model.

* Add ApplyCropJobTemplate service and for_crop scope to CropJobTemplate model

* Add job idea dismissal feature and related services

- Introduced JobIdeaDismissal model to manage dismissed job ideas for members.
- Added associations and validations for job idea dismissals in the Member model.
- Created JobIdeas service to generate job ideas based on templates and last year's activities.
- Implemented JobIdeas::LastYear and JobIdeas::Templates classes to fetch relevant job ideas.
- Added migration for job_idea_dismissals table and updated schema.
- Developed tests for job idea dismissal functionality and related services.

* Add PlantedLastYear service and update JobIdeas to include it

* Add action attribute to Job model and update related files

* Refactor JobIdeas service: replace LastYear and PlantedLastYear with PastJobs and PastPlantings; update related specs

* Add community and nearby job ideas sources; implement shown and more methods in JobIdeas service

* Implement job idea dismissal duration and snooze functionality; add hidden_until attribute and related specs

* Add Harvest class to JobIdeas service for managing harvest-related job ideas; implement ideas generation and related specs

* Add JobIdeas feature: implement JobIdeasController, views, and related services for job suggestions; include functionality for adding, hiding, and snoozing job ideas

* Refactor button options in job ideas view for improved readability and maintainability

* Enhance Job Ideas feature: add styling for idea cards, implement filtering options, and update controller logic to manage skipped sources; improve localization for job idea reasons and sections

* Add job ideas count and badge display; implement helper methods and update views for better visibility of job ideas

* Enhance Job Ideas feature: add UI elements for job suggestions, implement schedule display for template jobs, and improve localization; update related specs for new functionality

* Enhance Job Ideas feature: implement overlay for job suggestions, add animations for job transitions, and improve UI components; update controller and helper methods for better handling of job ideas

* Refactor Job Ideas feature: remove badge display and related tests; update UI components for better layout and visibility of job suggestions

* Refactor Jobs view and update specs: improve layout for job groups and enhance test coverage for jobs index with ideas

* Refactor Job Ideas feature: replace dismiss! with hide! method, update job count logic, and enhance UI components for better visibility and layout

* Add job ideas feature: implement ideas banner on homepage, enhance overlay logic, and add tests for job ideas display

* widen the range for suggseting jobs

* Fix comment to accurately describe planted crops timeframe in PastPlantings class
2026-10-23 22:24:12 +10:30

193 lines
7.0 KiB
JavaScript

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {addedBlock} from './job_ideas';
// The ideas overlay: opened from the links, choosing an idea without leaving the
// page, and showing the jobs an idea made.
const idea = (key, name) => `
<div class="idea" data-key="${key}">
<div class="idea-title">${name}</div>
<form class="idea-form button_to" action="/job_ideas" method="post">
<input type="hidden" name="key" value="${key}"><button type="submit">Add</button>
</form>
</div>`;
const page = () => `
<meta name="csrf-token" content="token-123">
<a href="/job_ideas" data-job-ideas-open>Ideas</a>
<a class="notepad-ideas" href="/job_ideas"><span class="notepad-ideas-text">We have 2</span></a>
<div id="notepad-groups"><div class="notepad-job" data-id="1">Water</div></div>
<div class="modal" id="job-ideas-modal" data-url="/job_ideas"><div id="job-ideas-body"></div></div>`;
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const json = (data) => ({ok: true, json: () => Promise.resolve(data)});
const html = (text) => ({ok: true, text: () => Promise.resolve(text)});
const body = () => document.getElementById('job-ideas-body');
const submit = (form) => form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true}));
// The script wires itself up when the page has loaded. Import it fresh for each
// test and run that step by hand.
const start = async () => {
vi.resetModules();
const listen = vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
await import('./job_ideas');
const handlers = listen.mock.calls.filter(([name]) => name === 'DOMContentLoaded').map(([, handler]) => handler);
listen.mockRestore();
handlers.forEach((handler) => handler());
};
let fetchMock;
let show;
beforeEach(() => {
document.body.innerHTML = page();
fetchMock = vi.fn();
show = vi.fn();
vi.stubGlobal('fetch', fetchMock);
// Asking for less motion makes every pause end at once.
vi.stubGlobal('matchMedia', () => ({matches: true}));
window.bootstrap = {Modal: {getOrCreateInstance: () => ({show})}};
});
afterEach(() => {
document.body.innerHTML = '';
delete window.bootstrap;
});
describe('addedBlock', () => {
const data = {added_label: 'Added 2 jobs', added: [{id: 1, name: 'Stake', due: '5 Nov'}, {id: 2, name: 'Feed', due: null}]};
it('says what was added', () => {
expect(addedBlock(data).querySelector('.idea-added-label').textContent).toBe('✓ Added 2 jobs');
});
it('lists each job with its day', () => {
expect(Array.from(addedBlock(data).querySelectorAll('li')).map((li) => li.textContent)).toEqual(['5 Nov Stake', 'Feed']);
});
it('brings each job in a moment after the one before', () => {
expect(Array.from(addedBlock(data).querySelectorAll('li')).map((li) => li.style.animationDelay)).toEqual(['0ms', '90ms']);
});
});
describe('opening', () => {
it('loads the ideas into the overlay', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
expect(body().querySelectorAll('.idea').length).toBe(1);
});
it('shows the overlay', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
expect(show).toHaveBeenCalled();
});
});
describe('arriving with the ideas to show', () => {
afterEach(() => window.history.replaceState(null, '', '/'));
it('opens the overlay', async () => {
window.history.replaceState(null, '', '/members/brenda/jobs?ideas=1');
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
await flush();
expect(show).toHaveBeenCalled();
});
it('takes the ideas out of the address, so a reload does not open it again', async () => {
window.history.replaceState(null, '', '/members/brenda/jobs?ideas=1');
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
expect(window.location.search).toBe('');
});
it('does not open the overlay without it', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
await flush();
expect(show).not.toHaveBeenCalled();
});
});
describe('choosing an idea', () => {
const added = {
added: [{id: 7, name: 'Plant tomato', due: null}], added_label: 'Added 1 job to your list', count: 1, banner: 'We have 1',
groups: '<div class="notepad-job" data-id="1">Water</div><div class="notepad-job" data-id="7">Plant tomato</div>',
list: idea('b', 'Dig'),
};
const choose = async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip') + idea('b', 'Dig'))).mockResolvedValueOnce(json(added));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
submit(body().querySelector('.idea-form'));
await flush();
await flush();
};
it('sends it in the background', async () => {
await choose();
expect(new URL(fetchMock.mock.calls[1][0]).pathname).toBe('/job_ideas');
});
it('sends the token', async () => {
await choose();
expect(fetchMock.mock.calls[1][1].headers['X-CSRF-Token']).toBe('token-123');
});
it('marks the new job in the notepad', async () => {
await choose();
expect(document.querySelector('.notepad-job[data-id="7"]').classList.contains('just-added')).toBe(true);
});
it('does not mark the jobs that were there', async () => {
await choose();
expect(document.querySelector('.notepad-job[data-id="1"]').classList.contains('just-added')).toBe(false);
});
it('puts the ideas that are left in the overlay', async () => {
await choose();
expect(Array.from(body().querySelectorAll('.idea')).map((el) => el.dataset.key)).toEqual(['b']);
});
it('updates the words in the box', async () => {
await choose();
expect(document.querySelector('.notepad-ideas-text').textContent).toBe('We have 1');
});
it('hides the box when there are no ideas left', async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip'))).mockResolvedValueOnce(json({...added, count: 0, list: ''}));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
submit(body().querySelector('.idea-form'));
await flush();
await flush();
expect(document.querySelector('.notepad-ideas').hidden).toBe(true);
});
it('does not send the choice again once the server has answered', async () => {
await choose();
const form = body().querySelector('.idea-form');
expect(form === null || form.submit === HTMLFormElement.prototype.submit).toBe(true);
});
it('sends the choice the ordinary way if it could not be sent', async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip'))).mockRejectedValueOnce(new Error('offline'));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
const form = body().querySelector('.idea-form');
form.submit = vi.fn();
submit(form);
await flush();
expect(form.submit).toHaveBeenCalled();
});
});