Enhance seed library functionality by integrating scope rendering and improving filter interactions

This commit is contained in:
Brenda Wallace committed 2026-11-04 07:39:36 +00:00
1 parent bf6e973bf0
commit d5ef4329e2
8 files changed
+76 -12

No files matched your search

+3 -1
View File
@@ -377,9 +377,11 @@ $seed-rule: rgba($brown, 0.15);
}
}
// Two choices joined into one control.
// Choices joined into one control, wrapping onto more lines on a narrow screen.
.seed-seg {
display: inline-flex;
flex-wrap: wrap;
max-width: 100%;
a {
@include seed-choice;
+4 -3
View File
@@ -140,11 +140,12 @@ class SeedsController < DataController
[page_seeds.select { |seed| on_page.include?([seed.owner_id, seed.crop_id]) }, pager]
end
# The seed library fetches its own controls' results alone, as the tabs, the
# results and the crop chosen, rather than the whole page.
# The seed library fetches its own controls' results alone, as the scope, the
# tabs, the results and the crop chosen, rather than the whole page.
def render_results
crop = { name: @search_crop.name, slug: @search_crop.slug } if @search_crop
render json: {
scope: render_to_string(partial: 'seeds/scope', formats: [:html]),
tabs: render_to_string(partial: 'seeds/tabs', formats: [:html]),
results: render_to_string(partial: 'seeds/results', formats: [:html]),
crop:
@@ -154,7 +155,7 @@ class SeedsController < DataController
# The chips and scope switcher above the shelves. Everyone's seeds are the
# ones gardeners chose to share.
def filter(seeds)
@everyone = @owner.blank? && @planting.blank? && request.format.html?
@everyone = @owner.blank? && @planting.blank? && page_of_piles?
seeds = seeds.tradable if @everyone
seeds = seeds.from_source(params[:from]) if Seed::SOURCES_BY_KIND.key?(params[:from])
seeds = seeds.where(heirloom: params[:heirloom]) if Seed::HEIRLOOM_VALUES.include?(params[:heirloom])
+1
View File
@@ -189,6 +189,7 @@ module SeedsHelper
crop_search:,
crop: search_crop && { name: search_crop.name, slug: search_crop.slug },
placeholder: everyone ? 'Search all shared seeds by crop' : 'Search seeds by crop',
scope: render('seeds/scope'),
tabs: render('seeds/tabs'),
html: render('seeds/results')
}
+18 -6
View File
@@ -57,7 +57,8 @@ function Choice({params, search, active, className, onChoose, children}) {
// again, which Rails renders (seeds/_tabs, seeds/_results) and this puts in
// place. Links in the tabs and results that stay on this page, such as the
// pages, do the same. Back and forward work.
export default function SeedLibrary({tabs: initialTabs, html: initialHtml, groups, filters, sow_now: sowNow, gardeners, months = [], clear = {}, crop: initialCrop, crop_search: cropSearch, placeholder}) {
export default function SeedLibrary({scope: initialScope, tabs: initialTabs, html: initialHtml, groups, filters, sow_now: sowNow, gardeners, months = [], clear = {}, crop: initialCrop, crop_search: cropSearch, placeholder}) {
const [scope, setScope] = useState(initialScope);
const [tabs, setTabs] = useState(initialTabs);
const [html, setHtml] = useState(initialHtml);
const [search, setSearch] = useState(window.location.search);
@@ -88,6 +89,7 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
const body = await response.json();
unmountIslands(results.current);
setScope(body.scope);
setTabs(body.tabs);
setHtml(body.results);
setCrop(body.crop);
@@ -118,7 +120,7 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
// A link that changes what is shown, in whichever look the control has.
const choice = (params, label, active, className) => (
<Choice key={label} params={params} search={search} active={active} className={className} onChoose={show}>{label}</Choice>
<Choice key={label} params={params} search={pendingSearch} active={active} className={className} onChoose={show}>{label}</Choice>
);
// A filter chip: choosing it adds that filter to the others in force, and
// choosing it again takes it off.
@@ -133,10 +135,20 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
const chooseGardener = (name) => {
if ((query.get('gardener') || '') !== name.trim()) show(addressWith({gardener: name.trim() || null}, search));
};
// The address as it will be once a gardener typed but not yet entered is
// counted, so choosing another control does not lose the name.
const pendingSearch = (() => {
if (!gardeners) return search;
const pending = new URLSearchParams(search);
if (gardener.trim()) pending.set('gardener', gardener.trim());
else pending.delete('gardener');
return pending.size ? `?${pending}` : '';
})();
const filtering = Object.keys(clear).some((key) => query.has(key));
return (
<>
<div dangerouslySetInnerHTML={{__html: scope}} onClick={onContentClick} />
<div dangerouslySetInnerHTML={{__html: tabs}} onClick={onContentClick} />
<div className="seed-filters">
<div className="seed-filter-row">
@@ -147,7 +159,7 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
{crop ? (
<span className="seed-crop">
{crop.name}
<Choice params={{crop: null}} search={search} className="seed-crop-clear" onChoose={show}>
<Choice params={{crop: null}} search={pendingSearch} className="seed-crop-clear" onChoose={show}>
<span aria-hidden="true">×</span>
<span className="visually-hidden">Clear crop</span>
</Choice>
@@ -159,14 +171,14 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
autoFocus={false}
label="Search seeds by crop"
placeholder={placeholder}
onChoose={(chosen) => show(addressWith({crop: chosen.slug}, search))}
onChoose={(chosen) => show(addressWith({crop: chosen.slug}, pendingSearch))}
/>
)}
</div>
</div>
)}
{filtering && (
<Choice params={clear} search={search} className="seed-clear" onChoose={show}>Clear all filters</Choice>
<Choice params={clear} search={pendingSearch} className="seed-clear" onChoose={show}>Clear all filters</Choice>
)}
</div>
<div className="seed-filter-row">
@@ -176,7 +188,7 @@ export default function SeedLibrary({tabs: initialTabs, html: initialHtml, group
id="seed-month"
className="seed-month form-select form-select-sm"
value={query.get('sow_month') || ''}
onChange={(event) => show(addressWith({sow_month: event.target.value || null}, search))}
onChange={(event) => show(addressWith({sow_month: event.target.value || null}, pendingSearch))}
>
<option value="">Any month</option>
<option value="now">{sowNow || 'Sow now'}</option>
+22 -1
View File
@@ -10,6 +10,7 @@ vi.mock('./CropPicker', () => ({
}));
const props = {
scope: '<nav class="seed-scope"><a href="/seeds?from=retail">Everyone</a></nav>',
tabs: '<nav><a href="/seeds?tab=mine">Mine</a></nav>',
html: '<p>First results</p><ul class="pagination"><li><a href="/seeds?page=2">2</a></li></ul>',
groups: [['crop', 'Crop'], ['year', 'Year']],
@@ -33,7 +34,7 @@ const props = {
placeholder: 'Find a crop',
};
function stubFetch(body = {tabs: '<nav />', results: '<p>Next results</p>', crop: null}) {
function stubFetch(body = {scope: '<nav class="seed-scope"><a href="/seeds">Everyone</a></nav>', tabs: '<nav />', results: '<p>Next results</p>', crop: null}) {
const fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => body}));
vi.stubGlobal('fetch', fetch);
return fetch;
@@ -160,6 +161,26 @@ describe('SeedLibrary', () => {
expect(screen.getByLabelText('Month to sow')).toBeInTheDocument();
});
it('shows the scope Rails rendered', () => {
setup();
expect(screen.getByRole('link', {name: 'Everyone'})).toHaveAttribute('href', '/seeds?from=retail');
});
it('shows the new scope when the results change', async () => {
stubFetch({scope: '<a href="/seeds?from=bought">Everyone now</a>', tabs: '<nav />', results: '<p>Next</p>', crop: null});
setup();
await userEvent.click(screen.getByRole('link', {name: 'Bought'}));
expect(await screen.findByRole('link', {name: 'Everyone now'})).toBeInTheDocument();
});
it('keeps a gardener typed but not entered when another filter is chosen', async () => {
const fetch = stubFetch();
setup();
await userEvent.type(screen.getByLabelText('Gardener'), 'Fiona');
await userEvent.click(screen.getByRole('link', {name: 'Bought'}));
expect(fetch).toHaveBeenLastCalledWith('/seeds?gardener=Fiona&from=bought', expect.anything());
});
it('labels the planted filter', () => {
setup();
expect(screen.getByText('Planted', {selector: '.seed-field-label'})).toBeInTheDocument();
-1
View File
@@ -14,7 +14,6 @@
= render 'seeds/page_head'
%section
= render 'seeds/scope'
= react_component 'SeedLibrary', seed_library_props(everyone: @everyone, crop_search: @crop.blank?, search_crop: @search_crop)
%section.open-data
+15
View File
@@ -141,6 +141,21 @@ describe SeedsController do
it { expect(assigns(:seeds).size).to eq Seed.sown_in(Seed.sow_now_months).count }
end
context 'when the seed library fetches its results' do
let(:friend) { create(:member, login_name: 'Fiona') }
let!(:shared_seed) { create(:seed, crop:, owner: friend, tradable_to: 'locally') }
before do
create(:seed, crop:, owner: friend, tradable_to: 'nowhere')
create(:seed, crop:, owner: create(:member), tradable_to: 'locally')
request.headers['X-Seed-Library'] = 'results'
get :index, params: { gardener: 'fiona' }, format: :json
end
it { expect(assigns(:seeds)).to contain_exactly(shared_seed) }
it { expect(response.parsed_body.keys).to include('scope', 'tabs', 'results') }
end
context 'when choosing a gardener by name' do
let(:friend) { create(:member, login_name: 'Fiona') }
let!(:friend_seed) { create(:seed, crop:, owner: friend, tradable_to: 'locally') }
+13
View File
@@ -57,6 +57,19 @@ describe "changing the seeds shown without reloading", :js do
it { expect(page).to have_current_path member_seeds_path(owner) }
end
context "when choosing mine after choosing a chip" do
include_context 'signed in member'
before do
visit seeds_path
click_link 'Retail outlet'
find('.seed-chips .active', text: 'Retail outlet')
click_link 'Mine'
end
it { expect(page).to have_seed_params(from: 'retail') }
end
context "when choosing a crop" do
before do
fill_in 'Search seeds by crop', with: 'tom'