mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 04:32: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
+16
-1
@@ -7,10 +7,25 @@ updates:
|
||||
time: "07:00"
|
||||
open-pull-requests-limit: 20
|
||||
versioning-strategy: lockfile-only
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "07:00"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "07:00"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
@@ -811,7 +811,6 @@ Style/OpenStructUse:
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/helpers/application_helper.rb'
|
||||
- 'app/models/concerns/member_newsletter.rb'
|
||||
|
||||
# Offense count: 1
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.7
|
||||
4.0.9
|
||||
+2
-1
@@ -98,8 +98,9 @@ submit the change with your pull request.
|
||||
|
||||
## Bots
|
||||
|
||||
### Security and Dependency Updates
|
||||
### Agents, Security and Dependency Updates
|
||||
- `codefactor-io[bot]`
|
||||
- DeppBot / [deppbot](https://github.com/deppbot)
|
||||
- `dependabot[bot]` [dependabot](https://github.com/dependabot-bot) / [dependabot-preview](https://github.com/apps/dependabot-preview)
|
||||
- [google-labs-jules[bot]](https://github.com/apps/google-labs-jules)
|
||||
- Copilot
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ruby:4.0.7-trixie
|
||||
FROM ruby:4.0.9-trixie
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update -qq && \
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update -qq && \
|
||||
gnupg2 \
|
||||
shared-mime-info \
|
||||
libvips \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& npm install -g yarn \
|
||||
&& apt-get clean \
|
||||
|
||||
@@ -44,7 +44,7 @@ gem 'flickraw'
|
||||
|
||||
gem 'cancancan' # for checking member privileges
|
||||
gem 'csv_shaper' # CSV export
|
||||
gem 'gibbon', '~>1.2.0' # for Mailchimp newsletter subscriptions
|
||||
gem 'flipper-active_record' # feature flags admins switch on and off
|
||||
|
||||
gem 'pg'
|
||||
|
||||
@@ -140,11 +140,11 @@ end
|
||||
|
||||
group :development, :test do
|
||||
gem 'bullet' # performance tuning by finding unnecesary queries
|
||||
gem 'byebug' # debugging
|
||||
gem 'capybara' # integration tests
|
||||
gem 'capybara-email' # integration tests for email
|
||||
gem 'capybara-screenshot' # for test debugging
|
||||
gem 'database_cleaner'
|
||||
gem 'debug', platforms: %i(mri windows), require: 'debug/prelude' # debugging
|
||||
gem 'factory_bot_rails' # for creating test data
|
||||
gem 'faker'
|
||||
gem 'haml-rails' # HTML templating language
|
||||
|
||||
+11
-14
@@ -129,8 +129,6 @@ GEM
|
||||
bullet (8.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
byebug (13.0.0)
|
||||
reline (>= 0.6.0)
|
||||
cancancan (3.7.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
@@ -177,6 +175,9 @@ GEM
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.6.0)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
@@ -210,6 +211,11 @@ GEM
|
||||
ffi (1.17.4)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
flickraw (0.9.10)
|
||||
flipper (1.4.2)
|
||||
concurrent-ruby (< 2)
|
||||
flipper-active_record (1.4.2)
|
||||
activerecord (>= 4.2, < 9)
|
||||
flipper (~> 1.4.2)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
friendly_id (5.7.0)
|
||||
@@ -218,9 +224,6 @@ GEM
|
||||
geocoder (1.8.6)
|
||||
base64 (>= 0.1.0)
|
||||
csv (>= 3.0.0)
|
||||
gibbon (1.2.1)
|
||||
httparty
|
||||
multi_json (>= 1.9.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
gravatar-ultimate (2.0.0)
|
||||
@@ -246,10 +249,6 @@ GEM
|
||||
logger
|
||||
highline (3.1.2)
|
||||
reline
|
||||
httparty (0.24.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.1.2)
|
||||
@@ -331,8 +330,6 @@ GEM
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.5)
|
||||
multi_json (1.19.1)
|
||||
multi_xml (0.8.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.8)
|
||||
@@ -643,7 +640,6 @@ DEPENDENCIES
|
||||
bootstrap_form (>= 4.5.0)
|
||||
bullet
|
||||
bundler (>= 2.4.22)
|
||||
byebug
|
||||
cancancan
|
||||
capybara
|
||||
capybara-email
|
||||
@@ -655,6 +651,7 @@ DEPENDENCIES
|
||||
csv_shaper
|
||||
dalli
|
||||
database_cleaner
|
||||
debug
|
||||
devise
|
||||
discard (>= 1.2)
|
||||
dotenv-rails
|
||||
@@ -662,11 +659,11 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday
|
||||
flickraw
|
||||
flipper-active_record
|
||||
foreman
|
||||
friendly_id
|
||||
gbifrb
|
||||
geocoder
|
||||
gibbon (~> 1.2.0)
|
||||
gravatar-ultimate
|
||||
haml
|
||||
haml-rails
|
||||
@@ -732,7 +729,7 @@ DEPENDENCIES
|
||||
xmlrpc
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.7
|
||||
ruby 4.0.9
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.7
|
||||
@@ -206,7 +206,9 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// The crop wrangling table, on white so it reads against the page.
|
||||
.wrangle-table {
|
||||
// The crop wrangling and job template tables, on white so they read against the
|
||||
// page.
|
||||
.wrangle-table,
|
||||
.job-templates-table {
|
||||
--bs-table-bg: #{$white};
|
||||
}
|
||||
@@ -14,6 +14,14 @@ $notepad-trail: #a4c98c;
|
||||
animation: notepad-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
// A pale green pill: chips, the link picked for a job, and the jobs an idea made.
|
||||
@mixin notepad-pill {
|
||||
background: $notepad-tile;
|
||||
border-radius: 999px;
|
||||
color: $notepad-leaf;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// A member's own jobs, as a notepad: a green title bar, then rows ruled like a
|
||||
// list. See the garden notepad design.
|
||||
.notepad {
|
||||
@@ -119,12 +127,23 @@ $notepad-trail: #a4c98c;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.notepad-hide-done {
|
||||
margin: 0 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.notepad-note {
|
||||
color: $ink-muted;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.75rem 1.1rem 0;
|
||||
}
|
||||
|
||||
.notepad-older summary {
|
||||
color: $ink-muted;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 1.1rem;
|
||||
}
|
||||
|
||||
.notepad-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -326,7 +345,17 @@ $notepad-trail: #a4c98c;
|
||||
}
|
||||
}
|
||||
|
||||
// The name takes the room beside the tick box and wraps there; a very long
|
||||
// one is clipped after three lines.
|
||||
.notepad-name {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
color: #212121;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
@@ -338,11 +367,8 @@ $notepad-trail: #a4c98c;
|
||||
gap: 0.35rem;
|
||||
min-height: 28px;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: $notepad-tile;
|
||||
color: $notepad-leaf;
|
||||
@include notepad-pill;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&.garden {
|
||||
@@ -377,18 +403,72 @@ $notepad-trail: #a4c98c;
|
||||
// The menu sits at the right edge. After a due label that label has already
|
||||
// pushed it there; without one the menu pushes itself.
|
||||
.notepad-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
.record-actions-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.notepad-due + .notepad-menu {
|
||||
.notepad-due + .notepad-menu,
|
||||
.notepad-day + .notepad-menu {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.notepad-day {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
// A day you can change looks like a button: outlined, with a small arrow.
|
||||
.notepad-due {
|
||||
margin-left: 0;
|
||||
padding: 0.2rem 0.6rem;
|
||||
line-height: 1.2;
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
margin-left: 0.4em;
|
||||
vertical-align: 0.15em;
|
||||
border-top: 0.3em solid;
|
||||
border-right: 0.3em solid transparent;
|
||||
border-left: 0.3em solid transparent;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.unset {
|
||||
color: $ink-muted;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The browser's calendar opens from this, beside the day; it is never seen itself.
|
||||
.notepad-date-input {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// A snail crawling along a trail at the foot of the page, and how many jobs
|
||||
// are done this week.
|
||||
.notepad-snail {
|
||||
@@ -505,11 +585,8 @@ $notepad-trail: #a4c98c;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: $notepad-tile;
|
||||
color: $notepad-leaf;
|
||||
@include notepad-pill;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
@@ -524,11 +601,8 @@ $notepad-trail: #a4c98c;
|
||||
margin: 0.4rem 1.1rem 0 2.8rem;
|
||||
padding: 0 0.25rem 0 0.75rem;
|
||||
min-height: 32px;
|
||||
border-radius: 999px;
|
||||
background: $notepad-tile;
|
||||
color: $notepad-leaf;
|
||||
@include notepad-pill;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
@@ -560,7 +634,6 @@ $notepad-trail: #a4c98c;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Crossing a job off: flowers blooming out of the tick box, a row of them
|
||||
// growing along the bottom of the page for a streak, and a sticky note with an
|
||||
// Undo toast in the corner. They sit outside the notepad, on the page.
|
||||
@@ -983,11 +1056,8 @@ $notepad-trail: #a4c98c;
|
||||
|
||||
.idea-added li {
|
||||
animation: idea-job-in 0.4s ease both;
|
||||
background: $notepad-tile;
|
||||
border-radius: 999px;
|
||||
color: $notepad-leaf;
|
||||
@include notepad-pill;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,17 @@ footer {
|
||||
z-index: $highest-level;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
z-index: $zindex-sticky;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.alert {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
a {
|
||||
font-weight: 800;
|
||||
|
||||
@@ -10,7 +10,7 @@ module Admin
|
||||
before_action :set_template, only: %i(edit update destroy)
|
||||
|
||||
def index
|
||||
@templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :steps).order(:name)
|
||||
@templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :creator, :steps).order(:name)
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -24,7 +24,7 @@ module Admin
|
||||
end
|
||||
|
||||
def create
|
||||
@template = CropJobTemplate.new(template_params)
|
||||
@template = CropJobTemplate.new(template_params.merge(creator: current_member))
|
||||
return render_with_another_step(:new) if add_step?
|
||||
|
||||
if @template.save
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
# The features admins can switch on and off for everyone.
|
||||
class FeaturesController < ApplicationController
|
||||
before_action :authenticate_member!
|
||||
before_action { authorize! :manage, :all }
|
||||
|
||||
def index
|
||||
@features = Feature.all
|
||||
end
|
||||
|
||||
def update
|
||||
feature = Feature.find(params[:id])
|
||||
feature.enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
|
||||
redirect_to admin_features_path, notice: t(".#{feature.enabled? ? 'on' : 'off'}", feature: t("admin.features.names.#{feature.name}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
# Every crop's job templates in one list, for crop wranglers to find the
|
||||
# drafts waiting to be checked and published.
|
||||
class JobTemplatesController < ApplicationController
|
||||
before_action :authenticate_member!
|
||||
before_action { authorize! :wrangle, Crop }
|
||||
|
||||
def index
|
||||
@status = params[:status] if CropJobTemplate.statuses.key?(params[:status])
|
||||
@templates = CropJobTemplate.joins(:crop).includes(:creator, :steps, crop: :parent).order('crops.name', :name)
|
||||
@templates = @templates.where(status: @status) if @status
|
||||
@templates = @templates.paginate(page: params[:page])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,8 +8,10 @@ module Admin
|
||||
responders :flash
|
||||
|
||||
def index
|
||||
@members = Member.order(:login_name)
|
||||
@members = @members.where("login_name ILIKE ?", "%#{search_term}%") if search_term.present?
|
||||
@status = params[:status] == 'deactivated' ? 'deactivated' : 'active'
|
||||
@members = @status == 'deactivated' ? Member.discarded : Member.kept
|
||||
@members = @members.includes(:roles).order(created_at: :desc)
|
||||
@members = @members.where("login_name ILIKE ? OR email ILIKE ?", "%#{search_term}%", "%#{search_term}%") if search_term.present?
|
||||
@members = @members.paginate(page: params[:page])
|
||||
end
|
||||
|
||||
@@ -27,6 +29,26 @@ module Admin
|
||||
def destroy
|
||||
@member = Member.find_by!(slug: params[:slug])
|
||||
@member.discard
|
||||
redirect_to admin_members_path(status: params[:status])
|
||||
end
|
||||
|
||||
def restore
|
||||
@member = Member.find_by!(slug: params[:slug])
|
||||
@member.undiscard
|
||||
redirect_to admin_members_path(status: 'deactivated')
|
||||
end
|
||||
|
||||
def bulk_ban
|
||||
member_ids = params[:member_ids]
|
||||
if member_ids.present? && member_ids.is_a?(Array)
|
||||
members_to_ban = Member.kept.where(id: member_ids)
|
||||
# Prevent admins from self-banning via bulk ban
|
||||
members_to_ban = members_to_ban.where.not(id: current_member.id)
|
||||
members_to_ban.find_each(&:discard)
|
||||
flash[:notice] = "Selected members have been banned."
|
||||
else
|
||||
flash[:alert] = "No members were selected."
|
||||
end
|
||||
redirect_to admin_members_path
|
||||
end
|
||||
|
||||
|
||||
@@ -5,10 +5,4 @@ class AdminController < ApplicationController
|
||||
def index
|
||||
authorize! :manage, :all
|
||||
end
|
||||
|
||||
def newsletter
|
||||
authorize! :manage, :all
|
||||
@members = Member.confirmed.wants_newsletter.all.paginate(page: params[:page], per_page: 100)
|
||||
respond_with @members
|
||||
end
|
||||
end
|
||||
@@ -79,7 +79,7 @@ class ApplicationController < ActionController::Base
|
||||
# profile stuff
|
||||
:bio, :location, :latitude, :longitude,
|
||||
# email settings
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder)
|
||||
:show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder)
|
||||
end
|
||||
|
||||
devise_parameter_sanitizer.permit(:account_update) do |member|
|
||||
@@ -91,7 +91,7 @@ class ApplicationController < ActionController::Base
|
||||
:bio, :location, :latitude, :longitude,
|
||||
:website_url, :instagram_handle, :facebook_handle, :bluesky_handle, :other_url,
|
||||
# email settings
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder,
|
||||
:show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder,
|
||||
# update password
|
||||
:current_password)
|
||||
end
|
||||
|
||||
@@ -35,22 +35,28 @@ class CommentsController < ApplicationController
|
||||
@commentable = @comment.commentable
|
||||
@comment.author = current_member
|
||||
@comment.save
|
||||
respond_with @comment, location: @commentable
|
||||
respond_with @comment, location: commentable_location(@commentable)
|
||||
end
|
||||
|
||||
def update
|
||||
@comment.update(body: comment_params['body'])
|
||||
respond_with @comment, location: @comment.commentable
|
||||
respond_with @comment, location: commentable_location(@comment.commentable)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@commentable = @comment.commentable
|
||||
@comment.destroy
|
||||
respond_with(@commentable)
|
||||
respond_with(@commentable, location: commentable_location(@commentable))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def commentable_location(commentable)
|
||||
return nil unless commentable
|
||||
|
||||
polymorphic_path(commentable, anchor: 'comments')
|
||||
end
|
||||
|
||||
def find_commentable
|
||||
return unless params[:comment]
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
# their jobs, snooze it, or say it is not for them. On the page, or as an overlay
|
||||
# on their jobs, where each choice is sent without leaving and answered with the
|
||||
# ideas that are left, the jobs that were added and the member's jobs as they now
|
||||
# are.
|
||||
# are. Only while admins have the job ideas feature switched on.
|
||||
class JobIdeasController < ApplicationController
|
||||
before_action :authenticate_member!
|
||||
before_action { raise ActiveRecord::RecordNotFound unless Feature.enabled?(:job_ideas) }
|
||||
|
||||
def index
|
||||
load_ideas
|
||||
|
||||
@@ -7,6 +7,7 @@ class JobsController < DataController
|
||||
|
||||
if notepad?
|
||||
load_notepad
|
||||
return render_groups(:ok) if request.xhr?
|
||||
else
|
||||
@jobs = listed_jobs.paginate(page: params[:page], per_page: 30)
|
||||
end
|
||||
@@ -78,6 +79,10 @@ class JobsController < DataController
|
||||
def render_notepad(status)
|
||||
@owner = current_member
|
||||
load_notepad
|
||||
render_groups(status)
|
||||
end
|
||||
|
||||
def render_groups(status)
|
||||
response.set_header('X-Jobs-Done-This-Week', @done_this_week.to_s)
|
||||
render partial: 'jobs/notepad_groups', locals: { groups: @job_groups, in_garden: garden.present? }, status:
|
||||
end
|
||||
|
||||
@@ -92,7 +92,7 @@ class MembersController < ApplicationController
|
||||
}.freeze
|
||||
|
||||
def member_params
|
||||
params.require(:member).permit(:login_name, :tos_agreement, :email, :newsletter, :send_harvest_reminder)
|
||||
params.require(:member).permit(:login_name, :tos_agreement, :email, :send_harvest_reminder)
|
||||
end
|
||||
|
||||
def member_json_fields
|
||||
|
||||
@@ -7,7 +7,7 @@ class NeighboursController < ApplicationController
|
||||
|
||||
def show
|
||||
current_member.feature_seen!(:neighbours)
|
||||
@neighbours = Neighbours.new(current_member, scope: params[:scope])
|
||||
@neighbours = Neighbours.new(current_member, scope: params[:scope], limit: Neighbours::FIRST_FEED_EVENTS)
|
||||
end
|
||||
|
||||
# The next small chunk of the feed, for "Show earlier days": its HTML, and
|
||||
|
||||
@@ -72,7 +72,7 @@ class RegistrationsController < Devise::RegistrationsController
|
||||
end
|
||||
|
||||
def sign_up_params
|
||||
params.require(:member).permit(:login_name, :email, :tos_agreement, :newsletter, :password, :password_confirmation)
|
||||
params.require(:member).permit(:login_name, :email, :tos_agreement, :password, :password_confirmation)
|
||||
end
|
||||
|
||||
def check_captcha
|
||||
|
||||
@@ -47,7 +47,7 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def build_alert_classes(alert_type = :info)
|
||||
classes = 'alert alert-dismissible '
|
||||
classes = 'alert alert-dismissible fade show '
|
||||
case alert_type.to_sym
|
||||
when :alert, :danger, :error, :validation_errors
|
||||
classes += 'alert-danger'
|
||||
|
||||
@@ -5,13 +5,16 @@ module EventHelper
|
||||
(days / 7.0).round
|
||||
end
|
||||
|
||||
# A "job_finished" event is described by jobs/_finished_description.
|
||||
def event_description(event)
|
||||
render "#{event.event_type.pluralize}/description", event_model: resolve_model(event)
|
||||
model = resolve_model(event)
|
||||
partial = event.event_type.end_with?('_finished') ? 'finished_description' : 'description'
|
||||
render "#{model.model_name.collection}/#{partial}", event_model: model
|
||||
rescue ActionView::MissingTemplate
|
||||
"#{event.event_type.humanize.downcase}d"
|
||||
end
|
||||
|
||||
def resolve_model(event)
|
||||
event.event_type.classify.constantize.find(event.id)
|
||||
event.event_type.delete_suffix('_finished').classify.constantize.find(event.id)
|
||||
end
|
||||
end
|
||||
@@ -82,6 +82,10 @@ module IconsHelper
|
||||
icon('fas', 'note-sticky')
|
||||
end
|
||||
|
||||
def job_finished_icon
|
||||
finished_icon
|
||||
end
|
||||
|
||||
def post_icon
|
||||
image_icon 'post'
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ module JobIdeasHelper
|
||||
# The sections of the ideas, in order: the kinds of idea each holds.
|
||||
JOB_IDEA_SECTIONS = {
|
||||
templates: %i(template), this_time: %i(past_job past_planting), harvest: %i(harvest),
|
||||
community: %i(community), nearby: %i(nearby)
|
||||
community: %i(community), nearby: %i(nearby), world: %i(world)
|
||||
}.freeze
|
||||
|
||||
# The shown ideas under their sections, those with none left out.
|
||||
@@ -38,7 +38,7 @@ module JobIdeasHelper
|
||||
# What a section says after its heading. Nearby is named for where the member is.
|
||||
def job_idea_section_note(section, member)
|
||||
place = member.location.to_s.split(',').first.to_s.strip
|
||||
return t('job_ideas.section.nearby_note', place:, km: JobIdeas::Nearby::RADIUS_KM) if section == :nearby && place.present?
|
||||
return t('job_ideas.section.nearby_note', place:) if section == :nearby && place.present?
|
||||
|
||||
t("job_ideas.section.#{section}_note")
|
||||
end
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -192,5 +192,12 @@ class Ability
|
||||
can :destroy, Member do |other_member|
|
||||
other_member&.id != member.id
|
||||
end
|
||||
|
||||
# Jobs are a member's own notepad: admins change theirs, not other members'.
|
||||
cannot %i(update destroy), Job
|
||||
can :update, Job, owner_id: member.id
|
||||
can :destroy, Job, owner_id: member.id
|
||||
can :update, Job, garden: { garden_collaborators: { member_id: member.id } }
|
||||
can :destroy, Job, garden: { garden_collaborators: { member_id: member.id } }
|
||||
end
|
||||
end
|
||||
@@ -1,48 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MemberNewsletter
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :update_newsletter_subscription
|
||||
before_destroy :newsletter_unsubscribe
|
||||
|
||||
scope :wants_newsletter, -> { where(newsletter: true) }
|
||||
|
||||
def update_newsletter_subscription
|
||||
return unless will_save_change_to_attribute?(:confirmed) || will_save_change_to_attribute?(:newsletter)
|
||||
|
||||
if newsletter
|
||||
newsletter_subscribe if confirmed_just_now? || requested_newsletter_just_now?
|
||||
elsif confirmed_at
|
||||
newsletter_unsubscribe
|
||||
end
|
||||
end
|
||||
|
||||
def confirmed_just_now?
|
||||
will_save_change_to_attribute?(:confirmed_at)
|
||||
end
|
||||
|
||||
def requested_newsletter_just_now?
|
||||
confirmed_at && will_save_change_to_attribute?(:newsletter)
|
||||
end
|
||||
|
||||
def newsletter_subscribe(gibbon = Gibbon::API.new, testing = false)
|
||||
return true if Rails.env.test? && !testing
|
||||
|
||||
gibbon.lists.subscribe(
|
||||
id: Rails.application.config.newsletter_list_id,
|
||||
email: { email: },
|
||||
merge_vars: { login_name: },
|
||||
double_optin: false # they already confirmed their email with us
|
||||
)
|
||||
end
|
||||
|
||||
def newsletter_unsubscribe(gibbon = Gibbon::API.new, testing = false)
|
||||
return true if Rails.env.test? && !testing
|
||||
|
||||
gibbon.lists.unsubscribe(id: Rails.application.config.newsletter_list_id,
|
||||
email: { email: })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
# A crop's suggested jobs, offered to gardeners who plant it. It belongs to the
|
||||
# crop it is written for and applies to that crop's varieties too. Only
|
||||
# published templates are offered.
|
||||
# published templates are offered. Its creator is the member who wrote it;
|
||||
# templates loaded from the seed files have none.
|
||||
class CropJobTemplate < ApplicationRecord
|
||||
belongs_to :crop
|
||||
belongs_to :creator, class_name: 'Member', optional: true, inverse_of: :created_crop_job_templates
|
||||
has_many :steps, -> { order(:position, :id) }, class_name: 'CropJobStep', dependent: :destroy, inverse_of: :crop_job_template
|
||||
|
||||
# Which months a season is depends on the gardener's hemisphere, so it is
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The features admins can switch on and off, kept in Flipper. Each is off until
|
||||
# an admin switches it on.
|
||||
class Feature
|
||||
NAMES = %i(job_ideas).freeze
|
||||
|
||||
attr_reader :name
|
||||
|
||||
def self.all
|
||||
NAMES.map { |name| new(name) }
|
||||
end
|
||||
|
||||
def self.find(name)
|
||||
feature = NAMES.find { |known| known.to_s == name.to_s }
|
||||
raise ActiveRecord::RecordNotFound unless feature
|
||||
|
||||
new(feature)
|
||||
end
|
||||
|
||||
def self.enabled?(name)
|
||||
Flipper.enabled?(name)
|
||||
end
|
||||
|
||||
def initialize(name)
|
||||
@name = name
|
||||
end
|
||||
|
||||
def enabled?
|
||||
Flipper.enabled?(name)
|
||||
end
|
||||
|
||||
def enabled=(on)
|
||||
on ? Flipper.enable(name) : Flipper.disable(name)
|
||||
end
|
||||
|
||||
def to_param
|
||||
name.to_s
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,6 @@ class Member < ApplicationRecord
|
||||
acts_as_messageable # messages can be sent to this model
|
||||
include Geocodable
|
||||
include MemberFlickr
|
||||
include MemberNewsletter
|
||||
|
||||
extend FriendlyId
|
||||
|
||||
@@ -82,6 +81,8 @@ class Member < ApplicationRecord
|
||||
inverse_of: :creator
|
||||
has_many :created_alternate_names, class_name: 'AlternateName', foreign_key: 'creator_id', inverse_of: :creator
|
||||
has_many :created_scientific_names, class_name: 'ScientificName', foreign_key: 'creator_id', inverse_of: :creator
|
||||
has_many :created_crop_job_templates, class_name: 'CropJobTemplate', foreign_key: 'creator_id', dependent: :nullify,
|
||||
inverse_of: :creator
|
||||
|
||||
#
|
||||
# Scopes
|
||||
|
||||
@@ -21,7 +21,7 @@ class JobIdeas
|
||||
|
||||
# Sources a member can switch off, by name, and the ones each name stands for.
|
||||
# Templates and harvests are the member's own garden, so they are always on.
|
||||
SWITCHES = { 'mine' => %i(past_jobs past_plantings), 'community' => %i(community), 'nearby' => %i(nearby) }.freeze
|
||||
SWITCHES = { 'mine' => %i(past_jobs past_plantings), 'community' => %i(community), 'nearby' => %i(nearby), 'world' => %i(world) }.freeze
|
||||
|
||||
# How many ideas a member has, for the box over their jobs. It looks at other
|
||||
# people's jobs, so it is kept for a few minutes, until the member adds or hides
|
||||
@@ -64,7 +64,8 @@ class JobIdeas
|
||||
def sources
|
||||
{
|
||||
templates: JobIdeas::Templates, past_jobs: JobIdeas::PastJobs, past_plantings: JobIdeas::PastPlantings,
|
||||
harvest: JobIdeas::Harvest, community: JobIdeas::Community, nearby: JobIdeas::Nearby
|
||||
harvest: JobIdeas::Harvest, community: JobIdeas::Community, nearby: JobIdeas::Nearby,
|
||||
world: JobIdeas::World
|
||||
}.except(*@skipped).values.map { |source| source.new(@member, @today) }
|
||||
end
|
||||
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# What gardeners near a member have been doing, now and at this time of year
|
||||
# before, whatever the crop.
|
||||
# before, whatever the crop. Starts close and widens until there are enough
|
||||
# gardeners to see a trend.
|
||||
class JobIdeas
|
||||
class Nearby < OthersJobs
|
||||
RADIUS_KM = 25
|
||||
RADII_KM = [25, 50, 100, 250, 500].freeze
|
||||
# How many gardeners to widen the search until there are.
|
||||
ENOUGH = 10
|
||||
|
||||
private
|
||||
|
||||
def owner_ids
|
||||
@owner_ids ||= if @member.latitude && @member.longitude
|
||||
Member.confirmed.located.near([@member.latitude, @member.longitude], RADIUS_KM, units: :km).reorder(nil).pluck(:id)
|
||||
within_distance
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def within_distance
|
||||
found = []
|
||||
RADII_KM.each do |radius|
|
||||
found = others.near([@member.latitude, @member.longitude], radius, units: :km).reorder(nil).pluck(:id)
|
||||
return found if found.size >= ENOUGH
|
||||
end
|
||||
found
|
||||
end
|
||||
|
||||
def others
|
||||
Member.confirmed.located.where.not(id: @member.id)
|
||||
end
|
||||
|
||||
def kind
|
||||
:nearby
|
||||
end
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# What gardeners anywhere at a similar latitude to a member did at this time of
|
||||
# year, in this year and in earlier ones. The same latitude, in the same
|
||||
# hemisphere, is the same season, wherever in the world they are.
|
||||
class JobIdeas
|
||||
class World < OthersJobs
|
||||
# Degrees of latitude either side of the member.
|
||||
BAND = 5
|
||||
|
||||
private
|
||||
|
||||
def owner_ids
|
||||
@owner_ids ||= if @member.latitude && @member.longitude
|
||||
Member.confirmed.located.where.not(id: @member.id)
|
||||
.where(latitude: (@member.latitude - BAND)..(@member.latitude + BAND))
|
||||
.where(@member.latitude.positive? ? 'latitude > 0' : 'latitude <= 0').reorder(nil).pluck(:id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# This year as well as the years before.
|
||||
def past_windows
|
||||
[(@today - BEFORE)..@today] + super
|
||||
end
|
||||
|
||||
def kind
|
||||
:world
|
||||
end
|
||||
end
|
||||
end
|
||||
+56
-15
@@ -1,16 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# A member's jobs laid out as a notepad: overdue, today, the rest of this week,
|
||||
# soon, some day, and done. Today, this week and some day are always there, as
|
||||
# places to drop a job; each says which day a job dropped in it gets. Overdue,
|
||||
# soon and done have no day of their own, so nothing is dropped there. A job
|
||||
# crossed off stays where its day puts it, unless it has no day or that day has
|
||||
# gone: then it moves to the bottom, to done, the one finished last first. Jobs
|
||||
# crossed off in the page being looked at are pinned: they stay where they were
|
||||
# until the page is loaded again, so a tick never makes a row jump.
|
||||
# this weekend, next weekend, later, some day, and done. All but overdue and done
|
||||
# are always there, as places to drop a job; each says which day a job dropped in
|
||||
# it gets. Overdue and done have no day of their own, so nothing is dropped there. A job
|
||||
# crossed off is in done, the one finished last first, unless it was crossed off in
|
||||
# the page being looked at: those are pinned, and stay where their day puts them
|
||||
# until the page is loaded again, or hidden, so a tick never makes a row jump. Only the
|
||||
# DONE_SHOWN most recently done are shown; the rest are older, behind "show more".
|
||||
class JobList
|
||||
DONE_SHOWN = 5
|
||||
|
||||
Group = Struct.new(:key, :jobs, :from, :to, :droppable, :drop_date, keyword_init: true) do
|
||||
alias_method :droppable?, :droppable
|
||||
|
||||
# The jobs to show straight away. Only done is cut short.
|
||||
def shown
|
||||
key == :done ? jobs.first(DONE_SHOWN) : jobs
|
||||
end
|
||||
|
||||
# The jobs behind "show more".
|
||||
def older
|
||||
key == :done ? jobs.drop(DONE_SHOWN) : []
|
||||
end
|
||||
end
|
||||
|
||||
# The last day of the week, a Sunday.
|
||||
@@ -18,6 +30,16 @@ class JobList
|
||||
today.end_of_week(:monday)
|
||||
end
|
||||
|
||||
# The first day of the weekend still to come: tomorrow, if today is the Saturday.
|
||||
def self.weekend_start(today)
|
||||
[week_end(today) - 1.day, today + 1.day].max
|
||||
end
|
||||
|
||||
# The day a job moved to Later gets: three weeks out.
|
||||
def self.later_date(today)
|
||||
today + 3.weeks
|
||||
end
|
||||
|
||||
def initialize(jobs, today: Time.zone.today, pinned: [])
|
||||
@jobs = jobs
|
||||
@today = today
|
||||
@@ -25,19 +47,26 @@ class JobList
|
||||
end
|
||||
|
||||
def groups
|
||||
dated, undated = @jobs.partition(&:due_date)
|
||||
past, current = dated.partition { |job| job.due_date < @today }
|
||||
done_past, overdue = past.partition { |job| settled?(job) }
|
||||
done_undated, someday = undated.partition { |job| settled?(job) }
|
||||
done = (done_past + done_undated).sort_by { |job| done_time(job) }.reverse
|
||||
settled, open = @jobs.partition { |job| settled?(job) }
|
||||
done = settled.sort_by { |job| done_time(job) }.reverse
|
||||
dated, someday = open.partition(&:due_date)
|
||||
overdue, current = dated.partition { |job| job.due_date < @today }
|
||||
now, later = current.partition { |job| job.due_date == @today }
|
||||
week, soon = later.partition { |job| ((@today + 1.day)..week_end).cover?(job.due_date) }
|
||||
week, later = later.partition { |job| ((@today + 1.day)..friday).cover?(job.due_date) }
|
||||
weekend, later = later.partition { |job| (weekend_from..week_end).cover?(job.due_date) }
|
||||
next_weekend, later = later.partition { |job| ((saturday + 7.days)..(week_end + 7.days)).cover?(job.due_date) }
|
||||
|
||||
[
|
||||
(Group.new(key: :overdue, jobs: overdue) if overdue.any?),
|
||||
Group.new(key: :today, jobs: now, from: @today, droppable: true, drop_date: @today),
|
||||
(Group.new(key: :week, jobs: week, from: @today + 1.day, to: week_end, droppable: true, drop_date: week_end) if week_end > @today),
|
||||
(Group.new(key: :soon, jobs: soon) if soon.any?),
|
||||
(Group.new(key: :week, jobs: week, from: @today + 1.day, to: friday, droppable: true, drop_date: friday) if friday > @today),
|
||||
(if week_end > @today
|
||||
Group.new(key: :weekend, jobs: weekend, from: weekend_from, to: week_end, droppable: true,
|
||||
drop_date: weekend_from)
|
||||
end),
|
||||
Group.new(key: :next_weekend, jobs: next_weekend, from: saturday + 7.days, to: week_end + 7.days, droppable: true,
|
||||
drop_date: saturday + 7.days),
|
||||
Group.new(key: :later, jobs: later, droppable: true, drop_date: self.class.later_date(@today)),
|
||||
Group.new(key: :someday, jobs: someday, droppable: true),
|
||||
(Group.new(key: :done, jobs: done) if done.any?)
|
||||
].compact
|
||||
@@ -59,4 +88,16 @@ class JobList
|
||||
def week_end
|
||||
self.class.week_end(@today)
|
||||
end
|
||||
|
||||
def friday
|
||||
week_end - 2.days
|
||||
end
|
||||
|
||||
def saturday
|
||||
week_end - 1.day
|
||||
end
|
||||
|
||||
def weekend_from
|
||||
self.class.weekend_start(@today)
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,7 @@ class Neighbours
|
||||
POSTS_SHOWN = 5
|
||||
FEED_KINDS = %i(planting harvest seed photo garden_layout transplant planned_job job_finished planting_finished seed_finished).freeze
|
||||
FEED_EVENTS = 15
|
||||
FIRST_FEED_EVENTS = 300
|
||||
MAX_OFFSET = 500
|
||||
PHOTO_SUBJECTS = %w(Planting Harvest Seed Garden Job).freeze
|
||||
EVENT_MODELS = { 'planting' => Planting, 'harvest' => Harvest, 'seed' => Seed, 'photo' => Photo, 'garden_layout' => Garden,
|
||||
@@ -53,12 +54,13 @@ class Neighbours
|
||||
end
|
||||
Day = Struct.new(:day, :updates, keyword_init: true)
|
||||
|
||||
attr_reader :member, :scope, :offset
|
||||
attr_reader :member, :scope, :offset, :limit
|
||||
|
||||
def initialize(member, scope: nil, offset: 0)
|
||||
def initialize(member, scope: nil, offset: 0, limit: FEED_EVENTS)
|
||||
@member = member
|
||||
@scope = SCOPES.include?(scope) ? scope : 'auto'
|
||||
@offset = offset.to_i.clamp(0, MAX_OFFSET)
|
||||
@limit = limit
|
||||
end
|
||||
|
||||
def shows_following?
|
||||
@@ -167,7 +169,7 @@ class Neighbours
|
||||
end
|
||||
|
||||
def next_offset
|
||||
offset + FEED_EVENTS
|
||||
offset + limit
|
||||
end
|
||||
|
||||
# Names a cached piece of the page for this member. It changes with who they
|
||||
@@ -273,14 +275,14 @@ class Neighbours
|
||||
|
||||
def build_feed
|
||||
rows = feed_rows
|
||||
@more_feed = rows.size > FEED_EVENTS
|
||||
@feed = days_of(rows.first(FEED_EVENTS))
|
||||
@more_feed = rows.size > limit
|
||||
@feed = days_of(rows.first(limit))
|
||||
end
|
||||
|
||||
def feed_rows
|
||||
return [] if feed_owner_ids.empty?
|
||||
|
||||
TimelineService.query(FEED_KINDS).where(owner_id: feed_owner_ids).offset(offset).limit(FEED_EVENTS + 1).to_a
|
||||
TimelineService.query(FEED_KINDS).where(owner_id: feed_owner_ids).offset(offset).limit(limit + 1).to_a
|
||||
end
|
||||
|
||||
def days_of(rows)
|
||||
|
||||
@@ -18,7 +18,6 @@ class TimelineService
|
||||
comment: :comments_query,
|
||||
photo: :photos_query,
|
||||
seed: :seeds_query,
|
||||
job: :jobs_query,
|
||||
planned_job: :planned_jobs_query,
|
||||
job_finished: :jobs_finished_query,
|
||||
like: :likes_query,
|
||||
@@ -27,11 +26,11 @@ class TimelineService
|
||||
planting_finished: :plantings_finished_query,
|
||||
seed_finished: :seeds_finished_query
|
||||
}.freeze
|
||||
DEFAULT_EVENTS = %i(planting harvest post comment photo seed job like).freeze
|
||||
DEFAULT_EVENTS = %i(planting harvest post comment photo seed planned_job job_finished like).freeze
|
||||
|
||||
# Events of the given kinds, newest first. Garden layouts are only here when
|
||||
# asked for, since the timeline page has nothing to say about them; the same goes
|
||||
# for transplants, planned and finished jobs, and finished plantings and seeds.
|
||||
# for transplants, and finished plantings and seeds.
|
||||
def self.query(kinds = DEFAULT_EVENTS)
|
||||
kinds.map { |kind| public_send(EVENT_QUERIES.fetch(kind)) }
|
||||
.reduce { |all, one| all.union_all(one) }
|
||||
@@ -81,16 +80,6 @@ class TimelineService
|
||||
Job.select(:id, "'job' as event_type", 'jobs.created_at as event_at', :owner_id, 'null as crop_id')
|
||||
end
|
||||
|
||||
def self.jobs_query
|
||||
Job.select(
|
||||
:id,
|
||||
"'job' as event_type",
|
||||
'COALESCE(jobs.due_date, jobs.created_at) as event_at',
|
||||
:owner_id,
|
||||
'null as crop_id'
|
||||
)
|
||||
end
|
||||
|
||||
def self.plantings_query
|
||||
Planting.select(
|
||||
:id,
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
- if @templates.empty?
|
||||
%p= "No job templates for #{@crop.name} yet."
|
||||
- else
|
||||
%table.table
|
||||
%table.table.job-templates-table
|
||||
%thead
|
||||
%tr
|
||||
%th Name
|
||||
%th Applies to
|
||||
%th Season
|
||||
%th Written by
|
||||
%th Steps
|
||||
%th Status
|
||||
%th
|
||||
@@ -31,6 +32,7 @@
|
||||
%td= link_to template.name, edit_admin_crop_crop_job_template_path(@crop, template)
|
||||
%td= template.crop == @crop ? t('crop_job_templates.options.applies_to_all', crop: @crop.name) : t('crop_job_templates.options.applies_to_one', crop: template.crop.name)
|
||||
%td= crop_job_option(:season, template.season)
|
||||
%td= template.creator ? link_to(template.creator, template.creator) : 'Seed data'
|
||||
%td= template.steps.size
|
||||
%td
|
||||
%span.badge{ class: template.published? ? 'text-bg-success' : 'text-bg-secondary' }= crop_job_option(:status, template.status)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
- content_for :title, t('.title')
|
||||
|
||||
- content_for :breadcrumbs do
|
||||
%li.breadcrumb-item= link_to 'Admin', admin_path
|
||||
%li.breadcrumb-item.active= link_to t('.title'), admin_features_path
|
||||
|
||||
%p= t('.intro')
|
||||
|
||||
%table.table.table-striped
|
||||
%thead
|
||||
%tr
|
||||
%th= t('.feature')
|
||||
%th= t('.state')
|
||||
%th
|
||||
%tbody
|
||||
- @features.each do |feature|
|
||||
%tr{ id: "feature-#{feature.name}" }
|
||||
%td
|
||||
%strong= t("admin.features.names.#{feature.name}")
|
||||
%br
|
||||
%span.text-muted= t("admin.features.descriptions.#{feature.name}")
|
||||
%td
|
||||
- if feature.enabled?
|
||||
%span.badge.bg-success= t('.on')
|
||||
- else
|
||||
%span.badge.bg-secondary= t('.off')
|
||||
%td
|
||||
- on = feature.enabled?
|
||||
= button_to t(on ? '.switch_off' : '.switch_on'), admin_feature_path(feature),
|
||||
method: :patch, params: { enabled: !on },
|
||||
class: ['btn btn-sm', on ? 'btn-outline-secondary' : 'btn-primary']
|
||||
@@ -15,6 +15,7 @@
|
||||
.card-body
|
||||
%nav.nav#site_admin
|
||||
%li= link_to "Roles", admin_roles_path, class: 'nav-link'
|
||||
%li= link_to t('admin.features.index.title'), admin_features_path, class: 'nav-link'
|
||||
%li= link_to "Forums", forums_path, class: 'nav-link'
|
||||
%li= link_to t('.garden_types'), garden_types_path, class: 'nav-link'
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
%li= link_to "Alternate names", alternate_names_path, class: 'nav-link'
|
||||
%li= link_to "Scientific names", scientific_names_path, class: 'nav-link'
|
||||
%li= link_to "Plant parts", plant_parts_path, class: 'nav-link'
|
||||
%li= link_to "Crop job templates", admin_job_templates_path, class: 'nav-link'
|
||||
|
||||
.col-md-4
|
||||
.card
|
||||
@@ -39,6 +41,5 @@
|
||||
Member admin
|
||||
.card-body
|
||||
%nav.nav#member_admin
|
||||
%li= link_to "Newsletter subscribers", admin_newsletter_path, class: 'nav-link'
|
||||
%li= link_to "Members", admin_members_path, class: 'nav-link'
|
||||
%li= link_to "Photo library access", admin_photo_library_accesses_path, class: 'nav-link'
|
||||
@@ -0,0 +1,44 @@
|
||||
- content_for :title, 'Crop job templates'
|
||||
|
||||
- content_for :breadcrumbs do
|
||||
%li.breadcrumb-item= link_to 'Admin', admin_path
|
||||
%li.breadcrumb-item.active Crop job templates
|
||||
|
||||
%h1 Crop job templates
|
||||
%p.text-muted
|
||||
Every crop's job templates. Gardeners are only offered the published ones, so drafts are waiting for a crop
|
||||
wrangler to check them.
|
||||
|
||||
%ul.nav.nav-tabs.mb-3
|
||||
%li.nav-item
|
||||
= link_to 'All', admin_job_templates_path, class: "nav-link #{'active' if @status.nil?}"
|
||||
- CropJobTemplate.statuses.each_key do |status|
|
||||
%li.nav-item
|
||||
= link_to crop_job_option(:status, status), admin_job_templates_path(status:),
|
||||
class: "nav-link #{'active' if @status == status}"
|
||||
|
||||
- if @templates.empty?
|
||||
%p No job templates here.
|
||||
- else
|
||||
%table.table.job-templates-table
|
||||
%thead
|
||||
%tr
|
||||
%th Crop
|
||||
%th Name
|
||||
%th Season
|
||||
%th Written by
|
||||
%th Steps
|
||||
%th Status
|
||||
%tbody
|
||||
- @templates.each do |template|
|
||||
- crop = template.crop.parent || template.crop
|
||||
%tr
|
||||
%td= link_to template.crop.name, admin_crop_crop_job_templates_path(crop)
|
||||
%td= link_to template.name, edit_admin_crop_crop_job_template_path(crop, template)
|
||||
%td= crop_job_option(:season, template.season)
|
||||
%td= template.creator ? link_to(template.creator, template.creator) : 'Seed data'
|
||||
%td= template.steps.size
|
||||
%td
|
||||
- badge = template.published? ? 'text-bg-success' : 'text-bg-secondary'
|
||||
%span.badge{ class: badge }= crop_job_option(:status, template.status)
|
||||
= will_paginate @templates
|
||||
@@ -2,32 +2,141 @@
|
||||
%li.breadcrumb-item= link_to 'Admin', admin_path
|
||||
%li.breadcrumb-item.active= link_to 'Members', admin_members_path
|
||||
|
||||
.row
|
||||
.row.mb-3
|
||||
.col-md-6
|
||||
= bootstrap_form_tag url: admin_members_path, method: :get, layout: :horizontal do |f|
|
||||
= hidden_field_tag :status, @status
|
||||
.input-group
|
||||
%span.input-group-text= icon 'fas', 'user'
|
||||
= f.text_field(:q, 'aria-label': 'search members', placeholder: 'search members', hide_label: true)
|
||||
= f.text_field(:q, value: params[:q], 'aria-label': 'search members', placeholder: 'search members', hide_label: true)
|
||||
= f.submit 'search', class: 'btn btn-secondary'
|
||||
|
||||
%table.table.table-striped
|
||||
%thead
|
||||
%tr
|
||||
%th{scope: "col"} #
|
||||
%th{scope: "col"} Login Name
|
||||
- @members.each do |member|
|
||||
%tr
|
||||
%td= render 'members/tiny', member: member
|
||||
%td= member.login_name
|
||||
%td
|
||||
%ul
|
||||
- member.roles.pluck(:name).each do |r|
|
||||
%li= r
|
||||
%td
|
||||
= link_to edit_admin_member_path(member), class: 'btn' do
|
||||
= edit_icon
|
||||
= link_to admin_member_path(member), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-light text-danger' do
|
||||
= icon 'fas', 'ban'
|
||||
Ban member
|
||||
%ul.nav.nav-tabs.mb-3
|
||||
%li.nav-item
|
||||
= link_to 'Active', admin_members_path(status: 'active', q: params[:q]), class: "nav-link #{@status == 'active' ? 'active' : ''}"
|
||||
%li.nav-item
|
||||
= link_to 'Deactivated', admin_members_path(status: 'deactivated', q: params[:q]), class: "nav-link #{@status == 'deactivated' ? 'active' : ''}"
|
||||
|
||||
- if @status == 'active'
|
||||
= form_tag bulk_ban_admin_members_path, method: :post, id: 'bulk_ban_form' do
|
||||
.d-flex.justify-content-between.align-items-center.mb-2
|
||||
%div
|
||||
= submit_tag 'Bulk Ban Selected', class: 'btn btn-danger btn-sm', data: { confirm: 'Are you sure you want to ban the selected members?' }
|
||||
|
||||
.table-responsive
|
||||
%table.table.table-striped.table-sm.align-middle
|
||||
%thead
|
||||
%tr
|
||||
%th{scope: "col"}
|
||||
%input#select_all_members{type: "checkbox", onclick: "document.querySelectorAll('.member-select-checkbox').forEach(cb => cb.checked = this.checked);"}
|
||||
%th{scope: "col"} #
|
||||
%th{scope: "col"} Login Name
|
||||
%th{scope: "col"} Email
|
||||
%th{scope: "col"} Confirmed
|
||||
%th{scope: "col"} Last Login
|
||||
%th{scope: "col"} Bio
|
||||
%th{scope: "col"} Roles
|
||||
%th{scope: "col"} Photos
|
||||
%th{scope: "col"} Plantings
|
||||
%th{scope: "col"} Forums
|
||||
%th{scope: "col"} Gardens
|
||||
%th{scope: "col"} Harvests
|
||||
%th{scope: "col"} Jobs
|
||||
%th{scope: "col"} Actions
|
||||
%tbody
|
||||
- @members.each do |member|
|
||||
%tr
|
||||
%td
|
||||
- unless member.id == current_member.id
|
||||
= check_box_tag "member_ids[]", member.id, false, class: 'member-select-checkbox'
|
||||
%td= render 'members/tiny', member: member
|
||||
%td= link_to member.login_name, member
|
||||
%td= member.email
|
||||
%td
|
||||
- if member.confirmed?
|
||||
%span.badge.bg-success Yes
|
||||
- else
|
||||
%span.badge.bg-secondary No
|
||||
%td
|
||||
- if member.last_sign_in_at.present?
|
||||
%small= l(member.last_sign_in_at, format: :short)
|
||||
- else
|
||||
%small.text-muted Never
|
||||
%td
|
||||
- if member.bio.present?
|
||||
%small{title: member.bio}= truncate(member.bio, length: 255)
|
||||
%td
|
||||
- if member.roles.any?
|
||||
%small= member.roles.map(&:name).join(', ')
|
||||
%td= member.photos_count || 0
|
||||
%td= member.plantings_count || 0
|
||||
%td= member.forums_count || 0
|
||||
%td= member.gardens_count || 0
|
||||
%td= member.harvests_count || 0
|
||||
%td= member.jobs_count || 0
|
||||
%td
|
||||
.btn-group.btn-group-sm
|
||||
= link_to edit_admin_member_path(member), class: 'btn btn-outline-secondary', title: 'Edit roles' do
|
||||
= edit_icon
|
||||
- unless member.id == current_member.id
|
||||
= link_to admin_member_path(member, status: @status), method: :delete, data: { confirm: 'Are you sure you want to ban this member?' }, class: 'btn btn-outline-danger', title: 'Ban member' do
|
||||
= icon 'fas', 'ban'
|
||||
|
||||
- else
|
||||
.table-responsive
|
||||
%table.table.table-striped.table-sm.align-middle
|
||||
%thead
|
||||
%tr
|
||||
%th{scope: "col"} #
|
||||
%th{scope: "col"} Login Name
|
||||
%th{scope: "col"} Email
|
||||
%th{scope: "col"} Confirmed
|
||||
%th{scope: "col"} Last Login
|
||||
%th{scope: "col"} Bio
|
||||
%th{scope: "col"} Instagram
|
||||
%th{scope: "col"} Roles
|
||||
%th{scope: "col"} Photos
|
||||
%th{scope: "col"} Plantings
|
||||
%th{scope: "col"} Forums
|
||||
%th{scope: "col"} Gardens
|
||||
%th{scope: "col"} Harvests
|
||||
%th{scope: "col"} Jobs
|
||||
%th{scope: "col"} Actions
|
||||
%tbody
|
||||
- @members.each do |member|
|
||||
%tr
|
||||
%td= render 'members/tiny', member: member
|
||||
%td= member.login_name
|
||||
%td= member.email
|
||||
%td
|
||||
- if member.confirmed?
|
||||
%span.badge.bg-success Yes
|
||||
- else
|
||||
%span.badge.bg-secondary No
|
||||
%td
|
||||
- if member.last_sign_in_at.present?
|
||||
%small= l(member.last_sign_in_at, format: :short)
|
||||
- else
|
||||
%small.text-muted Never
|
||||
%td
|
||||
- if member.bio.present?
|
||||
%small{title: member.bio}= truncate(member.bio, length: 255)
|
||||
%td
|
||||
- if member.instagram_handle.present?
|
||||
%small= member.instagram_handle
|
||||
%td
|
||||
- if member.roles.any?
|
||||
%small= member.roles.map(&:name).join(', ')
|
||||
%td= member.photos_count || 0
|
||||
%td= member.plantings_count || 0
|
||||
%td= member.forums_count || 0
|
||||
%td= member.gardens_count || 0
|
||||
%td= member.harvests_count || 0
|
||||
%td= member.jobs_count || 0
|
||||
%td
|
||||
= link_to restore_admin_member_path(member), method: :post, data: { confirm: 'Restore this member?' }, class: 'btn btn-sm btn-outline-success', title: 'Restore member' do
|
||||
= icon 'fas', 'undo'
|
||||
Restore
|
||||
|
||||
= page_entries_info @members
|
||||
= will_paginate @members
|
||||
@@ -1,7 +0,0 @@
|
||||
- content_for :title, 'Newsletter subscribers'
|
||||
|
||||
%p
|
||||
- @members.each do |m|
|
||||
= m.email
|
||||
%br/
|
||||
= will_paginate @members
|
||||
@@ -34,14 +34,6 @@
|
||||
= f.check_box :send_harvest_reminder
|
||||
Receive regular reminders of upcoming harvests.
|
||||
|
||||
.row.mb-3
|
||||
.offset-md-2.col-md-8.checkbox
|
||||
%label
|
||||
= f.check_box :newsletter
|
||||
Subscribe to the #{ENV['GROWSTUFF_SITE_NAME']} newsletter
|
||||
.form-text
|
||||
= render partial: 'newsletter_blurb'
|
||||
|
||||
.row.mb-3
|
||||
.offset-md-2.col-md-8
|
||||
= f.submit "Save", class: 'btn btn-primary'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
The #{ENV['GROWSTUFF_SITE_NAME']} newsletter is sent out
|
||||
every few weeks. Check out our
|
||||
= link_to 'past newsletters', 'http://blog.growstuff.org/category/newsletter/'
|
||||
if you want to see the sort of thing we'll send you. Of course, we'll never spam you
|
||||
or sell your email address, and you can unsubscribe any time.
|
||||
@@ -13,8 +13,6 @@
|
||||
= f.password_field :password_confirmation
|
||||
|
||||
= f.check_box :tos_agreement, label: safe_join(['I agree to the ', link_to('Terms of Service', '/policy/tos')])
|
||||
= f.check_box :newsletter, checked: true, label: "Subscribe to the #{site_name} newsletter"
|
||||
%p.form-text= render 'newsletter_blurb'
|
||||
|
||||
= f.submit 'Sign up', class: 'btn w-100 btn-success'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-# A yellow bar over the front page when there are job ideas to look at. It leads to
|
||||
-# their jobs, with the ideas showing.
|
||||
- count = JobIdeas.count_for(current_member)
|
||||
-# their jobs, with the ideas showing. Only while the job ideas feature is on.
|
||||
- count = Feature.enabled?(:job_ideas) ? JobIdeas.count_for(current_member) : 0
|
||||
- if count.positive?
|
||||
.home-ideas
|
||||
= render 'jobs/ideas_banner', count: count, href: member_jobs_path(current_member, ideas: 1), open: false
|
||||
@@ -4,7 +4,7 @@
|
||||
- on = @skipped.exclude?(name)
|
||||
= link_to job_idea_switch_path(name, @skipped), class: ['idea-filter', ('off' unless on)] do
|
||||
= on ? '✓' : '+'
|
||||
= t("job_ideas.filter.#{name}", km: JobIdeas::Nearby::RADIUS_KM)
|
||||
= t("job_ideas.filter.#{name}")
|
||||
- if @ideas.empty?
|
||||
%p.idea-none= t('job_ideas.none')
|
||||
- job_idea_sections(@ideas).each do |section, ideas|
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
-# The notepad has a tick box for this.
|
||||
- if job.active && !local_assigns[:inline_edit]
|
||||
= menu_link_to :finish, t('buttons.mark_as_finished'), update_path.call(finished: 1), method: :put, class: 'append-date'
|
||||
= menu_link_to :job, t('jobs.pad.move_today'), update_path.call(due_date: Time.zone.today), method: :put
|
||||
- if JobList.week_end(Time.zone.today) > Time.zone.today
|
||||
= menu_link_to :job, t('jobs.pad.move_week'), update_path.call(due_date: JobList.week_end(Time.zone.today)), method: :put
|
||||
-# On the notepad these are under the job's day.
|
||||
- unless local_assigns[:inline_edit]
|
||||
= render('jobs/reschedule', job:)
|
||||
.dropdown-divider
|
||||
= menu_delete_button(job)
|
||||
@@ -1,4 +1,9 @@
|
||||
#{event_model.name}
|
||||
- if event_model.finished
|
||||
planned to do “#{event_model.name}”
|
||||
- else
|
||||
plans to do “#{event_model.name}”
|
||||
- if event_model.due_date && event_model.due_date >= Time.zone.today
|
||||
(due #{job_due_label(event_model)})
|
||||
|
||||
- if event_model.planting
|
||||
%span on #{link_to event_model.planting, event_model.planting}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
did “#{event_model.name}”
|
||||
|
||||
- if event_model.planting
|
||||
%span on #{link_to event_model.planting, event_model.planting}
|
||||
|
||||
- if event_model.garden
|
||||
%span in #{link_to event_model.garden, event_model.garden}
|
||||
@@ -1,5 +1,6 @@
|
||||
-# The ideas over a member's jobs: the yellow box that says there are some, if there
|
||||
-# are, and the overlay it opens.
|
||||
- count = JobIdeas.count_for(owner)
|
||||
= render 'jobs/ideas_banner', count: count if count.positive?
|
||||
= render 'jobs/ideas_modal'
|
||||
-# are, and the overlay it opens. Only while the job ideas feature is on.
|
||||
- if Feature.enabled?(:job_ideas)
|
||||
- count = JobIdeas.count_for(owner)
|
||||
= render 'jobs/ideas_banner', count: count if count.positive?
|
||||
= render 'jobs/ideas_modal'
|
||||
@@ -27,6 +27,7 @@
|
||||
%p.notepad-note= t('jobs.pad.public_note') + ' ' + t('jobs.pad.link_hint')
|
||||
= render 'jobs/ideas', owner: owner unless garden || !local_assigns.fetch(:ideas_banner, true)
|
||||
|
||||
%button.btn.btn-sm.btn-outline-secondary.notepad-hide-done{ id: 'notepad-hide-done', type: 'button', hidden: true, data: { url: member_jobs_path(owner) } }= t('jobs.pad.hide_done')
|
||||
#notepad-groups{ data: { celebrations: job_celebrations.to_json, garden_id: garden&.id } }= render 'jobs/notepad_groups', groups:, in_garden: garden.present?
|
||||
.notepad-snail
|
||||
.notepad-snail-track{ 'aria-hidden': 'true' }
|
||||
|
||||
@@ -5,11 +5,21 @@
|
||||
%h2= t("jobs.pad.#{group.key}")
|
||||
- if group.key == :today
|
||||
%small= l(group.from, format: '%a %-d %b')
|
||||
- elsif group.key == :week && group.from <= group.to
|
||||
- elsif %i(week weekend next_weekend).include?(group.key) && group.from <= group.to
|
||||
%small= "#{l(group.from, format: '%a %-d')} – #{l(group.to, format: '%a %-d %b')}"
|
||||
- elsif group.key == :someday
|
||||
%small= t('jobs.pad.someday_note')
|
||||
- if group.jobs.empty?
|
||||
%p.notepad-drop-hint= t('jobs.pad.drop_here')
|
||||
- group.jobs.each do |job|
|
||||
= render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden]
|
||||
-# Later is rolled up until asked for: it is not for today's list.
|
||||
- row_for = ->(job) { render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] }
|
||||
- if group.key == :later && group.jobs.any?
|
||||
%details.notepad-older
|
||||
%summary= t('jobs.pad.show_later', count: group.jobs.size)
|
||||
= safe_join(group.shown.map(&row_for))
|
||||
- else
|
||||
= safe_join(group.shown.map(&row_for))
|
||||
- if group.older.any?
|
||||
%details.notepad-older
|
||||
%summary= t('jobs.pad.show_older', count: group.older.size)
|
||||
= safe_join(group.older.map(&row_for))
|
||||
@@ -28,6 +28,16 @@
|
||||
= link_to photo_path(photo), class: 'notepad-photo', title: t("jobs.pad.photo_of_#{moment}", title: photo.title) do
|
||||
= image_tag photo.thumbnail_url, alt: t("jobs.pad.photo_of_#{moment}", title: photo.title)
|
||||
%span.notepad-photo-moment= t("jobs.pad.#{moment}")
|
||||
- if date_label
|
||||
- if job.finished
|
||||
- if date_label
|
||||
%span.notepad-due{ class: ('late' if job_overdue?(job)) }= date_label
|
||||
- elsif can?(:edit, job)
|
||||
-# The day is a menu: move the job to a day, or pick one from a calendar.
|
||||
.dropdown.notepad-day
|
||||
%a.notepad-due{ href: '#', role: 'button', class: [('late' if job_overdue?(job)), ('unset' unless date_label)], 'aria-haspopup': 'true', 'aria-expanded': 'false',
|
||||
title: t('jobs.pad.change_day'), data: { 'bs-toggle': 'dropdown' } }= date_label || t('jobs.pad.set_day')
|
||||
.dropdown-menu.dropdown-menu-end= render 'jobs/reschedule', job:, inline_edit: true
|
||||
%input.notepad-date-input{ type: 'date', value: job.due_date&.to_fs(:ymd), tabindex: -1, 'aria-label': t('jobs.pad.change_date') }
|
||||
- elsif date_label
|
||||
%span.notepad-due{ class: ('late' if job_overdue?(job)) }= date_label
|
||||
.notepad-menu= render 'jobs/actions', job:, inline_edit: true
|
||||
@@ -0,0 +1,10 @@
|
||||
-# The ways to move a job to another day. On the notepad they sit under the job's day.
|
||||
- update_path = ->(attributes) { job_path(job, job: attributes) }
|
||||
= menu_link_to :job, t('jobs.pad.move_today'), update_path.call(due_date: Time.zone.today), method: :put
|
||||
= menu_link_to :job, t('jobs.pad.move_tomorrow'), update_path.call(due_date: Time.zone.tomorrow), method: :put
|
||||
- if JobList.weekend_start(Time.zone.today) > Time.zone.tomorrow
|
||||
= menu_link_to :job, t('jobs.pad.move_weekend'), update_path.call(due_date: JobList.weekend_start(Time.zone.today)), method: :put
|
||||
= menu_link_to :job, t('jobs.pad.move_later'), update_path.call(due_date: JobList.later_date(Time.zone.today)), method: :put
|
||||
-# On the notepad the script opens the browser's own calendar, which marks today.
|
||||
- if local_assigns[:inline_edit]
|
||||
= menu_link_to :job, t('jobs.pad.change_date'), edit_job_path(job), data: { notepad_change_date: true }
|
||||
@@ -1,5 +1,7 @@
|
||||
- my_garden_current = member_signed_in? && current_section?(member_gardens_path(current_member))
|
||||
- my_pages_current = member_signed_in? && current_section?(member_path(current_member))
|
||||
- admin_current = current_section?(admin_path, wrangle_crops_path)
|
||||
- crops_current = !admin_current && current_section?(crops_path)
|
||||
- community_current = !my_garden_current && !my_pages_current && current_section?(places_path, gardens_path, harvests_path, seeds_path, plantings_path, members_path, community_gardens_path, posts_path, forums_path, releases_path)
|
||||
#navbarSupportedContent.offcanvas-lg.offcanvas-end{ tabindex: -1, 'aria-label' => 'Menu' }
|
||||
-# The header and the section headings are only for the drawer a phone or tablet gets.
|
||||
@@ -45,7 +47,7 @@
|
||||
- if neighbours_new?
|
||||
%span.badge.text-bg-success.ms-1= t('.new_badge')
|
||||
%li.nav-item
|
||||
= link_to crops_path, class: 'nav-link', aria: { current: ('page' if current_section?(crops_path)) } do
|
||||
= link_to crops_path, class: 'nav-link', aria: { current: ('page' if crops_current) } do
|
||||
= menu_icon(:crop)
|
||||
= t('.crops')
|
||||
%li.nav-item.dropdown
|
||||
@@ -92,7 +94,7 @@
|
||||
= t('.help_and_community')
|
||||
- if member_signed_in? && (current_member.role?(:crop_wrangler) || current_member.role?(:admin))
|
||||
%li.nav-item.dropdown
|
||||
%a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button"}
|
||||
%a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button", "aria-current" => ('page' if admin_current)}
|
||||
%span
|
||||
= t('.admin')
|
||||
.dropdown-menu{"aria-labelledby" => "navbarDropdown"}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- if member.website_url.present?
|
||||
%p
|
||||
= icon 'fas', 'globe', class: 'fa-fw'
|
||||
= link_to "Website", member.website_url, target: '_blank', rel: 'noopener noreferrer'
|
||||
= link_to "Website", member.website_url, target: '_blank', rel: (member.plantings.empty? ? 'noopener noreferrer nofollow' : 'noopener noreferrer')
|
||||
|
||||
- if member.instagram_handle.present?
|
||||
%p
|
||||
@@ -24,7 +24,7 @@
|
||||
- if member.other_url.present?
|
||||
%p
|
||||
= icon 'fas', 'link', class: 'fa-fw'
|
||||
= link_to "More...", member.other_url, target: '_blank', rel: 'noopener noreferrer'
|
||||
= link_to "More...", member.other_url, target: '_blank', rel: (member.plantings.empty? ? 'noopener noreferrer nofollow' : 'noopener noreferrer')
|
||||
|
||||
- if flickr_auth
|
||||
%p
|
||||
|
||||
@@ -28,13 +28,5 @@
|
||||
I agree to the
|
||||
= succeed "." do
|
||||
= link_to 'Terms of Service', "#{root_url}/policy/tos"
|
||||
.mb-3
|
||||
.offset-md-2.col-md-8.checkbox
|
||||
%label
|
||||
= f.check_box :newsletter, checked: true
|
||||
Subscribe to the #{ENV['GROWSTUFF_SITE_NAME']} newsletter
|
||||
.help-inline
|
||||
= render partial: 'devise/registrations/newsletter_blurb'
|
||||
|
||||
.actions
|
||||
= f.submit 'Continue', class: 'btn btn-primary'
|
||||
@@ -1,4 +1,6 @@
|
||||
- flash.each do |type, content|
|
||||
%div{ class: build_alert_classes(type), role: "alert" }
|
||||
%button.btn-close{ type: "button", "aria-label" => "Close", "data-bs-dismiss" => "alert" }
|
||||
= sanitize(content)
|
||||
- if flash.any?
|
||||
.flash-messages
|
||||
- flash.each do |type, content|
|
||||
%div{ class: build_alert_classes(type), role: "alert" }
|
||||
%button.btn-close{ type: "button", "aria-label" => "Close", "data-bs-dismiss" => "alert" }
|
||||
= sanitize(content)
|
||||
@@ -72,12 +72,6 @@ module Growstuff
|
||||
config.user_agent = 'Growstuff'
|
||||
config.user_agent_email = "info@growstuff.org"
|
||||
|
||||
Gibbon::API.api_key = ENV['GROWSTUFF_MAILCHIMP_APIKEY'] || 'notarealkey'
|
||||
# API key can't be blank or tests fail
|
||||
Gibbon::API.timeout = 10
|
||||
Gibbon::API.throws_exceptions = false
|
||||
config.newsletter_list_id = ENV.fetch('GROWSTUFF_MAILCHIMP_NEWSLETTER_ID', nil)
|
||||
|
||||
# config.active_record.raise_in_transactional_callbacks = true
|
||||
config.middleware.insert_before 0, Rack::Attack
|
||||
|
||||
|
||||
+44
-4
@@ -125,6 +125,24 @@ en:
|
||||
heirloom_inclusion: "You must say whether the seeds are heirloom, hybrid, or unknown"
|
||||
source:
|
||||
source_inclusion: "You must say where the seeds are from, or that you don't know"
|
||||
admin:
|
||||
features:
|
||||
descriptions:
|
||||
job_ideas: Suggested jobs for members, from their past jobs and plantings, crop templates and what gardeners nearby are doing.
|
||||
index:
|
||||
feature: Feature
|
||||
intro: Switch features on or off for everyone.
|
||||
'off': 'Off'
|
||||
'on': 'On'
|
||||
state: State
|
||||
switch_off: Switch off
|
||||
switch_on: Switch on
|
||||
title: Features
|
||||
names:
|
||||
job_ideas: Job ideas
|
||||
update:
|
||||
'off': "%{feature} is now off."
|
||||
'on': "%{feature} is now on."
|
||||
application_helper:
|
||||
title:
|
||||
title:
|
||||
@@ -705,7 +723,8 @@ en:
|
||||
filter:
|
||||
mine: what I did before
|
||||
community: people growing what I grow
|
||||
nearby: near me, within %{km} km
|
||||
nearby: near me
|
||||
world: world neighbours
|
||||
section:
|
||||
templates: Templates
|
||||
templates_note: for plantings you have
|
||||
@@ -716,7 +735,9 @@ en:
|
||||
community: People growing what you grow
|
||||
community_note: public jobs
|
||||
nearby: Near you
|
||||
nearby_note: "%{place} and %{km} km around"
|
||||
nearby_note: "%{place} and around"
|
||||
world: World neighbours
|
||||
world_note: gardeners at your latitude, anywhere
|
||||
none: Nothing to suggest right now. Check back as the season changes.
|
||||
add: "+ Add"
|
||||
add_all: Add all %{count}
|
||||
@@ -769,6 +790,11 @@ en:
|
||||
other: "%{count} gardeners nearby did this lately."
|
||||
earlier:
|
||||
other: "%{count} gardeners nearby did this at this time of year before."
|
||||
world:
|
||||
now:
|
||||
other: "%{count} gardeners at your latitude did this lately."
|
||||
earlier:
|
||||
other: "%{count} gardeners at your latitude did this at this time of year."
|
||||
jobs:
|
||||
plan_something: Plan something
|
||||
celebrate:
|
||||
@@ -808,6 +834,13 @@ en:
|
||||
adding: "Adding “%{name}”…"
|
||||
edit_label: Job name
|
||||
edit_hint: Enter to save, Esc to cancel
|
||||
show_later:
|
||||
one: Show 1 job coming up
|
||||
other: Show %{count} jobs coming up
|
||||
show_older:
|
||||
one: Show 1 older done job
|
||||
other: Show %{count} older done jobs
|
||||
hide_done: Hide done
|
||||
add_failed: That job may not have been added. Check your list before you try again.
|
||||
public_note: Jobs are public, so don't put private info here.
|
||||
overdue: Overdue
|
||||
@@ -822,12 +855,19 @@ en:
|
||||
done_today: done today
|
||||
today: Today
|
||||
week: This week
|
||||
soon: Soon
|
||||
weekend: This weekend
|
||||
next_weekend: Next weekend
|
||||
later: Later
|
||||
someday: Some day
|
||||
drop_here: Drag a job here.
|
||||
slow: Slow and steady.
|
||||
move_today: Today
|
||||
move_week: This week
|
||||
change_day: Change the day
|
||||
set_day: Set a day
|
||||
change_date: Change date…
|
||||
move_tomorrow: Tomorrow
|
||||
move_weekend: This weekend
|
||||
move_later: Later
|
||||
someday_note: a wishlist, nothing committed
|
||||
garden: Garden
|
||||
crop: Crop
|
||||
|
||||
+6
-2
@@ -184,11 +184,13 @@ Rails.application.routes.draw do
|
||||
|
||||
scope :admin do
|
||||
get '/' => 'admin#index', as: 'admin'
|
||||
get '/newsletter' => 'admin#newsletter', as: 'admin_newsletter'
|
||||
end
|
||||
|
||||
namespace :admin do
|
||||
resources :members, param: :slug
|
||||
resources :members, param: :slug do
|
||||
post :bulk_ban, on: :collection
|
||||
post :restore, on: :member
|
||||
end
|
||||
resources :roles
|
||||
resources :photo_library_accesses, only: :index do
|
||||
member do
|
||||
@@ -197,6 +199,8 @@ Rails.application.routes.draw do
|
||||
post :revoke
|
||||
end
|
||||
end
|
||||
resources :features, only: %i(index update)
|
||||
resources :job_templates, only: :index
|
||||
resources :crops, param: :slug do
|
||||
resources :crop_companions
|
||||
resources :crop_job_templates, path: 'job_templates', except: :show
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Flipper's tables, for the features admins switch on and off.
|
||||
class CreateFlipperTables < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
create_table :flipper_features do |t|
|
||||
t.string :key, null: false
|
||||
t.timestamps null: false
|
||||
end
|
||||
add_index :flipper_features, :key, unique: true
|
||||
|
||||
create_table :flipper_gates do |t|
|
||||
t.string :feature_key, null: false
|
||||
t.string :key, null: false
|
||||
t.text :value
|
||||
t.timestamps null: false
|
||||
end
|
||||
add_index :flipper_gates, %i(feature_key key value), unique: true, length: { value: 255 }
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :flipper_gates
|
||||
drop_table :flipper_features
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Who wrote a crop job template. Templates loaded from the seed files have none.
|
||||
class AddCreatorToCropJobTemplates < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_reference :crop_job_templates, :creator, foreign_key: { to_table: :members }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The newsletter feature has been removed, but the column is kept in case we
|
||||
# bring it back.
|
||||
class CommentOnMembersNewsletter < ActiveRecord::Migration[8.1]
|
||||
COMMENT = 'From when we had a newsletter. Unused now, kept in case we bring it back.'
|
||||
|
||||
def change
|
||||
change_column_comment :members, :newsletter, from: nil, to: COMMENT
|
||||
end
|
||||
end
|
||||
+21
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_10_25_000000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -545,11 +545,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) do
|
||||
|
||||
create_table "crop_job_templates", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "creator_id"
|
||||
t.bigint "crop_id", null: false
|
||||
t.string "name", null: false
|
||||
t.string "season", default: "any", null: false
|
||||
t.string "status", default: "draft", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["creator_id"], name: "index_crop_job_templates_on_creator_id"
|
||||
t.index ["crop_id"], name: "index_crop_job_templates_on_crop_id"
|
||||
end
|
||||
|
||||
@@ -602,6 +604,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) do
|
||||
t.index ["slug"], name: "index_crops_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "flipper_features", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["key"], name: "index_flipper_features_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "flipper_gates", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "feature_key", null: false
|
||||
t.string "key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "value"
|
||||
t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
|
||||
end
|
||||
|
||||
create_table "follows", id: :serial, force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.integer "followed_id"
|
||||
@@ -841,7 +859,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) do
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.string "login_name"
|
||||
t.float "longitude"
|
||||
t.boolean "newsletter"
|
||||
t.boolean "newsletter", comment: "From when we had a newsletter. Unused now, kept in case we bring it back."
|
||||
t.string "other_url"
|
||||
t.integer "photos_count"
|
||||
t.integer "plantings_count"
|
||||
@@ -1107,6 +1125,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) do
|
||||
add_foreign_key "crop_dismissals", "members"
|
||||
add_foreign_key "crop_job_steps", "crop_job_templates"
|
||||
add_foreign_key "crop_job_templates", "crops"
|
||||
add_foreign_key "crop_job_templates", "members", column: "creator_id"
|
||||
add_foreign_key "harvests", "plantings"
|
||||
add_foreign_key "job_idea_dismissals", "members"
|
||||
add_foreign_key "jobs", "crop_job_steps", on_delete: :nullify
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 1. World neighbours
|
||||
|
||||
Date: 2026-10-24
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Job suggestions include a "Near you" section: what gardeners within a set
|
||||
distance of the member have been doing at this time of year. A fixed 25 km radius
|
||||
often finds too few gardeners to show a trend, because a suggestion needs at
|
||||
least `OthersJobs::MIN_GARDENERS` gardeners doing the same job.
|
||||
|
||||
Distance is a poor proxy for what matters when gardening: the season. The season
|
||||
follows latitude and hemisphere. A gardener 5,000 km east or west at the same
|
||||
latitude is sowing and harvesting at about the same time. A gardener 500 km
|
||||
north can be several weeks behind, and one in the other hemisphere is in the
|
||||
opposite season.
|
||||
|
||||
## Decision
|
||||
|
||||
A **world neighbour** is a confirmed member with a known location whose latitude
|
||||
is within `JobIdeas::World::BAND` degrees (5) of the member's, on the same side
|
||||
of the equator. Longitude and distance don't matter.
|
||||
|
||||
- The member's own jobs are left out, as are those of members they have blocked.
|
||||
- World neighbours are their own section, "World neighbours", with their own
|
||||
switch (`world`), separate from "Near you".
|
||||
- It suggests the jobs world neighbours finished in the three weeks either side
|
||||
of today's date in this year and each of the five before, plus the last two
|
||||
weeks, when enough of them did the same one. This year is included, unlike
|
||||
the other sources, so what neighbours are doing now counts.
|
||||
- "Near you" stays about place. It widens its radius (25, 50, 100, 250, 500 km)
|
||||
until there are `JobIdeas::Nearby::ENOUGH` gardeners, so a sparse area still
|
||||
finds neighbours.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Members in sparsely populated places get suggestions from gardeners who
|
||||
share their season.
|
||||
- Nearby gardeners can appear in both sections. Ideas have a key per source, so
|
||||
hiding one doesn't hide the other.
|
||||
- The band is in degrees of latitude, so it covers the same number of kilometres
|
||||
north to south everywhere, but sits closer to the poles where climate changes
|
||||
faster with latitude. Altitude, coast and local climate aren't considered.
|
||||
- Members whose latitude is within `BAND` degrees of the equator only see
|
||||
neighbours on their own side of it, even though the seasons there are less
|
||||
distinct.
|
||||
@@ -31,15 +31,6 @@ RAILS_SECRET_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# name that appears on the site, eg. in page titles
|
||||
GROWSTUFF_SITE_NAME="Growstuff (dev)"
|
||||
|
||||
# Mailchimp is used for subscribing/unsubscribing people from the newsletter
|
||||
# To fetch list IDs using Gibbon (and thus find the ID of your newsletter):
|
||||
# $ rails c
|
||||
# Gibbon::API.api_key = '...'
|
||||
# gb = Gibbon::API.new
|
||||
# puts gb.lists.list.to_yaml
|
||||
GROWSTUFF_MAILCHIMP_APIKEY=""
|
||||
GROWSTUFF_MAILCHIMP_NEWSLETTER_ID=""
|
||||
|
||||
# Used for connecting member accounts to Flickr
|
||||
# Get Flickr key from http://www.flickr.com/services/apps/create/apply/
|
||||
GROWSTUFF_FLICKR_KEY=""
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"sass": "^1.105.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"node": "24.x",
|
||||
"yarn": ">=0.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,18 +5,9 @@ require 'rails_helper'
|
||||
describe AdminController do
|
||||
login_member(:admin_member)
|
||||
|
||||
describe "GET admin/newsletter" do
|
||||
before { get :newsletter }
|
||||
describe "GET admin" do
|
||||
before { get :index }
|
||||
|
||||
describe 'fetches the admin newsletter page' do
|
||||
it { expect(response).to be_successful }
|
||||
it { expect(response).to render_template("admin/newsletter") }
|
||||
end
|
||||
|
||||
describe 'assigns @members' do
|
||||
let!(:m) { create(:newsletter_recipient_member) }
|
||||
|
||||
it { expect(assigns(:members)).to eq [m] }
|
||||
end
|
||||
it { expect(response).to be_successful }
|
||||
end
|
||||
end
|
||||
@@ -112,7 +112,7 @@ describe CommentsController do
|
||||
}
|
||||
end.to change(Comment, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(planting)
|
||||
expect(response).to redirect_to(planting_path(planting, anchor: 'comments'))
|
||||
expect(Comment.last.commentable).to eq(planting)
|
||||
end
|
||||
end
|
||||
@@ -124,7 +124,7 @@ describe CommentsController do
|
||||
let(:comment) { create(:comment, author: member) }
|
||||
|
||||
it "redirects to the comment's post" do
|
||||
expect(response).to redirect_to(comment.commentable)
|
||||
expect(response).to redirect_to(post_path(comment.commentable, anchor: 'comments'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -154,7 +154,7 @@ describe CommentsController do
|
||||
let(:comment) { create(:comment, author: member) }
|
||||
|
||||
it "redirects to the post the comment was on" do
|
||||
expect(response).to redirect_to(comment.commentable)
|
||||
expect(response).to redirect_to(post_path(comment.commentable, anchor: 'comments'))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -20,10 +20,6 @@ FactoryBot.define do
|
||||
tos_agreement { false }
|
||||
end
|
||||
|
||||
factory :newsletter_recipient_member do
|
||||
newsletter { true }
|
||||
end
|
||||
|
||||
factory :no_bio_member do
|
||||
bio { nil }
|
||||
end
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe "newsletter subscribers", :js do
|
||||
include_context 'signed in admin'
|
||||
|
||||
let(:subscriber) { create(:newsletter_recipient_member) }
|
||||
|
||||
describe "navigating to newsletter subscribers admin with js" do
|
||||
before do
|
||||
@subscriber = subscriber
|
||||
visit admin_path
|
||||
within 'nav#member_admin' do
|
||||
click_link "Newsletter subscribers"
|
||||
end
|
||||
end
|
||||
|
||||
it { expect(page).to have_current_path admin_newsletter_path, ignore_query: true }
|
||||
it { expect(page).to have_content @subscriber.email }
|
||||
end
|
||||
end
|
||||
@@ -54,6 +54,41 @@ RSpec.describe EventHelper do
|
||||
it { expect(event_description(event)).to have_text "took a photo" }
|
||||
end
|
||||
|
||||
describe 'jobs' do
|
||||
let(:job) { create(:job, name: 'Weed the beds', due_date:) }
|
||||
let(:due_date) { nil }
|
||||
let(:event) { OpenStruct.new(id: job.id, event_type: 'job') }
|
||||
|
||||
it { expect(subject).to eq job }
|
||||
it { expect(event_description(event)).to have_text 'plans to do “Weed the beds”' }
|
||||
|
||||
context 'when due later' do
|
||||
let(:due_date) { 3.months.from_now.to_date }
|
||||
|
||||
it { expect(event_description(event)).to have_text "(due #{l(due_date, format: '%a %-d %b')})" }
|
||||
end
|
||||
|
||||
context 'when overdue' do
|
||||
let(:due_date) { 1.month.ago.to_date }
|
||||
|
||||
it { expect(event_description(event)).to have_no_text 'due' }
|
||||
end
|
||||
|
||||
context 'when finished' do
|
||||
let(:job) { create(:job, name: 'Weed the beds', finished: true) }
|
||||
|
||||
it { expect(event_description(event)).to have_text 'planned to do “Weed the beds”' }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'finished jobs' do
|
||||
let(:job) { create(:job, name: 'Weed the beds', finished: true) }
|
||||
let(:event) { OpenStruct.new(id: job.id, event_type: 'job_finished') }
|
||||
|
||||
it { expect(subject).to eq job }
|
||||
it { expect(event_description(event)).to have_text 'did “Weed the beds”' }
|
||||
end
|
||||
|
||||
describe 'in_weeks' do
|
||||
it { expect(in_weeks(14)).to eq 2 }
|
||||
it { expect(in_weeks(15)).to eq 2 }
|
||||
|
||||
@@ -264,6 +264,24 @@ describe Ability do
|
||||
ability.should_not be_able_to(:destroy, other_job)
|
||||
end
|
||||
|
||||
context 'when an admin' do
|
||||
let(:role) { create(:admin) }
|
||||
|
||||
before { member.roles << role }
|
||||
|
||||
it "can't update another member's job" do
|
||||
ability.should_not be_able_to(:update, other_job)
|
||||
end
|
||||
|
||||
it "can't destroy another member's job" do
|
||||
ability.should_not be_able_to(:destroy, other_job)
|
||||
end
|
||||
|
||||
it 'can update their own job' do
|
||||
ability.should be_able_to(:update, job)
|
||||
end
|
||||
end
|
||||
|
||||
context 'garden collaborator' do
|
||||
let(:garden) { create(:garden) }
|
||||
let(:job_in_garden) { create(:job, garden:) }
|
||||
|
||||
@@ -100,15 +100,6 @@ describe Member do
|
||||
end
|
||||
end
|
||||
|
||||
context 'newsletter scope' do
|
||||
it 'finds newsletter recipients' do
|
||||
regular_member = create(:member)
|
||||
newsletter_member = create(:newsletter_recipient_member)
|
||||
Member.wants_newsletter.should include newsletter_member
|
||||
Member.wants_newsletter.should_not include regular_member
|
||||
end
|
||||
end
|
||||
|
||||
context 'same :login_name' do
|
||||
it "does not allow two members with the same login_name" do
|
||||
create(:member, login_name: "bob")
|
||||
@@ -339,21 +330,6 @@ describe Member do
|
||||
end
|
||||
end
|
||||
|
||||
context 'subscriptions' do
|
||||
let(:member) { create(:member) }
|
||||
let(:gb) { instance_double("Gibbon::API.new") }
|
||||
|
||||
it 'subscribes to the newsletter' do
|
||||
expect(gb).to receive_message_chain('lists.subscribe')
|
||||
member.newsletter_subscribe(gb, true)
|
||||
end
|
||||
|
||||
it 'unsubscribes from the newsletter' do
|
||||
expect(gb).to receive_message_chain('lists.unsubscribe')
|
||||
member.newsletter_unsubscribe(gb, true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'member deleted' do
|
||||
let(:member) { create(:member) }
|
||||
|
||||
@@ -365,16 +341,10 @@ describe Member do
|
||||
it { expect(Member.located).not_to include(member) }
|
||||
it { expect(Member.recently_signed_in).not_to include(member) }
|
||||
it { expect(Member.recently_joined).not_to include(member) }
|
||||
it { expect(Member.wants_newsletter).not_to include(member) }
|
||||
it { expect(Member.interesting).not_to include(member) }
|
||||
it { expect(Member.has_plantings).not_to include(member) }
|
||||
end
|
||||
|
||||
it "unsubscribes from mailing list" do
|
||||
expect(member).to receive(:newsletter_unsubscribe).and_return(true)
|
||||
member.destroy
|
||||
end
|
||||
|
||||
context "deleted admin member" do
|
||||
let(:member) { create(:admin_member) }
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ describe Admin::CropJobTemplatesController do
|
||||
expect(crop.crop_job_templates.first).to be_draft
|
||||
end
|
||||
|
||||
it 'records who wrote it' do
|
||||
expect(crop.crop_job_templates.first.creator).to eq wrangler
|
||||
end
|
||||
|
||||
it 'goes back to the list' do
|
||||
expect(response).to redirect_to(admin_crop_crop_job_templates_path(crop))
|
||||
end
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Admin::FeaturesController do
|
||||
let(:admin) { create(:admin_member) }
|
||||
let(:page) { Capybara.string(response.body) }
|
||||
|
||||
describe '#index' do
|
||||
context 'with an admin' do
|
||||
before do
|
||||
sign_in admin
|
||||
get admin_features_path
|
||||
end
|
||||
|
||||
it 'lists job ideas' do
|
||||
expect(page).to have_css('#feature-job_ideas', text: 'Job ideas')
|
||||
end
|
||||
|
||||
it 'shows it is off' do
|
||||
expect(page).to have_css('#feature-job_ideas .badge', text: 'Off')
|
||||
end
|
||||
|
||||
it 'offers to switch it on' do
|
||||
expect(page).to have_button('Switch on')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with job ideas switched on', flipper: :job_ideas do
|
||||
before do
|
||||
sign_in admin
|
||||
get admin_features_path
|
||||
end
|
||||
|
||||
it 'offers to switch it off' do
|
||||
expect(page).to have_button('Switch off')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a member who is not an admin' do
|
||||
before do
|
||||
sign_in create(:member)
|
||||
get admin_features_path
|
||||
end
|
||||
|
||||
it 'turns them away' do
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed out' do
|
||||
before { get admin_features_path }
|
||||
|
||||
it 'sends them to sign in' do
|
||||
expect(response).to redirect_to(new_member_session_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update' do
|
||||
before { sign_in admin }
|
||||
|
||||
context 'when switching on' do
|
||||
before { patch admin_feature_path('job_ideas'), params: { enabled: true } }
|
||||
|
||||
it 'switches it on' do
|
||||
expect(Flipper).to be_enabled(:job_ideas)
|
||||
end
|
||||
|
||||
it 'goes back to the features' do
|
||||
expect(response).to redirect_to(admin_features_path)
|
||||
end
|
||||
|
||||
it 'says so' do
|
||||
expect(flash[:notice]).to eq 'Job ideas is now on.'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when switching off', flipper: :job_ideas do
|
||||
before { patch admin_feature_path('job_ideas'), params: { enabled: false } }
|
||||
|
||||
it 'switches it off' do
|
||||
expect(Flipper).not_to be_enabled(:job_ideas)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a feature that is not on the list' do
|
||||
before { patch admin_feature_path('anything_else'), params: { enabled: true } }
|
||||
|
||||
it 'is not found' do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a member who is not an admin' do
|
||||
before do
|
||||
sign_in create(:member)
|
||||
patch admin_feature_path('job_ideas'), params: { enabled: true }
|
||||
end
|
||||
|
||||
it 'leaves it off' do
|
||||
expect(Flipper).not_to be_enabled(:job_ideas)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Admin::JobTemplatesController do
|
||||
let(:tomato) { create(:crop, name: 'tomato') }
|
||||
let(:cherry) { create(:crop, name: 'cherry tomato', parent: tomato) }
|
||||
let(:author) { create(:member, login_name: 'template_author') }
|
||||
|
||||
before do
|
||||
create(:crop_job_template, crop: tomato, name: 'Growing tomatoes', status: 'published', creator: author)
|
||||
create(:crop_job_template, crop: cherry, name: 'Growing cherry tomatoes')
|
||||
create(:crop_job_template, name: 'Growing something else')
|
||||
end
|
||||
|
||||
describe '#index' do
|
||||
context 'with a crop wrangler' do
|
||||
before { sign_in create(:crop_wrangling_member) }
|
||||
|
||||
context 'without a status' do
|
||||
before { get admin_job_templates_path }
|
||||
|
||||
it 'lists every crop\'s templates' do
|
||||
expect(response.body).to include('Growing tomatoes', 'Growing cherry tomatoes', 'Growing something else')
|
||||
end
|
||||
|
||||
it 'shows who wrote a template' do
|
||||
expect(response.body).to include('template_author')
|
||||
end
|
||||
|
||||
it 'links a variety\'s template to its parent crop\'s templates' do
|
||||
expect(response.body).to include(edit_admin_crop_crop_job_template_path(tomato, cherry.crop_job_templates.first))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtered to published' do
|
||||
before { get admin_job_templates_path(status: 'published') }
|
||||
|
||||
it 'lists the published templates' do
|
||||
expect(response.body).to include('Growing tomatoes')
|
||||
end
|
||||
|
||||
it 'leaves out the drafts' do
|
||||
expect(response.body).not_to include('Growing cherry tomatoes')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtered to draft' do
|
||||
before { get admin_job_templates_path(status: 'draft') }
|
||||
|
||||
it 'lists the drafts' do
|
||||
expect(response.body).to include('Growing cherry tomatoes', 'Growing something else')
|
||||
end
|
||||
|
||||
it 'leaves out the published templates' do
|
||||
expect(response.body).not_to include('Growing tomatoes')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an unknown status' do
|
||||
before { get admin_job_templates_path(status: 'nonsense') }
|
||||
|
||||
it 'lists every template' do
|
||||
expect(response.body).to include('Growing tomatoes', 'Growing cherry tomatoes')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not a crop wrangler' do
|
||||
before do
|
||||
sign_in create(:member)
|
||||
get admin_job_templates_path
|
||||
end
|
||||
|
||||
it 'is turned away' do
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Admin::Members', type: :request do
|
||||
let(:admin_role) { create(:role, name: 'admin') }
|
||||
let(:admin) { create(:member, roles: [admin_role]) }
|
||||
let!(:active_member) do
|
||||
create(
|
||||
:member,
|
||||
login_name: 'active_spammer',
|
||||
email: 'active@example.com',
|
||||
bio: 'A' * 300,
|
||||
photos_count: 3,
|
||||
plantings_count: 5,
|
||||
forums_count: 1,
|
||||
gardens_count: 2,
|
||||
harvests_count: 4,
|
||||
jobs_count: 6,
|
||||
confirmed_at: Time.current,
|
||||
last_sign_in_at: Time.current
|
||||
)
|
||||
end
|
||||
let!(:deleted_member) do
|
||||
member = create(:member, login_name: 'deleted_user', email: 'deleted@example.com')
|
||||
member.discard
|
||||
member
|
||||
end
|
||||
|
||||
before do
|
||||
login_as admin, scope: :member
|
||||
end
|
||||
|
||||
describe 'GET /admin/members' do
|
||||
context 'by default (Active tab)' do
|
||||
it 'filters out deleted members and shows active members' do
|
||||
get admin_members_path
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('active_spammer')
|
||||
expect(response.body).not_to include('deleted_user')
|
||||
end
|
||||
|
||||
it 'surfaces login name, email, confirmed status, last login, truncated bio, and count attributes' do
|
||||
get admin_members_path
|
||||
expect(response.body).to include('active@example.com')
|
||||
expect(response.body).to include('A' * 252 + '...') # truncated to 255 chars
|
||||
expect(response.body).to include('Photos')
|
||||
expect(response.body).to include('Plantings')
|
||||
expect(response.body).to include('Forums')
|
||||
expect(response.body).to include('Gardens')
|
||||
expect(response.body).to include('Harvests')
|
||||
expect(response.body).to include('Jobs')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting deactivated tab (status=deactivated)' do
|
||||
it 'shows discarded members and excludes active members' do
|
||||
get admin_members_path(status: 'deactivated')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('deleted_user')
|
||||
expect(response.body).not_to include('active_spammer')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /admin/members/bulk_ban' do
|
||||
it 'discards selected active members' do
|
||||
expect do
|
||||
post bulk_ban_admin_members_path, params: { member_ids: [active_member.id] }
|
||||
end.to change { active_member.reload.discarded? }.from(false).to(true)
|
||||
|
||||
expect(response).to redirect_to(admin_members_path)
|
||||
end
|
||||
|
||||
it 'does not allow admin to ban themselves via bulk ban' do
|
||||
post bulk_ban_admin_members_path, params: { member_ids: [admin.id] }
|
||||
expect(admin.reload.discarded?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /admin/members/:slug/restore' do
|
||||
it 'undiscards a deactivated member' do
|
||||
expect do
|
||||
post restore_admin_member_path(deleted_member)
|
||||
end.to change { deleted_member.reload.discarded? }.from(true).to(false)
|
||||
|
||||
expect(response).to redirect_to(admin_members_path(status: 'deactivated'))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe HomeController, '#index with job ideas' do
|
||||
describe HomeController, '#index with job ideas', flipper: :job_ideas do
|
||||
let(:member) { create(:member) }
|
||||
let(:page) { Capybara.string(response.body) }
|
||||
|
||||
@@ -30,6 +30,17 @@ describe HomeController, '#index with job ideas' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when job ideas are switched off', flipper: [] do
|
||||
before do
|
||||
create(:job, :finished_without_time, owner: member, name: 'Snip off scapes', due_date: Date.current - 1.year + 2.days)
|
||||
get root_path
|
||||
end
|
||||
|
||||
it 'has no yellow bar' do
|
||||
expect(page).to have_no_css('.notepad-ideas')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are none' do
|
||||
before { get root_path }
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe JobIdeasController do
|
||||
describe JobIdeasController, flipper: :job_ideas do
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let(:member) { create(:member) }
|
||||
@@ -144,6 +144,14 @@ describe JobIdeasController do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /job_ideas when job ideas are switched off', flipper: [] do
|
||||
before { get job_ideas_path }
|
||||
|
||||
it 'is not found' do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /job_ideas' do
|
||||
let(:key) { "past_job:snip off scapes:#{crop.id}" }
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe JobsController, '#index with ideas' do
|
||||
describe JobsController, '#index with ideas', flipper: :job_ideas do
|
||||
let(:member) { create(:member) }
|
||||
let(:page) { Capybara.string(response.body) }
|
||||
|
||||
@@ -30,6 +30,21 @@ describe JobsController, '#index with ideas' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when job ideas are switched off', flipper: [] do
|
||||
before do
|
||||
create(:job, :finished_without_time, owner: member, name: 'Snip off scapes', due_date: Date.current - 1.year + 2.days)
|
||||
get member_jobs_path(member)
|
||||
end
|
||||
|
||||
it 'shows no yellow box' do
|
||||
expect(page).to have_no_css('.notepad-ideas')
|
||||
end
|
||||
|
||||
it 'has no overlay' do
|
||||
expect(page).to have_no_css('#job-ideas-modal', visible: :all)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are none' do
|
||||
before { get member_jobs_path(member) }
|
||||
|
||||
|
||||
@@ -6,6 +6,5 @@ describe AdminController do
|
||||
describe "routing" do
|
||||
it { expect(get("/admin/")).to route_to("admin#index") }
|
||||
it { expect(get("/admin/members")).to route_to("admin/members#index") }
|
||||
it { expect(get("/admin/newsletter")).to route_to("admin#newsletter") }
|
||||
end
|
||||
end
|
||||
@@ -37,6 +37,14 @@ describe JobIdeas::Nearby do
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'with gardeners a few hundred km away' do
|
||||
let(:neighbours) { create_list(:london_member, 3, latitude: 53.5, longitude: -2.2) }
|
||||
|
||||
it 'widens the search to find them' do
|
||||
expect(ideas.map(&:name)).to eq ['Put out slug traps']
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the member has not said where they are' do
|
||||
let(:member) { create(:member) }
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe JobIdeas::World do
|
||||
subject(:ideas) { described_class.new(member, today).ideas }
|
||||
|
||||
let(:today) { Date.new(2026, 10, 23) }
|
||||
let(:member) { create(:london_member) }
|
||||
let(:neighbours) { neighbours_at(51.5, -100.0) }
|
||||
let(:due_date) { Date.new(2025, 10, 20) }
|
||||
|
||||
# Saving a member geocodes their location again, so the coordinates are set after.
|
||||
def neighbours_at(latitude, longitude)
|
||||
create_list(:london_member, 3).each do |neighbour|
|
||||
neighbour.update_columns(latitude:, longitude:) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
neighbours.each do |neighbour|
|
||||
create(:job, :finished_without_time, owner: neighbour, name: 'Put out slug traps', due_date:)
|
||||
end
|
||||
end
|
||||
|
||||
it 'has an idea for what enough gardeners at the same latitude did' do
|
||||
expect(ideas.map(&:name)).to eq ['Put out slug traps']
|
||||
end
|
||||
|
||||
it 'is a world idea' do
|
||||
expect(ideas.first.kind).to eq :world
|
||||
end
|
||||
|
||||
context 'with a job done this year' do
|
||||
let(:due_date) { Date.new(2026, 10, 12) }
|
||||
|
||||
it 'counts it' do
|
||||
expect(ideas.map(&:name)).to eq ['Put out slug traps']
|
||||
end
|
||||
end
|
||||
|
||||
context 'with gardeners at the opposite latitude' do
|
||||
let(:neighbours) { neighbours_at(-51.5, 0.0) }
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'with gardeners at a very different latitude in the same hemisphere' do
|
||||
let(:neighbours) { neighbours_at(30.0, 0.0) }
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'when the member has not said where they are' do
|
||||
let(:member) { create(:member) }
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,14 @@ require 'rails_helper'
|
||||
describe JobList do
|
||||
subject(:list) { described_class.new(jobs, today:) }
|
||||
|
||||
# A Wednesday, so the rest of the week is Thursday to Sunday the 25th.
|
||||
# A Wednesday, so the rest of the week is Thursday and Friday, then the weekend
|
||||
# of the 24th and 25th.
|
||||
let(:today) { Date.new(2026, 10, 21) }
|
||||
let(:jobs) { [due_today, saturday, next_week, overdue, no_day] }
|
||||
let(:jobs) { [due_today, thursday, saturday, next_saturday, next_week, overdue, no_day] }
|
||||
let(:thursday) { build(:job, due_date: Date.new(2026, 10, 22)) }
|
||||
let(:due_today) { build(:job, due_date: today) }
|
||||
let(:saturday) { build(:job, due_date: Date.new(2026, 10, 24)) }
|
||||
let(:next_saturday) { build(:job, due_date: Date.new(2026, 10, 31)) }
|
||||
let(:next_week) { build(:job, due_date: Date.new(2026, 10, 28)) }
|
||||
let(:overdue) { build(:job, due_date: Date.new(2026, 10, 19)) }
|
||||
let(:no_day) { build(:job, due_date: nil) }
|
||||
@@ -19,7 +22,7 @@ describe JobList do
|
||||
end
|
||||
|
||||
it 'shows the groups in order' do
|
||||
expect(list.groups.map(&:key)).to eq %i(overdue today week soon someday)
|
||||
expect(list.groups.map(&:key)).to eq %i(overdue today week weekend next_weekend later someday)
|
||||
end
|
||||
|
||||
it 'puts jobs due today in Today' do
|
||||
@@ -27,15 +30,23 @@ describe JobList do
|
||||
end
|
||||
|
||||
it 'puts jobs for the rest of the week in This week' do
|
||||
expect(group(:week).jobs).to eq [saturday]
|
||||
expect(group(:week).jobs).to eq [thursday]
|
||||
end
|
||||
|
||||
it 'puts jobs for Saturday and Sunday in This weekend' do
|
||||
expect(group(:weekend).jobs).to eq [saturday]
|
||||
end
|
||||
|
||||
it 'puts jobs for the weekend after in Next weekend' do
|
||||
expect(group(:next_weekend).jobs).to eq [next_saturday]
|
||||
end
|
||||
|
||||
it 'puts jobs from before today in Overdue' do
|
||||
expect(group(:overdue).jobs).to eq [overdue]
|
||||
end
|
||||
|
||||
it 'puts jobs for after this week in Soon' do
|
||||
expect(group(:soon).jobs).to eq [next_week]
|
||||
it 'puts jobs for after next weekend in Later' do
|
||||
expect(group(:later).jobs).to eq [next_week]
|
||||
end
|
||||
|
||||
it 'puts jobs without a day in Some day' do
|
||||
@@ -52,7 +63,7 @@ describe JobList do
|
||||
let(:jobs) { [due_today, overdue, done, done_later] }
|
||||
|
||||
it 'puts it in Done, at the bottom' do
|
||||
expect(list.groups.map(&:key)).to eq %i(overdue today week someday done)
|
||||
expect(list.groups.map(&:key)).to eq %i(overdue today week weekend next_weekend later someday done)
|
||||
end
|
||||
|
||||
it 'leaves it out of Overdue' do
|
||||
@@ -116,6 +127,19 @@ describe JobList do
|
||||
context 'with a crossed off job due today' do
|
||||
let(:due_today) { build(:job, due_date: today, finished: true) }
|
||||
|
||||
it 'puts it in Done' do
|
||||
expect(group(:done).jobs).to eq [due_today]
|
||||
end
|
||||
|
||||
it 'leaves Today without it' do
|
||||
expect(group(:today).jobs).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a crossed off job due today that is pinned' do
|
||||
let(:due_today) { build_stubbed(:job, due_date: today, finished: true) }
|
||||
let(:list) { described_class.new(jobs, today:, pinned: [due_today.id]) }
|
||||
|
||||
it 'keeps it in Today' do
|
||||
expect(group(:today).jobs).to eq [due_today]
|
||||
end
|
||||
@@ -125,7 +149,7 @@ describe JobList do
|
||||
let(:jobs) { [] }
|
||||
|
||||
it 'still has places to drop one' do
|
||||
expect(list.groups.map(&:key)).to eq %i(today week someday)
|
||||
expect(list.groups.map(&:key)).to eq %i(today week weekend next_weekend later someday)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -134,16 +158,24 @@ describe JobList do
|
||||
expect(group(:today).drop_date).to eq today
|
||||
end
|
||||
|
||||
it 'is the Sunday for This week' do
|
||||
expect(group(:week).drop_date).to eq Date.new(2026, 10, 25)
|
||||
it 'is the Friday for This week' do
|
||||
expect(group(:week).drop_date).to eq Date.new(2026, 10, 23)
|
||||
end
|
||||
|
||||
it 'is the Saturday for This weekend' do
|
||||
expect(group(:weekend).drop_date).to eq Date.new(2026, 10, 24)
|
||||
end
|
||||
|
||||
it 'is the next Saturday for Next weekend' do
|
||||
expect(group(:next_weekend).drop_date).to eq Date.new(2026, 10, 31)
|
||||
end
|
||||
|
||||
it 'is no day for Some day' do
|
||||
expect(group(:someday).drop_date).to be_nil
|
||||
end
|
||||
|
||||
it 'is nothing for Soon' do
|
||||
expect(group(:soon)).not_to be_droppable
|
||||
it 'is three weeks out for Later' do
|
||||
expect(group(:later).drop_date).to eq Date.new(2026, 11, 11)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -151,8 +183,29 @@ describe JobList do
|
||||
let(:today) { Date.new(2026, 10, 25) }
|
||||
let(:jobs) { [due_today] }
|
||||
|
||||
it 'has no This week, as none of the week is left' do
|
||||
expect(list.groups.map(&:key)).to eq %i(today someday)
|
||||
it 'has no This week or This weekend, as none of the week is left' do
|
||||
expect(list.groups.map(&:key)).to eq %i(today next_weekend later someday)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when today is Saturday' do
|
||||
let(:today) { Date.new(2026, 10, 24) }
|
||||
let(:jobs) { [] }
|
||||
|
||||
it 'drops on Sunday for This weekend' do
|
||||
expect(group(:weekend).drop_date).to eq Date.new(2026, 10, 25)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with more than five crossed off jobs' do
|
||||
let(:jobs) { Array.new(7) { |i| build(:job, due_date: today - (i + 1).days, finished: true) } }
|
||||
|
||||
it 'shows the five most recently done' do
|
||||
expect(group(:done).shown).to eq jobs.first(5)
|
||||
end
|
||||
|
||||
it 'keeps the rest as older' do
|
||||
expect(group(:done).older).to eq jobs.last(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -150,6 +150,20 @@ describe Neighbours do
|
||||
expect([shown, neighbours.more_feed?]).to eq [Neighbours::FEED_EVENTS, true]
|
||||
expect(described_class.new(member, offset: Neighbours::FEED_EVENTS).feed.flat_map(&:updates).sum { |entry| entry.items.size }).to eq 1
|
||||
end
|
||||
|
||||
context 'with a larger limit' do
|
||||
subject(:larger) { described_class.new(member, limit: Neighbours::FEED_EVENTS + 5) }
|
||||
|
||||
before { create_list(:planting, Neighbours::FEED_EVENTS + 5, owner: friend, garden: friend.gardens.first, planted_at: Date.new(2026, 10, 12)) }
|
||||
|
||||
it 'shows that many events' do
|
||||
expect(larger.feed.flat_map(&:updates).sum { |entry| entry.items.size }).to eq Neighbours::FEED_EVENTS + 5
|
||||
end
|
||||
|
||||
it 'carries on after them' do
|
||||
expect(larger.next_offset).to eq Neighbours::FEED_EVENTS + 5
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lede' do
|
||||
|
||||
@@ -30,6 +30,18 @@ describe TimelineService do
|
||||
it { expect(subject.first.event_type).to eq 'seed' }
|
||||
it { expect(subject.second.id).to eq my_post.id }
|
||||
it { expect(subject.second.event_type).to eq 'post' }
|
||||
|
||||
context 'with a job due later' do
|
||||
let!(:my_job) { create(:job, owner: member, due_date: 3.months.from_now, created_at: 5.days.ago) }
|
||||
|
||||
it { expect(subject.second.id).to eq my_job.id }
|
||||
end
|
||||
|
||||
context 'with a finished job' do
|
||||
let!(:my_job) { create(:job, owner: member, finished: true, created_at: 5.days.ago) }
|
||||
|
||||
it { expect(subject.first.event_type).to eq 'job_finished' }
|
||||
end
|
||||
end
|
||||
|
||||
describe "everyone's timeline" do
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ if ENV['CI'] || ENV['COVERAGE']
|
||||
SimpleCov.command_name "rspec-#{Process.pid}"
|
||||
SimpleCov.start 'rails' do
|
||||
enable_coverage :branch
|
||||
add_filter %r{^/spec/}
|
||||
skip %r{^/spec/}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Features are kept in memory in specs, and all off at the start of each. Tag an
|
||||
# example group with `flipper: :name` (or a list of names) to have them on.
|
||||
require 'flipper/test_help'
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.before do |example|
|
||||
Array(example.metadata[:flipper]).each { |name| Flipper.enable(name) }
|
||||
end
|
||||
end
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user