From fea43f59932b0939761e49cc022821c0cc852937 Mon Sep 17 00:00:00 2001 From: sks444 <krishnasingh.ss30@gmail.com> Date: Tue, 22 Sep 2020 17:26:40 +0530 Subject: [PATCH] Add filters to investments table --- hypha/public/partner/tables.py | 65 ++++++- .../partner/base_investments_table.html | 16 ++ .../includes/table_filter_and_search.html | 41 +++++ hypha/public/partner/views.py | 21 ++- .../javascript/public/investment-filters.js | 161 ++++++++++++++++++ .../src/sass/public/abstracts/_variables.scss | 3 + .../sass/public/components/_actions-bar.scss | 65 +++++++ .../src/sass/public/components/_button.scss | 49 ++++++ .../src/sass/public/components/_filters.scss | 63 +++++++ .../src/sass/public/components/_form.scss | 141 +++++++++++++++ .../src/sass/public/components/_input.scss | 8 + hypha/static_src/src/sass/public/main.scss | 2 + 12 files changed, 630 insertions(+), 5 deletions(-) create mode 100644 hypha/public/partner/templates/partner/includes/table_filter_and_search.html create mode 100644 hypha/static_src/src/javascript/public/investment-filters.js create mode 100644 hypha/static_src/src/sass/public/components/_actions-bar.scss create mode 100644 hypha/static_src/src/sass/public/components/_filters.scss diff --git a/hypha/public/partner/tables.py b/hypha/public/partner/tables.py index 066ff5ff6..3be0a5e08 100644 --- a/hypha/public/partner/tables.py +++ b/hypha/public/partner/tables.py @@ -1,7 +1,70 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ +import django_filters as filters +from django import forms +from django.db.models import Q +from hypha.apply.funds.tables import Select2MultipleChoiceFilter +from .models import Investment, InvestmentCategorySettings, PartnerPage -from .models import Investment, InvestmentCategorySettings + +def get_year_choices(): + years = Investment.objects.order_by('-year').values_list('year', flat=True).distinct() + return [(year, str(year)) for year in years] + + +class InvestmentFilter(filters.FilterSet): + PAGE_CHOICES = ( + (25, '25'), + (50, '50'), + (100, '100'), + ) + + AMOUNT_COMMITTED_CHOICES = ( + ('0_250k', '$0 > $250k'), + ('250k_1m', '$250k > $1m'), + ('1m+', '$1m+'), + ) + + YEAR_CHOICES = get_year_choices() + + year = Select2MultipleChoiceFilter(choices=YEAR_CHOICES, label='Years') + amount_committed = Select2MultipleChoiceFilter( + choices=AMOUNT_COMMITTED_CHOICES, + label='Amount Committed(US$)', + method='filter_amount_committed' + ) + partner__status = Select2MultipleChoiceFilter( + choices=PartnerPage.STATUS, label='Status' + ) + per_page = filters.ChoiceFilter( + choices=PAGE_CHOICES, + empty_label=_('Items per page'), + label='Per page', + method='per_page_handler' + ) + + class Meta: + model = Investment + fields = ('year', 'amount_committed') + + def filter_amount_committed(self, queryset, name, value): + query = Q() + for v in value: + if v == '0_250k': + query |= Q(amount_committed__gte=0, amount_committed__lt=250000) + if v == '250k_1m': + query |= Q(amount_committed__gte=250000, amount_committed__lt=1000000) + if v == '1m+': + query |= Q(amount_committed__gte=1000000) + return queryset.filter(query) + + def per_page_handler(self, queryset, name, value): + # Pagination is already implemented in view. We only need to add per_page query parameter. + return queryset + + +class InvestmentFilterAndSearch(InvestmentFilter): + query = filters.CharFilter(field_name='search_data', lookup_expr="icontains", widget=forms.HiddenInput) class InvestmentTable(tables.Table): diff --git a/hypha/public/partner/templates/partner/base_investments_table.html b/hypha/public/partner/templates/partner/base_investments_table.html index 71d970299..a89fadd02 100644 --- a/hypha/public/partner/templates/partner/base_investments_table.html +++ b/hypha/public/partner/templates/partner/base_investments_table.html @@ -3,8 +3,24 @@ {% load static %} {% load render_table from django_tables2 %} +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{{ filter.form.media.css }} +{% endblock %} + {% block content %} {% block table %} + {% include "partner/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action %} + {% render_table table %} {% endblock %} {% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> + <script src="{% static 'js/apply/fancybox-global.js' %}"></script> + <script src="https://cdn.jsdelivr.net/npm/symbol-es6@0.1.2/symbol-es6.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script> + <script src="{% static 'js/public/investment-filters.js' %}"></script> +{% endblock %} diff --git a/hypha/public/partner/templates/partner/includes/table_filter_and_search.html b/hypha/public/partner/templates/partner/includes/table_filter_and_search.html new file mode 100644 index 000000000..8a8558d9e --- /dev/null +++ b/hypha/public/partner/templates/partner/includes/table_filter_and_search.html @@ -0,0 +1,41 @@ +<div class="wrapper wrapper--table-actions js-table-actions"> + <div class="actions-bar"> + {# Left #} + <div class="actions-bar__inner actions-bar__inner--left"> + {% if heading %} + <h4 class="heading heading--normal heading--no-margin">{{ heading }}</h4> + {% endif %} + </div> + + {# Right #} + <div class="actions-bar__inner actions-bar__inner--right"> + <button class="button button--filters js-toggle-filters">Filters</button> + + {% if use_search|default:False %} + <form action="{{ search_action }}" method="get" role="search" class="form form--search-desktop"> + <button class="button button--search" type="submit" aria-label="Search"> + <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg> + </button> + <input class="input input--search input--secondary" type="text" placeholder="Search {{ search_placeholder|default:"investments" }}" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input"> + </form> + {% endif %} + </div> + </div> +</div> + +<div class="filters {% if filter_classes %}{{filter_classes}}{% endif %}"> + <div class="filters__header"> + <button class="filters__button js-clear-filters">Clear</button> + <div>Filter by</div> + <button class="filters__button js-close-filters">Close</button> + </div> + + <form action="{{ filter_action }}" method="get" class="form form--filters js-filter-form"> + <ul class="form__filters select2"> + {{ filter.form.as_ul }} + <li> + <button class="button button--primary" type="submit" value="Filter">Filter</button> + </li> + </ul> + </form> +</div> diff --git a/hypha/public/partner/views.py b/hypha/public/partner/views.py index 3cff8ecf0..b39ae699c 100644 --- a/hypha/public/partner/views.py +++ b/hypha/public/partner/views.py @@ -1,12 +1,16 @@ -from django_tables2 import SingleTableView - +from django_filters.views import FilterView from .models import Investment -from .tables import InvestmentTable +from .tables import InvestmentTable, InvestmentFilterAndSearch, InvestmentFilter +from django_tables2.views import SingleTableMixin +from django_tables2.paginators import LazyPaginator -class InvestmentTableView(SingleTableView): +class InvestmentTableView(SingleTableMixin, FilterView): model = Investment table_class = InvestmentTable + filterset_class = InvestmentFilterAndSearch + filter_action = '' + paginator_class = LazyPaginator table_pagination = {'per_page': 25} template_name = 'partner/investments.html' @@ -14,3 +18,12 @@ class InvestmentTableView(SingleTableView): kwargs = super(InvestmentTableView, self).get_table_kwargs() kwargs['request'] = self.request return kwargs + + def get_context_data(self, **kwargs): + search_term = self.request.GET.get('query') + + return super().get_context_data( + search_term=search_term, + filter_action=self.filter_action, + **kwargs, + ) diff --git a/hypha/static_src/src/javascript/public/investment-filters.js b/hypha/static_src/src/javascript/public/investment-filters.js new file mode 100644 index 000000000..dcd8bff44 --- /dev/null +++ b/hypha/static_src/src/javascript/public/investment-filters.js @@ -0,0 +1,161 @@ +(function ($) { + + 'use strict'; + + // Variables + const $toggleButton = $('.js-toggle-filters'); + const $closeButton = $('.js-close-filters'); + const $clearButton = $('.js-clear-filters'); + const filterOpenClass = 'filters-open'; + const filterActiveClass = 'is-active'; + + const urlParams = new URLSearchParams(window.location.search); + + const persistedParams = ['sort', 'query', 'investment']; + + // check if the page has a query string and keep filters open if so on desktop + const minimumNumberParams = persistedParams.reduce( + (count, param) => count + urlParams.has(param) ? 1 : 0, + 0 + ); + + if ([...urlParams].length > minimumNumberParams && $(window).width() > 1024) { + $('.filters').addClass(filterOpenClass); + $('.js-toggle-filters').text('Clear filters'); + } + + // Add active class to filters - dropdowns are dynamically appended to the dom, + // so we have to listen for the event higher up + $('body').on('click', '.select2-dropdown', (e) => { + // get the id of the dropdown + let selectId = e.target.parentElement.parentElement.id; + + // find the matching dropdown + let match = $(`.select2-selection[aria-owns="${selectId}"]`); + + // if the dropdown contains a clear class, the filters are active + if ($(match[0]).find('span.select2-selection__clear').length !== 0) { + match[0].classList.add(filterActiveClass); + } + else { + match[0].classList.remove(filterActiveClass); + } + }); + + // remove active class on clearing select2 + $('.select2').on('select2:unselecting', (e) => { + const dropdown = e.target.nextElementSibling.firstChild.firstChild; + if (dropdown.classList.contains(filterActiveClass)) { + dropdown.classList.remove(filterActiveClass); + } + }); + + // toggle filters + $toggleButton.on('click', (e) => { + // find the nearest filters + const filters = e.target.closest('.js-table-actions').nextElementSibling; + + if (filters.classList.contains(filterOpenClass)) { + handleClearFilters(); + } + else { + filters.classList.add(filterOpenClass); + // only update button text on desktop + if (window.innerWidth >= 1024) { + updateButtonText(e.target, filters); + } + } + }); + + // close filters on mobile + $closeButton.on('click', (e) => { + e.target.closest('.filters').classList.remove(filterOpenClass); + }); + + // redirect to investments home to clear filters + function handleClearFilters() { + const query = persistedParams.reduce( + (query, param) => query + (urlParams.get(param) ? `&${param}=${urlParams.get(param)}` : ''), '?'); + window.location.href = window.location.href.split('?')[0] + query; + } + + // toggle filters button wording + function updateButtonText(button, filters) { + if (filters.classList.contains(filterOpenClass)) { + button.textContent = 'Clear filters'; + } + else { + button.textContent = 'Filters'; + } + } + + // corrects spacing of dropdowns when toggled on mobile + function mobileFilterPadding(element) { + const expanded = 'expanded-filter-element'; + const dropdown = $(element).closest('.select2'); + const openDropdown = $('.select2 .' + expanded); + let dropdownMargin = 0; + + if (openDropdown.length > 0 && !openDropdown.hasClass('select2-container--open')) { + // reset the margin of the select we previously worked + openDropdown.removeClass(expanded); + // store the offset to adjust the new select box (elements above the old dropdown unaffected) + if (dropdown.position().top > openDropdown.position().top) { + dropdownMargin = parseInt(openDropdown.css('marginBottom')); + } + openDropdown.css('margin-bottom', '0px'); + } + + if (dropdown.hasClass('select2-container--open')) { + dropdown.addClass(expanded); + const dropdownID = $(element).closest('.select2-selection').attr('aria-owns'); + // Element which has the height of the select dropdown + const match = $(`ul#${dropdownID}`); + const dropdownHeight = match.outerHeight(true); + + // Element which has the position of the dropdown + const positionalMatch = match.closest('.select2-container'); + + // Pad the bottom of the select box + dropdown.css('margin-bottom', `${dropdownHeight}px`); + + // bump up the dropdown options by height of closed elements + positionalMatch.css('top', positionalMatch.position().top - dropdownMargin); + } + } + + // clear all filters + $clearButton.on('click', () => { + const dropdowns = document.querySelectorAll('.form__filters select'); + dropdowns.forEach(dropdown => { + $(dropdown).val(null).trigger('change'); + $('.select2-selection.is-active').removeClass(filterActiveClass); + mobileFilterPadding(dropdown); // eslint-disable-line no-undef + }); + }); + + $(function () { + // Add active class to select2 checkboxes after page has been filtered + const clearButtons = document.querySelectorAll('.select2-selection__clear'); + clearButtons.forEach(clearButton => { + clearButton.parentElement.parentElement.classList.add(filterActiveClass); + }); + }); + + // reset mobile filters if they're open past the tablet breakpoint + $(window).resize(function resize() { + if ($(window).width() < 1024) { + // close the filters if open when reducing the window size + $('body').removeClass('filters-open'); + + // update filter button text + $('.js-toggle-filters').text('Filters'); + + // Correct spacing of dropdowns when toggled + $('.select2').on('click', (e) => { + mobileFilterPadding(e.target); + }); + } + }).trigger('resize'); + +})(jQuery); diff --git a/hypha/static_src/src/sass/public/abstracts/_variables.scss b/hypha/static_src/src/sass/public/abstracts/_variables.scss index 4eba93625..6166355b3 100644 --- a/hypha/static_src/src/sass/public/abstracts/_variables.scss +++ b/hypha/static_src/src/sass/public/abstracts/_variables.scss @@ -111,6 +111,9 @@ $transition: .25s ease-out; $quick-transition: .15s ease; $medium-transition: .5s ease; +// Filters +$filter-dropdown: '.select2 .select2-selection.select2-selection--single'; + // Delays $base-delay: 30ms; diff --git a/hypha/static_src/src/sass/public/components/_actions-bar.scss b/hypha/static_src/src/sass/public/components/_actions-bar.scss new file mode 100644 index 000000000..f2afad48a --- /dev/null +++ b/hypha/static_src/src/sass/public/components/_actions-bar.scss @@ -0,0 +1,65 @@ +.actions-bar { + margin: 20px 0; + width: 100%; + + @include media-query(tablet-landscape) { + display: flex; + justify-content: space-between; + } + + &__inner { + & > * { + margin-bottom: 20px; + } + + @include media-query(tablet-landscape) { + display: flex; + align-items: center; + + & > * { + margin: 0 0 0 20px; + + &:first-child { + margin-left: 0; + } + } + } + + &--left { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + &--right { + align-items: flex-end; + } + + &--batch-actions { + display: none; + + @include media-query(tablet-landscape) { + display: flex; + opacity: 0; + pointer-events: none; + transition: opacity $quick-transition; + margin: 20px 0 0; + + .batch-actions-enabled & { + opacity: 1; + pointer-events: all; + } + } + } + } + + &__total { + background-color: $color--light-blue; + color: $color--white; + padding: 6px 16px; + border-radius: 30px; + min-width: 120px; + text-align: center; + font-weight: $weight--semibold; + } +} diff --git a/hypha/static_src/src/sass/public/components/_button.scss b/hypha/static_src/src/sass/public/components/_button.scss index a3b07c919..836ed57cd 100644 --- a/hypha/static_src/src/sass/public/components/_button.scss +++ b/hypha/static_src/src/sass/public/components/_button.scss @@ -26,6 +26,55 @@ } } + &--filters { + display: flex; + justify-content: space-between; + max-width: 300px; + padding: 15px 20px; + font-weight: $weight--normal; + color: $color--default; + background: url('./../../images/filters.svg') $color--white no-repeat 93% center; + border: 1px solid $color--light-mid-grey; + transition: none; + width: 100%; + + @include media-query(tablet-landscape) { + background: none; + padding: 10px; + border: 0; + justify-content: flex-start; + width: auto; + opacity: .7; + + &::before { + content: ''; + background-image: url('./../../images/filters.svg'); + transform: rotate(90deg); + background-color: transparent; + background-position: left center; + background-size: 20px; + width: 20px; + height: 20px; + display: inline-block; + margin-right: 10px; + } + } + } + + &--filters-header { + display: flex; + } + + &--search { + position: absolute; + top: .65em; + right: 10px; + + svg { + fill: $color--primary; + } + } + &--left-space { margin-left: 20px; } diff --git a/hypha/static_src/src/sass/public/components/_filters.scss b/hypha/static_src/src/sass/public/components/_filters.scss new file mode 100644 index 000000000..9a7f427b9 --- /dev/null +++ b/hypha/static_src/src/sass/public/components/_filters.scss @@ -0,0 +1,63 @@ +.filters { + display: none; + + &.filters-open { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 20; + display: block; + width: 100%; + height: 100vh; + background: $color--white; + } + + @include media-query(tablet-landscape) { + display: block; + max-height: 0; + transition: max-height $medium-transition; + transition-delay: $base-delay; + pointer-events: none; + + &.filters-open { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + height: auto; + background: transparent; + max-height: 85px; + pointer-events: all; + } + } + + &__header { + display: flex; + align-items: center; + justify-content: space-around; + padding: 20px 0; + + @include media-query(tablet-landscape) { + display: none; + } + + > div[class^='js-'] { + color: $color--primary; + + &:hover { + cursor: pointer; + } + } + } + + &__button { + appearance: none; + -webkit-appearance: none; // sass-lint:disable-line no-vendor-prefixes + border: 0; + color: $color--primary; + background: transparent; + } +} diff --git a/hypha/static_src/src/sass/public/components/_form.scss b/hypha/static_src/src/sass/public/components/_form.scss index f2a5c7e56..5f2859bde 100644 --- a/hypha/static_src/src/sass/public/components/_form.scss +++ b/hypha/static_src/src/sass/public/components/_form.scss @@ -32,6 +32,17 @@ } } + &--search-desktop { + position: relative; + max-width: 300px; + margin-top: $mobile-gutter; + + @include media-query(tablet-landscape) { + max-width: 280px; + margin: 0 0 0 30px; + } + } + &__group { position: relative; margin: 1rem 0; @@ -150,6 +161,124 @@ border-radius: 5px; } + &__filters { + #{$filter-dropdown} { + border: 0; + border-top: 1px solid $color--mid-grey; + + &.is-active { + font-weight: $weight--normal; + background-color: transparentize($color--primary, .9); + border-color: $color--mid-grey; + } + + @include media-query(tablet-landscape) { + border: 1px solid $color--mid-grey; + } + } + + @include media-query(tablet-landscape) { + display: flex; + align-items: flex-start; + padding: 10px 0 30px; + opacity: 0; + transition: opacity $transition; + + .filters-open & { + opacity: 1; + transition-delay: $base-delay * 10; + } + + .filters--dates & { + align-items: flex-end; + margin: 10px 0 30px; + padding: 0; + } + } + + label { + display: none; + + .filters--dates & { + display: block; + } + } + + // so the form can be output in any tag + > * { + @include media-query(tablet-landscape) { + flex-basis: 225px; + + &:not(:last-child) { + margin-right: 10px; + } + } + } + + &--mobile { + flex-direction: column; + padding: 0; + + // so the form can be output in any tag + > * { + flex-basis: auto; + margin: 0; + } + } + + > li { + padding: 0 1rem; + + @include media-query(tablet-landscape) { + padding: 0; + } + + // re-order from/to date inputs and text + .filters--dates & { + margin: 0 auto 1rem; + max-width: 320px; + + @include media-query(mob-landscape) { + display: flex; + max-width: 600px; + + @supports (display: grid) { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 5px; + } + } + + @include media-query(tablet-landscape) { + margin: 0 1rem 0 0; + max-width: initial; + } + + label { + @supports (display: grid) { + grid-column: 1; + grid-row: 1; + } + } + + input { + &:first-of-type { + @supports (display: grid) { + grid-column: 1; + } + } + } + + span { + @supports (display: grid) { + grid-column: 2; + grid-row: 1; + } + } + } + } + } + &__required { color: $color--purple; } @@ -176,6 +305,18 @@ background: url('./../../images/dropdown.svg') $color--white no-repeat 95% center; background-size: 8px; + .form--scoreable & { + margin-top: 20px; + } + + .form__filters & { + max-width: 100%; + + select { + height: $dropdown-height; + } + } + select[multiple='multiple'] { display: block; } diff --git a/hypha/static_src/src/sass/public/components/_input.scss b/hypha/static_src/src/sass/public/components/_input.scss index eaaf2ee57..bbd6e1cd5 100644 --- a/hypha/static_src/src/sass/public/components/_input.scss +++ b/hypha/static_src/src/sass/public/components/_input.scss @@ -8,4 +8,12 @@ &--bottom-space { margin-bottom: 10px; } + + &--search { + width: 100%; + padding: 10px; + color: $color--dark-grey; + background: transparent; + border: 1px solid $color--mid-dark-grey; + } } diff --git a/hypha/static_src/src/sass/public/main.scss b/hypha/static_src/src/sass/public/main.scss index 4c56f2abf..91bb5539e 100644 --- a/hypha/static_src/src/sass/public/main.scss +++ b/hypha/static_src/src/sass/public/main.scss @@ -10,6 +10,7 @@ // Components @import 'components/apply-bar'; @import 'components/button'; +@import 'components/actions-bar'; @import 'components/blockquote'; @import 'components/editor'; @import 'components/card'; @@ -38,6 +39,7 @@ @import 'components/select2'; @import 'components/show-more'; @import 'components/table'; +@import 'components/filters'; @import 'components/wrapper'; // Layout -- GitLab