diff --git a/opentech/apply/categories/blocks.py b/opentech/apply/categories/blocks.py index 02359dc54c454236183c6407d962f59fc8e4c687..b8bf9a67c1180cd53a62490fae49aa46930fe6e9 100644 --- a/opentech/apply/categories/blocks.py +++ b/opentech/apply/categories/blocks.py @@ -74,12 +74,11 @@ class CategoryQuestionBlock(OptionalFormFieldBlock): else: return forms.RadioSelect - def render(self, value, context): - data = context['data'] + def prepare_data(self, value, data, serialize): category = value['category'] if data: - context['data'] = category.options.filter(id__in=data).values_list('value', flat=True) - return super().render(value, context) + data = category.options.filter(id__in=data).values_list('value', flat=True) + return data def get_searchable_content(self, value, data): return None diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py index fa11fe29fbb57b9fc375c13ed6f37f7a784adcbd..4e294f673bb74547106e2521eff91bb93be5fc8a 100644 --- a/opentech/apply/funds/api_views.py +++ b/opentech/apply/funds/api_views.py @@ -1,6 +1,9 @@ +from django.db.models import Q from rest_framework import generics from rest_framework import permissions -from django_filters.rest_framework import DjangoFilterBackend +from django_filters import rest_framework as filters + +from wagtail.core.models import Page from opentech.api.pagination import StandardResultsSetPagination from .models import ApplicationSubmission @@ -8,14 +11,31 @@ from .serializers import SubmissionListSerializer, SubmissionDetailSerializer from .permissions import IsApplyStaffUser +class RoundLabFilter(filters.ModelChoiceFilter): + def filter(self, qs, value): + if not value: + return qs + + return qs.filter(Q(round=value) | Q(page=value)) + + +class SubmissionsFilter(filters.FilterSet): + # TODO replace with better call to Round and Lab base class + round = RoundLabFilter(queryset=Page.objects.all()) + + class Meta: + model = ApplicationSubmission + fields = ('status', 'round') + + class SubmissionList(generics.ListAPIView): queryset = ApplicationSubmission.objects.current() serializer_class = SubmissionListSerializer permission_classes = ( permissions.IsAuthenticated, IsApplyStaffUser, ) - filter_backends = (DjangoFilterBackend,) - filter_fields = ('round', 'status') + filter_backends = (filters.DjangoFilterBackend,) + filter_class = SubmissionsFilter pagination_class = StandardResultsSetPagination diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index d6a87a5f919876520865192f3e1dde137a13a782..7ddff78f7ef5869c3f92078415d452ab913d88d3 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -39,6 +39,9 @@ class ValueBlock(ApplicationSingleIncludeFieldBlock): class Meta: label = _('Requested amount') + def prepare_data(self, value, data, serialize): + return '$' + str(data) + class EmailBlock(ApplicationMustIncludeFieldBlock): name = 'email' @@ -62,14 +65,29 @@ class AddressFieldBlock(ApplicationSingleIncludeFieldBlock): def format_data(self, data): # Based on the fields listed in addressfields/widgets.py + return ', '.join( + data[field] + for field in order_fields + if data[field] + ) + + def prepare_data(self, value, data, serialize): order_fields = [ 'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country' ] - address = json.loads(data) - return ', '.join( - address[field] + data = json.loads(data) + data = { + field: data[field] for field in order_fields - if address[field] + } + + if serialize: + return data + + return ', '.join( + value + for value in data.values() + if value ) @@ -108,7 +126,7 @@ class DurationBlock(ApplicationMustIncludeFieldBlock): field_kwargs['choices'] = self.DURATION_OPTIONS.items() return field_kwargs - def format_data(self, data): + def prepare_data(self, value, data, serialize): return self.DURATION_OPTIONS[int(data)] class Meta: diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py index 87a1dd39ea31e63666f76fac46e9071fa3dd6d88..31a5b899affdd6817f197a886a099939f855dd7a 100644 --- a/opentech/apply/funds/models/mixins.py +++ b/opentech/apply/funds/models/mixins.py @@ -137,6 +137,22 @@ class AccessFormData: if isinstance(field.block, SingleIncludeMixin) } + @property + def normal_blocks(self): + return [ + field_id + for field_id in self.question_field_ids + if field_id not in self.named_blocks + ] + + def serialize(self, field_id): + field = self.field(field_id) + data = self.data(field_id) + return field.render(context={ + 'serialize': True, + 'data': data, + }) + def render_answer(self, field_id, include_question=False): try: field = self.field(field_id) @@ -149,8 +165,7 @@ class AccessFormData: # Returns a list of the rendered answers return [ self.render_answer(field_id, include_question=True) - for field_id in self.question_field_ids - if field_id not in self.named_blocks + for field_id in self.normal_blocks ] def output_answers(self): diff --git a/opentech/apply/funds/serializers.py b/opentech/apply/funds/serializers.py index 6275248cfc21d4ed822560759e8c014628d9cdda..3203a7ecca0cefc53594d594c9b39964655dbf3e 100644 --- a/opentech/apply/funds/serializers.py +++ b/opentech/apply/funds/serializers.py @@ -4,12 +4,44 @@ from .models import ApplicationSubmission class SubmissionListSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='funds:submissions-api:detail') + class Meta: model = ApplicationSubmission - fields = ('id', 'title', 'status') + fields = ('id', 'title', 'status', 'url') class SubmissionDetailSerializer(serializers.ModelSerializer): + questions = serializers.SerializerMethodField() + meta_questions = serializers.SerializerMethodField() + stage = serializers.CharField(source='stage.name') + class Meta: model = ApplicationSubmission - fields = ('id', 'title',) + fields = ('id', 'title', 'stage', 'meta_questions', 'questions') + + def serialize_questions(self, obj, fields): + for field_id in fields: + yield obj.serialize(field_id) + + def get_meta_questions(self, obj): + meta_questions = { + 'title': 'Project Name', + 'full_name': 'Legal Name', + 'email': 'Email', + 'value': 'Requested Funding', + 'duration': 'Project Duration', + 'address': 'Address' + } + data = self.serialize_questions(obj, obj.named_blocks.values()) + data = [ + { + **response, + 'question': meta_questions.get(response['type'], response['question']) + } + for response in data + ] + return data + + def get_questions(self, obj): + return self.serialize_questions(obj, obj.normal_blocks) diff --git a/opentech/apply/stream_forms/blocks.py b/opentech/apply/stream_forms/blocks.py index 684a692e88e083c2ef4b63f16a75fe162113d62e..d911275742eb4d0f1f8444bce96aa649a2d2563f 100644 --- a/opentech/apply/stream_forms/blocks.py +++ b/opentech/apply/stream_forms/blocks.py @@ -1,5 +1,6 @@ # Credit to https://github.com/BertrandBordage for initial implementation import bleach +from django_bleach.templatetags.bleach_tags import bleach_value from django import forms from django.db.models import BLANK_CHOICE_DASH @@ -50,17 +51,39 @@ class FormFieldBlock(StructBlock): field_kwargs = self.get_field_kwargs(struct_value) return self.get_field_class(struct_value)(**field_kwargs) - def get_context(self, value, parent_context): - context = super().get_context(value, parent_context) - parent_context['data'] = self.format_data(parent_context['data']) or self.no_response() - return context + def serialize(self, value, context): + return { + 'question': value['field_label'], + 'answer': context.get('data'), + 'type': self.name, + } + + def serialize_no_response(self, value, context): + return { + 'question': value['field_label'], + 'answer': 'No Response', + 'type': 'no_response', + } + + def prepare_data(self, value, data, serialize=False): + return bleach_value(str(data)) + + def render(self, value, context): + data = context.get('data') + data = self.prepare_data(value, data, context.get('serialize', False)) + + context.update(data=data or self.no_response()) + + if context.get('serialize'): + if not data: + return self.serialize_no_response(value, context) + return self.serialize(value, context) + + return super().render(value, context) def get_searchable_content(self, value, data): return str(data) - def format_data(self, data): - return data - def no_response(self): return "No response" @@ -273,6 +296,12 @@ class UploadableMediaBlock(OptionalFormFieldBlock): def get_searchable_content(self, value, data): return None + def prepare_data(self, value, data, serialize): + if serialize: + return data.serialize() + + return data + class ImageFieldBlock(UploadableMediaBlock): field_class = forms.ImageField @@ -301,6 +330,11 @@ class MultiFileFieldBlock(UploadableMediaBlock): label = _('Multiple File field') template = 'stream_forms/render_multi_file_field.html' + def prepare_data(self, value, data, serialize): + if serialize: + return [file.serialize() for file in data] + return data + def no_response(self): return [super().no_response()] diff --git a/opentech/apply/stream_forms/files.py b/opentech/apply/stream_forms/files.py index 1d5b8ce5d7df2b780b2e70269fa8bffc227dfe73..8fdf2febd2906c7baf65f19efe2f9dcb45db596e 100644 --- a/opentech/apply/stream_forms/files.py +++ b/opentech/apply/stream_forms/files.py @@ -66,6 +66,12 @@ class StreamFieldFile(File): return self.file.size return self.storage.size(self.name) + def serialize(self): + return { + 'url': self.url, + 'filename': self.filename, + } + def open(self, mode='rb'): if getattr(self, '_file', None) is None: self.file = self.storage.open(self.name, mode) diff --git a/opentech/static_src/src/app/.eslintrc b/opentech/static_src/src/app/.eslintrc new file mode 100644 index 0000000000000000000000000000000000000000..4217ad2a7fec678e4574f84dd1ffbe0ed10bf833 --- /dev/null +++ b/opentech/static_src/src/app/.eslintrc @@ -0,0 +1,31 @@ +{ + "parser": "babel-eslint", + "plugins": [ + "react" + ], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "mocha": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "no-console": "off" + } +} diff --git a/opentech/static_src/src/app/src/SubmissionsByRoundApp.js b/opentech/static_src/src/app/src/SubmissionsByRoundApp.js index d3ccafd766a6e79de63450a32021f841a6f73e8c..133ec908ac6d5d229408761c8f6bb708eae82fed 100644 --- a/opentech/static_src/src/app/src/SubmissionsByRoundApp.js +++ b/opentech/static_src/src/app/src/SubmissionsByRoundApp.js @@ -9,10 +9,17 @@ import { setCurrentSubmissionRound } from '@actions/submissions'; class SubmissionsByRoundApp extends React.Component { + static propTypes = { + roundID: PropTypes.number, + setSubmissionRound: PropTypes.func, + pageContent: PropTypes.node.isRequired, + }; + + state = { detailOpened: false }; componentDidMount() { - this.props.setSubmissionRound(this.props.roundId); + this.props.setSubmissionRound(this.props.roundID); } openDetail = () => { @@ -41,18 +48,13 @@ class SubmissionsByRoundApp extends React.Component { <div style={this.state.style} ref={this.setOriginalContentRef} dangerouslySetInnerHTML={{ __html: this.props.pageContent }} /> {this.state.detailOpened && - <GroupByStatusDetailView roundId={this.props.roundId} /> + <GroupByStatusDetailView roundId={this.props.roundID} /> } </> ) } } -SubmissionsByRoundApp.propTypes = { - roundId: PropTypes.number, - setSubmissionRound: PropTypes.func, -}; - const mapDispatchToProps = dispatch => { return { setSubmissionRound: id => { diff --git a/opentech/static_src/src/app/src/api/index.js b/opentech/static_src/src/app/src/api/index.js index 770802352d45563c36a504bc8c3fa7b55a39bb7a..6fcd16016bf03484a9777a09772ecd92b46f4507 100644 --- a/opentech/static_src/src/app/src/api/index.js +++ b/opentech/static_src/src/app/src/api/index.js @@ -1,5 +1,6 @@ -import { fetchSubmissionsByRound } from '@api/submissions'; +import { fetchSubmission, fetchSubmissionsByRound } from '@api/submissions'; export default { fetchSubmissionsByRound, + fetchSubmission, }; diff --git a/opentech/static_src/src/app/src/api/submissions.js b/opentech/static_src/src/app/src/api/submissions.js index 166600d4f66b3a970d6909fab38a756eecd03eed..8d7f95f776558bdb14a68dc874901214de1dcceb 100644 --- a/opentech/static_src/src/app/src/api/submissions.js +++ b/opentech/static_src/src/app/src/api/submissions.js @@ -6,3 +6,8 @@ export async function fetchSubmissionsByRound(id) { 'page_size': 1000, }); } + + +export async function fetchSubmission(id) { + return apiFetch(`/apply/api/submissions/${id}/`, 'GET'); +} diff --git a/opentech/static_src/src/app/src/components/ApplicationDisplay/index.js b/opentech/static_src/src/app/src/components/ApplicationDisplay/index.js deleted file mode 100644 index bdb5e1f9a3d3ef7dd4e59e970497070591fb5498..0000000000000000000000000000000000000000 --- a/opentech/static_src/src/app/src/components/ApplicationDisplay/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const ApplicationDisplay = () => <div>Application Display</div> - -export default ApplicationDisplay; diff --git a/opentech/static_src/src/app/src/components/DetailView/index.js b/opentech/static_src/src/app/src/components/DetailView/index.js index 41935c9ba4e2ca335439b11df0d123f707a1e5c6..b392e211ef577a62aaa64ad8a674b62544cf83cf 100644 --- a/opentech/static_src/src/app/src/components/DetailView/index.js +++ b/opentech/static_src/src/app/src/components/DetailView/index.js @@ -1,18 +1,101 @@ -import React from 'react'; +import React, { Component } from 'react' +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; +import { withWindowSizeListener } from 'react-window-size-listener'; + +import { clearCurrentSubmission } from '@actions/submissions'; +import DisplayPanel from '@containers/DisplayPanel'; +import SlideInRight from '@components/Transitions/SlideInRight' +import SlideOutLeft from '@components/Transitions/SlideOutLeft' +import { getCurrentSubmissionID } from '@selectors/submissions'; -import DisplayPanel from '@components/DisplayPanel'; import './style.scss'; -const DetailView = ({ listing, display }) => ( - <div className="detail-view"> - {listing} - <DisplayPanel /> - </div> -); +class DetailView extends Component { + static propTypes = { + listing: PropTypes.element.isRequired, + submissionID: PropTypes.number, + windowSize: PropTypes.objectOf(PropTypes.number), + clearSubmission: PropTypes.func.isRequired, + }; + + state = { + listingShown: true, + firstRender: true, + } + + isMobile = (width) => (width ? width : this.props.windowSize.windowWidth) < 1024 + + renderDisplay () { + return <DisplayPanel /> + } + + componentDidUpdate (prevProps, prevState) { + if (this.isMobile()) { + const haveCleared = prevProps.submissionID && !this.props.submissionID + const haveUpdated = !prevProps.submissionID && this.props.submissionID + + if ( haveCleared ) { + this.setState({listingShown: true}) + } else if ( haveUpdated && this.state.firstRender ) { + // Listing automatically updating after update + // clear, but dont run again + this.props.clearSubmission() + this.setState({firstRender: false}) + } else if ( prevProps.submissionID !== this.props.submissionID) { + // Submission has changed and we want to show it + // reset the firstRender so that we can clear it again + this.setState({ + listingShown: false, + firstRender: true, + }) + } + } + } + + render() { + const { listing } = this.props; + + if (this.isMobile()) { + var activeDisplay; + if (this.state.listingShown){ + activeDisplay = ( + <SlideOutLeft key={"listing"}> + {listing} + </SlideOutLeft> + ) + } else { + activeDisplay = ( + <SlideInRight key={"display"}> + { this.renderDisplay() } + </SlideInRight> + ) + } + + return ( + <div className="detail-view"> + { activeDisplay } + </div> + ) + } else { + return ( + <div className="detail-view"> + {listing} + { this.renderDisplay() } + </div> + ) + } + + } +} + +const mapStateToProps = state => ({ + submissionID: getCurrentSubmissionID(state), +}); + +const mapDispatchToProps = { + clearSubmission: clearCurrentSubmission +} -DetailView.propTypes = { - listing: PropTypes.node.isRequired, -}; -export default DetailView; +export default connect(mapStateToProps, mapDispatchToProps)(withWindowSizeListener(DetailView)); diff --git a/opentech/static_src/src/app/src/components/DisplayPanel/index.js b/opentech/static_src/src/app/src/components/DisplayPanel/index.js deleted file mode 100644 index d3d719c4ab0799fccc8a71905e7a5a92d1d0bc28..0000000000000000000000000000000000000000 --- a/opentech/static_src/src/app/src/components/DisplayPanel/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import ApplicationDisplay from '@components/ApplicationDisplay' -import Tabber from '@components/Tabber' -import './style.scss'; - -const DisplayPanel = () => ( - <div className="display-panel"> - <ApplicationDisplay /> - <Tabber /> - </div> -); - -export default DisplayPanel; diff --git a/opentech/static_src/src/app/src/components/DisplayPanel/style.scss b/opentech/static_src/src/app/src/components/DisplayPanel/style.scss deleted file mode 100644 index 00e22068d32b078b04289db1b407af6e54dd20ce..0000000000000000000000000000000000000000 --- a/opentech/static_src/src/app/src/components/DisplayPanel/style.scss +++ /dev/null @@ -1,19 +0,0 @@ -.display-panel { - // temporary to visualise middle column - > div:first-child { - background-color: $color--white; - } - - @include media-query(tablet-landscape) { - display: grid; - grid-template-columns: 1fr 250px; - } - - @include media-query(desktop) { - grid-template-columns: 1fr 390px; - @include target-ie11 { - display: flex; - flex-wrap: wrap; - } - } -} diff --git a/opentech/static_src/src/app/src/components/Listing/index.js b/opentech/static_src/src/app/src/components/Listing/index.js index 791a42f834f2df5511286ebe3ccd7588a203975d..06b733e44906a40ef8cb4eb9544c42c0121a5063 100644 --- a/opentech/static_src/src/app/src/components/Listing/index.js +++ b/opentech/static_src/src/app/src/components/Listing/index.js @@ -1,31 +1,76 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ListingHeading from '@components/ListingHeading'; import ListingGroup from '@components/ListingGroup'; import ListingItem from '@components/ListingItem'; import './style.scss'; export default class Listing extends React.Component { + static propTypes = { + items: PropTypes.array, + activeItem: PropTypes.number, + isLoading: PropTypes.bool, + error: PropTypes.string, + groupBy: PropTypes.string, + order: PropTypes.arrayOf(PropTypes.string), + onItemSelection: PropTypes.func, + }; + + state = { + orderedItems: [], + }; + + componentDidMount() { + this.orderItems(); + } + + componentDidUpdate(prevProps, prevState) { + // Order items + if (this.props.items !== prevProps.items) { + this.orderItems(); + } + + const oldItem = prevProps.activeItem + const newItem = this.props.activeItem + + // If we have never activated a submission, get the first item + if ( !newItem && !oldItem ) { + const firstGroup = this.state.orderedItems[0] + if ( firstGroup && firstGroup.items[0] ) { + this.setState({firstUpdate: false}) + this.props.onItemSelection(firstGroup.items[0].id) + } + } + } + renderListItems() { - const { isLoading, isError, items } = this.props; + const { isLoading, error, items, onItemSelection, activeItem } = this.props; if (isLoading) { return <p>Loading...</p>; - } else if (isError) { - return <p>Something went wrong. Please try again later.</p>; + } else if (error) { + return ( + <> + <p>Something went wrong. Please try again later.</p> + <p>{ error }</p> + </> + ); } else if (items.length === 0) { return <p>No results found.</p>; } return ( <ul className="listing__list"> - {this.getOrderedItems().filter(v => v.items.length !== 0).map(v => { + {this.state.orderedItems.map(group => { return ( - <ListingGroup key={`listing-group-${v.group}`} item={v}> - {v.items.map(item => { - return <ListingItem key={`listing-item-${item.id}`} item={item}/>; + <ListingGroup key={`listing-group-${group.name}`} item={group}> + {group.items.map(item => { + return <ListingItem + selected={!!activeItem && activeItem===item.id} + onClick={() => onItemSelection(item.id)} + key={`listing-item-${item.id}`} + item={item}/>; })} </ListingGroup> ); @@ -47,19 +92,19 @@ export default class Listing extends React.Component { }, {}); } - getOrderedItems() { + orderItems() { const groupedItems = this.getGroupedItems(); const { order = [] } = this.props; - const orderedItems = []; const leftOverKeys = Object.keys(groupedItems).filter(v => !order.includes(v)); - return order.concat(leftOverKeys).map(key => ({ - group: key, - items: groupedItems[key] || [] - })); + this.setState({ + orderedItems: order.concat(leftOverKeys).filter(key => groupedItems[key] ).map(key => ({ + name: key, + items: groupedItems[key] || [] + })), + }); } render() { - const { isLoading, isError } = this.props; return ( <div className="listing"> <div className="listing__header"></div> @@ -68,11 +113,3 @@ export default class Listing extends React.Component { ); } } - -Listing.propTypes = { - items: PropTypes.array, - isLoading: PropTypes.bool, - isError: PropTypes.bool, - groupBy: PropTypes.string, - order: PropTypes.arrayOf(PropTypes.string), -}; diff --git a/opentech/static_src/src/app/src/components/ListingGroup.js b/opentech/static_src/src/app/src/components/ListingGroup.js index 72c97d53b4f24946f99b0bf83752c3ebb3ca99a1..6508a18b13c4c5574109451211c0fcd6c4af8f6f 100644 --- a/opentech/static_src/src/app/src/components/ListingGroup.js +++ b/opentech/static_src/src/app/src/components/ListingGroup.js @@ -3,21 +3,24 @@ import PropTypes from 'prop-types'; import ListingHeading from '@components/ListingHeading'; + export default class ListingGroup extends React.Component { + static propTypes = { + children: PropTypes.arrayOf(PropTypes.node), + item: PropTypes.shape({ + name: PropTypes.string, + }), + }; + render() { + const {item, children} = this.props return ( <> - <ListingHeading title={this.props.item.group} count={this.props.children.length} /> + <ListingHeading title={item.name} count={children.length} /> <ul> - {this.props.children} + {children} </ul> </> ); } } - -ListingGroup.propTypes = { - item: PropTypes.shape({ - group: PropTypes.string, - }), -}; diff --git a/opentech/static_src/src/app/src/components/ListingItem.js b/opentech/static_src/src/app/src/components/ListingItem.js index 1207c65e8a44b087b97d2c3ed2baf437e05579e5..65969c7deae3a1ddb5e54a1dc0fd6cf19ebacbc8 100644 --- a/opentech/static_src/src/app/src/components/ListingItem.js +++ b/opentech/static_src/src/app/src/components/ListingItem.js @@ -4,9 +4,12 @@ import PropTypes from 'prop-types'; export default class ListingItem extends React.Component { render() { + const { onClick, item, selected} = this.props; return ( - <li className="listing__item"> - <a className="listing__link">{this.props.item.title}</a> + <li className={"listing__item " + (selected ? "is-active" : "")}> + <a className="listing__link" onClick={onClick}> + {item.title} + </a> </li> ); } @@ -16,4 +19,6 @@ ListingItem.propTypes = { item: PropTypes.shape({ title: PropTypes.string, }), + onClick: PropTypes.func, + selected: PropTypes.bool, }; diff --git a/opentech/static_src/src/app/src/components/SubmissionDisplay/answers.js b/opentech/static_src/src/app/src/components/SubmissionDisplay/answers.js new file mode 100644 index 0000000000000000000000000000000000000000..e2a50ee55e3b4119a2a9681912d617b0cc170542 --- /dev/null +++ b/opentech/static_src/src/app/src/components/SubmissionDisplay/answers.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Download from 'images/download.svg'; +import File from 'images/file.svg'; + +const answerType = {answer: PropTypes.string.isRequired} +const arrayAnswerType = {answer: PropTypes.arrayOf(PropTypes.string)} +const fileType = {answer: PropTypes.shape({ + filename: PropTypes.string.isRequired, + url:PropTypes.string.isRequired, +})} + +const ListAnswer = ({Wrapper, answers}) => ( + <ul>{ + answers.map((answer, index) => <li key={index}><Wrapper answer={answer} /></li>) + }</ul> +); +ListAnswer.propTypes = { + Wrapper: PropTypes.element, + ...arrayAnswerType, +} + +const BasicAnswer = ({answer}) => <p>{ answer }</p>; +BasicAnswer.propTypes = answerType + +const BasicListAnswer = ({answer}) => <ListAnswer Wrapper={BasicAnswer} answers={answer} />; +BasicListAnswer.propTypes = arrayAnswerType + +const RichTextAnswer = ({answer}) => <div dangerouslySetInnerHTML={{ __html: answer }} />; +RichTextAnswer.propTypes = answerType + +const FileAnswer = ({answer}) => ( + <a className="link link--download" href={answer.url}> + <div> + <File /><span>{answer.filename}</span> + </div> + <Download /> + </a> +); +FileAnswer.propTypes = fileType + +const MultiFileAnswer = ({answer}) => <ListAnswer Wrapper={FileAnswer} answers={answer} />; +MultiFileAnswer.propTypes = {answer: PropTypes.arrayOf(fileType)} + +const AddressAnswer = ({answer}) => ( + <div>{ + Object.entries(answer) + .filter(([key, value]) => !!value ) + .map(([key, value]) => <p key={key}>{value}</p> )} + </div> +) +AddressAnswer.propTypes = {answer: PropTypes.objectOf(PropTypes.string)} + + +const answerTypes = { + 'no_response': BasicAnswer, + 'char': BasicAnswer, + 'email': BasicAnswer, + 'name': BasicAnswer, + 'value': BasicAnswer, + 'title': BasicAnswer, + 'full_name': BasicAnswer, + 'duration': BasicAnswer, + 'date': BasicAnswer, + 'checkbox': BasicAnswer, + 'dropdown': BasicAnswer, + 'radios': BasicAnswer, + + // SPECIAL + 'rich_text': RichTextAnswer, + 'address': AddressAnswer, + 'category': BasicListAnswer, + // Files + 'file': FileAnswer, + 'multi_file': MultiFileAnswer, +} + +export const answerPropTypes = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.object), +]) + +const Answer = ({ answer, type }) => { + const AnswerType = answerTypes[type]; + + return <AnswerType answer={answer} />; +} +Answer.propTypes = { + answer: answerPropTypes, + type: PropTypes.string.isRequired, +} + +export default Answer; diff --git a/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js b/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e78303d8cd5286bcb2625fe4080f3703d037ea12 --- /dev/null +++ b/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js @@ -0,0 +1,73 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; + +import Answer, { answerPropTypes } from './answers' +import './styles.scss' + + +const MetaResponse = ({ question, answer, type }) => { + return ( + <div> + <h5>{question}</h5> + <Answer type={type} answer={answer} /> + </div> + ) +} +MetaResponse.propTypes = { + question: PropTypes.string.isRequired, + answer: answerPropTypes, + type: PropTypes.string.isRequired, +} + + +const Response = ({question, answer, type}) => { + return ( + <section> + <h4>{question}</h4> + <Answer type={type} answer={answer} /> + </section> + ) +} +Response.propTypes = { + question: PropTypes.string.isRequired, + answer: answerPropTypes, + type: PropTypes.string.isRequired, +} + + +export default class SubmissionDisplay extends Component { + static propTypes = { + isLoading: PropTypes.bool, + isError: PropTypes.bool, + submission: PropTypes.object, + } + + render() { + if (this.props.isLoading) { + return <div>Loading...</div>; + } else if (this.props.isError) { + return <div>Error occured...</div>; + } else if (this.props.submission === undefined) { + return <div>Not selected</div>; + } + const { meta_questions = [], questions = [], stage} = this.props.submission; + + return ( + <div className="application-display"> + <h3>{stage} Information</h3> + + <div className="grid grid--proposal-info"> + {meta_questions.map((response, index) => ( + <MetaResponse key={index} {...response} /> + ))} + </div> + + <div className="rich-text rich-text--answers"> + {questions.map((response, index) => ( + <Response key={index} {...response} /> + ))} + </div> + </div> + ) + } +} diff --git a/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss b/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..02ea2a95ec5535773cccf9c255b15828c6bdab09 --- /dev/null +++ b/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss @@ -0,0 +1,6 @@ +$application-header-height: 42px; + +.application-display { + height: calc(100vh - var(--header-admin-height) - #{$application-header-height}); + overflow-y: scroll; +} diff --git a/opentech/static_src/src/app/src/components/Switcher/index.js b/opentech/static_src/src/app/src/components/Switcher/index.js index e3c04e79fc44495eb1a50e6656ce558f1a585ebd..3f3aec96ded51b4be0178daf3fcb8edd3122c7c0 100644 --- a/opentech/static_src/src/app/src/components/Switcher/index.js +++ b/opentech/static_src/src/app/src/components/Switcher/index.js @@ -1,11 +1,20 @@ import React from 'react' import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + import ArrayIcon from 'images/icon-array.svg' import GridIcon from 'images/icon-grid.svg'; import './styles.scss'; class Switcher extends React.Component { + static propTypes = { + handleOpen: PropTypes.func.isRequired, + handleClose: PropTypes.func.isRequired, + selector: PropTypes.string.isRequired, + open: PropTypes.bool, + } + constructor(props) { super(props); this.el = document.getElementById(props.selector); diff --git a/opentech/static_src/src/app/src/components/Tabber/index.js b/opentech/static_src/src/app/src/components/Tabber/index.js index 1fb0d353784635de2c102c026d4ca09043732add..1b1ee8a357ddc3c527d589305bed96414800805c 100644 --- a/opentech/static_src/src/app/src/components/Tabber/index.js +++ b/opentech/static_src/src/app/src/components/Tabber/index.js @@ -1,5 +1,59 @@ -import React from 'react'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; -const Tabber = () => <div>Tabber</div> + + +export const Tab = ({button, children, handleClick}) => <div>{children}</div> +Tab.propTypes = { + button: PropTypes.node, + children: PropTypes.node, + handleClick: PropTypes.func, +} + +class Tabber extends Component { + static propTypes = { + children: PropTypes.arrayOf(PropTypes.element), + } + + constructor() { + super(); + + this.state = { + activeTab: 0 + } + } + + componentDidUpdate(prevProps, prevState) { + const { children } = this.props; + if ( !children[prevState.activeTab].props.children ) { + this.setState({activeTab: children.findIndex(child => child.props.children)}) + } + } + + handleClick = (child) => { + this.setState({ + activeTab: child + }) + } + + render() { + const { children } = this.props; + + return ( + <div className="tabber"> + <div className="tabber__navigation"> + {children.map((child, i) => { + return <a onClick={child.props.handleClick ? child.props.handleClick : () => this.handleClick(i)} className="display-panel__link" key={child.key}>{child.props.button}</a> + }) + } + </div> + <div className="tabber-tab__active"> + { children[this.state.activeTab] } + </div> + </div> + ) + } + +} export default Tabber; diff --git a/opentech/static_src/src/app/src/components/Transitions/SlideInRight.js b/opentech/static_src/src/app/src/components/Transitions/SlideInRight.js new file mode 100644 index 0000000000000000000000000000000000000000..3d02d4bf40da82afb2d8e009540f4e26950176ef --- /dev/null +++ b/opentech/static_src/src/app/src/components/Transitions/SlideInRight.js @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types'; +import Transition from 'react-transition-group/Transition'; + + +const SlideInRight = ({ children, in: inProp }) => { + const duration = 250; + + const defaultStyle = { + transition: `transform ${duration}ms ease-in-out`, + transform: 'translate3d(0, 0, 0)', + position: 'absolute', + zIndex: 2, + width: '100%' + } + + const transitionStyles = { + entering: { transform: 'translate3d(0, 0, 0)' }, + entered: { transform: 'translate3d(100%, 0, 0)' }, + exiting: { transform: 'translate3d(100%, 0, 0)' }, + exited: { transform: 'translate3d(0, 0, 0)' } + }; + + return ( + <Transition in={inProp} timeout={duration}> + {(state) => ( + <div style={{ ...defaultStyle, ...transitionStyles[state] }}> + {children} + </div> + )} + </Transition> + ) +} + +SlideInRight.propTypes = { + children: PropTypes.node, + in: PropTypes.bool, +} + +export default SlideInRight diff --git a/opentech/static_src/src/app/src/components/Transitions/SlideOutLeft.js b/opentech/static_src/src/app/src/components/Transitions/SlideOutLeft.js new file mode 100644 index 0000000000000000000000000000000000000000..49345eb18fa0ddc1501f9a6ca1b901c1621ea02b --- /dev/null +++ b/opentech/static_src/src/app/src/components/Transitions/SlideOutLeft.js @@ -0,0 +1,39 @@ +import React from 'react' +import PropTypes from 'prop-types'; +import Transition from 'react-transition-group/Transition'; + + +const SlideOutLeft = ({ children, in: inProp }) => { + const duration = 250; + + const defaultStyle = { + transition: `transform ${duration}ms ease-in-out`, + transform: 'translate3d(0, 0, 0)', + position: 'absolute', + width: '100%' + } + + const transitionStyles = { + entering: { transform: 'translate3d(0, 0, 0)' }, + entered: { transform: 'translate3d(-100%, 0, 0)' }, + exiting: { transform: 'translate3d(-100%, 0, 0)' }, + exited: { transform: 'translate3d(0, 0, 0)' } + }; + + return ( + <Transition in={inProp} timeout={duration}> + {(state) => ( + <div style={{ ...defaultStyle, ...transitionStyles[state] }}> + {children} + </div> + )} + </Transition> + ) +} + +SlideOutLeft.propTypes = { + children: PropTypes.node, + in: PropTypes.bool, +} + +export default SlideOutLeft diff --git a/opentech/static_src/src/app/src/containers/ByStatusListing.js b/opentech/static_src/src/app/src/containers/ByStatusListing.js index aaeb99202707af65dc5056500d1e609e7ba4d246..873036c9467564588ebc4fba69d8bba81002c29c 100644 --- a/opentech/static_src/src/app/src/containers/ByStatusListing.js +++ b/opentech/static_src/src/app/src/containers/ByStatusListing.js @@ -4,37 +4,58 @@ import { connect } from 'react-redux' import Listing from '@components/Listing'; import { + loadCurrentRound, + setCurrentSubmission, +} from '@actions/submissions'; +import { + getCurrentRound, getCurrentRoundID, getCurrentRoundSubmissions, - getSubmissionsByRoundErrorState, - getSubmissionsByRoundLoadingState, + getCurrentSubmissionID, + getSubmissionsByRoundError, } from '@selectors/submissions'; -import { setCurrentSubmissionRound, fetchSubmissionsByRound } from '@actions/submissions'; +const loadData = props => { + props.loadSubmissions(['submissions']) +} + class ByStatusListing extends React.Component { + static propTypes = { + loadSubmissions: PropTypes.func, + submissions: PropTypes.arrayOf(PropTypes.object), + roundID: PropTypes.number, + round: PropTypes.object, + error: PropTypes.string, + setCurrentItem: PropTypes.func, + activeSubmission: PropTypes.number, + }; + componentDidMount() { - const { roundId } = this.props; // Update items if round ID is defined. - if (roundId !== null && roundId !== undefined) { - this.props.loadSubmissions(roundId); + if ( this.props.roundID ) { + loadData(this.props) } } componentDidUpdate(prevProps) { - const { roundId } = this.props; + const { roundID } = this.props; // Update entries if round ID is changed or is not null. - if (roundId !== null && roundId !== undefined && prevProps.roundId !== roundId) { - this.props.loadSubmissions(roundId); + if (roundID && prevProps.roundID !== roundID) { + console.log('wooop') + loadData(this.props) } } render() { - const { isLoading, isError } = this.props; + const { error, submissions, round, setCurrentItem, activeSubmission } = this.props; + const isLoading = round && round.isFetching return <Listing isLoading={isLoading} - isError={isError} - items={this.props.items} + error={error} + items={submissions} + activeItem={activeSubmission} + onItemSelection={setCurrentItem} groupBy={'status'} order={[ // TODO: Set the proper order of statuses. @@ -52,22 +73,18 @@ class ByStatusListing extends React.Component { } const mapStateToProps = state => ({ - items: getCurrentRoundSubmissions(state), - roundId: getCurrentRoundID(state), - isError: getSubmissionsByRoundErrorState(state), - isLoading: getSubmissionsByRoundLoadingState(state), -}); + roundID: getCurrentRoundID(state), + submissions: getCurrentRoundSubmissions(state), + round: getCurrentRound(state), + error: getSubmissionsByRoundError(state), + activeSubmission: getCurrentSubmissionID(state), +}) const mapDispatchToProps = dispatch => ({ - loadSubmissions: id => dispatch(fetchSubmissionsByRound(id)), + loadSubmissions: () => dispatch(loadCurrentRound()), + setCurrentItem: id => dispatch(setCurrentSubmission(id)), }); -ByStatusListing.propTypes = { - loadSubmissions: PropTypes.func, - roundId: PropTypes.number, -}; - - export default connect( mapStateToProps, mapDispatchToProps diff --git a/opentech/static_src/src/app/src/containers/CurrentSubmissionDisplay.js b/opentech/static_src/src/app/src/containers/CurrentSubmissionDisplay.js new file mode 100644 index 0000000000000000000000000000000000000000..75a4a018e0597651c70d9524b9c08c112980da89 --- /dev/null +++ b/opentech/static_src/src/app/src/containers/CurrentSubmissionDisplay.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux' + +import { loadCurrentSubmission } from '@actions/submissions' +import { + getCurrentSubmission, + getCurrentSubmissionID, +} from '@selectors/submissions' +import SubmissionDisplay from '@components/SubmissionDisplay'; + +const loadData = props => { + props.loadCurrentSubmission(['questions']) + +} + +class CurrentSubmissionDisplay extends React.Component { + static propTypes = { + submission: PropTypes.object, + submissionID: PropTypes.number, + } + + componentDidMount() { + loadData(this.props) + } + + componentDidUpdate(prevProps) { + if (this.props.submissionID !== prevProps.submissionID ) { + loadData(this.props) + } + } + + render () { + const { submission } = this.props + if ( !submission ) { + return <p>Loading</p> + } + return <SubmissionDisplay + submission={submission} + isLoading={submission.isFetching} + isError={submission.isErrored} /> + } + +} + +const mapStateToProps = state => ({ + submissionID: getCurrentSubmissionID(state), + submission: getCurrentSubmission(state), +}) + + +export default connect(mapStateToProps, {loadCurrentSubmission})(CurrentSubmissionDisplay) diff --git a/opentech/static_src/src/app/src/containers/DisplayPanel/index.js b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e09e83f962d083bafa4acb743007b7443aef068b --- /dev/null +++ b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withWindowSizeListener } from 'react-window-size-listener'; + +import { clearCurrentSubmission } from '@actions/submissions'; +import { + getCurrentSubmission, + getCurrentSubmissionID, + getSubmissionErrorState, + getSubmissionLoadingState, + +} from '@selectors/submissions'; + +import CurrentSubmissionDisplay from '@containers/CurrentSubmissionDisplay' +import Tabber, {Tab} from '@components/Tabber' +import './style.scss'; + + +class DisplayPanel extends React.Component { + static propTypes = { + submissionID: PropTypes.number, + loadSubmission: PropTypes.func, + isLoading: PropTypes.bool, + isError: PropTypes.bool, + clearSubmission: PropTypes.func.isRequired, + windowSize: PropTypes.objectOf(PropTypes.number) + }; + + render() { + const { windowSize: {windowWidth: width} } = this.props; + const { clearSubmission } = this.props; + + const isMobile = width < 1024; + + const submission = <CurrentSubmissionDisplay /> + + let tabs = [ + <Tab button="Notes" key="note"> + <p>Notes</p> + </Tab>, + <Tab button="Status" key="status"> + <p>Status</p> + </Tab> + ] + + if ( isMobile ) { + tabs = [ + <Tab button="Back" key="back" handleClick={ clearSubmission } />, + <Tab button="Application" key="application"> + { submission } + </Tab>, + ...tabs + ] + } + + return ( + <div className="display-panel"> + { !isMobile && ( + <div className="display-panel__column"> + <div className="display-panel__header display-panel__header--spacer"></div> + <div className="display-panel__body"> + { submission } + </div> + </div> + )} + <div className="display-panel__column"> + <div className="display-panel__body"> + <Tabber> + { tabs } + </Tabber> + </div> + </div> + </div> + + ) + } +} + +const mapStateToProps = state => ({ + isLoading: getSubmissionLoadingState(state), + isError: getSubmissionErrorState(state), + submissionID: getCurrentSubmissionID(state), + submission: getCurrentSubmission(state), +}); + +const mapDispatchToProps = { + clearSubmission: clearCurrentSubmission +} + + +export default connect(mapStateToProps, mapDispatchToProps)(withWindowSizeListener(DisplayPanel)); diff --git a/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss b/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss new file mode 100644 index 0000000000000000000000000000000000000000..048c4907a51dab2757a023c9e4cb0a51fe7c06fa --- /dev/null +++ b/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss @@ -0,0 +1,45 @@ +.display-panel { + background-color: $color--white; + + @include media-query(tablet-landscape) { + display: grid; + grid-template-columns: 1fr 250px; + } + + @include media-query(desktop) { + grid-template-columns: 1fr 390px; + grid-template-rows: 75px 1fr; + } + + @include target-ie11 { + display: flex; + flex-wrap: wrap; + } + + &__body, + &__header { + @include submission-list-item; + padding: 20px; + } + + &__header { + &--spacer { + display: none; + min-height: 75px; + + @include media-query(tablet-landscape) { + display: block; + } + } + } + + &__links { + display: flex; + align-items: center; + padding: 0; + } + + &__link { + padding: 20px; + } +} diff --git a/opentech/static_src/src/app/src/containers/GroupByStatusDetailView.js b/opentech/static_src/src/app/src/containers/GroupByStatusDetailView.js index 4f2c88ec7cb1dbd795495ab429879d5885ed2982..d0ce02e5378e383edd7bfe8b75010f97870ae939 100644 --- a/opentech/static_src/src/app/src/containers/GroupByStatusDetailView.js +++ b/opentech/static_src/src/app/src/containers/GroupByStatusDetailView.js @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import DetailView from '@components/DetailView'; import ByStatusListing from '@containers/ByStatusListing'; diff --git a/opentech/static_src/src/app/src/index.js b/opentech/static_src/src/app/src/index.js index ce8b1d05f83bbd7b4f75b574a8387aafe54db289..96cb05daa85321660990598ae80cf8a252dda7d2 100644 --- a/opentech/static_src/src/app/src/index.js +++ b/opentech/static_src/src/app/src/index.js @@ -12,7 +12,7 @@ const store = createStore(); ReactDOM.render( <Provider store={store}> - <SubmissionsByRoundApp pageContent={container.innerHTML} roundId={parseInt(container.dataset.roundId)} /> + <SubmissionsByRoundApp pageContent={container.innerHTML} roundID={parseInt(container.dataset.roundId)} /> </Provider>, container ); diff --git a/opentech/static_src/src/app/src/redux/actions/submissions.js b/opentech/static_src/src/app/src/redux/actions/submissions.js index 980bf516e298386ee747af882ca282adcb618de6..db35aec0678e3239cec174980b3de9ff6a8d6459 100644 --- a/opentech/static_src/src/app/src/redux/actions/submissions.js +++ b/opentech/static_src/src/app/src/redux/actions/submissions.js @@ -1,4 +1,10 @@ import api from '@api'; +import { + getCurrentSubmission, + getCurrentSubmissionID, + getCurrentRoundID, + getCurrentRound, +} from '@selectors/submissions'; // Submissions by round @@ -7,46 +13,122 @@ export const UPDATE_SUBMISSIONS_BY_ROUND = 'UPDATE_SUBMISSIONS_BY_ROUND'; export const START_LOADING_SUBMISSIONS_BY_ROUND = 'START_LOADING_SUBMISSIONS_BY_ROUND'; export const FAIL_LOADING_SUBMISSIONS_BY_ROUND = 'FAIL_LOADING_SUBMISSIONS_BY_ROUND'; +// Submissions +export const SET_CURRENT_SUBMISSION = 'SET_CURRENT_SUBMISSION'; +export const START_LOADING_SUBMISSION = 'START_LOADING_SUBMISSION'; +export const FAIL_LOADING_SUBMISSION = 'FAIL_LOADING_SUBMISSION'; +export const UPDATE_SUBMISSION = 'UPDATE_SUBMISSION'; +export const CLEAR_CURRENT_SUBMISSION = 'CLEAR_CURRENT_SUBMISSION'; export const setCurrentSubmissionRound = id => ({ type: SET_CURRENT_SUBMISSION_ROUND, id, }); -export const fetchSubmissionsByRound = roundId => { +export const setCurrentSubmission = id => ({ + type: SET_CURRENT_SUBMISSION, + id, +}); + +export const loadCurrentRound = (requiredFields=[]) => (dispatch, getState) => { + const round = getCurrentRound(getState()) + + if (round && requiredFields.every(key => round.hasOwnProperty(key))) { + return null + } + + return dispatch(fetchSubmissionsByRound(getCurrentRoundID(getState()))) +} + + +export const fetchSubmissionsByRound = roundID => { return async function(dispatch) { - dispatch(startLoadingSubmissionsByRound(roundId)); + dispatch(startLoadingSubmissionsByRound(roundID)); try { - const response = await api.fetchSubmissionsByRound(roundId); + const response = await api.fetchSubmissionsByRound(roundID); const json = await response.json(); - if (!response.ok) { - dispatch(failLoadingSubmissionsByRound(roundId)); - return; + if (response.ok) { + dispatch(updateSubmissionsByRound(roundID, json)); + } else { + dispatch(failLoadingSubmissionsByRound(json.meta.error)); } - dispatch(updateSubmissionsByRound(roundId, json)); } catch (e) { - console.error(e); - dispatch(failLoadingSubmissionsByRound(roundId)); + dispatch(failLoadingSubmissionsByRound(e.message)); } }; }; -const updateSubmissionsByRound = (roundId, data) => ({ +const updateSubmissionsByRound = (roundID, data) => ({ type: UPDATE_SUBMISSIONS_BY_ROUND, - roundId, + roundID, data, }); -const startLoadingSubmissionsByRound = roundId => ({ +const startLoadingSubmissionsByRound = (roundID) => ({ type: START_LOADING_SUBMISSIONS_BY_ROUND, - roundId, + roundID, }); - -const failLoadingSubmissionsByRound = roundId => ({ +const failLoadingSubmissionsByRound = (message) => ({ type: FAIL_LOADING_SUBMISSIONS_BY_ROUND, - roundId, + message, +}); + + +export const loadCurrentSubmission = (requiredFields=[]) => (dispatch, getState) => { + const submissionID = getCurrentSubmissionID(getState()) + if ( !submissionID ) { + return null + } + const submission = getCurrentSubmission(getState()) + + if (submission && requiredFields.every(key => submission.hasOwnProperty(key))) { + return null + } + + return dispatch(fetchSubmission(getCurrentSubmissionID(getState()))) +} + + +export const fetchSubmission = submissionID => { + return async function(dispatch) { + + dispatch(startLoadingSubmission(submissionID)); + try { + const response = await api.fetchSubmission(submissionID); + const json = await response.json(); + if (response.ok) { + dispatch(updateSubmission(submissionID, json)); + } else { + dispatch(failLoadingSubmission(json.meta.error)); + } + } catch (e) { + dispatch(failLoadingSubmission(e.message)); + } + }; +}; + + +const startLoadingSubmission = submissionID => ({ + type: START_LOADING_SUBMISSION, + submissionID, +}); + +const failLoadingSubmission = submissionID => ({ + type: FAIL_LOADING_SUBMISSION, + submissionID, +}); + + +const updateSubmission = (submissionID, data) => ({ + type: UPDATE_SUBMISSION, + submissionID, + data, +}); + +export const clearCurrentSubmission = () => ({ + type: CLEAR_CURRENT_SUBMISSION, }); diff --git a/opentech/static_src/src/app/src/redux/reducers/index.js b/opentech/static_src/src/app/src/redux/reducers/index.js index b2c6861e4606ac7c820ee8dd4e870366d82aa8a2..d1e5e237467ee34a6e305045c469b99560e3e92b 100644 --- a/opentech/static_src/src/app/src/redux/reducers/index.js +++ b/opentech/static_src/src/app/src/redux/reducers/index.js @@ -1,7 +1,9 @@ import { combineReducers } from 'redux' import submissions from '@reducers/submissions'; +import rounds from '@reducers/rounds'; export default combineReducers({ submissions, + rounds, }); diff --git a/opentech/static_src/src/app/src/redux/reducers/rounds.js b/opentech/static_src/src/app/src/redux/reducers/rounds.js new file mode 100644 index 0000000000000000000000000000000000000000..0f4253b713fa386196a99315fa6221bb47e12630 --- /dev/null +++ b/opentech/static_src/src/app/src/redux/reducers/rounds.js @@ -0,0 +1,82 @@ +import { combineReducers } from 'redux'; + +import { + FAIL_LOADING_SUBMISSIONS_BY_ROUND, + SET_CURRENT_SUBMISSION_ROUND, + START_LOADING_SUBMISSIONS_BY_ROUND, + UPDATE_SUBMISSIONS_BY_ROUND, +} from '@actions/submissions'; + + +function round(state={id: null, submissions: [], isFetching: false}, action) { + switch(action.type) { + case UPDATE_SUBMISSIONS_BY_ROUND: + return { + ...state, + id: action.roundID, + submissions: action.data.results.map(submission => submission.id), + isFetching: false, + }; + case FAIL_LOADING_SUBMISSIONS_BY_ROUND: + return { + ...state, + isFetching: false, + }; + case START_LOADING_SUBMISSIONS_BY_ROUND: + return { + ...state, + id: action.roundID, + isFetching: true, + }; + default: + return state; + } +} + + +function roundsByID(state = {}, action) { + switch(action.type) { + case UPDATE_SUBMISSIONS_BY_ROUND: + case FAIL_LOADING_SUBMISSIONS_BY_ROUND: + case START_LOADING_SUBMISSIONS_BY_ROUND: + return { + ...state, + [action.roundID]: round(state[action.roundID], action) + }; + default: + return state; + } +} + + +function errorMessage(state = null, action) { + switch(action.type) { + case FAIL_LOADING_SUBMISSIONS_BY_ROUND: + return action.message; + case UPDATE_SUBMISSIONS_BY_ROUND: + case START_LOADING_SUBMISSIONS_BY_ROUND: + return null; + default: + return state; + } + +} + + +function currentRound(state = null, action) { + switch(action.type) { + case SET_CURRENT_SUBMISSION_ROUND: + return action.id; + default: + return state; + } +} + + +const rounds = combineReducers({ + byID: roundsByID, + current: currentRound, + error: errorMessage, +}); + +export default rounds; diff --git a/opentech/static_src/src/app/src/redux/reducers/submissions.js b/opentech/static_src/src/app/src/redux/reducers/submissions.js index 4970ddeb302aeb18a240de7462bdedbf54c13da7..7563d1e25f0ac19364f82d0c96af3624f2125d4c 100644 --- a/opentech/static_src/src/app/src/redux/reducers/submissions.js +++ b/opentech/static_src/src/app/src/redux/reducers/submissions.js @@ -1,58 +1,86 @@ +import { combineReducers } from 'redux'; + import { - FAIL_LOADING_SUBMISSIONS_BY_ROUND, - SET_CURRENT_SUBMISSION_ROUND, - START_LOADING_SUBMISSIONS_BY_ROUND, + CLEAR_CURRENT_SUBMISSION, + FAIL_LOADING_SUBMISSION, + START_LOADING_SUBMISSION, UPDATE_SUBMISSIONS_BY_ROUND, + UPDATE_SUBMISSION, + SET_CURRENT_SUBMISSION, } from '@actions/submissions'; -const initialState = { - currentRound: null, - submissionsByID: {}, - submissionsByRoundID: {}, - itemsLoadingError: false, - itemsLoading: false, -}; -export default function submissions(state = initialState, action) { +function submission(state, action) { switch(action.type) { - case SET_CURRENT_SUBMISSION_ROUND: + case START_LOADING_SUBMISSION: return { ...state, - currentRound: action.id, + isFetching: true, + isErrored: false, }; - case UPDATE_SUBMISSIONS_BY_ROUND: + case FAIL_LOADING_SUBMISSION: return { ...state, - submissionsByID: { - ...state.submissionsByID, - ...action.data.results.reduce((newItems, v) => { - newItems[v.id] = { - ...state.submissionsByID[v.id], - ...v - }; - return newItems; - }, {}), - }, - submissionsByRoundID: { - ...state.submissionsByRoundID, - [action.roundId]: action.data.results.map(v => v.id), - }, - itemsLoading: false, - itemsLoadingError: false, + isFetching: false, + isErrored: true, }; - case FAIL_LOADING_SUBMISSIONS_BY_ROUND: + case UPDATE_SUBMISSION: return { ...state, - itemsLoading: false, - itemsLoadingError: true, + ...action.data, + isFetching: false, + isErrored: false, }; - case START_LOADING_SUBMISSIONS_BY_ROUND: + default: + return state; + } +} + + +function submissionsByID(state = {}, action) { + switch(action.type) { + case START_LOADING_SUBMISSION: + case FAIL_LOADING_SUBMISSION: + case UPDATE_SUBMISSION: return { ...state, - itemsLoading: true, - itemsLoadingError: false, + [action.submissionID]: submission(state[action.submissionID], action), + }; + case UPDATE_SUBMISSIONS_BY_ROUND: + return { + ...state, + ...action.data.results.reduce((newItems, newSubmission) => { + newItems[newSubmission.id] = submission( + state[newSubmission.id], + { + type: UPDATE_SUBMISSION, + data: newSubmission, + } + ); + return newItems; + }, {}), }; default: return state; } } + + +function currentSubmission(state = null, action) { + switch(action.type) { + case SET_CURRENT_SUBMISSION: + return action.id; + case CLEAR_CURRENT_SUBMISSION: + return null; + default: + return state; + } +} + + +const submissions = combineReducers({ + byID: submissionsByID, + current: currentSubmission, +}); + +export default submissions; diff --git a/opentech/static_src/src/app/src/redux/selectors/submissions.js b/opentech/static_src/src/app/src/redux/selectors/submissions.js index cd43114ec79bd4743e51eb615e5abeb1f3ccbc32..09124b6896da42c2c177f2e706a8b2528deaca54 100644 --- a/opentech/static_src/src/app/src/redux/selectors/submissions.js +++ b/opentech/static_src/src/app/src/redux/selectors/submissions.js @@ -1,26 +1,53 @@ import { createSelector } from 'reselect'; -const getSubmissions = state => state.submissions.submissionsByID; +const getSubmissions = state => state.submissions.byID; -const getSubmissionIDsByRound = state => state.submissions.submissionsByRoundID; +const getRounds = state => state.rounds.byID; -const getCurrentRoundID = state => state.submissions.currentRound; +const getCurrentRoundID = state => state.rounds.current; + +const getCurrentRound = createSelector( + [ getCurrentRoundID, getRounds], + (id, rounds) => { + return rounds[id]; + } +); + +const getCurrentSubmissionID = state => state.submissions.current; const getCurrentRoundSubmissions = createSelector( - [ getSubmissionIDsByRound, getCurrentRoundID , getSubmissions], - (submissionsByRound, currentRoundID, submissions) => { - return (submissionsByRound[currentRoundID] || []).map(submissionID => submissions[submissionID]); + [ getCurrentRound, getSubmissions], + (round, submissions) => { + const roundSubmissions = round ? round.submissions : []; + return roundSubmissions.map(submissionID => submissions[submissionID]); + } +); + + +const getCurrentSubmission = createSelector( + [ getCurrentSubmissionID, getSubmissions ], + (id, submissions) => { + return submissions[id]; } ); -const getSubmissionsByRoundErrorState = state => state.submissions.itemsLoadingError; +const getSubmissionLoadingState = state => state.submissions.itemLoading === true; + +const getSubmissionErrorState = state => state.submissions.itemLoadingError === true; + +const getSubmissionsByRoundError = state => state.rounds.error; -const getSubmissionsByRoundLoadingState = state => state.submissions.itemsLoading; +const getSubmissionsByRoundLoadingState = state => state.submissions.itemsLoading === true; export { getCurrentRoundID, + getCurrentRound, getCurrentRoundSubmissions, - getSubmissionsByRoundErrorState, + getCurrentSubmission, + getCurrentSubmissionID, + getSubmissionsByRoundError, getSubmissionsByRoundLoadingState, + getSubmissionLoadingState, + getSubmissionErrorState, }; diff --git a/opentech/static_src/src/app/webpack.base.config.js b/opentech/static_src/src/app/webpack.base.config.js index 5e8e561a5948027544ac51a6396423adff7116df..b6017a03190fc4870e014265d069f79bdd48d734 100644 --- a/opentech/static_src/src/app/webpack.base.config.js +++ b/opentech/static_src/src/app/webpack.base.config.js @@ -23,7 +23,16 @@ module.exports = { 'react-hot-loader/babel', '@babel/plugin-proposal-class-properties' ] - } + }, + }, + { + test: /\.js$/, + exclude: /node_modules/, + include: [path.resolve(__dirname, './src')], + loader: 'eslint-loader', + options: { + configFile: path.resolve(__dirname, './.eslintrc'), + }, }, { test: /\.scss$/, diff --git a/opentech/static_src/src/images/download.svg b/opentech/static_src/src/images/download.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c729aba1bcd0ea17eb1bb8ea72babcc9366bf40 --- /dev/null +++ b/opentech/static_src/src/images/download.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg viewBox="0 0 16 21" height="21" width="16" xmlns="http://www.w3.org/2000/svg"> + <g stroke-width="3" fill="none" fill-rule="evenodd" stroke-linecap="square"> + <path d="M8.176 13.833V2.167M8.303 14l4.991-4.714M8 14L3.009 9.286M13.824 19.5H2.176" /> + </g> +</svg> diff --git a/opentech/static_src/src/images/file.svg b/opentech/static_src/src/images/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..936d57b3b18cd09fbe4b54a8c053bf21cd707a3b --- /dev/null +++ b/opentech/static_src/src/images/file.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg viewBox="0 0 27 32" height="32" width="27" xmlns="http://www.w3.org/2000/svg"> + <g stroke-width="2" fill="none" fill-rule="evenodd"> + <path d="M1.296 1v29.25H25V9.429h-8.218V1H1.296z" /> + <path d="M5.5 20h15M5.5 15H12" stroke-linecap="square" /> + <path d="M16.828.729l8.551 8.5" /> + </g> +</svg> diff --git a/package-lock.json b/package-lock.json index 20f5a048a4296536a409c71a33cc00f095e9ab04..c03d14147002a5f8545753a762f00216e4b07807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1369,6 +1369,16 @@ "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", "dev": true }, + "array-includes": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz", + "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.7.0" + } + }, "array-initial": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", @@ -1556,6 +1566,32 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, + "babel-eslint": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.0.1.tgz", + "integrity": "sha512-z7OT1iNV+TjOwHNLLyJk+HN+YVWX+CLE6fPD2SymJZOZQBs+QIexFjhm4keGTm8MW9xr4EC9Q0PbaLB24V5GoQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "eslint-scope": "3.7.1", + "eslint-visitor-keys": "^1.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz", + "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + } + } + }, "babel-loader": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.0.4.tgz", @@ -3023,6 +3059,14 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "dom-serializer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", @@ -3389,6 +3433,45 @@ } } }, + "eslint-loader": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-2.1.1.tgz", + "integrity": "sha512-1GrJFfSevQdYpoDzx8mEE2TDWsb/zmFuY09l6hURg1AeFIKQOvZ+vH0UPjzmd1CZIbfTV5HUkMeBmFiDBkgIsQ==", + "dev": true, + "requires": { + "loader-fs-cache": "^1.0.0", + "loader-utils": "^1.0.2", + "object-assign": "^4.0.1", + "object-hash": "^1.1.4", + "rimraf": "^2.6.1" + } + }, + "eslint-plugin-react": { + "version": "7.12.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.12.4.tgz", + "integrity": "sha512-1puHJkXJY+oS1t467MjbqjvX53uQ05HXwjqDgdbGBqf5j9eeydI54G3KwiJmWciQ0HTBacIKw2jgwSBSH3yfgQ==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "doctrine": "^2.1.0", + "has": "^1.0.3", + "jsx-ast-utils": "^2.0.1", + "object.fromentries": "^2.0.0", + "prop-types": "^15.6.2", + "resolve": "^1.9.0" + }, + "dependencies": { + "resolve": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.9.0.tgz", + "integrity": "sha512-TZNye00tI67lwYvzxCxHGjwTNlUV70io54/Ed4j6PscB8xVfuBJpRenI/o6dVk0cY0PYTY27AgCoGGxRnYuItQ==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, "eslint-scope": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.0.tgz", @@ -4173,9 +4256,9 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", - "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", "optional": true, "requires": { "nan": "^2.9.2", @@ -4184,25 +4267,21 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": false, - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "bundled": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": false, - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + "bundled": true }, "aproba": { "version": "1.2.0", - "resolved": false, - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "bundled": true, "optional": true }, "are-we-there-yet": { - "version": "1.1.4", - "resolved": false, - "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", + "version": "1.1.5", + "bundled": true, "optional": true, "requires": { "delegates": "^1.0.0", @@ -4211,76 +4290,64 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "bundled": true }, "brace-expansion": { "version": "1.1.11", - "resolved": false, - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "bundled": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "chownr": { - "version": "1.0.1", - "resolved": false, - "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", + "version": "1.1.1", + "bundled": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": false, - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "bundled": true }, "concat-map": { "version": "0.0.1", - "resolved": false, - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "resolved": false, - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "bundled": true }, "core-util-is": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "bundled": true, "optional": true }, "debug": { "version": "2.6.9", - "resolved": false, - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "bundled": true, "optional": true, "requires": { "ms": "2.0.0" } }, "deep-extend": { - "version": "0.5.1", - "resolved": false, - "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==", + "version": "0.6.0", + "bundled": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "bundled": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": false, - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "bundled": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": false, - "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", + "bundled": true, "optional": true, "requires": { "minipass": "^2.2.1" @@ -4288,14 +4355,12 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "bundled": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": false, - "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "bundled": true, "optional": true, "requires": { "aproba": "^1.0.3", @@ -4309,9 +4374,8 @@ } }, "glob": { - "version": "7.1.2", - "resolved": false, - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "bundled": true, "optional": true, "requires": { "fs.realpath": "^1.0.0", @@ -4324,23 +4388,20 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": false, - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "bundled": true, "optional": true }, "iconv-lite": { - "version": "0.4.21", - "resolved": false, - "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==", + "version": "0.4.24", + "bundled": true, "optional": true, "requires": { - "safer-buffer": "^2.1.0" + "safer-buffer": ">= 2.1.2 < 3" } }, "ignore-walk": { "version": "3.0.1", - "resolved": false, - "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", + "bundled": true, "optional": true, "requires": { "minimatch": "^3.0.4" @@ -4348,8 +4409,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": false, - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "bundled": true, "optional": true, "requires": { "once": "^1.3.0", @@ -4358,55 +4418,47 @@ }, "inherits": { "version": "2.0.3", - "resolved": false, - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "bundled": true }, "ini": { "version": "1.3.5", - "resolved": false, - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "bundled": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "bundled": true, "requires": { "number-is-nan": "^1.0.0" } }, "isarray": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "bundled": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": false, - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "bundled": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "resolved": false, - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "bundled": true }, "minipass": { - "version": "2.2.4", - "resolved": false, - "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", + "version": "2.3.5", + "bundled": true, "requires": { - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "minizlib": { - "version": "1.1.0", - "resolved": false, - "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==", + "version": "1.2.1", + "bundled": true, "optional": true, "requires": { "minipass": "^2.2.1" @@ -4414,22 +4466,19 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "bundled": true, "requires": { "minimist": "0.0.8" } }, "ms": { "version": "2.0.0", - "resolved": false, - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "bundled": true, "optional": true }, "needle": { - "version": "2.2.0", - "resolved": false, - "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==", + "version": "2.2.4", + "bundled": true, "optional": true, "requires": { "debug": "^2.1.2", @@ -4438,18 +4487,17 @@ } }, "node-pre-gyp": { - "version": "0.10.0", - "resolved": false, - "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==", + "version": "0.10.3", + "bundled": true, "optional": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", - "needle": "^2.2.0", + "needle": "^2.2.1", "nopt": "^4.0.1", "npm-packlist": "^1.1.6", "npmlog": "^4.0.2", - "rc": "^1.1.7", + "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", "tar": "^4" @@ -4457,8 +4505,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": false, - "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "bundled": true, "optional": true, "requires": { "abbrev": "1", @@ -4466,15 +4513,13 @@ } }, "npm-bundled": { - "version": "1.0.3", - "resolved": false, - "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==", + "version": "1.0.5", + "bundled": true, "optional": true }, "npm-packlist": { - "version": "1.1.10", - "resolved": false, - "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==", + "version": "1.2.0", + "bundled": true, "optional": true, "requires": { "ignore-walk": "^3.0.1", @@ -4483,8 +4528,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": false, - "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "bundled": true, "optional": true, "requires": { "are-we-there-yet": "~1.1.2", @@ -4495,39 +4539,33 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": false, - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "bundled": true }, "object-assign": { "version": "4.1.1", - "resolved": false, - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "bundled": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": false, - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "bundled": true, "requires": { "wrappy": "1" } }, "os-homedir": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "bundled": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "bundled": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": false, - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "bundled": true, "optional": true, "requires": { "os-homedir": "^1.0.0", @@ -4536,23 +4574,20 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": false, - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "bundled": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": false, - "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "bundled": true, "optional": true }, "rc": { - "version": "1.2.7", - "resolved": false, - "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==", + "version": "1.2.8", + "bundled": true, "optional": true, "requires": { - "deep-extend": "^0.5.1", + "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" @@ -4560,16 +4595,14 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": false, - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "bundled": true, "optional": true } } }, "readable-stream": { "version": "2.3.6", - "resolved": false, - "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "bundled": true, "optional": true, "requires": { "core-util-is": "~1.0.0", @@ -4582,53 +4615,45 @@ } }, "rimraf": { - "version": "2.6.2", - "resolved": false, - "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "version": "2.6.3", + "bundled": true, "optional": true, "requires": { - "glob": "^7.0.5" + "glob": "^7.1.3" } }, "safe-buffer": { - "version": "5.1.1", - "resolved": false, - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + "version": "5.1.2", + "bundled": true }, "safer-buffer": { "version": "2.1.2", - "resolved": false, - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "bundled": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": false, - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "bundled": true, "optional": true }, "semver": { - "version": "5.5.0", - "resolved": false, - "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "version": "5.6.0", + "bundled": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": false, - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "bundled": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": false, - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "bundled": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "bundled": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4637,8 +4662,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": false, - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "bundled": true, "optional": true, "requires": { "safe-buffer": "~5.1.0" @@ -4646,57 +4670,50 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": false, - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "bundled": true, "requires": { "ansi-regex": "^2.0.0" } }, "strip-json-comments": { "version": "2.0.1", - "resolved": false, - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "bundled": true, "optional": true }, "tar": { - "version": "4.4.1", - "resolved": false, - "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==", + "version": "4.4.8", + "bundled": true, "optional": true, "requires": { - "chownr": "^1.0.1", + "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.2.4", - "minizlib": "^1.1.0", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.2" } }, "util-deprecate": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "bundled": true, "optional": true }, "wide-align": { - "version": "1.1.2", - "resolved": false, - "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", + "version": "1.1.3", + "bundled": true, "optional": true, "requires": { - "string-width": "^1.0.2" + "string-width": "^1.0.2 || 2" } }, "wrappy": { "version": "1.0.2", - "resolved": false, - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "bundled": true }, "yallist": { - "version": "3.0.2", - "resolved": false, - "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" + "version": "3.0.3", + "bundled": true } } }, @@ -6421,6 +6438,15 @@ "verror": "1.10.0" } }, + "jsx-ast-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz", + "integrity": "sha1-6AGxs5mF4g//yHtA43SAgOLcrH8=", + "dev": true, + "requires": { + "array-includes": "^3.0.3" + } + }, "just-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.0.0.tgz", @@ -6525,6 +6551,38 @@ } } }, + "loader-fs-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/loader-fs-cache/-/loader-fs-cache-1.0.1.tgz", + "integrity": "sha1-VuC/CL2XCLJqdltoUJhAyN7J/bw=", + "dev": true, + "requires": { + "find-cache-dir": "^0.1.1", + "mkdirp": "0.5.1" + }, + "dependencies": { + "find-cache-dir": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz", + "integrity": "sha1-yN765XyKUqinhPnjHFfHQumToLk=", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "mkdirp": "^0.5.1", + "pkg-dir": "^1.0.0" + } + }, + "pkg-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz", + "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=", + "dev": true, + "requires": { + "find-up": "^1.0.0" + } + } + } + }, "loader-runner": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", @@ -6571,6 +6629,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + }, "lodash.assign": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", @@ -7403,6 +7466,12 @@ } } }, + "object-hash": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz", + "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==", + "dev": true + }, "object-keys": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", @@ -7438,6 +7507,18 @@ "isobject": "^3.0.0" } }, + "object.fromentries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.0.tgz", + "integrity": "sha512-9iLiI6H083uiqUuvzyY6qrlmc/Gz8hLQFOcb/Ri/0xXFkSNS3ctV+CbE6yM2+AnkYfOB3dGjdzC0wrMLIhQICA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.11.0", + "function-bind": "^1.1.1", + "has": "^1.0.1" + } + }, "object.getownpropertydescriptors": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", @@ -8287,8 +8368,7 @@ "react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "dev": true + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "react-redux": { "version": "6.0.0", @@ -8313,6 +8393,37 @@ } } }, + "react-transition-group": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.3.tgz", + "integrity": "sha512-2DGFck6h99kLNr8pOFk+z4Soq3iISydwOFeeEVPjTN6+Y01CmvbWmnN02VuTWyFdnRtIDPe+wy2q6Ui8snBPZg==", + "requires": { + "dom-helpers": "^3.3.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, + "react-window-size-listener": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-window-size-listener/-/react-window-size-listener-1.2.3.tgz", + "integrity": "sha512-95lyZTMBBqH0xuhBEP0LshEKlHVF+VHQO7UojfDYmyMoO9jriTAY9Ktr5p9ZF4yR8QKzWZBAUdOEndY/JuMmwA==", + "requires": { + "lodash.debounce": "^3.1.1", + "prop-types": "^15.6.0", + "randomatic": ">=3.0.0" + }, + "dependencies": { + "lodash.debounce": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-3.1.1.tgz", + "integrity": "sha1-gSIRw3ipTMKdWqTjNGzwv846ffU=", + "requires": { + "lodash._getnative": "^3.0.0" + } + } + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", diff --git a/package.json b/package.json index d1dc670a25bdc1440792d8c521ab9a13fe41c74c..7ad0fcfa4597981cdb4b18bee2f73c21963125a2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "react": "^16.7.0", "react-dom": "^16.7.0", "react-redux": "^6.0.0", + "react-transition-group": "^2.5.3", + "react-window-size-listener": "^1.2.3", "redux": "^4.0.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", @@ -38,8 +40,11 @@ "@babel/plugin-proposal-class-properties": "^7.2.3", "@babel/preset-env": "^7.2.3", "@babel/preset-react": "^7.0.0", + "babel-eslint": "^10.0.1", "babel-loader": "^8.0.4", "css-loader": "^2.1.0", + "eslint-loader": "^2.1.1", + "eslint-plugin-react": "^7.12.4", "gulp-eslint": "^5.0.0", "gulp-sass-lint": "^1.4.0", "gulp-sourcemaps": "^2.6.4",