mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 04:32:48 -05:00
Enhance seed library functionality by integrating scope rendering and improving filter interactions
This commit is contained in:
1 parent
bf6e973bf0
commit
d5ef4329e2
8 files changed
+76
-12
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user