Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add filter moments page #2011

Merged
merged 12 commits into from
Feb 20, 2024
13 changes: 13 additions & 0 deletions app/assets/stylesheets/application/shared.scss
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,17 @@
width: 100%;
}
}

&InputMultiSelect {
display: flex;
flex-direction: row;
margin-left: $size-8;
width: 25%;

@media screen and (max-width: $medium) {
flex-direction: column;
margin-left: $size-0;
width: 100%;
}
}
}
1 change: 1 addition & 0 deletions app/assets/stylesheets/base/_colors.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ $black: #000;
$black-90: rgba(0, 0, 0, 0.9);
$black-80: rgba(0, 0, 0, 0.8);
$black-70: rgba(0, 0, 0, 0.7);
$black-60: rgba(0, 0, 0, 0.6);
$black-50: rgba(0, 0, 0, 0.5);
$black-30: rgba(0, 0, 0, 0.3);
$black-20: rgba(0, 0, 0, 0.2);
Expand Down
15 changes: 14 additions & 1 deletion app/controllers/concerns/collection_page_setup_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ module CollectionPageSetupConcern
helper_method :page_collection, :setup_collection
end

def page_collection(collection, model_name)
def page_collection(collection, model_name, filters_model = {})
name = params[:search]
search_filters = params[:filters]
model = Object.const_get(model_name.capitalize)
search = model.where(
'name ilike ? AND user_id = ?',
"%#{name}%",
current_user.id
).all
user = model.where(user_id: current_user.id)
user = apply_filters(user, search_filters, filters_model[:filters]) if search_filters.present?
setup_collection(collection, user, search, name)
@multiselect_checkboxes = filters_model[:checkboxes]
@page_new = t("#{model_name.pluralize}.new")
end

Expand All @@ -26,4 +29,14 @@ def setup_collection(collection, user, search, name)
search_query.order('created_at DESC').page(params[:page])
)
end

private

def apply_filters(user, search_filters, filters_model)
filtered_user = user
search_filters.each do |filter|
filtered_user = filtered_user.where(filters_model[filter])
end
filtered_user.count > 0 ? filtered_user : user
end
end
2 changes: 1 addition & 1 deletion app/controllers/moments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MomentsController < ApplicationController
# GET /moments
# GET /moments.json
def index
page_collection('@moments', 'moment')
page_collection('@moments', 'moment', multiselect_hash)
respond_to do |format|
format.json { render json: moments_data_json }
format.html { moments_data_html }
Expand Down
13 changes: 9 additions & 4 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ def index

def posts
data_type = params[:search][:data_type]
term = params[:search][:name]
name = params[:search][:name]
search_filters = params[:search][:filters]

return unless data_type.in?(%w[moment category mood strategy medication])

redirect_to_path(make_path(term, data_type))
redirect_to_path(make_path(name, data_type, search_filters))
end

private
Expand All @@ -28,7 +29,11 @@ def search_by_email(email)
.where.not(banned: true)
end

def make_path(term, data_type)
send("#{data_type.pluralize}_path", ({ search: term } if term.present?))
def make_path(name, data_type, search_filters)
search_hash = {}
search_hash[:search] = name if name.present?
search_hash[:filters] = search_filters if search_filters.present?

send("#{data_type.pluralize}_path", search_hash)
end
end
41 changes: 41 additions & 0 deletions app/helpers/moments_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ def get_resources_data(moment, current_user)
show_crisis_prevention: r.nil? ? false : r.show_crisis_prevention }
end

def multiselect_hash
{ checkboxes: multiselect_checkboxes, filters: multiselect_filters }
end

private

def element_actions(element, present_object)
Expand Down Expand Up @@ -142,4 +146,41 @@ def get_share_link_info(is_strategy, element)

t('moments.secret_share.link_info')
end

def multiselect_filters
{
'secret_share' => 'secret_share_identifier IS NOT NULL',
'no_viewers' => { viewers: [] },
'one_viewer' => 'length(viewers) > 0',
'comments_enabled' => { comment: true },
'draft_enabled' => { published_at: nil },
'published' => 'published_at IS NOT NULL'
}
end

def is_checked(value)
return params[:filters].include?(value) if params[:filters]

false
end

def multiselect_checkboxes
values = %w[secret_share no_viewers one_viewer comments_enabled draft_enabled published]
checkboxes = []
index = 0
values.each do |value|
moment = current_user.moments.where(multiselect_filters[value])
next unless moment.count > 0

checkboxes.push({
id: "search_filters_#{index}",
name: 'search[filters][]',
label: I18n.t("moments.filters.#{value}"),
value:,
checked: is_checked(value)
})
index += 1
end
checkboxes
end
end
11 changes: 11 additions & 0 deletions app/views/search/_posts.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
dark: true
} %>
<%= f.hidden_field :data_type, value: local_assigns[:data_type] %>
<% if @moments %>
<div class="searchInputMultiSelect">
<%= react_component 'Input', props: {
type: 'multiSelect',
id: 'search_filters',
name: 'search[filters]',
label: t('search.filter_by'),
checkboxes: @multiselect_checkboxes
} %>
</div>
<% end %>
<%= f.submit t('search.label'), class: 'searchSubmit buttonDarkL' %>
</div>
<% end %>
Expand Down
73 changes: 73 additions & 0 deletions client/app/components/Input/InputMultiSelect.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @flow
import React, { useState, useEffect, useRef } from 'react';
import type { Node } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCaretUp, faCaretDown } from '@fortawesome/free-solid-svg-icons';
import { InputCheckbox } from 'components/Input/InputCheckbox';
import css from './InputMultiSelect.scss';
import type { Checkbox } from './utils';

export type Props = {
id: string,
label?: string,
checkboxes: Checkbox[],
};

export function InputMultiSelect({ id, checkboxes, label }: Props): Node {
const [opened, setOpened] = useState<boolean>(false);
const ref = useRef(null);

const handleOnClick = () => {
setOpened(!opened);
};

useEffect(() => {
const handleClickOutside = (event: any) => {
if (ref.current && !ref.current.contains(event.target)) {
setOpened(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);

return (
<div ref={ref}>
<button
className={`${css.buttonDarkL} ${css.multiSelectButton}`}
type="button"
onClick={handleOnClick}
id={id}
>
<div>{label}</div>
<div>
<FontAwesomeIcon icon={opened ? faCaretUp : faCaretDown} />
</div>
</button>
<div
data-testid="multiSelectCheckboxes"
aria-labelledby={id}
className={css.multiSelectCheckboxesWrapper}
role="listbox"
style={{ display: opened ? 'block' : 'none ' }}
>
<div className={css.multiSelectCheckboxes}>
{checkboxes.map((checkbox) => (
<InputCheckbox
id={checkbox.id}
name={checkbox.name}
key={checkbox.id}
value={checkbox.value}
checked={checkbox.checked}
uncheckedValue={checkbox.uncheckedValue}
label={checkbox.label}
/>
))}
</div>
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions client/app/components/Input/InputMultiSelect.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@import "~styles/_global.scss";

.multiSelect {
&Button {
width: 100%;
display: flex;
justify-content: space-between;
}

&Checkboxes {
@include setPadding($size-6, $size-6, $size-6, $size-6);
@include fadeIn(0.8s);

width: 100%;
box-sizing: border-box;
position: absolute;
z-index: $z-index-front;
font-weight: $font-weight-400;
text-transform: none;
letter-spacing: normal;
color: $black;
box-shadow: $size-0 $size-2 $size-10 $black-10;
border-radius: $size-4;
background: $white;
max-height: 200px;
overflow-x: hidden;
overflow-y: auto;

&Wrapper {
width: 100%;
position: relative;
}
}
}
38 changes: 38 additions & 0 deletions client/app/components/Input/__tests__/InputMultiSelect.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @flow
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InputMocks } from 'mocks/InputMocks';
import { InputMultiSelect } from 'components/Input/InputMultiSelect';

describe('InputMultiSelect', () => {
const {
id, name, label, checkboxes,
} = InputMocks.inputMultiSelectProps;

it('renders correctly when the component is closed', () => {
render(
<InputMultiSelect
name={name}
id={id}
checkboxes={checkboxes}
label={label}
/>,
);
expect(screen.getByRole('button', { value: label })).toBeVisible();
expect(screen.getByTestId('multiSelectCheckboxes')).not.toBeVisible();
});

it('renders correctly when the component is opened', async () => {
render(
<InputMultiSelect
name={name}
id={id}
label={label}
checkboxes={checkboxes}
/>,
);
await userEvent.click(screen.getByRole('button', { value: label }));
expect(screen.getByTestId('multiSelectCheckboxes')).toBeVisible();
});
});
9 changes: 9 additions & 0 deletions client/app/components/Input/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { InputCheckbox } from 'components/Input/InputCheckbox';
import { InputCheckboxGroup } from 'components/Input/InputCheckboxGroup';
import { InputPassword } from 'components/Input/InputPassword';
import { InputSelect } from 'components/Input/InputSelect';
import { InputMultiSelect } from 'components/Input/InputMultiSelect';
import { InputRadioGroup } from 'components/Input/InputRadioGroup';
import { InputTag } from 'components/Input/InputTag';
import { InputSwitch } from 'components/Input/InputSwitch';
Expand Down Expand Up @@ -186,6 +187,13 @@ export const Input = ({
return null;
};

const displayMultiSelect = () => {
if (type === 'multiSelect' && checkboxes) {
return <InputMultiSelect id={id} label={label} checkboxes={checkboxes} />;
}
return null;
};

const displayRadio = () => {
if (type === 'radio' && options) {
return (
Expand Down Expand Up @@ -293,6 +301,7 @@ export const Input = ({
{displayCheckboxGroup()}
{displayPassword()}
{displaySelect()}
{displayMultiSelect()}
{displayRadio()}
{displayTextarea()}
{displayTextareaTemplate()}
Expand Down
2 changes: 2 additions & 0 deletions client/app/components/Input/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const TYPES: string[] = REQUIRES_DEFAULT.concat([
'checkbox',
'radio',
'select',
'multiSelect',
'checkboxGroup',
'tag',
'switch',
Expand Down Expand Up @@ -67,6 +68,7 @@ export type Props = {
| 'time'
| 'date'
| 'select'
| 'multiSelect'
| 'checkboxGroup'
| 'tag'
| 'hidden'
Expand Down
4 changes: 3 additions & 1 deletion client/app/components/Toast/__tests__/Toast.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ describe('Toast', () => {
});

it('closes correctly on button click', () => {
const { getByRole, container } = render(<Toast notice="Login successful." />);
const { getByRole, container } = render(
<Toast notice="Login successful." />,
);

const toastContent = getByRole('region');
const toastBtn = container.querySelector('#btn-close-toast-notice');
Expand Down
Loading
Loading